Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners

- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight
  (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s
- #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks,
  hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped
  at one per frame
- #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates'
  pending LP in the team panel
- #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue);
  temp-row warning and ready-button reason
- #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx
- #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught)
- #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost
- #8 特质 right under the detail card header, with its official type icon
- #9 no spinning busy cursors / spinners next to texts; game data warmed before the match
- docs, recordings and tests updated (2414 unit tests, all browser suites green)
This commit is contained in:
sganggs committed 2026-09-29 23:42:55 +08:00
1 parent 01a44cd288
commit 0aba602ecf
72 files changed
+5132 -571

No files matched your search

+154 -38
View File
@@ -14,11 +14,22 @@
// 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 } · pieceDragEnd {uid, dropped}
// 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
// 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
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y } (client px) | null
// 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).
// 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
@@ -84,6 +95,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';
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
@@ -100,6 +112,21 @@ 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;
/** 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;
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;
}
let pixiPromise = null;
@@ -894,16 +921,13 @@ export async function createFieldView(host, options = {}) {
penList = null;
}
/** Front-most pen enemy under a canvas point. */
/** The pen enemy under a canvas point (render/pick.js; the figures idle on their pen tiles). */
function penUnitAt(x, y) {
if (penHidden) return null;
let best = null, bestZ = -Infinity;
for (const v of penViews.values()) {
if (!v.hitTest(x, y)) continue;
const z = v.root ? v.root.zIndex : 0;
if (z > bestZ) { bestZ = z; best = v; }
}
return best;
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));
return hit ? hit.ref : null;
}
function emitPenClick(v, e) {
@@ -913,27 +937,100 @@ export async function createFieldView(host, options = {}) {
// ---- drag & pointer ----------------------------------------------------------------------------------------
function pieceAt(x, y) {
if (mode !== 'prep') return null;
let best = null, bestZ = -Infinity;
/** The display tile under a canvas point (raised tops first), or null off the grid. */
function groundTile(x, y) {
const t = pickTile(cam, x, y, heightAt, tiles.levels);
return t ? { row: t.row, col: t.col } : 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) {
const v = views.get(e.key);
if (!v || !v.hitTest(x, y)) continue;
const z = v.root.zIndex;
if (z > bestZ) { bestZ = z; best = e; }
}
if (!best) {
// fall back to the tile under the pointer
const t = pickBoardTile(x, y);
if (t) best = prepPieces.find((e) => { const w = pieceTile(e); return w && w.row === t.row && w.col === t.col; }) || null;
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) {
if (mode !== 'prep') return null;
const best = prepEntryAt(x, y, null, hover);
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) => pieceAt(x, y),
hitPiece: (x, y, hover) => pieceAt(x, y, !!hover),
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; }
},
@@ -947,8 +1044,10 @@ 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': startDragVisual(payload); break;
case 'pieceDragStart': dragHit = null; 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;
@@ -1012,12 +1111,15 @@ export async function createFieldView(host, options = {}) {
function moveDragVisual(p) {
if (!dragState) return;
const v = dragState.view;
const zPlane = 0.1;
const w = cam.unproject(p.x, p.y + cam.scaleAt(v.x, v.y, 0) * 0.45, zPlane);
// 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);
const z = t ? heightAt(t.row, t.col) : 0;
const w = cam.unproject(hx, hy, z);
if (w) {
v.x = Math.max(-1, Math.min(21, w.x));
v.y = Math.max(-1, Math.min(19, w.y));
v.z = zPlane;
v.z = z;
}
if (p.target && p.target.area !== 'temp') {
const r = p.target.area === 'board' ? p.target.row : GEO.HAND_ROW;
@@ -1065,15 +1167,16 @@ 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 };
}
function battleUnitAt(x, y) {
let best = null, bestZ = -Infinity;
/** 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 = [];
for (const v of views.values()) {
if (!v.hitTest || !v.alive || !v.info || v.info.kind === 'device') continue;
if (!v.hitTest(x, y)) continue;
const z = v.root ? v.root.zIndex : 0;
if (z > bestZ) { bestZ = z; best = v; }
if (!v.info || v.info.kind === 'device') continue;
const b = pickShapeOf(v, v.info.side === 'ally' ? viewTile(v) : null);
if (b) bodies.push(b);
}
return best;
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y));
return hit ? hit.ref : null;
}
const onPointerDown = (e) => {
@@ -1103,7 +1206,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);
const v = battleUnitAt(ev.x, ev.y, true);
if (v !== hoverUnit) {
hoverUnit = v;
const info = v ? infos.get(v.id) || v.info : null;
@@ -1551,7 +1654,14 @@ 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;
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top };
// 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 };
},
/**
* Screen geometry of a BOARD-space tile (in the Final Assault prep: where that board tile is drawn on the boss
@@ -1636,6 +1746,8 @@ 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;
@@ -1654,7 +1766,11 @@ export async function createFieldView(host, options = {}) {
},
get mode() { return mode; },
/** 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); } },
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; },
},
};
return view;
}
+31 -10
View File
@@ -9,14 +9,20 @@
// 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.
//
// Events emitted through `emit(name, payload)`:
// pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes)
// tileHover { row, col, area, idx } | null (hover / drag tile changes)
// tileHover { row, col, area, idx } | null (hover / drag target tile changes)
// 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, clientX, clientY, target, legal } (for the ghost; high frequency)
// pieceDragMove { uid, piece, x, y, hx, hy, clientX, clientY, target, legal } (for the ghost; high frequency;
// hx, hy = the drop point)
// pieceDrop { uid, piece, from, target }
// pieceDragEnd { uid, piece, dropped, cancelled }
@@ -90,8 +96,11 @@ const DEFAULTS = { threshold: 6, touchThreshold: 10, longPressMs: 480 };
/**
* Pointer state machine.
* @param {{
* hitPiece: (x, y) => (object|null), // piece under canvas point (front-most); piece.uid required
* 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
* 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,
@@ -112,8 +121,14 @@ 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) => { try { return h.hitPiece(x, y); } catch { return null; } };
const pieceAt = (x, y, hover = false) => { try { return h.hitPiece(x, y, hover); } 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;
@@ -140,13 +155,18 @@ 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 tile = tileAt(e.x, e.y);
const over = overCanvas(e.clientX, e.clientY);
const pt = dropAt(e, st.piece);
const tile = tileAt(pt.x, pt.y);
const over = dropOverCanvas(e, pt);
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, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
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 });
}
function endDrag(e, cancelled) {
@@ -155,8 +175,9 @@ export function createDragController(hooks) {
if (!s) return;
let dropped = false;
if (!cancelled) {
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 });
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 });
if (d.kind === 'drop') {
dropped = true;
emit('pieceDrop', { uid: s.piece.uid, piece: s.piece, from: s.from, target: d.target });
@@ -205,7 +226,7 @@ export function createDragController(hooks) {
pointerMove(e) {
if (!st) {
if (e.pointerType === 'touch') return;
setHoverPiece(pieceAt(e.x, e.y), e);
setHoverPiece(pieceAt(e.x, e.y, true), e);
setHoverTile(tileAt(e.x, e.y));
return;
}
+821 -152
View File
File diff suppressed because it is too large. Load diff
+224
View File
@@ -0,0 +1,224 @@
// 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.
/** 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,
});
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;
}
/** 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));
}
/**
* 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).
*/
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) {
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; }
}
return best;
}
/**
* The body the pointer is on (see the header for the rule), or null.
* @param {Array<PickBody|null>} bodies
* @param {number} px
* @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`
*/
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;
}
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;
}
+22 -8
View File
@@ -88,15 +88,29 @@ export function dmgStyleKey(type) {
/** Hit spark colour per damage type. */
export const HIT_TINT = Object.freeze({ phys: 0xffd9a0, arts: 0xc77dff, true: 0xffffff, heal: 0x62f08a, elem: 0xff7b3a });
/** Projectile visuals per atk projKind (sim: none|arrow|bolt|bomb|lob|orb|drone|enemy|chain|chainHeal). */
/**
* Projectile visuals per b.ev 'atk' projKind (sim: none | arrow | bolt | bomb | lob | orb | drone | enemy | boomerang;
* chain / chainHeal / beam are beams). Drawn by render/fx.js:
* look tracer (bullet streak + muzzle flash) | orb (glowing ball, particle trail) | shell (lobbed on an arc with a
* ground shadow, smoke trail) | dart (small fast bolt) | boomerang (spins to the target and back to the thrower)
* speed tiles per game second — a copy of the sim's PROJECTILE_SPEEDS (server/sim/constants.js; `back` of the
* boomerang = BOOMERANG_RETURN_SPEED, PRTS 跃跃: out 15, back 3.75). fx.js prefers the sim's own values, loaded
* from /sim/constants.js, so the visual flight ends with the sim's hit; this copy stands in until then (and in
* Node). test/render/fxproj.test.js keeps the two equal.
* sizes in tiles (× the camera's px per tile at the shot): `len` trail length, `width` trail thickness, `head` glow
* diameter; `arc` peak height of a lob over a 3-tile throw (scales with the range)
* colours `tint` hot core, `glow` halo, `trail` trail particles, `muzzle` flash at the shooter
* hit arrival burst (fx.js _impact): spark | arts | heal | boom | splash | zap | enemy
*/
export const PROJ = Object.freeze({
arrow: { speed: 14, tex: 'streak', tint: 0xfff2d0, len: 0.55, width: 0.09, trail: 0 },
bolt: { speed: 11, tex: 'orb', tint: 0xd49bff, len: 0.28, width: 0.28, trail: 0xa35cff },
orb: { speed: 11, tex: 'orb', tint: 0x9dffb8, len: 0.26, width: 0.26, trail: 0x4ee07a },
bomb: { speed: 8, tex: 'orb', tint: 0xffb066, len: 0.24, width: 0.24, trail: 0xff7a33, arc: 0.9 },
lob: { speed: 8, tex: 'orb', tint: 0xffc78a, len: 0.22, width: 0.22, trail: 0xffa04a, arc: 1.2 },
drone: { speed: 16, tex: 'orb', tint: 0x8fe6ff, len: 0.16, width: 0.16, trail: 0x57c9ff },
enemy: { speed: 12, tex: 'orb', tint: 0xff6a5a, len: 0.22, width: 0.22, trail: 0xff3b30 },
arrow: { look: 'tracer', speed: 14, tint: 0xfff6dc, glow: 0xffc45a, len: 1.05, width: 0.2, head: 0.36, muzzle: 0xffd27a, hit: 'spark' },
bolt: { look: 'orb', speed: 11, tint: 0xf4e2ff, glow: 0xb36bff, len: 0.6, width: 0.34, head: 0.52, trail: 0xa35cff, muzzle: 0xc77dff, hit: 'arts' },
orb: { look: 'orb', speed: 10, tint: 0xeafff0, glow: 0x3fe07a, len: 0.55, width: 0.3, head: 0.48, trail: 0x62f08a, muzzle: 0x62f08a, hit: 'heal' },
bomb: { look: 'shell', speed: 8, tint: 0xffeed0, glow: 0xff8a3d, len: 0.85, width: 0.3, head: 0.56, trail: 0xff9c4a, arc: 1.1, smoke: 0x2e2824, muzzle: 0xffb35c, hit: 'boom' },
lob: { look: 'shell', speed: 8, tint: 0xfff4dc, glow: 0xffb04a, len: 0.7, width: 0.26, head: 0.5, trail: 0xffc27a, arc: 1.4, smoke: 0x3a322c, muzzle: 0xffc27a, hit: 'splash' },
drone: { look: 'dart', speed: 16, tint: 0xe4fbff, glow: 0x57c9ff, len: 0.7, width: 0.16, head: 0.3, trail: 0x57c9ff, hit: 'zap' },
enemy: { look: 'orb', speed: 10, tint: 0xffe2da, glow: 0xff3b30, len: 0.55, width: 0.28, head: 0.44, trail: 0xff4a3a, muzzle: 0xff6a5a, hit: 'enemy' },
boomerang: { look: 'boomerang', speed: 15, back: 3.75, tint: 0xfff4d6, glow: 0x9ff0dc, len: 0.4, width: 0.3, head: 0.5, trail: 0x9ff0dc, hit: 'spark' },
});
/** Status keys (b.ev 'status' + UF flags) → icon atlas key (render/textures.js) and colour. */
+160 -50
View File
@@ -600,11 +600,21 @@ export function groundTexture(img = null, crop = null) {
// =============================================================================================================
// FX atlas
const FX_SIZE = 512;
const FX_W = 1024, FX_H = 512;
/** Status icons: one 32 px row at the bottom of the FX atlas (frames `st_<key>`). */
const STATUS_ROW_Y = 448;
let _fx = null;
/**
* FX atlas frames — name: [x, y, w, h, draw(ctx, x, y, w, h)]. Every frame owns its cell: no two frames share a pixel
* (the light pillar once ran into the status-icon row, so every skill / deploy pillar carried the 'silence' and 'slow'
* icons at its foot) and each drawing stays off its cell's outer pixel (mipmapped sampling of a small sprite must not
* pull in a neighbour). Frame sizes are part of the API — sprites are scaled by them: glow / soft / ring / hex / shock /
* reticle / smoke / flare 128², spark / plus / orb / chevron / boomerang / muzzle 64², coin 48², dot / shard / square
* 32², streak / tracer / bolt 128×32 (head at the right), slash 128×64 (arc bulging up), pillar 64×256 (foot at the
* bottom).
*/
const FX_DRAW = {
// name: [x, y, w, h, draw(ctx, x, y, w, h)]
glow: [0, 0, 128, 128, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.25, 'rgba(255,255,255,0.6)'], [1, 'rgba(255,255,255,0)']]); }],
soft: [128, 0, 128, 128, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,0.8)'], [1, 'rgba(255,255,255,0)']]); }],
ring: [256, 0, 128, 128, (c, x, y, w) => {
@@ -625,68 +635,154 @@ const FX_DRAW = {
c.stroke(); c.globalAlpha = 1;
for (let k = 0; k < 6; k++) { const a = (k * Math.PI) / 3; c.fillStyle = '#fff'; c.fillRect(cx + Math.cos(a) * 60 - 3, cy + Math.sin(a) * 60 - 3, 6, 6); }
}],
spark: [0, 128, 64, 64, (c, x, y, w) => {
// a thin sharp ring with a faint wake inside: shockwaves (skill bursts, explosions, the bombard warning)
shock: [512, 0, 128, 128, (c, x, y, w) => {
radial(c, x + w / 2, y + w / 2, w / 2 - 1, [[0, 'rgba(255,255,255,0)'], [0.52, 'rgba(255,255,255,0)'], [0.8, 'rgba(255,255,255,0.16)'],
[0.9, 'rgba(255,255,255,1)'], [0.96, 'rgba(255,255,255,0.3)'], [1, 'rgba(255,255,255,0)']]);
}],
// lock-on reticle: circle, cross ticks, corner brackets, centre dot (蕾缪安 locks, marks)
reticle: [640, 0, 128, 128, (c, x, y, w) => {
const cx = x + w / 2, cy = y + w / 2;
c.strokeStyle = '#fff'; c.lineCap = 'round';
c.lineWidth = 5; c.beginPath(); c.arc(cx, cy, 36, 0, Math.PI * 2); c.stroke();
c.lineWidth = 2; c.globalAlpha = 0.55; c.beginPath(); c.arc(cx, cy, 27, 0, Math.PI * 2); c.stroke(); c.globalAlpha = 1;
c.lineWidth = 5;
for (let k = 0; k < 4; k++) {
const a = (k * Math.PI) / 2, ca = Math.cos(a), sa = Math.sin(a);
c.beginPath(); c.moveTo(cx + ca * 22, cy + sa * 22); c.lineTo(cx + ca * 50, cy + sa * 50); c.stroke();
}
c.lineWidth = 4;
for (let k = 0; k < 4; k++) {
const sx = k & 1 ? 1 : -1, sy = k & 2 ? 1 : -1, bx = cx + sx * 52, by = cy + sy * 52;
c.beginPath(); c.moveTo(bx - sx * 14, by); c.lineTo(bx, by); c.lineTo(bx, by - sy * 14); c.stroke();
}
c.fillStyle = '#fff'; c.beginPath(); c.arc(cx, cy, 4, 0, Math.PI * 2); c.fill();
}],
smoke: [768, 0, 128, 128, (c, x, y) => {
const r = rng(77);
for (let i = 0; i < 14; i++) radial(c, x + 30 + r() * 68, y + 30 + r() * 68, 18 + r() * 22, [[0, 'rgba(255,255,255,0.22)'], [1, 'rgba(255,255,255,0)']]);
}],
// star flare: hot centre, four long and four short rays (skill flash, explosion core, impacts)
flare: [896, 0, 128, 128, (c, x, y, w) => {
const cx = x + w / 2, cy = y + w / 2;
radial(c, cx, cy, w / 2 - 1, [[0, 'rgba(255,255,255,0.95)'], [0.12, 'rgba(255,255,255,0.55)'], [0.4, 'rgba(255,255,255,0.1)'], [1, 'rgba(255,255,255,0)']]);
const ray = (a, len, half) => {
c.save(); c.translate(cx, cy); c.rotate(a);
const g = c.createLinearGradient(0, 0, len, 0);
g.addColorStop(0, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g;
c.beginPath(); c.moveTo(0, -half); c.lineTo(len, 0); c.lineTo(0, half); c.closePath(); c.fill();
c.restore();
};
for (let k = 0; k < 4; k++) ray((k * Math.PI) / 2, 60, 5);
for (let k = 0; k < 4; k++) ray(Math.PI / 4 + (k * Math.PI) / 2, 30, 3);
}],
pillar: [0, 128, 64, 256, (c, x, y, w, h) => {
const g = c.createLinearGradient(x, 0, x + w, 0);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g; c.fillRect(x, y + 1, w, h - 2);
const v = c.createLinearGradient(0, y, 0, y + h);
v.addColorStop(0, 'rgba(0,0,0,1)'); v.addColorStop(0.35, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(0,0,0,0)');
c.globalCompositeOperation = 'destination-out'; c.fillStyle = v; c.fillRect(x, y, w, h); c.globalCompositeOperation = 'source-over';
}],
spark: [64, 128, 64, 64, (c, x, y, w) => {
const cx = x + w / 2, cy = y + w / 2;
radial(c, cx, cy, w / 2, [[0, 'rgba(255,255,255,0.9)'], [0.2, 'rgba(255,255,255,0.35)'], [1, 'rgba(255,255,255,0)']]);
c.fillStyle = 'rgba(255,255,255,1)';
c.beginPath(); c.moveTo(cx, y + 2); c.lineTo(cx + 3, cy - 3); c.lineTo(x + w - 2, cy); c.lineTo(cx + 3, cy + 3); c.lineTo(cx, y + w - 2);
c.lineTo(cx - 3, cy + 3); c.lineTo(x + 2, cy); c.lineTo(cx - 3, cy - 3); c.closePath(); c.fill();
}],
plus: [64, 128, 64, 64, (c, x, y, w) => {
plus: [128, 128, 64, 64, (c, x, y, w) => {
const cx = x + w / 2, cy = y + w / 2;
radial(c, cx, cy, w / 2, [[0, 'rgba(255,255,255,0.5)'], [1, 'rgba(255,255,255,0)']]);
c.fillStyle = '#fff';
c.fillRect(cx - 5, cy - 18, 10, 36); c.fillRect(cx - 18, cy - 5, 36, 10);
}],
orb: [128, 128, 64, 64, (c, x, y, w) => {
orb: [192, 128, 64, 64, (c, x, y, w) => {
radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.3, 'rgba(255,255,255,0.85)'], [0.55, 'rgba(255,255,255,0.3)'], [1, 'rgba(255,255,255,0)']]);
}],
dot: [192, 128, 32, 32, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.5, 'rgba(255,255,255,0.7)'], [1, 'rgba(255,255,255,0)']]); }],
shard: [224, 128, 32, 32, (c, x, y, w) => {
c.fillStyle = '#fff'; c.beginPath(); c.moveTo(x + w / 2, y + 1); c.lineTo(x + w - 8, y + w / 2); c.lineTo(x + w / 2, y + w - 1); c.lineTo(x + 8, y + w / 2); c.closePath(); c.fill();
}],
streak: [0, 192, 128, 32, (c, x, y, w, h) => {
const g = c.createLinearGradient(x, 0, x + w, 0);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.7, 'rgba(255,255,255,0.7)'); g.addColorStop(1, 'rgba(255,255,255,1)');
c.fillStyle = g;
c.beginPath(); c.moveTo(x, y + h / 2 - 1); c.lineTo(x + w - 10, y + h / 2 - 5); c.lineTo(x + w, y + h / 2); c.lineTo(x + w - 10, y + h / 2 + 5); c.lineTo(x, y + h / 2 + 1); c.closePath(); c.fill();
}],
slash: [128, 192, 128, 64, (c, x, y, w, h) => {
c.save(); c.beginPath(); c.rect(x, y, w, h); c.clip();
const g = c.createLinearGradient(x, y, x + w, y);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g;
c.beginPath(); c.ellipse(x + w / 2, y + h * 1.3, w * 0.48, h * 1.05, 0, Math.PI, 0); c.ellipse(x + w / 2, y + h * 1.45, w * 0.44, h * 1.05, 0, 0, Math.PI, true); c.fill();
c.restore();
}],
pillar: [256, 128, 64, 256, (c, x, y, w, h) => {
const g = c.createLinearGradient(x, 0, x + w, 0);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g; c.fillRect(x, y, w, h);
const v = c.createLinearGradient(0, y, 0, y + h);
v.addColorStop(0, 'rgba(0,0,0,1)'); v.addColorStop(0.35, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(0,0,0,0)');
c.globalCompositeOperation = 'destination-out'; c.fillStyle = v; c.fillRect(x, y, w, h); c.globalCompositeOperation = 'source-over';
}],
smoke: [320, 128, 128, 128, (c, x, y, w) => {
const r = rng(77);
for (let i = 0; i < 14; i++) radial(c, x + 30 + r() * 68, y + 30 + r() * 68, 18 + r() * 22, [[0, 'rgba(255,255,255,0.22)'], [1, 'rgba(255,255,255,0)']]);
}],
chevron: [448, 128, 64, 64, (c, x, y, w) => {
chevron: [256, 128, 64, 64, (c, x, y) => {
c.fillStyle = '#fff';
c.beginPath(); c.moveTo(x + 14, y + 8); c.lineTo(x + 30, y + 8); c.lineTo(x + 52, y + 32); c.lineTo(x + 30, y + 56); c.lineTo(x + 14, y + 56); c.lineTo(x + 36, y + 32); c.closePath(); c.fill();
}],
bolt: [0, 256, 128, 32, (c, x, y, w, h) => {
const g = c.createLinearGradient(0, y, 0, y + h);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g; c.fillRect(x, y, w, h);
// 回环射手 boomerang: two curved arms meeting at an elbow, centred on the cell (it spins about the centre)
boomerang: [320, 128, 64, 64, (c, x, y, w) => {
c.save(); c.translate(x + w / 2, y + w / 2 + 3);
c.beginPath();
c.moveTo(0, -21);
c.quadraticCurveTo(19, -17, 27, 14); c.quadraticCurveTo(21, 18, 14, 11); c.quadraticCurveTo(8, -5, 0, -8);
c.quadraticCurveTo(-8, -5, -14, 11); c.quadraticCurveTo(-21, 18, -27, 14); c.quadraticCurveTo(-19, -17, 0, -21);
c.closePath();
c.fillStyle = 'rgba(255,255,255,0.9)'; c.fill();
c.lineWidth = 2.5; c.lineJoin = 'round'; c.strokeStyle = '#fff'; c.stroke();
c.restore();
}],
square: [128, 256, 32, 32, (c, x, y, w) => { c.fillStyle = '#fff'; c.fillRect(x + 1, y + 1, w - 2, w - 2); }],
coin: [160, 256, 48, 48, (c, x, y, w) => {
// muzzle flash pointing right (+x) from a hot spot at (12, 32): sprite anchor (0.19, 0.5), rotated to the shot
muzzle: [384, 128, 64, 64, (c, x, y, w, h) => {
const cx = x + 12, cy = y + h / 2;
radial(c, cx, cy, 11, [[0, 'rgba(255,255,255,1)'], [0.5, 'rgba(255,255,255,0.6)'], [1, 'rgba(255,255,255,0)']]);
const g = c.createLinearGradient(cx, 0, x + w - 2, 0);
g.addColorStop(0, 'rgba(255,255,255,0.95)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g;
c.beginPath(); c.moveTo(cx, cy - 7); c.quadraticCurveTo(cx + 26, cy - 12, x + w - 2, cy); c.quadraticCurveTo(cx + 26, cy + 12, cx, cy + 7); c.closePath(); c.fill();
c.beginPath(); c.moveTo(cx + 2, cy - 3); c.lineTo(cx + 18, cy - 19); c.lineTo(cx + 9, cy); c.closePath(); c.fill();
c.beginPath(); c.moveTo(cx + 2, cy + 3); c.lineTo(cx + 18, cy + 19); c.lineTo(cx + 9, cy); c.closePath(); c.fill();
}],
coin: [448, 128, 48, 48, (c, x, y, w) => {
const cx = x + w / 2, cy = y + w / 2;
c.fillStyle = '#ffc600'; c.beginPath(); c.arc(cx, cy, 20, 0, Math.PI * 2); c.fill();
c.fillStyle = '#fff2a8'; c.beginPath(); c.arc(cx - 4, cy - 4, 11, 0, Math.PI * 2); c.fill();
c.fillStyle = '#c98f00'; c.font = 'bold 22px sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText('$', cx, cy + 1);
}],
dot: [512, 128, 32, 32, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.5, 'rgba(255,255,255,0.7)'], [1, 'rgba(255,255,255,0)']]); }],
shard: [544, 128, 32, 32, (c, x, y, w) => {
c.fillStyle = '#fff'; c.beginPath(); c.moveTo(x + w / 2, y + 1); c.lineTo(x + w - 8, y + w / 2); c.lineTo(x + w / 2, y + w - 1); c.lineTo(x + 8, y + w / 2); c.closePath(); c.fill();
}],
square: [576, 128, 32, 32, (c, x, y, w) => { c.fillStyle = '#fff'; c.fillRect(x + 1, y + 1, w - 2, w - 2); }],
// melee swing: a thick crescent (arc bulging up), hot in the middle, tips fading, a bright rim
slash: [640, 128, 128, 64, (c, x, y, w, h) => {
const cx = x + w / 2;
const g = c.createLinearGradient(x, 0, x + w, 0);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.28, 'rgba(255,255,255,0.8)'); g.addColorStop(0.55, 'rgba(255,255,255,1)');
g.addColorStop(0.82, 'rgba(255,255,255,0.7)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.save(); c.beginPath(); c.rect(x + 1, y + 1, w - 2, h - 2); c.clip();
c.beginPath();
c.ellipse(cx, y + h * 1.2, w * 0.47, h, 0, Math.PI, 0);
c.ellipse(cx, y + h * 1.42, w * 0.43, h, 0, 0, Math.PI, true);
c.closePath();
c.globalAlpha = 0.85; c.fillStyle = g; c.fill(); c.globalAlpha = 1;
c.lineWidth = 3; c.strokeStyle = g;
c.beginPath(); c.ellipse(cx, y + h * 1.2, w * 0.47, h, 0, Math.PI * 1.06, Math.PI * 1.94); c.stroke();
c.restore();
}],
// soft trail (orbs, shells, dashes): tapered band with a soft cross-section, transparent tail at the left
streak: [768, 128, 128, 32, (c, x, y, w, h) => {
const cy = y + h / 2;
const v = c.createLinearGradient(0, cy - 10, 0, cy + 10);
v.addColorStop(0, 'rgba(255,255,255,0)'); v.addColorStop(0.5, 'rgba(255,255,255,0.95)'); v.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = v;
c.beginPath(); c.moveTo(x + 1, cy - 2); c.lineTo(x + w - 14, cy - 10); c.quadraticCurveTo(x + w - 1, cy, x + w - 14, cy + 10); c.lineTo(x + 1, cy + 2); c.closePath(); c.fill();
c.fillStyle = '#fff';
c.beginPath(); c.moveTo(x + 1, cy - 0.5); c.lineTo(x + w - 10, cy - 2.5); c.lineTo(x + w - 4, cy); c.lineTo(x + w - 10, cy + 2.5); c.lineTo(x + 1, cy + 0.5); c.closePath(); c.fill();
fadeTail(c, x, y, w, h, 0.6);
}],
// bullet tracer: glow band + hot core line + bright head at the right
tracer: [768, 160, 128, 32, (c, x, y, w, h) => {
const cy = y + h / 2;
const v = c.createLinearGradient(0, y + 1, 0, y + h - 1);
v.addColorStop(0, 'rgba(255,255,255,0)'); v.addColorStop(0.32, 'rgba(255,255,255,0.22)'); v.addColorStop(0.5, 'rgba(255,255,255,0.75)');
v.addColorStop(0.68, 'rgba(255,255,255,0.22)'); v.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = v; c.fillRect(x + 1, y + 1, w - 10, h - 2);
c.fillStyle = '#fff';
c.beginPath(); c.moveTo(x + 1, cy - 0.8); c.lineTo(x + w - 10, cy - 2.6); c.lineTo(x + w - 10, cy + 2.6); c.lineTo(x + 1, cy + 0.8); c.closePath(); c.fill();
fadeTail(c, x, y, w, h, 0.45);
radial(c, x + w - 10, cy, 9, [[0, 'rgba(255,255,255,1)'], [0.45, 'rgba(255,255,255,0.75)'], [1, 'rgba(255,255,255,0)']]);
}],
bolt: [896, 128, 128, 32, (c, x, y, w, h) => {
const g = c.createLinearGradient(0, y, 0, y + h);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g; c.fillRect(x + 1, y, w - 2, h);
}],
};
function radial(c, cx, cy, r, stops) {
@@ -696,27 +792,41 @@ function radial(c, cx, cy, r, stops) {
c.fillRect(cx - r, cy - r, r * 2, r * 2);
}
/** Fade a horizontal frame towards its tail (left edge transparent → `mid` alpha at 55 % → opaque head at the right). */
function fadeTail(c, x, y, w, h, mid) {
c.globalCompositeOperation = 'destination-in';
const g = c.createLinearGradient(x, 0, x + w - 8, 0);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.55, `rgba(255,255,255,${mid})`); g.addColorStop(1, 'rgba(255,255,255,1)');
c.fillStyle = g; c.fillRect(x, y, w, h);
c.globalCompositeOperation = 'source-over';
}
/** The FX atlas layout (no canvas needed): `{ size: [w, h], frames: { name: [x, y, w, h] } }` incl. `st_<key>` icons. */
export function fxFrames() {
const frames = {};
for (const [name, [x, y, w, h]] of Object.entries(FX_DRAW)) frames[name] = [x, y, w, h];
STATUS_KEYS.forEach((k, i) => { frames['st_' + k] = [(i % 32) * 32, STATUS_ROW_Y + Math.floor(i / 32) * 32, 32, 32]; });
return { size: [FX_W, FX_H], frames };
}
/** FX atlas: `{ base, tex: { [name]: PIXI.Texture } }` (all frames share one base texture). */
export function fxAtlas() {
if (_fx) return _fx;
const P = PIXI();
const canvas = makeCanvas(FX_SIZE, FX_SIZE);
const canvas = makeCanvas(FX_W, FX_H);
const ctx = canvas.getContext('2d');
const frames = {};
const { frames } = fxFrames();
for (const [name, [x, y, w, h, draw]] of Object.entries(FX_DRAW)) {
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
try { draw(ctx, x, y, w, h); } catch { /* keep blank */ }
ctx.restore();
frames[name] = [x, y, w, h];
}
// status icons row (32 px each) at y = 320
STATUS_KEYS.forEach((k, i) => {
const x = (i % 16) * 32, y = 320 + Math.floor(i / 16) * 32;
for (const k of STATUS_KEYS) {
const [x, y] = frames['st_' + k];
ctx.save(); ctx.beginPath(); ctx.rect(x, y, 32, 32); ctx.clip();
try { drawStatusIcon(ctx, k, x, y, 32); } catch { /* ignore */ }
ctx.restore();
frames['st_' + k] = [x, y, 32, 32];
});
}
const base = P.BaseTexture.from(canvas, { mipmap: P.MIPMAP_MODES.ON });
const tex = {};
for (const [name, [x, y, w, h]] of Object.entries(frames)) tex[name] = new P.Texture(base, new P.Rectangle(x, y, w, h));
+67 -12
View File
@@ -22,12 +22,18 @@
// pen: idle loops only) animates through the impostor atlas every 3rd frame; under the view's adaptive load level
// (`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).
import { UF, ANIM } from '../../../shared/constants.js';
import { SpineActor } from './spine.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture } 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'];
@@ -137,6 +143,9 @@ 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());
@@ -397,6 +406,7 @@ 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;
@@ -514,6 +524,7 @@ 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);
@@ -558,6 +569,8 @@ 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();
@@ -693,6 +706,16 @@ 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);
@@ -858,18 +881,39 @@ export class UnitView {
return out;
}
/** Canvas-space bounds (CSS px) of the body. */
/** 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). */
bounds() {
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 };
return bodyBounds({ ...this.pickShape(), hud: null });
}
hitTest(x, y) {
if (!this.alive || this.alpha < 0.05) return false;
const b = this.bounds();
return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
return hitBody(this.pickShape(), x, y) > 0;
}
setHover(on) { this.hovered = !!on; }
@@ -921,7 +965,10 @@ function defaultInterval(ctx, info) {
// =============================================================================================================
/** Hand item: icon plate floating above its slot. */
/**
* 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.
*/
export class ItemView {
constructor(ctx, info) {
const P = ctx.P;
@@ -946,21 +993,29 @@ export class ItemView {
}
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
update(dt, cam, t) {
const p = cam.project(this.x, this.y, this.z + this.lift + 0.12 + Math.sin(t * 2 + this.bob) * 0.03, this.screen);
const lifted = this.lift > 0;
const p = cam.project(this.x, this.y, lifted ? this.z : this.z + this.lift + 0.12 + Math.sin(t * 2 + this.bob) * 0.03, this.screen);
this.root.position.set(p.x, p.y);
this.root.zIndex = unitDepthKey(cam, this.x, this.y, this.lift);
const size = p.s * 0.62;
this.plate.scale.set(size / 128);
this.plate.anchor.set(0.5, lifted ? 0.5 : 1);
this.root.alpha = this.dimmed ? 0.35 : 1;
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z);
const sh = cam.project(this.x, this.y, this.z, SH_P);
this.shadow.position.set(sh.x, sh.y);
this.shadow.scale.set((p.s * 0.55) / this.shadow.texture.width);
this.shadow.alpha = 0.35;
this.screen.top = p.y - size;
this.screen.top = lifted ? p.y - size / 2 : p.y - size;
}
bounds() { const s = this.screen.s; const w = s * 0.62; return { x: this.screen.x - w / 2, y: this.screen.y - w, width: w, height: w }; }
hitTest(x, y) { const b = this.bounds(); return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height; }
/** 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 };
}
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 }); }
}