feedback1f WV: the client draws a knocked-out operator on the tile it lies on

render/interp.js keeps the body tile of a b.snap `down` entry (row, col; only
when both are integers, so a 4-element entry is unchanged) and
render/units.js setDown moves the knocked-down view and its ring there: an
operator that fell on another piece's home lies on its own home (PRTS
卫戍协议/帮助 "…尝试返回其自身的初始位置"), and a view built from the unit's
deploy info (a field joined mid-battle) no longer stays on its first tile.

Tests: test/render/feedback1f-down-tile.test.js (fake PIXI, the sim's own
snapshot); opt-in test/render/feedback1f-down-tile.browser.test.js (real sim
frames in headless Chrome: drawn, picked and redeployed on its own home).
This commit is contained in:
sganggs committed 2026-10-03 07:16:34 +08:00
1 parent 5a3d562b75
commit 8de9002d1a
4 files changed
+198 -11

No files matched your search

+7 -4
View File
@@ -23,8 +23,9 @@
// * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's
// normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older
// snapshot, like flags);
// * `down` [[id, respawnAt, respawnTime, state]] — knocked-out operators waiting to redeploy (they are no longer in
// `units`): downAt(time) returns the list of the snapshot at `time`.
// * `down` [[id, respawnAt, respawnTime, state, row?, col?]] — knocked-out operators waiting to redeploy (they are no
// longer in `units`) and the tile they lie on (where they fell, or their home — sim Battle._layBody; kept only when
// both are integers): downAt(time) returns the list of the snapshot at `time`.
// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT.
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8, EL: 9, EL_FILL: 10, EL_UNTIL: 11, EL_DUR: 12 });
@@ -52,7 +53,7 @@ export function frameTime(msg) {
/**
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, down: [[id, respawnAt, respawnTime,
* state]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
* state, row?, col?]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
* to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped.
*/
export function normalizeSnapshot(snap) {
@@ -80,7 +81,9 @@ export function normalizeSnapshot(snap) {
if (Array.isArray(snap.down)) {
for (const d of snap.down) {
if (!Array.isArray(d) || !(typeof d[0] === 'number' || typeof d[0] === 'string')) continue;
(down || (down = [])).push([d[0], finite(d[1]), Math.max(0, finite(d[2])), finite(d[3]) | 0]);
const e = [d[0], finite(d[1]), Math.max(0, finite(d[2])), finite(d[3]) | 0];
if (Number.isInteger(d[4]) && Number.isInteger(d[5])) e.push(d[4], d[5]);
(down || (down = [])).push(e);
}
}
return { t, units, down, raw: snap };
+14 -7
View File
@@ -16,8 +16,9 @@
// plane). `setDir(dir)` re-orients a live view (swapping Front ⇄ Back without a fallback flash). Enemies flip by the
// sign of their horizontal velocity (with hysteresis).
//
// Knocked-out operators (user playtest #4 item 9, b.snap `down`): `setDown([id, respawnAt, respawnTime, state])`
// keeps a dead operator on its tile in its knocked-down pose — the Spine Die clip played once and held on its last
// Knocked-out operators (user playtest #4 item 9, b.snap `down`): `setDown([id, respawnAt, respawnTime, state, row,
// col])` keeps a dead operator on the tile it lies on (row / col: where it fell, or its home — sim Battle._layBody,
// player report F5 after 0.1.0) in its knocked-down pose — the Spine Die clip played once and held on its last
// frame (the collapsed / kneeling pose with closed eyes), slightly greyed — with a redeploy ring above its head:
// a dark disc, a mint arc filling as the respawn timer runs and the seconds left; once the timer is done and it still
// waits, a full amber ring with "DP" (not enough DP) or a red ring with "!" (its tile is taken). `onDeploy` (the
@@ -589,11 +590,13 @@ export class UnitView {
}
/**
* Knocked-out state (b.snap `down` entry `[id, respawnAt, respawnTime, state]`, render/interp.js downAt) or null;
* `t` = the render clock's game time. A living view is knocked down first (its Die clip plays; `instant`: a view made
* for a unit that is already down — a field joined mid-battle — starts on the held end of the clip). While down the
* view never fades: the Die clip's last frame stays on the tile under the redeploy ring. null on a view still down
* (it left for good) starts the fade; the redeploy itself comes through onDeploy / revive.
* Knocked-out state (b.snap `down` entry `[id, respawnAt, respawnTime, state, row?, col?]`, render/interp.js downAt)
* or null; `t` = the render clock's game time. A living view is knocked down first (its Die clip plays; `instant`: a
* view made for a unit that is already down — a field joined mid-battle — starts on the held end of the clip). While
* down the view never fades: the Die clip's last frame stays on the tile the entry names (the view moves there when
* it stands elsewhere: a body that went back to its home, a view built from the unit's deploy info) under the
* redeploy ring. null on a view still down (it left for good) starts the fade; the redeploy itself comes through
* onDeploy / revive.
*/
setDown(d, t, instant = false) {
if (Number.isFinite(t)) this.gameT = t;
@@ -604,6 +607,10 @@ export class UnitView {
return;
}
if (this.alive) this.die(instant);
if (Number.isInteger(d[4]) && Number.isInteger(d[5]) && (this.x !== d[5] || this.y !== d[4])) {
this.x = d[5]; this.y = d[4];
this.z = this.zTarget = groundZ(this.ctx, this.x, this.y);
}
if (this.zTarget == null) this.z = this.zTarget = groundZ(this.ctx, this.x, this.y); // never synced: its tile top
this.dying = 0; // no death fade while down
const dn = this.down || (this.down = { until: 0, total: 0, state: DOWN_STATE.COUNTING });