From 416e7d9e14b142e6822849cd7fba6907e11e7e6b Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:16:14 +0000 Subject: [PATCH] fix(render): show Swire Alter coin balance --- docs/SIM.md | 1 + docs/design/network.md | 5 ++ docs/history/0.2.2.md | 4 ++ public/js/render/interp.js | 15 ++++-- public/js/render/units.js | 29 ++++++++++- server/sim/battle/events.js | 4 ++ test/render/hud-readouts.test.js | 85 ++++++++++++++++++++++++++++++++ types/core.js | 1 + 8 files changed, 140 insertions(+), 4 deletions(-) diff --git a/docs/SIM.md b/docs/SIM.md index d297a48e..65b8d7e6 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -1399,6 +1399,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te 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). + Optional `coins` carries 琳琅诗怀雅's balance; its wire and display contract is in [DESIGN §8.2](design/network.md#82-match). `fieldMeta()` lists the knocked-out operators too (a client joining mid-battle shows them; DESIGN §18.3). `stand: [[id, until]]` (only when non-empty) = the game time each enemy's attack recovery ends (`atkStandUntil`: it stands for the rest of its attack clip, §1.2 `attackStand`) — enemies alive, deployed, visible, neither feared nor stunned; diff --git a/docs/design/network.md b/docs/design/network.md index f0b35ee8..f2964309 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -38,6 +38,11 @@ S→C: listed enemy's recovery was cut or ignored (失衡, fear, stun, hiding, leaving): an interval with a cut inside it stays linear. A newest snapshot showing a death, a stun or a block is not extrapolated. Old snapshots without them interpolate linearly; nothing is inferred from the attack animation. +- Optional `b.snap.coins: [[id, balance]]` carries 琳琅诗怀雅's whole-coin balance (`mem.coins`), including zero, for living, + deployed, visible allies. The client samples it at the older snapshot boundary, as with `wolves`; malformed rows and + unknown ids are ignored, and an absent entry clears the display. A coin icon and integer sit below HP/SP and above the + element gauge, hidden in prep and while down. The icon reuses the coin FX texture from `render/textures.js`. + [ASSUMED] HUD placement/style; no coin mechanics change. - `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}}` and `dir?` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16; `dir` = the unit's facing now, UP|RIGHT|DOWN|LEFT — the snapshot tuples carry none and UnitInfo `dir` is the facing at send time — which the detail card's range overlay rotates by, GitHub PR #281) as its next battle starts (§18.5) diff --git a/docs/history/0.2.2.md b/docs/history/0.2.2.md index 483d4fe7..f6e5cb73 100644 --- a/docs/history/0.2.2.md +++ b/docs/history/0.2.2.md @@ -1060,6 +1060,10 @@ players alive when the fight starts; no change.) 我执), `test/render/hud-readouts.test.js` (14: interp stepping / malformed rows / absent list, the cells, the pips, the red bar, the aura, and the chain sim → snapshot → buffer → view) — all fail on the parent; a headless-Chrome look at the real field view (`notes/shot-hud.mjs`, not committed) shows the three readouts as intended. Docs: design/network.md, SIM.md §9, design/client.md, PLAYING.md, `types/core.js`. +- 琳琅诗怀雅's coin balance uses the same snapshot/readout path. A player screenshot showed her HP/SP bars without a coin + count; the kit already tracked `mem.coins`, but snapshots omitted it. The optional `coins` field supplies the HUD's + coin icon and integer, including zero ([DESIGN §8.2](../design/network.md#82-match)). The icon reuses the coin FX texture; + the inspected `entry/img_coin` is an event ticket, not her currency. [ASSUMED] placement/style; generation, spending and skills are unchanged. ### 27.43 A renderer that loses the 12 s race no longer mounts its canvas late — the attempt is aborted (PR #302 by @siruimei07) — `ui/fieldHost.js mountFieldView / useFieldView`, `render/app.js createFieldView`, `render/app/host.js withTimeout` - **Seen**: `mountFieldView` raced `createFieldView` against a 12 s timeout and on failure removed every child of the host, but the factory diff --git a/public/js/render/interp.js b/public/js/render/interp.js index 3bb36fa4..e4e73800 100644 --- a/public/js/render/interp.js +++ b/public/js/render/interp.js @@ -29,10 +29,11 @@ // * `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`; -// * three HP-bar readouts, kept per snapshot beside the tuples (the tuple layout is unchanged) and handed out by sample() +// * 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 +// `coins` [[id, whole-coin balance]] → sample().coins number | null (zero is a balance) // `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 我执). @@ -117,6 +118,13 @@ export function normalizeSnapshot(snap) { } } const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units); + let coins = null; + if (Array.isArray(snap.coins)) { + for (const e of snap.coins) { + if (!Array.isArray(e) || !units.has(e[0]) || !Number.isSafeInteger(e[1]) || e[1] < 0) continue; + (coins || (coins = new Map())).set(e[0], e[1]); + } + } let neg = null; if (Array.isArray(snap.neg)) { for (const e of snap.neg) { @@ -147,7 +155,7 @@ export function normalizeSnapshot(snap) { (standCut || (standCut = new Map())).set(e[0], e[1]); } } - return { t, units, down, ammo, wolves, neg, stand, standCut, raw: snap }; + return { t, units, down, ammo, wolves, coins, neg, stand, standCut, raw: snap }; } /** @@ -356,7 +364,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, ammo: null, wolves: null, neg: 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, coins: 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]; @@ -402,6 +410,7 @@ export class SnapshotBuffer { // 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.coins = A.coins?.get(id) ?? null; o.neg = A.neg ? A.neg.get(id) || 0 : 0; o.seen = stamp; } diff --git a/public/js/render/units.js b/public/js/render/units.js index 656299fa..d349ebc9 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -477,6 +477,8 @@ export class UnitView { 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.coins = null; // Swire's balance, including zero; null without a purse + this.coinHud = null; 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(); @@ -825,6 +827,7 @@ export class UnitView { 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; + this.coins = s.coins ?? null; const prevFlags = this.flags; this.flags = s.flags | 0; this.anim = s.anim | 0; @@ -1412,7 +1415,8 @@ export class UnitView { // 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); + const coinH = this._updateCoins(showBars && !this.isEnemy && this.coins !== null, x0, rowsTop + wolfH, s); + this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + coinH + 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); @@ -1493,6 +1497,29 @@ export class UnitView { return d + 2 + WOLF_PIPS.pad; } + /** [ASSUMED] Coin icon + integer below the bars; the balance comes only from snapshots, not transient FX. */ + _updateCoins(show, x, top, s) { + let r = this.coinHud; + if (!show) { if (r) r.root.visible = false; return 0; } + const P = this.P; + if (!r) { + const root = new P.Container(); + const icon = new P.Sprite(fxAtlas().tex.coin); + icon.width = icon.height = 16; + const text = new P.Text('', { fontFamily: 'Bender, Oxanium, sans-serif', fontSize: 16, fontWeight: '700', fill: '#ffd04a', stroke: '#0b0f0e', strokeThickness: 3 }); + text.position.set(19, -2); + root.addChild(icon, text); + this.hud.addChild(root); + r = this.coinHud = { root, icon, text }; + } + r.text.text = String(this.coins); + const size = clamp(s * 0.2, 10, 16); + r.root.scale.set(size / 16); + r.root.position.set(x, top + 3); + r.root.visible = true; + return size + 5; + } + /** 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 39539d5e..8fbaa2f0 100644 --- a/server/sim/battle/events.js +++ b/server/sim/battle/events.js @@ -102,6 +102,10 @@ export class BattleEvents { if (wv) (wolves || (wolves = [])).push([u.id, wv[0], wv[1]]); const ng = negView(u); if (ng) (neg || (neg = [])).push([u.id, ng]); + // Swire's purse is initialized on deploy; zero is a balance, absence is no purse. + if (u.side === 'ally' && Number.isSafeInteger(u.mem.coins) && u.mem.coins >= 0) { + (snap.coins || (snap.coins = [])).push([u.id, u.mem.coins]); + } } if (elem) snap.elem = elem; if (ammo) snap.ammo = ammo; diff --git a/test/render/hud-readouts.test.js b/test/render/hud-readouts.test.js index 086581c7..7c2b12bb 100644 --- a/test/render/hud-readouts.test.js +++ b/test/render/hud-readouts.test.js @@ -288,6 +288,91 @@ describe('an ammo skill has no standing aura', () => { }); describe('the chain: sim → snapshot → SnapshotBuffer → UnitView', () => { + test('user coin HUD: Given real Swire skills, When coins accumulate and are spent, Then the visible balance includes zero', () => { + for (const skillIndex of [0, 1, 2]) { + const h = makeBattle({ + units: [{ chessId: 'chess_char_3_04_a', row: 10, col: 5, skillIndex }], + flags: { dpInit: 99, dpMax: 99, dpPerSec: 3 }, timeLimit: 120, + }); + h.step(); + const u = h.unit('chess_char_3_04_a'); + const v = new UnitView(fakeViewCtx(fake.P, { cam }), renderInfo(unitInfo(u))); + const buffer = new SnapshotBuffer({ delay: 0 }); + const show = (expected) => { + const snap = h.b.snapshot(); + assert.deepEqual(snap.coins, [[u.id, expected]]); + buffer.push(snap, snap.t); + v.sync(buffer.sample(snap.t).get(u.id), snap.t); + frames(v); + assert.equal(v.coinHud.root.visible, true); + assert.equal(v.coinHud.text.text, String(expected)); + assert.ok(v.coinHud.root.position.y > v.hpBg.position.y); + }; + if (skillIndex === 2) { + show(0); + assert.ok(u.skill.activate('test', { free: true })); + h.step(); + show(1); + h.run(3); + show(2); + u.skill.end('manual'); + h.step(); + show(0); + } else if (skillIndex === 1) { + show(0); + assert.ok(h.runUntil(() => u.mem.coins === 3, 60)); + show(3); + const bomb = h.b.allyUnits.find((a) => a.alive && a.defId === 'token_10031_swire2_gdtrap'); + h.b.retreat(bomb, { reason: 'expired', permanent: true }); + assert.ok(h.runUntil(() => u.mem.coins === 2, 5)); + show(2); + } else { + show(1); + h.run(3); + show(2); + h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.5, type: 'true' }); + assert.ok(h.runUntil(() => u.mem.coins === 1, 2)); + show(1); + } + v.prep = true; + frames(v); + assert.equal(v.coinHud.root.visible, false); + v.prep = false; + v.sync(sample()); + frames(v); + assert.equal(v.coinHud.root.visible, false, 'an old snapshot clears the balance'); + u.hidden = true; + assert.equal(h.b.snapshot().coins, undefined, 'hidden units do not publish a purse'); + u.hidden = false; + u.deployed = false; + assert.equal(h.b.snapshot().coins, undefined, 'undeployed units do not publish a purse'); + u.deployed = true; + h.b.retreat(u, { reason: 'expired', permanent: true }); + assert.equal(h.b.snapshot().coins, undefined); + v.sync(sample({ coins: 0 })); + frames(v); + assert.equal(v.coinHud.root.visible, true); + v.die(); + frames(v); + assert.equal(v.coinHud.root.visible, false); + assert.deepEqual(h.b.errors, []); + v.destroy(); + } + }); + + test('user coin HUD: Given adjacent snapshots, When sampling a spend, Then zero arrives only at the snapshot boundary', () => { + const buf = new SnapshotBuffer({ delay: 0 }); + buf.push({ t: 1, units: [tuple(1)], coins: [[1, 2], [1, -1], [1, 0.5], [9, 4], null] }, 0); + buf.push({ t: 2, units: [tuple(1)], coins: [[1, 0]] }, 0.5); + buf.push({ t: 3, units: [tuple(1)] }, 1); + const out = buf.sample(1.99); + assert.equal(out.get(1).coins, 2); + buf.sample(2, out); + assert.equal(out.get(1).coins, 0); + buf.sample(3, out); + assert.equal(out.get(1).coins, null); + }); + 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 }) } }, diff --git a/types/core.js b/types/core.js index c79208a7..1a71a88c 100644 --- a/types/core.js +++ b/types/core.js @@ -121,6 +121,7 @@ * @property {ElemSnap[]} [elem] * @property {CountSnap[]} [ammo] * @property {CountSnap[]} [wolves] + * @property {[number, number][]} [coins] Swire's whole-coin balance, including zero * @property {NegSnap[]} [neg] * @property {[number, number][]} [stand] * @property {[number, number][]} [standCut]