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

+3 -3
View File
@@ -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 ---------------------------------------------------------------------------------------------
+26
View File
@@ -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;
}
/**
+31
View File
@@ -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
View File
@@ -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;