Reuse held Spine poses and redeploy countdown textures (#434)
This commit is contained in:
1 parent
48f5159357
commit
2fdc565c7c
9 files changed
+351
-25
No files matched your search
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 ---------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
+24
-14
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {} }
|
||||
|
||||
@@ -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)`);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user