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

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