diff --git a/docs/PLAYING.md b/docs/PLAYING.md index f357981a..90ecfaf8 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -80,6 +80,7 @@ - **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。野鬃二技能沿她的朝向推开:正前方 45° 以内按上面的距离,身后、侧面或贴身的敌人只被轻推一下(约 0.4 格以内);琳琅诗怀雅三技能的金币则从她身边向外推开,落在她前方 2–4 格的范围(官方「范围2-4」)内的地面敌人和她阻挡的敌人身上。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。 - **生命回复不是治疗**(与官方一致):吟游者的特性(魔王、浊心斯卡蒂及其海嗣)、调香师的天赋、瑕光二技能、铃兰三技能、锡人炼金单元给友军的「每秒恢复生命」是提高对方的**生命回复速度**,不是治疗:收割者 / 不屈者 / 武者(如隐德来希、圣约送葬人、折桠)这类「无法被友方治疗」的干员和处于禁疗的干员也能吃到,治疗量加成(如「受到的治疗效果提升」)也不会放大它;铃兰三技能开启后的第一秒没有回复。直接治疗(医疗干员的治疗、技能的「恢复…生命」)仍然对这些干员无效。 - **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。 +- **弹药、狼影和「我执」**也画在血条上(0.2.2):弹药类技能(如隐现的二技能,14 发)开启时,血条下方的技力条换成**分格的黄色弹药条**——一格一发,打一发少一格,补弹时回来;这类技能开启期间脚下不再有持续的金色光环。伺夜的狼群在血条下方有一排**菱形**,亮的是剩下的狼影,暗的是已用掉的空位(最多 3 个),进入战术点形态时消失。斩业星熊进入「我执」后,绿色血条停在 1 点,超出的伤害涨成血条上的**红条**,红条满了才会倒下。这些只是显示,不改变任何战斗数值。 - **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。没被阻挡的远程敌人每次攻击都会站定、播完攻击动作,两次攻击之间才继续前进。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡(或被揭示)时才能被选为攻击目标,溅射、技能的圆形范围伤害也打不到没被挡住的隐匿敌人(和官方一样);阻挡解除后 3 秒内它仍然可以被选中(官方页面注明 0 秒或 1 秒的敌人按页面),之后才重新隐匿:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明;被阻挡时被击倒的,重生结束后约 2 秒内也能用远程或范围伤害补刀),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空、技能冷却从头开始,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。 - **复活**(策略「埃芒加德」的前 3 次、M3茧甲)与官方一致:算作一次被击倒,紧接着在倒下的位置免费立即再部署——干员「被击倒时」和「部署时」的效果都会触发(例如砾的不屈 / 卡西米尔层数、瑕光的层数、卡西米尔盟约的部署攻击力),技力和身上的增益像普通再部署一样重置。 - **策略「鸭爵」(“神秘顾客”)**:从第 5 回合起,你和队友的每场作战(**最终攻势和隐秘核心也算**,联防不另外替换)有 0–2 名地面敌人(不含领袖及其部件、悬赏敌人)被替换为特殊版本的鸭爵、高普尼克、流泪小子或圆仔,出现在被替换敌人的时间和路线上(两人同场的领袖战里,各自只替换走向自己保护目标点的敌人);击倒者获得 1 资金(联防中的队友也算),漏进保护目标点照常扣 1 点目标生命值。鸭爵和圆仔**不攻击、无法被阻挡**:鸭爵受到伤害后移动速度 +300%;圆仔始终面向我方干员较多的一侧,来自正面的物理 / 法术伤害 −80%。流泪小子隐匿(被阻挡时才能被选中),攻击使目标晕眩 3.5 秒,被晕眩的干员挡不住它(官方规则);它被放开后要过 1 秒才重新隐匿(官方是 0 秒,本模式有意放宽,维护者 2026-10-07 的决定);高普尼克免疫晕眩、冻结和浮空。 diff --git a/docs/SIM.md b/docs/SIM.md index 08a53a3f..094ee03b 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -1296,7 +1296,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te ## 9. Wire format (snapshot.js, DESIGN §8.2) -- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`. +- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem?, ammo?, wolves?, neg? }`. `sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the activation's real total — 拉特兰's and 逃犯引渡手续's extra bullets included, community report #35). Units in DIE state stay 0.8 s. Active finite zero-SP duration skills display `timeLeft/duration` using `duration` as `spMax`; after end they show @@ -1307,7 +1307,11 @@ Unknown subprofessions fall back to the profession default (test `professions.te respawn timer; `state` = constants.js `DOWN_STATE`: 0 counting, 1 timer done / DP short, 2 timer done / its tile taken — a safeguard: no ally deploys on a knocked-out operator's tile, §1; `row, col` = the tile it lies on and comes back on, `unit.body`); `elem: [[id, element, fill, - cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3). + cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3); + `ammo: [[id, left, magazine]]`, `wolves: [[id, left, max]]` and `neg: [[id, fill]]` (each only when non-empty; display only) = the HP-bar + readouts of `snapshot.js` `ammoView` (an ally's running ammo skill: whole rounds, the magazine = `ammoMax`), `wolfView` (伺夜's 狼群: + `mem.shadows` of `mem.wolfCapacity`) and `negView` (`unit.negFill()`, set by 斩业星熊's T1 业火: the 我执 pool as a share of its cap); + UnitInfo `ammoSkill` marks an ally whose skill is an ammo magazine (DESIGN §8.2). `fieldMeta()` lists the knocked-out operators too (a client joining mid-battle shows them; DESIGN §18.3). - `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy and 【移动】 — the client plays the deploy and its interpolation snaps instead of sliding), `['atk', src, tgt, projKind]` (`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|droneBomb|chain|chainHeal`; a boomerang's way back has no event — the diff --git a/docs/design/client.md b/docs/design/client.md index 5a3db6c4..1d9e476a 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -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.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). --- diff --git a/docs/design/network.md b/docs/design/network.md index 384b3c9c..fb1af4b7 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -23,7 +23,7 @@ S→C: - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type). - Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display `0/0`. Snapshot display capacity does not change runtime SP/cost. -- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0) +- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem?, ammo?, wolves?, neg? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0); the three HP-bar readouts (0.2.2, GitHub PR #303 / #378 / #383, display only — no sim state reads them, the nine-field tuples and the SP fraction an ammo skill still puts in them are unchanged; `render/interp.js` keeps them per snapshot and `sample()` hands them out from the older snapshot like `flags`, so they step with the skill flag; a malformed entry or an id the snapshot does not list is dropped, a snapshot without the list clears the readout): `ammo: [[id, rounds left, rounds in the magazine]]` — an ally's running ammo skill, whole rounds, the magazine being the activation's real total (extra bullets and refills included; 隐现's S2 holds 14, skill_table `attack@trigger_time`) — the client draws yellow cells in place of the SP bar (the count, not a pixel copy of the official magazine widget — its art is in no game data we have), and UnitInfo `ammoSkill: true` (an ally whose skill is an ammo magazine, known from its first appearance) tells it to draw no standing skill aura; `wolves: [[id, 狼影 left, the talent's maximum]]` — 伺夜's 狼群 (`mem.shadows` / `mem.wolfCapacity`, both the placed and the automatic pack), one diamond per slot under the HP bar (plain diamonds: no official counter art is known, the portrait of PR #378 was not taken), nothing in the 战术点形态; `neg: [[id, fill]]` — the share (0.01–1) of its cap a negative-HP pool holds (斩业星熊's 我执, `unit.negFill`), drawn as the red bar over her drained HP bar. These show only for a living, deployed, visible unit and only when it has one - `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]` - `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}` - `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16) as its next battle starts (§18.5) diff --git a/public/js/render/app/info.js b/public/js/render/app/info.js index 1eaa8306..51885de7 100644 --- a/public/js/render/app/info.js +++ b/public/js/render/app/info.js @@ -37,6 +37,9 @@ export function renderInfo(u) { // DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a // tap hands them to the detail card (a teammate's unit shows its owner's skill / module) skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined, + // an ally whose skill is an ammo magazine (UnitInfo.ammoSkill): its rounds are the cells under the HP bar, so no sustained + // skill aura is drawn for it (render/fx/rings.js _aura) + ammoSkill: u.ammoSkill === true ? true : undefined, moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined, // the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's // unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own diff --git a/public/js/render/fx/rings.js b/public/js/render/fx/rings.js index 11f2907c..3c0fe892 100644 --- a/public/js/render/fx/rings.js +++ b/public/js/render/fx/rings.js @@ -74,8 +74,13 @@ export class FxRings { this._aura(view, true); } - /** Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). */ + /** + * Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). An ammo + * skill (UnitInfo.ammoSkill) has none: its magazine is the yellow cells under the HP bar, which shrink with every round + * — a standing glow would read as a buff that never changes. The activation burst (skill()) is the same for all. + */ _aura(view, on) { + if (on && view.info?.ammoSkill) return; const P = this.P; let a = this.auras.get(view.id); if (on) { diff --git a/public/js/render/interp.js b/public/js/render/interp.js index a3d99c8a..ad54a7bd 100644 --- a/public/js/render/interp.js +++ b/public/js/render/interp.js @@ -20,14 +20,21 @@ // game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events — an enemy's form fx // included — are always delivered, and a full queue sheds only cosmetic ones. // -// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along -// (server/sim/battle/events.js snapshot, user playtest #4 items 8 / 9): +// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Optional lists ride along +// (server/sim/battle/events.js snapshot; the first two from user playtest #4 items 8 / 9): // * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's // normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older // snapshot, like flags); // * `down` [[id, respawnAt, respawnTime, state, row?, col?]] — knocked-out operators waiting to redeploy (they are no // longer in `units`) and the tile they lie on (where they fell, or their home — sim Battle._layBody; kept only when -// both are integers): downAt(time) returns the list of the snapshot at `time`. +// both are integers): downAt(time) returns the list of the snapshot at `time`; +// * three HP-bar readouts, kept per snapshot beside the tuples (the tuple layout is unchanged) and handed out by sample() +// from the older snapshot like flags, so they step with the skill flag instead of sliding: +// `ammo` [[id, rounds left, rounds in the magazine]] → sample().ammo [left, magazine] | null (whole numbers only) +// `wolves` [[id, 狼影 left, the talent's maximum]] → sample().wolves [left, maximum] | null +// `neg` [[id, fill]] (0.01–1: the negative-HP pool's share of its cap) → sample().neg number (0: none) +// An entry that is malformed, or names a unit the snapshot does not list, is dropped; a snapshot without the list +// clears the readout (render/units.js: the segmented ammo bar, the wolf pips, the red bar of 斩业星熊's 我执). // Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT. import { fxForm } from '../../../shared/protocol.js'; @@ -66,8 +73,9 @@ export function frameTime(msg) { /** * Validate & normalise a b.snap payload. Returns `{ t, units: Map, down: [[id, respawnAt, respawnTime, - * state, row?, col?]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default - * to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped. + * state, row?, col?]] | null, ammo, wolves: Map id → [left, max] | null, neg: Map id → fill | null, raw }` or null when unusable. + * Tuples with a non-finite id/x/y are skipped; other numbers default to 0; a unit's `elem` entry (see header) is appended to + * its tuple; malformed `elem` / `down` / `ammo` / `wolves` / `neg` entries are dropped. */ export function normalizeSnapshot(snap) { if (!snap || typeof snap !== 'object') return null; @@ -90,6 +98,14 @@ export function normalizeSnapshot(snap) { if (tu && tu.length === 9) tu.push(e[1], clamp(finite(e[2]), 0, 1), finite(e[3]), Math.max(0, finite(e[4]))); } } + const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units); + let neg = null; + if (Array.isArray(snap.neg)) { + for (const e of snap.neg) { + if (!Array.isArray(e) || !units.has(e[0]) || !(typeof e[1] === 'number' && e[1] > 0)) continue; + (neg || (neg = new Map())).set(e[0], Math.min(1, e[1])); + } + } let down = null; if (Array.isArray(snap.down)) { for (const d of snap.down) { @@ -99,7 +115,22 @@ export function normalizeSnapshot(snap) { (down || (down = [])).push(e); } } - return { t, units, down, raw: snap }; + return { t, units, down, ammo, wolves, neg, raw: snap }; +} + +/** + * `[[id, left, max]]` → Map id → [left, max] for the ids in `units`: whole numbers with 0 ≤ left ≤ max and max ≥ 1 (the + * `ammo` and `wolves` lists); null without a valid entry. + */ +function countList(list, units) { + if (!Array.isArray(list)) return null; + let m = null; + for (const e of list) { + if (!Array.isArray(e) || !units.has(e[0]) || !Number.isSafeInteger(e[1]) || !Number.isSafeInteger(e[2]) + || e[1] < 0 || e[2] < 1 || e[1] > e[2]) continue; + (m || (m = new Map())).set(e[0], [e[1], e[2]]); + } + return m; } export class SnapshotBuffer { @@ -265,8 +296,9 @@ export class SnapshotBuffer { /** * Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused - * across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur }` — - * `el` = the shown element gauge (null: none), see header). Samples of units no longer present are deleted from `out`. + * across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur, ammo, wolves, + * neg }` — `el` = the shown element gauge (null: none), `ammo` / `wolves` / `neg` the HP-bar readouts (null / null / 0: + * none), see header). Samples of units no longer present are deleted from `out`. */ sample(time = this.renderT, out = new Map()) { const s = this.snaps; @@ -282,7 +314,7 @@ export class SnapshotBuffer { const stamp = A.t; for (const [id, a] of A.units) { let o = out.get(id); - if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0 }; out.set(id, o); } + if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0, ammo: null, wolves: null, neg: 0 }; out.set(id, o); } const b = B ? B.units.get(id) : null; if (b) { const dx = b[1] - a[1], dy = b[2] - a[2]; @@ -317,6 +349,10 @@ export class SnapshotBuffer { o.flags = a[7]; o.anim = a[8]; if (a.length > 9) { o.el = a[9]; o.elFill = a[10]; o.elUntil = a[11]; o.elDur = a[12]; } else if (o.el !== null) { o.el = null; o.elFill = 0; o.elUntil = 0; o.elDur = 0; } + // the HP-bar readouts come with the older snapshot, like flags (whole rounds step with the skill flag) + o.ammo = A.ammo ? A.ammo.get(id) || null : null; + o.wolves = A.wolves ? A.wolves.get(id) || null : null; + o.neg = A.neg ? A.neg.get(id) || 0 : 0; o.seen = stamp; } for (const id of out.keys()) if (!A.units.has(id)) out.delete(id); diff --git a/public/js/render/style.js b/public/js/render/style.js index e93cb397..c0dd24b9 100644 --- a/public/js/render/style.js +++ b/public/js/render/style.js @@ -60,9 +60,14 @@ export const COLORS = Object.freeze({ hpBoss: 0xff2d55, hpGhost: 0xfff0c8, hpBack: 0x0c0f0e, + // the red bar of a negative-HP pool (斩业星熊's 我执, b.snap `neg`) — the same red as an enemy's HP + hpNeg: 0xff4b3e, sp: 0x6fd3ff, spReady: 0xffe066, spActive: 0xffb347, + // the ammo bar's yellow cells (b.snap `ammo`) and 伺夜's 狼影 pips (b.snap `wolves`) + ammo: 0xffd04a, + wolf: 0xe8f0ff, shield: 0xdfe8ff, }); diff --git a/public/js/render/units.js b/public/js/render/units.js index 4e38aaf7..b385fe42 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -184,6 +184,17 @@ export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_130 * between `pulse` and 1 at `pulseHz` (real time). */ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 }); +/** + * The HP-bar readouts under the bars (b.snap `ammo`, `wolves`, `neg`; render/interp.js header): + * - AMMO_BAR — the ammo skill's segmented bar replaces the SP bar: a cell per round, `height` of the HP bar's thickness + * (the SP bar's is 0.6), cells drawn only while they are at least `minCell` px wide; + * - WOLF_PIPS — 伺夜's 狼影 count as `size` (tiles) wide diamonds, clamped to `min`…`max` px, `gap` px apart, `pad` px + * below the rows above; a lit diamond is a 狼影 left, a dim one a spent slot (`dim` alpha). + * [ASSUMED] the look: the count is data (skill_table `attack@trigger_time` 14 for 隐现's S2; the talent text 「至多3只」), the + * official HUD art of neither is in the game data. + */ +export const AMMO_BAR = Object.freeze({ height: 0.85, minCell: 2 }); +export const WOLF_PIPS = Object.freeze({ size: 0.1, min: 5, max: 9, gap: 2, pad: 1.5, dim: 0.25 }); /** * Enemy modes drawn with another clip set of the same skeleton (the `form` of a sim fx — shared/protocol.js fxForm — @@ -407,6 +418,9 @@ export class UnitView { this.visFacing = this.isEnemy ? -1 : this.facing; this.hp = Number(info.maxHp) || 1; this.maxHp = Number(info.maxHp) || 1; this.ghostHp = this.hp; this.sp = 0; this.spMax = 0; + this.ammo = null; // [rounds left, rounds in the magazine] while an ammo skill runs (sync; b.snap `ammo`) + this.wolves = null; // [狼影 left, the talent's maximum] of a 狼群 (sync; b.snap `wolves`) + this.neg = 0; // the share of its cap a negative-HP pool holds, 0 = none (sync; b.snap `neg`, 斩业星熊's 我执) this.flags = 0; this.anim = ANIM.IDLE; this.statuses = new Set(); this.alive = true; @@ -417,6 +431,8 @@ export class UnitView { this.gameT = 0; // battle game time of the frame (render clock), for the redeploy ring's countdown and the element bar's refill this.el = null; this.elFill = 0; this.elUntil = 0; this.elDur = 0; // shown element gauge (sync) this._elBar = null; // { root, disc, bg, fill } sprites of the element gauge row, built on first use + this._wolfPips = null; // { root, back[], lit[] } diamonds of the 狼影 row, built on first use + this.ammoCuts = []; // the thin separators between the ammo bar's cells (_updateAmmoCuts) this._downRing = null; // { disc, track, arc, text } sprites, built on first use this.alpha = 1; this.fadeIn = this.prep ? 1 : 0; this.lunge = 0; this.lungeDir = { x: 1, y: 0 }; @@ -649,6 +665,8 @@ export class UnitView { this.hpBg = bar(P, h, COLORS.hpBack, 0.85); this.hpGhost = bar(P, h, COLORS.hpGhost, 0.9); this.hpFill = bar(P, h, this.isEnemy ? (this.isBoss ? COLORS.hpBoss : COLORS.hpEnemy) : COLORS.hpAlly); + // a negative-HP pool (斩业星熊's 我执), drawn over the drained HP bar as the red bar + this.negFill = bar(P, h, COLORS.hpNeg); this.shieldBar = bar(P, h, COLORS.shield, 0.95); this.spBg = bar(P, h, COLORS.hpBack, 0.85); this.spFill = bar(P, h, COLORS.sp); @@ -658,7 +676,7 @@ export class UnitView { this.spGlow.blendMode = P.BLEND_MODES.ADD; h.addChild(this.spGlow); // nothing shows until the first HUD update decides (a culled or prep view never draws bars) - for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.shieldBar, this.spBg, this.spFill, this.spGlow]) b.visible = false; + for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.negFill, this.shieldBar, this.spBg, this.spFill, this.spGlow]) b.visible = false; this.chip = null; // operators only: summon tokens have no tier (hand and field alike) if (!this.isEnemy && !this.isToken && this.info.kind !== 'device') { @@ -744,6 +762,7 @@ export class UnitView { if (hp < this.hp - 0.5 && this.isBoss) this.shake = 0.25; this.hp = hp; this.sp = s.sp; this.spMax = s.spMax; + this.ammo = s.ammo || null; this.wolves = s.wolves || null; this.neg = s.neg > 0 ? Math.min(1, s.neg) : 0; const prevFlags = this.flags; this.flags = s.flags | 0; this.anim = s.anim | 0; @@ -1174,24 +1193,32 @@ export class UnitView { this.hpFill.position.set(x0, cy); this.hpFill.width = bw * k; this.hpFill.height = bh; if (!this.isEnemy) this.hpFill.tint = k < 0.3 ? COLORS.hpAllyLow : COLORS.hpAlly; } + // 业火 我执: her HP sits on the 1-HP floor and the damage past it fills a pool — the red bar over the drained green one + const neg = showHp ? this.neg : 0; + this.negFill.visible = neg > 0; + if (neg > 0) { this.negFill.position.set(x0, cy); this.negFill.width = bw * neg; this.negFill.height = bh; } const shielded = showHp && (this.flags & UF.SHIELD); this.shieldBar.visible = !!shielded; if (shielded) { this.shieldBar.position.set(x0, cy - bh / 2 - 1); this.shieldBar.width = bw; this.shieldBar.height = Math.max(1.5, bh * 0.35); } - // SP - const showSp = showBars && !this.isEnemy && this.spMax > 0; + // SP — or, while an ammo skill runs, its magazine: yellow cells, one per round, emptying from the right (b.snap `ammo`; + // the SP bar's own fraction, ammoLeft / ammoMax, is the same width). Without an `ammo` row the bar is the plain SP bar. + const ammo = !this.isEnemy ? this.ammo : null; + const showSp = showBars && !this.isEnemy && (this.spMax > 0 || !!ammo); this.spBg.visible = this.spFill.visible = showSp; - const spH = Math.max(2, bh * 0.6); + const spH = Math.max(2, bh * (ammo ? AMMO_BAR.height : 0.6)); let ready = false; + let sy = 0; if (showSp) { const active = !!(this.flags & UF.SKILL); - const k = clamp(this.sp / this.spMax, 0, 1); + const k = ammo ? ammo[0] / ammo[1] : clamp(this.sp / this.spMax, 0, 1); ready = !active && k >= 0.999; - const sy = cy + bh / 2 + spH / 2 + 1.5; + sy = cy + bh / 2 + spH / 2 + 1.5; this.spBg.position.set(x0 - 1, sy); this.spBg.width = bw + 2; this.spBg.height = spH + 2; this.spFill.position.set(x0, sy); this.spFill.width = bw * k; this.spFill.height = spH; - this.spFill.tint = active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp; + this.spFill.tint = ammo ? COLORS.ammo : active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp; this._spY = sy; } + this._updateAmmoCuts(showSp && !!ammo, ammo, x0, sy, bw, spH); this.spGlow.visible = ready; if (ready) { const pulse = 0.55 + 0.35 * Math.sin(t * 6); @@ -1222,8 +1249,11 @@ export class UnitView { ic.width = ic.height = isz; ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy); } - // element gauge row under the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`) - this._updateElementBar(showBars && !!this.el, x0, bw, cy + bh / 2 + (showSp ? spH + 1.5 : 0) + 1, spH, s, t); + // 狼影 pips under the bars (b.snap `wolves`), then the element gauge row under them (b.snap `elem`); redeploy ring above a + // knocked-down operator (b.snap `down`) + const rowsTop = cy + bh / 2 + (showSp ? spH + 1.5 : 0); + const wolfH = this._updateWolfPips(showBars && !this.isEnemy && !!this.wolves, x0, rowsTop, s); + this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + 1, spH, s, t); this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t); // blocked marker at the feet (enemies held by a blocker) const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED); @@ -1248,6 +1278,62 @@ export class UnitView { } } + /** + * The thin dark separators between the ammo bar's cells (see _updateHud): one at every round boundary of the `n`-round + * magazine across the bar `x0` … `x0 + bw` at height `y`, `h` tall; none while a cell would be under AMMO_BAR.minCell px + * (the fill keeps its exact width). Sprites are built as the magazines need them and hidden when not in use. + */ + _updateAmmoCuts(show, ammo, x0, y, bw, h) { + const cuts = this.ammoCuts; + const n = show ? ammo[1] : 0; + const step = n > 1 ? bw / n : 0; + const want = step >= AMMO_BAR.minCell ? n - 1 : 0; + while (cuts.length < want) cuts.push(bar(this.P, this.hud, COLORS.hpBack, 0.9)); + for (let i = 0; i < cuts.length; i++) { + const c = cuts[i]; + c.visible = i < want; + if (c.visible) { c.position.set(x0 + (i + 1) * step - 0.5, y); c.width = 1; c.height = h; } + } + } + + /** + * 伺夜's 狼群 count (b.snap `wolves`, [left, maximum]): a row of diamonds from `x0`, its top at `top`, one per 狼影 the + * talent allows, the first `left` of them lit. Built on the first count, hidden without one. Returns the height the row + * takes (0 while hidden) so the element gauge row can sit below it. + */ + _updateWolfPips(show, x0, top, s) { + let r = this._wolfPips; + if (!show) { if (r) r.root.visible = false; return 0; } + const P = this.P; + const [left, max] = this.wolves; + if (!r) { + r = this._wolfPips = { root: new P.Container(), back: [], lit: [] }; + this.hud.addChild(r.root); + } + const mk = (list, color, alpha) => { + const d = new P.Sprite(white()); + d.anchor.set(0.5); d.rotation = Math.PI / 4; d.tint = color; d.alpha = alpha; + r.root.addChild(d); + list.push(d); + }; + while (r.lit.length < max) { mk(r.back, COLORS.hpBack, 0.85); mk(r.lit, COLORS.wolf, 1); } + const d = clamp(s * WOLF_PIPS.size, WOLF_PIPS.min, WOLF_PIPS.max); // a diamond's diagonal + const side = d / Math.SQRT2; + const cy = top + WOLF_PIPS.pad + d / 2 + 1; + for (let i = 0; i < r.lit.length; i++) { + const on = i < max; + const b = r.back[i], l = r.lit[i]; + b.visible = l.visible = on; + if (!on) continue; + const cx = x0 + d / 2 + 1 + i * (d + 2 + WOLF_PIPS.gap); + b.position.set(cx, cy); b.width = b.height = side + 2; + l.position.set(cx, cy); l.width = l.height = side; + l.alpha = i < left ? 1 : WOLF_PIPS.dim; + } + r.root.visible = true; + return d + 2 + WOLF_PIPS.pad; + } + /** True while the shown gauge is in its 爆发冷却 (b.snap `elem` carries the cooldown's end and length). */ elementCooling() { return !!this.el && this.elDur > 0 && this.elUntil > 0; diff --git a/server/sim/battle/events.js b/server/sim/battle/events.js index 690b4c54..e083b719 100644 --- a/server/sim/battle/events.js +++ b/server/sim/battle/events.js @@ -4,7 +4,7 @@ import { EVENT_BUFFER_CAP } from '../constants.js'; import { elementView } from '../damage.js'; -import { unitInfo, snapshotUnits } from '../snapshot.js'; +import { unitInfo, snapshotUnits, ammoView, wolfView, negView } from '../snapshot.js'; export class BattleEvents { fx(kind, params = {}) { @@ -37,7 +37,11 @@ export class BattleEvents { * down: [[id, respawnAt, respawnTime, state, row, col]] — operators that left the field waiting to redeploy (isDown): the * game time their respawn timer ends, its length (s), constants.js DOWN_STATE and the tile they lie on (and * come back on: _layBody — where they fell, or their home); - * elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView). + * elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView); + * ammo: [[id, rounds left, rounds in the magazine]] — a running ammo skill, whole rounds (snapshot.js ammoView): the segmented bar; + * wolves: [[id, 狼影 left, the talent's maximum]] — 伺夜's 狼群 (snapshot.js wolfView): the pips under the HP bar; + * neg: [[id, fill]] — the share of its cap a negative-HP pool holds (snapshot.js negView; 斩业星熊's 我执): the red bar. + * The last three are display only: no sim state reads them, and the nine-field unit tuples are unchanged. */ snapshot() { const snap = { @@ -60,13 +64,22 @@ export class BattleEvents { (down || (down = [])).push([u.id, r2(u.respawnAt), r2(Math.max(0, u.respawnAt - u.deathAt)), this._downState(u), ...this.restTile(u)]); } if (down) snap.down = down; - let elem = null; + let elem = null, ammo = null, wolves = null, neg = null; for (const u of this.units) { if (!u.alive || !u.deployed || u.hidden) continue; const v = elementView(u, this.time); if (v) (elem || (elem = [])).push([u.id, v[0], v[1], v[2], v[3]]); + const am = ammoView(u); + if (am) (ammo || (ammo = [])).push([u.id, am[0], am[1]]); + const wv = wolfView(u); + if (wv) (wolves || (wolves = [])).push([u.id, wv[0], wv[1]]); + const ng = negView(u); + if (ng) (neg || (neg = [])).push([u.id, ng]); } if (elem) snap.elem = elem; + if (ammo) snap.ammo = ammo; + if (wolves) snap.wolves = wolves; + if (neg) snap.neg = neg; return snap; } diff --git a/server/sim/content/kits/ops/chess_char_3_19-vigil.js b/server/sim/content/kits/ops/chess_char_3_19-vigil.js index 6295c5b1..7bbe88ae 100644 --- a/server/sim/content/kits/ops/chess_char_3_19-vigil.js +++ b/server/sim/content/kits/ops/chess_char_3_19-vigil.js @@ -43,6 +43,7 @@ export default { skill: null, trait: { hitsFn: (b, w) => Math.max(1, w.mem.wolves || 1) }, install(battle, w) { + w.mem.wolfCapacity = maxWolves; // (b.snap `wolves`, snapshot.js wolfView: the pips under the HP bar) const tal = w.def.talents || []; const wb = tal[0]?.bb ?? {}; const per = num(wb.block_cnt, num(wb['vigil_wolf_t_1_enhance[trigger].block_cnt'], 1)); diff --git a/server/sim/content/kits/ops/op-hsgma2.js b/server/sim/content/kits/ops/op-hsgma2.js index dcd1c29c..8f067ca8 100644 --- a/server/sim/content/kits/ops/op-hsgma2.js +++ b/server/sim/content/kits/ops/op-hsgma2.js @@ -180,6 +180,9 @@ export default { const capOf = () => num(t0.max_minus_hp_ratio, 2) * unit.s.maxHp; const quiet = num(t0['hsgma2_t_1[heal].interval'], 5); const regen = num(t0['hsgma2_t_1[heal].hp_recovery_per_sec_by_max_hp_ratio']); + // the pool as a share of its cap, read when a snapshot is taken (b.snap `neg`, snapshot.js negView): the green HP bar + // sits on the 1-HP floor in 我执, so the client draws the pool as the red bar — display only + unit.negFill = () => (unit.mem.hsEgo && unit.mem.hsEgo.pool > 0 ? unit.mem.hsEgo.pool / Math.max(1e-9, capOf()) : 0); const enter = (pool) => { unit.mem.hsEgo = { pool: Math.max(0, pool), lastHurt: battle.time }; battle.addBuff(unit, { key: EGO, status: 'healFree', flags: { noHeal: true, healFree: true }, tags: ['talent'] }); diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 77f8a7c4..1dc3f510 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -773,6 +773,7 @@ function wolfPack(bb, raw, def) { skill: null, trait: { hitsFn: (b, u) => Math.max(1, u.mem.shadows ?? 1) }, install(battle, unit) { + unit.mem.wolfCapacity = maxShadows; // (b.snap `wolves`, snapshot.js wolfView: the pips under the HP bar) // initial shadows: 伺夜's talent text ("初始两只"), else one below the maximum const ot = (ownerOf(unit)?.def?.talents || []).map((t) => t.description || '').join(' '); const mi = ot.match(/初始(两|二|\d+)只/); diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 8c1794bb..04217dd5 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -3,7 +3,8 @@ // b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total } // (hp of a countdown summon — unit.countdown, content/tokens.js startCountdown — is maxHp × the share of its life left) // UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, -// form?, skillIndex?, moduleId?, items?, standInFor?, diy? } (standInFor = the replaced operator's charId of a 补位 +// form?, skillIndex?, ammoSkill?, moduleId?, items?, standInFor?, diy? } (ammoSkill = an ally whose skill is an ammo +// magazine: the HP bar shows its rounds as cells, b.snap `ammo`; standInFor = the replaced operator's charId of a 补位 // stand-in; diy = a 自选 piece's pick { charId, skillIndex, uniEquipId } — defId is its slot, spine / avatar the operator's; // form = the unit's current model form — an enemy's, content/enemies/helpers.js setForm: // 掠海漂移体 'crawl', 暴鸰 'bombed', 转译基底·α's forms …; a 傀儡师 fighting as its 替身 'doll', professions.js — a view built @@ -48,6 +49,9 @@ export function unitInfo(u) { uid: u.uid ?? undefined, // DESIGN §16: the equipped skill's index (the renderer / audio pick that skill's Spine clip and sound) skillIndex: u.side === 'ally' && Number.isInteger(d.skill?.index) ? d.skill.index : undefined, + // an ally whose skill is an ammo magazine: the renderer shows it as the segmented bar under the HP bar (b.snap `ammo`) and + // draws no sustained skill aura for it — known from the unit's first appearance, before any snapshot or skill event + ammoSkill: u.side === 'ally' && u.skill?.kind === 'ammo' ? true : undefined, // DESIGN §16: an elite ally's equipped module (uniEquipId | 'none'; display only — a teammate's unit in a shared // field shows its owner's module in the detail card) moduleId: u.side === 'ally' && d.golden && typeof d.loadout?.moduleId === 'string' ? d.loadout.moduleId : undefined, @@ -118,6 +122,45 @@ export function unitTuple(u, t) { return [u.id, r2(u.x), r2(u.y), hp, maxHp, r1(sp), spMax, flagsOf(u), animOf(u, t)]; } +/** + * The three optional HUD readouts of b.snap (events.js snapshot(): `ammo`, `wolves`, `neg`; each only for a living, + * deployed, visible unit and only when it has one). They ride beside the 9-field tuples — the tuple, and the SP + * fraction the ammo skill still puts in it, stay as they are. + * + * `ammoView(u)` = `[rounds left, rounds in the magazine]` of a running ammo skill (whole numbers; the magazine is the + * activation's real total — extra bullets and refills included — that unitTuple's SP fraction runs on): the segmented + * yellow bar under the HP bar (隐现's S2 holds 14 rounds, skill_table skchr_inside_2 `attack@trigger_time`). + */ +export function ammoView(u) { + const sk = u.skill; + if (u.side !== 'ally' || !sk || !sk.active || sk.kind !== 'ammo') return null; + const left = Math.max(0, Math.ceil(sk.ammoLeft - 1e-9)); + return [left, Math.max(1, Math.ceil((sk.ammoMax || sk.ammo || 0) - 1e-9), left)]; +} + +/** + * `wolfView(u)` = `[狼影 left, the talent's maximum]` of 伺夜's 狼群 pack (content/tokens.js wolfPack and the managed kit + * chess_char_3_19-vigil.js keep the count in `mem.shadows` and the maximum in `mem.wolfCapacity`): the pips under the HP + * bar. A pack in its 战术点形态 is not alive and shows nothing. + */ +export function wolfView(u) { + const cap = u.mem?.wolfCapacity, n = u.mem?.shadows; + if (u.side !== 'ally' || !(cap >= 1) || !Number.isFinite(n)) return null; + const max = Math.round(cap); + return [Math.max(0, Math.min(max, Math.round(n))), max]; +} + +/** + * `negView(u)` = the share (0.01–1) of its cap that a unit's negative-HP pool holds, null without a pool: 斩业星熊's 我执 + * (kits/ops/op-hsgma2.js sets `unit.negFill`, the pool read when the snapshot is taken) — the red bar over the drained HP + * bar. The pool itself, and the 1-HP floor she stands on, are the sim's and are not touched. + */ +export function negView(u) { + if (typeof u.negFill !== 'function') return null; + const v = u.negFill(); + return v > 0 ? Math.min(1, Math.max(0.01, r2(v))) : null; +} + /** Units included in a snapshot: deployed & visible, plus recently dead ones (DIE animation). */ export function snapshotUnits(units, t) { const out = []; diff --git a/server/sim/units.js b/server/sim/units.js index 42412ab4..a948cabc 100644 --- a/server/sim/units.js +++ b/server/sim/units.js @@ -84,6 +84,9 @@ export class Unit { // a countdown summon's life on the field ({ from, until } battle times; content/tokens.js startCountdown, cleared by // every deployment): its bar shows the time left (snapshot.js unitTuple), its HP never moves (无敌 + 禁疗) this.countdown = null; + // a unit with a negative-HP pool (斩业星熊's 我执, kits/ops/op-hsgma2.js) sets this to a function returning the share of the + // pool's cap it holds (0–1): b.snap's `neg` list (snapshot.js negView) draws it as the red bar; display only + this.negFill = null; // knocked out, the unit lies — and redeploys — on its home tile instead of where it fell (Battle._layBody) while content // holds this: 乌尔比安 moved by his S3 (the owner's decision of 2026-10-07, a deviation from PRTS's "where it fell"); // every deployment clears it (battle/deploy.js _deploy) diff --git a/test/content/op_hsgma2.test.js b/test/content/op_hsgma2.test.js index a5e1e713..f5167ae9 100644 --- a/test/content/op_hsgma2.test.js +++ b/test/content/op_hsgma2.test.js @@ -300,6 +300,33 @@ test('S3 临死模式 (a 主动关闭 only — no strategy of this mode closes i } }); +// 社区反馈 (PR #383 by @Convey123): 「绿条被打空以后受到伤害就会涨红条,这里看不到红条」 — the official HP bar shows the 我执 pool as a red bar +// growing over the drained green one. The sim has kept the pool since 0.2.0 (unit.mem.hsEgo); the snapshot now lists its share of the +// cap (b.snap `neg`, snapshot.js negView — display only) and render/units.js draws it (test/render/hud-readouts.test.js). +test('T1 业火 我执: the pool reaches the client as b.snap `neg` — its share of the cap (2 × max HP) from the first lethal hit, growing with the damage, gone with 我执', () => { + const { h, u } = field({ tier: 6, elite: true, skill: 0, others: [{ uid: 2, chessId: TEXAS, row: 12, col: 9 }] }); + const e = h.spawn('enemy_dummy', { pos: [10, 8] }); + h.step(); + const max = u.s.maxHp; + const negOf = () => (h.b.snapshot().neg || []).find((x) => x[0] === u.id); + assert.equal(h.b.snapshot().neg, undefined, 'not in 我执: no list'); + h.b.dealDamage(e, u, { amount: u.hp + 300, type: 'true' }); + h.step(); + assert.ok(u.mem.hsEgo, '已进入 我执'); + const cap = 2 * max; // max_minus_hp_ratio + approx(negOf()[1], 300 / cap, 'the share of the cap', 0.02); + h.b.dealDamage(e, u, { amount: 400, type: 'true' }); + h.step(); + approx(negOf()[1], 700 / cap, 'it grew with the damage', 0.02); + assert.ok(negOf()[1] < 1, 'still below a full pool'); + assert.ok(u.hp / max < 0.01, 'her green bar sits on the 1-HP floor: the red bar is the whole story'); + assert.equal(h.b.snapshot().units.find((t) => t[0] === u.id).length, 9, 'the tuple is not widened'); + assert.ok(h.runUntil(() => !u.mem.hsEgo, 60), 'the quiet-time regeneration clears the pool'); + h.step(); + assert.equal(h.b.snapshot().neg, undefined, 'no pool, no red bar'); + done(h); +}); + test('T1 业火 我执: a lethal hit leaves her on the field (HP floor) with the excess as negative HP; later damage goes there (受击回复 SP still), 禁疗 meanwhile; 200 % of max HP knocks her out; 4 s without damage ⇒ 生命回复速度 5 %/s clears it and she leaves 我执', () => { for (const [tier, elite] of [[5, false], [6, true]]) { const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb; diff --git a/test/render/hud-readouts.test.js b/test/render/hud-readouts.test.js new file mode 100644 index 00000000..086581c7 --- /dev/null +++ b/test/render/hud-readouts.test.js @@ -0,0 +1,349 @@ +// test/render/hud-readouts.test.js — the HP-bar readouts, client side (R22-HUD; PR #303 and #378 by @IceCodeNew, PR #383 +// by @Convey123, folded into one change): render/interp.js carries b.snap `ammo` [[id, left, magazine]], `wolves` +// [[id, left, max]] and `neg` [[id, fill]] beside the unit tuples and samples them from the older snapshot; render/units.js +// draws the ammo skill's segmented yellow bar in place of the SP bar, 伺夜's 狼影 as a row of diamonds and the 我执 pool +// as the red bar over the drained HP bar; an ammo skill has no standing aura (headless fake PIXI, test/render/fakepixi.js). +// The server side is test/sim/hud-readouts.test.js and test/content/op_hsgma2.test.js. +// Run: node --test test/render/hud-readouts.test.js + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { installFakePixi, fakeViewCtx } from './fakepixi.js'; +import { presetCamera } from '../../public/js/render/projection.js'; +import { SnapshotBuffer, normalizeSnapshot } from '../../public/js/render/interp.js'; +import { renderInfo } from '../../public/js/render/app/info.js'; +import { UF } from '../../shared/constants.js'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; +import { unitInfo } from '../../server/sim/snapshot.js'; + +let fake, FX, UnitView, COLORS, AMMO_BAR, WOLF_PIPS; +before(async () => { + fake = installFakePixi(); + FX = await import('../../public/js/render/fx.js'); + ({ UnitView, AMMO_BAR, WOLF_PIPS } = await import('../../public/js/render/units.js')); + ({ COLORS } = await import('../../public/js/render/style.js')); +}); +after(() => { FX?.setSimProjectileSpeeds(null); fake.restore(); }); + +const cam = () => presetCamera('normal', { width: 1280, height: 720 }); +const tuple = (id, hp = 1000, extra = {}) => [id, 5, 10, hp, 1000, extra.sp ?? 0, extra.spMax ?? 0, extra.flags ?? 0, 0]; +const view = (info = {}, extra = {}) => new UnitView(fakeViewCtx(fake.P, { cam, ...extra }), + { id: 1, side: 'ally', kind: 'op', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT', ...info }); +const sample = (o = {}) => ({ x: 5, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, ...o }); +const frames = (v, n = 2) => { for (let i = 0; i < n; i++) v.update(1 / 60, cam(), i / 60); }; +const barWidth = (v) => v.hpBg.width - 2; + +describe('interp: ammo / wolves / neg', () => { + test('sampled from the older snapshot (they step at snapshot boundaries) and cleared by a snapshot without them', () => { + const buf = new SnapshotBuffer({ delay: 0 }); + buf.push({ t: 1, units: [tuple(1), tuple(2)], ammo: [[1, 13, 14]], wolves: [[2, 2, 3]], neg: [[2, 0.25]] }, 0); + buf.push({ t: 2, units: [tuple(1), tuple(2)], ammo: [[1, 12, 14]], wolves: [[2, 1, 3]], neg: [[2, 0.5]] }, 0.5); + buf.push({ t: 3, units: [tuple(1), tuple(2)] }, 1); + // between the first two snapshots nothing slides: a round, a 狼影 or a share of the pool is not halfway + const out = buf.sample(1.99); + assert.deepEqual([out.get(1).ammo, out.get(1).wolves, out.get(1).neg], [[13, 14], null, 0]); + assert.deepEqual([out.get(2).ammo, out.get(2).wolves, out.get(2).neg], [null, [2, 3], 0.25]); + buf.sample(2, out); + assert.deepEqual([out.get(1).ammo, out.get(2).wolves, out.get(2).neg], [[12, 14], [1, 3], 0.5]); + // a snapshot without the lists takes the readouts off the reused sample objects + buf.sample(3, out); + assert.deepEqual([out.get(1).ammo, out.get(2).wolves, out.get(2).neg], [null, null, 0]); + }); + + test('malformed rows are dropped: fractions, negatives, a count above its maximum, a maximum under 1, unknown ids, junk', () => { + const s = normalizeSnapshot({ + t: 1, units: [tuple(1), tuple(2), tuple(3)], + ammo: [[1, 13, 14], [2, -1, 14], [2, 2, 1], [2, 1.5, 4], [9, 1, 2], [3, 15, 14], null, 'x', [3, 0, 0]], + wolves: [[2, 3, 3], [1, 2.5, 3], [1, 4, 3], [3, 1, 0], [9, 1, 3]], + neg: [[1, 0.4], [2, 0], [3, -1], [3, 'x'], [9, 0.5], [2, 5], null], + }); + assert.deepEqual([...s.ammo], [[1, [13, 14]]]); + assert.deepEqual([...s.wolves], [[2, [3, 3]]]); + assert.deepEqual([...s.neg], [[1, 0.4], [2, 1]], 'a share above 1 is a full pool; 0 / negative / non-numbers are no pool'); + const none = normalizeSnapshot({ t: 1, units: [tuple(1)] }); + assert.deepEqual([none.ammo, none.wolves, none.neg], [null, null, null]); + assert.equal(none.units.get(1).length, 9, 'the tuples are never widened by the readouts'); + }); +}); + +describe('UnitView: the ammo skill\'s segmented bar', () => { + test('replaces the SP bar while the magazine runs: a yellow cell per round, the fill is rounds left / magazine, a thin cut at every boundary', () => { + const v = view(); + v.sync(sample({ flags: UF.SKILL, sp: 22.3, spMax: 24, ammo: [13, 14] })); + frames(v); + const bw = barWidth(v); + assert.equal(v.spBg.visible && v.spFill.visible, true); + assert.equal(v.spFill.tint, COLORS.ammo); + assert.ok(Math.abs(v.spFill.width - bw * 13 / 14) < 1e-9, 'rounds left over the magazine, exactly (not the 0.1-rounded SP)'); + const cuts = v.ammoCuts.filter((c) => c.visible); + assert.equal(cuts.length, 13, 'one cut per boundary of 14 cells'); + const step = bw / 14, x0 = v.spFill.position.x; + cuts.forEach((c, i) => { + assert.ok(Math.abs(c.position.x - (x0 + (i + 1) * step - 0.5)) < 1e-9, `cut ${i + 1} on the boundary`); + assert.equal(c.width, 1); + assert.equal(c.height, v.spFill.height); + assert.equal(c.position.y, v.spFill.position.y); + assert.equal(c.tint, COLORS.hpBack); + }); + assert.ok(v.spFill.height > v.hpFill.height * 0.6, 'taller than the plain SP bar (the HP bar\'s thickness × AMMO_BAR.height)'); + assert.ok(Math.abs(v.spFill.height - Math.max(2, v.hpFill.height * AMMO_BAR.height)) < 1e-9); + // a round is spent: the fill loses 1/14 and the cuts stay + v.sync(sample({ flags: UF.SKILL, sp: 20.6, spMax: 24, ammo: [12, 14] })); + frames(v); + assert.ok(Math.abs(v.spFill.width - bw * 12 / 14) < 1e-9); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 13); + // the last round, then the magazine ends: the plain SP bar is back (cyan, no cuts) + v.sync(sample({ flags: UF.SKILL, ammo: [0, 14], sp: 0, spMax: 24 })); + frames(v); + assert.equal(v.spFill.width, 0); + v.sync(sample({ flags: 0, sp: 6, spMax: 24, ammo: null })); + frames(v); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'no cuts without a magazine'); + assert.notEqual(v.spFill.tint, COLORS.ammo); + assert.ok(Math.abs(v.spFill.width - bw * 6 / 24) < 1e-9, 'the SP fraction of the tuple'); + v.destroy(); + }); + + test('a magazine needs no SP cost to show; a dense one keeps its exact fill and draws no cuts under AMMO_BAR.minCell px; an enemy never has the bar', () => { + const v = view(); + v.sync(sample({ flags: UF.SKILL, ammo: [20, 40] })); // spMax 0: an auto skill with no SP cost + frames(v); + const bw = barWidth(v); + assert.equal(v.spFill.visible, true, 'the bar shows without an SP cost'); + assert.ok(Math.abs(v.spFill.width - bw / 2) < 1e-9); + const cell = bw / 40; + assert.equal(v.ammoCuts.filter((c) => c.visible).length, cell >= AMMO_BAR.minCell ? 39 : 0, `${cell.toFixed(2)} px cells`); + v.sync(sample({ flags: UF.SKILL, ammo: [20, 400] })); + frames(v); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'under 2 px a cell: no cuts'); + assert.ok(Math.abs(v.spFill.width - bw * 20 / 400) < 1e-9, 'the fill is still exact'); + v.destroy(); + const e = view({ id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_x' }); + e.sync(sample({ hp: 500, flags: UF.SKILL, ammo: [3, 4] })); + frames(e); + assert.equal(e.spFill.visible, false, 'enemies have no SP bar, and so no magazine'); + assert.equal(e.ammoCuts.filter((c) => c.visible).length, 0); + e.destroy(); + }); + + test('the prep view draws no bars, so no cells either', () => { + const v = view({}, {}); + v.prep = true; + v.sync(sample({ flags: UF.SKILL, ammo: [5, 14] })); + frames(v); + assert.equal(v.spFill.visible, false); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0); + v.destroy(); + }); +}); + +describe('UnitView: 伺夜\'s 狼影 pips', () => { + const pack = (extra = {}) => view({ kind: 'token', defId: 'token_10028_vigil_wolf', ...extra }); + + test('one diamond per 狼影 the talent allows under the HP bar, the first ones lit for the shadows left', () => { + const v = pack(); + v.sync(sample({ wolves: [2, 3] })); + frames(v); + const r = v._wolfPips; + assert.equal(r.root.visible, true); + assert.equal(r.lit.length, 3); + assert.deepEqual(r.lit.map((d) => d.alpha), [1, 1, WOLF_PIPS.dim]); + assert.ok(r.lit.every((d) => d.visible && d.tint === COLORS.wolf && Math.abs(d.rotation - Math.PI / 4) < 1e-9), 'pale diamonds'); + assert.ok(r.back.every((d) => d.visible && d.tint === COLORS.hpBack), 'on a dark backing'); + assert.ok(r.lit[0].position.y > v.hpBg.position.y, 'below the HP bar'); + assert.ok(r.lit[0].position.x > v.hpFill.position.x && r.lit[2].position.x < v.hpFill.position.x + barWidth(v), 'inside the bar\'s span'); + assert.ok(r.lit[1].position.x > r.lit[0].position.x && r.lit[2].position.x > r.lit[1].position.x, 'left to right'); + assert.equal(v.spFill.visible, false, 'not an SP bar or a skill indicator'); + // a 狼影 is spent: the last lit one dims; one is gained: it lights again + v.sync(sample({ wolves: [1, 3] })); + frames(v); + assert.deepEqual(r.lit.map((d) => d.alpha), [1, WOLF_PIPS.dim, WOLF_PIPS.dim]); + v.sync(sample({ wolves: [3, 3] })); + frames(v); + assert.deepEqual(r.lit.map((d) => d.alpha), [1, 1, 1]); + // a smaller maximum hides the surplus diamonds + v.sync(sample({ wolves: [1, 2] })); + frames(v); + assert.equal(r.lit.filter((d) => d.visible).length, 2); + v.destroy(); + }); + + test('hidden without a count (an older snapshot cannot leave stale pips), in the prep view, and for enemies', () => { + const v = pack(); + v.sync(sample({ wolves: [2, 3] })); + frames(v); + assert.equal(v._wolfPips.root.visible, true); + v.sync(sample({})); + frames(v); + assert.equal(v._wolfPips.root.visible, false, 'no row in the snapshot: no pips'); + v.sync(sample({ wolves: [2, 3] })); + v.prep = true; + frames(v); + assert.equal(v._wolfPips.root.visible, false, 'the prep board draws no bars'); + v.destroy(); + const e = view({ id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_x' }); + e.sync(sample({ hp: 500, wolves: [2, 3] })); + frames(e); + assert.equal(e._wolfPips, null, 'only allies carry the row'); + e.destroy(); + }); + + test('the element gauge row moves down below the pips (and below the SP bar when there is one)', () => { + const plain = view(); + plain.sync(sample({ el: 'burn', elFill: 0.4, elUntil: 0, elDur: 0 })); + frames(plain); + const v = pack(); + v.sync(sample({ wolves: [2, 3], el: 'burn', elFill: 0.4, elUntil: 0, elDur: 0 })); + frames(v); + const pipsBottom = v._wolfPips.back[0].position.y + (v._wolfPips.back[0].width * Math.SQRT2) / 2; + assert.ok(v._elBar.bg.position.y - v._elBar.bg.height / 2 >= pipsBottom - 1e-6, 'the gauge row starts under the pips'); + assert.ok(v._elBar.bg.position.y > plain._elBar.bg.position.y + 4, 'and so sits lower than without pips'); + plain.destroy(); + v.destroy(); + }); +}); + +describe('UnitView: the 我执 pool as the red bar', () => { + const ego = (extra = {}) => view({ defId: 'char_1044_hsgma2', tier: 6, ...extra }); + + test('drawn over the drained HP bar from its left edge: width = the bar × the pool\'s share, the pool\'s red, gone with the pool', () => { + const v = ego(); + v.sync(sample({ hp: 1 })); + frames(v); + assert.equal(v.negFill.visible, false, 'no pool, no red bar'); + assert.equal(v.negFill.tint, COLORS.hpNeg); + v.sync(sample({ hp: 1, neg: 0.5 })); + frames(v); + assert.equal(v.negFill.visible, true); + assert.ok(Math.abs(v.negFill.width - barWidth(v) * 0.5) < 1e-9, 'half the pool: half the bar'); + assert.equal(v.negFill.position.x, v.hpFill.position.x, 'from the bar\'s left edge'); + assert.equal(v.negFill.position.y, v.hpFill.position.y); + assert.equal(v.negFill.height, v.hpFill.height); + assert.ok(v.hpFill.width < 1, 'the green bar is drained meanwhile'); + v.sync(sample({ hp: 1, neg: 1 })); + frames(v); + assert.ok(Math.abs(v.negFill.width - barWidth(v)) < 1e-9, 'a full pool fills the bar'); + v.sync(sample({ hp: 600, neg: 0 })); + frames(v); + assert.equal(v.negFill.visible, false, 'she left 我执'); + v.destroy(); + }); + + test('not in the prep view, and gone once she falls', () => { + const p = ego(); + p.prep = true; + p.sync(sample({ hp: 1, neg: 0.6 })); + frames(p); + assert.equal(p.negFill.visible, false); + p.destroy(); + const v = ego(); + v.sync(sample({ hp: 1, neg: 1 })); + frames(v); + assert.equal(v.negFill.visible, true); + v.die(); + for (let i = 0; i < 300; i++) v.update(1 / 60, cam(), i / 60); + assert.equal(v.alive, false); + assert.equal(v.negFill.visible, false, 'no bars for a knocked-out operator'); + v.destroy(); + }); +}); + +describe('an ammo skill has no standing aura', () => { + const unit = (id, info) => ({ id, x: 3, y: 10, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, isEnemy: false, maxHp: 3000, info, onHit() {} }); + const makeFx = (views) => { + const ctx = fakeViewCtx(fake.P); + const map = new Map(views.map((v) => [v.id, v])); + const c = presetCamera('normal', { width: 1600, height: 900 }); + return new FX.FxSystem({ + P: fake.P, layers: ctx.layers, cam: () => c, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true }, + timeScale: () => 2, loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null, + screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120, + }); + }; + + test('UnitInfo.ammoSkill: the activation burst plays, the ground glow and hex do not (a plain skill keeps both)', () => { + const magazine = unit(1, { defId: 'char_x', ammoSkill: true }), plain = unit(2, { defId: 'char_y' }); + const fx = makeFx([magazine, plain]); + fx.skill(magazine, true); + assert.ok(fx.parts.length > 0 && fx.rings.length > 0, 'the cast itself is still shown'); + assert.equal(fx.auras.size, 0, 'no glow and hex under a magazine'); + fx.skill(magazine, false); + fx.skill(plain, true); + assert.equal(fx.auras.size, 1, 'a plain skill keeps its aura'); + fx.destroy(); + }); + + test('the flag travels: unitInfo (ammo skills of allies only) → renderInfo', () => { + const h = makeBattle({ + defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }, timeLimit: 30, seed: 7, + units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 11, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + }); + h.run(0.5); + assert.equal(renderInfo(unitInfo(h.unit('chess_char_1_01_a'))).ammoSkill, true); + assert.equal(renderInfo(unitInfo(h.unit('chess_char_1_02_a'))).ammoSkill, undefined); + assert.equal(renderInfo(unitInfo(h.enemy('enemy_dummy'))).ammoSkill, undefined); + assert.equal(renderInfo({ id: 1, ammoSkill: 'yes' }).ammoSkill, undefined, 'only a real true counts'); + }); +}); + +describe('the chain: sim → snapshot → SnapshotBuffer → UnitView', () => { + test('隐现 S2: 14 cells, 13 after the first round, one more gone per attack; no aura; the plain SP bar after the last round', () => { + const h = makeBattle({ + defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }, + units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + timeLimit: 200, seed: 7, + }); + const u = h.unit('chess_char_1_01_a'); + assert.ok(h.runUntil(() => u.skill.active, 80)); + const v = new UnitView(fakeViewCtx(fake.P, { cam }), renderInfo(unitInfo(u))); + const buffer = new SnapshotBuffer({ delay: 0 }); + const show = () => { + const snap = h.b.snapshot(); + buffer.push(snap, snap.t); + v.sync(buffer.sample(snap.t).get(u.id), snap.t); + v.update(1 / 60, cam(), h.b.time); + }; + show(); + const bw = barWidth(v); + assert.equal(u.skill.ammoLeft, 13, 'the activation tick already fired the first round'); + assert.ok(Math.abs(v.spFill.width - bw * 13 / 14) < 1e-9); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 13); + const left = u.skill.ammoLeft; + assert.ok(h.runUntil(() => u.skill.ammoLeft < left && u.skill.active, 10)); + show(); + assert.ok(Math.abs(v.spFill.width - bw * 12 / 14) < 1e-9, 'one attack removes exactly one round'); + assert.ok(h.runUntil(() => !u.skill.active, 60)); + show(); + show(); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'the magazine is spent: the plain SP bar is back'); + assert.notEqual(v.spFill.tint, COLORS.ammo); + v.destroy(); + }); + + test('伺夜\'s placed pack: the pips follow 2 → 3 → 2 and are gone when the pack falls', () => { + const h = makeBattle({ + units: [{ chessId: 'chess_char_3_19_a', row: 12, col: 3, uid: 1 }, { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 11, col: 5, uid: 2 }], + timeLimit: 120, defs: { enemies: { enemy_d: enemyRec({ key: 'enemy_d', hp: 1e9, speed: 0, atk: 0 }) } }, enemies: [{ key: 'enemy_d', pos: [1, 1] }], + }); + h.run(0.2); + const w = h.b.allyUnits.find((x) => x.defId === 'token_10028_vigil_wolf'); + const v = new UnitView(fakeViewCtx(fake.P, { cam }), renderInfo(unitInfo(w))); + const buffer = new SnapshotBuffer({ delay: 0 }); + const lit = () => { + const snap = h.b.snapshot(); + buffer.push(snap, snap.t); + v.sync(buffer.sample(snap.t).get(w.id), snap.t); + v.update(1 / 60, cam(), h.b.time); + return v._wolfPips ? v._wolfPips.lit.filter((d) => d.visible && d.alpha === 1).length : 0; + }; + assert.equal(lit(), 2); + h.run(25); + assert.equal(lit(), 3); + h.b.dealDamage(null, w, { amount: 1e9, type: 'true' }); + h.step(); // (a snapshot of the same game time as the last one would be dropped as a duplicate) + assert.equal(lit(), 2); + v.destroy(); + }); +}); diff --git a/test/sim/hud-readouts.test.js b/test/sim/hud-readouts.test.js new file mode 100644 index 00000000..68358dcb --- /dev/null +++ b/test/sim/hud-readouts.test.js @@ -0,0 +1,97 @@ +// test/sim/hud-readouts.test.js — the optional b.snap lists behind the HP-bar readouts (R22-HUD; PR #303 and #378 by +// @IceCodeNew, PR #383 by @Convey123): `ammo` (a running ammo skill's rounds — 隐现's S2 holds 14 in the data), `wolves` +// (伺夜's 狼群 count) and, in test/content/op_hsgma2.test.js, `neg` (斩业星熊's 我执 pool). All three are display only: the +// nine-field unit tuples are unchanged and no sim state reads them. The client side is test/render/hud-readouts.test.js. +// Run: node --test test/sim/hud-readouts.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { unitInfo, unitTuple } from '../../server/sim/snapshot.js'; + +const DUMMY = { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }; +const INSIDE = 'chess_char_1_01_a'; // 隐现: the chess skill index 1 is skchr_inside_2 (AMMO, attack@trigger_time 14) + +test('隐现 S2: b.snap `ammo` carries the whole rounds left and the 14-round magazine of the data; the tuple keeps its SP fraction', () => { + const h = makeBattle({ + defs: DUMMY, timeLimit: 200, seed: 7, + units: [{ chessId: INSIDE, row: 10, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + }); + const u = h.unit(INSIDE); + const row = () => (h.b.snapshot().ammo || []).find((e) => e[0] === u.id); + assert.equal(h.b.snapshot().ammo, undefined, 'no skill running: no list at all'); + assert.equal(unitInfo(u).ammoSkill, true, 'UnitInfo says the skill is an ammo magazine from the first appearance'); + assert.ok(h.runUntil(() => u.skill.active, 80)); + assert.equal(u.skill.kind, 'ammo'); + assert.equal(u.skill.ammoMax, 14, 'skill_table skchr_inside_2 attack@trigger_time at the chess skill level'); + // the activation tick already fired the first round + assert.deepEqual(row(), [u.id, 13, 14]); + const [, , , , , sp, spMax] = unitTuple(u, h.b.time); + assert.ok(Math.abs(sp / spMax - 13 / 14) < 0.01, 'the 9-field tuple still runs the SP bar on the same fraction'); + assert.equal(h.b.snapshot().units.find((t) => t[0] === u.id).length, 9, 'the tuple is not widened'); + // every attack spends one round + const before = u.skill.ammoLeft; + assert.ok(h.runUntil(() => u.skill.ammoLeft < before, 10)); + assert.deepEqual(row(), [u.id, u.skill.ammoLeft, 14]); + // a refill above the magazine raises it (拉特兰's extras, 逃犯引渡手续, refills: the activation's real total) + u.skill.addAmmo(20); + assert.deepEqual(row(), [u.id, u.skill.ammoLeft, u.skill.ammoMax]); + assert.ok(u.skill.ammoMax > 14 && row()[1] === row()[2], 'full again at the new mark'); + // the skill ends with its last round: the list goes, the plain SP bar is back + assert.ok(h.runUntil(() => !u.skill.active, 60)); + assert.equal(h.b.snapshot().ammo, undefined); + checkInvariants(h.b); + assert.deepEqual(h.b.errors, []); +}); + +test('ammo rows: whole numbers (a fractional count rounds up), allies only, living deployed units only; an operator without an ammo skill is no ammoSkill', () => { + const h = makeBattle({ + defs: DUMMY, timeLimit: 200, seed: 7, + units: [{ chessId: INSIDE, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 11, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + }); + const u = h.unit(INSIDE); + h.run(0.5); + assert.equal(unitInfo(h.unit('chess_char_1_02_a')).ammoSkill, undefined, 'a duration skill is not a magazine'); + assert.equal(unitInfo(h.enemy('enemy_dummy')).ammoSkill, undefined); + assert.ok(h.runUntil(() => u.skill.active, 80)); + u.skill.ammoLeft = 6.2; // (a kit that spends a share of a round) + const e = h.b.snapshot().ammo.find((r) => r[0] === u.id); + assert.deepEqual(e, [u.id, 7, 14]); + assert.ok(Number.isInteger(e[1]) && Number.isInteger(e[2])); + u.deployed = false; // off the field: no row (the unit is not in the snapshot either) + assert.equal(h.b.snapshot().ammo, undefined); + u.deployed = true; +}); + +for (const placed of [false, true]) { + test(`伺夜's ${placed ? 'placed' : 'automatic'} pack: b.snap \`wolves\` follows the 狼影 count (2 of 3, +1 per 25 s, one lost per lethal hit) and goes with the pack`, () => { + const units = [{ chessId: 'chess_char_3_19_a', row: 12, col: 3, uid: 1 }]; + if (placed) units.push({ kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 11, col: 5, uid: 2 }); + const h = makeBattle({ + units, timeLimit: 120, + defs: { enemies: { enemy_d: enemyRec({ key: 'enemy_d', hp: 1e9, speed: 0, atk: 0 }) } }, + enemies: [{ key: 'enemy_d', pos: [1, 1] }], + }); + h.run(0.2); + const w = h.b.allyUnits.find((u) => u.defId === 'token_10028_vigil_wolf'); + const count = () => (h.b.snapshot().wolves || []).find((e) => e[0] === w.id); + assert.deepEqual(count(), [w.id, 2, 3], '初始两只, at most 3 (the talent text)'); + assert.equal(h.b.snapshot().wolves.length, 1, 'only the pack has the row: 伺夜 herself does not'); + h.run(25); + assert.deepEqual(count(), [w.id, 3, 3], '+1 every 25 s'); + for (const left of [2, 1]) { + h.b.dealDamage(null, w, { amount: 1e9, type: 'true' }); + assert.deepEqual(count(), [w.id, left, 3], 'a lethal hit spends one 狼影 and refills the HP'); + } + h.b.dealDamage(null, w, { amount: 1e9, type: 'true' }); + assert.equal(w.alive, false, 'the last 狼影: 战术点形态 (the pack is out of the fight)'); + assert.equal(count(), undefined, 'a pack in that form shows no pips'); + h.run(26); + assert.deepEqual(count(), [w.id, 1, 3], 'back after the 狼影 interval with one 狼影'); + h.b.retreat(h.unit('chess_char_3_19_a'), { reason: 'expired', permanent: true }); + assert.equal(h.b.snapshot().wolves, undefined, 'the pack leaves with its owner'); + assert.deepEqual(h.b.errors, []); + }); +} diff --git a/types/core.js b/types/core.js index effe5f3a..95c23f3a 100644 --- a/types/core.js +++ b/types/core.js @@ -88,10 +88,23 @@ * @typedef {[number, string, number, number, number]} ElemSnap */ +/** + * One HP-bar readout of a unit: `[id, left, max]` — the rounds left of a running ammo skill and its magazine (`ammo`), or the + * 狼影 left of 伺夜's 狼群 and the talent's maximum (`wolves`). Whole numbers. + * + * @typedef {[number, number, number]} CountSnap + */ + +/** + * A negative-HP pool as a share of its cap (`neg`, 斩业星熊's 我执): `[id, fill]`, fill 0.01–1. + * + * @typedef {[number, number]} NegSnap + */ + /** * `snapshot()` (SIM §9). On the wire this object is the payload of `b.snap`, * and `t` is sent as `gt` because the frame's `t` is the message type (DESIGN §8.2). - * `dp` is one number. `down` and `elem` are omitted when empty. + * `dp` is one number. `down`, `elem`, `ammo`, `wolves` and `neg` are omitted when empty. * * @typedef {Object} FieldSnapshot * @property {string} fieldId @@ -104,6 +117,9 @@ * @property {Object} [boss] * @property {DownSnap[]} [down] * @property {ElemSnap[]} [elem] + * @property {CountSnap[]} [ammo] + * @property {CountSnap[]} [wolves] + * @property {NegSnap[]} [neg] */ /**