Reuse held Spine poses and redeploy countdown textures (#434)

This commit is contained in:
sganggs committed 2026-10-09 17:14:50 +08:00
1 parent 48f5159357
commit 2fdc565c7c
9 files changed
+351 -25

No files matched your search

+1 -1
View File
@@ -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. `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).
--- ---
+10
View File
@@ -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 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 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. 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.
+3 -3
View File
@@ -121,7 +121,7 @@ import { TileField } from './tiles.js';
import { UnitView, ItemView, DeviceView, FORMS, syncView } from './units.js'; import { UnitView, ItemView, DeviceView, FORMS, syncView } from './units.js';
import { FxSystem, ensureDamageFonts } from './fx.js'; import { FxSystem, ensureDamageFonts } from './fx.js';
import { createDragController, pieceTile, dragStandTile } from './drag.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 { TILE_H, TIER_COLORS, COLORS } from './style.js';
import { loadBoardArt } from './boardArt.js'; import { loadBoardArt } from './boardArt.js';
import { ImpostorAtlas } from './impostor.js'; import { ImpostorAtlas } from './impostor.js';
@@ -458,8 +458,8 @@ export async function createFieldView(host, options = {}) {
} }
loadShadow(); loadShadow();
ensureDamageFonts(); ensureDamageFonts();
// web fonts may land after the first chips were drawn // web fonts may land after the first chips / redeploy-ring labels were drawn
if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) refreshTierChips(); }).catch(() => {}); if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) { refreshTierChips(); refreshDownLabels(); } }).catch(() => {});
// ---- camera --------------------------------------------------------------------------------------------- // ---- camera ---------------------------------------------------------------------------------------------
+26
View File
@@ -31,6 +31,8 @@
// closing clip timed to end `in` s from now (a 重生's last clip ends with the // closing clip timed to end `in` s from now (a 重生's last clip ends with the
// 重生), landing in `roles` // 重生), landing in `roles`
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything) // 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 // 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 // `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 // 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.clipPerAttack = false;
this.wound = false; // clipPerAttack: wound up for the coming attack (windUp → attack) 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); this._play(this._idleName(), true);
} }
@@ -310,6 +314,7 @@ export class SpineActor {
if (start) e.trackTime = start; if (start) e.trackTime = start;
} }
this.current = name; this.current = name;
this._applied = false;
return true; return true;
} }
@@ -317,6 +322,7 @@ export class SpineActor {
if (!this.has(name)) return false; if (!this.has(name)) return false;
const e = this.spine.state.addAnimation(0, name, loop, 0); const e = this.spine.state.addAnimation(0, name, loop, 0);
if (e) e.timeScale = timeScale; if (e) e.timeScale = timeScale;
this._applied = false;
return true; return true;
} }
@@ -548,6 +554,24 @@ export class SpineActor {
return 0; 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 (redeploy after death). */
revive() { revive() {
this.dead = false; this.dead = false;
@@ -621,6 +645,8 @@ export class SpineActor {
try { this.spine.update(dt); } catch { /* a broken skeleton must not stop the frame */ } try { this.spine.update(dt); } catch { /* a broken skeleton must not stop the frame */ }
} }
if (this.clipped && !this.clipOn) this._eyeMaskFallback(); if (this.clipped && !this.clipOn) this._eyeMaskFallback();
this._applied = true;
this._appliedTint = this.spine.tint;
} }
/** /**
+31
View File
@@ -7,6 +7,7 @@
// * FX atlas (one base texture ⇒ particles batch / fit a ParticleContainer), // * FX atlas (one base texture ⇒ particles batch / fit a ParticleContainer),
// * status icons, tier chips, backdrop gradients, // * status icons, tier chips, backdrop gradients,
// * HUD rings: the element discs of the element gauge row and the redeploy countdown ring (hudRings / ringArc), // * 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. // * 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 // 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). // 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; if (k === 0 && f > 0) k = 1;
return r.arcs[k]; 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 */ }
}
}
+24 -14
View File
@@ -67,7 +67,7 @@
import { UF, ANIM } from '../../../shared/constants.js'; import { UF, ANIM } from '../../../shared/constants.js';
import { SpineActor } from './spine.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 { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey, statusIconSuppressed } from './style.js';
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.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._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._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.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.alpha = 1; this.fadeIn = this.prep ? 1 : 0;
this.lunge = 0; this.lungeDir = { x: 1, y: 0 }; this.lunge = 0; this.lungeDir = { x: 1, y: 0 };
this.flash = 0; this.flash = 0;
@@ -1220,18 +1220,26 @@ export class UnitView {
this._far = this._far ? s < 60 : s < 52; this._far = this._far ? s < 60 : s < 52;
if (lvl >= 2 || this._far) interval = Math.max(interval, 2); if (lvl >= 2 || this._far) interval = Math.max(interval, 2);
} }
let clipFlip = false;
if (this.actor.clipped) { if (this.actor.clipped) {
const clip = this.ctx.clipAllowed ? this.ctx.clipAllowed() : true; const clip = this.ctx.clipAllowed ? this.ctx.clipAllowed() : true;
const was = this.actor.clipOn;
this.actor.setClipping(clip); this.actor.setClipping(clip);
clipFlip = was !== this.actor.clipOn;
if (clip && this.ctx.impostors) interval = Math.max(1, interval); 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) { 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 { } else {
if (this.imp) this._leaveImpostor(); if (this.imp) this._leaveImpostor();
this.actor.spine.alpha = this.swapT; this.actor.spine.alpha = this.swapT;
this.actor.spine.scale.set(sc * flip, sc * this.modelKY); 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; } 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 root = new P.Container();
const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; }; 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 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 }); const text = mk(P.Texture.EMPTY);
text.anchor.set(0.5); r = this._downRing = { root, disc, track, arc, text, label: null, res: 0 };
root.addChild(text);
r = this._downRing = { root, disc, track, arc, text, label: null };
this.hud.addChild(root); this.hud.addChild(root);
} }
const dn = this.down; const dn = this.down;
@@ -1562,10 +1568,11 @@ export class UnitView {
const color = DOWN_LOOK.ring[dn.state] ?? DOWN_LOOK.ring[DOWN_STATE.COUNTING]; const color = DOWN_LOOK.ring[dn.state] ?? DOWN_LOOK.ring[DOWN_STATE.COUNTING];
r.arc.tint = color; r.arc.tint = color;
const label = counting ? String(Math.ceil(left - 1e-6)) : dn.state === DOWN_STATE.WAIT_DP ? 'DP' : '!'; 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.label = label;
r.text.text = label; r.res = res;
r.text.style.fill = counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0'); r.text.texture = downLabel(label, counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0'), res);
} }
const d = clamp(s * DOWN_LOOK.size, 22, 52); const d = clamp(s * DOWN_LOOK.size, 22, 52);
const k = d / tex.size; const k = d / tex.size;
@@ -1595,7 +1602,7 @@ export class UnitView {
return this._box; return this._box;
} }
_updateImpostor(sc, flip, tint, animDt, interval) { _updateImpostor(sc, flip, tint, animDt, interval, held = false) {
const P = this.P; const P = this.P;
const atlas = this.ctx.impostors || null; const atlas = this.ctx.impostors || null;
if (!this.imp) { 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 // 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. // 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 turn = this.ctx.impostorSlot ? this.ctx.impostorSlot() : imp.phase;
const due = imp.dirty || interval <= 1 || (frame + turn) % interval === 0 || frame - imp.last >= 2 * interval const rescale = Math.abs(sc - imp.sc) > imp.sc * 0.12;
|| 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) { if (due) {
this.actor.update(imp.acc); this.actor.update(imp.acc);
imp.acc = 0; imp.acc = 0;
+43 -6
View File
@@ -70,21 +70,25 @@ describe('knocked-down operator (UnitView.setDown)', () => {
assert.ok(v.alpha > 0.85, `drawn (alpha ${v.alpha})`); assert.ok(v.alpha > 0.85, `drawn (alpha ${v.alpha})`);
const r = v._downRing; const r = v._downRing;
assert.ok(r && r.root.visible, 'ring shown'); 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.tint, DOWN_LOOK.ring[DOWN_STATE.COUNTING]);
assert.equal(r.arc.texture, T.ringArc(0), 'nothing elapsed yet'); assert.equal(r.arc.texture, T.ringArc(0), 'nothing elapsed yet');
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 25); v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 25);
frames(v, 1, 1 / 60, () => 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'); assert.equal(r.arc.texture, T.ringArc(0.75), 'three quarters elapsed');
v.setDown([1, 30, 20, DOWN_STATE.WAIT_DP], 31); v.setDown([1, 30, 20, DOWN_STATE.WAIT_DP], 31);
frames(v, 1, 1 / 60, () => 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.tint, DOWN_LOOK.ring[DOWN_STATE.WAIT_DP]);
assert.equal(r.arc.texture, T.ringArc(1), 'full ring while it waits'); assert.equal(r.arc.texture, T.ringArc(1), 'full ring while it waits');
v.setDown([1, 30, 20, DOWN_STATE.WAIT_TILE], 32); v.setDown([1, 30, 20, DOWN_STATE.WAIT_TILE], 32);
frames(v, 1); 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; const kids = v.hud.children.length;
frames(v, 30); frames(v, 30);
assert.equal(v.hud.children.length, kids, 'no per-frame allocation'); 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'); 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', () => { test('a view made for a unit already down starts on the held end of the Die clip', () => {
const v = view(); const v = view();
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 12, true); 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); assert.ok(v.dieT >= 30);
frames(v, 2, 1 / 60, () => 12); frames(v, 2, 1 / 60, () => 12);
assert.ok(v._downRing.root.visible); 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 () => { 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); frames(v, 240, 1 / 60, () => 2);
assert.equal(v.remove, false, 'stays on its tile'); assert.equal(v.remove, false, 'stays on its tile');
assert.ok(v._downRing.root.visible); assert.ok(v._downRing.root.visible);
assert.equal(v._downRing.text.text, '68'); assert.equal(v._downRing.label, '68');
const w = view({ id: 2 }); const w = view({ id: 2 });
frames(w, 2); frames(w, 2);
w.die(); w.die();
+4 -1
View File
@@ -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; } 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; } 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 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 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() {} } class RenderTexture extends Texture { static create({ width = 1, height = 1 } = {}) { return new RenderTexture(new BaseTexture({ width, height })); } resize() {} }
+209
View File
@@ -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)`);
});
});