diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 85595d2f..a5bfd9fc 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -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 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 -'ember' / 'revive' / 'telegraph' / 'stone' fx — or, for a view built mid-battle, UnitInfo `form`, which -`render/app.js renderInfo` passes to the view; a `change` clip plays once first, an `end` clip is timed from the fx's -`dur` to finish as that state ends). A blocked or revealed 隐匿 enemy is drawn solid: the sim sends the stealth bit only -while its 隐匿 is on: +`public/js/render/units.js FORMS` (keyed by Spine id, switched by the `form` of the sim's 'phase' / 'ember' / 'revive' +/ 'telegraph' / 'stone' fx — `shared/protocol.js fxForm`; the client runner keeps these fx through catch-up frames and +hidden tabs — or, for a view built mid-battle, UnitInfo `form`, which `render/app.js renderInfo` passes to the view; a +`change` clip plays once first, an `end` clip is timed from the fx's `dur` to finish as that state ends, keeping the +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 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 diff --git a/docs/SIM.md b/docs/SIM.md index 38a2b951..4869cfd1 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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 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 -fx announces it: the `kind` of a form 'phase' fx -(crawl, translator_*), else the `form` of the 'ember' ('husk'), 'revive' ('revived' / 'form2' / 'fly'), 'telegraph' -('reborn') or 'stone' ('stone') fx — render/units.js FORMS. Barrier / charge 'phase' kinds are no forms. +fx announces it as its `form`: a 'phase' fx (crawl, translator_* — also its `kind`), 'ember' ('husk'), 'revive' +('revived' / 'form2' / 'fly'), 'telegraph' ('reborn') or 'stone' ('stone') — render/units.js FORMS. Barrier / charge +'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 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, 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]`, - `['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]`. - `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) diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js index 58f2a0fc..c74e7540 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -13,6 +13,11 @@ // 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 // ~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 // 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 @@ -53,7 +58,7 @@ import { net as appNet } from '../net.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; /** Fast-forward budget per frame (ticks) when far behind. */ @@ -62,6 +67,13 @@ export const CATCHUP_TICKS = 240; const PREPARE_SLICE = 600; const MAX_ENTRIES = 4; 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()). */ 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) { + // 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 = []; try { ev = e.battle.drainEvents() || []; } catch { ev = []; } const gt = Number(e.battle.time) || 0; - if (ev.length) { - const list = catchingUp ? ev.filter((x) => Array.isArray(x) && STATE_EV.has(x[0])) : ev; + const held = e.held; + 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 }); } 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) { if (!e.authoritative || e.resultSent || !net) return; const t = now(); @@ -420,7 +449,7 @@ export function createBattleRunner(deps) { const dt = stepEntry(e, n); if (dt > stats.maxFrameMs) stats.maxFrameMs = dt; if (render) emitFrame(e, catchingUp); - else { try { e.battle.drainEvents(); } catch { /* ignore */ } } + else hold(e); noteLeaks(e); progress(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. */ function show(e) { cur = e; + e.held = []; + e.stale = false; let meta = null; try { meta = e.battle.fieldMeta(); } catch { meta = { units: [] }; } const field = { @@ -538,6 +569,9 @@ export function createBattleRunner(deps) { leaks: 0, leakMark: '', left: null, // live bond layers grown in this battle { [playerId]: { [bondId]: n } } and their total gain (noteLayers) 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') { battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined); diff --git a/public/js/render/app.js b/public/js/render/app.js index e9e01acc..f9a1bcb9 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -21,8 +21,10 @@ // operators on the field under a redeploy ring and `elem` draws the // element gauges (user playtest #4 items 8 / 9, render/units.js); a // 'die' with reason FORCED_EXIT (an operator entering 联防 knocked out, -// user playtest #5 item 2) goes straight to the held pose, no burst -// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat): +// 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): // ~2-frame buffer at the battle's game speed // view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'| // 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all @@ -89,6 +91,7 @@ // `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 { fxForm } from '../../../shared/protocol.js'; import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js'; import { SnapshotBuffer, frameTime } from './interp.js'; import { TileField } from './tiles.js'; @@ -1430,24 +1433,25 @@ export async function createFieldView(host, options = {}) { 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) { consumedIds.add(e[4].id); 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 - // revival, the leaders' 重生 — user report after 0.1.0): a 'phase' kind, or the `form` any other fx carries, - // switches the view's clip set (UnitView.setForm, a kind without a clip set of that skeleton changes nothing); - // the info keeps it for a view built later - const ex4 = e[4] && typeof e[4] === 'object' && e[4].id != null ? e[4] : null; - const form = !ex4 ? undefined : e[1] === 'phase' ? ex4.kind : 'form' in ex4 ? ex4.form : undefined; + // an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user + // playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after + // 0.1.0) — switches the view's clip set (UnitView.setForm; a kind without a clip set of that skeleton changes + // nothing); the info keeps it for a view built later. The client runner never drops these fx (catch-up frames, + // hidden-tab backlog), nor does the game screen's pre-entry buffer. + const form = fxForm(e); if (form !== undefined) { - const inf = infos.get(ex4.id); - if (inf && (typeof form !== 'string' || FORMS[inf.spine || inf.defId]?.[form])) inf.form = typeof form === 'string' ? form : null; - views.get(ex4.id)?.setForm?.(form, ex4); + const inf = infos.get(e[4].id); + if (inf && (form === null || FORMS[inf.spine || inf.defId]?.[form])) inf.form = form; + views.get(e[4].id)?.setForm?.(form, e[4]); } fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]); break; + } case 'layer': { const bondId = e[2], n = Number(e[3]) || 0; if (!(n > 0) || typeof bondId !== 'string') break; diff --git a/public/js/render/units.js b/public/js/render/units.js index 54bc5e4f..3e08db56 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -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 * 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 - * 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; * (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); @@ -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 - * `fx.dur` game s later; null goes back - * to the manifest clips; a kind this skeleton has no clip set for (an arts barrier, a broken charge …) changes - * nothing. Kept for a model built later. + * `fx.dur` game s later — the unit is still in this mode while it plays (an ember can be beaten in its last second), + * so this mode's death clip stays until the next mode's fx; null goes back to the manifest clips; a kind this skeleton + * has no clip set for (an arts barrier, a broken charge …) changes nothing. Kept for a model built later. */ setForm(kind, fx = null) { const k = typeof kind === 'string' ? kind : null; @@ -409,7 +410,8 @@ export class UnitView { const f = this._formSpec(); if (!this.actor) return; 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); else if (had) this.actor.setForm(null); } diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 55c855a2..b7b5c349 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -58,6 +58,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.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 { useGameData, GIcon } from '../ui/gameComponents.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). */ const MERGE_HL = Object.freeze({ group: 'mergeTile', color: 0xffd45a, fill: 0.34, line: 1 }); 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. */ export function GameScreen() { @@ -446,7 +450,7 @@ function MatchScreen() { if (typeof msg.fieldId !== 'string') return; const buf = evBufRef.current.get(msg.fieldId) || []; // 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); evBufRef.current.set(msg.fieldId, buf); if (evBufRef.current.size > 8) evBufRef.current.delete(evBufRef.current.keys().next().value); diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 4ef112b3..36653b27 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -61,10 +61,10 @@ // 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'} // · '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) -// · 'lpLoss' {value, reason} · 'steal' · 'ignite'. Forms go through setForm(): the unit keeps it (`e.form`, UnitInfo -// `form`) and the fx's `form` — a form 'phase' fx's `kind` — is the model's clip set from then on (render/app.js → -// UnitView.setForm, units.js FORMS). +// {id} · 'liberate' {id} · 'phase' {id, kind, dur?, form?} (form changes — crawl, translator_* — and barrier / charge +// states) · 'lpLoss' {value, reason} · 'steal' · 'ignite'. Forms go through setForm(): the unit keeps it (`e.form`, +// UnitInfo `form`) and the fx's `form` is the model's clip set from then on (render/app.js → UnitView.setForm, units.js +// 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. 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 * (`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' - * fx carries the form as its `kind`, any other fx as `form`. Barrier / charge 'phase' kinds are no forms: they go - * through b.fx directly. + * later, 联防 observers, a reconnect — starts in it) and announced by the fx `fxKind` (+ id, x, y, `params`) carrying it + * as `form` (a 'phase' fx also as its `kind`). The `form` key marks the fx as state (shared/protocol.js fxForm): the + * 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) { 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). */ @@ -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 * +N s, 重量等级 +N): * 寻仇者 (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 C): no stat change, unblockable, no attack (PRTS lists none; its model has no attack clip). + * 特战术师 (form D): ranged only (the data's 2.4 radius), arts, 2 targets at once — flyers included [ASSUMED: the + * 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, * 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 * 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 * 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) { const t = (k) => T(ab, k) ?? 0; diff --git a/shared/protocol.js b/shared/protocol.js index 912d9034..1e3f1313 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -326,3 +326,17 @@ export const EV = Object.freeze({ SPAWN: 'spawn', ATK: 'atk', DMG: 'dmg', HEAL: 'heal', SKILL: 'skill', DIE: 'die', LEAK: 'leak', 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; +} diff --git a/test/match/feedback1-runner-forms.test.js b/test/match/feedback1-runner-forms.test.js new file mode 100644 index 00000000..5e397057 --- /dev/null +++ b/test/match/feedback1-runner-forms.test.js @@ -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\);/); +}); diff --git a/test/render/feedback1-forms.browser.test.js b/test/render/feedback1-forms.browser.test.js index 9098c6d4..90ab2923 100644 --- a/test/render/feedback1-forms.browser.test.js +++ b/test/render/feedback1-forms.browser.test.js @@ -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; // 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 -// 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 // 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); } +/** + * 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 }, () => { let srv, browser; 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'); 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))]})`); - 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))]})`); 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))]})`); @@ -225,4 +271,21 @@ describe('player reports after 0.1.0: the models follow the knock-out forms (hea 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(); + } + }); }); diff --git a/test/render/forms.test.js b/test/render/forms.test.js index 81934202..cd48ec3e 100644 --- a/test/render/forms.test.js +++ b/test/render/forms.test.js @@ -223,6 +223,25 @@ describe('逐火 embers and the 再生 puppet (user report after 0.1.0, #8)', () 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 () => { const v = await enemy('enemy_1288_duskls', { form: 'husk' }); 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'); }); -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'); - 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\]\)/); });