feedback7 R22-HUD: the HP bar shows the ammo skill's rounds as yellow cells (隐现 S2: 14), 伺夜's 狼影 left as diamonds and 斩业星熊's 我执 pool as a red bar — one snapshot change for PR #303 and #378 by @IceCodeNew and PR #383 by @Convey123; golden: unchanged
The three PRs patched the same snapshot and HUD lines (Grok's review: do not merge three diffs), so this is one design built from their code and tests; IceCodeNew is the commit author (#303, #378), Convey123 co-author (#383). - b.snap gains three optional, display-only lists beside the unchanged 9-field tuples (snapshot.js ammoView / wolfView / negView, called from battle/events.js snapshot(); a living, deployed, visible ally, only when it has one): ammo [[id, rounds left, magazine]] (whole rounds; the magazine is the activation's real total that unitTuple's SP fraction runs on, extras and refills included), wolves [[id, 狼影 left, talent maximum]] (mem.shadows of the new mem.wolfCapacity, set by tokens.js wolfPack and the managed vigil kit), neg [[id, fill]] (unit.negFill(), a function op-hsgma2.js sets: the pool is read when the snapshot is taken, no per-tick sync). UnitInfo.ammoSkill (an ally whose skill is an ammo magazine) is known from the first appearance: fx events run before the frame's sample, so the aura cannot wait for the first ammo row. - render/interp.js keeps the lists per snapshot (malformed rows / unknown ids dropped) and sample() hands them out from the older snapshot like flags (they step with the skill flag); render/units.js draws them: the ammo magazine replaces the SP bar (a cell per round, thin cuts, exact fill, no cuts under 2 px a cell), a row of diamonds under the bars for the wolves (the element gauge row moves below), the red bar over the drained HP bar for the pool. An ammo skill draws no standing aura (rings.js _aura; the activation burst stays). - PR #378's wolf-head avatars are NOT taken: the count is data (the talent text 至多3只, mem.shadows), the HUD art of the official counter is in no game data we have, so the pips are plain diamonds [ASSUMED look]. The PRs' own tests are folded in and extended (test/sim/hud-readouts.test.js, test/render/hud-readouts.test.js, op_hsgma2.test.js). - Docs: network.md, SIM.md §9, client.md, PLAYING.md, types/core.js. - Golden: unchanged (283 scenarios; the canon hashes units / down / elem only, no sim state moved). - Tests: 4 + 14 new + 1 in op_hsgma2 (12), all fail on the parent; 344 related tests pass; lint 0 errors, typecheck, check:imports, docs tests. Co-authored-by: Convey123 <Convey123@users.noreply.github.com>
This commit is contained in:
1 parent
ae22335cfe
commit
8ae891f1f3
19 files changed
+721
-28
No files matched your search
@@ -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 / 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 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。
|
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 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茧甲)与官方一致:算作一次被击倒,紧接着在倒下的位置免费立即再部署——干员「被击倒时」和「部署时」的效果都会触发(例如砾的不屈 / 卡西米尔层数、瑕光的层数、卡西米尔盟约的部署攻击力),技力和身上的增益像普通再部署一样重置。
|
- **复活**(策略「埃芒加德」的前 3 次、M3茧甲)与官方一致:算作一次被击倒,紧接着在倒下的位置免费立即再部署——干员「被击倒时」和「部署时」的效果都会触发(例如砾的不屈 / 卡西米尔层数、瑕光的层数、卡西米尔盟约的部署攻击力),技力和身上的增益像普通再部署一样重置。
|
||||||
- **策略「鸭爵」(“神秘顾客”)**:从第 5 回合起,你和队友的每场作战(**最终攻势和隐秘核心也算**,联防不另外替换)有 0–2 名地面敌人(不含领袖及其部件、悬赏敌人)被替换为特殊版本的鸭爵、高普尼克、流泪小子或圆仔,出现在被替换敌人的时间和路线上(两人同场的领袖战里,各自只替换走向自己保护目标点的敌人);击倒者获得 1 资金(联防中的队友也算),漏进保护目标点照常扣 1 点目标生命值。鸭爵和圆仔**不攻击、无法被阻挡**:鸭爵受到伤害后移动速度 +300%;圆仔始终面向我方干员较多的一侧,来自正面的物理 / 法术伤害 −80%。流泪小子隐匿(被阻挡时才能被选中),攻击使目标晕眩 3.5 秒,被晕眩的干员挡不住它(官方规则);它被放开后要过 1 秒才重新隐匿(官方是 0 秒,本模式有意放宽,维护者 2026-10-07 的决定);高普尼克免疫晕眩、冻结和浮空。
|
- **策略「鸭爵」(“神秘顾客”)**:从第 5 回合起,你和队友的每场作战(**最终攻势和隐秘核心也算**,联防不另外替换)有 0–2 名地面敌人(不含领袖及其部件、悬赏敌人)被替换为特殊版本的鸭爵、高普尼克、流泪小子或圆仔,出现在被替换敌人的时间和路线上(两人同场的领袖战里,各自只替换走向自己保护目标点的敌人);击倒者获得 1 资金(联防中的队友也算),漏进保护目标点照常扣 1 点目标生命值。鸭爵和圆仔**不攻击、无法被阻挡**:鸭爵受到伤害后移动速度 +300%;圆仔始终面向我方干员较多的一侧,来自正面的物理 / 法术伤害 −80%。流泪小子隐匿(被阻挡时才能被选中),攻击使目标晕眩 3.5 秒,被晕眩的干员挡不住它(官方规则);它被放开后要过 1 秒才重新隐匿(官方是 0 秒,本模式有意放宽,维护者 2026-10-07 的决定);高普尼克免疫晕眩、冻结和浮空。
|
||||||
|
|||||||
+6
-2
@@ -1296,7 +1296,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
|
|||||||
|
|
||||||
## 9. Wire format (snapshot.js, DESIGN §8.2)
|
## 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
|
`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.
|
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
|
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
|
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
|
`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,
|
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).
|
`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]`
|
- `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
|
(`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|droneBomb|chain|chainHeal`; a boomerang's way back has no event — the
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ 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.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir)
|
||||||
view.resize() ; view.destroy()
|
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).
|
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. 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
- 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
|
- 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.
|
`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]`
|
- `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.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)
|
- `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)
|
||||||
|
|||||||
@@ -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
|
// 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)
|
// 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,
|
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,
|
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
|
// 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
|
// unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own
|
||||||
|
|||||||
@@ -74,8 +74,13 @@ export class FxRings {
|
|||||||
this._aura(view, true);
|
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) {
|
_aura(view, on) {
|
||||||
|
if (on && view.info?.ammoSkill) return;
|
||||||
const P = this.P;
|
const P = this.P;
|
||||||
let a = this.auras.get(view.id);
|
let a = this.auras.get(view.id);
|
||||||
if (on) {
|
if (on) {
|
||||||
|
|||||||
@@ -20,14 +20,21 @@
|
|||||||
// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events — an enemy's form fx
|
// 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.
|
// 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
|
// 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, user playtest #4 items 8 / 9):
|
// (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
|
// * `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
|
// normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older
|
||||||
// snapshot, like flags);
|
// snapshot, like flags);
|
||||||
// * `down` [[id, respawnAt, respawnTime, state, row?, col?]] — knocked-out operators waiting to redeploy (they are no
|
// * `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
|
// 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.
|
// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT.
|
||||||
|
|
||||||
import { fxForm } from '../../../shared/protocol.js';
|
import { fxForm } from '../../../shared/protocol.js';
|
||||||
@@ -66,8 +73,9 @@ export function frameTime(msg) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, down: [[id, respawnAt, respawnTime,
|
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, 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
|
* state, row?, col?]] | null, ammo, wolves: Map id → [left, max] | null, neg: Map id → fill | null, raw }` or null when unusable.
|
||||||
* to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped.
|
* 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) {
|
export function normalizeSnapshot(snap) {
|
||||||
if (!snap || typeof snap !== 'object') return null;
|
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])));
|
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;
|
let down = null;
|
||||||
if (Array.isArray(snap.down)) {
|
if (Array.isArray(snap.down)) {
|
||||||
for (const d of snap.down) {
|
for (const d of snap.down) {
|
||||||
@@ -99,7 +115,22 @@ export function normalizeSnapshot(snap) {
|
|||||||
(down || (down = [])).push(e);
|
(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 {
|
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
|
* 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 }` —
|
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur, ammo, wolves,
|
||||||
* `el` = the shown element gauge (null: none), see header). Samples of units no longer present are deleted from `out`.
|
* 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()) {
|
sample(time = this.renderT, out = new Map()) {
|
||||||
const s = this.snaps;
|
const s = this.snaps;
|
||||||
@@ -282,7 +314,7 @@ export class SnapshotBuffer {
|
|||||||
const stamp = A.t;
|
const stamp = A.t;
|
||||||
for (const [id, a] of A.units) {
|
for (const [id, a] of A.units) {
|
||||||
let o = out.get(id);
|
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;
|
const b = B ? B.units.get(id) : null;
|
||||||
if (b) {
|
if (b) {
|
||||||
const dx = b[1] - a[1], dy = b[2] - a[2];
|
const dx = b[1] - a[1], dy = b[2] - a[2];
|
||||||
@@ -317,6 +349,10 @@ export class SnapshotBuffer {
|
|||||||
o.flags = a[7];
|
o.flags = a[7];
|
||||||
o.anim = a[8];
|
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; }
|
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;
|
o.seen = stamp;
|
||||||
}
|
}
|
||||||
for (const id of out.keys()) if (!A.units.has(id)) out.delete(id);
|
for (const id of out.keys()) if (!A.units.has(id)) out.delete(id);
|
||||||
|
|||||||
@@ -60,9 +60,14 @@ export const COLORS = Object.freeze({
|
|||||||
hpBoss: 0xff2d55,
|
hpBoss: 0xff2d55,
|
||||||
hpGhost: 0xfff0c8,
|
hpGhost: 0xfff0c8,
|
||||||
hpBack: 0x0c0f0e,
|
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,
|
sp: 0x6fd3ff,
|
||||||
spReady: 0xffe066,
|
spReady: 0xffe066,
|
||||||
spActive: 0xffb347,
|
spActive: 0xffb347,
|
||||||
|
// the ammo bar's yellow cells (b.snap `ammo`) and 伺夜's 狼影 pips (b.snap `wolves`)
|
||||||
|
ammo: 0xffd04a,
|
||||||
|
wolf: 0xe8f0ff,
|
||||||
shield: 0xdfe8ff,
|
shield: 0xdfe8ff,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,17 @@ export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_130
|
|||||||
* between `pulse` and 1 at `pulseHz` (real time).
|
* 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 });
|
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 —
|
* 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.visFacing = this.isEnemy ? -1 : this.facing;
|
||||||
this.hp = Number(info.maxHp) || 1; this.maxHp = Number(info.maxHp) || 1; this.ghostHp = this.hp;
|
this.hp = Number(info.maxHp) || 1; this.maxHp = Number(info.maxHp) || 1; this.ghostHp = this.hp;
|
||||||
this.sp = 0; this.spMax = 0;
|
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.flags = 0; this.anim = ANIM.IDLE;
|
||||||
this.statuses = new Set();
|
this.statuses = new Set();
|
||||||
this.alive = true;
|
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.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.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._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._downRing = null; // { disc, track, arc, text } sprites, built on first use
|
||||||
this.alpha = 1; this.fadeIn = this.prep ? 1 : 0;
|
this.alpha = 1; this.fadeIn = this.prep ? 1 : 0;
|
||||||
this.lunge = 0; this.lungeDir = { x: 1, y: 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.hpBg = bar(P, h, COLORS.hpBack, 0.85);
|
||||||
this.hpGhost = bar(P, h, COLORS.hpGhost, 0.9);
|
this.hpGhost = bar(P, h, COLORS.hpGhost, 0.9);
|
||||||
this.hpFill = bar(P, h, this.isEnemy ? (this.isBoss ? COLORS.hpBoss : COLORS.hpEnemy) : COLORS.hpAlly);
|
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.shieldBar = bar(P, h, COLORS.shield, 0.95);
|
||||||
this.spBg = bar(P, h, COLORS.hpBack, 0.85);
|
this.spBg = bar(P, h, COLORS.hpBack, 0.85);
|
||||||
this.spFill = bar(P, h, COLORS.sp);
|
this.spFill = bar(P, h, COLORS.sp);
|
||||||
@@ -658,7 +676,7 @@ export class UnitView {
|
|||||||
this.spGlow.blendMode = P.BLEND_MODES.ADD;
|
this.spGlow.blendMode = P.BLEND_MODES.ADD;
|
||||||
h.addChild(this.spGlow);
|
h.addChild(this.spGlow);
|
||||||
// nothing shows until the first HUD update decides (a culled or prep view never draws bars)
|
// 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;
|
this.chip = null;
|
||||||
// operators only: summon tokens have no tier (hand and field alike)
|
// operators only: summon tokens have no tier (hand and field alike)
|
||||||
if (!this.isEnemy && !this.isToken && this.info.kind !== 'device') {
|
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;
|
if (hp < this.hp - 0.5 && this.isBoss) this.shake = 0.25;
|
||||||
this.hp = hp;
|
this.hp = hp;
|
||||||
this.sp = s.sp; this.spMax = s.spMax;
|
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;
|
const prevFlags = this.flags;
|
||||||
this.flags = s.flags | 0;
|
this.flags = s.flags | 0;
|
||||||
this.anim = s.anim | 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;
|
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;
|
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);
|
const shielded = showHp && (this.flags & UF.SHIELD);
|
||||||
this.shieldBar.visible = !!shielded;
|
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); }
|
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
|
// SP — or, while an ammo skill runs, its magazine: yellow cells, one per round, emptying from the right (b.snap `ammo`;
|
||||||
const showSp = showBars && !this.isEnemy && this.spMax > 0;
|
// 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;
|
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 ready = false;
|
||||||
|
let sy = 0;
|
||||||
if (showSp) {
|
if (showSp) {
|
||||||
const active = !!(this.flags & UF.SKILL);
|
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;
|
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.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.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._spY = sy;
|
||||||
}
|
}
|
||||||
|
this._updateAmmoCuts(showSp && !!ammo, ammo, x0, sy, bw, spH);
|
||||||
this.spGlow.visible = ready;
|
this.spGlow.visible = ready;
|
||||||
if (ready) {
|
if (ready) {
|
||||||
const pulse = 0.55 + 0.35 * Math.sin(t * 6);
|
const pulse = 0.55 + 0.35 * Math.sin(t * 6);
|
||||||
@@ -1222,8 +1249,11 @@ export class UnitView {
|
|||||||
ic.width = ic.height = isz;
|
ic.width = ic.height = isz;
|
||||||
ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy);
|
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`)
|
// 狼影 pips under the bars (b.snap `wolves`), then the element gauge row under them (b.snap `elem`); redeploy ring above a
|
||||||
this._updateElementBar(showBars && !!this.el, x0, bw, cy + bh / 2 + (showSp ? spH + 1.5 : 0) + 1, spH, s, t);
|
// 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);
|
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)
|
// blocked marker at the feet (enemies held by a blocker)
|
||||||
const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED);
|
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). */
|
/** True while the shown gauge is in its 爆发冷却 (b.snap `elem` carries the cooldown's end and length). */
|
||||||
elementCooling() {
|
elementCooling() {
|
||||||
return !!this.el && this.elDur > 0 && this.elUntil > 0;
|
return !!this.el && this.elDur > 0 && this.elUntil > 0;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { EVENT_BUFFER_CAP } from '../constants.js';
|
import { EVENT_BUFFER_CAP } from '../constants.js';
|
||||||
import { elementView } from '../damage.js';
|
import { elementView } from '../damage.js';
|
||||||
import { unitInfo, snapshotUnits } from '../snapshot.js';
|
import { unitInfo, snapshotUnits, ammoView, wolfView, negView } from '../snapshot.js';
|
||||||
|
|
||||||
export class BattleEvents {
|
export class BattleEvents {
|
||||||
fx(kind, params = {}) {
|
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
|
* 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
|
* 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);
|
* 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() {
|
snapshot() {
|
||||||
const snap = {
|
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)]);
|
(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;
|
if (down) snap.down = down;
|
||||||
let elem = null;
|
let elem = null, ammo = null, wolves = null, neg = null;
|
||||||
for (const u of this.units) {
|
for (const u of this.units) {
|
||||||
if (!u.alive || !u.deployed || u.hidden) continue;
|
if (!u.alive || !u.deployed || u.hidden) continue;
|
||||||
const v = elementView(u, this.time);
|
const v = elementView(u, this.time);
|
||||||
if (v) (elem || (elem = [])).push([u.id, v[0], v[1], v[2], v[3]]);
|
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 (elem) snap.elem = elem;
|
||||||
|
if (ammo) snap.ammo = ammo;
|
||||||
|
if (wolves) snap.wolves = wolves;
|
||||||
|
if (neg) snap.neg = neg;
|
||||||
return snap;
|
return snap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default {
|
|||||||
skill: null,
|
skill: null,
|
||||||
trait: { hitsFn: (b, w) => Math.max(1, w.mem.wolves || 1) },
|
trait: { hitsFn: (b, w) => Math.max(1, w.mem.wolves || 1) },
|
||||||
install(battle, w) {
|
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 tal = w.def.talents || [];
|
||||||
const wb = tal[0]?.bb ?? {};
|
const wb = tal[0]?.bb ?? {};
|
||||||
const per = num(wb.block_cnt, num(wb['vigil_wolf_t_1_enhance[trigger].block_cnt'], 1));
|
const per = num(wb.block_cnt, num(wb['vigil_wolf_t_1_enhance[trigger].block_cnt'], 1));
|
||||||
|
|||||||
@@ -180,6 +180,9 @@ export default {
|
|||||||
const capOf = () => num(t0.max_minus_hp_ratio, 2) * unit.s.maxHp;
|
const capOf = () => num(t0.max_minus_hp_ratio, 2) * unit.s.maxHp;
|
||||||
const quiet = num(t0['hsgma2_t_1[heal].interval'], 5);
|
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']);
|
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) => {
|
const enter = (pool) => {
|
||||||
unit.mem.hsEgo = { pool: Math.max(0, pool), lastHurt: battle.time };
|
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'] });
|
battle.addBuff(unit, { key: EGO, status: 'healFree', flags: { noHeal: true, healFree: true }, tags: ['talent'] });
|
||||||
|
|||||||
@@ -773,6 +773,7 @@ function wolfPack(bb, raw, def) {
|
|||||||
skill: null,
|
skill: null,
|
||||||
trait: { hitsFn: (b, u) => Math.max(1, u.mem.shadows ?? 1) },
|
trait: { hitsFn: (b, u) => Math.max(1, u.mem.shadows ?? 1) },
|
||||||
install(battle, unit) {
|
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
|
// initial shadows: 伺夜's talent text ("初始两只"), else one below the maximum
|
||||||
const ot = (ownerOf(unit)?.def?.talents || []).map((t) => t.description || '').join(' ');
|
const ot = (ownerOf(unit)?.def?.talents || []).map((t) => t.description || '').join(' ');
|
||||||
const mi = ot.match(/初始(两|二|\d+)只/);
|
const mi = ot.match(/初始(两|二|\d+)只/);
|
||||||
|
|||||||
+44
-1
@@ -3,7 +3,8 @@
|
|||||||
// b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total }
|
// 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)
|
// (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?,
|
// 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;
|
// 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:
|
// 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
|
// 掠海漂移体 '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,
|
uid: u.uid ?? undefined,
|
||||||
// DESIGN §16: the equipped skill's index (the renderer / audio pick that skill's Spine clip and sound)
|
// 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,
|
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
|
// 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)
|
// 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,
|
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)];
|
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). */
|
/** Units included in a snapshot: deployed & visible, plus recently dead ones (DIE animation). */
|
||||||
export function snapshotUnits(units, t) {
|
export function snapshotUnits(units, t) {
|
||||||
const out = [];
|
const out = [];
|
||||||
|
|||||||
@@ -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
|
// 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 (无敌 + 禁疗)
|
// every deployment): its bar shows the time left (snapshot.js unitTuple), its HP never moves (无敌 + 禁疗)
|
||||||
this.countdown = null;
|
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
|
// 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");
|
// 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)
|
// every deployment clears it (battle/deploy.js _deploy)
|
||||||
|
|||||||
@@ -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 我执', () => {
|
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]]) {
|
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||||
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
|
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, []);
|
||||||
|
});
|
||||||
|
}
|
||||||
+17
-1
@@ -88,10 +88,23 @@
|
|||||||
* @typedef {[number, string, number, number, number]} ElemSnap
|
* @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`,
|
* `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).
|
* 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
|
* @typedef {Object} FieldSnapshot
|
||||||
* @property {string} fieldId
|
* @property {string} fieldId
|
||||||
@@ -104,6 +117,9 @@
|
|||||||
* @property {Object} [boss]
|
* @property {Object} [boss]
|
||||||
* @property {DownSnap[]} [down]
|
* @property {DownSnap[]} [down]
|
||||||
* @property {ElemSnap[]} [elem]
|
* @property {ElemSnap[]} [elem]
|
||||||
|
* @property {CountSnap[]} [ammo]
|
||||||
|
* @property {CountSnap[]} [wolves]
|
||||||
|
* @property {NegSnap[]} [neg]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user