diff --git a/public/js/render/units.js b/public/js/render/units.js index a7de69ad..206c8278 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -117,6 +117,12 @@ const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Dat * 无头像; data/assets.json has no avatar or model for it, so the token fallback showed 圣聆初雪's own face) — the marker of a * frozen gate. */ const ICE_TOKENS = new Set(['token_10058_sbell2_icetgt']); +/** + * Units that never show an HP bar: 琳琅诗怀雅's 香槟炸弹 — PRTS 香槟炸弹 备注 "即使自身生命值未满,模型下方也不会显示生命值槽" + * (the owner's report of 2026-10-08, 「香槟会掉血,会被治疗」: the bar showed every change). Only the bar: its HP, the + * damage it takes (活性源石 hurts what stands on it) and healing are unchanged — no source makes it invulnerable. + */ +const NO_HP_BAR = new Set(['token_10031_swire2_gdtrap']); const ICE_FRAME = 0x9fe6ff; /** How long a view waits for its avatar before showing the image-less placeholder diamond. */ const PIC_WAIT_MS = 400; @@ -1249,7 +1255,7 @@ export class UnitView { const prep = this.prep; const showBars = !prep && this.alive && this.info.kind !== 'item'; const damaged = this.hp < this.maxHp - 0.5; - const showHp = showBars && (!this.isEnemy || damaged || this.isBoss); + const showHp = showBars && (!this.isEnemy || damaged || this.isBoss) && !NO_HP_BAR.has(this.info.defId); const bw = clamp(s * (this.isBoss ? UNIT.bossBarWidth : UNIT.barWidth), 24, this.isBoss ? 260 : 96); const bh = clamp(s * (this.isBoss ? 0.12 : 0.075), 3, this.isBoss ? 12 : 7); // a knocked-down operator's HUD is its redeploy ring alone, drawn at full strength over the greyed model diff --git a/test/render/champagne-hpbar.test.js b/test/render/champagne-hpbar.test.js new file mode 100644 index 00000000..d05548d3 --- /dev/null +++ b/test/render/champagne-hpbar.test.js @@ -0,0 +1,43 @@ +// test/render/champagne-hpbar.test.js — 琳琅诗怀雅's 香槟炸弹 (token_10031_swire2_gdtrap) never shows an HP bar: PRTS 香槟炸弹 +// 备注 "即使自身生命值未满,模型下方也不会显示生命值槽" (the owner's report of 2026-10-08, 「香槟会掉血,会被治疗」). Allies +// otherwise always draw one (render/units.js _updateHud). Only the bar changes; its HP and healing stay as they are. +// Run: node --test test/render/champagne-hpbar.test.js + +import { test, 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'; + +let fake, UnitView; +before(async () => { + fake = installFakePixi(); + ({ UnitView } = await import('../../public/js/render/units.js')); +}); +after(() => fake.restore()); + +const cam = () => presetCamera('normal', { width: 1280, height: 720 }); +const view = (defId, id) => new UnitView(fakeViewCtx(fake.P, { cam }), { id, side: 'ally', kind: 'summon', defId, tier: 1, x: 11, y: 10, maxHp: 1000, hp: 1000 }, {}); +const frames = (v) => { for (let i = 0; i < 2; i++) v.update(1 / 60, cam(), i / 60); }; +const bar = (v) => [v.hpBg.visible, v.hpFill.visible, v.hpGhost.visible]; + +test('香槟炸弹 shows no HP bar at full HP, below it, or healed back up', () => { + const bomb = view('token_10031_swire2_gdtrap', 1); + frames(bomb); + assert.deepEqual(bar(bomb), [false, false, false], 'full HP'); + bomb.sync({ x: 11, y: 10, hp: 860, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0 }, 1); + frames(bomb); + assert.equal(bomb.hp, 860, 'the view keeps its HP'); + assert.deepEqual(bar(bomb), [false, false, false], 'hurt (活性源石: 70 true damage a second)'); + bomb.sync({ x: 11, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0 }, 2); + frames(bomb); + assert.deepEqual(bar(bomb), [false, false, false], 'healed'); +}); + +test('another summon still draws its HP bar, full or hurt', () => { + const other = view('token_10000_silent_healrb', 2); + frames(other); + assert.deepEqual(bar(other), [true, true, true]); + other.sync({ x: 11, y: 10, hp: 400, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0 }, 1); + frames(other); + assert.deepEqual(bar(other), [true, true, true]); +});