feedback1 WD: form changes survive the client's catch-up frames, hidden tab and pre-entry buffer

Every setForm fx now carries 'form' (shared/protocol.js fxForm). The runner keeps
these fx in catch-up frames (keepsState), holds the state-bearing events of the
battle on screen while the tab is hidden and delivers them with the first frame
back (re-entering from the field meta past HELD_MAX); screens/game.js buffers
them before a field is entered. A husk beaten while its stand-up clip plays dies
on the husk's clip. 幽灵 form cited from PRTS; 特战术师 flyer question recorded.
This commit is contained in:
sganggs committed 2026-10-03 04:27:24 +08:00
1 parent e1ce0316dd
commit 2591b8638e
11 files changed
+381 -46

No files matched your search

+6 -5
View File
@@ -203,11 +203,12 @@ A skill clip may also come from directional-only animations when a model has no
The resolver's full precedence list is in the header of `tools/assets/anim-roles.mjs`. The resolver's full precedence list is in the header of `tools/assets/anim-roles.mjs`.
The manifest roles describe an enemy's first form. Enemies whose skeleton holds another form's clip set get it from The manifest roles describe an enemy's first form. Enemies whose skeleton holds another form's clip set get it from
`public/js/render/units.js FORMS` (keyed by Spine id, switched by the sim's 'phase' fx kind or the `form` of its `public/js/render/units.js FORMS` (keyed by Spine id, switched by the `form` of the sim's 'phase' / 'ember' / 'revive'
'ember' / 'revive' / 'telegraph' / 'stone' fx — or, for a view built mid-battle, UnitInfo `form`, which / 'telegraph' / 'stone' fx — `shared/protocol.js fxForm`; the client runner keeps these fx through catch-up frames and
`render/app.js renderInfo` passes to the view; a `change` clip plays once first, an `end` clip is timed from the fx's hidden tabs — or, for a view built mid-battle, UnitInfo `form`, which `render/app.js renderInfo` passes to the view; a
`dur` to finish as that state ends). A blocked or revealed 隐匿 enemy is drawn solid: the sim sends the stealth bit only `change` clip plays once first, an `end` clip is timed from the fx's `dur` to finish as that state ends, keeping the
while its 隐匿 is on: current form's death clip until the next form's fx). A blocked or revealed 隐匿 enemy is drawn solid: the sim sends the
stealth bit only while its 隐匿 is on:
- 掠海漂移体's crawl (`Change`, then `*_02`); - 掠海漂移体's crawl (`Change`, then `*_02`);
- 转译基底·α's three forms (`A_Die_B` / `_C` / `_D`, 2 s each, then `B_*` 寻仇者, `C_*` 幽灵, `D_*` 特战术师); - 转译基底·α's three forms (`A_Die_B` / `_C` / `_D`, 2 s each, then `B_*` 寻仇者, `C_*` 幽灵, `D_*` 特战术师);
- the 深池逐火 embers (`Die`, then `Idle_2` / `Move_2` / `Die_2`; `Revive` ends as it stands up) and 假想敌:再生's puppet - the 深池逐火 embers (`Die`, then `Idle_2` / `Move_2` / `Die_2`; `Revive` ends as it stands up) and 假想敌:再生's puppet
+8 -4
View File
@@ -245,9 +245,12 @@ still standing after `Revive[Trigger].interval` s stands up again with full HP),
instance or a block starts a 2 s change). Each form change goes through `setForm(b, e, form, fxKind, params)`: the instance or a block starts a 2 s change). Each form change goes through `setForm(b, e, form, fxKind, params)`: the
unit keeps it (`e.form`, published as UnitInfo `form`, so a view built mid-battle from `fieldMeta()` — a watched unit keeps it (`e.form`, published as UnitInfo `form`, so a view built mid-battle from `fieldMeta()` — a watched
teammate's field, 联防 observers, a reconnect — starts in it: `render/app.js renderInfo` hands it to the view) and the teammate's field, 联防 observers, a reconnect — starts in it: `render/app.js renderInfo` hands it to the view) and the
fx announces it: the `kind` of a form 'phase' fx fx announces it as its `form`: a 'phase' fx (crawl, translator_* — also its `kind`), 'ember' ('husk'), 'revive'
(crawl, translator_*), else the `form` of the 'ember' ('husk'), 'revive' ('revived' / 'form2' / 'fly'), 'telegraph' ('revived' / 'form2' / 'fly'), 'telegraph' ('reborn') or 'stone' ('stone') — render/units.js FORMS. Barrier / charge
('reborn') or 'stone' ('stone') fx — render/units.js FORMS. Barrier / charge 'phase' kinds are no forms. 'phase' kinds carry no `form` and are no forms. A form is state, not decoration (b.snap tuples carry none): the client
keeps every fx with a `form` (`shared/protocol.js fxForm`) where it drops other events — the runner's catch-up frames
and its hidden-tab backlog (`battle/runner.js keepsState`), the game screen's events buffered before a field is entered
(`screens/game.js`); dropping them was report #5's look again after a stall or a background tab.
**Ownership** (`enemy.ownerId`, used for `killed/total` and leak attribution): `ownerPlayerId` if given, else the **Ownership** (`enemy.ownerId`, used for `killed/total` and leak attribution): `ownerPlayerId` if given, else the
player whose half contains the spawn tile (cols ≥ 11 = right half / player with colOffset 8 or side R). A leak is player whose half contains the spawn tile (cols ≥ 11 = right half / player with colOffset 8 or side R). A leak is
@@ -978,7 +981,8 @@ Unknown subprofessions fall back to the profession default (test `professions.te
renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`; an enemy's `profile.shot` may name another kind, renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`; an enemy's `profile.shot` may name another kind,
e.g. `mortar` for 帝国炮火先兆者, which the renderer does not draw — its fx `bombardShell` is the shell), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`), e.g. `mortar` for 帝国炮火先兆者, which the renderer does not draw — its fx `bombardShell` is the shell), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`),
`['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`, `['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`,
`['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing), `['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing;
`extra.form` = the enemy's model form from then on, `content/enemies.js setForm` / `shared/protocol.js fxForm`),
`['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`. `['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`.
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's current model form — `content/enemies.js setForm` — so a view built mid-battle from `fieldMeta()` starts on that clip set) - `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's current model form — `content/enemies.js setForm` — so a view built mid-battle from `fieldMeta()` starts on that clip set)
(`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
+38 -4
View File
@@ -13,6 +13,11 @@
// and b.result (compactResult) at the end; applies b.pool (LocalBossPool.sync) and b.end (forceEnd / takeover), // and b.result (compactResult) at the end; applies b.pool (LocalBossPool.sync) and b.end (forceEnd / takeover),
// * keeps an authoritative battle running while the tab is hidden (a 250 ms interval pump; browsers throttle it to // * keeps an authoritative battle running while the tab is hidden (a 250 ms interval pump; browsers throttle it to
// ~1 Hz, the bounded fast-forward absorbs that); the server deadline + takeover cover anything worse. // ~1 Hz, the bounded fast-forward absorbs that); the server deadline + takeover cover anything worse.
// A frame that fast-forwards (catch-up) passes on only the state-bearing events (keepsState: spawns, deaths, deploys,
// statuses, skills, leaks and the fx that change an enemy's model form — shared/protocol.js fxForm); while the tab is
// hidden the battle on screen keeps the same events (≤ HELD_MAX, else the view re-enters from the field meta) and the
// first frame back delivers them, so the view still knows every unit and every enemy's current form (player report #5
// after 0.1.0: a 转译基底·α that changed form during a stall or in a background tab died in its first-form model).
// Display replicas (a teammate's field after the own battle, 联防 observers, the partner of a boss pair) run the same // Display replicas (a teammate's field after the own battle, 联防 observers, the partner of a boss pair) run the same
// spec fast-forwarded to the server's clock (`elapsed`) and never report. // spec fast-forwarded to the server's clock (`elapsed`) and never report.
// A b.result lost with the socket (the request failed DISCONNECTED / OFFLINE, or timed out twice) is kept and sent // A b.result lost with the socket (the request failed DISCONNECTED / OFFLINE, or timed out twice) is kept and sent
@@ -53,7 +58,7 @@
import { net as appNet } from '../net.js'; import { net as appNet } from '../net.js';
import { store as appStore } from '../store.js'; import { store as appStore } from '../store.js';
import { unitStatsEntry } from '../../../shared/protocol.js'; import { unitStatsEntry, fxForm } from '../../../shared/protocol.js';
const TICK = 1 / 30; const TICK = 1 / 30;
/** Fast-forward budget per frame (ticks) when far behind. */ /** Fast-forward budget per frame (ticks) when far behind. */
@@ -62,6 +67,13 @@ export const CATCHUP_TICKS = 240;
const PREPARE_SLICE = 600; const PREPARE_SLICE = 600;
const MAX_ENTRIES = 4; const MAX_ENTRIES = 4;
const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill', 'leak']); const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill', 'leak']);
/**
* The b.ev tuples a catch-up frame or the hidden-tab backlog keeps: the state-bearing kinds, and every fx that sets an
* enemy's model form (shared/protocol.js fxForm — dropped, the view kept the old model: player report #5 after 0.1.0).
*/
export const keepsState = (x) => Array.isArray(x) && (STATE_EV.has(x[0]) || fxForm(x) !== undefined);
/** Hidden-tab backlog cap (tuples) of the battle on screen; beyond it the view re-enters from the field meta. */
export const HELD_MAX = 3000;
/** Data files the simulation reads (DataSource + content/support gameData()). */ /** Data files the simulation reads (DataSource + content/support gameData()). */
export const SIM_DATA_FILES = Object.freeze(['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects']); export const SIM_DATA_FILES = Object.freeze(['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects']);
@@ -304,16 +316,33 @@ export function createBattleRunner(deps) {
} }
function emitFrame(e, catchingUp) { function emitFrame(e, catchingUp) {
// the hidden-tab backlog overflowed: the view starts again from the field meta (UnitInfo carries every unit's form)
if (e.stale) { show(e); return; }
let ev = []; let ev = [];
try { ev = e.battle.drainEvents() || []; } catch { ev = []; } try { ev = e.battle.drainEvents() || []; } catch { ev = []; }
const gt = Number(e.battle.time) || 0; const gt = Number(e.battle.time) || 0;
if (ev.length) { const held = e.held;
const list = catchingUp ? ev.filter((x) => Array.isArray(x) && STATE_EV.has(x[0])) : ev; if (held.length) e.held = [];
if (ev.length || held.length) {
const list = held.concat(catchingUp ? ev.filter(keepsState) : ev);
if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt, ev: list }); if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt, ev: list });
} }
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); } try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); }
} }
/**
* Events of a step that is not rendered (hidden tab): the battle on screen keeps its state-bearing ones for the next
* rendered frame (keepsState, ≤ HELD_MAX — beyond that it is re-entered from the field meta); any other battle starts
* from its field meta when shown (show()), so its events go.
*/
function hold(e) {
let ev = [];
try { ev = e.battle.drainEvents() || []; } catch { ev = []; }
if (e !== cur || e.stale) return;
for (const x of ev) if (keepsState(x)) e.held.push(x);
if (e.held.length > HELD_MAX) { e.held = []; e.stale = true; }
}
function progress(e, force = false) { function progress(e, force = false) {
if (!e.authoritative || e.resultSent || !net) return; if (!e.authoritative || e.resultSent || !net) return;
const t = now(); const t = now();
@@ -420,7 +449,7 @@ export function createBattleRunner(deps) {
const dt = stepEntry(e, n); const dt = stepEntry(e, n);
if (dt > stats.maxFrameMs) stats.maxFrameMs = dt; if (dt > stats.maxFrameMs) stats.maxFrameMs = dt;
if (render) emitFrame(e, catchingUp); if (render) emitFrame(e, catchingUp);
else { try { e.battle.drainEvents(); } catch { /* ignore */ } } else hold(e);
noteLeaks(e); noteLeaks(e);
progress(e); progress(e);
if (e.battle.finished) finished(e); if (e.battle.finished) finished(e);
@@ -456,6 +485,8 @@ export function createBattleRunner(deps) {
/** Put an entry on screen: field meta into the store (the game screen enters it), then its current frame. */ /** Put an entry on screen: field meta into the store (the game screen enters it), then its current frame. */
function show(e) { function show(e) {
cur = e; cur = e;
e.held = [];
e.stale = false;
let meta = null; let meta = null;
try { meta = e.battle.fieldMeta(); } catch { meta = { units: [] }; } try { meta = e.battle.fieldMeta(); } catch { meta = { units: [] }; }
const field = { const field = {
@@ -538,6 +569,9 @@ export function createBattleRunner(deps) {
leaks: 0, leakMark: '', left: null, leaks: 0, leakMark: '', left: null,
// live bond layers grown in this battle { [playerId]: { [bondId]: n } } and their total gain (noteLayers) // live bond layers grown in this battle { [playerId]: { [bondId]: n } } and their total gain (noteLayers)
live: null, layerSum: 0, live: null, layerSum: 0,
// state-bearing events of the steps run while the tab was hidden (hold(); delivered by the next rendered frame),
// and whether that backlog overflowed (the next frame re-enters the view from the field meta)
held: [], stale: false,
}; };
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') { if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined); battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
+15 -11
View File
@@ -21,7 +21,9 @@
// operators on the field under a redeploy ring and `elem` draws the // operators on the field under a redeploy ring and `elem` draws the
// element gauges (user playtest #4 items 8 / 9, render/units.js); a // element gauges (user playtest #4 items 8 / 9, render/units.js); a
// 'die' with reason FORCED_EXIT (an operator entering 联防 knocked out, // 'die' with reason FORCED_EXIT (an operator entering 联防 knocked out,
// user playtest #5 item 2) goes straight to the held pose, no burst // user playtest #5 item 2) goes straight to the held pose, no burst; an
// fx with a `form` (shared/protocol.js fxForm) switches the enemy's
// model to that clip set (render/units.js FORMS)
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat): // view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
// ~2-frame buffer at the battle's game speed // ~2-frame buffer at the battle's game speed
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'| // view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
@@ -89,6 +91,7 @@
// `data` is the client data store (public/js/data.js: lookup(file, id)) or plain { chess, tokens, items, enemies } maps. // `data` is the client data store (public/js/data.js: lookup(file, id)) or plain { chess, tokens, items, enemies } maps.
import { GEO, ANIM, UF } from '../../../shared/constants.js'; import { GEO, ANIM, UF } from '../../../shared/constants.js';
import { fxForm } from '../../../shared/protocol.js';
import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js'; import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js';
import { SnapshotBuffer, frameTime } from './interp.js'; import { SnapshotBuffer, frameTime } from './interp.js';
import { TileField } from './tiles.js'; import { TileField } from './tiles.js';
@@ -1430,24 +1433,25 @@ export async function createFieldView(host, options = {}) {
break; break;
} }
case 'status': { const v = views.get(e[1]); if (v) v.onStatus?.(e[2], !!e[3]); break; } case 'status': { const v = views.get(e[1]); if (v) v.onStatus?.(e[2], !!e[3]); break; }
case 'fx': case 'fx': {
if (e[4] && typeof e[4] === 'object' && e[4].consumed && e[4].id != null) { if (e[4] && typeof e[4] === 'object' && e[4].consumed && e[4].id != null) {
consumedIds.add(e[4].id); consumedIds.add(e[4].id);
if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value); if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value);
} }
// an enemy's mode change (掠海漂移体 → 爬行模式, user playtest #5 item 1; 转译基底's forms, a 逐火 ember and its // an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user
// revival, the leaders' 重生 — user report after 0.1.0): a 'phase' kind, or the `form` any other fx carries, // playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after
// switches the view's clip set (UnitView.setForm, a kind without a clip set of that skeleton changes nothing); // 0.1.0) — switches the view's clip set (UnitView.setForm; a kind without a clip set of that skeleton changes
// the info keeps it for a view built later // nothing); the info keeps it for a view built later. The client runner never drops these fx (catch-up frames,
const ex4 = e[4] && typeof e[4] === 'object' && e[4].id != null ? e[4] : null; // hidden-tab backlog), nor does the game screen's pre-entry buffer.
const form = !ex4 ? undefined : e[1] === 'phase' ? ex4.kind : 'form' in ex4 ? ex4.form : undefined; const form = fxForm(e);
if (form !== undefined) { if (form !== undefined) {
const inf = infos.get(ex4.id); const inf = infos.get(e[4].id);
if (inf && (typeof form !== 'string' || FORMS[inf.spine || inf.defId]?.[form])) inf.form = typeof form === 'string' ? form : null; if (inf && (form === null || FORMS[inf.spine || inf.defId]?.[form])) inf.form = form;
views.get(ex4.id)?.setForm?.(form, ex4); views.get(e[4].id)?.setForm?.(form, e[4]);
} }
fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]); fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]);
break; break;
}
case 'layer': { case 'layer': {
const bondId = e[2], n = Number(e[3]) || 0; const bondId = e[2], n = Number(e[3]) || 0;
if (!(n > 0) || typeof bondId !== 'string') break; if (!(n > 0) || typeof bondId !== 'string') break;
+8 -6
View File
@@ -109,7 +109,8 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g
* - 转译基底·α (user report after 0.1.0, #5): its 2 s change clip A_Die_B / _C / _D, then 寻仇者 B_*, 幽灵 C_* (no attack * - 转译基底·α (user report after 0.1.0, #5): its 2 s change clip A_Die_B / _C / _D, then 寻仇者 B_*, 幽灵 C_* (no attack
* clip: it never attacks) or 特战术师 D_* — its manifest roles are the original form's A_Idle / A_Move; * clip: it never attacks) or 特战术师 D_* — its manifest roles are the original form's A_Idle / A_Move;
* - 深池逐火战士 / 精锐战士 / 护卫 (#8): knocked out → 'Die' (the 1 s 重生), the 余烬 on Idle_2 / Move_2 and its death on * - 深池逐火战士 / 精锐战士 / 护卫 (#8): knocked out → 'Die' (the 1 s 重生), the 余烬 on Idle_2 / Move_2 and its death on
* Die_2; standing up again → 'Revive', then the warrior's manifest clips; * Die_2 (also while 'Revive' plays: the ember can still be beaten); standing up again → 'Revive', then the warrior's
* manifest clips;
* - 假想敌:再生: knocked out → A_Die, the 傀儡 on B_Idle / B_Move / B_Die; back → B_Revive, then the A_* manifest clips; * - 假想敌:再生: knocked out → A_Die, the 傀儡 on B_Idle / B_Move / B_Die; back → B_Revive, then the A_* manifest clips;
* (both: the stand-up clip (`end`) is timed from the 'ember' fx's `dur` to end as the husk stands up — the 'revive' * (both: the stand-up clip (`end`) is timed from the 'ember' fx's `dur` to end as the husk stands up — the 'revive'
* fx then only lands in the manifest clips); * fx then only lands in the manifest clips);
@@ -393,11 +394,11 @@ export class UnitView {
} }
/** /**
* The unit changed mode (sim fx 'phase' { id, kind } or another fx's `form`; `fx` = that fx's extra): the mode's clip * The unit changed mode (the `form` of a sim fx — shared/protocol.js fxForm; `fx` = that fx's extra): the mode's clip
* set (FORMS) after its change clip, and its closing clip (`end`, landing in the `next` form's clips) timed to end * set (FORMS) after its change clip, and its closing clip (`end`, landing in the `next` form's clips) timed to end
* `fx.dur` game s later; null goes back * `fx.dur` game s later — the unit is still in this mode while it plays (an ember can be beaten in its last second),
* to the manifest clips; a kind this skeleton has no clip set for (an arts barrier, a broken charge …) changes * so this mode's death clip stays until the next mode's fx; null goes back to the manifest clips; a kind this skeleton
* nothing. Kept for a model built later. * has no clip set for (an arts barrier, a broken charge …) changes nothing. Kept for a model built later.
*/ */
setForm(kind, fx = null) { setForm(kind, fx = null) {
const k = typeof kind === 'string' ? kind : null; const k = typeof kind === 'string' ? kind : null;
@@ -409,7 +410,8 @@ export class UnitView {
const f = this._formSpec(); const f = this._formSpec();
if (!this.actor) return; if (!this.actor) return;
const dur = fx && Number(fx.dur); const dur = fx && Number(fx.dur);
const next = f && f.next ? FORMS[this.info.spine || this.info.defId]?.[f.next]?.roles || null : null; let next = f && f.next ? FORMS[this.info.spine || this.info.defId]?.[f.next]?.roles || null : null;
if (next && typeof f.roles.die === 'string') next = { ...next, die: f.roles.die };
if (f) this.actor.setForm(f.roles, f.change || null, f.end && dur > 0 ? { clip: f.end, in: dur, roles: next } : null); if (f) this.actor.setForm(f.roles, f.change || null, f.end && dur > 0 ? { clip: f.end, in: dur, roles: next } : null);
else if (had) this.actor.setForm(null); else if (had) this.actor.setForm(null);
} }
+5 -1
View File
@@ -58,6 +58,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js'; import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE, GEO } from '../../../shared/constants.js'; import { PHASE, GEO } from '../../../shared/constants.js';
import { fxForm } from '../../../shared/protocol.js';
import { html, Spinner, PhaseBanner, Icon, Button, MicroLabel, confirmDialog, useTicker } from '../ui/components.js'; import { html, Spinner, PhaseBanner, Icon, Button, MicroLabel, confirmDialog, useTicker } from '../ui/components.js';
import { useGameData, GIcon } from '../ui/gameComponents.js'; import { useGameData, GIcon } from '../ui/gameComponents.js';
import { useFieldView } from '../ui/fieldHost.js'; import { useFieldView } from '../ui/fieldHost.js';
@@ -109,6 +110,9 @@ const SEL_RANGE = Object.freeze({ group: 'selRange', color: 0xff9c33, fill: 0.3,
/** The tile an armed merge-completing card's elite will take (its own group; gold like the promotion cue, render/fx.js). */ /** The tile an armed merge-completing card's elite will take (its own group; gold like the promotion cue, render/fx.js). */
const MERGE_HL = Object.freeze({ group: 'mergeTile', color: 0xffd45a, fill: 0.34, line: 1 }); const MERGE_HL = Object.freeze({ group: 'mergeTile', color: 0xffd45a, fill: 0.34, line: 1 });
const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']); const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']);
/** Event tuples replayed when a field is entered late: the state-bearing kinds and the fx that change an enemy's model
* form (shared/protocol.js fxForm — the field meta's UnitInfo `form` predates them). */
const keepEarly = (e) => Array.isArray(e) && (STATE_EV.has(e[0]) || fxForm(e) !== undefined);
/** Router for the in-match screens. */ /** Router for the in-match screens. */
export function GameScreen() { export function GameScreen() {
@@ -446,7 +450,7 @@ function MatchScreen() {
if (typeof msg.fieldId !== 'string') return; if (typeof msg.fieldId !== 'string') return;
const buf = evBufRef.current.get(msg.fieldId) || []; const buf = evBufRef.current.get(msg.fieldId) || [];
// only state-bearing tuples are replayed later (see the enter effect) // only state-bearing tuples are replayed later (see the enter effect)
for (const e of msg.ev) if (Array.isArray(e) && STATE_EV.has(e[0])) buf.push(e); for (const e of msg.ev) if (keepEarly(e)) buf.push(e);
if (buf.length > 1500) buf.splice(0, buf.length - 1500); if (buf.length > 1500) buf.splice(0, buf.length - 1500);
evBufRef.current.set(msg.fieldId, buf); evBufRef.current.set(msg.fieldId, buf);
if (evBufRef.current.size > 8) evBufRef.current.delete(evBufRef.current.keys().next().value); if (evBufRef.current.size > 8) evBufRef.current.delete(evBufRef.current.keys().next().value);
+15 -11
View File
@@ -61,10 +61,10 @@
// kind, tiles?, id?, form?} (delayed strikes, charges, 'reborn' with form 'reborn') · 'beam' {from, to, kind} · // kind, tiles?, id?, form?} (delayed strikes, charges, 'reborn' with form 'reborn') · 'beam' {from, to, kind} ·
// 'summon' {id, key} · 'ember' {id, hits, dur, form: 'husk'} · 'revive' {id, kind?, form: 'form2' | 'revived' | 'fly'} // 'summon' {id, key} · 'ember' {id, hits, dur, form: 'husk'} · 'revive' {id, kind?, form: 'form2' | 'revived' | 'fly'}
// · 'stone' {id, dur, form: 'stone'} · 'blink' {id, fx, fy} · 'charge' {id, tx, ty} · 'expose' {id} · 'shieldBreak' // · 'stone' {id, dur, form: 'stone'} · 'blink' {id, fx, fy} · 'charge' {id, tx, ty} · 'expose' {id} · 'shieldBreak'
// {id} · 'liberate' {id} · 'phase' {id, kind, dur?} (form changes — crawl, translator_* — and barrier / charge states) // {id} · 'liberate' {id} · 'phase' {id, kind, dur?, form?} (form changes — crawl, translator_* — and barrier / charge
// · 'lpLoss' {value, reason} · 'steal' · 'ignite'. Forms go through setForm(): the unit keeps it (`e.form`, UnitInfo // states) · 'lpLoss' {value, reason} · 'steal' · 'ignite'. Forms go through setForm(): the unit keeps it (`e.form`,
// `form`) and the fx's `form` — a form 'phase' fx's `kind` — is the model's clip set from then on (render/app.js → // UnitInfo `form`) and the fx's `form` is the model's clip set from then on (render/app.js → UnitView.setForm, units.js
// UnitView.setForm, units.js FORMS). // FORMS); the client keeps every fx with a `form` through catch-ups and hidden tabs (shared/protocol.js fxForm).
// Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss. // Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss.
import { TICK, MOVE_SCALE, ELEMENT } from '../constants.js'; import { TICK, MOVE_SCALE, ELEMENT } from '../constants.js';
@@ -472,13 +472,14 @@ export const isHitCount = (e) => !!e.findBuff(HIT_COUNT_KEY);
/** /**
* The enemy's model takes another form (render/units.js FORMS: a clip set of its skeleton): kept on the unit * The enemy's model takes another form (render/units.js FORMS: a clip set of its skeleton): kept on the unit
* (`e.form`, published by snapshot.js unitInfo — a view built mid-battle from fieldMeta(): a teammate's field watched * (`e.form`, published by snapshot.js unitInfo — a view built mid-battle from fieldMeta(): a teammate's field watched
* later, 联防 observers, a reconnect — starts in it) and announced by the fx `fxKind` (+ id, x, y, `params`): a 'phase' * later, 联防 observers, a reconnect — starts in it) and announced by the fx `fxKind` (+ id, x, y, `params`) carrying it
* fx carries the form as its `kind`, any other fx as `form`. Barrier / charge 'phase' kinds are no forms: they go * as `form` (a 'phase' fx also as its `kind`). The `form` key marks the fx as state (shared/protocol.js fxForm): the
* through b.fx directly. * client never drops it, not in a catch-up frame nor while its tab is hidden. Barrier / charge 'phase' kinds are no
* forms: they go through b.fx directly, without `form`.
*/ */
export function setForm(b, e, form, fxKind = 'phase', params = null) { export function setForm(b, e, form, fxKind = 'phase', params = null) {
e.form = form; e.form = form;
b.fx(fxKind, fxKind === 'phase' ? { ...params, x: e.x, y: e.y, id: e.id, kind: form } : { ...params, x: e.x, y: e.y, id: e.id, form }); b.fx(fxKind, fxKind === 'phase' ? { ...params, x: e.x, y: e.y, id: e.id, kind: form, form } : { ...params, x: e.x, y: e.y, id: e.id, form });
} }
/** Leader "扣除目标生命" effects: recorded for the match (hook 'lpLoss' + result.lpLoss). */ /** Leader "扣除目标生命" effects: recorded for the match (hook 'lpLoss' + result.lpLoss). */
@@ -2119,15 +2120,18 @@ function kitWolfLord(ab) {
* still cancelled]; then the form's flat stat changes apply (move speed relative to the data's 1.0, attack interval * still cancelled]; then the form's flat stat changes apply (move speed relative to the data's 1.0, attack interval
* +N s, 重量等级 +N): * +N s, 重量等级 +N):
* 寻仇者 (form B): melee only, physical; ATK +100 % while HP < 50 % (Mode_Fuchou_Anger.atk); * 寻仇者 (form B): melee only, physical; ATK +100 % while HP < 50 % (Mode_Fuchou_Anger.atk);
* 特战术师 (form D): ranged only (the data's 2.4 radius), arts, 2 targets at once; * 特战术师 (form D): ranged only (the data's 2.4 radius), arts, 2 targets at once — flyers included [ASSUMED: the
* 幽灵 (form C): no stat change, unblockable, no attack (PRTS lists none; its model has no attack clip). * translator's text "仅进行远程攻击,同时攻击2个目标,造成法术伤害" names no exception; the standalone PRTS 特战术师's talent
* "不会攻击飞行单位" may carry over — then `canTarget: (u) => !u.isFlying` in finish()];
* 幽灵 (form C): no stat change, unblockable, no attack (PRTS 转译基底·α "幽灵形态 无属性变化;无法被阻挡" — no attack line,
* unlike the armed forms — and the linked PRTS 幽灵 天赋 "不进行普通攻击,无法被阻挡", 攻击方式 不攻击).
* The armed forms attack through the engine's enemy attack (ai.js enemyAttack, `profile.dmgType`), so attack clips, * The armed forms attack through the engine's enemy attack (ai.js enemyAttack, `profile.dmgType`), so attack clips,
* projectiles and the attack hooks are the normal ones. Before its change ends it cannot be killed at all (damage is * projectiles and the attack hooks are the normal ones. Before its change ends it cannot be killed at all (damage is
* cancelled; an HP loss stops at 1 HP) — user report after 0.1.0 (#5): its model never changed (no FORMS clip set), so * cancelled; an HP loss stops at 1 HP) — user report after 0.1.0 (#5): its model never changed (no FORMS clip set), so
* it died on the manifest's die clip, the 寻仇者's B_Die, from its first-form look; and the v2.5 kit let damage through, * it died on the manifest's die clip, the 寻仇者's B_Die, from its first-form look; and the v2.5 kit let damage through,
* so some lineups killed it before either counter reached 4. fx 'phase' {id, kind: translator_fuchou | _shushi | _youling} starts the model's 2 s * so some lineups killed it before either counter reached 4. fx 'phase' {id, kind: translator_fuchou | _shushi | _youling} starts the model's 2 s
* change clip (render/units.js FORMS). Form letters: B / C / D follow the talents' order Fuchou / Youling / Shushi — C, * change clip (render/units.js FORMS). Form letters: B / C / D follow the talents' order Fuchou / Youling / Shushi — C,
* the only clip set without an attack, is the non-attacking 幽灵. * the only clip set without an attack, is the non-attacking 幽灵 (skills ChangeToB / C / D).
*/ */
function kitTranslator(ab, e) { function kitTranslator(ab, e) {
const t = (k) => T(ab, k) ?? 0; const t = (k) => T(ab, k) ?? 0;
+14
View File
@@ -326,3 +326,17 @@ export const EV = Object.freeze({
SPAWN: 'spawn', ATK: 'atk', DMG: 'dmg', HEAL: 'heal', SKILL: 'skill', DIE: 'die', LEAK: 'leak', SPAWN: 'spawn', ATK: 'atk', DMG: 'dmg', HEAL: 'heal', SKILL: 'skill', DIE: 'die', LEAK: 'leak',
STATUS: 'status', FX: 'fx', LAYER: 'layer', BOUNTY: 'bounty', DEPLOY: 'deploy', STATUS: 'status', FX: 'fx', LAYER: 'layer', BOUNTY: 'bounty', DEPLOY: 'deploy',
}); });
/**
* The model form a `b.ev` 'fx' tuple ['fx', kind, x, y, extra] puts its unit in: `extra.form` (sim content/enemies.js
* setForm — 转译基底·α's forms, a 逐火 余烬 and its revival, a leader's 重生, 守墓石像's modes, 掠海漂移体's crawl; a string is
* that clip set, null the base one), undefined for any other tuple. A form is state, not decoration: a view that misses
* the fx keeps drawing the old model (player report #5 after 0.1.0), so the client's catch-up frames, its hidden-tab
* backlog (battle/runner.js) and the events buffered before a field is entered (screens/game.js) keep these tuples.
*/
export function fxForm(ev) {
if (!Array.isArray(ev) || ev[0] !== EV.FX) return undefined;
const x = ev[4];
if (!x || typeof x !== 'object' || x.id == null || !Object.hasOwn(x, 'form')) return undefined;
return typeof x.form === 'string' ? x.form : null;
}
+186
View File
@@ -0,0 +1,186 @@
// test/match/feedback1-runner-forms.test.js — player report #5 after 0.1.0 ("转译基底不会变身…加载变身动画然后就没了"), the
// client runner's part (public/js/battle/runner.js under Node: fake socket, manual clock and animation frames, the real
// sim on the real stage 战场#01). A form change reaches the view only as an fx with a `form` (sim content/enemies.js
// setForm; shared/protocol.js fxForm) — b.snap tuples carry no form. The runner used to drop those fx in a catch-up
// frame (a stall of more than ≈0.53 s real at 2×: only spawn / die / deploy / status / skill / leak passed) and every
// event while the tab was hidden, so the view kept the first form and the 转译基底·α died on the 寻仇者's B_Die (the
// review's headless-Chrome repro). Now a catch-up keeps them (keepsState), a hidden tab holds the battle on screen's
// state-bearing events for the first frame back (≤ HELD_MAX, else the view re-enters from the field meta), and the game
// screen's pre-entry buffer keeps them too (screens/game.js keepEarly, same predicate).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createBattleRunner, keepsState, HELD_MAX } from '../../public/js/battle/runner.js';
import { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { validateC2S, fxForm } from '../../shared/protocol.js';
import { DATA } from './harness.js';
const DS = new DataSource(DATA, null);
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const TR = 'enemy_10081_mpplai';
function fakeNet() {
const handlers = new Map();
const n = {
sent: [],
on(t, fn) { if (!handlers.has(t)) handlers.set(t, new Set()); handlers.get(t).add(fn); return () => handlers.get(t).delete(fn); },
emit(t, msg) { for (const fn of handlers.get(t) || []) fn({ t, ...msg }); },
send(t, fields) { const msg = { ...fields, t }; assert.equal(validateC2S(msg), null, `invalid ${t}`); n.sent.push(msg); return true; },
request(t, fields) { const msg = { ...fields, t, rid: 1 }; assert.equal(validateC2S(msg), null, `invalid ${t}`); n.sent.push(msg); return Promise.resolve({ t: 'ok' }); },
};
return n;
}
function rig({ hidden = false } = {}) {
let t = 1000;
const frames = [];
const intervals = [];
const docListeners = [];
const doc = { hidden, addEventListener(type, fn) { if (type === 'visibilitychange') docListeners.push(fn); } };
const net = fakeNet();
const store = createStore(initialState);
const runner = createBattleRunner({
net, store, doc,
now: () => t,
raf: (fn) => { frames.push(fn); return frames.length; },
caf: () => {},
setInterval: (fn) => { intervals.push(fn); return intervals.length; },
clearInterval: () => {},
loadSim: async () => ({ spec: specMod, ds: DS }),
logger: { error() {}, warn() {}, info() {}, debug() {} },
});
const feed = { evs: [], fields: [], snaps: 0 };
runner.on('ev', (e) => feed.evs.push(e));
runner.on('field', (f) => feed.fields.push(f));
runner.on('snap', () => { feed.snaps++; });
return {
runner, net, doc, feed,
/** advance the clock by `ms` in animation frames of `step` ms (pump intervals while hidden) */
advance(ms, step = 1000 / 60) {
const end = t + ms;
while (t < end) {
t = Math.min(end, t + step);
if (doc.hidden) { for (const fn of intervals) fn(); continue; }
const q = frames.splice(0);
for (const fn of q) fn(t);
}
},
show() { doc.hidden = false; for (const fn of docListeners) fn(); },
async settle() { for (let i = 0; i < 50; i++) { await new Promise((res) => setImmediate(res)); const q = frames.splice(0); for (const fn of q) fn(t); } },
};
}
/** 战场#01: 百炼嘉维尔 blocks the lower lane on (9,8); one 转译基底·α walks it (9,10) → (9,2) — blocked, it turns 幽灵. */
const start = (authoritative = false) => ({
battleId: 'fb1.runner', fieldId: 'n:P1', kind: 'normal', speed: 2, elapsed: 0, authoritative, watch: !authoritative,
spec: specMod.buildBattleSpec({
battleId: 'fb1.runner', fieldId: 'n:P1', kind: 'normal', seed: 5, modeId: 'mode_multi_hard', round: 3, stageId: 'act2autochess_m01', timeLimit: 400,
players: [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'chess_char_4_23_b', row: 9, col: 8, dir: 'RIGHT' }], bonds: {}, playerEffects: [] }],
spawns: [{ time: 0, enemyKey: TR, routeIndex: 0, count: 1, mods: { hpMul: 1, atkMul: 0.01 } }],
routes: [{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }], flags: {},
}),
});
const all = (feed) => feed.evs.flatMap((x) => x.ev);
const formsOf = (list, id) => list.filter((x) => fxForm(x) !== undefined && x[4].id === id).map(fxForm);
const trOf = (e) => e.battle.units.find((u) => u.defId === TR);
test('fxForm / keepsState: a setForm fx (any fx kind with a `form`) is state; a barrier \'phase\', hit sparks and damage numbers are not', () => {
assert.equal(fxForm(['fx', 'phase', 1, 2, { id: 3, kind: 'translator_youling', form: 'translator_youling', dur: 2 }]), 'translator_youling');
assert.equal(fxForm(['fx', 'ember', 1, 2, { id: 3, hits: 5, dur: 11, form: 'husk' }]), 'husk');
assert.equal(fxForm(['fx', 'revive', 1, 2, { id: 3, form: null }]), null, 'null = back to the base clip set');
assert.equal(fxForm(['fx', 'phase', 1, 2, { id: 3, kind: 'artsBarrier', value: 300 }]), undefined, 'a barrier phase is no form');
assert.equal(fxForm(['fx', 'ember', 1, 2, { form: 'husk' }]), undefined, 'no unit');
assert.equal(fxForm(['dmg', 3, 100]), undefined);
assert.ok(keepsState(['fx', 'revive', 1, 2, { id: 3, form: 'revived' }]));
assert.ok(keepsState(['spawn', { id: 3 }]) && keepsState(['die', 3, 'killed']));
assert.ok(!keepsState(['fx', 'explode', 1, 2, { r: 1 }]) && !keepsState(['atk', 1, 3]) && !keepsState(['dmg', 3, 100]));
});
test('the smooth feed carries 转译基底·α\'s form fx (blocked ⇒ 幽灵)', async () => {
const r = rig();
r.net.emit('b.start', start());
await r.settle();
const e = r.runner._entries.get('fb1.runner');
for (let i = 0; i < 12 && trOf(e)?.form == null; i++) r.advance(1000);
r.advance(1500);
const tr = trOf(e);
assert.equal(tr?.form, 'translator_youling', 'fixture: blocked by 百炼嘉维尔 it turns 幽灵');
assert.equal(r.runner.stats().catchups, 0, 'no catch-up');
assert.deepEqual(formsOf(all(r.feed), tr.id), ['translator_youling']);
r.runner.dispose();
});
test('janky frames (0.7 s each: every frame a catch-up) keep the form fx — hit sparks and damage numbers still go', async () => {
const r = rig();
r.net.emit('b.start', start());
await r.settle();
const e = r.runner._entries.get('fb1.runner');
for (let i = 0; i < 40 && !e.done && !(trOf(e)?.form && e.battle.time > 12); i++) r.advance(700, 700);
const tr = trOf(e);
assert.equal(tr?.form, 'translator_youling');
assert.ok(r.runner.stats().catchups > 5, `catch-up frames (${r.runner.stats().catchups})`);
const list = all(r.feed);
assert.deepEqual(formsOf(list, tr.id), ['translator_youling'], 'the view learns the 幽灵 form (it used to keep the A model)');
assert.ok(!list.some((x) => x[0] === 'atk' || x[0] === 'dmg'), 'catch-up frames still drop the decoration');
const spawnAt = list.findIndex((x) => x[0] === 'spawn' && x[1].id === tr.id);
const formAt = list.findIndex((x) => fxForm(x) !== undefined && x[4].id === tr.id);
const dieAt = list.findIndex((x) => x[0] === 'die' && x[1] === tr.id);
assert.ok(spawnAt >= 0 && formAt > spawnAt && (dieAt < 0 || dieAt > formAt), `in order: spawn ${spawnAt}, form ${formAt}, die ${dieAt}`);
r.runner.dispose();
});
test('a hidden tab across the change: nothing rendered meanwhile, the first frame back delivers the held spawn and form fx', async () => {
const r = rig({ hidden: true });
r.net.emit('b.start', start(true));
await r.settle();
const e = r.runner._entries.get('fb1.runner');
assert.equal(r.feed.fields.length, 1, 'shown (the field meta) before the first tick');
for (let i = 0; i < 12 && trOf(e)?.form == null; i++) r.advance(1000, 250);
r.advance(1500, 250);
const tr = trOf(e);
assert.equal(tr?.form, 'translator_youling', 'pumped through the change while hidden');
assert.equal(r.feed.evs.length, 0, 'nothing rendered while hidden');
assert.ok(e.held.length > 0 && e.held.every(keepsState), 'the battle on screen holds its state-bearing events');
r.show();
r.advance(1000 / 60);
const first = r.feed.evs[0]?.ev || [];
assert.ok(first.some((x) => x[0] === 'spawn' && x[1].id === tr.id), 'the translator\'s spawn (it used to come back as an unknown view)');
assert.deepEqual(formsOf(first, tr.id), ['translator_youling'], 'and its form');
assert.equal(e.held.length, 0);
assert.equal(r.feed.fields.length, 1, 'no re-entry needed');
r.runner.dispose();
});
test('a hidden-tab backlog beyond HELD_MAX: the first frame back re-enters the view from the field meta (UnitInfo `form`)', async () => {
const r = rig({ hidden: true });
r.net.emit('b.start', start(true));
await r.settle();
const e = r.runner._entries.get('fb1.runner');
for (let i = 0; i < 12 && trOf(e)?.form == null; i++) r.advance(1000, 250);
const tr = trOf(e);
assert.ok(tr?.form, 'changing');
e.held.push(...Array.from({ length: HELD_MAX }, () => ['status', tr.id, 'stun', false]));
r.advance(500, 250);
assert.ok(e.stale && e.held.length === 0, 'overflow: dropped, marked stale');
r.show();
r.advance(1000 / 60);
assert.equal(r.feed.fields.length, 2, 're-entered');
const meta = r.feed.fields[1];
assert.equal(meta.units.find((u) => u.id === tr.id)?.form, 'translator_youling', 'the field meta carries the form');
assert.equal(e.stale, false);
r.advance(1000 / 60);
assert.ok(!r.feed.evs.some((x) => x.ev.some((y) => y[0] === 'status' && y[2] === 'stun')), 'the dropped backlog never arrives');
r.runner.dispose();
});
test('screens/game.js buffers the form fx with the state-bearing events it replays when a field is entered late', () => {
const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(src, /const keepEarly = \(e\) => Array\.isArray\(e\) && \(STATE_EV\.has\(e\[0\]\) \|\| fxForm\(e\) !== undefined\);/);
assert.match(src, /for \(const e of msg\.ev\) if \(keepEarly\(e\)\) buf\.push\(e\);/);
});
+65 -2
View File
@@ -7,7 +7,10 @@
// Idle_2 (blocked, so its blocker beats it) and dies on Die_2 — it used to stay an untargetable ember forever; // Idle_2 (blocked, so its blocker beats it) and dies on Die_2 — it used to stay an untargetable ember forever;
// a view built mid-battle — the real `view.enterBattle(b.fieldMeta())` after a silent catch-up, as for a teammate's // a view built mid-battle — the real `view.enterBattle(b.fieldMeta())` after a silent catch-up, as for a teammate's
// field watched later, 联防 observers or a reconnect — starts in the current forms (UnitInfo `form` → render/app.js // field watched later, 联防 observers or a reconnect — starts in the current forms (UnitInfo `form` → render/app.js
// renderInfo): it used to draw the warrior / the A model again (the look of report #5) and 掠海漂移体 hovering. // renderInfo): it used to draw the warrior / the A model again (the look of report #5) and 掠海漂移体 hovering;
// the real client runner (battle/runner.js createBattleRunner) feeding the view the way screens/game.js does, with
// janky 0.7 s frames (every frame a catch-up) across the change of a 转译基底·α that walks into 百炼嘉维尔's block:
// the view turns 幽灵 (C_*) — the catch-up filter used to drop the form fx, so it stayed on A_Move and died on B_Die.
// //
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1-forms.browser.test.js // Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1-forms.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/feedback1-*.png. // Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/feedback1-*.png.
@@ -121,6 +124,49 @@ async function lateViewInPage(page, port, s, atSecs, setupSrc) {
}, s, atSecs, setupSrc); }, s, atSecs, setupSrc);
} }
/**
* The real client runner in the page (fake socket / store, its default sim loader and clock) feeds the demo's view like
* screens/game.js (field → enterBattle, snap → pushSnapshot, ev → pushEvents); its animation frames come every `jankMs`
* real ms for `secs` real s. Returns samples { t, simForm, form, clip, spine, alive } of the 转译基底·α's view and the
* runner's catch-up count.
*/
async function runnerInPage(page, port, s, jankMs, secs) {
await page.goto(`http://127.0.0.1:${port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
return page.evaluate(async (spec, jankMs, secs) => {
const { createBattleRunner } = await import('/js/battle/runner.js');
const { data } = await import('/js/data.js');
const v = window.__demo.view;
v.setStage(data.lookup('stages', spec.stageId));
const handlers = new Map();
const net = {
on(t, fn) { if (!handlers.has(t)) handlers.set(t, new Set()); handlers.get(t).add(fn); return () => handlers.get(t).delete(fn); },
send() { return true; }, request() { return Promise.resolve({ t: 'ok' }); },
};
const queue = [];
const runner = createBattleRunner({ net, store: { patch() {} }, doc: { hidden: false, addEventListener() {} }, raf: (fn) => { queue.push(fn); return queue.length; }, caf() {} });
runner.on('field', (f) => { v.enterBattle(f); v.setCamera('normal', { rect: f.rect, side: 'L', instant: true }); v.setLocalFeed({ on: true, speed: f.speed }); });
runner.on('snap', (x) => v.pushSnapshot(x));
runner.on('ev', (m) => v.pushEvents(m));
for (const fn of handlers.get('b.start') || []) fn({ t: 'b.start', battleId: spec.battleId, fieldId: spec.fieldId, kind: 'normal', spec, authoritative: false, watch: true, speed: 2, elapsed: 0 });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
for (let i = 0; i < 100 && !runner._entries.size; i++) await sleep(50);
const e = [...runner._entries.values()][0];
const out = [];
const end = performance.now() + secs * 1000;
while (performance.now() < end) {
await sleep(jankMs);
for (const fn of queue.splice(0)) fn(performance.now());
const tr = e.battle.units.find((u) => u.defId === 'enemy_10081_mpplai');
const view = tr && v.debug.views.get(tr.id);
if (tr) out.push({ t: +e.battle.time.toFixed(2), simForm: tr.form, form: view?.form ?? null, clip: view?.actor?.current ?? null, spine: !!view?.spineReady, alive: tr.alive });
}
const catchups = runner.stats().catchups;
runner.dispose();
return { out, catchups };
}, s, jankMs, secs);
}
describe('player reports after 0.1.0: the models follow the knock-out forms (headless Chrome, real sim)', { skip }, () => { describe('player reports after 0.1.0: the models follow the knock-out forms (headless Chrome, real sim)', { skip }, () => {
let srv, browser; let srv, browser;
before(async () => { before(async () => {
@@ -159,7 +205,7 @@ describe('player reports after 0.1.0: the models follow the knock-out forms (hea
assert.equal(state.after, state.hp, 'the 4 hits took no HP'); assert.equal(state.after, state.hp, 'the 4 hits took no HP');
const pre = out.filter((x) => x.spine && x.t < state.hit); const pre = out.filter((x) => x.spine && x.t < state.hit);
assert.ok(pre.length > 0 && pre.every((x) => /^A_/.test(x.clip)), `first form: A_* (${[...new Set(pre.map((x) => x.clip))]})`); assert.ok(pre.length > 0 && pre.every((x) => /^A_/.test(x.clip)), `first form: A_* (${[...new Set(pre.map((x) => x.clip))]})`);
const change = out.filter((x) => x.t > state.hit + LAG && x.t < state.hit + 1.8); const change = out.filter((x) => x.t > state.hit + 2 * LAG && x.t < state.hit + 1.8); // (a loaded machine: the view lags more)
assert.ok(change.length > 0 && change.every((x) => x.clip === 'A_Die_B' && x.alive), `the change clip (${[...new Set(change.map((x) => x.clip))]})`); assert.ok(change.length > 0 && change.every((x) => x.clip === 'A_Die_B' && x.alive), `the change clip (${[...new Set(change.map((x) => x.clip))]})`);
const form = out.filter((x) => x.t > state.hit + 2 + LAG && x.t < state.kill); const form = out.filter((x) => x.t > state.hit + 2 + LAG && x.t < state.kill);
assert.ok(form.length > 0 && form.every((x) => /^B_/.test(x.clip) && x.form === 'translator_fuchou'), `寻仇者 clips (${[...new Set(form.map((x) => x.clip))]})`); assert.ok(form.length > 0 && form.every((x) => /^B_/.test(x.clip) && x.form === 'translator_fuchou'), `寻仇者 clips (${[...new Set(form.map((x) => x.clip))]})`);
@@ -225,4 +271,21 @@ describe('player reports after 0.1.0: the models follow the knock-out forms (hea
await p.close(); await p.close();
} }
}); });
test('the real client runner with janky frames (every frame a catch-up): 转译基底·α blocked by 百炼嘉维尔 turns 幽灵 in the view (C_*), never back to A_Move / B_Die', async () => {
const { p, problems } = await page();
try {
const { out, catchups } = await runnerInPage(p, srv.port, spec('enemy_10081_mpplai', 'runner', 9, 7), 700, 9);
await p.screenshot({ path: path.join(OUT, 'feedback1-runner.png') });
assert.ok(catchups >= 5, `catch-up frames (${catchups})`);
const changed = out.find((x) => x.simForm === 'translator_youling');
assert.ok(changed, `the sim's 转译基底·α turned 幽灵 (${JSON.stringify(out.slice(-3))})`);
const after = out.filter((x) => x.spine && x.t > changed.t + 2 + LAG);
assert.ok(after.length > 0, 'samples after the change');
assert.ok(after.every((x) => x.form === 'translator_youling' && /^C_/.test(x.clip)), `幽灵's clips (${[...new Set(after.map((x) => `${x.form}:${x.clip}`))]})`);
assert.ok(!out.some((x) => x.spine && /^B_/.test(x.clip)), 'never the 寻仇者\'s clips (B_Die was the look of report #5)');
assert.deepEqual(problems, []);
} finally {
await p.close();
}
});
}); });
+21 -2
View File
@@ -223,6 +223,25 @@ describe('逐火 embers and the 再生 puppet (user report after 0.1.0, #8)', ()
assert.equal(clip(v), 'A_Move'); assert.equal(clip(v), 'A_Move');
}); });
test('beaten in its last second (the stand-up clip already playing, still a husk in the sim): the ember dies on Die_2, 再生\'s 傀儡 on B_Die — the warrior\'s / A_* death clip only after the revive fx', async () => {
for (const [id, end, die, die0] of [['enemy_1288_duskls', 'Revive', 'Die_2', 'Die'], ['enemy_9010_acpupp', 'B_Revive', 'B_Die', 'A_Die']]) {
const anims = assets.enemies[id].spine.animations;
const v = await enemy(id);
v.setForm('husk', { dur: 4 });
frames(v, Math.round((4 - anims[end]) * 60) + 6);
assert.equal(clip(v), end, `${id}: the closing clip plays`);
v.die();
assert.equal(clip(v), die, `${id}: the husk's death clip (review of WD)`);
const w = await enemy(id);
w.setForm('husk', { dur: 4 });
frames(w, 4 * 60 + 6);
w.setForm('revived');
frames(w, 6);
w.die();
assert.equal(clip(w), die0, `${id}: stood up, it dies on its first form's clip`);
}
});
test('a view built mid-husk (UnitInfo form, no timing) shows the husk; the revival lands in the manifest clips', async () => { test('a view built mid-husk (UnitInfo form, no timing) shows the husk; the revival lands in the manifest clips', async () => {
const v = await enemy('enemy_1288_duskls', { form: 'husk' }); const v = await enemy('enemy_1288_duskls', { form: 'husk' });
v.sync(sample(UF.STEALTH, ANIM.MOVE), 1); v.sync(sample(UF.STEALTH, ANIM.MOVE), 1);
@@ -348,7 +367,7 @@ test('render/app.js renderInfo keeps UnitInfo `form` (it used to drop it: views
assert.equal(renderInfo({ kind: 'enemy' }), null, 'no id: no info'); assert.equal(renderInfo({ kind: 'enemy' }), null, 'no id: no info');
}); });
test('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx, with the fx — to the view and keeps the mode on the unit info', () => { test('render/app.js hands the `form` of a sim fx (shared/protocol.js fxForm) with the fx to the view and keeps the mode on the unit info', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8'); const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(src, /e\[1\] === 'phase' \? ex4\.kind : 'form' in ex4 \? ex4\.form[\s\S]{0,200}inf\.form = [\s\S]{0,120}setForm\?\.\(form, ex4\)/); assert.match(src, /const form = fxForm\(e\);[\s\S]{0,200}inf\.form = form[\s\S]{0,120}setForm\?\.\(form, e\[4\]\)/);
}); });