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;
}