From 2fdc565c7c49b29dfbdd3ff22e4e90b609c23fcf Mon Sep 17 00:00:00 2001 From: sganggs Date: Fri, 9 Oct 2026 17:14:50 +0800 Subject: [PATCH] Reuse held Spine poses and redeploy countdown textures (#434) --- docs/design/client.md | 2 +- docs/history/0.2.3.md | 10 ++ public/js/render/app.js | 6 +- public/js/render/spine.js | 26 +++++ public/js/render/textures.js | 31 +++++ public/js/render/units.js | 38 ++++--- test/render/downring.test.js | 49 +++++++- test/render/fakepixi.js | 5 +- test/render/held-pose.test.js | 209 ++++++++++++++++++++++++++++++++++ 9 files changed, 351 insertions(+), 25 deletions(-) create mode 100644 test/render/held-pose.test.js diff --git a/docs/design/client.md b/docs/design/client.md index eb97fed3..d0f5eb4b 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -34,7 +34,7 @@ view.resize() ; view.destroy() `createFieldView(host, { …, signal })` takes an `AbortSignal` (0.2.2, GitHub PR #302 by @siruimei07): an abort during the build — the Pixi load, the manifest, the fonts, the board art and the 3D board waits — destroys what the attempt made (the canvas, the ticker, the listeners, the observers) and rejects, a failure while it is built releases the same partial resources, and `view.destroy()` is idempotent. `ui/fieldHost.js mountFieldView(host, { signal })` aborts that signal when the engine fails, times out (12 s) or the owner unmounts, disposes a view a factory returns after that, and never clears the other children of `host` — the simplified view and a newer mount share it. -Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done). Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). +Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done; a model whose pose cannot change — `SpineActor.poseHeld()`: a knocked-out operator in the held end of its Die clip, a frozen model — is neither re-posed nor redrawn, only its clock runs, until a new clip, a revive, a wind-up, a form's closing clip, a clipping switch or a zoom past the impostor's rescale threshold; the redeploy rings draw one shared rasterised label per number, `render/textures.js downLabel`, the same pixels as a Text of their own). 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/history/0.2.3.md b/docs/history/0.2.3.md index 453e573f..8054a960 100644 --- a/docs/history/0.2.3.md +++ b/docs/history/0.2.3.md @@ -105,3 +105,13 @@ interface strings. Only its message values are ported; later keys and the pack's checklist covering the problem, source evidence, assumptions, actual checks and golden changes. The template is installed at `.github/pull_request_template.md`, the default template location, so GitHub can offer it without a named-template query parameter. No gameplay rules change. + +### 28.8 Stop recalculating held Spine poses — PR #434 + +Ported from [PR #434](https://github.com/sganggs/Stronghold-Protocol/pull/434) by @Cloudnyco. A finished, +non-looping, unmixed death pose or frozen model can reuse its posed skeleton and cached pixels while its +clock advances. First poses, new clips, tint changes, mixing, multiple tracks, pending wind-ups / closing clips, +clipping switches, dirty textures and zoom rescaling still update. Redeployment resumes normal animation. +Redeploy countdown labels share rasterized textures keyed by text, colour and resolution; late web fonts refresh +them in place. The 64 targeted renderer tests passed. The author's performance measurements are not reproduced +here; real Pixi checks are part of the integration pass. No battle rule or golden result is intentionally changed. diff --git a/public/js/render/app.js b/public/js/render/app.js index 1c721bd9..3616883d 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -121,7 +121,7 @@ import { TileField } from './tiles.js'; import { UnitView, ItemView, DeviceView, FORMS, syncView } from './units.js'; import { FxSystem, ensureDamageFonts } from './fx.js'; import { createDragController, pieceTile, dragStandTile } from './drag.js'; -import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js'; +import { backdropTextures, shadowTexture, refreshTierChips, refreshDownLabels, silhouetteTexture } from './textures.js'; import { TILE_H, TIER_COLORS, COLORS } from './style.js'; import { loadBoardArt } from './boardArt.js'; import { ImpostorAtlas } from './impostor.js'; @@ -458,8 +458,8 @@ export async function createFieldView(host, options = {}) { } loadShadow(); ensureDamageFonts(); - // web fonts may land after the first chips were drawn - if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) refreshTierChips(); }).catch(() => {}); + // web fonts may land after the first chips / redeploy-ring labels were drawn + if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) { refreshTierChips(); refreshDownLabels(); } }).catch(() => {}); // ---- camera --------------------------------------------------------------------------------------------- diff --git a/public/js/render/spine.js b/public/js/render/spine.js index f4179f1c..79711210 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -31,6 +31,8 @@ // closing clip timed to end `in` s from now (a 重生's last clip ends with the // 重生), landing in `roles` // update(dt) advances the skeleton (autoUpdate is off: one clock for everything) +// poseHeld() true while the pose cannot change (render/units.js then neither re-poses nor +// redraws the model, only advances `clock`) // Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast and every attack of a // `clipPerAttack` actor: to the end of its clip), then the end clip (if any) and base — except the attacks of a skill // with its own idle clip, which go straight back to that idle: the skill's end clip closes the skill, not each spell of @@ -160,6 +162,8 @@ export class SpineActor { */ this.clipPerAttack = false; this.wound = false; // clipPerAttack: wound up for the coming attack (windUp → attack) + this._applied = false; // an update has posed the skeleton since its clip last changed (poseHeld) + this._appliedTint = undefined; // the tint that update put on the slots (pixi-spine applies `tint` in update) this._play(this._idleName(), true); } @@ -310,6 +314,7 @@ export class SpineActor { if (start) e.trackTime = start; } this.current = name; + this._applied = false; return true; } @@ -317,6 +322,7 @@ export class SpineActor { if (!this.has(name)) return false; const e = this.spine.state.addAnimation(0, name, loop, 0); if (e) e.timeScale = timeScale; + this._applied = false; return true; } @@ -548,6 +554,24 @@ export class SpineActor { return 0; } + /** + * True while an update would draw the same pixels: a frozen model, or a dead one whose only clip — track 0, not + * looping, not mixing — has played out (the held end of a death clip; a skeleton without one holds its idle at + * timeScale 0). Only once an update has posed the current clip and put the current tint on the slots (pixi-spine applies + * `tint` in update: the grey of a knocked-out operator, a hit flash). Never while a form's closing clip or a wind-up is + * pending: those act on the clock. + */ + poseHeld() { + if (!this._applied || this.spine.tint !== this._appliedTint || this.endClip || this.windUntil != null) return false; + if (this.frozen) return true; + if (this.mode !== 'die') return false; + const tracks = this.spine.state.tracks; + for (let i = 1; i < tracks.length; i++) if (tracks[i]) return false; + const e = tracks[0]; + if (!e || e.loop || e.mixingFrom) return false; + return e.timeScale === 0 || e.trackTime >= e.animationEnd - e.animationStart; + } + /** Revive (redeploy after death). */ revive() { this.dead = false; @@ -621,6 +645,8 @@ export class SpineActor { try { this.spine.update(dt); } catch { /* a broken skeleton must not stop the frame */ } } if (this.clipped && !this.clipOn) this._eyeMaskFallback(); + this._applied = true; + this._appliedTint = this.spine.tint; } /** diff --git a/public/js/render/textures.js b/public/js/render/textures.js index 3182c8e7..63fc14e9 100644 --- a/public/js/render/textures.js +++ b/public/js/render/textures.js @@ -7,6 +7,7 @@ // * FX atlas (one base texture ⇒ particles batch / fit a ParticleContainer), // * status icons, tier chips, backdrop gradients, // * HUD rings: the element discs of the element gauge row and the redeploy countdown ring (hudRings / ringArc), +// and the ring's labels (downLabel: one rasterised Text per label, shared by every ring), // * avatar-in-rarity-diamond composites (cached per unit asset id) for the Spine fallback. // Everything procedural is deterministic (seeded noise) and generated lazily on first use. Requires // globalThis.PIXI and a DOM canvas at call time (never at import time). @@ -1424,3 +1425,33 @@ export function ringArc(frac) { if (k === 0 && f > 0) k = 1; return r.arcs[k]; } + +/** Redeploy-ring label style (the seconds left, "DP", "!"); `fill` is set per label. */ +export const DOWN_LABEL_STYLE = Object.freeze({ fontFamily: 'Bender, Oxanium, "Noto Sans SC", sans-serif', fontSize: 32, fontWeight: '700', fill: '#ffffff', stroke: '#0b0f0e', strokeThickness: 6 }); +const _downLabels = new Map(); + +/** + * The texture of a redeploy-ring label: one rasterised PIXI.Text per (label, fill, resolution), shared by every ring + * that shows it (render/units.js _updateDownRing draws it as a Sprite, anchor 0.5). A ring's own Text re-rasterised its + * canvas and re-uploaded its texture on every change, once a second per knocked-out operator, though rings mostly show the + * same few numbers; the shared one is drawn once and kept, the same pixels. At most 80 counts (the longest respawn in + * chess.json) + "DP" + "!" per resolution, ≈ 2.5 MB at resolution 2. + */ +export function downLabel(label, fill, resolution = 1) { + const key = `${label}|${fill}|${resolution}`; + let t = _downLabels.get(key); + if (!t) { + t = new (PIXI().Text)(label, { ...DOWN_LABEL_STYLE, fill }); + t.resolution = resolution; + t.updateText(false); + _downLabels.set(key, t); + } + return t.texture; +} + +/** Redraw the shared ring labels in place (web fonts that landed after some were drawn); their textures stay the same. */ +export function refreshDownLabels() { + for (const t of _downLabels.values()) { + try { t.updateText(false); } catch { /* keep the old glyphs */ } + } +} diff --git a/public/js/render/units.js b/public/js/render/units.js index f7fd28be..81ad9a30 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -67,7 +67,7 @@ import { UF, ANIM } from '../../../shared/constants.js'; import { SpineActor } from './spine.js'; -import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, HUD_DISC, ELEMENT_RING } from './textures.js'; +import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, downLabel, HUD_DISC, ELEMENT_RING } from './textures.js'; import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey, statusIconSuppressed } from './style.js'; import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js'; @@ -490,7 +490,7 @@ export class UnitView { 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 (text: the shared label, textures.js downLabel), built on first use this.alpha = 1; this.fadeIn = this.prep ? 1 : 0; this.lunge = 0; this.lungeDir = { x: 1, y: 0 }; this.flash = 0; @@ -1220,18 +1220,26 @@ export class UnitView { this._far = this._far ? s < 60 : s < 52; if (lvl >= 2 || this._far) interval = Math.max(interval, 2); } + let clipFlip = false; if (this.actor.clipped) { const clip = this.ctx.clipAllowed ? this.ctx.clipAllowed() : true; + const was = this.actor.clipOn; this.actor.setClipping(clip); + clipFlip = was !== this.actor.clipOn; if (clip && this.ctx.impostors) interval = Math.max(1, interval); } + // a pose that cannot change (a knocked-out operator lying in its held Die pose, a frozen model) is neither re-posed + // nor redrawn — it would draw the same pixels again; the actor's clock still runs + const held = !clipFlip && this.actor.poseHeld(); if (interval > 0 && this.ctx.renderer) { - this._updateImpostor(sc, flip, tint, animDt, interval); + if (clipFlip && this.imp) this.imp.dirty = true; // masks on / off: the slot is redrawn either way + this._updateImpostor(sc, flip, tint, animDt, interval, held); } else { if (this.imp) this._leaveImpostor(); this.actor.spine.alpha = this.swapT; this.actor.spine.scale.set(sc * flip, sc * this.modelKY); - this.actor.update(animDt); + if (held) this.actor.clock += animDt; + else this.actor.update(animDt); if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; } } } @@ -1549,10 +1557,8 @@ export class UnitView { const root = new P.Container(); const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; }; const disc = mk(tex.downDisc), track = mk(tex.track), arc = mk(tex.arcs[0]); - const text = new P.Text('', { fontFamily: 'Bender, Oxanium, "Noto Sans SC", sans-serif', fontSize: 32, fontWeight: '700', fill: '#ffffff', stroke: '#0b0f0e', strokeThickness: 6 }); - text.anchor.set(0.5); - root.addChild(text); - r = this._downRing = { root, disc, track, arc, text, label: null }; + const text = mk(P.Texture.EMPTY); + r = this._downRing = { root, disc, track, arc, text, label: null, res: 0 }; this.hud.addChild(root); } const dn = this.down; @@ -1562,10 +1568,11 @@ export class UnitView { const color = DOWN_LOOK.ring[dn.state] ?? DOWN_LOOK.ring[DOWN_STATE.COUNTING]; r.arc.tint = color; const label = counting ? String(Math.ceil(left - 1e-6)) : dn.state === DOWN_STATE.WAIT_DP ? 'DP' : '!'; - if (r.label !== label) { + const res = this.ctx.renderer?.resolution || 1; + if (r.label !== label || r.res !== res) { r.label = label; - r.text.text = label; - r.text.style.fill = counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0'); + r.res = res; + r.text.texture = downLabel(label, counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0'), res); } const d = clamp(s * DOWN_LOOK.size, 22, 52); const k = d / tex.size; @@ -1595,7 +1602,7 @@ export class UnitView { return this._box; } - _updateImpostor(sc, flip, tint, animDt, interval) { + _updateImpostor(sc, flip, tint, animDt, interval, held = false) { const P = this.P; const atlas = this.ctx.impostors || null; if (!this.imp) { @@ -1612,8 +1619,11 @@ export class UnitView { // a context without slots keeps the random phase. A unit whose slot keeps moving with the frame (the units before it // culled on and off in step) is still refreshed after 2 intervals at the latest. const turn = this.ctx.impostorSlot ? this.ctx.impostorSlot() : imp.phase; - const due = imp.dirty || interval <= 1 || (frame + turn) % interval === 0 || frame - imp.last >= 2 * interval - || Math.abs(sc - imp.sc) > imp.sc * 0.12; + const rescale = Math.abs(sc - imp.sc) > imp.sc * 0.12; + // a held pose (SpineActor.poseHeld) keeps the image its slot already shows; only its clock moves on + const still = held && !imp.dirty && !rescale; + if (still) { this.actor.clock += imp.acc; imp.acc = 0; } + const due = !still && (imp.dirty || interval <= 1 || (frame + turn) % interval === 0 || frame - imp.last >= 2 * interval || rescale); if (due) { this.actor.update(imp.acc); imp.acc = 0; diff --git a/test/render/downring.test.js b/test/render/downring.test.js index 68797814..adc56e40 100644 --- a/test/render/downring.test.js +++ b/test/render/downring.test.js @@ -70,21 +70,25 @@ describe('knocked-down operator (UnitView.setDown)', () => { assert.ok(v.alpha > 0.85, `drawn (alpha ${v.alpha})`); const r = v._downRing; assert.ok(r && r.root.visible, 'ring shown'); - assert.equal(r.text.text, '20', 'seconds left (game s)'); + assert.equal(r.label, '20', 'seconds left (game s)'); + assert.equal(r.text.texture, T.downLabel('20', '#ffffff', 1), 'drawn with the shared label texture'); assert.equal(r.arc.tint, DOWN_LOOK.ring[DOWN_STATE.COUNTING]); assert.equal(r.arc.texture, T.ringArc(0), 'nothing elapsed yet'); v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 25); frames(v, 1, 1 / 60, () => 25); - assert.equal(r.text.text, '5'); + assert.equal(r.label, '5'); + assert.equal(r.text.texture, T.downLabel('5', '#ffffff', 1)); assert.equal(r.arc.texture, T.ringArc(0.75), 'three quarters elapsed'); v.setDown([1, 30, 20, DOWN_STATE.WAIT_DP], 31); frames(v, 1, 1 / 60, () => 31); - assert.equal(r.text.text, 'DP'); + assert.equal(r.label, 'DP'); + assert.equal(r.text.texture, T.downLabel('DP', '#ffc600', 1), 'in the ring colour'); assert.equal(r.arc.tint, DOWN_LOOK.ring[DOWN_STATE.WAIT_DP]); assert.equal(r.arc.texture, T.ringArc(1), 'full ring while it waits'); v.setDown([1, 30, 20, DOWN_STATE.WAIT_TILE], 32); frames(v, 1); - assert.equal(r.text.text, '!'); + assert.equal(r.label, '!'); + assert.equal(r.text.texture, T.downLabel('!', '#ff4b3e', 1)); const kids = v.hud.children.length; frames(v, 30); assert.equal(v.hud.children.length, kids, 'no per-frame allocation'); @@ -95,6 +99,39 @@ describe('knocked-down operator (UnitView.setDown)', () => { assert.equal(r.root.visible, false, 'ring gone'); }); + test('rings showing the same label share one rasterised texture, drawn once (textures.js downLabel)', () => { + const a = view({ id: 11 }), b = view({ id: 12 }); + frames(a, 2); + frames(b, 2); + a.setDown([11, 30, 20, DOWN_STATE.COUNTING], 17.5); + b.setDown([12, 31, 20, DOWN_STATE.COUNTING], 18.5); // both 12.5 s left → "13" + frames(a, 1, 1 / 60, () => 17.5); + frames(b, 1, 1 / 60, () => 18.5); + const ra = a._downRing, rb = b._downRing; + assert.equal(ra.label, '13'); + assert.equal(rb.label, '13'); + assert.equal(ra.text.texture, rb.text.texture, 'one texture for both rings'); + assert.ok(!(ra.text instanceof fake.P.Text), 'the ring holds a plain Sprite, not a Text of its own'); + const t13 = ra.text.texture; + frames(a, 1, 1 / 60, () => 18.5); + assert.equal(ra.label, '12'); + assert.notEqual(ra.text.texture, t13, 'a new number, a new texture'); + a.setDown([11, 30, 20, DOWN_STATE.COUNTING], 17.5); + frames(a, 1, 1 / 60, () => 17.5); + assert.equal(ra.text.texture, t13, 'a number seen before reuses its texture'); + assert.equal(T.downLabel('13', '#ffffff', 2) === t13, false, 'another resolution, another texture'); + }); + + test('the shared labels are redrawn in place once the web fonts land (refreshDownLabels)', () => { + const tex = T.downLabel('47', '#ffffff', 1); + const proto = fake.P.Text.prototype, up = proto.updateText; + const redrawn = []; + proto.updateText = function (respectDirty) { redrawn.push([this.text, respectDirty]); return up.call(this, respectDirty); }; + try { T.refreshDownLabels(); } finally { proto.updateText = up; } + assert.ok(redrawn.some(([t, d]) => t === '47' && d === false), 'the cached label re-rasterised (forced)'); + assert.equal(T.downLabel('47', '#ffffff', 1), tex, 'same texture object, so every ring showing it updates'); + }); + test('a view made for a unit already down starts on the held end of the Die clip', () => { const v = view(); v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 12, true); @@ -102,7 +139,7 @@ describe('knocked-down operator (UnitView.setDown)', () => { assert.ok(v.dieT >= 30); frames(v, 2, 1 / 60, () => 12); assert.ok(v._downRing.root.visible); - assert.equal(v._downRing.text.text, '18'); + assert.equal(v._downRing.label, '18'); }); test('an operator entering the battle knocked out (联防, reason FORCED_EXIT): the held pose at once, no death burst, then its ring', async () => { @@ -120,7 +157,7 @@ describe('knocked-down operator (UnitView.setDown)', () => { frames(v, 240, 1 / 60, () => 2); assert.equal(v.remove, false, 'stays on its tile'); assert.ok(v._downRing.root.visible); - assert.equal(v._downRing.text.text, '68'); + assert.equal(v._downRing.label, '68'); const w = view({ id: 2 }); frames(w, 2); w.die(); diff --git a/test/render/fakepixi.js b/test/render/fakepixi.js index c6f155f5..346d7996 100644 --- a/test/render/fakepixi.js +++ b/test/render/fakepixi.js @@ -83,7 +83,10 @@ export function installFakePixi() { drawRect() { return this; } drawCircle() { return this; } drawEllipse() { return this; } drawPolygon() { return this; } drawRoundedRect() { return this; } closePath() { return this; } quadraticCurveTo() { return this; } bezierCurveTo() { return this; } arc() { return this; } } - class Text extends Sprite { constructor(text = '', style = {}) { super(); this.text = text; this.style = style; } } + class Text extends Sprite { + constructor(text = '', style = {}) { super(); this.text = text; this.style = style; this.resolution = 1; this.rasters = 0; this.texture = new Texture(new BaseTexture({ width: 1, height: 1 })); } + updateText() { this.rasters++; } + } class BitmapText extends Text {} class Matrix { constructor() { this.a = 1; this.b = 0; this.c = 0; this.d = 1; this.tx = 0; this.ty = 0; } set() { return this; } translate() { return this; } scale() { return this; } identity() { return this; } } class RenderTexture extends Texture { static create({ width = 1, height = 1 } = {}) { return new RenderTexture(new BaseTexture({ width, height })); } resize() {} } diff --git a/test/render/held-pose.test.js b/test/render/held-pose.test.js new file mode 100644 index 00000000..4000ea59 --- /dev/null +++ b/test/render/held-pose.test.js @@ -0,0 +1,209 @@ +// test/render/held-pose.test.js — a model whose pose cannot change is neither re-posed nor redrawn (render/spine.js +// SpineActor.poseHeld, render/units.js UnitView.update / _updateImpostor): a knocked-out operator lying in the held end +// of its Die clip, a frozen model. On a slow device in a late round a dozen operators lie so, and each was re-posed and +// redrawn into the impostor atlas every couple of frames for the same pixels. The actor's clock still runs; anything +// that can move the pose again (a new clip, a revive, a wind-up, a form's closing clip, clipping masks switched, a zoom +// past the impostor's rescale threshold, a tint the slots do not show yet) brings the updates back. Headless fake PIXI +// (test/render/fakepixi.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'; + +let fake, UnitView, DOWN_STATE, SpineActor; +before(async () => { + fake = installFakePixi(); + ({ UnitView, DOWN_STATE } = await import('../../public/js/render/units.js')); + ({ SpineActor } = await import('../../public/js/render/spine.js')); +}); +after(() => fake.restore()); + +const tick = () => new Promise((r) => setImmediate(r)); +const cam = () => presetCamera('normal', { width: 1280, height: 720 }); +const ENTRY = { skel: '/s/op.skel', atlas: '/s/op.atlas', textures: ['/s/op.png'], anims: { idle: 'Idle', die: 'Die' }, animations: { Idle: 1, Die: 0.8 } }; +const DATA = { animations: [{ name: 'Idle' }, { name: 'Die' }] }; +/** Play the current track 0 clip to its end (the fake skeleton does not advance clip time itself). */ +const finish = (a) => { const e = a.spine.state.tracks[0]; e.trackTime = e.animationEnd - e.animationStart; }; + +describe('SpineActor.poseHeld', () => { + test('a dead model on the finished end of its Die clip is held; a clip still playing, looping or mixing is not', () => { + const a = new SpineActor(DATA, ENTRY); + assert.equal(a.poseHeld(), false, 'idle loop'); + a.die(); + assert.equal(a.current, 'Die'); + assert.equal(a.poseHeld(), false, 'falling'); + finish(a); + assert.equal(a.poseHeld(), false, 'not before an update has posed the end of the clip'); + a.update(0); + assert.equal(a.poseHeld(), true, 'lying on the last frame'); + const e = a.spine.state.tracks[0]; + e.mixingFrom = {}; + assert.equal(a.poseHeld(), false, 'still mixing out of the previous clip'); + e.mixingFrom = null; + a.spine.state.tracks[1] = {}; + assert.equal(a.poseHeld(), false, 'another track plays'); + a.spine.state.tracks.length = 1; + e.loop = true; + assert.equal(a.poseHeld(), false, 'a looping clip moves on'); + e.loop = false; + assert.equal(a.poseHeld(), true); + a.revive(); + assert.equal(a.poseHeld(), false, 'standing again'); + }); + + test('a skeleton without a Die clip holds its idle at timeScale 0 at once; a frozen model is held', () => { + const a = new SpineActor({ animations: [{ name: 'Idle' }] }, { ...ENTRY, anims: { idle: 'Idle' }, animations: { Idle: 1 } }); + a.die(); + assert.equal(a.spine.state.tracks[0].timeScale, 0); + assert.equal(a.poseHeld(), false, 'not before an update has posed it'); + a.update(0); + assert.equal(a.poseHeld(), true); + const b = new SpineActor(DATA, ENTRY); + b.frozen = true; + b.update(0); + assert.equal(b.poseHeld(), true, 'frozen: update never moves the skeleton'); + }); + + test('a tint the slots do not show yet waits for one update (pixi-spine applies tint in update)', () => { + const a = new SpineActor(DATA, ENTRY); + a.die(); + finish(a); + a.update(0); + assert.equal(a.poseHeld(), true); + a.spine.tint = 0xb4b4b4; // the knocked-out grey, set after the frame's update + assert.equal(a.poseHeld(), false, 'the new tint is not on the slots yet'); + a.update(0); + assert.equal(a.poseHeld(), true, 'shown: held again'); + }); + + test('never while something pending acts on the clock: a form\'s closing clip, a wind-up', () => { + const a = new SpineActor(DATA, ENTRY); + a.die(); + finish(a); + a.update(0); + a.endClip = 'Die'; a.endAt = 5; + assert.equal(a.poseHeld(), false, 'closing clip pending'); + a.endClip = null; + a.windUntil = 3; + assert.equal(a.poseHeld(), false, 'wind-up pending'); + a.windUntil = null; + a.frozen = true; + a.endClip = 'X'; + assert.equal(a.poseHeld(), false, 'frozen, but a closing clip is due'); + }); +}); + +describe('UnitView: a held pose is neither re-posed nor redrawn', () => { + /** An operator with a Spine model in a context with an impostor atlas (interval `iv`) and counters. */ + async function operator(iv) { + let frame = 0; + let draws = 0; + let clip = true; + const atlas = { + alloc: (w, h, o) => ({ w, h, tex: new fake.P.Texture(), clip: !!o?.clip }), + free() {}, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() { draws++; }, + }; + const ctx = fakeViewCtx(fake.P, { + cam, frameNo: () => frame, impostors: atlas, impostorInterval: () => iv, clipAllowed: () => clip, + renderer: { resolution: 1, render() {} }, + assets: { picture: () => null, image: async () => null, spineEntry: () => ENTRY, spine: { acquire: async () => DATA, release() {} } }, + }); + const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT' }); + await tick(); await tick(); + assert.ok(v.spineReady, 'spine ready'); + let updates = 0; + const upd = v.actor.update.bind(v.actor); + v.actor.update = (dt) => { updates++; return upd(dt); }; + const step = (n = 1) => { for (let i = 0; i < n; i++) { v.update(1 / 60, cam(), frame / 60); frame++; } }; + const counts = () => ({ updates, draws }); + return { v, step, counts, setClip: (on) => { clip = on; } }; + } + const knockOut = (v) => { v.setDown([1, 60, 60, DOWN_STATE.COUNTING], 1); }; + + test('impostor mode: lying in the held Die pose, no re-pose and no atlas redraw; the clock still runs', async () => { + const { v, step, counts } = await operator(2); + step(4); + assert.ok(v.imp, 'impostor mode'); + knockOut(v); + step(4); + const falling = counts(); + assert.ok(falling.updates >= 2 && falling.draws >= 2, 'the fall is animated'); + finish(v.actor); + step(1); // the frame the clip ends on may still draw (dirty / turn) + const c0 = counts(), clock0 = v.actor.clock; + step(60); + assert.deepEqual(counts(), c0, 'one second down: nothing re-posed, nothing redrawn'); + assert.ok(Math.abs(v.actor.clock - clock0 - 1) < 1e-6, `the actor clock ran on (${v.actor.clock - clock0})`); + assert.ok(v._downRing.root.visible, 'under its redeploy ring'); + }); + + test('a zoom past the rescale threshold, clipping masks switched or a redeploy bring the redraws back', async () => { + const { v, step, counts, setClip } = await operator(2); + step(4); + knockOut(v); + step(2); + finish(v.actor); + step(2); + let c = counts(); + v.modelK *= 1.3; // the impostor's scale is off by > 12 % + step(1); + assert.equal(counts().draws, c.draws + 1, 'redrawn once at the new scale'); + step(10); + assert.equal(counts().draws, c.draws + 1, 'then held again'); + v.actor.clipped = true; // a skeleton with clipping masks, the masks switched off + c = counts(); + setClip(false); + step(1); + assert.equal(counts().draws, c.draws + 1, 'masks off: redrawn'); + assert.equal(counts().updates, c.updates + 1, '… and re-posed (the eyelid fallback runs in the update)'); + step(10); + assert.equal(counts().draws, c.draws + 1, 'then held again'); + c = counts(); + v.onDeploy(); // b.ev 'deploy' + v.setDown(null, 70); + step(6); + assert.ok(counts().updates >= c.updates + 3, 'standing up: animated again'); + }); + + test('direct mode (no impostor): no re-pose while held, the clock still runs', async () => { + const { v, step, counts } = await operator(0); + step(2); + assert.ok(!v.imp, 'drawn directly'); + knockOut(v); + step(3); + finish(v.actor); + step(1); + const c0 = counts(), clock0 = v.actor.clock; + step(30); + assert.equal(counts().updates, c0.updates, 'no update while held'); + assert.ok(Math.abs(v.actor.clock - clock0 - 0.5) < 1e-6, 'clock advanced by the frames\' time'); + }); + + test('direct mode: a hit flash on a lying model is updated until its tint is steady on the slots', async () => { + const { v, step, counts } = await operator(0); + step(2); + knockOut(v); + step(3); + finish(v.actor); + step(2); + const c0 = counts(); + step(10); + assert.equal(counts().updates, c0.updates, 'held'); + v.flash = 1; // a hit: the tint mixes toward red and fades back over ~10 frames + step(20); + const c1 = counts(); + assert.ok(c1.updates > c0.updates, `updated while the tint changes (${c1.updates - c0.updates})`); + step(10); + assert.equal(counts().updates, c1.updates, 'held again once the tint is steady'); + }); + + test('a living model in the same context keeps its usual refreshes', async () => { + const { step, counts } = await operator(2); + step(2); + const c0 = counts(); + step(20); + const c1 = counts(); + assert.ok(c1.updates - c0.updates >= 9 && c1.draws - c0.draws >= 9, `every 2nd frame (${c1.updates - c0.updates} updates, ${c1.draws - c0.draws} draws in 20)`); + }); +});