A special terrain tile explains itself on a tap, and 活性源石 is one material on both boards (GitHub #184) (#185)
Tapping a 活性源石 / 沼泽 / 深水区 / 排气格栅 / gate / teleport tile opens a read-only card with that stage's own terrain numbers (the boss-prep board picks the board tile once, through prepXf.toBoard); the 2D atlas cell and the 3D shader now share one 活性源石 palette and recipe and draw to the tile edge. Contributed by @Convey123.
This commit is contained in:
1 parent
d582925816
commit
f6f5ed50cd
16 files changed
+905
-37
No files matched your search
+23
-4
@@ -35,6 +35,8 @@
|
||||
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target } · pieceDragEnd {uid, dropped}
|
||||
// pieceClick { uid, piece, button, detail, clientX, clientY } (battle units: { unitId, uid, unit, … })
|
||||
// pieceDetail (right-click / long-press) · pieceHover { uid } | { uid: null } (battle: + unitId, unit)
|
||||
// tileClick { row, col, x, y } — the ground itself was tapped and nothing stands there (GitHub issue #184:
|
||||
// a special terrain tile's own tip; the screen resolves it with gameLogic.terrainInfo)
|
||||
// tileHover { row, col, area, idx } | null (while dragging: the drop target — the tile under the pointer)
|
||||
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y } (client px: the drawn body) | null
|
||||
// Picking (user playtest #4 item 1: the ground is drawn as tiles — a press on a tile is a press on the unit standing
|
||||
@@ -1338,6 +1340,21 @@ export async function createFieldView(host, options = {}) {
|
||||
return hit ? hit.ref : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ground itself was tapped: nothing stands there, so the TILE explains itself — a special terrain tile (活性源石,
|
||||
* 沼泽, 排气格栅, 深水区, 红/蓝门, 传送) opens its own card (GitHub issue #184; screens/game.js `tileClick` →
|
||||
* gameLogic.terrainInfo, which says nothing about an ordinary floor / road / wall tile).
|
||||
* The tile is picked as a BOARD tile (`pickBoardTile`, i.e. through `prepXf.toBoard`): on a Final Assault / Hidden Core
|
||||
* PREP the board draws the boss field's own rows (stage 2–5 as board 9–12), and the screen maps board → stage once more
|
||||
* with `gameLogic.fieldTile` — reporting the DRAWN tile here would be converted twice and explain the wrong tile
|
||||
* (review on #185).
|
||||
*/
|
||||
function emitTileClick(ev, e) {
|
||||
const t = pickBoardTile(ev.x, ev.y);
|
||||
if (!t || !(t.row >= 0) || !(t.col >= 0)) return; // outside the board this field draws
|
||||
emit('tileClick', { row: t.row, col: t.col, button: e.button, clientX: e.clientX, clientY: e.clientY });
|
||||
}
|
||||
|
||||
const onPointerDown = (e) => {
|
||||
if (destroyed) return;
|
||||
const ev = evPayload(e);
|
||||
@@ -1348,18 +1365,20 @@ export async function createFieldView(host, options = {}) {
|
||||
const payload = { unitId: v.id, uid: info?.uid ?? null, unit: info, button: e.button, detail: e.button === 2, clientX: e.clientX, clientY: e.clientY };
|
||||
emit('pieceClick', payload);
|
||||
if (e.button === 2) emit('pieceDetail', payload);
|
||||
} else if (penViews.size) {
|
||||
const pv = penUnitAt(ev.x, ev.y);
|
||||
if (pv) emitPenClick(pv, e);
|
||||
return;
|
||||
}
|
||||
const pv = penViews.size ? penUnitAt(ev.x, ev.y) : null;
|
||||
if (pv) emitPenClick(pv, e);
|
||||
else emitTileClick(ev, e);
|
||||
return;
|
||||
}
|
||||
if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } return; }
|
||||
if (mode === 'prep') { const lv = leaderAt(ev.x, ev.y); if (lv) { emitPenClick(lv, e); return; } }
|
||||
if (penViews.size && mode === 'prep') {
|
||||
const pv = penUnitAt(ev.x, ev.y);
|
||||
if (pv) emitPenClick(pv, e);
|
||||
if (pv) { emitPenClick(pv, e); return; }
|
||||
}
|
||||
emitTileClick(ev, e);
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (destroyed) return;
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// (a soft world-space falloff), so the active field reads like the original's lit board without re-building
|
||||
// geometry when the camera moves.
|
||||
|
||||
import { ORIGINIUM } from '../style.js';
|
||||
|
||||
/** Shared focus uniforms (one object for every lit material). */
|
||||
export function focusUniforms(THREE) {
|
||||
return {
|
||||
@@ -275,7 +277,17 @@ export function mireMaterial(THREE, tex, focus) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Infection (活性源石): dark originium crust patches with glowing, pulsing orange veins over the concrete. */
|
||||
/**
|
||||
* 活性源石 (infection): dark originium crust patches with glowing, pulsing orange veins over the concrete. The palette
|
||||
* (`style.js ORIGINIUM`) and the recipe are the ones `render/textures.js originiumOverlay` draws on the 2D board —
|
||||
* two octaves of noise for the crust, the ridge of the fine one for the veins, a third for the crystal grains — so both
|
||||
* boards show ONE material (GitHub #184).
|
||||
* The noise is world-space, so the crust carries on into the neighbouring tile: there is deliberately NO per-tile edge
|
||||
* fade (the old `edge` term faded every tile out at its own border, which made a field of 活性源石 read as separate
|
||||
* patches instead of one floor).
|
||||
*/
|
||||
const V3 = (c) => `vec3(${c.map((v) => Number(v).toFixed(3)).join(', ')})`;
|
||||
|
||||
export function infectionMaterial(THREE, tex, focus) {
|
||||
const uniforms = { uTime: { value: 0 }, uNoise: { value: tex.noise || null }, ...focus };
|
||||
return new THREE.ShaderMaterial({
|
||||
@@ -288,15 +300,17 @@ export function infectionMaterial(THREE, tex, focus) {
|
||||
'void main() {',
|
||||
' float n = texture2D(uNoise, vWorld.xy * 0.55).r;',
|
||||
' float n2 = texture2D(uNoise, vWorld.xy * 1.3 + 0.37).r;',
|
||||
' float n3 = texture2D(uNoise, vWorld.xy * 3.1 + 0.11).r;',
|
||||
' float crust = smoothstep(0.38, 0.62, n * 0.7 + n2 * 0.45);',
|
||||
' float vein = smoothstep(0.86, 0.98, 1.0 - abs(n2 * 2.0 - 1.0)) * (0.35 + 0.65 * crust);',
|
||||
' float grain = smoothstep(0.93, 1.0, n3) * crust;',
|
||||
' float pulse = 0.6 + 0.4 * sin(uTime * 2.2 + n * 9.0);',
|
||||
' vec2 e = min(vUv, 1.0 - vUv);',
|
||||
' float edge = smoothstep(0.0, 0.1, min(e.x, e.y));',
|
||||
' vec3 base = mix(vec3(0.16, 0.05, 0.05), vec3(0.3, 0.1, 0.07), n2);',
|
||||
' vec3 glow = vec3(1.0, 0.46, 0.18) * (1.2 + 0.8 * pulse);',
|
||||
' vec3 c = mix(base, glow, vein) * focusMask(vWorld.xy);',
|
||||
' float a = clamp(crust * 0.72 + vein, 0.0, 1.0) * edge;',
|
||||
` vec3 base = mix(${V3(ORIGINIUM.base)}, ${V3(ORIGINIUM.crust)}, n2);`,
|
||||
` vec3 glow = ${V3(ORIGINIUM.vein)} * (1.2 + 0.8 * pulse);`,
|
||||
' vec3 c = mix(base, glow, vein);',
|
||||
` c = mix(c, ${V3(ORIGINIUM.spec)} * (1.0 + 0.6 * pulse), grain);`,
|
||||
' c *= focusMask(vWorld.xy);',
|
||||
' float a = clamp(crust * 0.72 + vein, 0.0, 1.0);',
|
||||
' gl_FragColor = vec4(c, a);',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
|
||||
@@ -70,6 +70,37 @@ export const COLORS = Object.freeze({
|
||||
export const TIER_COLORS = Object.freeze([0x9aa5a0, 0x9aa5a0, 0x7fd37a, 0x52b6ff, 0xb98cff, 0xffc600, 0xff6b3d]);
|
||||
export const ENEMY_FRAME = Object.freeze({ normal: 0xc84a3c, elite: 0xff7a33, boss: 0xff2d55 });
|
||||
|
||||
/**
|
||||
* 活性源石 (infection) — the ONE palette of that tile, shared by the two boards (GitHub #184: the 2D atlas cell and the
|
||||
* 3D shader used to be two different materials — a beveled brick with crystal clusters against a world-space crust).
|
||||
* The values are the 3D board's own working colours (`render/board3d/materials.js` writes them into its shaders, which
|
||||
* end in `#include <colorspace_fragment>`, i.e. they are LINEAR); `textures.js` converts them for its canvas with
|
||||
* `linearToHex`, so both renderers show the same colour.
|
||||
* base — the crust's dark side, crust — its lit side, vein — the glowing veins, spec — the bright crystal grains.
|
||||
*/
|
||||
export const ORIGINIUM = Object.freeze({
|
||||
base: Object.freeze([0.16, 0.05, 0.05]),
|
||||
crust: Object.freeze([0.3, 0.1, 0.07]),
|
||||
vein: Object.freeze([1.0, 0.46, 0.18]),
|
||||
spec: Object.freeze([1.0, 0.78, 0.52]),
|
||||
glow: 0xff6a3d, // the additive pulse the 2D board tints per tile
|
||||
});
|
||||
|
||||
/**
|
||||
* One channel of a working (linear) colour as the 0–255 sRGB value a canvas stores — the shaders' `#include
|
||||
* <colorspace_fragment>` does this for the 3D board, the canvas has to do it itself.
|
||||
*/
|
||||
export function linearToSrgb255(v) {
|
||||
const k = Number(v);
|
||||
const s = k <= 0.0031308 ? k * 12.92 : 1.055 * Math.pow(Math.max(0, k), 1 / 2.4) - 0.055;
|
||||
return Math.max(0, Math.min(255, Math.round(s * 255)));
|
||||
}
|
||||
|
||||
/** A working (linear) `ORIGINIUM` colour as the `#rrggbb` sRGB string of the canvas (`textures.js originiumCanvas`). */
|
||||
export function linearToHex(rgb) {
|
||||
return `#${rgb.map((v) => linearToSrgb255(v).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
export const DMG_STYLE = Object.freeze({
|
||||
phys: { font: 'sp-dmg-phys', fill: ['#fffbe8', '#ffb35c'], stroke: '#3b1400' },
|
||||
arts: { font: 'sp-dmg-arts', fill: ['#fbe8ff', '#c77dff'], stroke: '#2a0b45' },
|
||||
|
||||
+103
-19
@@ -11,7 +11,7 @@
|
||||
// Everything procedural is deterministic (seeded noise) and generated lazily on first use. Requires
|
||||
// globalThis.PIXI and a DOM canvas at call time (never at import time).
|
||||
|
||||
import { statusIconKey } from './style.js';
|
||||
import { statusIconKey, ORIGINIUM, linearToSrgb255 } from './style.js';
|
||||
|
||||
const PIXI = () => globalThis.PIXI;
|
||||
|
||||
@@ -50,7 +50,7 @@ const PROC_UNIT = 112;
|
||||
export const MATERIALS = [
|
||||
'road', 'road2', 'road3', 'roadN', 'roadN2', 'floor', 'floor2', 'preview', 'wall', 'wallSide', 'forbid', 'forbid2', 'forbidSide',
|
||||
'sep', 'sepSide', 'fence', 'fenceSide', 'start', 'end', 'telin', 'telout', 'hand', 'temp', 'benchSide', 'benchSideTemp', 'mire',
|
||||
'smog', 'deepsea', 'infection', 'cliff', 'blank', 'lowSide', 'margin',
|
||||
'smog', 'deepsea', 'infection', 'infection2', 'cliff', 'blank', 'lowSide', 'margin',
|
||||
// high-ground plates by connectivity (horizontal strip L/M/R, vertical strip B(near)/VM/T(far); 'wall' = single)
|
||||
'wallL', 'wallM', 'wallR', 'wallB', 'wallVM', 'wallT',
|
||||
// textured props
|
||||
@@ -62,6 +62,11 @@ export const MATERIALS = [
|
||||
/** Art layers of materials the crop table (tiles.json) may not describe yet: the board atlas' glass hatch + a glaze. */
|
||||
const DEFAULT_ART_LAYERS = Object.freeze({
|
||||
penGlass: Object.freeze([Object.freeze({ src: 'D', rect: [549, 1787, 255, 256] }), Object.freeze({ proc: 'glass' })]),
|
||||
// 活性源石 (GitHub #184): the official concrete slab the 3D board lays its crust on (`board3d/atlas.js concrete`), plus
|
||||
// the same crust as a procedural layer — so with the board art installed the 2D tile is composed exactly like the 3D
|
||||
// one, and `MAT_DRAW.infection` is only the no-art fallback.
|
||||
infection: Object.freeze([Object.freeze({ src: 'D', rect: [256, 512, 256, 256] }), Object.freeze({ proc: 'originium' })]),
|
||||
infection2: Object.freeze([Object.freeze({ src: 'D', rect: [256, 512, 256, 256] }), Object.freeze({ proc: 'originium2' })]),
|
||||
});
|
||||
|
||||
function speckle(ctx, x, y, w, h, r, n, colors, size = [0.6, 1.8]) {
|
||||
@@ -91,6 +96,97 @@ function bevelTile(ctx, x, y, s, o) {
|
||||
ctx.fillRect(x + s - m - bw, y + m, bw, s - 2 * m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toroidal value noise on an `n × n` grid: `(u, v) ∈ [0, 1)` wraps, so a cell that is repeated over the board joins
|
||||
* itself without a seam (the knobs of 活性源石's crust line up across neighbouring tiles).
|
||||
*/
|
||||
function noiseField(n, seed) {
|
||||
const r = rng(seed);
|
||||
const g = new Float32Array(n * n);
|
||||
for (let i = 0; i < g.length; i++) g[i] = r();
|
||||
const smooth = (t) => t * t * (3 - 2 * t);
|
||||
return (u, v) => {
|
||||
const x = u * n, y = v * n;
|
||||
const x0 = Math.floor(x), y0 = Math.floor(y);
|
||||
const i0 = ((x0 % n) + n) % n, j0 = ((y0 % n) + n) % n;
|
||||
const i1 = (i0 + 1) % n, j1 = (j0 + 1) % n;
|
||||
const sx = smooth(x - x0), sy = smooth(y - y0);
|
||||
const a = g[j0 * n + i0], b = g[j0 * n + i1], c = g[j1 * n + i0], d = g[j1 * n + i1];
|
||||
return (a * (1 - sx) + b * sx) * (1 - sy) + (c * (1 - sx) + d * sx) * sy;
|
||||
};
|
||||
}
|
||||
|
||||
const ORIGINIUM_RES = 256; // the crust's own resolution (scaled into the atlas cell)
|
||||
|
||||
const smoothstep = (a, b, t) => {
|
||||
const k = Math.max(0, Math.min(1, (t - a) / (b - a)));
|
||||
return k * k * (3 - 2 * k);
|
||||
};
|
||||
|
||||
/**
|
||||
* 活性源石 (infection) — the dark originium crust with thin glowing veins, drawn with the palette and the recipe the 3D
|
||||
* board's shader uses (`render/board3d/materials.js infectionMaterial`, the same octaves and thresholds over its own
|
||||
* world-space noise), so the two boards show ONE material (GitHub #184). Two octaves make the crust mask, the ridge of
|
||||
* the fine octave its veins (brightened where the crust is), the rare top of a third one the crystal grains; the crust
|
||||
* reaches the tile's edges, so a field of 活性源石 carries on into the neighbouring tile instead of ending in a frame.
|
||||
* `mirrored` draws the second variant (`infection2`; `render/tiles.js variantMat` picks between them by tile).
|
||||
*/
|
||||
const _originiumCells = new Map();
|
||||
|
||||
/**
|
||||
* 活性源石's crust, rendered once per variant into `size × size` pixels (cached: the atlas rebuilds on every board art
|
||||
* change, the pattern does not). `mirrored` is the second variant (see `originiumOverlay`).
|
||||
*/
|
||||
function originiumCanvas(size, mirrored) {
|
||||
const key = `${size}:${mirrored ? 1 : 0}`;
|
||||
const hit = _originiumCells.get(key);
|
||||
if (hit) return hit;
|
||||
const canvas = makeCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const crustN = noiseField(16, 9137), fineN = noiseField(32, 4421), grainN = noiseField(64, 733);
|
||||
const img = ctx.createImageData(size, size);
|
||||
const d = img.data;
|
||||
const base = ORIGINIUM.base, crust = ORIGINIUM.crust, vein = ORIGINIUM.vein, spec = ORIGINIUM.spec;
|
||||
for (let py = 0; py < size; py++) {
|
||||
const v = (py + 0.5) / size;
|
||||
for (let px = 0; px < size; px++) {
|
||||
const u = mirrored ? 1 - (px + 0.5) / size : (px + 0.5) / size;
|
||||
const n1 = crustN(u, v), n2 = fineN(u, v), n3 = grainN(u, v);
|
||||
const crustM = smoothstep(0.38, 0.62, n1 * 0.7 + n2 * 0.45);
|
||||
const ridge = smoothstep(0.86, 0.98, 1 - Math.abs(n2 * 2 - 1)) * (0.35 + 0.65 * crustM);
|
||||
const grain = smoothstep(0.93, 1, n3) * crustM;
|
||||
const alpha = Math.min(1, crustM * 0.72 + ridge);
|
||||
if (alpha <= 0.004 && grain <= 0.01) continue;
|
||||
const i = (py * size + px) * 4;
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const lit = base[c] * (1 - n2) + crust[c] * n2; // the crust's own shading
|
||||
const vc = lit * (1 - ridge) + vein[c] * ridge; // …and the vein over it
|
||||
// the mixing happens in the shaders' working (linear) space; the canvas stores sRGB (the 3D board's
|
||||
// colorspace_fragment does the same conversion at the end of its fragment shader)
|
||||
d[i + c] = linearToSrgb255(Math.max(0, Math.min(1, vc * (1 - grain) + spec[c] * grain)));
|
||||
}
|
||||
d[i + 3] = Math.round(alpha * 255);
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
_originiumCells.set(key, canvas);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* 活性源石 (infection) — the dark originium crust with thin glowing veins, drawn with the palette and the recipe the 3D
|
||||
* board's shader uses (`render/board3d/materials.js infectionMaterial`: the same octaves and thresholds over its own
|
||||
* world-space noise), so the two boards show ONE material (GitHub #184: this cell used to be a beveled brick with random
|
||||
* crystal clusters while the 3D quad carried the crust). Two octaves make the crust mask, the ridge of the fine octave
|
||||
* its veins (brightened where the crust is), the rare top of a third one the crystal grains; the crust reaches the
|
||||
* tile's edges, so a field of 活性源石 carries on into the neighbouring tile instead of ending in a frame — the noise
|
||||
* wraps, so a repeated cell joins itself. Drawn through `drawImage`, so the callers' transform (the procedura draw path
|
||||
* scales a 112 px cell up to the atlas cell) applies; `putImageData` would ignore it.
|
||||
*/
|
||||
function originiumOverlay(ctx, x, y, s, mirrored = false) {
|
||||
ctx.drawImage(originiumCanvas(ORIGINIUM_RES, mirrored), x, y, s, s);
|
||||
}
|
||||
|
||||
function cracks(ctx, x, y, s, r, n, color) {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
@@ -285,23 +381,8 @@ const MAT_DRAW = {
|
||||
}
|
||||
speckle(ctx, x, y, s, s, r, 60, ['rgba(200,255,255,0.12)']);
|
||||
},
|
||||
infection(ctx, x, y, s, r) {
|
||||
bevelTile(ctx, x, y, s, { light: '#3a2a33', dark: '#241a20', seamColor: '#140e11', hi: 'rgba(255,140,90,0.14)' });
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const cx = x + 16 + r() * (s - 32), cy = y + 16 + r() * (s - 32), rr = 5 + r() * 10, a = r() * Math.PI;
|
||||
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, rr * 2.2);
|
||||
g.addColorStop(0, 'rgba(255,110,60,0.45)'); g.addColorStop(1, 'rgba(255,110,60,0)');
|
||||
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, rr * 2.2, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = i % 2 ? '#ff7a45' : '#ffb070';
|
||||
ctx.beginPath();
|
||||
for (let k = 0; k < 5; k++) {
|
||||
const aa = a + (k / 5) * Math.PI * 2, rad = k % 2 ? rr * 0.45 : rr;
|
||||
ctx.lineTo(cx + Math.cos(aa) * rad, cy + Math.sin(aa) * rad);
|
||||
}
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
cracks(ctx, x, y, s, r, 4, 'rgba(255,120,70,0.45)');
|
||||
},
|
||||
infection(ctx, x, y, s, r) { floorBase(ctx, x, y, s, r); originiumOverlay(ctx, x, y, s, false); },
|
||||
infection2(ctx, x, y, s, r) { floorBase(ctx, x, y, s, r); originiumOverlay(ctx, x, y, s, true); },
|
||||
cliff(ctx, x, y, s) {
|
||||
const g = ctx.createLinearGradient(x, y, x, y + s);
|
||||
g.addColorStop(0, 'rgba(70,80,86,1)'); g.addColorStop(0.08, 'rgba(38,45,49,1)'); g.addColorStop(1, 'rgba(10,13,14,0)');
|
||||
@@ -439,6 +520,9 @@ const _atlases = new Map(); // art key → atlas
|
||||
|
||||
/** Procedural finishing layers usable in tiles.json (`{ proc: 'rim' }`): a bevelled block edge. */
|
||||
const PROC_LAYERS = {
|
||||
/** 活性源石's crust over the official concrete (DEFAULT_ART_LAYERS.infection); the second variant is mirrored. */
|
||||
originium(ctx, x, y, s) { originiumOverlay(ctx, x, y, s, false); },
|
||||
originium2(ctx, x, y, s) { originiumOverlay(ctx, x, y, s, true); },
|
||||
/** A glossy glass pane over the inner square of a hatch: sky-lit gradient, soft cloud reflections, a highlight. */
|
||||
glass(ctx, x, y, s) {
|
||||
const i = s * 0.135, w = s - i * 2;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
// are built; switching back (WebGL context lost, art missing) rebuilds the 2D board.
|
||||
|
||||
import { tileAtlas, fxAtlas, rng, groundTexture } from './textures.js';
|
||||
import { GLYPH, TILEKEY_GLYPH, TILE_H, COLORS } from './style.js';
|
||||
import { GLYPH, TILEKEY_GLYPH, TILE_H, COLORS, ORIGINIUM } from './style.js';
|
||||
import { parsePenRect, PEN_RECT } from './pen.js';
|
||||
|
||||
/**
|
||||
@@ -193,6 +193,9 @@ function variantMat(t) {
|
||||
case 'roadN': return h < 4 ? 'roadN' : 'roadN2';
|
||||
case 'floor': return h < 4 ? 'floor' : 'floor2';
|
||||
case 'forbid': return h < 4 ? 'forbid' : 'forbid2';
|
||||
// 活性源石: the same material twice (the second mirrored), so a field of it does not repeat one pattern
|
||||
// (textures.js originiumOverlay — the palette and recipe match the 3D board's shader, GitHub #184)
|
||||
case 'infection': return h < 4 ? 'infection' : 'infection2';
|
||||
default: return t.mat;
|
||||
}
|
||||
}
|
||||
@@ -650,7 +653,7 @@ export class TileField {
|
||||
switch (t.glyph) {
|
||||
case 'g': add('smog', 'smoke', P.BLEND_MODES.NORMAL, 0xb8c4c0, 2); break;
|
||||
case 'd': add('sea', 'soft', P.BLEND_MODES.ADD, 0x5fe0ff, 1); break;
|
||||
case 'i': add('infect', 'glow', P.BLEND_MODES.ADD, 0xff6a3d, 1); break;
|
||||
case 'i': add('infect', 'glow', P.BLEND_MODES.ADD, ORIGINIUM.glow, 1); break;
|
||||
case 'm': add('mire', 'dot', P.BLEND_MODES.ADD, 0xc8d890, 2); break;
|
||||
case 'I': case 'O': add('tel', 'ring', P.BLEND_MODES.ADD, 0xb36bff, 1); break;
|
||||
case 'S': if (r <= 12) add('gateGlow', 'glow', P.BLEND_MODES.ADD, COLORS.gateRed, 1); break;
|
||||
|
||||
@@ -94,8 +94,9 @@ import { pieceTile } from '../render/drag.js';
|
||||
import {
|
||||
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
terrainInfo,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
@@ -368,6 +369,15 @@ function MatchScreen() {
|
||||
const st = ownView ? ownStage : baseStage;
|
||||
if (st) view.setStage(st);
|
||||
}, [view, ownView, ownStage, baseStage]);
|
||||
// the stage behind the board ON SCREEN — the own one (机变 overrides applied) or, while watching a teammate, the plain
|
||||
// one — and how a tapped BOARD tile maps to it (GitHub issue #184: tileClick → gameLogic.terrainInfo). Everywhere but a
|
||||
// boss-prep board the two spaces are the same: a 最终攻势 / 隐秘核心 battle renders the stage's own rows (GEO.BOSS_RECT),
|
||||
// 联防 / normal rects are stage rows; the boss PREP draws the player's half (stage rows 2–5) as board rows 9–12
|
||||
// (render/prepfield.js toDisp), which is exactly gameLogic.fieldTile.
|
||||
live.current.terrainStage = ownView ? ownStage : baseStage;
|
||||
live.current.terrainTile = showPrep && (deployField === 'bossL' || deployField === 'bossR')
|
||||
? (row, col) => fieldTile(deployField, row, col)
|
||||
: (row, col) => [row, col];
|
||||
const staleFieldRef = useRef(null);
|
||||
const enteredFieldRef = useRef(null);
|
||||
const pressSel = useRef(null); // the selected piece when the current field press began
|
||||
@@ -885,6 +895,19 @@ function MatchScreen() {
|
||||
setSel(wasSel ? null : { uid: e.uid });
|
||||
if (wasSel) setDetail((d) => (d?.kind === 'piece' && d.uid === e.uid ? null : d));
|
||||
}),
|
||||
// a tap on the ground itself: a special terrain tile explains itself (GitHub issue #184 「建议加入对于特殊地形的单击
|
||||
// 信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 / 传送, with the numbers of the stage behind the board.
|
||||
// An ordinary tile (road / floor / wall) says nothing, so the press keeps its other meanings (deselect, close).
|
||||
view.on('tileClick', (t) => {
|
||||
if (!t || !Number.isInteger(t.row) || !Number.isInteger(t.col)) return;
|
||||
const L = live.current;
|
||||
const [row, col] = L.terrainTile(t.row, t.col);
|
||||
const info = terrainInfo(L.terrainStage, row, col);
|
||||
if (!info) return;
|
||||
audio.sfx('click', { volume: 0.4 });
|
||||
setSel(null);
|
||||
setDetail({ kind: 'terrain', terrain: info });
|
||||
}),
|
||||
];
|
||||
return () => { moveOff?.(); for (const off of offs) { try { off?.(); } catch { /* ignore */ } } };
|
||||
}, [view]);
|
||||
|
||||
@@ -602,13 +602,36 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
|
||||
${talents.length ? html`<${Section} title="天赋">${talents.map((t, i) => html`<p class="dtext" key=${i}><b>${t.name}</b> ${t.desc}</p>`)}<//>` : null}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A special terrain tile's tip (GitHub issue #184: 「建议加入对于特殊地形的单击信息提示」). Opened by a tap on the tile
|
||||
* itself — the game screen resolves it with `gameLogic.terrainInfo` from the stage the board on screen is built from, so
|
||||
* the mechanism lines carry that stage's own numbers (活性源石's damage / duration, 沼泽's stacks, 深水区's drowning …).
|
||||
* @param {{ name:string, tag:string, lines:string[], facts:string[], row:number, col:number }} terrain
|
||||
*/
|
||||
function TerrainDetail({ terrain }) {
|
||||
return html`
|
||||
<div class="dhead">
|
||||
<div class="dhead__icon"><${Icon} name="info" /></div>
|
||||
<div class="dhead__info">
|
||||
<div class="dhead__chips"><span class="dtag-kind">${terrain.tag}</span></div>
|
||||
<h3 class="dhead__name">${terrain.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<${Section} title="地形机制" micro="TERRAIN">
|
||||
${terrain.lines.map((t, i) => html`<p class="dtext" key=${i}>${t}</p>`)}
|
||||
<//>
|
||||
${Array.isArray(terrain.facts) && terrain.facts.length ? html`<${Section} title="这一格"><p class="dtext">${terrain.facts.join(' · ')}</p><//>` : null}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve what a detail target shows.
|
||||
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token', id?:string, uid?:number, unit?:any, count?:number }} target
|
||||
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token'|'terrain', id?:string, uid?:number, unit?:any, count?:number }} target
|
||||
* @param {Map<number, any>} pieces indexPieces(priv)
|
||||
*/
|
||||
export function resolveDetail(target, pieces) {
|
||||
if (!target) return null;
|
||||
// a special terrain tile (issue #184): the screen resolved the stage's own numbers already (gameLogic.terrainInfo)
|
||||
if (target.kind === 'terrain') return target.terrain && typeof target.terrain === 'object' ? { type: 'terrain', terrain: target.terrain } : null;
|
||||
if (target.kind === 'piece') {
|
||||
const e = pieces?.get(target.uid);
|
||||
if (!e) return null;
|
||||
@@ -690,6 +713,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
|
||||
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null}
|
||||
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
|
||||
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
|
||||
${detail.type === 'terrain' ? html`<${TerrainDetail} terrain=${detail.terrain} />` : null}
|
||||
</div>
|
||||
</aside>`;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
// Final Assault prep) keeps the own board layout here — every coordinate the UI exchanges is a board coordinate anyway —
|
||||
// but draws the tiles of the player's half of the boss field (gameLogic fieldTile, the legality the highlights use: act2
|
||||
// m01's fence tiles are floor there, not the normal field's walls; user playtest #5 item 7).
|
||||
// A tap on the ground itself emits tileClick { row, col } like render/app.js does, so a special terrain tile
|
||||
// explains itself on this board too (GitHub issue #184).
|
||||
|
||||
import { render } from '../../vendor/preact.module.js';
|
||||
import { html, TierChip } from './components.js';
|
||||
@@ -310,6 +312,7 @@ export function createFallbackView(host, opts = {}) {
|
||||
const hov = st.hoverTarget?.area === 'board' && st.hoverTarget.row === row && st.hoverTarget.col === col ? dropState(st.hoverTarget) : null;
|
||||
tiles.push(html`<div key=${k} class=${cx('ff-tile', `ff-tile--${tileClass(row, col)}`, hl && `is-${hl}`, hov && `is-hover-${hov}`)}
|
||||
data-drop="board" data-row=${row} data-col=${col}
|
||||
onPointerDown=${(e) => { if (e.button === 0) emit('tileClick', { row, col, button: 0, clientX: e.clientX, clientY: e.clientY }); }}
|
||||
style=${`transform:translate(${p.x}px,${p.y}px);width:${L.tile}px;height:${L.tile}px`}></div>`);
|
||||
}
|
||||
}
|
||||
|
||||
+114
-1
@@ -887,6 +887,117 @@ export function deploySets(stage, field = 'normal', overrides = {}) {
|
||||
return { melee, ranged };
|
||||
}
|
||||
|
||||
// ---- special terrain tips (GitHub issue #184: 特殊地形的单击信息提示) ----------------------------------------
|
||||
|
||||
/**
|
||||
* What tapping a special tile says. `lines` are functions of the stage's own terrain parameters (`stage.special[<terrain>]`
|
||||
* and the tile's `bb`, the very numbers the sim runs on — server/sim/content/devices.js), so a tip can never disagree with
|
||||
* the battle; the prose is ours (docs/PLAYING.md wording, PRTS 特殊地形 / 沼泽控制 / 深水区 地形信息).
|
||||
* `tag` is the chip above the name; `fact` needs the tile's own legend entry (see terrainInfo).
|
||||
*/
|
||||
const TERRAIN_TIPS = Object.freeze({
|
||||
infection: {
|
||||
name: '活性源石', tag: '特殊地形',
|
||||
lines: (st) => {
|
||||
const b = isObj(st?.infection?.bb) ? st.infection.bb : {};
|
||||
const dmg = param(b.damage, 0);
|
||||
const mods = [];
|
||||
if (param(b.atk, 0)) mods.push(`攻击力 +${Math.round(param(b.atk, 0) * 100)}%`);
|
||||
if (param(b.attack_speed, 0)) mods.push(`攻击速度 +${param(b.attack_speed, 0)}`);
|
||||
return [
|
||||
dmg ? `部署于其上的我方单位、经过的敌方单位,每秒受到 ${dmg} 点真实伤害(无来源)` : '在其上的我方单位与经过的敌方单位持续受到伤害',
|
||||
mods.length ? `同时获得:${mods.join('、')}` : null,
|
||||
param(b.duration, 0) ? `效果持续 ${param(b.duration, 0)} 秒;离开地块后仍然保留,再次接触会重新计时` : null,
|
||||
].filter(Boolean);
|
||||
},
|
||||
},
|
||||
mire: {
|
||||
name: '沼泽', tag: '特殊地形',
|
||||
lines: (st) => {
|
||||
const m = isObj(st?.mire) ? st.mire : {};
|
||||
const per = param(m.aspdPerStack, -0.05);
|
||||
const move = param(m.moveMulPerStack, -0.05);
|
||||
const max = param(m.maxStacks, 10);
|
||||
const heavy = param(m.heavyWeight, 3);
|
||||
return [
|
||||
`留在沼泽里的单位每 ${param(m.intervalSec, 1)} 秒获得 1 层「陷入沼泽」:攻击速度 ${pctText(per)}${move ? `,敌方单位还有移动速度 ${pctText(move)}` : ''}`,
|
||||
heavy ? `重量 ≥ ${heavy} 的敌人一次获得 2 层` : null,
|
||||
`最多 ${max} 层;离开沼泽后解除`,
|
||||
].filter(Boolean);
|
||||
},
|
||||
},
|
||||
smog: {
|
||||
name: '排气格栅', tag: '特殊地形',
|
||||
// the sim gives the tile's buff `flags: { stealth: true }` (devices.js enterTerrain): enemy ranged targeting
|
||||
// skips it like 隐匿 — and, like 隐匿, it does NOT stop the enemy it blocks from attacking it (PRTS 隐匿).
|
||||
lines: () => [
|
||||
'站在排气格栅上的干员不会被敌方的远程攻击选中(效果相当于隐匿)',
|
||||
'但挡住敌人的干员仍会被它攻击到',
|
||||
],
|
||||
},
|
||||
deepsea: {
|
||||
name: '深水区', tag: '特殊地形',
|
||||
lines: (st) => {
|
||||
const b = isObj(st?.deepsea?.bb) ? st.deepsea.bb : {};
|
||||
const dmg = param(b['sea_drown[enemy].damage'], 0);
|
||||
const aspd = param(b['sea_drown[enemy].attack_speed'], 0);
|
||||
const move = param(b['sea_drown[enemy].move_speed'], 0);
|
||||
const out = [];
|
||||
if (dmg) out.push(`敌人每秒受到 ${dmg} 点伤害`);
|
||||
const mods = [];
|
||||
if (aspd) mods.push(`攻击速度 ${pctText(aspd)}`);
|
||||
if (move && move !== 1) mods.push(`移动速度 ×${move}`);
|
||||
if (mods.length) out.push(mods.join('、'));
|
||||
// devices.js tickDeepsea: sourceless true damage tagged 'dot' / 'periodic' / 'deepsea' — deliberately NOT 'terrain'
|
||||
// (环境伤害, which is what 活性源石's tick is): it is nobody's damage, so no 干员's 增伤 / 穿透 / 装备 applies.
|
||||
out.push('溺水伤害属于无来源伤害(不吃干员的增伤、穿透与装备加成),也不归类为环境伤害');
|
||||
out.push('拒绝部署(特制水上平台可以让这一格变得可部署)');
|
||||
return out;
|
||||
},
|
||||
},
|
||||
start: { name: '红门', tag: '敌方入口', lines: () => ['敌方单位从这里出场'] },
|
||||
end: { name: '蓝门', tag: '保护目标', lines: () => ['敌人走进这里会扣你的目标生命值(LP),一回合至多 10 点'] },
|
||||
telin: { name: '传送入口', tag: '特殊地形', lines: () => ['敌人走到这里会从场上消失'] },
|
||||
telout: { name: '传送出口', tag: '特殊地形', lines: () => ['消失的敌人会从这里重新出现'] },
|
||||
});
|
||||
|
||||
/** Tile keys that carry a tip of their own although the legend gives them no `special` tag (gates, teleports). */
|
||||
const TIP_BY_TILEKEY = Object.freeze({ tile_start: 'start', tile_end: 'end', tile_telin: 'telin', tile_telout: 'telout' });
|
||||
/** 深水区's own legend entry is the one tile whose mechanism overrides the level's buildableType (grid.js DEPLOY_REFUSED_TILES). */
|
||||
const BUILDABILITY = Object.freeze({ ALL: '可部署', MELEE: '仅近战位可部署', RANGED: '仅远程位可部署', NONE: '不可部署' });
|
||||
|
||||
const param = (v, d) => (Number.isFinite(Number(v)) ? Number(v) : d);
|
||||
const pctText = (v) => `${v > 0 ? '+' : '−'}${Math.abs(Math.round(param(v, 0) * 100))}%`;
|
||||
|
||||
/**
|
||||
* The tip a tap on board tile (row, col) opens (GitHub issue #184 — "建议加入对于特殊地形的单击信息提示"), or null for an
|
||||
* ordinary tile (road / floor / wall / fence …): those say nothing, so a tap on them still just closes what is open.
|
||||
* The tile comes from the stage the board on screen is built from (`stage.rows` + `stage.tiles`, data/stages.json), and
|
||||
* the numbers from that stage's own terrain parameters — the same values the sim runs.
|
||||
* @param {{ rows?: string[], tiles?: Record<string, any>, special?: any } | null | undefined} stage the shown field's stage
|
||||
* @param {number} row board row (row 0 = the bottom row, DESIGN §1)
|
||||
* @param {number} col
|
||||
* @returns {{ key:string, name:string, tag:string, row:number, col:number, lines:string[], facts:string[] } | null}
|
||||
*/
|
||||
export function terrainInfo(stage, row, col) {
|
||||
const rows = Array.isArray(stage?.rows) ? stage.rows : null;
|
||||
const line = rows && Number.isInteger(row) && row >= 0 ? rows[row] : null;
|
||||
if (typeof line !== 'string' || !Number.isInteger(col) || col < 0 || col >= line.length) return null;
|
||||
const tiles = isObj(stage.tiles) ? stage.tiles : null;
|
||||
const tile = tiles ? tiles[line[col]] : null;
|
||||
if (!isObj(tile)) return null;
|
||||
const key = tile.special || TIP_BY_TILEKEY[tile.tileKey] || null;
|
||||
const tip = key ? TERRAIN_TIPS[key] : null;
|
||||
if (!tip) return null;
|
||||
const facts = [];
|
||||
const build = BUILDABILITY[tile.buildable];
|
||||
if (build) facts.push(build);
|
||||
if (tile.height === 'HIGH') facts.push('高台');
|
||||
if (tile.groundPassable === false) facts.push('只有空中单位能通过');
|
||||
else if (tile.groundPassable === true) facts.push('地面单位可通过');
|
||||
return { key, name: tip.name, tag: tip.tag, row, col, lines: tip.lines(stage.special).filter((s) => typeof s === 'string' && s), facts };
|
||||
}
|
||||
|
||||
// ---- per-player stage overrides (terrain 机变 cards) ------------------------------------------------------
|
||||
|
||||
const OBSTACLE_ROLES = new Set(['crate', 'mound']);
|
||||
@@ -1564,7 +1675,9 @@ export function shortcutFor(e) {
|
||||
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
|
||||
* @param {{ kind?: string }|null|undefined} detail
|
||||
*/
|
||||
export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit';
|
||||
// a card opened BY a field press (a piece, a unit, a special terrain tile: issue #184) closes on the next press of
|
||||
// the field; the ones opened from the shop / hand / HUD stay until their own close button (or the flow that opened them)
|
||||
export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit' || detail?.kind === 'terrain';
|
||||
|
||||
/**
|
||||
* Whether an open overlay swallows a game shortcut: a modal / the guide own the keyboard (Esc included — they close
|
||||
|
||||
Reference in new issue
Block a user