Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash

- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
sganggs committed 2026-09-30 05:35:45 +08:00
1 parent 0aba602ecf
commit d4f868cd19
107 files changed
+4510 -1653

No files matched your search

+103 -144
View File
@@ -8,28 +8,26 @@
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] }
// view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in
// `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings)
// — 100 ms interpolation buffer
// — 100 ms interpolation buffer; b.snap `down` keeps knocked-out
// operators on the field under a redeploy ring and `elem` draws the
// element gauges (user playtest #4 items 8 / 9, render/units.js)
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
// ~2-frame buffer at the battle's game speed
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
// 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all
// view.on(name, fn) → unsubscribe ; view.off(name, fn)
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target, over } · pieceDragEnd {uid, dropped, over}
// pieceDragOver { uid, over, clientX, clientY } — while an item is dragged: `over` = uid of the own operator /
// summon under the dragged plate (null: none), sent when it changes, BEFORE the drag asks canPlace
// 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)
// tileHover { row, col, area, idx } | null (while dragging: the drop target, the tile under the ghost's feet)
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y, shape } (client px) | null;
// `shape` = the body for render/pick.js (ui/gameLogic.js pickPieceAt)
// Picking (user playtest #3 item 7): every "which unit is under the pointer" — prep press / drag start / detail
// (pieceAt), battle clicks and hover (battleUnitAt), the pen (penUnitAt), the operator under a dragged item
// (pieceUnder) — is render/pick.js pickBody over the views' drawn bodies (UnitView.pickShape) and the tile under the
// pointer, with a pixel probe (drawnAt: a view rendered alone into a 1×1 target) wherever bodies overlap: the front-most
// unit whose model draws the pixel under the pointer wins; elsewhere the face / torso of a unit is its own, then the
// unit standing on the pressed tile. A dragged
// piece's ghost stands on its drop point — the pointer (mouse / pen) or TOUCH_LIFT_TILES above the finger (touch) —
// and the drop target is the tile under it (render/drag.js dropPoint).
// 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
// there): every "which unit is under the pointer" — prep press / click / detail / drag start / hover (pieceAt), battle
// clicks and hover (battleUnitAt), the pen (penUnitAt) — is render/pick.js over the tile under the pointer (groundTile:
// raised tops first); battle enemies, which walk between tiles, by their ground position (flying ones by their drawn
// body). A dragged piece is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer
// on its body), an item plate centred on it — and drops on the tile under the pointer (render/drag.js); an item
// dropped on a unit's tile equips that unit.
// Direction step (ui/facingWheel.js, research 09 §1.2):
// view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
@@ -95,7 +93,7 @@ import { BoardScene } from './board3d/scene.js';
import { AREAS, areaFor, unionAreas } from './board3d/layout.js';
import { layoutPen, penSignature } from './pen.js';
import { IDENTITY, bossPrepField, tilesToDisp } from './prepfield.js';
import { pickBody } from './pick.js';
import { pickOnTile, pickBattle } from './pick.js';
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
@@ -112,20 +110,26 @@ const RANGE_GROUPS = new Set(['facing', 'range', 'rangeStand', 'select', 'sel',
/** atk projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
const DROP_PENDING_MS = 1300;
/** A touch drag holds its ghost — and so its drop point — this many tiles above the finger (neither is hidden). */
export const TOUCH_LIFT_TILES = 0.6;
/**
* A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model
* under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it.
*/
export const DRAG_HOLD_TILES = 0.45;
/** The display tile a view stands on (its drawn position). */
const viewTile = (v) => ({ row: Math.round(v.y), col: Math.round(v.x) });
/** A view's body for render/pick.js, or null when it cannot be picked (gone, faded out, dead). */
function pickShapeOf(v, tile) {
if (!v || v.destroyed || typeof v.pickShape !== 'function' || v.alive === false) return null;
/**
* A view as a render/pick.js unit, or null when it cannot be picked (gone, faded out, dead): standing on the display
* tile it is drawn on, or — `walks` (battle enemies) — by its ground position and its drawn body (feet to head, on
* screen; a flying one by its body alone).
*/
function pickUnitOf(v, walks = false) {
// a knocked-out operator lying on its tile waiting to redeploy (b.snap `down`, user playtest #4 item 9) is on that tile
// too: a press there selects it; other dead / dying views are gone
if (!v || v.destroyed || (v.alive === false && !v.down)) return null;
if (Number.isFinite(v.alpha) && v.alpha < 0.05) return null;
const b = v.pickShape();
if (!b) return null;
b.tile = tile === undefined ? viewTile(v) : tile;
b.ref = v;
return b;
const sc = v.screen;
const body = walks && sc && sc.s > 0 && !v.culled ? { x: sc.x, top: Number.isFinite(sc.top) ? sc.top : sc.y, feet: sc.y, s: sc.s } : null;
const tile = walks ? null : { row: Math.round(v.y), col: Math.round(v.x) };
return { tile, x: v.x, y: v.y, fly: walks && !!v.flying, body, depth: v.root && !v.root.destroyed ? v.root.zIndex : 0, ref: v };
}
let pixiPromise = null;
@@ -921,12 +925,12 @@ export async function createFieldView(host, options = {}) {
penList = null;
}
/** The pen enemy under a canvas point (render/pick.js; the figures idle on their pen tiles). */
/** The pen enemy under a canvas point: a figure on the tile under it (render/pick.js; ≤ 3 idle per pen tile). */
function penUnitAt(x, y) {
if (penHidden) return null;
const bodies = [];
for (const v of penViews.values()) { const b = pickShapeOf(v); if (b) bodies.push(b); }
const hit = pickBody(bodies, x, y, groundTile(x, y), probeAt(x, y));
const units = [];
for (const v of penViews.values()) { const u = pickUnitOf(v); if (u) units.push(u); }
const hit = pickOnTile(units, groundTile(x, y));
return hit ? hit.ref : null;
}
@@ -937,100 +941,28 @@ export async function createFieldView(host, options = {}) {
// ---- drag & pointer ----------------------------------------------------------------------------------------
/** The display tile under a canvas point (raised tops first), or null off the grid. */
/** The display tile under a canvas point (raised tops first) and the point on its top (world x, y), or null. */
function groundTile(x, y) {
const t = pickTile(cam, x, y, heightAt, tiles.levels);
return t ? { row: t.row, col: t.col } : null;
return t ? { row: t.row, col: t.col, x: t.x, y: t.y } : null;
}
// pixel probe of render/pick.js: one view's display tree rendered alone into a 1×1 target over the canvas pixel
let probeRT = null;
let probeM = null;
/**
* Does view `v` draw an opaque pixel at canvas point (x, y) — as last rendered (its world transforms of the last
* frame: what the player sees)? null when it cannot tell (no WebGL readback, lost context…). Only asked where two or
* more bodies overlap (render/pick.js probeBodies), i.e. a few 1-px renders per press / hover move there.
*/
function drawnAt(v, x, y) {
const root = v && v.root;
if (!root || root.destroyed) return false;
if (!root.visible || !(root.worldAlpha > 0.02)) return false;
try {
const R = app.renderer;
if (!R || !R.extract || typeof R.extract.pixels !== 'function' || !P.RenderTexture || !P.Matrix) return null;
if (!probeRT) probeRT = P.RenderTexture.create({ width: 1, height: 1, resolution: 1 });
if (!probeM) probeM = new P.Matrix();
probeM.set(1, 0, 0, 1, 0.5 - x, 0.5 - y); // the pixel centred on the point
R.render(root, { renderTexture: probeRT, clear: true, transform: probeM, skipUpdateTransform: true });
const px = R.extract.pixels(probeRT);
return px && px.length >= 4 ? px[3] > 40 : null;
} catch { return null; }
}
const probeAt = (x, y) => (b) => drawnAt(b.ref, x, y);
// Hover picks (mouse moves over the field, item drags) probe at most once per frame: every readback waits for the
// GPU and a mouse reports up to 1000 moves per second, so a second hover pick in the same frame gets the shape rule
// alone (it only moves a highlight). Presses, releases and drops always probe.
let hoverProbeFrame = -1;
const hoverProbeAt = (x, y) => {
if (hoverProbeFrame === frameNo) return null;
hoverProbeFrame = frameNo;
return probeAt(x, y);
};
/** The prep piece entry under a canvas point (render/pick.js), among the entries `accept` lets through. */
function prepEntryAt(x, y, accept = null, hover = false) {
const bodies = [];
for (const e of prepPieces) {
if (accept && !accept(e)) continue;
const b = pickShapeOf(views.get(e.key));
if (b) { b.entry = e; bodies.push(b); }
}
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y));
return hit ? hit.entry : null;
}
function pieceAt(x, y, hover = false) {
/** The prep piece under a canvas point: the one on the tile under it (render/pick.js; board, bench and temp rows). */
function pieceAt(x, y) {
if (mode !== 'prep') return null;
const best = prepEntryAt(x, y, null, hover);
const units = [];
for (const e of prepPieces) {
const u = pickUnitOf(views.get(e.key));
if (u) { u.entry = e; units.push(u); }
}
const best = pickOnTile(units, groundTile(x, y))?.entry;
if (!best) return null;
return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true };
}
/** uid of the own operator / summon under a canvas point (not `exceptUid`): where a dragged item would go. */
function pieceUnder(x, y, exceptUid, hover = false) {
if (mode !== 'prep') return null;
const e = prepEntryAt(x, y, (en) => en.uid !== exceptUid && (en.piece.kind === 'chess' || en.piece.kind === 'token'), hover);
return e ? e.uid : null;
}
// the current drag's drop point and the own unit under it (items): { uid, over, x, y }
let dragHit = null;
/**
* Drop point of a dragged piece (render/drag.js dropPoint): the pointer, or TOUCH_LIFT_TILES above a finger. For an
* item it also finds the operator under the plate and tells the UI (pieceDragOver) before the drag asks canPlace
* (a move is a hover pick; the release (`final`) always probes).
*/
function dropPoint(e, piece, final = false) {
let x = e.x, y = e.y;
if (e.pointerType === 'touch') {
const g = cam.unproject(x, y, 0);
y -= TOUCH_LIFT_TILES * (g ? cam.scaleAt(g.x, g.y, 0) : cam.scale);
}
const uid = piece ? piece.uid : null;
const over = piece && piece.kind === 'item' ? pieceUnder(x, y, uid, !final) : null;
const changed = !dragHit || dragHit.uid !== uid || dragHit.over !== over;
dragHit = { uid, over, x, y };
if (changed && piece && piece.kind === 'item') {
const r = canvas.getBoundingClientRect();
emit('pieceDragOver', { uid, over, clientX: r.left + x, clientY: r.top + y });
}
return { x, y };
}
const drag = createDragController({
hitPiece: (x, y, hover) => pieceAt(x, y, !!hover),
hitPiece: (x, y) => pieceAt(x, y),
pickTile: (x, y) => pickBoardTile(x, y),
dropPoint: (e, piece, final) => dropPoint(e, piece, !!final),
isOverCanvas: (cx, cy) => {
try { const el = document.elementFromPoint(cx, cy); return !el || el === canvas; } catch { return true; }
},
@@ -1044,10 +976,8 @@ export async function createFieldView(host, options = {}) {
function onDragEvent(name, payload) {
const out = payload && payload.piece ? { ...payload, piece: publicPiece(payload.piece) } : payload;
// the own unit under a dropped item (render/pick.js at the drop point; null for units / nothing there)
if ((name === 'pieceDrop' || name === 'pieceDragEnd') && out && typeof out === 'object') out.over = dragHit && dragHit.uid === payload.uid ? dragHit.over : null;
switch (name) {
case 'pieceDragStart': dragHit = null; startDragVisual(payload); break;
case 'pieceDragStart': startDragVisual(payload); break;
case 'pieceDragMove': moveDragVisual(payload); return; // internal
case 'pieceDrop': dropDragVisual(payload); break;
case 'pieceDragEnd': if (!payload.dropped) endDragVisual(true); else clearHl('dragTarget'); break;
@@ -1111,11 +1041,18 @@ export async function createFieldView(host, options = {}) {
function moveDragVisual(p) {
if (!dragState) return;
const v = dragState.view;
// the ghost stands (an item: floats centred) on its drop point — the tile the drop goes to is the one under it
const hx = Number.isFinite(p.hx) ? p.hx : p.x, hy = Number.isFinite(p.hy) ? p.hy : p.y;
const t = pickTile(cam, hx, hy, heightAt, tiles.levels);
// held under the pointer: a unit with its drawn (lifted) feet DRAG_HOLD_TILES of its own px per tile below it (the
// scale at the pointer's ground, refined once at the feet), an item plate centred on it; the drop target
// (p.target, highlighted below) is the tile under the pointer
const item = v instanceof ItemView;
const g = cam.unproject(p.x, p.y, 0);
const hold = item ? 0 : DRAG_HOLD_TILES;
const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale;
const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels);
const z = t ? heightAt(t.row, t.col) : 0;
const w = cam.unproject(hx, hy, z);
const up = item ? z : z + (v.lift || 0) + (v.hover || 0);
let w = cam.unproject(p.x, p.y + hold * s0, up);
if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w;
if (w) {
v.x = Math.max(-1, Math.min(21, w.x));
v.y = Math.max(-1, Math.min(19, w.y));
@@ -1167,15 +1104,15 @@ export async function createFieldView(host, options = {}) {
return { pointerId: e.pointerId, pointerType: e.pointerType, button: e.button, x: p.x, y: p.y, clientX: e.clientX, clientY: e.clientY };
}
/** The battle unit under a canvas point (render/pick.js; allies stand on their tiles, enemies walk: no tile). */
function battleUnitAt(x, y, hover = false) {
const bodies = [];
/** The battle unit under a canvas point (render/pick.js: an ally on the tile under it, an enemy near its ground). */
function battleUnitAt(x, y) {
const units = [];
for (const v of views.values()) {
if (!v.info || v.info.kind === 'device') continue;
const b = pickShapeOf(v, v.info.side === 'ally' ? viewTile(v) : null);
if (b) bodies.push(b);
const u = pickUnitOf(v, v.info.side !== 'ally');
if (u) units.push(u);
}
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y));
const hit = pickBattle(units, groundTile(x, y), x, y);
return hit ? hit.ref : null;
}
@@ -1206,7 +1143,7 @@ export async function createFieldView(host, options = {}) {
const ev = evPayload(e);
if (mode === 'battle') {
if (e.pointerType === 'touch') return;
const v = battleUnitAt(ev.x, ev.y, true);
const v = battleUnitAt(ev.x, ev.y);
if (v !== hoverUnit) {
hoverUnit = v;
const info = v ? infos.get(v.id) || v.info : null;
@@ -1220,12 +1157,18 @@ export async function createFieldView(host, options = {}) {
const onPointerCancel = (e) => { if (!destroyed) drag.pointerCancel(evPayload(e)); };
const onPointerLeave = (e) => { if (!destroyed && !drag.dragging) drag.pointerLeave(evPayload(e)); if (hoverUnit) { hoverUnit = null; emit('pieceHover', { uid: null, unitId: null }); } };
const onContext = (e) => e.preventDefault();
// A finger is handled through the pointer events above only. The compatibility mouse events + click of a tap come
// after touchend, hit-tested at the finger again — where the tap may just have opened DOM UI: a tap on a unit's tile
// selects it and its underframe opens over the tile (clamped under the top bar on a phone), and the click pressed
// 撤退 / 出售 (user playtest #4 item 1 on a phone). Cancelling touchend drops them.
const onTouchEnd = (e) => { if (e.cancelable) e.preventDefault(); };
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerCancel);
canvas.addEventListener('pointerleave', onPointerLeave);
canvas.addEventListener('contextmenu', onContext);
canvas.addEventListener('touchend', onTouchEnd, { passive: false });
// ---- battle ---------------------------------------------------------------------------------------------
@@ -1436,6 +1379,7 @@ export async function createFieldView(host, options = {}) {
}
let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave)
let downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list
function syncBattle(renderT) {
if (renderT0Battle == null) renderT0Battle = renderT;
interp.sample(renderT, sample);
@@ -1448,13 +1392,36 @@ export async function createFieldView(host, options = {}) {
if (!v) v = battleView(id) || createUnknown(id, s);
if (!v) continue;
if (!v._seen) { v._seen = true; v.fadeIn = 0; }
if (v.alive || v.info?.kind === 'device') v.sync(s);
if (v.alive || v.info?.kind === 'device') v.sync(s, renderT);
else if (v.dying > 0) { v.x = s.x; v.y = s.y; }
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s); }
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, renderT); }
}
// knocked-out operators waiting to redeploy (b.snap `down`, user playtest #4 item 9): their view stays on the
// field knocked down under a redeploy ring (UnitView.setDown) — made on the spot for one already down when this
// field was entered; one that leaves the list without a redeploy fades out
const down = interp.downAt(renderT);
downSeq++;
if (down) {
for (const d of down) {
let v = views.get(d[0]);
let fresh = false;
if (!v) {
const info = infos.get(d[0]);
if (!info || info.side !== 'ally' || info.kind === 'device') continue;
gone.delete(d[0]);
v = battleView(d[0]);
fresh = true;
}
if (!v || typeof v.setDown !== 'function') continue;
v._seen = true;
v.setDown(d, renderT, fresh);
v._downSeq = downSeq;
}
}
const now = performance.now();
for (const [id, v] of views) {
if (sample.has(id)) continue;
if (v.down && v._downSeq !== downSeq) v.setDown(null, renderT);
if (sample.has(id) || v.down) continue;
if (v._seen) {
// left the snapshot: removed (leaked / hidden / died and its DIE window passed)
if (v.alive) { v.alive = false; v.dying = 0.25; v.dieDur = 0.25; }
@@ -1654,14 +1621,7 @@ export async function createFieldView(host, options = {}) {
const b = v.bounds();
const r = canvas.getBoundingClientRect();
const left = r.left + b.x, top = r.top + b.y;
// the drawn body in client px (render/pick.js, ui/gameLogic.js pickPieceAt)
let shape = null;
if (typeof v.pickShape === 'function') {
const s = v.pickShape();
const hud = s.hud ? { x0: s.hud.x0 + r.left, y0: s.hud.y0 + r.top, x1: s.hud.x1 + r.left, y1: s.hud.y1 + r.top } : null;
shape = { ...s, x: s.x + r.left, y: s.y + r.top, hud };
}
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top, shape };
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top };
},
/**
* Screen geometry of a BOARD-space tile (in the Final Assault prep: where that board tile is drawn on the boss
@@ -1736,6 +1696,7 @@ export async function createFieldView(host, options = {}) {
canvas.removeEventListener('pointercancel', onPointerCancel);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('contextmenu', onContext);
canvas.removeEventListener('touchend', onTouchEnd);
app.ticker.remove(frame);
app.ticker.remove(preRender);
app.ticker.remove(postRender);
@@ -1746,8 +1707,6 @@ export async function createFieldView(host, options = {}) {
try { fx.destroy(); } catch { /* ignore */ }
try { tiles.destroy(); } catch { /* ignore */ }
try { impostors.destroy(); } catch { /* ignore */ }
try { probeRT?.destroy(true); } catch { /* ignore */ }
probeRT = null;
listeners.clear();
try { board3d?.destroy(); } catch { /* ignore */ }
board3d = null;
@@ -1768,8 +1727,8 @@ export async function createFieldView(host, options = {}) {
/** Dev hooks (demo / tests). */
debug: {
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view / own unit under a dragged item
pick: { pieceAt, battleUnitAt, penUnitAt, pieceUnder, groundTile, drawnAt }, get dragHit() { return dragHit; },
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
},
};
return view;
+13 -30
View File
@@ -9,11 +9,10 @@
// target (temp slot, enemy lane, `canPlace` false, or the piece's own bench slot) cancels the drag without a
// pieceDrop. A board piece released on its OWN tile is a drop (when `canPlace` allows it): the UI opens the
// direction wheel to re-orient it in place (research 09 §1.2 — "drag the unit onto its own tile and swipe").
// While dragging, the target is the tile under the dragged ghost's feet: `dropPoint(e, piece)` (app.js) gives that
// canvas point — the pointer itself (mouse / pen), a little above the finger (touch) — and the ghost is drawn standing
// on it, so what the player sees is where the piece lands (user playtest #3 item 7); whether DOM UI covers the drop is
// tested there too (a lifted touch ghost stands on the bench while the finger is on the shop bar below it). Hover and
// presses use the pointer.
// Everything is judged at the pointer (user playtest #4 item 1: the ground is drawn as tiles): a press / hover is on the
// piece standing on the tile under it (`hitPiece`, render/pick.js), and while dragging the target, `tileHover` and the
// legality are that tile — an item dropped on a unit's tile goes to that unit; app.js holds the dragged ghost under
// the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside' (mouse and touch alike).
//
// Events emitted through `emit(name, payload)`:
// pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes)
@@ -21,8 +20,7 @@
// pieceClick { uid, piece, button, detail, clientX, clientY } (detail: right-click / long-press)
// pieceDetail { uid, piece, clientX, clientY } (right-click / long-press)
// pieceDragStart { uid, piece, from }
// pieceDragMove { uid, piece, x, y, hx, hy, clientX, clientY, target, legal } (for the ghost; high frequency;
// hx, hy = the drop point)
// pieceDragMove { uid, piece, x, y, clientX, clientY, target, legal } (for the ghost; high frequency)
// pieceDrop { uid, piece, from, target }
// pieceDragEnd { uid, piece, dropped, cancelled }
@@ -96,11 +94,8 @@ const DEFAULTS = { threshold: 6, touchThreshold: 10, longPressMs: 480 };
/**
* Pointer state machine.
* @param {{
* hitPiece: (x, y, hover) => (object|null), // piece under canvas point (render/pick.js rule); piece.uid required;
* // hover = a mouse move (not a press): may skip the pixel probe
* hitPiece: (x, y) => (object|null), // piece on the tile under canvas point (render/pick.js); piece.uid required
* pickTile: (x, y) => ({row, col}|null), // grid tile under canvas point
* dropPoint?: (e, piece, final) => ({x, y}|null), // canvas point of the dragged ghost's feet (default: the pointer);
* // final = the release (a move may skip the pixel probe)
* isOverCanvas?: (clientX, clientY) => boolean, // false when DOM UI covers the point (default true)
* canPlace?: (piece, row, col, target) => boolean,
* emit: (name, payload) => void,
@@ -121,14 +116,8 @@ export function createDragController(hooks) {
const emit = (name, payload) => { try { h.emit(name, payload); } catch (err) { console.error('[drag] listener failed', err); } };
const draggable = (piece) => !!piece && piece.draggable !== false && piece.uid != null;
const tileAt = (x, y) => { try { return h.pickTile(x, y); } catch { return null; } };
const pieceAt = (x, y, hover = false) => { try { return h.hitPiece(x, y, hover); } catch { return null; } };
const pieceAt = (x, y) => { try { return h.hitPiece(x, y); } catch { return null; } };
const overCanvas = (cx, cy) => { try { return h.isOverCanvas ? h.isOverCanvas(cx, cy) !== false : true; } catch { return true; } };
/** The drop point of a dragged piece (its ghost's feet) for pointer event `e`. */
const dropAt = (e, piece, final = false) => {
let p = null;
try { p = typeof h.dropPoint === 'function' ? h.dropPoint(e, piece, final) : null; } catch { p = null; }
return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : { x: e.x, y: e.y };
};
function setHoverTile(tile) {
const slot = tile ? tileSlot(tile) : null;
@@ -155,18 +144,13 @@ export function createDragController(hooks) {
moveDrag(e);
}
/** Is the drop point (not the finger) on the canvas? A touch ghost is lifted over the board while the finger may be
* on the DOM below it (the shop bar under the bench on a phone): what counts is where the piece is drawn. */
const dropOverCanvas = (e, pt) => overCanvas(e.clientX + (pt.x - e.x), e.clientY + (pt.y - e.y));
function moveDrag(e) {
const pt = dropAt(e, st.piece);
const tile = tileAt(pt.x, pt.y);
const over = dropOverCanvas(e, pt);
const tile = tileAt(e.x, e.y);
const over = overCanvas(e.clientX, e.clientY);
const slot = over && tile ? tileSlot(tile) : null;
const legal = !!slot && slot.area !== 'temp' && (slot.area === 'board' || !sameSlot(slot, st.from)) && isLegal(canPlace, st.piece, slot);
setHoverTile(over ? tile : null);
emit('pieceDragMove', { uid: st.piece.uid, piece: st.piece, x: e.x, y: e.y, hx: pt.x, hy: pt.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
emit('pieceDragMove', { uid: st.piece.uid, piece: st.piece, x: e.x, y: e.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
}
function endDrag(e, cancelled) {
@@ -175,9 +159,8 @@ export function createDragController(hooks) {
if (!s) return;
let dropped = false;
if (!cancelled) {
const pt = dropAt(e, s.piece, true);
const tile = tileAt(pt.x, pt.y);
const d = resolveDrop({ piece: s.piece, from: s.from, tile, overCanvas: dropOverCanvas(e, pt), clientX: e.clientX, clientY: e.clientY, canPlace });
const tile = tileAt(e.x, e.y);
const d = resolveDrop({ piece: s.piece, from: s.from, tile, overCanvas: overCanvas(e.clientX, e.clientY), clientX: e.clientX, clientY: e.clientY, canPlace });
if (d.kind === 'drop') {
dropped = true;
emit('pieceDrop', { uid: s.piece.uid, piece: s.piece, from: s.from, target: d.target });
@@ -226,7 +209,7 @@ export function createDragController(hooks) {
pointerMove(e) {
if (!st) {
if (e.pointerType === 'touch') return;
setHoverPiece(pieceAt(e.x, e.y, true), e);
setHoverPiece(pieceAt(e.x, e.y), e);
setHoverTile(tileAt(e.x, e.y));
return;
}
+154 -8
View File
@@ -103,6 +103,10 @@ const BOOM_SPIN = 22, BOOM_BOW = 0.35, BOOM_MAX_T = 6;
* S3: ammo grants / reloads lock far more than the 5 base shots, ≤ 33 shells).
*/
const LOCK_T = 5, LOCK_FADE = 0.2, MAX_LOCKS = 48;
// 炎佑 fire jet: real s a jet burns past its tick's `dur` (the next 1 game s tick re-aims and extends it before it fades:
// one continuous stream), its fade-out, the most jets at once, the fire colours
const FLAME_TAIL = 0.2, FLAME_FADE = 0.15, MAX_FLAMES = 8;
const FLAME_TINTS = Object.freeze([0xffd27a, 0xffa94d, 0xff8a3d, 0xff5a2a]);
/** Bombard shell: share of its flight spent rising from the shooter, and the height it climbs / falls from (tiles). */
const SHELL_RISE = 0.34, SHELL_UP = 5.5;
/** 蕾缪安 S3 shell (fx 'bombardShell'); `look` 'mortar' is its own flight (_stepMortar). */
@@ -198,7 +202,9 @@ export const FX_KINDS = Object.freeze({
knack: { a: 'buff', c: 0xffd45a }, bloodBattle: { a: 'buff', c: 0xff4b3e }, sword: { a: 'buff', c: 0xdfe8ff }, equip: { a: 'buff', c: 0x4ed8af },
garrisonGrant: { a: 'buff', c: 0x4ed8af }, extraAttack: { a: 'buff', c: 0xffe066 }, soul: { a: 'buff', c: 0xb36bff }, jungleSoul: { a: 'buff', c: 0x7fd37a },
candle: { a: 'buff', c: 0xffb347 }, mote: { a: 'buff', c: 0xfff0a8 }, ember: { a: 'buff', c: 0xff7a33 }, ignite: { a: 'buff', c: 0xff6a2a },
flame: { a: 'buff', c: 0xff6a2a }, yanyouFlame: { a: 'buff', c: 0xff8a3d }, grow: { a: 'buff', c: 0x7fd37a }, weightlessBuff: { a: 'buff', c: 0xcfe0ff },
flame: { a: 'buff', c: 0xff6a2a }, grow: { a: 'buff', c: 0x7fd37a }, weightlessBuff: { a: 'buff', c: 0xcfe0ff },
// 炎佑 祛恶之焰: a continuous jet from the dragon (`id`) onto its locked target (`target`) + a burning disc (_flame)
yanyouFlame: { a: 'flame', c: 0xff8a3d, r: 1 },
// states
takeoff: { a: 'lift', c: 0xcfe0ff }, levitate: { a: 'lift', c: 0xcfe0ff }, weightless: { a: 'lift', c: 0xcfe0ff },
sleep: { a: 'sleep', c: 0xa8b6ff }, crit: { a: 'crit', c: 0xffe066 },
@@ -310,6 +316,7 @@ export class FxSystem {
this.beams.blendMode = P.BLEND_MODES.ADD;
ctx.layers.fxAdd.addChild(this.beams);
this.beamList = [];
this.flames = []; // fire jets (炎佑 祛恶之焰) { src, tgt, x, y, z, r, col, t, end, disc, edge, … }
this.nums = [];
this._numPools = new Map(); // bitmap font → free number records
this.auras = new Map(); // unit id → { sprite, ring, t }
@@ -925,6 +932,128 @@ export class FxSystem {
this.beamList.length = w;
}
// ---- fire jets (炎佑 祛恶之焰) ---------------------------------------------------------------------------------
/**
* fx 'yanyouFlame' (sim/content/tokens.js yanyouKit; user playtest #4 item 12 "一段持续时间的喷火"): the sim emits one
* event per game second of the channel; each keeps the jet of dragon `srcId` burning for `dur` real s + FLAME_TAIL
* onto its locked target `tgtId` (else the spot (x, y)), so the 1 s ticks join into ONE continuous stream, with a
* burning disc of radius `r` tiles following the target. One jet per dragon (a new tick re-aims and extends it).
*/
_flame(srcId, tgtId, x, y, r, col, dur) {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
let F = srcId != null ? this.flames.find((f) => f.src === srcId) : null;
if (!F) {
const P = this.P;
const disc = new P.Sprite(this.tex.soft);
disc.anchor.set(0.5); disc.blendMode = P.BLEND_MODES.ADD; disc.alpha = 0;
const edge = new P.Sprite(this.tex.ring);
edge.anchor.set(0.5); edge.blendMode = P.BLEND_MODES.ADD; edge.alpha = 0;
F = { src: srcId, disc, edge, t: 0, end: 0, jet: 0, fire: 0, smoke: 0, seed: Math.random() * 100 };
this.flames.push(F);
if (this.flames.length > MAX_FLAMES) this._freeFlame(this.flames.shift());
}
F.tgt = tgtId; F.x = x; F.y = y; F.z = this._groundZ(x, y); F.r = r; F.col = col;
F.end = Math.max(F.end, F.t + dur + FLAME_TAIL);
F.disc.tint = F.edge.tint = col;
}
_freeFlame(F) { F.disc.destroy(); F.edge.destroy(); }
_updateFlames(dt) {
if (!this.flames.length) return;
const cam = this.ctx.cam();
const g = this.beams; // drawn after _updateBeams cleared it
const p = this._p, q = this._q, m = this._g;
const low = this.quality === 'low';
let w = 0;
for (const F of this.flames) {
F.t += dt;
const sv = this._viewOf(F.src);
// the channel is over (no tick kept it going) or the dragon is gone
if (F.t >= F.end || (F.src != null && this.ctx.view && (!sv || sv.alive === false))) { this._freeFlame(F); continue; }
const tv = this._viewOf(F.tgt);
if (tv && tv.alive !== false && Number.isFinite(tv.x) && Number.isFinite(tv.y)) { F.x = tv.x; F.y = tv.y; F.z = tv.z || 0; }
const fade = Math.min(1, F.t / 0.12, (F.end - F.t) / FLAME_FADE);
const flick = 0.82 + 0.18 * Math.sin(F.t * 23 + F.seed) * Math.sin(F.t * 7.3);
// burning disc on the ground around the target
this._onGround(F.disc, F.y, F.z); this._onGround(F.edge, F.y, F.z);
cam.project(F.x, F.y, F.z + 0.02, p);
cam.project(F.x, F.y + F.r, F.z + 0.02, q);
const cx = p.x, cy = p.y, s = p.s;
const rx = s * F.r, ry = Math.max(1, cy - q.y);
F.disc.position.set(cx, cy); F.disc.scale.set((rx * 2) / 128, (ry * 2) / 128); F.disc.alpha = 0.5 * fade * flick;
F.edge.position.set(cx, cy); F.edge.scale.set((rx * 2.05) / 128, (ry * 2.05) / 128); F.edge.alpha = 0.7 * fade * flick;
// flame tongues and embers rising from the disc
F.fire += dt * (low ? 14 : 34) * fade;
while (F.fire >= 1) {
F.fire -= 1;
if (!this._room()) { F.fire = 0; break; }
const a = Math.random() * Math.PI * 2, d = Math.sqrt(Math.random()) * 0.9;
const px = cx + Math.cos(a) * rx * d, py = cy + Math.sin(a) * ry * d;
const ember = Math.random() < 0.3;
const po = this._o();
po.tint = ember ? 0xffd27a : FLAME_TINTS[(Math.random() * FLAME_TINTS.length) | 0];
po.vx = (Math.random() - 0.5) * s * 0.3; po.vy = -s * (ember ? 1.6 : 0.9 + Math.random() * 0.6); po.drag = ember ? 0.5 : 1.5;
po.life = ember ? 0.7 : 0.35 + Math.random() * 0.25;
po.s0 = (s / 128) * (ember ? 0.06 : 0.28 + Math.random() * 0.12); po.s1 = (s / 128) * (ember ? 0.02 : 0.08);
po.a0 = 0.9; po.a1 = 0; po.fadeIn = 0.05;
this.particle(ember ? 'dot' : 'soft', px, py, po);
}
if (this.rich) {
F.smoke += dt * 5 * fade;
while (F.smoke >= 1) {
F.smoke -= 1;
if (!this._room()) { F.smoke = 0; break; }
const po = this._o();
po.add = false; po.tint = 0x3a3430; po.vx = (Math.random() - 0.5) * s * 0.2; po.vy = -s * 0.5; po.drag = 1;
po.life = 0.9; po.s0 = (s / 128) * 0.35; po.s1 = (s / 128) * 0.8; po.a0 = 0.32; po.a1 = 0; po.fadeIn = 0.15;
this.particle('smoke', cx + (Math.random() - 0.5) * rx, cy - s * 0.2, po);
}
}
// the jet: from the dragon's chest onto the target (its chest; the disc centre without a target view)
if (sv) {
this._chest(sv, m);
const mx = m.x, my = m.y;
if (tv && tv.alive !== false) this._chest(tv, q); else { q.x = cx; q.y = cy - s * 0.15; }
const tx = q.x, ty = q.y;
const segs = 6;
for (let pass = 0; pass < 3; pass++) {
const col = pass === 2 ? 0xfff2c0 : pass === 1 ? 0xffb347 : F.col;
const alpha = (pass === 0 ? 0.3 : pass === 1 ? 0.55 : 0.8) * fade * flick;
let x0 = mx, y0 = my;
for (let i = 1; i <= segs; i++) {
const f = i / segs;
// widens towards the target (a cone of fire), wobbling
const wd = s * (pass === 0 ? 0.1 + 0.22 * f : pass === 1 ? 0.05 + 0.1 * f : 0.02 + 0.03 * f);
const j = i < segs ? Math.sin(F.seed + i * 3.1 + F.t * 30) * s * 0.05 * f : 0;
const x1 = mx + (tx - mx) * f + j, y1 = my + (ty - my) * f - j * 0.5;
g.lineStyle(Math.max(1, wd), col, alpha);
g.moveTo(x0, y0); g.lineTo(x1, y1);
x0 = x1; y0 = y1;
}
}
// fire puffs streaming along it
F.jet += dt * (low ? 18 : 42) * fade;
const T = 0.2;
while (F.jet >= 1) {
F.jet -= 1;
if (!this._room()) { F.jet = 0; break; }
const a = Math.random() * Math.PI * 2, d = Math.random() * 0.5;
const gx = tx + Math.cos(a) * rx * d, gy = ty + Math.sin(a) * ry * d;
const po = this._o();
po.tint = FLAME_TINTS[(Math.random() * FLAME_TINTS.length) | 0];
po.vx = (gx - mx) / T; po.vy = (gy - my) / T;
po.life = T * (0.9 + Math.random() * 0.3);
po.s0 = (s / 128) * 0.1; po.s1 = (s / 128) * (0.38 + Math.random() * 0.2); po.a0 = 0.85; po.a1 = 0.15;
this.particle('soft', mx, my, po);
}
}
this.flames[w++] = F;
}
this.flames.length = w;
}
// ---- lock-on reticles (蕾缪安) -------------------------------------------------------------------------------
/**
@@ -1508,10 +1637,10 @@ export class FxSystem {
/**
* b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph,
* heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge,
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element); unknown kinds get a generic
* sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration, t (shell
* flight, game s), src / from / to / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions), element, n,
* scale, kind, tiles.
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame); unknown kinds get a
* generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration,
* t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions),
* element, n, scale, kind, tiles.
*/
simFx(kind, x, y, extra) {
const ex = extra && typeof extra === 'object' ? extra : {};
@@ -1720,6 +1849,12 @@ export class FxSystem {
else this.strike(at.x, at.y, at.z, col);
break;
}
case 'flame': {
// at the event's spot (the locked target), not snapped onto the dragon hovering 0.25 tile from it
const fx0 = Number(x), fy0 = Number(y);
this._flame(ex.id ?? ex.src ?? null, ex.target ?? ex.to ?? null, fx0, fy0, r, col, Math.max(0.2, dur || 0.5));
break;
}
case 'strike': case 'pillar': this.strike(at.x, at.y, at.z, col, spec.a === 'pillar'); break;
case 'volley': {
const list = Array.isArray(ex.targets) ? ex.targets.slice(0, 12) : [];
@@ -1938,9 +2073,17 @@ export class FxSystem {
glow.anchor.set(0.5); glow.tint = tint; glow.blendMode = P.BLEND_MODES.ADD; glow.scale.set(0.9);
const sp = new P.Sprite(icon);
sp.anchor.set(0.5);
const k = 46 / Math.max(1, Math.max(icon.width, icon.height));
sp.scale.set(k);
sp.tint = tint;
// 46 px along its longer side. A bond icon (app.js 'layer': PIXI.Texture.from(url)) is a 1×1 placeholder until
// its image has loaded: sized from that it was drawn 46× too big, ~5000 px over the whole screen for the pop's
// 1.4 s (user playtest #4 item 13) — so it is sized once its texture is valid, and hidden until then.
const fit = () => {
if (sp.destroyed) return;
sp.scale.set(46 / Math.max(1, icon.width, icon.height));
sp.visible = true;
};
if (icon.valid) fit();
else { sp.visible = false; icon.once('update', fit); }
c.addChild(glow, sp);
}
if (label) {
@@ -1989,6 +2132,8 @@ export class FxSystem {
this.pops.length = 0;
this.beamList.length = 0;
this.beams.clear();
for (const F of this.flames) this._freeFlame(F);
this.flames.length = 0;
this.vigT = 0;
this.vignette.alpha = 0;
for (const zn of this.zones) this._freeZone(zn);
@@ -2006,6 +2151,7 @@ export class FxSystem {
this._updateProjs(dt);
this._updateLocks(dt);
this._updateBeams(dt);
this._updateFlames(dt);
this._updateNums(dt);
this._updateRings(dt);
this._updateAuras(dt);
@@ -2028,7 +2174,7 @@ export class FxSystem {
}
get counts() {
return { particles: this.parts.length, projectiles: this.projs.length, numbers: this.nums.length, rings: this.rings.length, auras: this.auras.size, locks: this.locks.length };
return { particles: this.parts.length, projectiles: this.projs.length, numbers: this.nums.length, rings: this.rings.length, auras: this.auras.size, locks: this.locks.length, flames: this.flames.length };
}
destroy() {
+44 -8
View File
@@ -18,9 +18,18 @@
// and handed out by `takeEvents()` once renderT passes the stamp. Stale cosmetic events (> `eventMaxLag`
// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events are always delivered.
//
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim].
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along
// (server/sim/Battle.js snapshot, user playtest #4 items 8 / 9):
// * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's
// normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older
// snapshot, like flags);
// * `down` [[id, respawnAt, respawnTime, state]] — knocked-out operators waiting to redeploy (they are no longer in
// `units`): downAt(time) returns the list of the snapshot at `time`.
// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT.
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8 });
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8, EL: 9, EL_FILL: 10, EL_UNTIL: 11, EL_DUR: 12 });
/** Element keys a snapshot `elem` entry may carry (server/sim/constants.js ELEMENT_ORDER). */
const ELEMENT_KEYS = new Set(['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']);
const MAX_EVENTS = 6000;
const finite = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
@@ -42,8 +51,9 @@ export function frameTime(msg) {
}
/**
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, raw }` or null when unusable.
* Tuples with a non-finite id/x/y are skipped; other numbers default to 0.
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, down: [[id, respawnAt, respawnTime,
* state]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
* to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped.
*/
export function normalizeSnapshot(snap) {
if (!snap || typeof snap !== 'object') return null;
@@ -59,7 +69,21 @@ export function normalizeSnapshot(snap) {
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) continue;
units.set(id, [id, x, y, finite(u[3]), finite(u[4]), finite(u[5]), finite(u[6]), finite(u[7]) | 0, finite(u[8]) | 0]);
}
return { t, units, raw: snap };
if (Array.isArray(snap.elem)) {
for (const e of snap.elem) {
if (!Array.isArray(e) || !ELEMENT_KEYS.has(e[1])) continue;
const tu = units.get(e[0]);
if (tu && tu.length === 9) tu.push(e[1], clamp(finite(e[2]), 0, 1), finite(e[3]), Math.max(0, finite(e[4])));
}
}
let down = null;
if (Array.isArray(snap.down)) {
for (const d of snap.down) {
if (!Array.isArray(d) || !(typeof d[0] === 'number' || typeof d[0] === 'string')) continue;
(down || (down = [])).push([d[0], finite(d[1]), Math.max(0, finite(d[2])), finite(d[3]) | 0]);
}
}
return { t, units, down, raw: snap };
}
export class SnapshotBuffer {
@@ -212,10 +236,21 @@ export class SnapshotBuffer {
return ans;
}
/**
* The `down` list (knocked-out operators waiting to redeploy, see header) of the snapshot shown at `time` (default
* renderT) — the same snapshot sample() reads flags from — or null.
*/
downAt(time = this.renderT) {
const s = this.snaps;
if (!s.length || !Number.isFinite(time)) return null;
const ia = this._indexAt(time);
return s[ia < 0 ? 0 : ia].down || null;
}
/**
* Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen }`). Samples of units no longer
* present are deleted from `out`.
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur }` —
* `el` = the shown element gauge (null: none), see header). Samples of units no longer present are deleted from `out`.
*/
sample(time = this.renderT, out = new Map()) {
const s = this.snaps;
@@ -231,7 +266,7 @@ export class SnapshotBuffer {
const stamp = A.t;
for (const [id, a] of A.units) {
let o = out.get(id);
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0 }; out.set(id, o); }
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0 }; out.set(id, o); }
const b = B ? B.units.get(id) : null;
if (b) {
const dx = b[1] - a[1], dy = b[2] - a[2];
@@ -264,6 +299,7 @@ export class SnapshotBuffer {
else if (!(o.hp > 0)) o.hp = 0;
o.flags = a[7];
o.anim = a[8];
if (a.length > 9) { o.el = a[9]; o.elFill = a[10]; o.elUntil = a[11]; o.elDur = a[12]; } else if (o.el !== null) { o.el = null; o.elFill = 0; o.elUntil = 0; o.elDur = 0; }
o.seen = stamp;
}
for (const id of out.keys()) if (!A.units.has(id)) out.delete(id);
+78 -207
View File
@@ -1,224 +1,95 @@
// render/pick.js — which unit the pointer is on (user playtest #3 item 7: "clicking a unit on the map often selects
// the one a row below it; dropping equipment too"). Pure screen-space geometry (no PIXI, no DOM), the ONE rule behind
// every picking path: prep press / drag start / right-click detail and equipment dropped on an operator
// (render/app.js pieceAt / pieceUnder, and ui/gameLogic.js pickPieceAt over view.pieceScreenRect shapes), battle
// clicks and hover (battleUnitAt) and the enemy preview pen (penUnitAt).
//
// Why "the front-most rectangle under the pointer" failed: a chibi is an upright billboard ~1.27 tiles tall (median
// of the 138 official operators' Front / Back skeletons in their idle pose, p10–p90 1.17–1.41) standing on its tile,
// while the rows of the official 30° cameras are only ~0.84 (prep) – 0.91 (battle) of a tile apart on screen. The unit
// in front covers the legs / feet of the unit behind it and its head sits over that unit's tile; its 0.7-tile-wide
// rectangle also covered the ground and the legs showing beside its head (a head is only ~0.5 tile wide), and a
// pressed tile was only looked at when no rectangle at all was hit — so a press on the lower part of a unit, on its
// feet or on its tile went to the unit in front of it: the one a row lower on screen.
//
// Body of a unit (all px on one screen; `x`, `y` = its feet, `s` = px per tile at the feet, `h` = model height in
// tiles, `w` = width factor (1 for operators; enemies scale with their size), `flip` = +1 facing right / −1 left). The
// measured opaque profile of the idle chibis is ~±0.3 tile wide through the body (the back side a little wider: hair,
// capes) and ~±0.2 at the crown; a taller model is taller hair / ears / hats — its face stays ~0.86 tile up:
// outline head ellipse (centre h − 0.33·w above the feet, radii 0.27·w × 0.33·w) ∪ body box (0.08 below the feet …
// the shoulders at min(0.62·h, 0.78·w); −0.34·w … +0.28·w facing right) ∪ the unit's HUD (tier chip /
// bars) — what is drawn
// core face (ellipse 0.21·w × 0.15·w centred min(0.86·w, h − 0.36·w) above the feet) ∪ torso / legs (±0.2·w, from
// 0.1 above the feet to the shoulders) — what a player aims at
// 'diamond' (the avatar diamond shown until the Spine model is there): the rhombus of side `d` tiles above the feet
// 'plate' (hand items): a square of half-size `r` tiles centred on (x, y)
// Rule (pickBody):
// 1. the pointer is on units' cores → the front-most of them (drawn on top: the face of the unit in front is its own;
// the torso of the unit behind is its own above that face)
// 2. else a unit stands on the tile under the pointer → that unit (its feet, its tile — also where the hair or a
// weapon of the unit in front hangs over it)
// 3. else the pointer is on outlines only (hair, hands, feet, weapon edges) → the unit whose core is nearest (px / its
// own scale); ties → the front-most
// The feet of a unit hidden behind the face of the unit in front are not pickable (nothing of them shows there); its
// tile beside that face and everything of it above that face are.
//
// Pixel probe (optional `probe(body)` → true / false / null, render/app.js drawnAt): the shapes above are a model of
// the drawn chibi, and where the bodies of two units overlap the model cannot tell whose arm, hair or weapon is drawn
// on top. Measured on the mock match against the rendered pixels (1920×1080, every opaque unit pixel sampled, the
// front-most drawn unit = the truth): the rule alone put 7 % of the prep presses (18 % in battle) on the wrong unit —
// mostly the unit BEHIND: the hair crown and the sides (arms, clothes, weapons) of the unit in front lie over the legs /
// tile of the one behind — against 2 % / 11 % for the old front-most rectangle; with the probe 0.7 % / 1.7 % (the rest:
// anti-aliased edges). So wherever the probe can change the answer (two or more bodies within their outline +
// PROBE_MARGIN of the pointer, or one the rule did not pick), the front-most body whose model really draws the pixel
// under the pointer wins — what the player sees. Only when none of them draws there (a gap, the bare tile) or the probe
// cannot tell (null) does the rule above decide.
// render/pick.js — which unit the pointer is on (user playtest #4 item 1: "地上都画好了一个一个方格,点击对应方格就选中那个
// 方格的人物就行" — the ground is drawn as tiles: a press on a tile selects the unit on that tile). Pure logic (no PIXI, no
// DOM): render/app.js hands in the units and the ground under the pointer (render/projection.js pickTile, raised tops
// first, in the tiles the views are drawn on). The one rule behind every picking path — prep press / click / right-click
// and long-press detail / drag start / hover (app.js pieceAt), battle clicks and hover (battleUnitAt), the enemy preview
// pen (penUnitAt); a dragged piece (an item too) drops on the tile under the pointer (render/drag.js):
// * prep pieces (board tiles, bench row 7, temp row 8), battle allies and the figures of the enemy pen stand on
// tiles: the unit on the tile under the pointer. An empty tile selects nothing — a press on a unit's head where it is
// drawn over the tile behind it is a press on that tile. Several on one tile (the pen's clusters of up to 3, a piece
// held for a moment on an occupied tile) → the one nearest to the pointer's point on the tile, ties → the front-most.
// * battle enemies walk between tiles: the one within ENEMY_REACH tile of the pointer — its ground position from the
// pointer's point on the ground, or its drawn body (on screen: the upright line from its feet to its head, in its px
// per tile), whichever is nearer, so a press on a tall enemy's / a boss's body picks it too; a flying enemy (drawn in
// the air, over whatever tile it crosses) by its drawn body alone.
// * an ally's tile and an enemy both qualify → the nearer one (distance in tiles to the pointer's point), ties → the
// ally, then the front-most.
// (Replaces the drawn-body shapes and the 1-px render probe of v2.2, DESIGN §17.2: the player aims at the grid.)
/** Median model height of the official chibis (tiles above the feet), when the model's own is not known. */
export const BODY_H = 1.27;
/** Shape constants in tiles (see the header). */
export const BODY = Object.freeze({
headRx: 0.27, headRy: 0.33, faceRx: 0.21, faceRy: 0.15, face: 0.86, faceTop: 0.36, back: 0.34, front: 0.28,
below: 0.08, shoulder: 0.62, neck: 0.78, torso: 0.2, torsoFrom: 0.1, lean: 0.03,
});
/** Battle enemies are picked within this many tiles of the pointer's ground point or of their drawn body on screen. */
export const ENEMY_REACH = 0.6;
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const inRect = (r, px, py) => !!r && px >= r.x0 && px <= r.x1 && py >= r.y0 && py <= r.y1;
const inEllipse = (e, px, py) => ((px - e.cx) / e.rx) ** 2 + ((py - e.cy) / e.ry) ** 2 <= 1;
const rectDist = (r, px, py) => Math.hypot(Math.max(r.x0 - px, 0, px - r.x1), Math.max(r.y0 - py, 0, py - r.y1));
function ellipseDist(e, px, py) {
const k = Math.hypot((px - e.cx) / e.rx, (py - e.cy) / e.ry);
return k > 1 ? (k - 1) * Math.min(e.rx, e.ry) : 0;
const EPS = 1e-9;
const isTile = (t) => !!t && Number.isInteger(t.row) && Number.isInteger(t.col);
const onTile = (u, t) => isTile(u.tile) && isTile(t) && u.tile.row === t.row && u.tile.col === t.col;
const depthOf = (u) => (u && Number.isFinite(u.depth) ? u.depth : 0);
/** Tiles between a unit's ground position and the pointer's point on the ground (0 when either is unknown). */
function groundDist(u, g) {
if (!g || !Number.isFinite(g.x) || !Number.isFinite(g.y) || !Number.isFinite(u.x) || !Number.isFinite(u.y)) return 0;
return Math.hypot(u.x - g.x, u.y - g.y);
}
/** Rhombus |dx|/a + |dy|/a ≤ k around (cx, cy). */
const inRhombus = (d, px, py, k = 1) => Math.abs(px - d.cx) / d.a + Math.abs(py - d.cy) / d.a <= k;
/**
* @typedef {{ x: number, y: number, s: number, h?: number, w?: number, flip?: number, kind?: 'chibi'|'diamond'|'plate',
* d?: number, r?: number, depth?: number, hud?: { x0: number, y0: number, x1: number, y1: number } | null,
* tile?: { row: number, col: number } | null }} PickBody
* `depth`: draw order (larger = nearer the camera, drawn on top); `tile`: the tile it stands on (null: moving units)
*/
/**
* Screen geometry of a body: `{ outline: [parts], core: [parts] }`, parts `{ rect }` / `{ ellipse }` / `{ rhombus }`.
* @param {PickBody} b
*/
export function bodyParts(b) {
const s = b.s, x = b.x, y = b.y;
const hud = b.hud && [b.hud.x0, b.hud.y0, b.hud.x1, b.hud.y1].every(Number.isFinite) ? { rect: b.hud } : null;
if (b.kind === 'plate') {
const a = (Number(b.r) > 0 ? b.r : 0.31) * s;
return { outline: [{ rect: { x0: x - a, x1: x + a, y0: y - a, y1: y + a } }], core: [{ rect: { x0: x - a * 0.7, x1: x + a * 0.7, y0: y - a * 0.7, y1: y + a * 0.7 } }] };
}
if (b.kind === 'diamond') {
const half = ((Number(b.d) > 0 ? b.d : 0.78) / 2) * s;
const rh = { cx: x, cy: y - BODY.below * s - half, a: half };
return { outline: [{ rhombus: rh, k: 1 }, ...(hud ? [hud] : [])], core: [{ rhombus: rh, k: 0.7 }] };
}
const w = Number(b.w) > 0 ? b.w : 1;
const h = clamp(Number(b.h) > 0 ? b.h : BODY_H, 0.3, 4);
const flip = b.flip === -1 ? -1 : 1;
const cx = x - BODY.lean * flip * w * s;
const head = { cx, cy: y - (h - BODY.headRy * w) * s, rx: BODY.headRx * w * s, ry: BODY.headRy * w * s };
const face = { cx, cy: y - Math.min(BODY.face * w, h - BODY.faceTop * w) * s, rx: BODY.faceRx * w * s, ry: BODY.faceRy * w * s };
const top = y - Math.min(BODY.shoulder * h, BODY.neck * w) * s;
const box = { x0: x - (flip > 0 ? BODY.back : BODY.front) * w * s, x1: x + (flip > 0 ? BODY.front : BODY.back) * w * s, y0: top, y1: y + BODY.below * s };
const torso = { x0: cx - BODY.torso * w * s, x1: cx + BODY.torso * w * s, y0: top, y1: y - BODY.torsoFrom * Math.min(1, h) * s };
// the HUD (tier chip / bars) is drawn above every unit, but over the unit behind: an outline, never a core
return { outline: [{ ellipse: head }, { rect: box }, ...(hud ? [hud] : [])], core: [{ ellipse: face }, { rect: torso }] };
}
const partHit = (p, px, py) => (p.rect ? inRect(p.rect, px, py) : p.ellipse ? inEllipse(p.ellipse, px, py) : inRhombus(p.rhombus, px, py, p.k));
function partDist(p, px, py) {
if (p.rect) return rectDist(p.rect, px, py);
if (p.ellipse) return ellipseDist(p.ellipse, px, py);
const r = p.rhombus, k = (Math.abs(px - r.cx) + Math.abs(py - r.cy)) / r.a;
return k > p.k ? (k - p.k) * r.a * Math.SQRT1_2 : 0;
}
/** 2 = on the body's core, 1 = on its outline only, 0 = off it. */
export function hitBody(b, px, py) {
if (!b || !(b.s > 0) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return 0;
// cheap reject (battle hover tests every unit on every pointer move): far outside the body and its HUD
if (b.kind !== 'plate' && !(b.hud && inRect(b.hud, px, py))) {
const w = Number(b.w) > 0 ? b.w : 1, reach = Math.max(BODY.back * w, (Number(b.d) || 0) / 2) * b.s + 1;
const top = b.y - (b.kind === 'diamond' ? (Number(b.d) || 0.78) + BODY.below : clamp(Number(b.h) > 0 ? b.h : BODY_H, 0.3, 4)) * b.s - 1;
if (Math.abs(px - b.x) > reach || py < top || py > b.y + BODY.below * b.s + 1) return 0;
}
const parts = bodyParts(b);
if (parts.core.some((p) => partHit(p, px, py))) return 2;
return parts.outline.some((p) => partHit(p, px, py)) ? 1 : 0;
}
/** Distance (px) from a point to the body's core, divided by the body's scale (0 on the core). */
export function coreDistance(b, px, py) {
const parts = bodyParts(b);
let d = Infinity;
for (const p of parts.core) d = Math.min(d, partDist(p, px, py));
return d / b.s;
}
/** The screen bounding box of a body's outline (px): `{ x, y, width, height }`. */
export function bodyBounds(b) {
const parts = bodyParts(b);
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const p of [...parts.outline, ...parts.core]) {
if (p.rect) { x0 = Math.min(x0, p.rect.x0); x1 = Math.max(x1, p.rect.x1); y0 = Math.min(y0, p.rect.y0); y1 = Math.max(y1, p.rect.y1); }
else if (p.ellipse) { const e = p.ellipse; x0 = Math.min(x0, e.cx - e.rx); x1 = Math.max(x1, e.cx + e.rx); y0 = Math.min(y0, e.cy - e.ry); y1 = Math.max(y1, e.cy + e.ry); }
else { const r = p.rhombus, a = r.a * p.k; x0 = Math.min(x0, r.cx - a); x1 = Math.max(x1, r.cx + a); y0 = Math.min(y0, r.cy - a); y1 = Math.max(y1, r.cy + a); }
}
if (!Number.isFinite(x0)) return { x: b.x, y: b.y, width: 0, height: 0 };
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
}
const depthOf = (b) => (Number.isFinite(b.depth) ? b.depth : 0);
/** Margin (tiles) around a body's outline in which the pixel probe still looks (weapons, capes, hair, big enemies past
* the modelled outline; 0.25 left 4 % of the battle presses wrong, 0.5 1.7 %). */
export const PROBE_MARGIN = 0.5;
/** At most this many overlapping bodies are probed (front-most first). */
const PROBE_MAX = 6;
/** Bodies whose outline (+ PROBE_MARGIN) contains the point, front-most first. */
export function probeCandidates(bodies, px, py) {
const out = [];
for (const b of bodies) {
if (!b || !(b.s > 0) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) continue;
const r = bodyBounds({ ...b, hud: null }), m = PROBE_MARGIN * b.s;
if (px >= r.x - m && px <= r.x + r.width + m && py >= r.y - m && py <= r.y + r.height + m) out.push(b);
}
return out.sort((a, b) => depthOf(b) - depthOf(a));
/** Tiles (its px per tile) between screen point (px, py) and an enemy's drawn body — the line from its feet up to its head. */
function bodyDist(b, px, py) {
if (!b || !(b.s > 0) || ![b.x, b.top, b.feet, px, py].every(Number.isFinite)) return Infinity;
const y = Math.max(Math.min(b.top, b.feet), Math.min(Math.max(b.top, b.feet), py));
return Math.hypot(px - b.x, py - y) / b.s;
}
/**
* Pixel probe step of pickBody: the front-most body around the point whose model draws the pixel under it. Asked only
* when it can change the answer: two or more bodies there, or one that the shape rule did not pick (`rule`: e.g. its
* arm over the tile of a unit standing behind it). null when none of them draws there or the probe cannot tell for one
* in front of the first that does (then the shape rule decides).
* @typedef {{ row: number, col: number, x?: number, y?: number }} PickGround the tile under the pointer and the pointer's
* point on its top (world tiles: x = col, y = row), or null off the grid
* @typedef {{ tile?: { row: number, col: number } | null, x?: number, y?: number, depth?: number, fly?: boolean,
* body?: { x: number, top: number, feet: number, s: number } | null }} PickUnit
* `tile`: the tile it stands on (null: it walks — a battle enemy); `x`, `y`: its ground position (world tiles);
* `body`: an enemy's drawn body on screen (px: x, head `top`, `feet`; `s` px per tile there); `fly`: in the air — its
* ground position does not count; `depth`: draw order (larger = in front)
*/
function probeBodies(bodies, px, py, probe, rule) {
const cand = probeCandidates(bodies, px, py);
if (!cand.length || (cand.length === 1 && cand[0] === rule)) return null;
for (const b of cand.slice(0, PROBE_MAX)) {
let d = null;
try { d = probe(b); } catch { d = null; }
if (d === true) return b;
if (d !== false) return null;
}
return null;
}
const onTile = (b, t) => !!t && !!b.tile && b.tile.row === t.row && b.tile.col === t.col;
function nearest(list, px, py) {
/**
* The unit standing on the tile under the pointer (several → the nearest to the pointer's point, ties → the front-most).
* @param {Array<PickUnit|null>} units
* @param {PickGround|null} ground
* @returns {PickUnit|null} one of `units`
*/
export function pickOnTile(units, ground) {
if (!Array.isArray(units) || !isTile(ground)) return null;
let best = null, bd = Infinity;
for (const b of list) {
const d = coreDistance(b, px, py);
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && depthOf(b) > depthOf(best))) { best = b; bd = d; }
for (const u of units) {
if (!u || !onTile(u, ground)) continue;
const d = groundDist(u, ground);
if (d < bd - EPS || (Math.abs(d - bd) <= EPS && depthOf(u) > depthOf(best))) { best = u; bd = d; }
}
return best;
}
/**
* The body the pointer is on (see the header for the rule), or null.
* @param {Array<PickBody|null>} bodies
* @param {number} px
* Battle pick (see the header): allies on the tile under the pointer, enemies within ENEMY_REACH of it; the nearer wins.
* @param {Array<PickUnit|null>} units allies with `tile`, enemies with `tile: null` and their drawn `body`
* @param {PickGround|null} ground
* @param {number} px pointer on screen (enemies' drawn bodies)
* @param {number} py
* @param {{ row: number, col: number } | null} [ground] the tile under the pointer (same space as the bodies' `tile`)
* @param {((body: PickBody) => (boolean|null)) | null} [probe] does this body's model draw the pixel under the pointer
* (null: cannot tell)? See the header.
* @returns {PickBody|null} one of `bodies`
* @returns {PickUnit|null} one of `units`
*/
export function pickBody(bodies, px, py, ground = null, probe = null) {
if (!Number.isFinite(px) || !Number.isFinite(py) || !Array.isArray(bodies)) return null;
const rule = ruleBody(bodies, px, py, ground);
if (typeof probe === 'function') {
const drawn = probeBodies(bodies, px, py, probe, rule);
if (drawn) return drawn;
export function pickBattle(units, ground, px, py) {
if (!Array.isArray(units)) return null;
const onGround = !!ground && Number.isFinite(ground.x) && Number.isFinite(ground.y);
let best = null, bd = Infinity;
const better = (u, d) => {
if (d < bd - EPS) return true;
if (Math.abs(d - bd) > EPS) return false;
if (!!u.tile !== !!best.tile) return !!u.tile; // a tie: the ally standing there
return depthOf(u) > depthOf(best);
};
for (const u of units) {
if (!u) continue;
let d = Infinity;
if (u.tile) { if (onTile(u, ground)) d = groundDist(u, ground); }
else {
d = bodyDist(u.body, px, py);
if (!u.fly && onGround && Number.isFinite(u.x) && Number.isFinite(u.y)) d = Math.min(d, Math.hypot(u.x - ground.x, u.y - ground.y));
if (!(d <= ENEMY_REACH)) continue;
}
if (d < Infinity && better(u, d)) { best = u; bd = d; }
}
return rule;
}
/** The shape rule of pickBody (the header's steps 1–3). */
function ruleBody(bodies, px, py, ground) {
let core = null;
const rims = [];
for (const b of bodies) {
const hit = hitBody(b, px, py);
if (hit === 2) { if (!core || depthOf(b) > depthOf(core)) core = b; }
else if (hit === 1) rims.push(b);
}
if (core) return core;
const standing = ground ? bodies.filter((b) => b && b.s > 0 && onTile(b, ground)) : [];
if (standing.length) return nearest(standing, px, py);
return rims.length ? nearest(rims, px, py) : null;
return best;
}
+128
View File
@@ -6,6 +6,7 @@
// renderer swaps the texture without touching geometry,
// * FX atlas (one base texture ⇒ particles batch / fit a ParticleContainer),
// * status icons, tier chips, backdrop gradients,
// * HUD rings: element gauge icons and the redeploy countdown ring (hudRings / ringArc),
// * avatar-in-rarity-diamond composites (cached per unit asset id) for the Spine fallback.
// 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).
@@ -1156,3 +1157,130 @@ export function itemTexture(key, img, color) {
_items.set(k, tex);
return tex;
}
// =============================================================================================================
// HUD rings (user playtest #4 items 8 and 9): the element gauge icon of a unit (PRTS 元素: "模型下部会显示对应的元素
// 图标,并以白条显示剩余的元素值" — operators: the element's disc with its glyph; enemies: "小尺寸图标(不显示元素图标,
// 仅根据元素种类改变背景色)") inside a white ring of the remaining 元素值, and the redeploy countdown ring above a
// knocked-out operator. One 512×576 atlas: RING_STEPS + 1 arc frames (white, clockwise from 12 o'clock) and the discs.
// Official element colours / glyphs after the client icons (图标 元素 sanity / water / fire / dark), drawn procedurally
// (the local-client extraction has no general battle HUD sprites).
/** Arc frames: `arcs[k]` covers k / RING_STEPS of the circle. */
export const RING_STEPS = 48;
/** Element disc colours (official icons: 神经 teal, 侵蚀 steel blue, 灼燃 rust, 凋亡 charcoal) and glyph colours. */
export const ELEMENT_RING = Object.freeze({
neural: Object.freeze({ disc: '#13806c', glyph: '#bfe6df', tint: 0x1fae93 }),
erosion: Object.freeze({ disc: '#2d5d86', glyph: '#cfdcea', tint: 0x4a8cc4 }),
burn: Object.freeze({ disc: '#8f3512', glyph: '#f0cdbd', tint: 0xe0632c }),
apoptosis: Object.freeze({ disc: '#363338', glyph: '#bdb8c2', tint: 0x9c8fb0 }),
necrosis: Object.freeze({ disc: '#363338', glyph: '#bdb8c2', tint: 0x9c8fb0 }),
});
const RING_KEYS = Object.keys(ELEMENT_RING);
const RC = 64; // cell px
const RING_R = 26, RING_W = 6.5, DISC_R = 21.5;
let _rings = null;
function drawElementGlyph(c, el, cx, cy, col) {
c.fillStyle = col; c.strokeStyle = col; c.lineCap = 'round'; c.lineJoin = 'round';
if (el === 'neural') { // concentric target
c.lineWidth = 2.4;
c.beginPath(); c.arc(cx, cy, 12, 0, Math.PI * 2); c.stroke();
c.beginPath(); c.arc(cx, cy, 7, 0, Math.PI * 2); c.stroke();
c.beginPath(); c.arc(cx, cy, 2.6, 0, Math.PI * 2); c.fill();
} else if (el === 'erosion') { // a big and a small water drop over a wave
const drop = (x, y, r) => {
c.beginPath(); c.moveTo(x, y - r * 2.1);
c.bezierCurveTo(x + r * 0.5, y - r * 1.2, x + r, y - r * 0.5, x + r, y + r * 0.1);
c.arc(x, y + r * 0.1, r, 0, Math.PI);
c.bezierCurveTo(x - r, y - r * 0.5, x - r * 0.5, y - r * 1.2, x, y - r * 2.1); c.fill();
};
drop(cx - 3, cy - 1, 5.6); drop(cx + 7.5, cy + 2, 3.1);
c.lineWidth = 2.2; c.beginPath(); c.moveTo(cx - 13, cy + 11); c.quadraticCurveTo(cx - 6.5, cy + 7, cx, cy + 11); c.quadraticCurveTo(cx + 6.5, cy + 15, cx + 13, cy + 11); c.stroke();
} else if (el === 'burn') { // flame
c.beginPath(); c.moveTo(cx, cy - 13); c.quadraticCurveTo(cx + 11, cy - 1, cx + 7, cy + 9); c.quadraticCurveTo(cx, cy + 14, cx - 7, cy + 9);
c.quadraticCurveTo(cx - 11, cy - 1, cx - 2, cy - 5); c.quadraticCurveTo(cx - 1, cy - 9, cx, cy - 13); c.fill();
c.globalCompositeOperation = 'destination-out';
c.beginPath(); c.moveTo(cx, cy - 1); c.quadraticCurveTo(cx + 5, cy + 5, cx + 2, cy + 9); c.quadraticCurveTo(cx - 2, cy + 11, cx - 4, cy + 7); c.quadraticCurveTo(cx - 4, cy + 3, cx, cy - 1); c.fill();
c.globalCompositeOperation = 'source-over';
} else { // 凋亡: an eye-like lozenge
c.lineWidth = 2.2;
c.beginPath(); c.moveTo(cx - 13, cy); c.lineTo(cx, cy - 8); c.lineTo(cx + 13, cy); c.lineTo(cx, cy + 8); c.closePath(); c.stroke();
c.beginPath(); c.moveTo(cx - 6, cy); c.lineTo(cx, cy - 3.5); c.lineTo(cx + 6, cy); c.lineTo(cx, cy + 3.5); c.closePath(); c.fill();
}
}
/**
* The HUD ring atlas: `{ arcs: Texture[RING_STEPS + 1], track, disc: { [element]: Texture }, discEnemy: { [element] },
* downDisc }` — every frame RC px square, centred on the ring centre (anchor 0.5). Built once, lazily.
*/
export function hudRings() {
if (_rings) return _rings;
const P = PIXI();
const W = 8 * RC, rowsArc = Math.ceil((RING_STEPS + 1) / 8), H = (rowsArc + 2) * RC;
const canvas = makeCanvas(W, H);
const c = canvas.getContext('2d');
const cell = (i) => [(i % 8) * RC, Math.floor(i / 8) * RC];
const frames = { arcs: [] };
// arcs: white with a thin dark edge (readable on light and dark ground)
for (let k = 0; k <= RING_STEPS; k++) {
const [x, y] = cell(k);
frames.arcs.push([x, y]);
if (!k) continue;
const cx = x + RC / 2, cy = y + RC / 2, a0 = -Math.PI / 2, a1 = a0 + (Math.PI * 2 * k) / RING_STEPS;
c.lineCap = k === RING_STEPS ? 'butt' : 'round';
c.strokeStyle = 'rgba(0,0,0,0.55)'; c.lineWidth = RING_W + 2.5;
c.beginPath(); c.arc(cx, cy, RING_R, a0, a1); c.stroke();
c.strokeStyle = '#ffffff'; c.lineWidth = RING_W;
c.beginPath(); c.arc(cx, cy, RING_R, a0, a1); c.stroke();
}
const row = rowsArc * RC;
// row A: ally discs (with glyph), the track ring, the dark disc of the redeploy ring
RING_KEYS.forEach((el, i) => {
const x = i * RC, cx = x + RC / 2, cy = row + RC / 2;
c.fillStyle = ELEMENT_RING[el].disc; c.beginPath(); c.arc(cx, cy, DISC_R, 0, Math.PI * 2); c.fill();
c.strokeStyle = 'rgba(0,0,0,0.5)'; c.lineWidth = 1.5; c.stroke();
c.save(); c.beginPath(); c.arc(cx, cy, DISC_R - 1, 0, Math.PI * 2); c.clip();
drawElementGlyph(c, el, cx, cy, ELEMENT_RING[el].glyph);
c.restore();
frames['a:' + el] = [x, row];
});
{
const x = RING_KEYS.length * RC, cx = x + RC / 2, cy = row + RC / 2;
c.strokeStyle = 'rgba(12,16,15,0.72)'; c.lineWidth = RING_W + 2.5; c.beginPath(); c.arc(cx, cy, RING_R, 0, Math.PI * 2); c.stroke();
c.strokeStyle = 'rgba(255,255,255,0.16)'; c.lineWidth = RING_W; c.beginPath(); c.arc(cx, cy, RING_R, 0, Math.PI * 2); c.stroke();
frames.track = [x, row];
const x2 = x + RC, cx2 = x2 + RC / 2;
const g = c.createRadialGradient(cx2, cy, 2, cx2, cy, RING_R + 3);
g.addColorStop(0, 'rgba(18,24,22,0.92)'); g.addColorStop(0.8, 'rgba(10,14,13,0.86)'); g.addColorStop(1, 'rgba(10,14,13,0)');
c.fillStyle = g; c.beginPath(); c.arc(cx2, cy, RING_R + 3, 0, Math.PI * 2); c.fill();
frames.downDisc = [x2, row];
}
// row B: enemy discs (element colour only)
RING_KEYS.forEach((el, i) => {
const x = i * RC, cx = x + RC / 2, cy = row + RC + RC / 2;
c.fillStyle = ELEMENT_RING[el].disc; c.beginPath(); c.arc(cx, cy, DISC_R, 0, Math.PI * 2); c.fill();
c.strokeStyle = 'rgba(0,0,0,0.5)'; c.lineWidth = 1.5; c.stroke();
frames['e:' + el] = [x, row + RC];
});
const base = P.BaseTexture.from(canvas, { mipmap: P.MIPMAP_MODES.ON });
const tex = ([x, y]) => new P.Texture(base, new P.Rectangle(x, y, RC, RC));
_rings = {
base, canvas, size: RC,
arcs: frames.arcs.map(tex),
track: tex(frames.track),
downDisc: tex(frames.downDisc),
disc: Object.fromEntries(RING_KEYS.map((el) => [el, tex(frames['a:' + el])])),
discEnemy: Object.fromEntries(RING_KEYS.map((el) => [el, tex(frames['e:' + el])])),
};
return _rings;
}
/** Arc frame of a fraction 0..1 (rounded to the nearest step; a non-zero fraction never shows an empty ring). */
export function ringArc(frac) {
const r = hudRings();
const f = frac > 1 ? 1 : frac > 0 ? frac : 0;
let k = Math.round(f * RING_STEPS);
if (k === 0 && f > 0) k = 1;
return r.arcs[k];
}
+165 -73
View File
@@ -14,6 +14,17 @@
// plane). `setDir(dir)` re-orients a live view (swapping Front ⇄ Back without a fallback flash). Enemies flip by the
// sign of their horizontal velocity (with hysteresis).
//
// Knocked-out operators (user playtest #4 item 9, b.snap `down`): `setDown([id, respawnAt, respawnTime, state])`
// keeps a dead operator on its tile in its knocked-down pose — the Spine Die clip played once and held on its last
// frame (the collapsed / kneeling pose with closed eyes), slightly greyed — with a redeploy ring above its head:
// a dark disc, a mint arc filling as the respawn timer runs and the seconds left; once the timer is done and it still
// waits, a full amber ring with "DP" (not enough DP) or a red ring with "!" (its tile is taken). `onDeploy` (the
// redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out.
// Element gauges (item 8, b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`): the official element icon
// beside the bars (PRTS 元素: operators the element's disc with its glyph, enemies a smaller plain disc of the
// element's colour) inside a white ring of the remaining 元素值 (1 − fill); during a 爆发冷却 the ring refills over
// the cooldown. Both rings are sprites of one atlas (textures.js hudRings), created on first use and hidden when idle.
//
// ItemView renders hand items as a floating icon plate; DeviceView renders battle devices (crates) as 3D boxes
// with an HP bar once damaged.
//
@@ -23,17 +34,15 @@
// (`ctx.loadLevel()` 1–3, app.js) small / far units (< ~56 px per tile) animate every 2nd frame, and from level 2 on
// every unit does.
//
// Picking (user playtest #3 item 7): `pickShape()` describes the body as drawn — feet, px per tile, the model's own
// height (measured once from the posed skeleton's bounds; the median chibi until then), facing, draw order and the
// HUD it shows (tier chip / bars) — for the shared rule of render/pick.js; `bounds()` / `hitTest()` use the same shape.
// A dragged (lifted) item plate is drawn centred on its ground point, i.e. on the pointer (render/app.js).
// Picking is by tile (render/pick.js, user playtest #4 item 1): views carry no hit shapes; `bounds()` is the drawn body's
// screen rect for tooltips and overlays (view.pieceScreenRect). A dragged (lifted) item plate is drawn centred on its
// ground point, i.e. on the pointer (render/app.js).
import { UF, ANIM } from '../../../shared/constants.js';
import { SpineActor } from './spine.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture } from './textures.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc } from './textures.js';
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, statusIconKey } from './style.js';
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
import { BODY_H, bodyBounds, hitBody } from './pick.js';
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const DIRS = ['UP', 'RIGHT', 'DOWN', 'LEFT'];
@@ -54,6 +63,17 @@ const RAISED_Z = 0.12;
/** Flying units hover this many tiles above the ground they cross. */
export const FLY_HOVER = 0.32;
/** b.snap `down` entry states (server/sim/constants.js DOWN_STATE). */
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
/** Knocked-down look: model tint and alpha; redeploy ring colours per state; ring size (tiles) and height. */
export const DOWN_LOOK = Object.freeze({
tint: 0xb4b4b4, alpha: 0.92,
ring: Object.freeze({ [DOWN_STATE.COUNTING]: 0x4ed8af, [DOWN_STATE.WAIT_DP]: 0xffc600, [DOWN_STATE.WAIT_TILE]: 0xff4b3e }),
size: 0.42, height: 1.02,
});
/** Element ring diameter in tiles (operators; enemies ×0.8) and its pixel clamp. */
const EL_RING = 0.3;
/**
* Keep `obj` in the right layer: the surface container of block row round(y) when it lies on a raised top at
* height `z` (drawn after that row's blocks), else `fallback`. Reparents only on change.
@@ -132,8 +152,13 @@ export class UnitView {
this.statuses = new Set();
this.alive = true;
this.dying = 0; // seconds left of the death fade (0 = not dying)
this.dieT = 0;
this.dieT = 0; // seconds since die() (the Die clip's clock; a late Spine model catches up)
this.remove = false; // set when the death fade ended (owner removes the view)
this.down = null; // knocked out, waiting to redeploy: { until, total, state } (setDown) — no fade meanwhile
this.gameT = 0; // battle game time of the frame (render clock), for the rings' countdowns
this.el = null; this.elFill = 0; this.elUntil = 0; this.elDur = 0; // shown element gauge (sync)
this._elRing = null; // { track, disc, arc } sprites, built on first use
this._downRing = null; // { disc, track, arc, text } sprites, built on first use
this.alpha = 1; this.fadeIn = this.prep ? 1 : 0;
this.lunge = 0; this.lungeDir = { x: 1, y: 0 };
this.flash = 0;
@@ -143,9 +168,6 @@ export class UnitView {
this.shake = 0;
this.screen = { x: 0, y: 0, s: 1, top: 0 };
this.destroyed = false;
this._bodyH = null; // model height (tiles above the feet), measured once per Spine model (pickShape)
this._hudBox = null; // screen rect of the HUD shown this frame (tier chip / bars), or null
this._flip = this.visFacing; // drawn facing (±1) of the model
// --- display objects
this.shadow = new P.Sprite(ctx.shadowTex || shadowTexture());
@@ -253,9 +275,12 @@ export class UnitView {
this.spineReady = true;
this.swapT = swap ? 1 : 0;
this.actor.spine.alpha = swap ? 1 : 0;
// replay current state
if (!this.alive) this.actor.die();
else {
// replay current state (a dead model resumes its Die clip where it would be — a knocked-down one holds its end)
if (!this.alive) {
const d = this.actor.die();
const at = Math.min(d, this.dieT * (this.ctx.animRate?.() || 1));
if (at > 0) this.actor.update(at);
} else {
if (this.flags & UF.SKILL) this.actor.setSkill(true);
this.actor.setBase(this._baseFromAnim());
}
@@ -339,9 +364,13 @@ export class UnitView {
return 'idle';
}
/** Apply an interpolated sample (render/interp.js). */
sync(s) {
/** Apply an interpolated sample (render/interp.js); `t` = the render clock's game time (ring countdowns). */
sync(s, t) {
if (!s) return;
if (Number.isFinite(t)) this.gameT = t;
// the element gauge shown (b.snap `elem`): element, fill 0..1, cooldown end (game s) and length
this.el = typeof s.el === 'string' ? s.el : null;
this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0;
this.x = s.x; this.y = s.y;
this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY';
// ground enemies only ever walk low tiles (a rounding step onto a block edge must not pop them up)
@@ -406,7 +435,6 @@ export class UnitView {
this.imp = null;
}
this._box = null;
this._bodyH = null; // the next model is measured again
const old = this.actor;
this.actor = null;
this.spineReady = false;
@@ -480,10 +508,38 @@ export class UnitView {
revive() {
this.alive = true;
this.dying = 0; this.remove = false; this.alpha = 1;
this.down = null;
this.hp = this.maxHp; this.ghostHp = this.hp;
if (this.actor) this.actor.revive();
}
/**
* Knocked-out state (b.snap `down` entry `[id, respawnAt, respawnTime, state]`, render/interp.js downAt) or null;
* `t` = the render clock's game time. A living view is knocked down first (its Die clip plays; `instant`: a view made
* for a unit that is already down — a field joined mid-battle — starts on the held end of the clip). While down the
* view never fades: the Die clip's last frame stays on the tile under the redeploy ring. null on a view still down
* (it left for good) starts the fade; the redeploy itself comes through onDeploy / revive.
*/
setDown(d, t, instant = false) {
if (Number.isFinite(t)) this.gameT = t;
if (!d) {
if (!this.down) return;
this.down = null;
if (!this.alive) { this.dying = 0.55; this.dieDur = 0.55; }
return;
}
if (this.alive) {
this.die();
if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); }
}
if (this.zTarget == null) this.z = this.zTarget = groundZ(this.ctx, this.x, this.y); // never synced: its tile top
this.dying = 0; // no death fade while down
const dn = this.down || (this.down = { until: 0, total: 0, state: DOWN_STATE.COUNTING });
dn.until = Number(d[1]) || 0;
dn.total = Math.max(0, Number(d[2]) || 0);
dn.state = d[3] | 0;
}
// ---- per frame -------------------------------------------------------------------------------------------
/** @param {number} dt real seconds @param {any} cam camera @param {number} t real clock */
@@ -501,7 +557,11 @@ export class UnitView {
// fades
if (this.fadeIn < 1) this.fadeIn = Math.min(1, this.fadeIn + dt * 4);
let alpha = this.fadeIn;
if (this.dying > 0) {
if (this.down) {
// knocked down: the Die clip ends and holds its last frame (the actor keeps its clock), no fade
this.dieT += dt;
alpha *= DOWN_LOOK.alpha;
} else if (this.dying > 0) {
this.dieT += dt;
this.dying -= dt;
const tail = 0.55;
@@ -524,7 +584,6 @@ export class UnitView {
// off-screen: nothing to animate or draw (bounds / hit-testing still follow `screen`)
if (this._cull(bx, by, s, dt)) return;
const flip = this.isEnemy ? (ENEMY_MODEL_FACES_LEFT ? -this.visFacing : this.visFacing) : this.visFacing;
this._flip = flip;
// shadow (on a raised top it is drawn with that block row, else in the shadow layer under everything)
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z);
@@ -543,7 +602,8 @@ export class UnitView {
const sc = s * UNIT.modelScale;
const flashK = this.flash > 0 ? this.flash : 0;
let tint = 0xffffff;
if (this.flags & UF.FROZEN) tint = 0x9fd4ff;
if (this.down) tint = DOWN_LOOK.tint;
else if (this.flags & UF.FROZEN) tint = 0x9fd4ff;
else if (this.flags & UF.COLD) tint = 0xcfe6ff;
if (flashK > 0) tint = mixTint(tint, 0xff8a80, flashK * 0.8);
let animDt = dt * (this.ctx.animRate?.() || 1);
@@ -569,8 +629,6 @@ export class UnitView {
this.actor.update(animDt);
if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; }
}
// the model's own height for picking: once, from its first resting pose (not a deploy / skill clip)
if (this._bodyH == null && this.actor.clock > 0 && this.actor.mode === 'base') this._bodyH = this._measureBody();
}
// the diamond is only needed while no model shows (a cross-fade keeps whatever diamond was already up)
if (!spineShown) this._ensurePicture();
@@ -579,7 +637,7 @@ export class UnitView {
const bob = this.alive ? Math.sin(t * 2.4 + this.bob) * s * 0.03 : 0;
this.fallback.scale.set(size / 160);
this.fallback.position.set(0, -s * 0.08 + bob);
this.fallback.tint = this.flash > 0 ? mixTint(0xffffff, 0xff8a80, this.flash) : (this.flags & UF.FROZEN ? 0x9fd4ff : 0xffffff);
this.fallback.tint = this.down ? DOWN_LOOK.tint : this.flash > 0 ? mixTint(0xffffff, 0xff8a80, this.flash) : (this.flags & UF.FROZEN ? 0x9fd4ff : 0xffffff);
if (!this.alive) this.fallback.alpha = Math.max(0, this.fallback.alpha);
}
this.flash = Math.max(0, this.flash - dt * 6);
@@ -656,7 +714,8 @@ export class UnitView {
const showHp = showBars && (!this.isEnemy || damaged || this.isBoss);
const bw = clamp(s * (this.isBoss ? UNIT.bossBarWidth : UNIT.barWidth), 24, this.isBoss ? 260 : 96);
const bh = clamp(s * (this.isBoss ? 0.12 : 0.075), 3, this.isBoss ? 12 : 7);
this.hud.alpha = this.dying > 0 ? 0 : alpha;
// a knocked-down operator's HUD is its redeploy ring alone, drawn at full strength over the greyed model
this.hud.alpha = this.down ? this.fadeIn : this.dying > 0 ? 0 : alpha;
let sx = this.shake > 0 ? Math.sin(t * 90) * this.shake * 10 : 0;
this.shake = Math.max(0, this.shake - dt);
const x0 = x - bw / 2 + sx;
@@ -706,16 +765,6 @@ export class UnitView {
if (prep) this.chip.position.set(x, y - this.chip.height / 2 + 2);
else this.chip.position.set(x0 - this.chip.width / 2 - 1, cy + (showSp ? spH / 2 : 0));
}
// the HUD's screen rect (drawn above every unit: a press on a unit's chip / bars is on that unit, render/pick.js)
let hx0 = Infinity, hy0 = Infinity, hx1 = -Infinity, hy1 = -Infinity;
const hudAdd = (ax, ay, bx2, by2) => { hx0 = Math.min(hx0, ax); hy0 = Math.min(hy0, ay); hx1 = Math.max(hx1, bx2); hy1 = Math.max(hy1, by2); };
if (showHp) hudAdd(x0 - 1, cy - bh / 2 - 1, x0 + bw + 1, cy + bh / 2 + 1);
if (showSp) hudAdd(x0 - 1, cy, x0 + bw + 1, this._spY + spH / 2 + 1);
if (this.chip && this.chip.visible) {
const cw = this.chip.width / 2, ch = this.chip.height / 2;
hudAdd(this.chip.position.x - cw, this.chip.position.y - ch, this.chip.position.x + cw, this.chip.position.y + ch);
}
this._hudBox = hx1 > hx0 && hy1 > hy0 && this.hud.alpha > 0.05 ? { x0: hx0, y0: hy0, x1: hx1, y1: hy1 } : null;
// status icons row above the bars
const icons = this._iconKeys();
const isz = clamp(s * 0.26, 12, 26);
@@ -731,6 +780,9 @@ export class UnitView {
ic.width = ic.height = isz;
ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy);
}
// element gauge right of the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`)
this._updateElementRing(showBars && !!this.el, x0 + bw, cy + (showSp ? spH / 2 : 0), s);
this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t);
// blocked marker at the feet (enemies held by a blocker)
const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED);
this.blockIcon.visible = !!blocked;
@@ -754,6 +806,72 @@ export class UnitView {
}
}
/**
* Element gauge icon (see header): the element's disc — operators with its glyph, enemies plain and smaller — in a
* white ring of the remaining 元素值 (1 − fill), which refills over a 爆发冷却; left edge at `xl`, centred on `cy`.
* Sprites of the hudRings atlas, built on the first gauge and hidden while there is none.
*/
_updateElementRing(show, xl, cy, s) {
let r = this._elRing;
if (!show) { if (r) r.root.visible = false; return; }
const tex = hudRings();
if (!r) {
const P = this.P;
const root = new P.Container();
const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; };
r = this._elRing = { root, track: mk(tex.track), disc: mk(tex.disc.burn), arc: mk(tex.arcs[0]) };
this.hud.addChild(root);
}
const cooling = this.elDur > 0 && this.elUntil > 0;
const frac = cooling ? clamp(1 - (this.elUntil - this.gameT) / this.elDur, 0, 1) : clamp(1 - this.elFill, 0, 1);
r.disc.texture = (this.isEnemy ? tex.discEnemy : tex.disc)[this.el] || tex.disc.burn;
r.arc.texture = ringArc(frac);
const d = clamp(s * EL_RING * (this.isEnemy ? 0.8 : 1), 13, 32);
r.root.scale.set(d / tex.size);
r.root.position.set(xl + d / 2 + 2, cy);
r.root.visible = true;
}
/**
* Redeploy ring above a knocked-down operator (see header), centred on (cx, cy): a mint arc filling as its respawn
* timer runs with the seconds left; a full pulsing amber ring with "DP" / red ring with "!" once it only waits.
*/
_updateDownRing(show, cx, cy, s, t) {
let r = this._downRing;
if (!show) { if (r) r.root.visible = false; return; }
const tex = hudRings();
if (!r) {
const P = this.P;
const root = new P.Container();
const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; };
const disc = mk(tex.downDisc), track = mk(tex.track), arc = mk(tex.arcs[0]);
const text = new P.Text('', { fontFamily: 'Bender, Oxanium, "Noto Sans SC", sans-serif', fontSize: 32, fontWeight: '700', fill: '#ffffff', stroke: '#0b0f0e', strokeThickness: 6 });
text.anchor.set(0.5);
root.addChild(text);
r = this._downRing = { root, disc, track, arc, text, label: null };
this.hud.addChild(root);
}
const dn = this.down;
const counting = dn.state === DOWN_STATE.COUNTING;
const left = Math.max(0, dn.until - this.gameT);
r.arc.texture = ringArc(counting ? (dn.total > 0 ? clamp(1 - left / dn.total, 0, 1) : 1) : 1);
const color = DOWN_LOOK.ring[dn.state] ?? DOWN_LOOK.ring[DOWN_STATE.COUNTING];
r.arc.tint = color;
const label = counting ? String(Math.ceil(left - 1e-6)) : dn.state === DOWN_STATE.WAIT_DP ? 'DP' : '!';
if (r.label !== label) {
r.label = label;
r.text.text = label;
r.text.style.fill = counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0');
}
const d = clamp(s * DOWN_LOOK.size, 22, 52);
const k = d / tex.size;
r.disc.scale.set(k); r.track.scale.set(k); r.arc.scale.set(k);
r.text.scale.set((d * (label.length > 2 ? 0.34 : 0.44)) / 32);
r.root.position.set(cx, cy);
r.root.alpha = counting ? 1 : 0.72 + 0.28 * Math.sin(t * 5);
r.root.visible = true;
}
// ---- impostor mode (crowded fields, clipped skeletons): the skeleton is rendered into a slot of the shared
// impostor atlas (render/impostor.js) every `interval` frames (staggered per unit; every frame for clipped
// skeletons, whose stencil masks must stay out of the main pass) and shown as one sprite. Without a free atlas
@@ -872,6 +990,9 @@ export class UnitView {
for (const k of this.statuses) {
if (out.length >= 4) break;
if (k === 'skill') continue;
// a burst's lock ('burnBurst', 'neuralBurst' … — the 爆发冷却) is shown by the element icon beside the bars
// (b.snap `elem`); only a feed without gauges (an older recording) shows it as a status
if (this.el && k.endsWith('Burst')) continue;
const icon = statusIconKey(k);
// flag-driven states are authoritative (a stale 'stun' status must not outlive the flag)
if (!icon || icon === 'stun' || icon === 'freeze' || icon === 'sleep' || icon === 'stealth' || icon === 'invuln') continue;
@@ -881,39 +1002,13 @@ export class UnitView {
return out;
}
/** Model height (tiles above the feet) of the posed Spine model: its local bounds' top, clamped per kind. */
_measureBody() {
let b = null;
try { b = this.actor.spine.getLocalBounds(); } catch { b = null; }
const top = b && Number.isFinite(b.y) && b.height > 1 ? -b.y * UNIT.modelScale : NaN;
const [lo, hi] = this.isEnemy ? (this.isBoss ? [0.6, 3.2] : [0.45, 2.4]) : [1.0, 1.55];
if (Number.isFinite(top) && top > 0.2) return clamp(top, lo, hi);
return this.isEnemy ? clamp(this._headTiles || BODY_H, lo, hi) : BODY_H;
}
/**
* The body as drawn, for render/pick.js (canvas CSS px): the Spine chibi (feet, px per tile, its height — measured,
* else the median chibi / the enemy's bounds —, width factor, facing, draw order, HUD) or the avatar diamond shown
* until the model is there.
* @returns {import('./pick.js').PickBody}
*/
pickShape() {
const sc = this.screen;
const depth = this.root && !this.root.destroyed ? this.root.zIndex : 0;
const hud = this.culled ? null : this._hudBox; // a culled unit's HUD box is where it was last drawn: stale
if (!(this.actor && this.spineReady)) return { kind: 'diamond', x: sc.x, y: sc.y, s: sc.s || 1, d: UNIT.diamond * (this.isBoss ? 1.5 : 1), depth, hud };
const h = this._bodyH || (this.isEnemy ? clamp(this._headTiles || BODY_H, 0.45, this.isBoss ? 3.2 : 2.4) : BODY_H);
return { kind: 'chibi', x: sc.x, y: sc.y, s: sc.s || 1, h, w: this.isEnemy ? clamp(h / 1.2, 0.75, 2.2) : 1, flip: this._flip === -1 ? -1 : 1, depth, hud };
}
/** Canvas-space bounds (CSS px) of the drawn body (the outline of pickShape, without the HUD). */
/** Canvas-space bounds (CSS px) of the body: 0.7 tile wide, from its head (UNIT.headroom; enemies: their model) down
* to just below the feet — tooltips and overlays (view.pieceScreenRect), not picking (render/pick.js is by tile). */
bounds() {
return bodyBounds({ ...this.pickShape(), hud: null });
}
hitTest(x, y) {
if (!this.alive || this.alpha < 0.05) return false;
return hitBody(this.pickShape(), x, y) > 0;
const s = this.screen.s || 1;
const h = (this._headTiles || UNIT.headroom) * s;
const w = s * 0.7;
return { x: this.screen.x - w / 2, y: this.screen.y - h, width: w, height: h + s * 0.1 };
}
setHover(on) { this.hovered = !!on; }
@@ -967,7 +1062,7 @@ function defaultInterval(ctx, info) {
/**
* Hand item: icon plate floating above its slot. While dragged (`lift` > 0) the plate is centred on its ground point —
* the pointer (render/app.js moveDragVisual) — so the item lands, and equips, where it is drawn.
* the pointer (render/app.js moveDragVisual); it drops on (and equips the unit on) the tile under the pointer.
*/
export class ItemView {
constructor(ctx, info) {
@@ -1008,14 +1103,11 @@ export class ItemView {
this.shadow.alpha = 0.35;
this.screen.top = lifted ? p.y - size / 2 : p.y - size;
}
/** The plate on screen for render/pick.js: a square centred on the icon. */
pickShape() {
const s = this.screen.s || 1;
const cy = this.lift > 0 ? this.screen.y : this.screen.y - s * 0.31;
return { kind: 'plate', x: this.screen.x, y: cy, s, r: 0.31, depth: this.root && !this.root.destroyed ? this.root.zIndex : 0, hud: null };
/** Canvas-space bounds (CSS px) of the plate (centred on the pointer while dragged). */
bounds() {
const w = (this.screen.s || 1) * 0.62;
return { x: this.screen.x - w / 2, y: this.lift > 0 ? this.screen.y - w / 2 : this.screen.y - w, width: w, height: w };
}
bounds() { return bodyBounds(this.pickShape()); }
hitTest(x, y) { return hitBody(this.pickShape(), x, y) > 0; }
setHover() {}
destroy() { if (this.destroyed) return; this.destroyed = true; this.shadow.destroy(); this.root.destroy({ children: true }); }
}