diff --git a/docs/PLAYING.md b/docs/PLAYING.md index b631b08b..3aafbfe7 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -47,7 +47,7 @@ | 刷新 | 1 资金 | | 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 | | 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁;盟约之币、博士投影等装备即销毁的道具也一样,先销毁选中的那件再生效,干员留下一个空栏位,精锐干员不能使用博士投影,此时什么都不销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被销毁(部署在场上的话,原来的格子空出来,剩余可放置角色加 1),突变细胞和它的其他装备先退回整备区,可以再次配发给任意干员;随后获得一名高一阶(最高 VI)的随机普通干员(装备者是精锐也一样),和其他新获得的干员一样进入整备区(满了进入临时整备区),需要自己重新部署;如果它凑齐了合成所需的同名干员,照常合成精锐。**坚固维式重锤**首次受到致命伤害时锁血 8 秒,**每次部署一次**:被击倒再部署(或突袭跳跃再部署、被 M3茧甲 / 埃芒加德复活)后又能锁一次,娜仁图亚策略借来的锤子也一样;借用到期时正在锁血的会锁满 8 秒;站在阿戈尔干员身前被开场吞噬、自带源石溶剂掉血时也会用掉这一次 | -| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新 | +| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新。策略「梓兰」的每次主动刷新会单独冻结送来的那张同名干员:只有这一张卡显示冰霜(上方的冻结按钮不亮),没有再刷新就留到下一回合,再手动刷新会连它一起重抽 | | 升级调度中心(1→2 … 5→6) | 险境及以上 5 / 8 / 11 / 12 / 13;同盟标准 5 / 8 / 10 / 11 / 11;独立标准 1 / 1 / 5 / 8 / 10。**每过一回合价格 −1**(最低 0),升级后按下一级的价格重新计算。升级到干员栏位更多的等级(险境及以上 2 级 4 个、4 级 5 个;标准 2 级 4 个、6 级 5 个)时,**新栏位立刻刷出一名新干员**,已有的卡片不变 | | 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 | @@ -80,6 +80,7 @@ - **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。野鬃二技能沿她的朝向推开:正前方 45° 以内按上面的距离,身后、侧面或贴身的敌人只被轻推一下(约 0.4 格以内);琳琅诗怀雅三技能的金币则从她身边向外推开,落在她前方 2–4 格的范围(官方「范围2-4」)内的地面敌人和她阻挡的敌人身上。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。 - **生命回复不是治疗**(与官方一致):吟游者的特性(魔王、浊心斯卡蒂及其海嗣)、调香师的天赋、瑕光二技能、铃兰三技能、锡人炼金单元给友军的「每秒恢复生命」是提高对方的**生命回复速度**,不是治疗:收割者 / 不屈者 / 武者(如隐德来希、圣约送葬人、折桠)这类「无法被友方治疗」的干员和处于禁疗的干员也能吃到,治疗量加成(如「受到的治疗效果提升」)也不会放大它;铃兰三技能开启后的第一秒没有回复。直接治疗(医疗干员的治疗、技能的「恢复…生命」)仍然对这些干员无效。 - **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。 +- **弹药、狼影和「我执」**也画在血条上(0.2.2):弹药类技能(如隐现的二技能,14 发)开启时,血条下方的技力条换成**分格的黄色弹药条**——一格一发,打一发少一格,补弹时回来;这类技能开启期间脚下不再有持续的金色光环。伺夜的狼群在血条下方有一排**菱形**,亮的是剩下的狼影,暗的是已用掉的空位(最多 3 个),进入战术点形态时消失。斩业星熊进入「我执」后,绿色血条停在 1 点,超出的伤害涨成血条上的**红条**,红条满了才会倒下。这些只是显示,不改变任何战斗数值。 - **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。没被阻挡的远程敌人每次攻击都会站定、播完攻击动作,两次攻击之间才继续前进。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡(或被揭示)时才能被选为攻击目标,溅射、技能的圆形范围伤害也打不到没被挡住的隐匿敌人(和官方一样);阻挡解除后 3 秒内它仍然可以被选中(官方页面注明 0 秒或 1 秒的敌人按页面),之后才重新隐匿:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明;被阻挡时被击倒的,重生结束后约 2 秒内也能用远程或范围伤害补刀),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空、技能冷却从头开始,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。 - **复活**(策略「埃芒加德」的前 3 次、M3茧甲)与官方一致:算作一次被击倒,紧接着在倒下的位置免费立即再部署——干员「被击倒时」和「部署时」的效果都会触发(例如砾的不屈 / 卡西米尔层数、瑕光的层数、卡西米尔盟约的部署攻击力),技力和身上的增益像普通再部署一样重置。 - **策略「鸭爵」(“神秘顾客”)**:从第 5 回合起,你和队友的每场作战(**最终攻势和隐秘核心也算**,联防不另外替换)有 0–2 名地面敌人(不含领袖及其部件、悬赏敌人)被替换为特殊版本的鸭爵、高普尼克、流泪小子或圆仔,出现在被替换敌人的时间和路线上(两人同场的领袖战里,各自只替换走向自己保护目标点的敌人);击倒者获得 1 资金(联防中的队友也算),漏进保护目标点照常扣 1 点目标生命值。鸭爵和圆仔**不攻击、无法被阻挡**:鸭爵受到伤害后移动速度 +300%;圆仔始终面向我方干员较多的一侧,来自正面的物理 / 法术伤害 −80%。流泪小子隐匿(被阻挡时才能被选中),攻击使目标晕眩 3.5 秒,被晕眩的干员挡不住它(官方规则);它被放开后要过 1 秒才重新隐匿(官方是 0 秒,本模式有意放宽,维护者 2026-10-07 的决定);高普尼克免疫晕眩、冻结和浮空。 diff --git a/docs/SIM.md b/docs/SIM.md index 08a53a3f..094ee03b 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -1296,7 +1296,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te ## 9. Wire format (snapshot.js, DESIGN §8.2) -- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`. +- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem?, ammo?, wolves?, neg? }`. `sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the activation's real total — 拉特兰's and 逃犯引渡手续's extra bullets included, community report #35). Units in DIE state stay 0.8 s. Active finite zero-SP duration skills display `timeLeft/duration` using `duration` as `spMax`; after end they show @@ -1307,7 +1307,11 @@ Unknown subprofessions fall back to the profession default (test `professions.te respawn timer; `state` = constants.js `DOWN_STATE`: 0 counting, 1 timer done / DP short, 2 timer done / its tile taken — a safeguard: no ally deploys on a knocked-out operator's tile, §1; `row, col` = the tile it lies on and comes back on, `unit.body`); `elem: [[id, element, fill, - cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3). + cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3); + `ammo: [[id, left, magazine]]`, `wolves: [[id, left, max]]` and `neg: [[id, fill]]` (each only when non-empty; display only) = the HP-bar + readouts of `snapshot.js` `ammoView` (an ally's running ammo skill: whole rounds, the magazine = `ammoMax`), `wolfView` (伺夜's 狼群: + `mem.shadows` of `mem.wolfCapacity`) and `negView` (`unit.negFill()`, set by 斩业星熊's T1 业火: the 我执 pool as a share of its cap); + UnitInfo `ammoSkill` marks an ally whose skill is an ammo magazine (DESIGN §8.2). `fieldMeta()` lists the knocked-out operators too (a client joining mid-battle shows them; DESIGN §18.3). - `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy and 【移动】 — the client plays the deploy and its interpolation snaps instead of sliding), `['atk', src, tgt, projKind]` (`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|droneBomb|chain|chainHeal`; a boomerang's way back has no event — the diff --git a/docs/design/client.md b/docs/design/client.md index 0374a3da..bbb3429f 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -29,7 +29,10 @@ view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile w view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir) view.resize() ; view.destroy() ``` -Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). + +`createFieldView(host, { …, signal })` takes an `AbortSignal` (0.2.2, GitHub PR #302 by @siruimei07): an abort during the build — the Pixi load, the manifest, the fonts, the board art and the 3D board waits — destroys what the attempt made (the canvas, the ticker, the listeners, the observers) and rejects, a failure while it is built releases the same partial resources, and `view.destroy()` is idempotent. `ui/fieldHost.js mountFieldView(host, { signal })` aborts that signal when the engine fails, times out (12 s) or the owner unmounts, disposes a view a factory returns after that, and never clears the other children of `host` — the simplified view and a newer mount share it. + +Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done). Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). --- @@ -45,7 +48,7 @@ A bond the mode never activates (标准: official `inactiveBondIdList`) reads Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small). -Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4), boss music in R14/15. +Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15. --- diff --git a/docs/design/network.md b/docs/design/network.md index 88db2a6b..d5bef754 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -23,7 +23,7 @@ S→C: - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type). - Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display `0/0`. Snapshot display capacity does not change runtime SP/cost. -- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0) +- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem?, ammo?, wolves?, neg? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0); the three HP-bar readouts (0.2.2, GitHub PR #303 / #378 / #383, display only — no sim state reads them, the nine-field tuples and the SP fraction an ammo skill still puts in them are unchanged; `render/interp.js` keeps them per snapshot and `sample()` hands them out from the older snapshot like `flags`, so they step with the skill flag; a malformed entry or an id the snapshot does not list is dropped, a snapshot without the list clears the readout): `ammo: [[id, rounds left, rounds in the magazine]]` — an ally's running ammo skill, whole rounds, the magazine being the activation's real total (extra bullets and refills included; 隐现's S2 holds 14, skill_table `attack@trigger_time`) — the client draws yellow cells in place of the SP bar (the count, not a pixel copy of the official magazine widget — its art is in no game data we have), and UnitInfo `ammoSkill: true` (an ally whose skill is an ammo magazine, known from its first appearance) tells it to draw no standing skill aura; `wolves: [[id, 狼影 left, the talent's maximum]]` — 伺夜's 狼群 (`mem.shadows` / `mem.wolfCapacity`, both the placed and the automatic pack), one diamond per slot under the HP bar (plain diamonds: no official counter art is known, the portrait of PR #378 was not taken), nothing in the 战术点形态; `neg: [[id, fill]]` — the share (0.01–1) of its cap a negative-HP pool holds (斩业星熊's 我执, `unit.negFill`), drawn as the red bar over her drained HP bar. These show only for a living, deployed, visible unit and only when it has one - `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]` - `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}` - `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16) as its next battle starts (§18.5) @@ -36,7 +36,7 @@ S→C: ```js { playerId, seat, alive, lp, funds, bandId, ready, canReady /* false while temp non-empty */, shop: { level, maxLevel: 6, upgradePrice, refreshPrice /* effective (0 when a free refresh is available) */, freeRefreshes, - frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold } | null ], + frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold, frozen } | null ], // slot.frozen: the 冻结 toggle copies onto every unsold slot, and 梓兰's 猎头顾问 freezes ONE card of an active refresh with the toggle off — every card draws its own flag (GitHub #354) rewardOffer: null | { tier, source: 'merge'|'special', label, queued, slots: [ { kind: 'chess'|'item', id, price: 0, sold } ] } }, // §21.2 hand: [ Piece | null ], // length 10, index = hand slot temp: [ Piece | null ], // length 5 diff --git a/public/js/audio.js b/public/js/audio.js index f7a8784a..72d2ce26 100644 --- a/public/js/audio.js +++ b/public/js/audio.js @@ -25,7 +25,10 @@ // channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions`. // - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most // MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a -// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable. +// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable. A `['skill', id, 1]` that arrives before its +// unit is known is held and played once the unit is tracked (`pendingSkill`): a unit that casts inside its own deploy +// tick emits that first cue before its `['spawn', unitInfo]`, and dropping it left the one cast silent while every +// later one played (GitHub PR #292 by @LimitlessHPPK). // - Impact sounds (user playtest #4 item 6): a 'dmg' plays the `hit` sound of the unit whose hostile attack ('atk' on a // unit of the other side) aimed at the target — once, within IMPACT_WINDOW_MS, and only for phys / arts / true damage. // A heal "attack" ('atk' of a healer on an ally, chain heals) never makes the healer the author of the next damage @@ -76,6 +79,8 @@ const CHAIN_KINDS = new Set(['chain', 'chainHeal']); const IMPACT_TYPES = new Set(['phys', 'arts', 'true']); /** A 'dmg' later than this (real ms) after the attack aimed at the target is not that attack's impact. */ const IMPACT_WINDOW_MS = 2500; +/** Casts held for a unit that is not tracked yet (`pendingSkill`): a hold per unit of a field, never more than this. */ +const PENDING_SKILL_MAX = 64; /** Official operator sound files of a skill mode: `…_d` / `…_h` / `…_s` (+ digits) — the normal attack's end in `_n`. */ const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i; @@ -463,6 +468,7 @@ export class AudioManager { this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain } this.bgmToken = 0; this.units = new Map(); // battle unit id → defId + this.pendingSkill = new Map(); // unit id → the 'skill' tuple that arrived before the unit was known (see _track) this.lastAttacker = new Map(); // target id → { def, at } of the hostile attack last aimed at it (its impact sound) this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound this.installed = false; @@ -912,6 +918,9 @@ export class AudioManager { /** Reset the unit map for a new field (m.field.units = UnitInfo[]). */ setFieldUnits(units) { this.units.clear(); + // unit ids belong to one battle: a cast still held for a unit of the field left must not sound for a unit of the new + // one that happens to share its id + this.pendingSkill.clear(); this.lastAttacker.clear(); this.consumed.clear(); // a new field is a new battle: the first operator deployed says 行动出发 again and no cooldown carries over @@ -926,6 +935,12 @@ export class AudioManager { // official class sounds (operator vs summon vs device) this.units.set(u.id, { def: u.spine || u.defId, defId: u.defId ?? null, kind: u.kind ?? null, side: u.side, boss: !!u.boss, skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null }); + // a cast that arrived before this unit was known (handleBattleEvents): it sounds now, once + const held = this.pendingSkill.get(u.id); + if (held) { + this.pendingSkill.delete(u.id); + this.handleBattleEvents([held]); + } } /** Play a resolved battle sound for a unit event, limited like unit sounds. */ @@ -970,6 +985,13 @@ export class AudioManager { const n = Number.isInteger(u.skillIndex) ? Math.min(4, u.skillIndex + 1) : 1; this.voice(u.def, `skill${n}`, { unitKey: e[1] }); } + } else { + // The unit is not tracked yet. The sim emits the cast of a deployment that fires inside its own first tick + // (`initSp` already at `spCost`, 宴's deploy-timed skill) BEFORE the unit's `['spawn', unitInfo]` — the same + // batch or the next one — so the cue waits here and `_track` plays it when the spawn arrives. Only a skill + // turning ON is a cast; a hold for a unit that never appears goes with the field (`setFieldUnits`). + if (this.pendingSkill.size >= PENDING_SKILL_MAX) this.pendingSkill.delete(this.pendingSkill.keys().next().value); + this.pendingSkill.set(e[1], e); } } else if (kind === 'engage') { // 行动开始: the first attack a unit makes on an enemy (the sim's ENGAGE, official ENCOUNTER_ENEMY, 3 s apart) diff --git a/public/js/render/app.js b/public/js/render/app.js index 32784604..51474794 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -148,14 +148,17 @@ export { releaseGl } from './app/host.js'; /** * Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it). * @param {HTMLElement} host - * @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, + * @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, signal?: AbortSignal, * padding?: object|((kind:string, size:{width:number,height:number}) => object), * hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts] */ export async function createFieldView(host, options = {}) { if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required'); const opts = options && typeof options === 'object' ? options : {}; - const P = await ensurePixi(); + const signal = opts.signal; + signal?.throwIfAborted(); + const P = await withTimeout(ensurePixi(), null, signal); + signal?.throwIfAborted(); const assets = resolveAssets(opts.assets); const data = makeData(opts.data); const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) }; @@ -164,19 +167,28 @@ export async function createFieldView(host, options = {}) { const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d'); // three.js (~2 MB) is fetched only when the local-art manifest lists the board atlas (in parallel with the art) const artListed = want3d ? boardArtListed(assets).catch(() => false) : Promise.resolve(false); - const threePromise = artListed.then((ok) => (ok ? loadThree() : null)); - const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null)); + const threePromise = artListed.then((ok) => (ok ? loadThree() : null)).catch(() => null); + const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null)).catch(() => null); // the manifest, and the optional local-art manifest in parallel: unit views pick an enemy's local-client model by it // (assets.js spineEntry, DESIGN §13 — 灼热源石虫 / 炽焰源石虫); absent or slow, they draw the web models await withTimeout(Promise.all([assets.ready ? assets.ready() : null, assets.local ? assets.local() : null] - .map((p) => Promise.resolve(p).catch(() => {}))), 4000); + .map((p) => Promise.resolve(p).catch(() => {}))), 4000, signal); + signal?.throwIfAborted(); // web fonts for the bitmap damage numbers / tier chips (never block long) - try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500); } catch { /* ignore */ } + try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500, signal); } catch { /* optional fonts */ } + signal?.throwIfAborted(); const size = () => ({ width: Math.max(1, host.clientWidth || 1), height: Math.max(1, host.clientHeight || 1) }); const dpr = () => Math.min(globalThis.devicePixelRatio || 1, QUALITY_RES[settings.quality] || 2); const boardDpr = () => Math.min(globalThis.devicePixelRatio || 1, BOARD_RES[settings.quality] || 2); const s0 = size(); + // Construction is synchronous until the complete view can own cancellation. If setup throws, + // dispose only the resources acquired by this attempt, never another view's host contents. + const setupCleanup = []; + let destroyed = false; + let view = null; + let onAbort; + try { const app = new P.Application({ // MSAA only where it pays: dense (DPR ≥ 1.5) screens are sharp enough without it and it would cost 4× the fill // transparent: the 3D board canvas shows through (the 2D board paints an opaque backdrop itself) @@ -184,6 +196,11 @@ export async function createFieldView(host, options = {}) { resolution: dpr(), autoDensity: true, powerPreference: 'high-performance', }); const canvas = app.view; + setupCleanup.push(() => { + releaseGl(app.renderer); + try { app.destroy(true, { children: true, texture: false, baseTexture: false }); } + finally { canvas.remove(); } + }); canvas.style.display = 'block'; canvas.style.width = '100%'; canvas.style.height = '100%'; @@ -240,7 +257,6 @@ export async function createFieldView(host, options = {}) { for (const fn of [...set]) { try { fn(payload); } catch (err) { console.error(`[render] ${name} listener failed`, err); } } }; - let destroyed = false; let mode = 'idle'; // 'idle' | 'prep' | 'battle' let stageRec = null; let cam = presetCamera('prep', { width: s0.width, height: s0.height, padding: defaultPadding('prep', s0) }, { hud: hudBands('prep', s0) }); @@ -300,12 +316,14 @@ export async function createFieldView(host, options = {}) { renderer: app.renderer, frameNo: () => frameNo, impostorInterval: () => impInterval, + impostorSlot: () => impSlot++, clipAllowed: () => clipAllowed, viewport: () => vp, loadLevel: () => loadLevel, surfaceLayer: (row) => tiles.surfaceLayer(row), }; const tiles = new TileField({ ground: layers.ground, overlay: layers.overlay, props: layers.units, anim: layers.anim }); + setupCleanup.push(() => tiles.destroy()); const fx = new FxSystem({ P, layers, cam: () => cam, settings, assets, heightAt, redVignette: bg.red, surfaceLayer: ctx.surfaceLayer, timeScale: () => ctx.timeScale(), @@ -320,10 +338,12 @@ export async function createFieldView(host, options = {}) { return Math.max(80, Math.min(size().height * 0.4, p.y)); }, }); + setupCleanup.push(() => fx.destroy()); ctx.fx = fx; // battle devices (crates / turrets as sim units): the official crate mesh in the 3D scene, else a Pixi box ctx.createBox = () => switchableBox({ board: () => board3d, pixi: () => tiles.createBox() }); const impostors = new ImpostorAtlas(app.renderer); + setupCleanup.push(() => impostors.destroy()); ctx.impostors = impostors; tiles.setView(bandFor('prep'), camRect(), fieldRows('prep')); // the real board art of the local client (optional): wait briefly so the first frame already uses it; a late @@ -332,7 +352,6 @@ export async function createFieldView(host, options = {}) { if (art && !destroyed) { tiles.setArt(art); tiles.project(cam, true); } return art; }, () => null); - await withTimeout(artPromise, 2500); // ---- 3D board layer (render/board3d, DESIGN §15) ----------------------------------------------------------- let board3d = null; // BoardScene while the 3D board is on @@ -340,6 +359,7 @@ export async function createFieldView(host, options = {}) { let board3dError = null; // lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 }; + setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); }); function enable3d(THREE, pack) { if (destroyed || board3d || !THREE || !pack) return false; recover.THREE = THREE; recover.pack = pack; @@ -409,13 +429,13 @@ export async function createFieldView(host, options = {}) { if (!enable3d(recover.THREE, recover.pack)) scheduleRecover(); }, delay); } - if (want3d) { - const ready = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false); - await withTimeout(ready, 6000); - } + // Attach the board only after synchronous setup completes; enable3d also guards late arrivals. + const boardReady = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false); // the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the // manifest arrives late) let shadowAsked = false; + let offShadow = null; + setupCleanup.push(() => offShadow?.()); function loadShadow() { const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null; if (!shadowUrl) return; @@ -428,7 +448,11 @@ export async function createFieldView(host, options = {}) { for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; for (const v of penViews.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; }; - if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use); + if (t.baseTexture.valid) use(); + else { + t.baseTexture.once('loaded', use); + offShadow = () => t.baseTexture.off('loaded', use); + } } catch { /* optional */ } } loadShadow(); @@ -1206,6 +1230,17 @@ export async function createFieldView(host, options = {}) { // the 收起 toggle of PR #149 moved the discs one button to the right) opened the bond popup instead of selecting the // unit. As a click target that holds the finger's point the canvas wins: a tap on the board stays on the tile under it. const onTapTarget = () => {}; + const removeInput = () => { + canvas.removeEventListener('pointerdown', onPointerDown); + canvas.removeEventListener('pointermove', onPointerMove); + canvas.removeEventListener('pointerup', onPointerUp); + canvas.removeEventListener('pointercancel', onPointerCancel); + canvas.removeEventListener('pointerleave', onPointerLeave); + canvas.removeEventListener('contextmenu', onContext); + canvas.removeEventListener('touchend', onTouchEnd); + canvas.removeEventListener('click', onTapTarget); + }; + setupCleanup.push(removeInput); canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp); @@ -1531,6 +1566,10 @@ export async function createFieldView(host, options = {}) { let cpuMs = 0; let frameNo = 0; let impInterval = 0; + // Each frame the impostor units take slots 0, 1, 2 … in update order (units.js _updateImpostor): a unit refreshes when + // (frame + slot) % interval === 0, so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units. Random per-unit phases left the + // busiest frame 20–66% above that (16–48 units, intervals 2–6) and set the frame-time peaks of a crowded battle. + let impSlot = 0; let vp = { width: s0.width, height: s0.height }; // viewport (CSS px) of this frame: unit culling let culledCount = 0; // Adaptive load level 0–3: a device that cannot hold the frame rate with the current work switches crowds to @@ -1571,6 +1610,7 @@ export async function createFieldView(host, options = {}) { } function frameBody(now) { frameNo++; + impSlot = 0; if (frameNo % 30 === 1) { impInterval = pickImpostorInterval(); clipAllowed = pickClipping(); culledCount = 0; @@ -1655,6 +1695,7 @@ export async function createFieldView(host, options = {}) { let ro = null; if (typeof ResizeObserver === 'function') { ro = new ResizeObserver(() => resize()); + setupCleanup.push(() => ro.disconnect()); ro.observe(host); } // a DPR change without a size change (window dragged to another monitor, zoom) must re-rasterise too; polled @@ -1677,6 +1718,7 @@ export async function createFieldView(host, options = {}) { leader?.view.retryAssets?.(); } const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null; + setupCleanup.push(() => offAssets?.()); const onVisible = () => { if (destroyed || globalThis.document?.visibilityState !== 'visible') return; if (assets.loaded === false && typeof assets.ready === 'function') assets.ready(); @@ -1685,10 +1727,11 @@ export async function createFieldView(host, options = {}) { leader?.view.retryAssets?.(); }; globalThis.document?.addEventListener?.('visibilitychange', onVisible); + setupCleanup.push(() => globalThis.document?.removeEventListener?.('visibilitychange', onVisible)); // ---- public API --------------------------------------------------------------------------------------------- - const view = { + view = { setStage, setCamera, setPrep, @@ -1804,14 +1847,9 @@ export async function createFieldView(host, options = {}) { try { ro?.disconnect(); } catch { /* ignore */ } try { offAssets?.(); } catch { /* ignore */ } globalThis.document?.removeEventListener?.('visibilitychange', onVisible); - canvas.removeEventListener('pointerdown', onPointerDown); - canvas.removeEventListener('pointermove', onPointerMove); - canvas.removeEventListener('pointerup', onPointerUp); - canvas.removeEventListener('pointercancel', onPointerCancel); - canvas.removeEventListener('pointerleave', onPointerLeave); - canvas.removeEventListener('contextmenu', onContext); - canvas.removeEventListener('touchend', onTouchEnd); - canvas.removeEventListener('click', onTapTarget); + removeInput(); + offShadow?.(); + signal?.removeEventListener('abort', onAbort); app.ticker.remove(frame); app.ticker.remove(preRender); app.ticker.remove(postRender); @@ -1829,6 +1867,7 @@ export async function createFieldView(host, options = {}) { try { board3dCanvas?.remove(); } catch { /* ignore */ } releaseGl(app.renderer); try { app.destroy(true, { children: true, texture: false, baseTexture: false }); } catch { /* ignore */ } + canvas.remove(); }, stats() { return { @@ -1848,5 +1887,22 @@ export async function createFieldView(host, options = {}) { pick: { pieceAt, battleUnitAt, penUnitAt, groundTile }, }, }; + // All teardown dependencies now exist. Aborting during either asset wait immediately releases + // the renderer, ticker and listeners; late shared asset results see destroyed and do nothing. + setupCleanup.length = 0; + onAbort = () => view.destroy(); + signal?.addEventListener('abort', onAbort, { once: true }); + signal?.throwIfAborted(); + await withTimeout(artPromise, 2500, signal); + if (want3d) await withTimeout(boardReady, 6000, signal); + signal?.throwIfAborted(); return view; + } catch (err) { + if (view) view.destroy(); + else { + destroyed = true; + for (const dispose of setupCleanup.reverse()) { try { dispose(); } catch { /* continue cleanup */ } } + } + throw err; + } } diff --git a/public/js/render/app/host.js b/public/js/render/app/host.js index fee19919..79d38ff4 100644 --- a/public/js/render/app/host.js +++ b/public/js/render/app/host.js @@ -4,8 +4,22 @@ import { createAssets, assets as defaultAssets } from '../../assets.js'; import { standInRecord } from '../../../../shared/standIn.js'; import { diyRecordOf } from '../../../../shared/diy.js'; -function withTimeout(p, ms) { - return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]); +/** Wait for optional assets, or stop waiting when this view is cancelled. Shared loads keep running. */ +function withTimeout(p, ms, signal) { + let timer; + let onAbort; + const stopped = new Promise((resolve, reject) => { + if (ms != null) timer = setTimeout(resolve, ms); + if (signal) { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + } + }); + return Promise.race([p, stopped]).finally(() => { + clearTimeout(timer); + if (onAbort) signal.removeEventListener('abort', onAbort); + }); } /** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */ diff --git a/public/js/render/app/info.js b/public/js/render/app/info.js index 1eaa8306..51885de7 100644 --- a/public/js/render/app/info.js +++ b/public/js/render/app/info.js @@ -37,6 +37,9 @@ export function renderInfo(u) { // DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a // tap hands them to the detail card (a teammate's unit shows its owner's skill / module) skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined, + // an ally whose skill is an ammo magazine (UnitInfo.ammoSkill): its rounds are the cells under the HP bar, so no sustained + // skill aura is drawn for it (render/fx/rings.js _aura) + ammoSkill: u.ammoSkill === true ? true : undefined, moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined, // the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's // unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own diff --git a/public/js/render/fx/rings.js b/public/js/render/fx/rings.js index 11f2907c..3c0fe892 100644 --- a/public/js/render/fx/rings.js +++ b/public/js/render/fx/rings.js @@ -74,8 +74,13 @@ export class FxRings { this._aura(view, true); } - /** Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). */ + /** + * Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). An ammo + * skill (UnitInfo.ammoSkill) has none: its magazine is the yellow cells under the HP bar, which shrink with every round + * — a standing glow would read as a buff that never changes. The activation burst (skill()) is the same for all. + */ _aura(view, on) { + if (on && view.info?.ammoSkill) return; const P = this.P; let a = this.auras.get(view.id); if (on) { diff --git a/public/js/render/interp.js b/public/js/render/interp.js index a3d99c8a..ad54a7bd 100644 --- a/public/js/render/interp.js +++ b/public/js/render/interp.js @@ -20,14 +20,21 @@ // game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events — an enemy's form fx // included — are always delivered, and a full queue sheds only cosmetic ones. // -// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along -// (server/sim/battle/events.js snapshot, user playtest #4 items 8 / 9): +// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Optional lists ride along +// (server/sim/battle/events.js snapshot; the first two from user playtest #4 items 8 / 9): // * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's // normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older // snapshot, like flags); // * `down` [[id, respawnAt, respawnTime, state, row?, col?]] — knocked-out operators waiting to redeploy (they are no // longer in `units`) and the tile they lie on (where they fell, or their home — sim Battle._layBody; kept only when -// both are integers): downAt(time) returns the list of the snapshot at `time`. +// both are integers): downAt(time) returns the list of the snapshot at `time`; +// * three HP-bar readouts, kept per snapshot beside the tuples (the tuple layout is unchanged) and handed out by sample() +// from the older snapshot like flags, so they step with the skill flag instead of sliding: +// `ammo` [[id, rounds left, rounds in the magazine]] → sample().ammo [left, magazine] | null (whole numbers only) +// `wolves` [[id, 狼影 left, the talent's maximum]] → sample().wolves [left, maximum] | null +// `neg` [[id, fill]] (0.01–1: the negative-HP pool's share of its cap) → sample().neg number (0: none) +// An entry that is malformed, or names a unit the snapshot does not list, is dropped; a snapshot without the list +// clears the readout (render/units.js: the segmented ammo bar, the wolf pips, the red bar of 斩业星熊's 我执). // Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT. import { fxForm } from '../../../shared/protocol.js'; @@ -66,8 +73,9 @@ export function frameTime(msg) { /** * Validate & normalise a b.snap payload. Returns `{ t, units: Map, down: [[id, respawnAt, respawnTime, - * state, row?, col?]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default - * to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped. + * state, row?, col?]] | null, ammo, wolves: Map id → [left, max] | null, neg: Map id → fill | null, raw }` or null when unusable. + * Tuples with a non-finite id/x/y are skipped; other numbers default to 0; a unit's `elem` entry (see header) is appended to + * its tuple; malformed `elem` / `down` / `ammo` / `wolves` / `neg` entries are dropped. */ export function normalizeSnapshot(snap) { if (!snap || typeof snap !== 'object') return null; @@ -90,6 +98,14 @@ export function normalizeSnapshot(snap) { if (tu && tu.length === 9) tu.push(e[1], clamp(finite(e[2]), 0, 1), finite(e[3]), Math.max(0, finite(e[4]))); } } + const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units); + let neg = null; + if (Array.isArray(snap.neg)) { + for (const e of snap.neg) { + if (!Array.isArray(e) || !units.has(e[0]) || !(typeof e[1] === 'number' && e[1] > 0)) continue; + (neg || (neg = new Map())).set(e[0], Math.min(1, e[1])); + } + } let down = null; if (Array.isArray(snap.down)) { for (const d of snap.down) { @@ -99,7 +115,22 @@ export function normalizeSnapshot(snap) { (down || (down = [])).push(e); } } - return { t, units, down, raw: snap }; + return { t, units, down, ammo, wolves, neg, raw: snap }; +} + +/** + * `[[id, left, max]]` → Map id → [left, max] for the ids in `units`: whole numbers with 0 ≤ left ≤ max and max ≥ 1 (the + * `ammo` and `wolves` lists); null without a valid entry. + */ +function countList(list, units) { + if (!Array.isArray(list)) return null; + let m = null; + for (const e of list) { + if (!Array.isArray(e) || !units.has(e[0]) || !Number.isSafeInteger(e[1]) || !Number.isSafeInteger(e[2]) + || e[1] < 0 || e[2] < 1 || e[1] > e[2]) continue; + (m || (m = new Map())).set(e[0], [e[1], e[2]]); + } + return m; } export class SnapshotBuffer { @@ -265,8 +296,9 @@ export class SnapshotBuffer { /** * Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused - * across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur }` — - * `el` = the shown element gauge (null: none), see header). Samples of units no longer present are deleted from `out`. + * across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur, ammo, wolves, + * neg }` — `el` = the shown element gauge (null: none), `ammo` / `wolves` / `neg` the HP-bar readouts (null / null / 0: + * none), see header). Samples of units no longer present are deleted from `out`. */ sample(time = this.renderT, out = new Map()) { const s = this.snaps; @@ -282,7 +314,7 @@ export class SnapshotBuffer { const stamp = A.t; for (const [id, a] of A.units) { let o = out.get(id); - if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0 }; out.set(id, o); } + if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0, ammo: null, wolves: null, neg: 0 }; out.set(id, o); } const b = B ? B.units.get(id) : null; if (b) { const dx = b[1] - a[1], dy = b[2] - a[2]; @@ -317,6 +349,10 @@ export class SnapshotBuffer { o.flags = a[7]; o.anim = a[8]; if (a.length > 9) { o.el = a[9]; o.elFill = a[10]; o.elUntil = a[11]; o.elDur = a[12]; } else if (o.el !== null) { o.el = null; o.elFill = 0; o.elUntil = 0; o.elDur = 0; } + // the HP-bar readouts come with the older snapshot, like flags (whole rounds step with the skill flag) + o.ammo = A.ammo ? A.ammo.get(id) || null : null; + o.wolves = A.wolves ? A.wolves.get(id) || null : null; + o.neg = A.neg ? A.neg.get(id) || 0 : 0; o.seen = stamp; } for (const id of out.keys()) if (!A.units.has(id)) out.delete(id); diff --git a/public/js/render/style.js b/public/js/render/style.js index e93cb397..c0dd24b9 100644 --- a/public/js/render/style.js +++ b/public/js/render/style.js @@ -60,9 +60,14 @@ export const COLORS = Object.freeze({ hpBoss: 0xff2d55, hpGhost: 0xfff0c8, hpBack: 0x0c0f0e, + // the red bar of a negative-HP pool (斩业星熊's 我执, b.snap `neg`) — the same red as an enemy's HP + hpNeg: 0xff4b3e, sp: 0x6fd3ff, spReady: 0xffe066, spActive: 0xffb347, + // the ammo bar's yellow cells (b.snap `ammo`) and 伺夜's 狼影 pips (b.snap `wolves`) + ammo: 0xffd04a, + wolf: 0xe8f0ff, shield: 0xdfe8ff, }); diff --git a/public/js/render/units.js b/public/js/render/units.js index 4e38aaf7..9e9b8819 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -184,6 +184,17 @@ export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_130 * between `pulse` and 1 at `pulseHz` (real time). */ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 }); +/** + * The HP-bar readouts under the bars (b.snap `ammo`, `wolves`, `neg`; render/interp.js header): + * - AMMO_BAR — the ammo skill's segmented bar replaces the SP bar: a cell per round, `height` of the HP bar's thickness + * (the SP bar's is 0.6), cells drawn only while they are at least `minCell` px wide; + * - WOLF_PIPS — 伺夜's 狼影 count as `size` (tiles) wide diamonds, clamped to `min`…`max` px, `gap` px apart, `pad` px + * below the rows above; a lit diamond is a 狼影 left, a dim one a spent slot (`dim` alpha). + * [ASSUMED] the look: the count is data (skill_table `attack@trigger_time` 14 for 隐现's S2; the talent text 「至多3只」), the + * official HUD art of neither is in the game data. + */ +export const AMMO_BAR = Object.freeze({ height: 0.85, minCell: 2 }); +export const WOLF_PIPS = Object.freeze({ size: 0.1, min: 5, max: 9, gap: 2, pad: 1.5, dim: 0.25 }); /** * Enemy modes drawn with another clip set of the same skeleton (the `form` of a sim fx — shared/protocol.js fxForm — @@ -407,6 +418,9 @@ export class UnitView { this.visFacing = this.isEnemy ? -1 : this.facing; this.hp = Number(info.maxHp) || 1; this.maxHp = Number(info.maxHp) || 1; this.ghostHp = this.hp; this.sp = 0; this.spMax = 0; + this.ammo = null; // [rounds left, rounds in the magazine] while an ammo skill runs (sync; b.snap `ammo`) + this.wolves = null; // [狼影 left, the talent's maximum] of a 狼群 (sync; b.snap `wolves`) + this.neg = 0; // the share of its cap a negative-HP pool holds, 0 = none (sync; b.snap `neg`, 斩业星熊's 我执) this.flags = 0; this.anim = ANIM.IDLE; this.statuses = new Set(); this.alive = true; @@ -417,6 +431,8 @@ export class UnitView { this.gameT = 0; // battle game time of the frame (render clock), for the redeploy ring's countdown and the element bar's refill this.el = null; this.elFill = 0; this.elUntil = 0; this.elDur = 0; // shown element gauge (sync) this._elBar = null; // { root, disc, bg, fill } sprites of the element gauge row, built on first use + this._wolfPips = null; // { root, back[], lit[] } diamonds of the 狼影 row, built on first use + this.ammoCuts = []; // the thin separators between the ammo bar's cells (_updateAmmoCuts) this._downRing = null; // { disc, track, arc, text } sprites, built on first use this.alpha = 1; this.fadeIn = this.prep ? 1 : 0; this.lunge = 0; this.lungeDir = { x: 1, y: 0 }; @@ -649,6 +665,8 @@ export class UnitView { this.hpBg = bar(P, h, COLORS.hpBack, 0.85); this.hpGhost = bar(P, h, COLORS.hpGhost, 0.9); this.hpFill = bar(P, h, this.isEnemy ? (this.isBoss ? COLORS.hpBoss : COLORS.hpEnemy) : COLORS.hpAlly); + // a negative-HP pool (斩业星熊's 我执), drawn over the drained HP bar as the red bar + this.negFill = bar(P, h, COLORS.hpNeg); this.shieldBar = bar(P, h, COLORS.shield, 0.95); this.spBg = bar(P, h, COLORS.hpBack, 0.85); this.spFill = bar(P, h, COLORS.sp); @@ -658,7 +676,7 @@ export class UnitView { this.spGlow.blendMode = P.BLEND_MODES.ADD; h.addChild(this.spGlow); // nothing shows until the first HUD update decides (a culled or prep view never draws bars) - for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.shieldBar, this.spBg, this.spFill, this.spGlow]) b.visible = false; + for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.negFill, this.shieldBar, this.spBg, this.spFill, this.spGlow]) b.visible = false; this.chip = null; // operators only: summon tokens have no tier (hand and field alike) if (!this.isEnemy && !this.isToken && this.info.kind !== 'device') { @@ -744,6 +762,7 @@ export class UnitView { if (hp < this.hp - 0.5 && this.isBoss) this.shake = 0.25; this.hp = hp; this.sp = s.sp; this.spMax = s.spMax; + this.ammo = s.ammo || null; this.wolves = s.wolves || null; this.neg = s.neg > 0 ? Math.min(1, s.neg) : 0; const prevFlags = this.flags; this.flags = s.flags | 0; this.anim = s.anim | 0; @@ -1174,24 +1193,32 @@ export class UnitView { this.hpFill.position.set(x0, cy); this.hpFill.width = bw * k; this.hpFill.height = bh; if (!this.isEnemy) this.hpFill.tint = k < 0.3 ? COLORS.hpAllyLow : COLORS.hpAlly; } + // 业火 我执: her HP sits on the 1-HP floor and the damage past it fills a pool — the red bar over the drained green one + const neg = showHp ? this.neg : 0; + this.negFill.visible = neg > 0; + if (neg > 0) { this.negFill.position.set(x0, cy); this.negFill.width = bw * neg; this.negFill.height = bh; } const shielded = showHp && (this.flags & UF.SHIELD); this.shieldBar.visible = !!shielded; if (shielded) { this.shieldBar.position.set(x0, cy - bh / 2 - 1); this.shieldBar.width = bw; this.shieldBar.height = Math.max(1.5, bh * 0.35); } - // SP - const showSp = showBars && !this.isEnemy && this.spMax > 0; + // SP — or, while an ammo skill runs, its magazine: yellow cells, one per round, emptying from the right (b.snap `ammo`; + // the SP bar's own fraction, ammoLeft / ammoMax, is the same width). Without an `ammo` row the bar is the plain SP bar. + const ammo = !this.isEnemy ? this.ammo : null; + const showSp = showBars && !this.isEnemy && (this.spMax > 0 || !!ammo); this.spBg.visible = this.spFill.visible = showSp; - const spH = Math.max(2, bh * 0.6); + const spH = Math.max(2, bh * (ammo ? AMMO_BAR.height : 0.6)); let ready = false; + let sy = 0; if (showSp) { const active = !!(this.flags & UF.SKILL); - const k = clamp(this.sp / this.spMax, 0, 1); + const k = ammo ? ammo[0] / ammo[1] : clamp(this.sp / this.spMax, 0, 1); ready = !active && k >= 0.999; - const sy = cy + bh / 2 + spH / 2 + 1.5; + sy = cy + bh / 2 + spH / 2 + 1.5; this.spBg.position.set(x0 - 1, sy); this.spBg.width = bw + 2; this.spBg.height = spH + 2; this.spFill.position.set(x0, sy); this.spFill.width = bw * k; this.spFill.height = spH; - this.spFill.tint = active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp; + this.spFill.tint = ammo ? COLORS.ammo : active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp; this._spY = sy; } + this._updateAmmoCuts(showSp && !!ammo, ammo, x0, sy, bw, spH); this.spGlow.visible = ready; if (ready) { const pulse = 0.55 + 0.35 * Math.sin(t * 6); @@ -1222,8 +1249,11 @@ export class UnitView { ic.width = ic.height = isz; ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy); } - // element gauge row under the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`) - this._updateElementBar(showBars && !!this.el, x0, bw, cy + bh / 2 + (showSp ? spH + 1.5 : 0) + 1, spH, s, t); + // 狼影 pips under the bars (b.snap `wolves`), then the element gauge row under them (b.snap `elem`); redeploy ring above a + // knocked-down operator (b.snap `down`) + const rowsTop = cy + bh / 2 + (showSp ? spH + 1.5 : 0); + const wolfH = this._updateWolfPips(showBars && !this.isEnemy && !!this.wolves, x0, rowsTop, s); + this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + 1, spH, s, t); this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t); // blocked marker at the feet (enemies held by a blocker) const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED); @@ -1248,6 +1278,62 @@ export class UnitView { } } + /** + * The thin dark separators between the ammo bar's cells (see _updateHud): one at every round boundary of the `n`-round + * magazine across the bar `x0` … `x0 + bw` at height `y`, `h` tall; none while a cell would be under AMMO_BAR.minCell px + * (the fill keeps its exact width). Sprites are built as the magazines need them and hidden when not in use. + */ + _updateAmmoCuts(show, ammo, x0, y, bw, h) { + const cuts = this.ammoCuts; + const n = show ? ammo[1] : 0; + const step = n > 1 ? bw / n : 0; + const want = step >= AMMO_BAR.minCell ? n - 1 : 0; + while (cuts.length < want) cuts.push(bar(this.P, this.hud, COLORS.hpBack, 0.9)); + for (let i = 0; i < cuts.length; i++) { + const c = cuts[i]; + c.visible = i < want; + if (c.visible) { c.position.set(x0 + (i + 1) * step - 0.5, y); c.width = 1; c.height = h; } + } + } + + /** + * 伺夜's 狼群 count (b.snap `wolves`, [left, maximum]): a row of diamonds from `x0`, its top at `top`, one per 狼影 the + * talent allows, the first `left` of them lit. Built on the first count, hidden without one. Returns the height the row + * takes (0 while hidden) so the element gauge row can sit below it. + */ + _updateWolfPips(show, x0, top, s) { + let r = this._wolfPips; + if (!show) { if (r) r.root.visible = false; return 0; } + const P = this.P; + const [left, max] = this.wolves; + if (!r) { + r = this._wolfPips = { root: new P.Container(), back: [], lit: [] }; + this.hud.addChild(r.root); + } + const mk = (list, color, alpha) => { + const d = new P.Sprite(white()); + d.anchor.set(0.5); d.rotation = Math.PI / 4; d.tint = color; d.alpha = alpha; + r.root.addChild(d); + list.push(d); + }; + while (r.lit.length < max) { mk(r.back, COLORS.hpBack, 0.85); mk(r.lit, COLORS.wolf, 1); } + const d = clamp(s * WOLF_PIPS.size, WOLF_PIPS.min, WOLF_PIPS.max); // a diamond's diagonal + const side = d / Math.SQRT2; + const cy = top + WOLF_PIPS.pad + d / 2 + 1; + for (let i = 0; i < r.lit.length; i++) { + const on = i < max; + const b = r.back[i], l = r.lit[i]; + b.visible = l.visible = on; + if (!on) continue; + const cx = x0 + d / 2 + 1 + i * (d + 2 + WOLF_PIPS.gap); + b.position.set(cx, cy); b.width = b.height = side + 2; + l.position.set(cx, cy); l.width = l.height = side; + l.alpha = i < left ? 1 : WOLF_PIPS.dim; + } + r.root.visible = true; + return d + 2 + WOLF_PIPS.pad; + } + /** True while the shown gauge is in its 爆发冷却 (b.snap `elem` carries the cooldown's end and length). */ elementCooling() { return !!this.el && this.elDur > 0 && this.elUntil > 0; @@ -1371,16 +1457,22 @@ export class UnitView { if (this.actor.spine.parent) this.actor.spine.parent.removeChild(this.actor.spine); if (atlas) atlas.park(this.actor.spine); this.body.addChild(sprite); - this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true }; + this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true, last: 0 }; } const imp = this.imp; imp.acc += animDt; const frame = this.ctx.frameNo ? this.ctx.frameNo() : 0; - const due = imp.dirty || interval <= 1 || (frame + imp.phase) % interval === 0 || Math.abs(sc - imp.sc) > imp.sc * 0.12; + // the view's slot in this frame's update order (app.js impostorSlot) spreads the refreshes evenly over the interval; + // a context without slots keeps the random phase. A unit whose slot keeps moving with the frame (the units before it + // culled on and off in step) is still refreshed after 2 intervals at the latest. + const turn = this.ctx.impostorSlot ? this.ctx.impostorSlot() : imp.phase; + const due = imp.dirty || interval <= 1 || (frame + turn) % interval === 0 || frame - imp.last >= 2 * interval + || Math.abs(sc - imp.sc) > imp.sc * 0.12; if (due) { this.actor.update(imp.acc); imp.acc = 0; imp.dirty = false; + imp.last = frame; this._renderImpostor(sc, atlas); } const k = imp.sc > 0 ? sc / imp.sc : 1; diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 52a79eb0..5a406697 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -115,7 +115,7 @@ import { audio, resultSpeaker, resultVoiceSlot } from '../audio.js'; import { useDocClass, FullscreenButton } from '../ui/device.js'; // MatchEnded, PausedOverlay, the highlight styles and keepEarly live in ./game/*.js. import { HUD_HZ_MS, MERGE_HL, SEL_RANGE, cx } from './game/marks.js'; -import { keepEarly } from './game/early.js'; +import { keepEarly, audioEarly } from './game/early.js'; import { MatchEnded, PausedOverlay } from './game/overlays.js'; import { t, tParts } from '../../../shared/i18n.js'; @@ -434,7 +434,7 @@ function MatchScreen() { if (early && early.length) { // replay state-bearing events only (a burst of stale hit sparks / damage numbers would look wrong) view.pushEvents(early); - audio.handleBattleEvents(early.filter((e) => e[0] === 'spawn')); + audio.handleBattleEvents(audioEarly(early)); // (the 'spawn' tuples only: the sound would replay every old death, deploy and cast) } if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units)) { // prep scouting: no battle snapshots follow. A later m.field for this board (the teammate moved) re-enters diff --git a/public/js/screens/game/early.js b/public/js/screens/game/early.js index 62f3c282..b5daedd3 100644 --- a/public/js/screens/game/early.js +++ b/public/js/screens/game/early.js @@ -7,3 +7,12 @@ export 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). */ export const keepEarly = (e) => Array.isArray(e) && (STATE_EV.has(e[0]) || fxForm(e) !== undefined); + +/** + * What the SOUND gets of that replay: the 'spawn' tuples alone, which teach the audio who is on the field. The buffered + * list is every spawn, die, deploy, status and skill since the field's m.field — up to 1500 of them, while the view was not + * showing it — so handing it to the audio would play the old deaths, deploy lines and skill cues all at once on entering + * (GitHub PR #292 by @LimitlessHPPK handed over the whole list; its hold of a cast that arrives before its unit, + * audio.js `pendingSkill`, is what the live path needs, and `onEv` already forwards the full list). + */ +export const audioEarly = (early) => (Array.isArray(early) ? early.filter((e) => Array.isArray(e) && e[0] === 'spawn') : []); diff --git a/public/js/ui/fieldHost.js b/public/js/ui/fieldHost.js index eeec6551..f40e4db4 100644 --- a/public/js/ui/fieldHost.js +++ b/public/js/ui/fieldHost.js @@ -132,8 +132,32 @@ function renderPref() { return globalThis.__SP_RENDER__ === 'fallback' ? 'fallback' : 'auto'; } -function withTimeout(p, ms, what) { - return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out`)), ms))]); +function abortError(signal) { + return signal?.reason ?? new DOMException('Field view initialization cancelled', 'AbortError'); +} + +function checkActive(signal) { + if (signal?.aborted) throw abortError(signal); +} + +function withTimeout(p, ms, what, signal) { + return new Promise((resolve, reject) => { + let settled = false; + let timer; + const finish = (done, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + done(value); + }; + const onAbort = () => finish(reject, abortError(signal)); + signal?.addEventListener('abort', onAbort, { once: true }); + timer = setTimeout(() => finish(reject, new Error(`${what} timed out`)), ms); + // Keep a rejection handler attached even when cancellation wins before this promise settles. + Promise.resolve(p).then((value) => finish(resolve, value), (err) => finish(reject, err)); + if (signal?.aborted) onAbort(); + }); } /** @@ -186,34 +210,55 @@ export function seedAssets(store) { /** * Create a field view in `host`: the render engine when available, else the DOM fallback. * @param {HTMLElement} host + * @param {{ signal?: AbortSignal }} [options] Cancels a pending mount without creating a fallback. * @returns {Promise>} */ -export async function mountFieldView(host) { +export async function mountFieldView(host, { signal } = {}) { + checkActive(signal); const pref = renderPref(); const opts = { data, assets: data.get('assets'), audio, settings: settingsStore.get(), padding: hudPadding, hud: hudBands }; if (pref !== 'fallback') { + const controller = new AbortController(); + const onAbort = () => controller.abort(abortError(signal)); + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) onAbort(); + let engineView = null; + const dispose = (view) => { try { view?.destroy?.(); } catch { /* ignore */ } }; try { + checkActive(controller.signal); // the shared asset store (public/js/assets.js) keeps its Spine cache across remounts (next match, reconnect) - const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import').catch(() => null); + const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import', controller.signal).catch(() => { + checkActive(controller.signal); + return null; + }); + checkActive(controller.signal); if (am?.assets && typeof am.assets.ready === 'function') { opts.assets = am.assets; seedAssets(am.assets); } - const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import'); + const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import', controller.signal); + checkActive(controller.signal); if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing'); - const view = await withTimeout(Promise.resolve(mod.createFieldView(host, opts)), LOAD_TIMEOUT_MS, 'createFieldView'); + // A factory that ignores cancellation still belongs to this attempt when it eventually returns. + const pending = Promise.resolve(mod.createFieldView(host, { ...opts, signal: controller.signal })).then((view) => { + if (controller.signal.aborted) dispose(view); else engineView = view; + return view; + }); + const view = await withTimeout(pending, LOAD_TIMEOUT_MS, 'createFieldView', controller.signal); + checkActive(controller.signal); const missing = METHODS.filter((k) => typeof view?.[k] !== 'function'); - if (missing.length) { - try { view?.destroy?.(); } catch { /* ignore */ } - throw new Error(`view lacks ${missing.join(', ')}`); - } + if (missing.length) throw new Error(`view lacks ${missing.join(', ')}`); return guardView(view, 'engine'); } catch (err) { + controller.abort(); + dispose(engineView); + checkActive(signal); console.warn('[field] render engine unavailable, using the simplified view:', err?.message || err); - // anything the engine left behind in the host goes - try { while (host.firstChild) host.removeChild(host.firstChild); } catch { /* ignore */ } + } finally { + signal?.removeEventListener('abort', onAbort); } } + checkActive(signal); return guardView(createFallbackView(host, { ...opts, assets: data.get('assets') }), 'fallback'); } @@ -228,17 +273,19 @@ export function useFieldView(hostRef) { let dead = false; const host = hostRef.current; if (!host) return undefined; - mountFieldView(host).then((view) => { + const controller = new AbortController(); + mountFieldView(host, { signal: controller.signal }).then((view) => { if (dead) { view.destroy(); return; } viewRef.current = view; globalThis.__SP_VIEW__ = view; // dev / E2E introspection (view.raw.stats?.()) setState({ view, kind: view.kind }); - }, (err) => console.error('[field] mount failed', err)); + }, (err) => { if (!dead && !controller.signal.aborted) console.error('[field] mount failed', err); }); const unsub = settingsStore.subscribe((s) => viewRef.current?.setSettings?.(s)); const onResize = () => viewRef.current?.resize(); window.addEventListener('resize', onResize); return () => { dead = true; + controller.abort(); unsub(); window.removeEventListener('resize', onResize); // the dev hook must not keep the destroyed view — and through its host the whole detached match screen — alive diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js index d001b15e..25f8cd87 100644 --- a/public/js/ui/shopBar.js +++ b/public/js/ui/shopBar.js @@ -57,6 +57,14 @@ export function mergeHint(priv, chessId) { return mergeTarget(priv, chessId, LOOKUPS.getChess) ? t('精锐干员将出现在作战区原位置') : t('精锐干员将进入整备区'); } +/** + * Whether a shop card shows its frost: its own slot's `frozen` (m.private shop.slots[i].frozen) — the 冻结 toggle copies onto + * every unsold slot, and 梓兰's 猎头顾问 freezes ONE copied card with every active refresh while the toggle stays off + * (GitHub #354: the bar drew only the toggle, so the frozen card showed no frost) — or the toggle itself (a frame without + * per-slot flags). The 冻结 button and the bar's frame keep following the toggle alone. + */ +export const slotFrozen = (slot, toggle = false) => !!slot?.frozen || !!toggle; + /** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */ function ArmedTag({ reason, free }) { if (reason) return html`${t('无法购买')}${reason}`; @@ -350,7 +358,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, ${chessSlots.map(({ s, i }) => { if (!s || s.sold) return html`<${SoldCard} key=${`s${i}`} />`; const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS }); - return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds} + return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds} reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`; })} `} @@ -359,7 +367,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, ? itemSlots.map(({ s, i }) => { if (s.sold) return html`<${SoldCard} key=${`is${i}`} item=${true} />`; const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS }); - return html`<${ItemCard} key=${`i${i}:${s.id}`} slot=${s} idx=${i} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} reason=${reason} + return html`<${ItemCard} key=${`i${i}:${s.id}`} slot=${s} idx=${i} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} reason=${reason} armed=${armed === armKey('i', i, s)} onTap=${editable ? (idx) => tapCard('i', idx, s, 'item', reason, onBuy) : null} />`; }) : html`<${SoldCard} item=${true} />`} diff --git a/server/sim/battle/events.js b/server/sim/battle/events.js index 690b4c54..e083b719 100644 --- a/server/sim/battle/events.js +++ b/server/sim/battle/events.js @@ -4,7 +4,7 @@ import { EVENT_BUFFER_CAP } from '../constants.js'; import { elementView } from '../damage.js'; -import { unitInfo, snapshotUnits } from '../snapshot.js'; +import { unitInfo, snapshotUnits, ammoView, wolfView, negView } from '../snapshot.js'; export class BattleEvents { fx(kind, params = {}) { @@ -37,7 +37,11 @@ export class BattleEvents { * down: [[id, respawnAt, respawnTime, state, row, col]] — operators that left the field waiting to redeploy (isDown): the * game time their respawn timer ends, its length (s), constants.js DOWN_STATE and the tile they lie on (and * come back on: _layBody — where they fell, or their home); - * elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView). + * elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView); + * ammo: [[id, rounds left, rounds in the magazine]] — a running ammo skill, whole rounds (snapshot.js ammoView): the segmented bar; + * wolves: [[id, 狼影 left, the talent's maximum]] — 伺夜's 狼群 (snapshot.js wolfView): the pips under the HP bar; + * neg: [[id, fill]] — the share of its cap a negative-HP pool holds (snapshot.js negView; 斩业星熊's 我执): the red bar. + * The last three are display only: no sim state reads them, and the nine-field unit tuples are unchanged. */ snapshot() { const snap = { @@ -60,13 +64,22 @@ export class BattleEvents { (down || (down = [])).push([u.id, r2(u.respawnAt), r2(Math.max(0, u.respawnAt - u.deathAt)), this._downState(u), ...this.restTile(u)]); } if (down) snap.down = down; - let elem = null; + let elem = null, ammo = null, wolves = null, neg = null; for (const u of this.units) { if (!u.alive || !u.deployed || u.hidden) continue; const v = elementView(u, this.time); if (v) (elem || (elem = [])).push([u.id, v[0], v[1], v[2], v[3]]); + const am = ammoView(u); + if (am) (ammo || (ammo = [])).push([u.id, am[0], am[1]]); + const wv = wolfView(u); + if (wv) (wolves || (wolves = [])).push([u.id, wv[0], wv[1]]); + const ng = negView(u); + if (ng) (neg || (neg = [])).push([u.id, ng]); } if (elem) snap.elem = elem; + if (ammo) snap.ammo = ammo; + if (wolves) snap.wolves = wolves; + if (neg) snap.neg = neg; return snap; } diff --git a/server/sim/content/kits/ops/chess_char_3_19-vigil.js b/server/sim/content/kits/ops/chess_char_3_19-vigil.js index 6295c5b1..7bbe88ae 100644 --- a/server/sim/content/kits/ops/chess_char_3_19-vigil.js +++ b/server/sim/content/kits/ops/chess_char_3_19-vigil.js @@ -43,6 +43,7 @@ export default { skill: null, trait: { hitsFn: (b, w) => Math.max(1, w.mem.wolves || 1) }, install(battle, w) { + w.mem.wolfCapacity = maxWolves; // (b.snap `wolves`, snapshot.js wolfView: the pips under the HP bar) const tal = w.def.talents || []; const wb = tal[0]?.bb ?? {}; const per = num(wb.block_cnt, num(wb['vigil_wolf_t_1_enhance[trigger].block_cnt'], 1)); diff --git a/server/sim/content/kits/ops/op-hsgma2.js b/server/sim/content/kits/ops/op-hsgma2.js index dcd1c29c..8f067ca8 100644 --- a/server/sim/content/kits/ops/op-hsgma2.js +++ b/server/sim/content/kits/ops/op-hsgma2.js @@ -180,6 +180,9 @@ export default { const capOf = () => num(t0.max_minus_hp_ratio, 2) * unit.s.maxHp; const quiet = num(t0['hsgma2_t_1[heal].interval'], 5); const regen = num(t0['hsgma2_t_1[heal].hp_recovery_per_sec_by_max_hp_ratio']); + // the pool as a share of its cap, read when a snapshot is taken (b.snap `neg`, snapshot.js negView): the green HP bar + // sits on the 1-HP floor in 我执, so the client draws the pool as the red bar — display only + unit.negFill = () => (unit.mem.hsEgo && unit.mem.hsEgo.pool > 0 ? unit.mem.hsEgo.pool / Math.max(1e-9, capOf()) : 0); const enter = (pool) => { unit.mem.hsEgo = { pool: Math.max(0, pool), lastHurt: battle.time }; battle.addBuff(unit, { key: EGO, status: 'healFree', flags: { noHeal: true, healFree: true }, tags: ['talent'] }); diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 77f8a7c4..1dc3f510 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -773,6 +773,7 @@ function wolfPack(bb, raw, def) { skill: null, trait: { hitsFn: (b, u) => Math.max(1, u.mem.shadows ?? 1) }, install(battle, unit) { + unit.mem.wolfCapacity = maxShadows; // (b.snap `wolves`, snapshot.js wolfView: the pips under the HP bar) // initial shadows: 伺夜's talent text ("初始两只"), else one below the maximum const ot = (ownerOf(unit)?.def?.talents || []).map((t) => t.description || '').join(' '); const mi = ot.match(/初始(两|二|\d+)只/); diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 8c1794bb..04217dd5 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -3,7 +3,8 @@ // b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total } // (hp of a countdown summon — unit.countdown, content/tokens.js startCountdown — is maxHp × the share of its life left) // UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, -// form?, skillIndex?, moduleId?, items?, standInFor?, diy? } (standInFor = the replaced operator's charId of a 补位 +// form?, skillIndex?, ammoSkill?, moduleId?, items?, standInFor?, diy? } (ammoSkill = an ally whose skill is an ammo +// magazine: the HP bar shows its rounds as cells, b.snap `ammo`; standInFor = the replaced operator's charId of a 补位 // stand-in; diy = a 自选 piece's pick { charId, skillIndex, uniEquipId } — defId is its slot, spine / avatar the operator's; // form = the unit's current model form — an enemy's, content/enemies/helpers.js setForm: // 掠海漂移体 'crawl', 暴鸰 'bombed', 转译基底·α's forms …; a 傀儡师 fighting as its 替身 'doll', professions.js — a view built @@ -48,6 +49,9 @@ export function unitInfo(u) { uid: u.uid ?? undefined, // DESIGN §16: the equipped skill's index (the renderer / audio pick that skill's Spine clip and sound) skillIndex: u.side === 'ally' && Number.isInteger(d.skill?.index) ? d.skill.index : undefined, + // an ally whose skill is an ammo magazine: the renderer shows it as the segmented bar under the HP bar (b.snap `ammo`) and + // draws no sustained skill aura for it — known from the unit's first appearance, before any snapshot or skill event + ammoSkill: u.side === 'ally' && u.skill?.kind === 'ammo' ? true : undefined, // DESIGN §16: an elite ally's equipped module (uniEquipId | 'none'; display only — a teammate's unit in a shared // field shows its owner's module in the detail card) moduleId: u.side === 'ally' && d.golden && typeof d.loadout?.moduleId === 'string' ? d.loadout.moduleId : undefined, @@ -118,6 +122,45 @@ export function unitTuple(u, t) { return [u.id, r2(u.x), r2(u.y), hp, maxHp, r1(sp), spMax, flagsOf(u), animOf(u, t)]; } +/** + * The three optional HUD readouts of b.snap (events.js snapshot(): `ammo`, `wolves`, `neg`; each only for a living, + * deployed, visible unit and only when it has one). They ride beside the 9-field tuples — the tuple, and the SP + * fraction the ammo skill still puts in it, stay as they are. + * + * `ammoView(u)` = `[rounds left, rounds in the magazine]` of a running ammo skill (whole numbers; the magazine is the + * activation's real total — extra bullets and refills included — that unitTuple's SP fraction runs on): the segmented + * yellow bar under the HP bar (隐现's S2 holds 14 rounds, skill_table skchr_inside_2 `attack@trigger_time`). + */ +export function ammoView(u) { + const sk = u.skill; + if (u.side !== 'ally' || !sk || !sk.active || sk.kind !== 'ammo') return null; + const left = Math.max(0, Math.ceil(sk.ammoLeft - 1e-9)); + return [left, Math.max(1, Math.ceil((sk.ammoMax || sk.ammo || 0) - 1e-9), left)]; +} + +/** + * `wolfView(u)` = `[狼影 left, the talent's maximum]` of 伺夜's 狼群 pack (content/tokens.js wolfPack and the managed kit + * chess_char_3_19-vigil.js keep the count in `mem.shadows` and the maximum in `mem.wolfCapacity`): the pips under the HP + * bar. A pack in its 战术点形态 is not alive and shows nothing. + */ +export function wolfView(u) { + const cap = u.mem?.wolfCapacity, n = u.mem?.shadows; + if (u.side !== 'ally' || !(cap >= 1) || !Number.isFinite(n)) return null; + const max = Math.round(cap); + return [Math.max(0, Math.min(max, Math.round(n))), max]; +} + +/** + * `negView(u)` = the share (0.01–1) of its cap that a unit's negative-HP pool holds, null without a pool: 斩业星熊's 我执 + * (kits/ops/op-hsgma2.js sets `unit.negFill`, the pool read when the snapshot is taken) — the red bar over the drained HP + * bar. The pool itself, and the 1-HP floor she stands on, are the sim's and are not touched. + */ +export function negView(u) { + if (typeof u.negFill !== 'function') return null; + const v = u.negFill(); + return v > 0 ? Math.min(1, Math.max(0.01, r2(v))) : null; +} + /** Units included in a snapshot: deployed & visible, plus recently dead ones (DIE animation). */ export function snapshotUnits(units, t) { const out = []; diff --git a/server/sim/units.js b/server/sim/units.js index 42412ab4..a948cabc 100644 --- a/server/sim/units.js +++ b/server/sim/units.js @@ -84,6 +84,9 @@ export class Unit { // a countdown summon's life on the field ({ from, until } battle times; content/tokens.js startCountdown, cleared by // every deployment): its bar shows the time left (snapshot.js unitTuple), its HP never moves (无敌 + 禁疗) this.countdown = null; + // a unit with a negative-HP pool (斩业星熊's 我执, kits/ops/op-hsgma2.js) sets this to a function returning the share of the + // pool's cap it holds (0–1): b.snap's `neg` list (snapshot.js negView) draws it as the red bar; display only + this.negFill = null; // knocked out, the unit lies — and redeploys — on its home tile instead of where it fell (Battle._layBody) while content // holds this: 乌尔比安 moved by his S3 (the owner's decision of 2026-10-07, a deviation from PRTS's "where it fell"); // every deployment clears it (battle/deploy.js _deploy) diff --git a/test/content/op_hsgma2.test.js b/test/content/op_hsgma2.test.js index a5e1e713..f5167ae9 100644 --- a/test/content/op_hsgma2.test.js +++ b/test/content/op_hsgma2.test.js @@ -300,6 +300,33 @@ test('S3 临死模式 (a 主动关闭 only — no strategy of this mode closes i } }); +// 社区反馈 (PR #383 by @Convey123): 「绿条被打空以后受到伤害就会涨红条,这里看不到红条」 — the official HP bar shows the 我执 pool as a red bar +// growing over the drained green one. The sim has kept the pool since 0.2.0 (unit.mem.hsEgo); the snapshot now lists its share of the +// cap (b.snap `neg`, snapshot.js negView — display only) and render/units.js draws it (test/render/hud-readouts.test.js). +test('T1 业火 我执: the pool reaches the client as b.snap `neg` — its share of the cap (2 × max HP) from the first lethal hit, growing with the damage, gone with 我执', () => { + const { h, u } = field({ tier: 6, elite: true, skill: 0, others: [{ uid: 2, chessId: TEXAS, row: 12, col: 9 }] }); + const e = h.spawn('enemy_dummy', { pos: [10, 8] }); + h.step(); + const max = u.s.maxHp; + const negOf = () => (h.b.snapshot().neg || []).find((x) => x[0] === u.id); + assert.equal(h.b.snapshot().neg, undefined, 'not in 我执: no list'); + h.b.dealDamage(e, u, { amount: u.hp + 300, type: 'true' }); + h.step(); + assert.ok(u.mem.hsEgo, '已进入 我执'); + const cap = 2 * max; // max_minus_hp_ratio + approx(negOf()[1], 300 / cap, 'the share of the cap', 0.02); + h.b.dealDamage(e, u, { amount: 400, type: 'true' }); + h.step(); + approx(negOf()[1], 700 / cap, 'it grew with the damage', 0.02); + assert.ok(negOf()[1] < 1, 'still below a full pool'); + assert.ok(u.hp / max < 0.01, 'her green bar sits on the 1-HP floor: the red bar is the whole story'); + assert.equal(h.b.snapshot().units.find((t) => t[0] === u.id).length, 9, 'the tuple is not widened'); + assert.ok(h.runUntil(() => !u.mem.hsEgo, 60), 'the quiet-time regeneration clears the pool'); + h.step(); + assert.equal(h.b.snapshot().neg, undefined, 'no pool, no red bar'); + done(h); +}); + test('T1 业火 我执: a lethal hit leaves her on the field (HP floor) with the excess as negative HP; later damage goes there (受击回复 SP still), 禁疗 meanwhile; 200 % of max HP knocks her out; 4 s without damage ⇒ 生命回复速度 5 %/s clears it and she leaves 我执', () => { for (const [tier, elite] of [[5, false], [6, true]]) { const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb; diff --git a/test/match/feedback1-runner-forms.test.js b/test/match/feedback1-runner-forms.test.js index 4d6336c8..fa4825ab 100644 --- a/test/match/feedback1-runner-forms.test.js +++ b/test/match/feedback1-runner-forms.test.js @@ -17,6 +17,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createBattleRunner, keepsState, compactHeld, HELD_MAX, EV_SLICE } from '../../public/js/battle/runner.js'; import { SnapshotBuffer } from '../../public/js/render/interp.js'; +import { keepEarly, audioEarly } from '../../public/js/screens/game/early.js'; +import { AudioManager } from '../../public/js/audio.js'; import { createStore, initialState } from '../../public/js/store.js'; import * as specMod from '../../server/sim/spec.js'; import { DataSource } from '../../server/sim/simdata.js'; @@ -243,6 +245,56 @@ test('screens/game.js buffers the form fx with the state-bearing events it repla assert.match(src, /lastFieldRef\.current = field\.fieldId;\s+reentryRef\.current = null;/); }); +// GitHub PR #292 (by @LimitlessHPPK) wanted the whole pre-entry buffer handed to the sound as well, so that a deploy-tick cast +// ahead of its unit's spawn would be heard. The review kept the hold of such a cast (audio.js `pendingSkill`, which the live +// path needs: `onEv` already forwards every list) and refused the replay: the buffer is every spawn, die, deploy, status and +// skill since the field's m.field (up to 1500 of them), so the sound would play the field's old deaths, deploy lines and casts +// all at once on entering. +test('entering a field late: the view replays every buffered state event, the sound only the spawn tuples that teach it who is there', () => { + const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8'); + assert.match(src, /view\.pushEvents\(early\);/, 'the view gets the buffered list'); + assert.match(src, /audio\.handleBattleEvents\(audioEarly\(early\)\);/, 'the sound gets audioEarly of it'); + assert.ok(!/audio\.handleBattleEvents\(early\)/.test(src), 'never the whole list'); + const stream = [ + ['atk', 1, 2, 'none'], // stale cosmetics: dropped by keepEarly + ['dmg', 2, 40, 'phys'], + ['skill', 1, 1], // a deploy-tick cast, ahead of its unit's spawn + ['spawn', { id: 1, side: 'ally', kind: 'op', spine: 'char_a', skillIndex: 2 }], + ['spawn', { id: 2, side: 'enemy', kind: 'enemy', spine: 'enemy_x' }], + ['deploy', 1], + ['skill', 1, 0], + ['fx', 'form', 2, 3, { id: 2, form: 'translator_youling' }], + ['die', 2, 'killed'], + ]; + const early = stream.filter(keepEarly); + assert.deepEqual(early.map((e) => e[0]), ['skill', 'spawn', 'spawn', 'deploy', 'skill', 'fx', 'die'], 'the pre-entry buffer keeps every state-bearing kind'); + assert.deepEqual(audioEarly(early).map((e) => e[0]), ['spawn', 'spawn'], 'the sound is told who is on the field, nothing else'); + assert.deepEqual(audioEarly(null), [], 'no buffer, nothing'); + // through a real AudioManager: the replay makes no sound at all (no old death, deploy line or cast) but tracks the units, + // so the first live cast after entering sounds + const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3', born: '/s/a_born.mp3' }, enemy_x: { die: '/s/x_die.mp3' } } }, voice: {} } }; + const a = new AudioManager({ win: null, getManifest: () => vm }); + a.ctx = {}; + const played = []; + a._play = (url) => { played.push(url); }; + a.voice = () => { played.push('voice'); return true; }; + a.setFieldUnits([]); + a.handleBattleEvents(audioEarly(early)); + assert.deepEqual(played, [], 'the replay is silent'); + assert.equal(a.units.size, 2, 'and has taught the sound both units'); + a.handleBattleEvents([['skill', 1, 1]]); + assert.ok(played.includes('/s/a_skill.mp3'), 'the next live cast of a known unit sounds'); + // (the contrast: the whole list would have played the buffered death, the deploy and the cast on entering) + const b = new AudioManager({ win: null, getManifest: () => vm }); + b.ctx = {}; + const heard = []; + b._play = (url) => { heard.push(url); }; + b.voice = () => true; + b.setFieldUnits([]); + b.handleBattleEvents(early); + assert.ok(heard.length >= 2, `the whole list would sound ${heard.length} old cues at once`); +}); + /** * The view's side too (render/app.js): the runner's frames go into the render engine's SnapshotBuffer, whose clock * follows the snapshots, and processEvents takes the due events with the 1.5 game s stale-cosmetic window — a form fx diff --git a/test/render/hud-readouts.test.js b/test/render/hud-readouts.test.js new file mode 100644 index 00000000..086581c7 --- /dev/null +++ b/test/render/hud-readouts.test.js @@ -0,0 +1,349 @@ +// test/render/hud-readouts.test.js — the HP-bar readouts, client side (R22-HUD; PR #303 and #378 by @IceCodeNew, PR #383 +// by @Convey123, folded into one change): render/interp.js carries b.snap `ammo` [[id, left, magazine]], `wolves` +// [[id, left, max]] and `neg` [[id, fill]] beside the unit tuples and samples them from the older snapshot; render/units.js +// draws the ammo skill's segmented yellow bar in place of the SP bar, 伺夜's 狼影 as a row of diamonds and the 我执 pool +// as the red bar over the drained HP bar; an ammo skill has no standing aura (headless fake PIXI, test/render/fakepixi.js). +// The server side is test/sim/hud-readouts.test.js and test/content/op_hsgma2.test.js. +// Run: node --test test/render/hud-readouts.test.js + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { installFakePixi, fakeViewCtx } from './fakepixi.js'; +import { presetCamera } from '../../public/js/render/projection.js'; +import { SnapshotBuffer, normalizeSnapshot } from '../../public/js/render/interp.js'; +import { renderInfo } from '../../public/js/render/app/info.js'; +import { UF } from '../../shared/constants.js'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; +import { unitInfo } from '../../server/sim/snapshot.js'; + +let fake, FX, UnitView, COLORS, AMMO_BAR, WOLF_PIPS; +before(async () => { + fake = installFakePixi(); + FX = await import('../../public/js/render/fx.js'); + ({ UnitView, AMMO_BAR, WOLF_PIPS } = await import('../../public/js/render/units.js')); + ({ COLORS } = await import('../../public/js/render/style.js')); +}); +after(() => { FX?.setSimProjectileSpeeds(null); fake.restore(); }); + +const cam = () => presetCamera('normal', { width: 1280, height: 720 }); +const tuple = (id, hp = 1000, extra = {}) => [id, 5, 10, hp, 1000, extra.sp ?? 0, extra.spMax ?? 0, extra.flags ?? 0, 0]; +const view = (info = {}, extra = {}) => new UnitView(fakeViewCtx(fake.P, { cam, ...extra }), + { id: 1, side: 'ally', kind: 'op', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT', ...info }); +const sample = (o = {}) => ({ x: 5, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, ...o }); +const frames = (v, n = 2) => { for (let i = 0; i < n; i++) v.update(1 / 60, cam(), i / 60); }; +const barWidth = (v) => v.hpBg.width - 2; + +describe('interp: ammo / wolves / neg', () => { + test('sampled from the older snapshot (they step at snapshot boundaries) and cleared by a snapshot without them', () => { + const buf = new SnapshotBuffer({ delay: 0 }); + buf.push({ t: 1, units: [tuple(1), tuple(2)], ammo: [[1, 13, 14]], wolves: [[2, 2, 3]], neg: [[2, 0.25]] }, 0); + buf.push({ t: 2, units: [tuple(1), tuple(2)], ammo: [[1, 12, 14]], wolves: [[2, 1, 3]], neg: [[2, 0.5]] }, 0.5); + buf.push({ t: 3, units: [tuple(1), tuple(2)] }, 1); + // between the first two snapshots nothing slides: a round, a 狼影 or a share of the pool is not halfway + const out = buf.sample(1.99); + assert.deepEqual([out.get(1).ammo, out.get(1).wolves, out.get(1).neg], [[13, 14], null, 0]); + assert.deepEqual([out.get(2).ammo, out.get(2).wolves, out.get(2).neg], [null, [2, 3], 0.25]); + buf.sample(2, out); + assert.deepEqual([out.get(1).ammo, out.get(2).wolves, out.get(2).neg], [[12, 14], [1, 3], 0.5]); + // a snapshot without the lists takes the readouts off the reused sample objects + buf.sample(3, out); + assert.deepEqual([out.get(1).ammo, out.get(2).wolves, out.get(2).neg], [null, null, 0]); + }); + + test('malformed rows are dropped: fractions, negatives, a count above its maximum, a maximum under 1, unknown ids, junk', () => { + const s = normalizeSnapshot({ + t: 1, units: [tuple(1), tuple(2), tuple(3)], + ammo: [[1, 13, 14], [2, -1, 14], [2, 2, 1], [2, 1.5, 4], [9, 1, 2], [3, 15, 14], null, 'x', [3, 0, 0]], + wolves: [[2, 3, 3], [1, 2.5, 3], [1, 4, 3], [3, 1, 0], [9, 1, 3]], + neg: [[1, 0.4], [2, 0], [3, -1], [3, 'x'], [9, 0.5], [2, 5], null], + }); + assert.deepEqual([...s.ammo], [[1, [13, 14]]]); + assert.deepEqual([...s.wolves], [[2, [3, 3]]]); + assert.deepEqual([...s.neg], [[1, 0.4], [2, 1]], 'a share above 1 is a full pool; 0 / negative / non-numbers are no pool'); + const none = normalizeSnapshot({ t: 1, units: [tuple(1)] }); + assert.deepEqual([none.ammo, none.wolves, none.neg], [null, null, null]); + assert.equal(none.units.get(1).length, 9, 'the tuples are never widened by the readouts'); + }); +}); + +describe('UnitView: the ammo skill\'s segmented bar', () => { + test('replaces the SP bar while the magazine runs: a yellow cell per round, the fill is rounds left / magazine, a thin cut at every boundary', () => { + const v = view(); + v.sync(sample({ flags: UF.SKILL, sp: 22.3, spMax: 24, ammo: [13, 14] })); + frames(v); + const bw = barWidth(v); + assert.equal(v.spBg.visible && v.spFill.visible, true); + assert.equal(v.spFill.tint, COLORS.ammo); + assert.ok(Math.abs(v.spFill.width - bw * 13 / 14) < 1e-9, 'rounds left over the magazine, exactly (not the 0.1-rounded SP)'); + const cuts = v.ammoCuts.filter((c) => c.visible); + assert.equal(cuts.length, 13, 'one cut per boundary of 14 cells'); + const step = bw / 14, x0 = v.spFill.position.x; + cuts.forEach((c, i) => { + assert.ok(Math.abs(c.position.x - (x0 + (i + 1) * step - 0.5)) < 1e-9, `cut ${i + 1} on the boundary`); + assert.equal(c.width, 1); + assert.equal(c.height, v.spFill.height); + assert.equal(c.position.y, v.spFill.position.y); + assert.equal(c.tint, COLORS.hpBack); + }); + assert.ok(v.spFill.height > v.hpFill.height * 0.6, 'taller than the plain SP bar (the HP bar\'s thickness × AMMO_BAR.height)'); + assert.ok(Math.abs(v.spFill.height - Math.max(2, v.hpFill.height * AMMO_BAR.height)) < 1e-9); + // a round is spent: the fill loses 1/14 and the cuts stay + v.sync(sample({ flags: UF.SKILL, sp: 20.6, spMax: 24, ammo: [12, 14] })); + frames(v); + assert.ok(Math.abs(v.spFill.width - bw * 12 / 14) < 1e-9); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 13); + // the last round, then the magazine ends: the plain SP bar is back (cyan, no cuts) + v.sync(sample({ flags: UF.SKILL, ammo: [0, 14], sp: 0, spMax: 24 })); + frames(v); + assert.equal(v.spFill.width, 0); + v.sync(sample({ flags: 0, sp: 6, spMax: 24, ammo: null })); + frames(v); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'no cuts without a magazine'); + assert.notEqual(v.spFill.tint, COLORS.ammo); + assert.ok(Math.abs(v.spFill.width - bw * 6 / 24) < 1e-9, 'the SP fraction of the tuple'); + v.destroy(); + }); + + test('a magazine needs no SP cost to show; a dense one keeps its exact fill and draws no cuts under AMMO_BAR.minCell px; an enemy never has the bar', () => { + const v = view(); + v.sync(sample({ flags: UF.SKILL, ammo: [20, 40] })); // spMax 0: an auto skill with no SP cost + frames(v); + const bw = barWidth(v); + assert.equal(v.spFill.visible, true, 'the bar shows without an SP cost'); + assert.ok(Math.abs(v.spFill.width - bw / 2) < 1e-9); + const cell = bw / 40; + assert.equal(v.ammoCuts.filter((c) => c.visible).length, cell >= AMMO_BAR.minCell ? 39 : 0, `${cell.toFixed(2)} px cells`); + v.sync(sample({ flags: UF.SKILL, ammo: [20, 400] })); + frames(v); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'under 2 px a cell: no cuts'); + assert.ok(Math.abs(v.spFill.width - bw * 20 / 400) < 1e-9, 'the fill is still exact'); + v.destroy(); + const e = view({ id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_x' }); + e.sync(sample({ hp: 500, flags: UF.SKILL, ammo: [3, 4] })); + frames(e); + assert.equal(e.spFill.visible, false, 'enemies have no SP bar, and so no magazine'); + assert.equal(e.ammoCuts.filter((c) => c.visible).length, 0); + e.destroy(); + }); + + test('the prep view draws no bars, so no cells either', () => { + const v = view({}, {}); + v.prep = true; + v.sync(sample({ flags: UF.SKILL, ammo: [5, 14] })); + frames(v); + assert.equal(v.spFill.visible, false); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0); + v.destroy(); + }); +}); + +describe('UnitView: 伺夜\'s 狼影 pips', () => { + const pack = (extra = {}) => view({ kind: 'token', defId: 'token_10028_vigil_wolf', ...extra }); + + test('one diamond per 狼影 the talent allows under the HP bar, the first ones lit for the shadows left', () => { + const v = pack(); + v.sync(sample({ wolves: [2, 3] })); + frames(v); + const r = v._wolfPips; + assert.equal(r.root.visible, true); + assert.equal(r.lit.length, 3); + assert.deepEqual(r.lit.map((d) => d.alpha), [1, 1, WOLF_PIPS.dim]); + assert.ok(r.lit.every((d) => d.visible && d.tint === COLORS.wolf && Math.abs(d.rotation - Math.PI / 4) < 1e-9), 'pale diamonds'); + assert.ok(r.back.every((d) => d.visible && d.tint === COLORS.hpBack), 'on a dark backing'); + assert.ok(r.lit[0].position.y > v.hpBg.position.y, 'below the HP bar'); + assert.ok(r.lit[0].position.x > v.hpFill.position.x && r.lit[2].position.x < v.hpFill.position.x + barWidth(v), 'inside the bar\'s span'); + assert.ok(r.lit[1].position.x > r.lit[0].position.x && r.lit[2].position.x > r.lit[1].position.x, 'left to right'); + assert.equal(v.spFill.visible, false, 'not an SP bar or a skill indicator'); + // a 狼影 is spent: the last lit one dims; one is gained: it lights again + v.sync(sample({ wolves: [1, 3] })); + frames(v); + assert.deepEqual(r.lit.map((d) => d.alpha), [1, WOLF_PIPS.dim, WOLF_PIPS.dim]); + v.sync(sample({ wolves: [3, 3] })); + frames(v); + assert.deepEqual(r.lit.map((d) => d.alpha), [1, 1, 1]); + // a smaller maximum hides the surplus diamonds + v.sync(sample({ wolves: [1, 2] })); + frames(v); + assert.equal(r.lit.filter((d) => d.visible).length, 2); + v.destroy(); + }); + + test('hidden without a count (an older snapshot cannot leave stale pips), in the prep view, and for enemies', () => { + const v = pack(); + v.sync(sample({ wolves: [2, 3] })); + frames(v); + assert.equal(v._wolfPips.root.visible, true); + v.sync(sample({})); + frames(v); + assert.equal(v._wolfPips.root.visible, false, 'no row in the snapshot: no pips'); + v.sync(sample({ wolves: [2, 3] })); + v.prep = true; + frames(v); + assert.equal(v._wolfPips.root.visible, false, 'the prep board draws no bars'); + v.destroy(); + const e = view({ id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_x' }); + e.sync(sample({ hp: 500, wolves: [2, 3] })); + frames(e); + assert.equal(e._wolfPips, null, 'only allies carry the row'); + e.destroy(); + }); + + test('the element gauge row moves down below the pips (and below the SP bar when there is one)', () => { + const plain = view(); + plain.sync(sample({ el: 'burn', elFill: 0.4, elUntil: 0, elDur: 0 })); + frames(plain); + const v = pack(); + v.sync(sample({ wolves: [2, 3], el: 'burn', elFill: 0.4, elUntil: 0, elDur: 0 })); + frames(v); + const pipsBottom = v._wolfPips.back[0].position.y + (v._wolfPips.back[0].width * Math.SQRT2) / 2; + assert.ok(v._elBar.bg.position.y - v._elBar.bg.height / 2 >= pipsBottom - 1e-6, 'the gauge row starts under the pips'); + assert.ok(v._elBar.bg.position.y > plain._elBar.bg.position.y + 4, 'and so sits lower than without pips'); + plain.destroy(); + v.destroy(); + }); +}); + +describe('UnitView: the 我执 pool as the red bar', () => { + const ego = (extra = {}) => view({ defId: 'char_1044_hsgma2', tier: 6, ...extra }); + + test('drawn over the drained HP bar from its left edge: width = the bar × the pool\'s share, the pool\'s red, gone with the pool', () => { + const v = ego(); + v.sync(sample({ hp: 1 })); + frames(v); + assert.equal(v.negFill.visible, false, 'no pool, no red bar'); + assert.equal(v.negFill.tint, COLORS.hpNeg); + v.sync(sample({ hp: 1, neg: 0.5 })); + frames(v); + assert.equal(v.negFill.visible, true); + assert.ok(Math.abs(v.negFill.width - barWidth(v) * 0.5) < 1e-9, 'half the pool: half the bar'); + assert.equal(v.negFill.position.x, v.hpFill.position.x, 'from the bar\'s left edge'); + assert.equal(v.negFill.position.y, v.hpFill.position.y); + assert.equal(v.negFill.height, v.hpFill.height); + assert.ok(v.hpFill.width < 1, 'the green bar is drained meanwhile'); + v.sync(sample({ hp: 1, neg: 1 })); + frames(v); + assert.ok(Math.abs(v.negFill.width - barWidth(v)) < 1e-9, 'a full pool fills the bar'); + v.sync(sample({ hp: 600, neg: 0 })); + frames(v); + assert.equal(v.negFill.visible, false, 'she left 我执'); + v.destroy(); + }); + + test('not in the prep view, and gone once she falls', () => { + const p = ego(); + p.prep = true; + p.sync(sample({ hp: 1, neg: 0.6 })); + frames(p); + assert.equal(p.negFill.visible, false); + p.destroy(); + const v = ego(); + v.sync(sample({ hp: 1, neg: 1 })); + frames(v); + assert.equal(v.negFill.visible, true); + v.die(); + for (let i = 0; i < 300; i++) v.update(1 / 60, cam(), i / 60); + assert.equal(v.alive, false); + assert.equal(v.negFill.visible, false, 'no bars for a knocked-out operator'); + v.destroy(); + }); +}); + +describe('an ammo skill has no standing aura', () => { + const unit = (id, info) => ({ id, x: 3, y: 10, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, isEnemy: false, maxHp: 3000, info, onHit() {} }); + const makeFx = (views) => { + const ctx = fakeViewCtx(fake.P); + const map = new Map(views.map((v) => [v.id, v])); + const c = presetCamera('normal', { width: 1600, height: 900 }); + return new FX.FxSystem({ + P: fake.P, layers: ctx.layers, cam: () => c, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true }, + timeScale: () => 2, loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null, + screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120, + }); + }; + + test('UnitInfo.ammoSkill: the activation burst plays, the ground glow and hex do not (a plain skill keeps both)', () => { + const magazine = unit(1, { defId: 'char_x', ammoSkill: true }), plain = unit(2, { defId: 'char_y' }); + const fx = makeFx([magazine, plain]); + fx.skill(magazine, true); + assert.ok(fx.parts.length > 0 && fx.rings.length > 0, 'the cast itself is still shown'); + assert.equal(fx.auras.size, 0, 'no glow and hex under a magazine'); + fx.skill(magazine, false); + fx.skill(plain, true); + assert.equal(fx.auras.size, 1, 'a plain skill keeps its aura'); + fx.destroy(); + }); + + test('the flag travels: unitInfo (ammo skills of allies only) → renderInfo', () => { + const h = makeBattle({ + defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }, timeLimit: 30, seed: 7, + units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 11, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + }); + h.run(0.5); + assert.equal(renderInfo(unitInfo(h.unit('chess_char_1_01_a'))).ammoSkill, true); + assert.equal(renderInfo(unitInfo(h.unit('chess_char_1_02_a'))).ammoSkill, undefined); + assert.equal(renderInfo(unitInfo(h.enemy('enemy_dummy'))).ammoSkill, undefined); + assert.equal(renderInfo({ id: 1, ammoSkill: 'yes' }).ammoSkill, undefined, 'only a real true counts'); + }); +}); + +describe('the chain: sim → snapshot → SnapshotBuffer → UnitView', () => { + test('隐现 S2: 14 cells, 13 after the first round, one more gone per attack; no aura; the plain SP bar after the last round', () => { + const h = makeBattle({ + defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }, + units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + timeLimit: 200, seed: 7, + }); + const u = h.unit('chess_char_1_01_a'); + assert.ok(h.runUntil(() => u.skill.active, 80)); + const v = new UnitView(fakeViewCtx(fake.P, { cam }), renderInfo(unitInfo(u))); + const buffer = new SnapshotBuffer({ delay: 0 }); + const show = () => { + const snap = h.b.snapshot(); + buffer.push(snap, snap.t); + v.sync(buffer.sample(snap.t).get(u.id), snap.t); + v.update(1 / 60, cam(), h.b.time); + }; + show(); + const bw = barWidth(v); + assert.equal(u.skill.ammoLeft, 13, 'the activation tick already fired the first round'); + assert.ok(Math.abs(v.spFill.width - bw * 13 / 14) < 1e-9); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 13); + const left = u.skill.ammoLeft; + assert.ok(h.runUntil(() => u.skill.ammoLeft < left && u.skill.active, 10)); + show(); + assert.ok(Math.abs(v.spFill.width - bw * 12 / 14) < 1e-9, 'one attack removes exactly one round'); + assert.ok(h.runUntil(() => !u.skill.active, 60)); + show(); + show(); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'the magazine is spent: the plain SP bar is back'); + assert.notEqual(v.spFill.tint, COLORS.ammo); + v.destroy(); + }); + + test('伺夜\'s placed pack: the pips follow 2 → 3 → 2 and are gone when the pack falls', () => { + const h = makeBattle({ + units: [{ chessId: 'chess_char_3_19_a', row: 12, col: 3, uid: 1 }, { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 11, col: 5, uid: 2 }], + timeLimit: 120, defs: { enemies: { enemy_d: enemyRec({ key: 'enemy_d', hp: 1e9, speed: 0, atk: 0 }) } }, enemies: [{ key: 'enemy_d', pos: [1, 1] }], + }); + h.run(0.2); + const w = h.b.allyUnits.find((x) => x.defId === 'token_10028_vigil_wolf'); + const v = new UnitView(fakeViewCtx(fake.P, { cam }), renderInfo(unitInfo(w))); + const buffer = new SnapshotBuffer({ delay: 0 }); + const lit = () => { + const snap = h.b.snapshot(); + buffer.push(snap, snap.t); + v.sync(buffer.sample(snap.t).get(w.id), snap.t); + v.update(1 / 60, cam(), h.b.time); + return v._wolfPips ? v._wolfPips.lit.filter((d) => d.visible && d.alpha === 1).length : 0; + }; + assert.equal(lit(), 2); + h.run(25); + assert.equal(lit(), 3); + h.b.dealDamage(null, w, { amount: 1e9, type: 'true' }); + h.step(); // (a snapshot of the same game time as the last one would be dropped as a duplicate) + assert.equal(lit(), 2); + v.destroy(); + }); +}); diff --git a/test/render/initialization.browser.test.js b/test/render/initialization.browser.test.js new file mode 100644 index 00000000..b8ba3d17 --- /dev/null +++ b/test/render/initialization.browser.test.js @@ -0,0 +1,343 @@ +// Real renderer startup and cancellation, without downloaded art. +// RENDER_E2E=1 node --test test/render/initialization.browser.test.js +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const OUT = path.join(ROOT, 'test/e2e/out'); +const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const skip = process.env.RENDER_E2E === '1' && existsSync(CHROME) ? false : 'set RENDER_E2E=1 (needs Chrome)'; +const HTML = ` +
`; + +// These wrappers keep the real browser/Pixi behavior and record resources owned by each view. +function trackBrowserResources() { + const state = window.__initialization = { apps: [], observers: [], listeners: [], calls: 0, settled: 0 }; + const timers = new Map(); + const set = window.setTimeout.bind(window), clear = window.clearTimeout.bind(window); + window.setTimeout = (fn, ms, ...args) => { + const id = set(() => { timers.delete(id); fn(...args); }, ms); + if (ms === 12000) timers.set(id, () => fn(...args)); + return id; + }; + window.clearTimeout = (id) => { timers.delete(id); clear(id); }; + state.expire = () => { + for (const [id, fn] of [...timers]) { clear(id); timers.delete(id); fn(); } + }; + const NativeObserver = window.ResizeObserver; + window.ResizeObserver = class extends NativeObserver { + constructor(fn) { super(fn); this.targets = new Set(); state.observers.push(this); } + observe(target, ...args) { this.targets.add(target); return super.observe(target, ...args); } + unobserve(target) { this.targets.delete(target); return super.unobserve(target); } + disconnect() { this.targets.clear(); return super.disconnect(); } + }; + const add = EventTarget.prototype.addEventListener, remove = EventTarget.prototype.removeEventListener; + const tracked = (target, type) => target instanceof HTMLCanvasElement || + (target === document && type === 'visibilitychange') || (target === window && type === 'resize'); + const capture = (opts) => typeof opts === 'boolean' ? opts : !!opts?.capture; + EventTarget.prototype.addEventListener = function (type, fn, opts) { + if (tracked(this, type) && !state.listeners.some((r) => r.target === this && r.type === type && r.fn === fn && r.capture === capture(opts))) { + state.listeners.push({ target: this, type, fn, capture: capture(opts) }); + } + return add.call(this, type, fn, opts); + }; + EventTarget.prototype.removeEventListener = function (type, fn, opts) { + state.listeners = state.listeners.filter((r) => !(r.target === this && r.type === type && r.fn === fn && r.capture === capture(opts))); + return remove.call(this, type, fn, opts); + }; + state.snapshot = () => ({ + apps: state.apps.map(({ app, ticker, canvas }) => ({ destroyed: app.renderer === null, ticking: ticker.started, attached: canvas.isConnected })), + observers: state.observers.filter((r) => r.targets.size).length, + listeners: state.listeners.map((r) => r.type), + canvases: document.querySelectorAll('#host canvas').length, + fallbacks: document.querySelectorAll('#host .ff').length, + }); +} + +function trackPixiApplications() { + const Application = PIXI.Application; + PIXI.Application = class extends Application { + constructor(...args) { + super(...args); + window.__initialization.apps.push({ app: this, ticker: this.ticker, canvas: this.view }); + } + }; +} + +const FACTORY_WRAPPER = `export * from '/js/render/app.js?implementation'; +import { createFieldView as create } from '/js/render/app.js?implementation'; +export async function createFieldView(...args) { + window.__initialization.calls++; + if (window.__initialization.ignoreSignal) args[1] = { ...args[1], signal: undefined }; + try { return await create(...args); } + finally { window.__initialization.settled++; } +}`; + +describe('field initialization in headless Chrome', { skip }, () => { + let srv, browser, pixi; + before(async () => { + const puppeteer = (await import('puppeteer-core')).default; + const { startServer } = await import('../../server/index.js'); + srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] }); + pixi = readFileSync(path.join(ROOT, 'public/vendor/pixi.min.js'), 'utf8') + `\n;(${trackPixiApplications.toString()})();`; + }); + after(async () => { await browser?.close(); await srv?.close(); }); + + async function open(t, { holdPixi = false } = {}) { + const page = await browser.newPage(); + t.after(() => page.close()); + const errors = []; + page.on('pageerror', (err) => errors.push(err.message)); + await page.setViewport({ width: 1100, height: 750 }); + await page.evaluateOnNewDocument(trackBrowserResources); + await page.setRequestInterception(true); + let releasePixi; + const requested = new Promise((resolve) => { + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.pathname === '/initialization-test') return request.respond({ status: 200, contentType: 'text/html', body: HTML }); + if (url.pathname === '/js/render/app.js' && !url.search) return request.respond({ status: 200, contentType: 'text/javascript', body: FACTORY_WRAPPER }); + if (url.pathname === '/vendor/pixi.min.js') { + releasePixi = () => request.respond({ status: 200, contentType: 'text/javascript', body: pixi }); + resolve(); + if (!holdPixi) return releasePixi(); + return; + } + return request.continue(); + }); + }); + await page.goto(`http://127.0.0.1:${srv.port}/initialization-test?board=2d`); + // Empty manifests prevent optional local art requests while preserving the real asset store. + await page.evaluate(async () => { + const { assets } = await import('/js/assets.js'); + assets.seed({ version: 1, chars: {}, enemies: {}, tokens: {}, items: {} }); + assets.seedLocal({ version: 1, files: {} }); + const { data } = await import('/js/data.js'); + await data.load('stages'); + window.__initialization.stage = data.list('stages')[0]; + window.__initialization.host = document.getElementById('host'); + }); + return { page, errors, requested, release: () => releasePixi() }; + } + + async function start(page) { + await page.evaluate(async () => { + const { mountFieldView } = await import('/js/ui/fieldHost.js'); + const s = window.__initialization; + s.mount = mountFieldView(s.host).then((view) => { + s.view = view; + view.setStage(s.stage); + view.setPrep({ board: [], hand: [], temp: [] }, { editable: true }); + s.clicks = []; + view.on('tileClick', (click) => s.clicks.push([click.row, click.col])); + }); + }); + } + + async function pixiFinished(page) { + await page.evaluate(async () => { + const { ensurePixi } = await import('/js/render/app/pixi.js'); + await ensurePixi(); + await new Promise(requestAnimationFrame); + }); + } + + async function clickFallback(page) { + await page.waitForSelector('#host .ff-tile--floor'); + const tile = await page.$eval('#host .ff-tile--floor', (element) => { + const r = element.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2, row: +element.dataset.row, col: +element.dataset.col }; + }); + await page.mouse.click(tile.x, tile.y); + return [tile.row, tile.col]; + } + + test('successful startup owns one running app and destroy releases its browser resources', async (t) => { + const { page, errors } = await open(t); + await start(page); + await page.waitForFunction(() => window.__initialization.view?.kind === 'engine'); + const mounted = await page.evaluate(() => window.__initialization.snapshot()); + assert.equal(mounted.canvases, 1); + assert.deepEqual(mounted.apps, [{ destroyed: false, ticking: true, attached: true }]); + assert.equal(mounted.observers, 1); + assert.ok(mounted.listeners.includes('pointerdown')); + assert.ok(mounted.listeners.includes('visibilitychange')); + const destroyed = await page.evaluate(() => { + const s = window.__initialization; + s.view.destroy(); s.view.destroy(); + return s.snapshot(); + }); + assert.deepEqual(destroyed, { apps: [{ destroyed: true, ticking: false, attached: false }], observers: 0, listeners: [], canvases: 0, fallbacks: 0 }); + assert.deepEqual(errors, []); + }); + + for (const ignoreSignal of [false, true]) test(`a timed-out Pixi load cannot cover the clickable fallback (${ignoreSignal ? 'legacy factory ignores cancellation' : 'cancellable factory'})`, async (t) => { + const { page, errors, requested, release } = await open(t, { holdPixi: true }); + await page.evaluate((value) => { window.__initialization.ignoreSignal = value; }, ignoreSignal); + await start(page); + await requested; + await page.evaluate(() => window.__initialization.expire()); + await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback'); + const target = await clickFallback(page); + assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]); + await release(); + await pixiFinished(page); + await page.waitForFunction(() => window.__initialization.settled === 1); + await clickFallback(page); + const late = await page.evaluate(() => ({ ...window.__initialization.snapshot(), clicks: window.__initialization.clicks })); + assert.deepEqual(late.clicks, [target, target], 'real pointer input still reaches the fallback after Pixi finishes'); + assert.equal(late.canvases, 0, 'the abandoned engine must not append an orphan canvas'); + assert.equal(late.fallbacks, 1); + assert.ok(late.apps.every((app) => app.destroyed && !app.ticking && !app.attached)); + assert.deepEqual(late.listeners, []); + assert.equal(late.observers, 1, 'only the fallback observes the host'); + if (!ignoreSignal) { + mkdirSync(OUT, { recursive: true }); + await page.screenshot({ path: path.join(OUT, 'render-initialization-fallback.png') }); + } + const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); }); + assert.equal(destroyed.observers, 0); + assert.equal(destroyed.canvases, 0); + assert.equal(destroyed.fallbacks, 0); + assert.deepEqual(errors, []); + }); + + test('timeout during board-art startup immediately releases the allocated app and permits a fresh engine', async (t) => { + const { page, errors } = await open(t); + await page.evaluate(async () => { + const { assets } = await import('/js/assets.js'); + const s = window.__initialization, local = assets.local.bind(assets); + assets.local = () => { + if (!s.apps.length) return local(); + assets.local = local; + s.artBlocked = true; + return new Promise((resolve) => { s.releaseArt = async () => resolve(await local()); }); + }; + }); + await start(page); + await page.waitForFunction(() => window.__initialization.artBlocked); + const allocated = await page.evaluate(() => window.__initialization.snapshot()); + assert.deepEqual(allocated.apps, [{ destroyed: false, ticking: true, attached: true }]); + await page.evaluate(() => window.__initialization.expire()); + await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback'); + const cancelled = await page.evaluate(() => window.__initialization.snapshot()); + assert.deepEqual(cancelled.apps, [{ destroyed: true, ticking: false, attached: false }], 'cancellation frees the real renderer before the delayed art resolves'); + assert.equal(cancelled.canvases, 0); + assert.equal(cancelled.observers, 1); + assert.deepEqual(cancelled.listeners, []); + const target = await clickFallback(page); + assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]); + await page.evaluate(async () => { + const s = window.__initialization; + await s.releaseArt(); + s.view.destroy(); + s.view = null; + }); + await page.waitForFunction(() => window.__initialization.settled === 1); + await start(page); + await page.waitForFunction(() => window.__initialization.view?.kind === 'engine'); + const replacement = await page.evaluate(async () => { + await new Promise(requestAnimationFrame); + const s = window.__initialization, app = s.view.raw.debug.app; + app.renderer.render(app.stage); + return s.snapshot(); + }); + assert.deepEqual(replacement.apps, [ + { destroyed: true, ticking: false, attached: false }, + { destroyed: false, ticking: true, attached: true }, + ], 'a replacement engine can render with the surviving shared caches'); + assert.equal(replacement.canvases, 1); + const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); }); + assert.ok(destroyed.apps.every((app) => app.destroyed && !app.ticking && !app.attached)); + assert.equal(destroyed.observers, 0); + assert.deepEqual(destroyed.listeners, []); + assert.equal(destroyed.canvases, 0); + assert.deepEqual(errors, []); + }); + + test('a setup exception releases partial resources without removing another host child', async (t) => { + const { page, errors } = await open(t); + await page.evaluate(async () => { + const { assets } = await import('/js/assets.js'); + const s = window.__initialization, subscribe = assets.onChange.bind(assets); + s.sibling = document.createElement('span'); + s.sibling.textContent = 'Existing host content'; + s.host.appendChild(s.sibling); + // Fail after input and resize resources exist, while the real view is still being built. + assets.onChange = (fn) => { + if (s.observers.some((observer) => observer.targets.has(s.host))) { + assets.onChange = subscribe; + throw new Error('asset subscription unavailable'); + } + return subscribe(fn); + }; + }); + await start(page); + await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback'); + const failed = await page.evaluate(() => ({ ...window.__initialization.snapshot(), sibling: window.__initialization.sibling.isConnected })); + assert.deepEqual(failed.apps, [{ destroyed: true, ticking: false, attached: false }]); + assert.equal(failed.sibling, true, 'failed initialization only cleans up its own resources'); + assert.equal(failed.canvases, 0); + assert.equal(failed.observers, 1); + assert.deepEqual(failed.listeners, []); + const target = await clickFallback(page); + assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]); + const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); }); + assert.equal(destroyed.observers, 0); + assert.equal(destroyed.fallbacks, 0); + assert.deepEqual(errors, []); + }); + + test('unmount during startup cannot erase a newer hook mount on the same host', async (t) => { + const { page, errors, requested, release } = await open(t, { holdPixi: true }); + await page.evaluate(async () => { + const { h, render } = await import('/vendor/preact.module.js'); + const { useFieldView } = await import('/js/ui/fieldHost.js'); + const s = window.__initialization; + function Field() { s.hook = useFieldView({ current: s.host }); return null; } + s.renderHook = (key) => render(key ? h(Field, { key }) : null, document.getElementById('hooks')); + s.renderHook('first'); + }); + await requested; + await page.evaluate(() => { + const s = window.__initialization; + s.renderHook(null); + window.__SP_RENDER__ = 'fallback'; + s.renderHook('second'); + }); + await page.waitForFunction(() => window.__initialization.hook?.kind === 'fallback'); + await page.evaluate(() => { + const s = window.__initialization; + s.current = s.hook.view; + s.current.setStage(s.stage); + s.current.setPrep({ board: [], hand: [], temp: [] }, { editable: true }); + s.clicks = []; + s.current.on('tileClick', (click) => s.clicks.push([click.row, click.col])); + s.expire(); + }); + await release(); + await pixiFinished(page); + await page.waitForFunction(() => window.__initialization.settled === 1); + const late = await page.evaluate(() => ({ ...window.__initialization.snapshot(), current: window.__SP_VIEW__ === window.__initialization.current })); + assert.equal(late.fallbacks, 1, 'the old mount must not clear the newer view from the shared host'); + assert.equal(late.canvases, 0); + assert.equal(late.current, true); + assert.ok(late.apps.every((app) => app.destroyed && !app.ticking && !app.attached)); + const target = await clickFallback(page); + assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]); + const destroyed = await page.evaluate(() => { + window.__initialization.renderHook(null); + return { ...window.__initialization.snapshot(), current: window.__SP_VIEW__ }; + }); + assert.equal(destroyed.current, null); + assert.equal(destroyed.observers, 0); + assert.equal(destroyed.canvases, 0); + assert.equal(destroyed.fallbacks, 0); + assert.deepEqual(destroyed.listeners, []); + assert.deepEqual(errors, []); + }); +}); diff --git a/test/render/unitview.test.js b/test/render/unitview.test.js index 26e426b9..6fd882ea 100644 --- a/test/render/unitview.test.js +++ b/test/render/unitview.test.js @@ -279,6 +279,77 @@ describe('enemy preview pen figures (lod idle)', () => { }); }); +// The impostor refreshes of a frame are spread evenly over the interval (app.js impostorSlot: each frame the impostor +// units take slots 0, 1, 2 … in update order): a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units. With a random phase per +// unit, the busiest frame did 20–66% more refreshes than that (16–48 units, intervals 2–6) — the frame-time peaks of a +// crowded battle on a slow device. +describe('impostor refresh slots', () => { + /** n pen figures (lod idle: interval 3) in one context; `slotOf(frame, i)` gives view i its slot, or null: no slots. */ + async function crowd(n, slotOf) { + let frame = 0; + let i = 0; + const atlas = { + alloc: (w, h) => ({ w, h, tex: new fake.P.Texture(), clip: false }), + free() {}, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() {}, + }; + const ctx = fakeViewCtx(fake.P, { + assets: store({ spine: true }), cam, frameNo: () => frame, impostors: atlas, + renderer: { resolution: 1, render() {} }, + ...(slotOf ? { impostorSlot: () => slotOf(frame, i) } : {}), + }); + const views = []; + for (let k = 0; k < n; k++) { + views.push(new UnitView(ctx, { id: `e:${k}`, side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', tier: 1, x: 9, y: 15, maxHp: 1, facing: -1 }, { prep: true, lod: 'idle' })); + } + await tick(); await tick(); + const refreshed = views.map(() => []); + views.forEach((v, k) => { + assert.ok(v.spineReady, 'spine ready'); + const upd = v.actor.update.bind(v.actor); + v.actor.update = (dt) => { refreshed[k].push(frame); return upd(dt); }; + }); + const step = () => { views.forEach((v, k) => { i = k; v.update(1 / 60, cam(), frame / 60); }); frame++; }; + return { step, refreshed, frames: () => frame }; + } + const perFrame = (refreshed, from, to) => { + const c = new Map(); + for (const list of refreshed) for (const f of list) if (f >= from && f < to) c.set(f, (c.get(f) || 0) + 1); + return Array.from({ length: to - from }, (_, j) => c.get(from + j) || 0); + }; + + test('slots in update order: every frame refreshes ⌊n/3⌋ or ⌈n/3⌉ units, each unit every 3rd frame', async () => { + for (const n of [7, 16, 24]) { + const { step, refreshed } = await crowd(n, (frame, k) => k); + for (let f = 0; f < 31; f++) step(); + // frame 0: every new impostor is drawn once (dirty); from frame 1 on, the interval rules + const counts = perFrame(refreshed, 1, 31); + assert.ok(counts.every((c) => c === Math.floor(n / 3) || c === Math.ceil(n / 3)), `${n} units: ${counts.join(' ')}`); + for (const list of refreshed) { + const after = list.filter((f) => f >= 1); + assert.equal(after.length, 10, `each unit refreshed 10 times in 30 frames (${after.join(',')})`); + assert.ok(after.every((f, j) => j === 0 || f - after[j - 1] === 3), 'exactly every 3rd frame'); + } + } + }); + + test('a slot that moves with the frame never starves a unit: refreshed at least every 2 intervals', async () => { + // (frame + slot) % 3 is never 0: the turn alone would never come + const { step, refreshed } = await crowd(3, (frame) => 4 - (frame % 3)); + for (let f = 0; f < 31; f++) step(); + for (const list of refreshed) { + const after = list.filter((f) => f >= 1); + assert.ok(after.length >= 5, `refreshed ${after.length} times`); + assert.ok([0, ...after].every((f, j, a) => j === 0 || f - a[j - 1] <= 6), `gaps ≤ 6 frames (${after.join(',')})`); + } + }); + + test('without slots (a context that has none) the random phase still refreshes every 3rd frame', async () => { + const { step, refreshed } = await crowd(5, null); + for (let f = 0; f < 31; f++) step(); + for (const list of refreshed) assert.equal(list.filter((f) => f >= 1).length, 10); + }); +}); + // Player report 2026-10-05: 「无人机等飞行单位贴图位置明显偏低」, and the follow-up "绝对不止 0.35" with an official // screenshot of 帝国炮火先兆者 over a tile (PR #211 by @xcdoge; the owner's decision of 2026-10-06). The lift is the // official client's own single constant — Vector3(0, 0.35, 0) written by Torappu.Battle.CharacterAnimator's constructor diff --git a/test/sim/hud-readouts.test.js b/test/sim/hud-readouts.test.js new file mode 100644 index 00000000..68358dcb --- /dev/null +++ b/test/sim/hud-readouts.test.js @@ -0,0 +1,97 @@ +// test/sim/hud-readouts.test.js — the optional b.snap lists behind the HP-bar readouts (R22-HUD; PR #303 and #378 by +// @IceCodeNew, PR #383 by @Convey123): `ammo` (a running ammo skill's rounds — 隐现's S2 holds 14 in the data), `wolves` +// (伺夜's 狼群 count) and, in test/content/op_hsgma2.test.js, `neg` (斩业星熊's 我执 pool). All three are display only: the +// nine-field unit tuples are unchanged and no sim state reads them. The client side is test/render/hud-readouts.test.js. +// Run: node --test test/sim/hud-readouts.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { unitInfo, unitTuple } from '../../server/sim/snapshot.js'; + +const DUMMY = { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }; +const INSIDE = 'chess_char_1_01_a'; // 隐现: the chess skill index 1 is skchr_inside_2 (AMMO, attack@trigger_time 14) + +test('隐现 S2: b.snap `ammo` carries the whole rounds left and the 14-round magazine of the data; the tuple keeps its SP fraction', () => { + const h = makeBattle({ + defs: DUMMY, timeLimit: 200, seed: 7, + units: [{ chessId: INSIDE, row: 10, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + }); + const u = h.unit(INSIDE); + const row = () => (h.b.snapshot().ammo || []).find((e) => e[0] === u.id); + assert.equal(h.b.snapshot().ammo, undefined, 'no skill running: no list at all'); + assert.equal(unitInfo(u).ammoSkill, true, 'UnitInfo says the skill is an ammo magazine from the first appearance'); + assert.ok(h.runUntil(() => u.skill.active, 80)); + assert.equal(u.skill.kind, 'ammo'); + assert.equal(u.skill.ammoMax, 14, 'skill_table skchr_inside_2 attack@trigger_time at the chess skill level'); + // the activation tick already fired the first round + assert.deepEqual(row(), [u.id, 13, 14]); + const [, , , , , sp, spMax] = unitTuple(u, h.b.time); + assert.ok(Math.abs(sp / spMax - 13 / 14) < 0.01, 'the 9-field tuple still runs the SP bar on the same fraction'); + assert.equal(h.b.snapshot().units.find((t) => t[0] === u.id).length, 9, 'the tuple is not widened'); + // every attack spends one round + const before = u.skill.ammoLeft; + assert.ok(h.runUntil(() => u.skill.ammoLeft < before, 10)); + assert.deepEqual(row(), [u.id, u.skill.ammoLeft, 14]); + // a refill above the magazine raises it (拉特兰's extras, 逃犯引渡手续, refills: the activation's real total) + u.skill.addAmmo(20); + assert.deepEqual(row(), [u.id, u.skill.ammoLeft, u.skill.ammoMax]); + assert.ok(u.skill.ammoMax > 14 && row()[1] === row()[2], 'full again at the new mark'); + // the skill ends with its last round: the list goes, the plain SP bar is back + assert.ok(h.runUntil(() => !u.skill.active, 60)); + assert.equal(h.b.snapshot().ammo, undefined); + checkInvariants(h.b); + assert.deepEqual(h.b.errors, []); +}); + +test('ammo rows: whole numbers (a fractional count rounds up), allies only, living deployed units only; an operator without an ammo skill is no ammoSkill', () => { + const h = makeBattle({ + defs: DUMMY, timeLimit: 200, seed: 7, + units: [{ chessId: INSIDE, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 11, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + }); + const u = h.unit(INSIDE); + h.run(0.5); + assert.equal(unitInfo(h.unit('chess_char_1_02_a')).ammoSkill, undefined, 'a duration skill is not a magazine'); + assert.equal(unitInfo(h.enemy('enemy_dummy')).ammoSkill, undefined); + assert.ok(h.runUntil(() => u.skill.active, 80)); + u.skill.ammoLeft = 6.2; // (a kit that spends a share of a round) + const e = h.b.snapshot().ammo.find((r) => r[0] === u.id); + assert.deepEqual(e, [u.id, 7, 14]); + assert.ok(Number.isInteger(e[1]) && Number.isInteger(e[2])); + u.deployed = false; // off the field: no row (the unit is not in the snapshot either) + assert.equal(h.b.snapshot().ammo, undefined); + u.deployed = true; +}); + +for (const placed of [false, true]) { + test(`伺夜's ${placed ? 'placed' : 'automatic'} pack: b.snap \`wolves\` follows the 狼影 count (2 of 3, +1 per 25 s, one lost per lethal hit) and goes with the pack`, () => { + const units = [{ chessId: 'chess_char_3_19_a', row: 12, col: 3, uid: 1 }]; + if (placed) units.push({ kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 11, col: 5, uid: 2 }); + const h = makeBattle({ + units, timeLimit: 120, + defs: { enemies: { enemy_d: enemyRec({ key: 'enemy_d', hp: 1e9, speed: 0, atk: 0 }) } }, + enemies: [{ key: 'enemy_d', pos: [1, 1] }], + }); + h.run(0.2); + const w = h.b.allyUnits.find((u) => u.defId === 'token_10028_vigil_wolf'); + const count = () => (h.b.snapshot().wolves || []).find((e) => e[0] === w.id); + assert.deepEqual(count(), [w.id, 2, 3], '初始两只, at most 3 (the talent text)'); + assert.equal(h.b.snapshot().wolves.length, 1, 'only the pack has the row: 伺夜 herself does not'); + h.run(25); + assert.deepEqual(count(), [w.id, 3, 3], '+1 every 25 s'); + for (const left of [2, 1]) { + h.b.dealDamage(null, w, { amount: 1e9, type: 'true' }); + assert.deepEqual(count(), [w.id, left, 3], 'a lethal hit spends one 狼影 and refills the HP'); + } + h.b.dealDamage(null, w, { amount: 1e9, type: 'true' }); + assert.equal(w.alive, false, 'the last 狼影: 战术点形态 (the pack is out of the fight)'); + assert.equal(count(), undefined, 'a pack in that form shows no pips'); + h.run(26); + assert.deepEqual(count(), [w.id, 1, 3], 'back after the 狼影 interval with one 狼影'); + h.b.retreat(h.unit('chess_char_3_19_a'), { reason: 'expired', permanent: true }); + assert.equal(h.b.snapshot().wolves, undefined, 'the pack leaves with its owner'); + assert.deepEqual(h.b.errors, []); + }); +} diff --git a/test/ui/audio.test.js b/test/ui/audio.test.js index addb8c96..d931f2f2 100644 --- a/test/ui/audio.test.js +++ b/test/ui/audio.test.js @@ -577,6 +577,89 @@ describe('AudioManager', () => { } finally { globalThis.fetch = origFetch; } } }); + + // 「技能音效有时候不触发」 (PR #292 by @LimitlessHPPK): the sim emits the cast of a deployment that fires inside its own first + // tick (`initSp` already at `spCost`, 宴's deploy-timed skill, 银灰 S3 真银斩 …) before the unit's `['spawn', unitInfo]` — the + // same batch, or the next — so `handleBattleEvents` used to skip the cue for good: that first cast was silent and every + // later one played. The cue is held and answered once, when the unit is tracked (`_track`); it never outlives its field. + test('a cast that arrives before its unit is known is held and plays once when the unit is tracked', () => { + const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3' } } }, + voice: { char_a: { skill3: '/v/a_s3.mp3' } } } }; + const a = new AudioManager({ win: null, getManifest: () => vm }); + a.ctx = {}; // unlocked: every path below is the real one + const played = [], voiced = []; + a._play = (url) => { played.push(url); }; // the cue itself, and the 作战中 line that goes with it + a.voice = (charId, slot, o) => { voiced.push([charId, slot, o.unitKey]); return true; }; + /** UnitInfo of the operator `char_a` (spine = the sfx.units key; skillIndex picks its 作战中N slot). */ + const info = (id, skillIndex) => ({ id, side: 'ally', spine: 'char_a', kind: 'op', skillIndex }); + + // (1) the sim's order inside one batch: the cast, then the unit's spawn — the cast waits, then sounds once + a.handleBattleEvents([['skill', 8, 1]]); + assert.deepEqual(played, [], 'nothing to play yet: the unit is unknown'); + a.handleBattleEvents([['spawn', info(8, 2)]]); + assert.deepEqual(played, ['/s/a_skill.mp3'], 'the held cast plays as soon as the unit is tracked'); + assert.deepEqual(voiced, [['char_a', 'skill3', 8]], 'with the 作战中N line its skillIndex picks'); + a.handleBattleEvents([['skill', 7, 1], ['atk', 7, 8, 'none'], ['spawn', info(7, 0)]]); + assert.equal(played.length, 2, 'the same batch: the cast waits for the spawn of its own batch'); + assert.deepEqual(voiced.at(-1), ['char_a', 'skill1', 7]); + // (2) once: a unit that is told about again (a repeated spawn) never replays it; a later cast plays directly + a.handleBattleEvents([['spawn', info(8, 2)], ['spawn', info(7, 0)]]); + assert.equal(played.length, 2, 'no second cue for the held cast'); + a.handleBattleEvents([['skill', 8, 1]]); + assert.equal(played.length, 3, 'a later cast of a known unit plays through'); + assert.deepEqual(voiced.at(-1), ['char_a', 'skill3', 8], 'and sounds the same as the replayed one'); + // (3) skill off (`['skill', id, 0]`) is no cast: nothing is held for it + a.handleBattleEvents([['skill', 9, 0]]); + a.handleBattleEvents([['spawn', info(9, 0)]]); + assert.equal(played.length, 3, 'the off event holds nothing'); + assert.equal(a.pendingSkill.size, 0); + }); + + test('a held cast does not outlive its field: a unit of the next field that shares its id does not sound it', () => { + const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3' } } }, voice: {} } }; + const a = new AudioManager({ win: null, getManifest: () => vm }); + a.ctx = {}; + const played = []; + a._play = (url) => { played.push(url); }; + a.voice = () => true; + const info = (id) => ({ id, side: 'ally', spine: 'char_a', kind: 'op' }); + a.handleBattleEvents([['skill', 9, 1]]); // a cast whose unit never appeared on this field + assert.equal(a.pendingSkill.size, 1); + a.setFieldUnits([info(9)]); // another battle: unit ids are per battle, 9 is a different unit + assert.equal(a.pendingSkill.size, 0, 'the field change drops the hold'); + a.handleBattleEvents([['spawn', info(9)]]); + assert.deepEqual(played, [], 'the old field\'s cast never sounds in the new one'); + // the hold is bounded: casts of units that never appear cannot pile up + for (let id = 100; id < 400; id++) a.handleBattleEvents([['skill', id, 1]]); + assert.ok(a.pendingSkill.size <= 64, `${a.pendingSkill.size} holds`); + assert.ok(a.pendingSkill.has(399) && !a.pendingSkill.has(100), 'the oldest holds go first'); + }); + + test('a real battle: the deploy-tick cast of a deployment-activated skill is heard, once', () => { + // The reported shape in 0.2.0's own sim: 宴's kit activates its skill on deployment (activateOnDeploy, PR #109), and + // `sim/battle/deploy.js` runs `skill.reset()` (→ `activate` → `['skill', id, 1]`) BEFORE it emits + // `['spawn', unitInfo]` — three events earlier in 宴's case (a status and an fx come between). Fed to the audio as + // the socket delivers it, that cast used to be dropped for good. + const chessId = 'chess_char_1_18_a'; // 宴 (char_337_utage): a deploy-timed skill with a skill sound + const h = makeBattle({ seed: 7, autoFinish: false, timeLimit: 5, units: [{ chessId, row: 9, col: 5 }] }); + h.step(3); + const ev = h.events; + const iCast = ev.findIndex((e) => e[0] === 'skill' && e[2]); + const iSpawn = ev.findIndex((e) => e[0] === 'spawn'); + assert.ok(iCast >= 0 && iSpawn > iCast, `the sim emits the deploy-tick cast before the unit info (${iCast} < ${iSpawn})`); + // the cast's sound, resolved as `unit()` does (the equipped skill's own ON_SKILL_START file, else its `skill`) + const info = ev[iSpawn][1]; + const rec = manifest.audio.sfx.units[info.spine]; + const url = (Number.isInteger(info.skillIndex) && rec?.skills ? rec.skills[info.skillIndex] : null) ?? rec?.skill; + assert.ok(typeof url === 'string', `前提:${info.spine} 有技能音效`); + const played = []; + const a = new AudioManager({ win: null, getManifest: () => manifest }); + a.ctx = {}; + a._play = (u) => { played.push(u); }; + a.voice = () => true; + a.handleBattleEvents(ev); + assert.equal(played.filter((u) => u === url).length, 1, 'the cast of the deployment is heard, exactly once'); + }); }); // user playtest #4 item 6: 纯烬艾雅法拉's skill sound rang outside her skill — her manifest `hit` is her S3 impact diff --git a/test/ui/feedback7-orchid-freeze.test.js b/test/ui/feedback7-orchid-freeze.test.js new file mode 100644 index 00000000..a3127934 --- /dev/null +++ b/test/ui/feedback7-orchid-freeze.test.js @@ -0,0 +1,109 @@ +// test/ui/feedback7-orchid-freeze.test.js — GitHub #354 (DuolaCCCCat): 「梓兰的主动刷新不会触发主动冻结」. 梓兰's 猎头顾问 (band_orchid) makes +// every active refresh a special one: two operators of the same name, and ONE of them frozen — kept to the next prep. The +// server did freeze it (slot.frozen, sent in m.private shop.slots[i].frozen) while the shop toggle stayed off; the shop bar +// drew the frost from the toggle alone, so the player never saw which card was frozen. Each card now reads its own slot. +// The rule itself (a second manual refresh re-rolls the frozen card too, like the toggle) is unchanged. + +import { test, describe } 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 { makeMatch, DATA } from '../match/harness.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +// the browser data store reads the real data files from disk +globalThis.fetch = async (url) => { + const name = String(url).split('/').pop(); + try { + const body = readFileSync(path.join(ROOT, 'data', name), 'utf8'); + return { ok: true, status: 200, json: async () => JSON.parse(body) }; + } catch { + return { ok: false, status: 404, json: async () => ({}) }; + } +}; +const { ChessCard, ItemCard, slotFrozen } = await import('../../public/js/ui/shopBar.js'); +const { data } = await import('../../public/js/data.js'); +await data.loadAll('items', 'effects', 'choices', 'assets', 'chess', 'bonds', 'garrisons'); + +/** Every vnode of a preact tree (htm output), depth first. */ +function* walk(v) { + if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; } + if (!v || typeof v !== 'object') return; + yield v; + yield* walk(v.props?.children); +} +const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c); +const iced = (card) => hasClass(card, 'is-frozen') && [...walk(card)].some((n) => hasClass(n, 'scard__ice')); +const bare = (card) => !hasClass(card, 'is-frozen') && ![...walk(card)].some((n) => hasClass(n, 'scard__ice')); + +/** A solo match in its first prep with 梓兰's strategy, level 4 shop, funds for a few refreshes. */ +function orchid(seed = 12) { + const h = makeMatch({ mode: 'solo', seed, fake: true }).start(); + h.toPrep(1); + const ps = h.ps('p_0'); + ps.bandId = 'band_orchid'; + ps.shop.level = 4; + ps.funds = 50; + return { h, ps }; +} + +describe('#354: the card the 猎头顾问 freezes shows its frost', () => { + test('the wire: the active refresh freezes one copied card and leaves the shop toggle off; the cards read their own slot', () => { + const { h, ps } = orchid(); + assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true }); + const priv = ps.privateView(); + assert.equal(priv.shop.frozen, false, 'the 冻结 toggle is off'); + const chess = priv.shop.slots.filter((s) => s && s.kind === 'chess'); + const frozen = chess.filter((s) => s.frozen); + assert.equal(frozen.length, 1, 'exactly one card is frozen'); + assert.equal(chess.filter((s) => s.id === frozen[0].id).length, 2, 'one of the two operators of the same name'); + // the bar's frame and the button follow the toggle (off), each card follows its own slot + for (const s of priv.shop.slots.filter(Boolean)) { + const props = { slot: s, idx: 0, priv, frozen: slotFrozen(s, priv.shop.frozen), onBuy() {}, onDetail() {} }; + const card = s.kind === 'item' ? ItemCard(props) : ChessCard(props); + assert.equal(s.frozen ? iced(card) : bare(card), true, `${s.id}: ${s.frozen ? 'frost' : 'no frost'}`); + } + h.m.dispose(); + }); + + test('the toggle still freezes every unsold card, and the helper reads the slot and the toggle', () => { + const { h, ps } = orchid(13); + h.m.handle('p_0', { t: 'g.freeze' }); + const priv = ps.privateView(); + assert.equal(priv.shop.frozen, true); + assert.ok(priv.shop.slots.filter(Boolean).every((s) => s.frozen), 'every slot copies the toggle'); + assert.equal(slotFrozen(priv.shop.slots[0], priv.shop.frozen), true); + assert.equal(slotFrozen({ frozen: false }, true), true, 'a frame without per-slot flags: the toggle'); + assert.equal(slotFrozen({ frozen: true }, false), true, 'a slot of its own: its flag'); + assert.equal(slotFrozen({ frozen: false }, false), false); + assert.equal(slotFrozen(null), false); + h.m.dispose(); + }); + + test('the rule stays as it was: the next manual refresh re-rolls the frozen card too and freezes one of the new pair; one refresh then the next prep keeps that card', () => { + const { h, ps } = orchid(); + h.m.handle('p_0', { t: 'g.refresh' }); + const first = ps.shop.slots.find((s) => s && s.frozen); + h.m.handle('p_0', { t: 'g.refresh' }); + const frozenNow = ps.shop.slots.filter((s) => s && s.frozen); + assert.equal(frozenNow.length, 1, 'again exactly one frozen card'); + assert.equal(ps.shop.frozen, false); + assert.ok(first && frozenNow[0], 'both refreshes froze a card'); + const kept = frozenNow[0].id; + h.toPrep(2); + assert.ok(ps.shop.slots.some((s) => s && s.id === kept), 'the frozen card is still in the shop in the next prep'); + assert.equal(ps.shop.frozen, false); + h.m.dispose(); + }); + + test('ShopBar passes slotFrozen(slot, toggle) to the operator and item cards; the frame and the 冻结 button stay on the toggle', () => { + const src = readFileSync(path.join(ROOT, 'public/js/ui/shopBar.js'), 'utf8'); + assert.match(src, /<\$\{ChessCard\} key=\$\{`c\$\{i\}:\$\{s\.id\}`\} slot=\$\{s\} idx=\$\{i\} priv=\$\{priv\} frozen=\$\{slotFrozen\(s, frozen\)\}/); + assert.match(src, /<\$\{ItemCard\} key=\$\{`i\$\{i\}:\$\{s\.id\}`\} slot=\$\{s\} idx=\$\{i\} frozen=\$\{slotFrozen\(s, frozen\)\}/); + assert.match(src, /const frozen = !!shop\.frozen;/); + assert.match(src, /cx\('shopbar', frozen && 'is-frozen'/, 'the bar\'s frame is the toggle'); + assert.match(src, /cx\('toolbtn', 'toolbtn--ice', frozen && 'is-on'\)/, 'the button is the toggle'); + assert.ok(DATA.bands.band_orchid, 'the band exists in the data'); + }); +}); diff --git a/test/ui/fieldHost-lifecycle.test.js b/test/ui/fieldHost-lifecycle.test.js new file mode 100644 index 00000000..721d56d2 --- /dev/null +++ b/test/ui/fieldHost-lifecycle.test.js @@ -0,0 +1,158 @@ +// Field-host ownership under cancellation and timeout. Browser tests cover the actual renderer; this fixture runs +// the production orchestration with controlled imports and timers, without a DOM or GPU dependency. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { getEventListeners } from 'node:events'; +import vm from 'node:vm'; + +const sourcePath = new URL('../../public/js/ui/fieldHost.js', import.meta.url); +const source = readFileSync(sourcePath, 'utf8') + .replace(/^import .*;$/gm, '').replace(/^export /gm, '') + .replace("import('../assets.js')", 'importAssets()') + .replace("import('../render/app.js')", 'importApp()'); +const METHODS = ['setStage', 'setCamera', 'setPrep', 'enterBattle', 'pushSnapshot', 'pushEvents', 'highlightTiles', 'on', 'resize']; +const flush = async () => { for (let i = 0; i < 16; i++) await Promise.resolve(); }; + +function fixture() { + const timers = new Map(), requests = [], fallbacks = [], warnings = []; + let timerId = 0; + const unrelated = { name: 'unrelated' }; + const host = { + children: [unrelated], + get firstChild() { return this.children[0] || null; }, + appendChild(node) { this.children.push(node); }, + removeChild(node) { const i = this.children.indexOf(node); if (i >= 0) this.children.splice(i, 1); }, + }; + function view(name, valid = true) { + const node = { name }; + host.appendChild(node); + return { + ...(valid ? Object.fromEntries(METHODS.map((k) => [k, () => {}])) : {}), + node, destroyCalls: 0, + destroy() { this.destroyCalls++; host.removeChild(node); }, + }; + } + const context = vm.createContext({ + AbortController, DOMException, URLSearchParams, Promise, + console: { warn: (...args) => warnings.push(args) }, + audio: {}, data: { get() { return null; } }, settingsStore: { get() { return {}; } }, + setTimeout(fn) { timers.set(++timerId, fn); return timerId; }, + clearTimeout(id) { timers.delete(id); }, + importAssets: async () => null, + importApp: async () => ({ + createFieldView(_host, opts) { + // Deliberately ignore cancellation: the host must still dispose a factory's late return value. + return new Promise((resolve, reject) => requests.push({ resolve, reject, signal: opts.signal })); + }, + }), + createFallbackView() { + const fallback = view('fallback'); + fallbacks.push({ view: fallback, rendererAborted: requests.at(-1)?.signal?.aborted }); + return fallback; + }, + }); + vm.runInContext(source + '\nglobalThis.mount = mountFieldView;', context, { filename: sourcePath.pathname }); + return { + host, unrelated, timers, requests, fallbacks, warnings, view, + mount: (signal) => context.mount(host, { signal }), + timeout() { const fn = [...timers.values()].at(-1); assert.ok(fn, 'initialization has a deadline'); fn(); }, + }; +} + +function settled(promise) { + const result = { state: 'pending', value: null }; + promise.then((value) => { result.state = 'fulfilled'; result.value = value; }, (error) => { result.state = 'rejected'; result.value = error; }); + return result; +} + +test('successful initialization releases deadlines and cancellation listeners', async () => { + const f = fixture(), controller = new AbortController(); + const pending = f.mount(controller.signal); + await flush(); + const raw = f.view('engine'); + f.requests[0].resolve(raw); + const mounted = await pending; + assert.equal(mounted.kind, 'engine'); + assert.equal(f.timers.size, 0); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + assert.equal(getEventListeners(f.requests[0].signal, 'abort').length, 0); + mounted.destroy(); + assert.equal(raw.destroyCalls, 1); +}); + +test('cancelling a pending mount rejects without fallback and disposes a late result', async () => { + const f = fixture(), controller = new AbortController(); + const result = settled(f.mount(controller.signal)); + await flush(); + controller.abort(); + await flush(); + assert.equal(result.state, 'rejected'); + assert.equal(result.value.name, 'AbortError'); + assert.equal(f.fallbacks.length, 0); + assert.equal(f.warnings.length, 0); + assert.equal(f.timers.size, 0); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + const late = f.view('late'); + f.requests[0].resolve(late); + await flush(); + assert.equal(late.destroyCalls, 1); + assert.deepEqual(f.host.children, [f.unrelated]); +}); + +test('cancellation between factory resolution and mount continuation disposes the resolved view', async () => { + const f = fixture(), controller = new AbortController(); + const result = settled(f.mount(controller.signal)); + await flush(); + const raw = f.view('settling'); + f.requests[0].resolve(raw); + await Promise.resolve(); + controller.abort(); + await flush(); + assert.equal(result.state, 'rejected'); + assert.equal(result.value.name, 'AbortError'); + assert.equal(raw.destroyCalls, 1); + assert.equal(f.fallbacks.length, 0); + assert.equal(f.timers.size, 0); + assert.deepEqual(f.host.children, [f.unrelated]); +}); + +test('an invalid renderer API is disposed and aborted before fallback mounts', async () => { + const f = fixture(); + const pending = f.mount(); + await flush(); + const invalid = f.view('invalid', false); + f.requests[0].resolve(invalid); + const fallback = await pending; + assert.equal(fallback.kind, 'fallback'); + assert.equal(invalid.destroyCalls, 1); + assert.equal(f.fallbacks[0].rendererAborted, true); + assert.equal(f.timers.size, 0); + assert.deepEqual(f.host.children, [f.unrelated, f.fallbacks[0].view.node]); + fallback.destroy(); +}); + +test('a timed-out factory returning late cannot remove a replacement view or unrelated children', async () => { + const f = fixture(); + const first = f.mount(); + await flush(); + f.timeout(); + const fallback = await first; + assert.equal(fallback.kind, 'fallback'); + fallback.destroy(); + const replacementPending = f.mount(); + await flush(); + const replacement = f.view('replacement'); + f.requests[1].resolve(replacement); + const mounted = await replacementPending; + const late = f.view('late'); + f.requests[0].resolve(late); + await flush(); + assert.equal(late.destroyCalls, 1); + assert.equal(replacement.destroyCalls, 0); + assert.equal(f.fallbacks[0].rendererAborted, true); + assert.equal(f.timers.size, 0); + assert.deepEqual(f.host.children, [f.unrelated, replacement.node]); + mounted.destroy(); +}); diff --git a/test/ui/playtest2.test.js b/test/ui/playtest2.test.js index 8b5e2f16..7cff96b6 100644 --- a/test/ui/playtest2.test.js +++ b/test/ui/playtest2.test.js @@ -126,7 +126,7 @@ describe('8: the detail card never covers the selected unit\'s underframe', () = const { readFileSync } = await import('node:fs'); const app = readFileSync(new URL('../../public/js/render/app.js', import.meta.url), 'utf8'); assert.match(app, /\n {2}canvas\.addEventListener\('click', onTapTarget\);\n/, 'registered with the other canvas listeners'); - assert.match(app, /\n {6}canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy'); + assert.match(app, /\n +canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy'); assert.match(app, /const onTapTarget = \(\) => \{\};/, 'a no-op: the press itself stays with the pointer events'); }); diff --git a/types/core.js b/types/core.js index effe5f3a..95c23f3a 100644 --- a/types/core.js +++ b/types/core.js @@ -88,10 +88,23 @@ * @typedef {[number, string, number, number, number]} ElemSnap */ +/** + * One HP-bar readout of a unit: `[id, left, max]` — the rounds left of a running ammo skill and its magazine (`ammo`), or the + * 狼影 left of 伺夜's 狼群 and the talent's maximum (`wolves`). Whole numbers. + * + * @typedef {[number, number, number]} CountSnap + */ + +/** + * A negative-HP pool as a share of its cap (`neg`, 斩业星熊's 我执): `[id, fill]`, fill 0.01–1. + * + * @typedef {[number, number]} NegSnap + */ + /** * `snapshot()` (SIM §9). On the wire this object is the payload of `b.snap`, * and `t` is sent as `gt` because the frame's `t` is the message type (DESIGN §8.2). - * `dp` is one number. `down` and `elem` are omitted when empty. + * `dp` is one number. `down`, `elem`, `ammo`, `wolves` and `neg` are omitted when empty. * * @typedef {Object} FieldSnapshot * @property {string} fieldId @@ -104,6 +117,9 @@ * @property {Object} [boss] * @property {DownSnap[]} [down] * @property {ElemSnap[]} [elem] + * @property {CountSnap[]} [ammo] + * @property {CountSnap[]} [wolves] + * @property {NegSnap[]} [neg] */ /**