// render/spine.js — Spine battle chibi wrapper + animation state machine (research 07 §5.4–5.5, ASSETS.md Roles). // // SpineActor owns one PIXI.spine.Spine built from cached skeleton data (assets.spine LRU; the instance never // owns the atlas, so destroying it never frees shared textures). Animation roles come from the manifest // (`anims`: idle, deploy, attack{begin,loop,end}, attackDown, skill{begin,loop,end,idle}, die, move, stun). // // Driving (units.js calls these; the sim is authoritative, the actor only visualises): // setBase('idle'|'move'|'stun') the resting state from the snapshot anim code // attack(interval, once) one attack happened now (b.ev 'atk'): consumes its wind-up or starts at // OnAttack, once; operator timeScale = loopDuration / interval; // `once` (a one-off cast, style.js PROJ[kind].once — 暴鸰's bomb drop): the // clip plays once at its own speed, then base; `clipPerAttack` (enemies, // GitHub #58): every attack plays the clip once — at its own speed, faster // only when the attacks come quicker than the clip —, then base (Move while // the sim walks it: it stands for that clip, server/sim/ai.js attackStand) // setSkill(on) skill begin, then — when the skill has an idle clip of its own (skill.idle, // not its loop) — that idle between attacks, its loop (the skill's attack clip) // only on attacks (community report #23: 折桠's S2 jump attack looped with no // enemy engaged); end on stop. A skill clip with NEITHER a Begin nor an own Idle // (德克萨斯 剑雨 and the other 58 instant skills: anims.skill {begin:null, // loop:'Skill', end:null}) plays that clip once for its own length: an instant // skill's flag is off again within the same 0.5 s window (sim skills.js), so // without this the actor fell through to its idle and played no skill clip at // all (player report). // deploy() 'Start' once, then base // die() die clip once (callers fade out afterwards); a skeleton without one holds its // idle clip's first frame (GitHub issue #25: the attack loop went on) // stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0 // setForm(roles, change, end) another clip set of the skeleton (an enemy's mode, a 傀儡师's 替身), after a change clip // (no attack cuts the change clip short); `end` = { clip, in, roles? }: a // 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); a // model frozen before it was ever posed (built while stunned without a stun // clip, or dead without a death or idle clip) shows its clip's first frame, // never its setup pose (GitHub #542: the impostor box covers the clips only) // poseHeld() true while the pose cannot change (render/units.js then neither re-poses nor // redraws the model, only advances `clock`) // clipBounds(names) skeleton-space bounds of those clips (sampled once per skeleton data, see // "clip bounds" below; render/units.js sizes the impostor box from them) // Each attack plays its clip once, 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 // attacks while it runs. const clampN = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); /** Longest wind-up compression (× the rhythm speed) when the look-ahead is shorter than the natural wind-up. */ export const MAX_WIND_SPEEDUP = 2.5; /** Floor for the one-shot window of a skill clip with no Begin / own Idle (a very short clip still gets frames). */ const SKILL_CLIP_MIN = 0.2; /** * Attack clip timing (pure). `loopDur` / `hit` are clip seconds (strike frame at `hit`), `interval` and `lead` game * seconds. The clip plays at `ts = loopDur / interval` (one loop per attack); started `lead` before the attack it * reaches the strike frame on time: from `start = hit − lead·ts` at `ts` when little of the wind-up is lost, * otherwise from (nearly) the beginning at up to MAX_WIND_SPEEDUP × ts (`tsWind`). * @returns {{ ts: number, tsWind: number, start: number }} */ export function windUpPlan(loopDur, hit, interval, lead) { const ts = clampN(loopDur / Math.max(0.08, interval), 0.35, 4); const L = Math.max(0, lead); let start = Math.max(0, hit - L * ts), tsWind = ts; if (start > hit * 0.15 && L > 0) { tsWind = Math.min(ts * MAX_WIND_SPEEDUP, hit / L); start = Math.max(0, hit - L * tsWind); } return { ts, tsWind, start }; } /** A clipping attachment (pixi-spine AttachmentType.Clipping = 6). */ const isClip = (a) => !!a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a))); /** Whether any skin of the skeleton data has a clipping attachment. */ export function hasClipping(data) { try { for (const skin of data?.skins || []) { const list = typeof skin.getAttachments === 'function' ? skin.getAttachments() : []; for (const e of list) if (isClip(e && e.attachment)) return true; } } catch { /* unknown runtime shape: assume none */ } return false; } /** * Is (x, y) inside the polygon of `n` points `pts` (flat [x0, y0, x1, y1, …])? Ray casting (PR #384 by @Convey123). */ export function pointInPolygon(x, y, pts, n) { let inside = false; for (let i = 0, j = n - 1; i < n; j = i++) { const xi = pts[i * 2], yi = pts[i * 2 + 1], xj = pts[j * 2], yj = pts[j * 2 + 1]; if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; } return inside; } /** * World centre of what a slot draws, into `out` ([x, y]); false when it draws nothing measurable. A mesh: the mean of its * world vertices (`buf` grows to fit); a region: its quad's centre — the attachment's (x, y) through the bone (the four * corners sit symmetrically around it; RegionAttachment.computeWorldVertices takes a bone in runtime 3.8, a slot in 4.1). */ function slotCentre(slot, att, buf, out) { const n = att.worldVerticesLength | 0; if (n >= 2 && typeof att.computeWorldVertices === 'function') { if (buf.v.length < n) buf.v = new Float32Array(n); const v = buf.v; att.computeWorldVertices(slot, 0, n, v, 0, 2); let cx = 0, cy = 0; for (let k = 0; k < n; k += 2) { cx += v[k]; cy += v[k + 1]; } out[0] = cx / (n >> 1); out[1] = cy / (n >> 1); } else if (att.region !== undefined && Number.isFinite(att.x) && Number.isFinite(att.y) && slot.bone) { const b = slot.bone; out[0] = b.a * att.x + b.b * att.y + b.worldX; out[1] = b.c * att.x + b.d * att.y + b.worldY; } else return false; return Number.isFinite(out[0]) && Number.isFinite(out[1]); } // ---- clip bounds (GitHub #542) ------------------------------------------------------------------------------------ // What a skeleton can draw, in skeleton space (pixi-spine's y-down world; rects are [x0, y0, x1, y1]): each clip, sampled // once per skeleton data and shared by every actor built from it (the setup pose is never shown: SpineActor.update poses // even a model frozen before its first update at its clip's first frame). render/units.js sizes a unit's // impostor slot from the clips the unit plays — a slot sized from the first pose drawn (plus a margin) cut 野鬃's idle // flag and 浊心斯卡蒂's head in her S3 flight, and 598 of the roster's 681 skeletons play a clip that leaves a slot sized // that way from their idle's first frame. A clip is posed on a scratch skeleton of the same runtime (never an actor's // own) CLIP_SAMPLE_HZ times a second, at most CLIP_SAMPLE_MAX + 1 times (a longer clip is sampled more sparsely); every // slot that is drawn (alpha > 0) counts — a region with its whole attachment rectangle (the atlas trim only makes the // drawn sprite smaller), a mesh with its world vertices. (Skeleton.getBounds() cannot be used: pixi-spine never computes // the RegionAttachment offsets it reads, its sprites do not need them.) Requests are queued; `stepClipBounds(ms)`, called // by render/app.js once per frame, samples within that time budget: the clips a unit plays take ≈ 9 ms per skeleton on // the current roster (53 ms at most), so a model is measured a few frames after it loads, long before a crowd needs its // impostor. [ASSUMED] the rate, the cap and the budget (a pose between two samples is covered by the box's padding, // units.js IMP_BOX, and anything past it is cut by the atlas slot's scissor, never left behind). A record is queued only // while an actor uses its skeleton data: when the last one is destroyed (a model swap, a battlefield torn down) its pending // clips and its scratch skeleton are dropped — nothing holds a skeleton the Spine store let go, and nothing stale is // measured ahead of the next scene's models —, and a later actor queues what was never measured again. A clip whose // sampling throws is recorded as not measurable (false): units.js draws it directly while it plays, never as empty. export const CLIP_SAMPLE_HZ = 60; export const CLIP_SAMPLE_MAX = 48; export const CLIP_BOUNDS_BUDGET_MS = 2.5; const ATT_REGION = 0, ATT_MESH = 2; // pixi-spine AttachmentType const boundsOf = new WeakMap(); // skeleton data → { data, Skeleton, skel, rects: Map, todo, job, users, failed } const boundsQueue = []; // records with clips still to sample, oldest request first const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now()); const emptyRect = () => [Infinity, Infinity, -Infinity, -Infinity]; const rectOk = (r) => !!r && Number.isFinite(r[0]) && Number.isFinite(r[1]) && Number.isFinite(r[2]) && Number.isFinite(r[3]) && r[0] <= r[2] && r[1] <= r[3]; /** Grow `r` by everything `skel` draws in its current pose (see "clip bounds"); `buf.v` is a scratch vertex buffer. */ function poseRect(skel, r, buf) { const grow = (x, y) => { if (x < r[0]) r[0] = x; if (x > r[2]) r[2] = x; if (y < r[1]) r[1] = y; if (y > r[3]) r[3] = y; }; for (const slot of skel.drawOrder) { const bone = slot.bone; if (!bone || bone.active === false) continue; const att = typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment; if (!att || (slot.color ? slot.color.a : 1) * (att.color ? att.color.a : 1) <= 0.004) continue; if (att.type === ATT_MESH && typeof att.computeWorldVertices === 'function') { const n = att.worldVerticesLength | 0; if (n < 2) continue; if (buf.v.length < n) buf.v = new Float32Array(n); const v = buf.v; att.computeWorldVertices(slot, 0, n, v, 0, 2); for (let k = 0; k < n; k += 2) grow(v[k], v[k + 1]); } else if (att.type === ATT_REGION && bone.matrix) { // the attachment rectangle (width × height × scale, rotated, at x / y) through the bone (a PIXI matrix) const m = bone.matrix; const hw = (att.width * att.scaleX) / 2, hh = (att.height * att.scaleY) / 2; const rad = (att.rotation * Math.PI) / 180, c = Math.cos(rad), s = Math.sin(rad); for (let q = 0; q < 4; q++) { const px = q === 0 || q === 3 ? -hw : hw, py = q < 2 ? -hh : hh; const lx = px * c - py * s + att.x, ly = px * s + py * c + att.y; grow(m.a * lx + m.c * ly + m.tx, m.b * lx + m.d * ly + m.ty); } } } return r; } /** The scratch skeleton of a bounds record (made on demand, dropped once its queued clips are sampled). */ function scratchSkeleton(rec) { if (rec.skel) return rec.skel; const Skeleton = rec.Skeleton; if (typeof Skeleton !== 'function' || Skeleton === Object) throw new Error('no Spine runtime'); const skel = new Skeleton(rec.data); if (!Array.isArray(skel.drawOrder) || typeof skel.setToSetupPose !== 'function' || typeof skel.updateWorldTransform !== 'function') throw new Error('no Spine runtime'); rec.skel = skel; return skel; } /** The bounds record of skeleton data `data`; `failed` when this runtime cannot be posed here (tried at once). */ function boundsRecord(data, Skeleton) { let rec = boundsOf.get(data); if (rec) return rec; rec = { data, Skeleton, skel: null, rects: new Map(), todo: [], job: null, users: 0, failed: false, buf: { v: new Float32Array(256) } }; try { const skel = scratchSkeleton(rec); skel.setToSetupPose(); skel.updateWorldTransform(); } catch { rec.failed = true; rec.skel = null; } boundsOf.set(data, rec); return rec; } /** * Start sampling clip `name`: { name, anim, d, n, i, r } (posed at t = d·i/n for i = 0…n; `r` grows), or null when the * skeleton has no such clip. */ function startClip(rec, name) { const anim = typeof rec.data.findAnimation === 'function' ? rec.data.findAnimation(name) : null; if (!anim || typeof anim.apply !== 'function') return null; const d = Number.isFinite(anim.duration) && anim.duration > 0 ? anim.duration : 0; const n = d > 0 ? Math.min(CLIP_SAMPLE_MAX, Math.max(1, Math.ceil(d * CLIP_SAMPLE_HZ))) : 0; scratchSkeleton(rec).setToSetupPose(); return { name, anim, d, n, i: 0, r: emptyRect() }; } /** * Queue clips `names` of skeleton data `data` (pixi-spine runtime class `Skeleton`) for sampling — those neither sampled * nor queued yet — and return its record (`rects`, `failed`). */ export function requestClipBounds(data, Skeleton, names) { const rec = boundsRecord(data, Skeleton); if (rec.failed) return rec; let added = false; for (const n of names) { if (typeof n === 'string' && !rec.rects.has(n) && !rec.todo.includes(n) && !(rec.job && rec.job.name === n)) { rec.todo.push(n); added = true; } } if (added && !boundsQueue.includes(rec)) boundsQueue.push(rec); else if (!boundsQueue.includes(rec)) rec.skel = null; // nothing to sample: no scratch skeleton kept return rec; } /** * One actor more (+1, SpineActor.clipBounds) or less (−1, SpineActor.destroy) measures record `rec`; with none left, the * record leaves the queue with its pending clips (todo and the clip in progress) and its scratch skeleton — a later actor * queues what was never measured again (a todo left behind would read as queued and never be measured). */ function useBounds(rec, delta) { rec.users = Math.max(0, rec.users + delta); if (rec.users > 0) return; const i = boundsQueue.indexOf(rec); if (i >= 0) boundsQueue.splice(i, 1); rec.todo.length = 0; rec.job = null; rec.skel = null; } /** * Sample queued clips for up to `budgetMs` (render/app.js calls it once per frame): pose after pose, a clip resumed on the * next call when the budget runs out inside it; at least one pose per call. * @returns {number} clips finished */ export function stepClipBounds(budgetMs = CLIP_BOUNDS_BUDGET_MS) { if (!boundsQueue.length) return 0; const end = nowMs() + budgetMs; let finished = 0, posed = 0; while (boundsQueue.length) { const rec = boundsQueue[0]; if (!rec.job) { const name = rec.todo.shift(); if (name === undefined) { boundsQueue.shift(); rec.skel = null; continue; } let broken = false; try { rec.job = startClip(rec, name); } catch { rec.job = null; broken = true; } // no such clip: it never plays (null); a clip that could not be set up: not measurable (false) if (!rec.job) { rec.rects.set(name, broken ? false : null); finished++; continue; } } const j = rec.job; try { const skel = scratchSkeleton(rec); while (j.i <= j.n) { const t = j.n ? (j.d * j.i) / j.n : 0; j.anim.apply(skel, t, t, false, null, 1, 0, 0); // MixBlend.setup, MixDirection.mixIn: the clip's own pose at t skel.updateWorldTransform(); poseRect(skel, j.r, rec.buf); j.i++; posed++; if (nowMs() >= end) break; } } catch { j.i = j.n + 1; j.broken = true; } if (j.i > j.n) { // a pose that threw: the clip is not measurable (false) — drawn directly while it plays, not taken as empty rec.rects.set(j.name, j.broken ? false : rectOk(j.r) ? j.r : null); rec.job = null; finished++; } if (posed && nowMs() >= end) break; } return finished; } /** Clips still waiting to be sampled, the one in progress included (dev / tests). */ export function pendingClipBounds() { return boundsQueue.reduce((s, r) => s + r.todo.length + (r.job ? 1 : 0), 0); } export class SpineActor { /** * @param {any} spineData PIXI.spine skeleton data * @param {object} entry manifest Spine entry (anims, animations, hits, bounds, pma) */ constructor(spineData, entry) { const P = globalThis.PIXI; this.entry = entry; this.roles = entry.anims || {}; this.durations = entry.animations || {}; this.spine = new P.spine.Spine(spineData); this.spine.autoUpdate = false; this.names = new Set((spineData.animations || []).map((a) => a.name)); /** * Clipping attachments render as stencil masks (≈1.5 ms of GPU each per frame on tiled GPUs): such skeletons * are drawn through the impostor atlas while clipping is on, and clipping is switched off when too many of them * share a field (app.js budget) — then _eyeMaskFallback hides what a closed eyelid would cut (GitHub #177). */ this.clipped = hasClipping(spineData); this.clipOn = true; this._clipHidden = new Set(); // slot indexes the eyelid fallback hid in its last pass (_eyeMaskFallback) if (this.clipped) { const sp = this.spine; const orig = typeof sp.createGraphics === 'function' ? sp.createGraphics.bind(sp) : null; if (orig) sp.createGraphics = (slot, att) => { const g = orig(slot, att); if (!this.clipOn && slot.clippingContainer) { slot.clippingContainer.mask = null; g.renderable = false; } return g; }; } try { this.spine.stateData.defaultMix = 0.12; } catch { /* ignore */ } this.base = 'idle'; this.mode = 'base'; // base | attack | skillBegin | deploy | die | stun | change this.endClip = null; // setForm's closing clip, played as a change clip once the clock reaches endAt, this.endAt = 0; // landing in endRoles (the next form's) when given this.endRoles = null; this.skillIndex = null; // the skill slot whose clip `skill` plays (setSkillIndex), null = the manifest's primary this.runMode = false; // move on the model's own Run cycle (setRunMode) this.formRoles = null; // the clip set of the form in force (setForm), over the unit's own roles this.skillOn = false; this.skillClipOnce = false; // skillBegin: the skill's own clip plays once (a clip with no Begin / own Idle) this.skillEndPending = false; // … and the skill turned off inside it: play its End clip / base when it ends this.attackUntil = 0; this.clock = 0; this.current = ''; this.frozen = false; this.dead = false; this.interval = 1; /** * Enemies (render/units.js, GitHub #58): each attack plays the attack clip once — at its own speed, faster only when * the attacks come quicker than the clip — and then the resting state; the sim stands the enemy for exactly that * clip (server/sim/ai.js attackStand). Off (operators): retain rhythm scaling, but still play each attack once. */ this.clipPerAttack = false; this.wound = false; // wound up for the coming attack (windUp → attack) this._applied = false; // an update has posed the skeleton since its clip last changed (poseHeld) this._posed = false; // the skeleton was ever posed (a model frozen before that shows its clip's first frame) this._appliedTint = undefined; // the tint that update put on the slots (pixi-spine applies `tint` in update) this._play(this._idleName(), true); } /** Enable / disable the skeleton's clipping masks. */ setClipping(on) { on = !!on; if (!this.clipped || on === this.clipOn) return; this.clipOn = on; if (on) this._showClipHidden(); // the stencil takes over from the eyelid fallback for (const slot of this.spine?.skeleton?.slots || []) { if (!slot.clippingContainer) continue; slot.clippingContainer.mask = on ? slot.currentGraphics || null : null; // PIXI makes a released mask renderable again: the clip polygon must never draw as a white shape if (slot.currentGraphics) slot.currentGraphics.renderable = false; } } /** * The equipped skill (DESIGN §16 loadout, UnitInfo.skillIndex): its own Spine clip when the model has one per skill * index (`anims.skills`), else the primary skill's clip. * @param {number|undefined} index 0-based skill index */ setSkillIndex(index) { this.skillIndex = Number.isInteger(index) ? index : null; this._applyRoles(); } /** * A fast mover walks on its model's own Run cycle (`anims.run`; PR #275 by @xcdoge): 猎狗pro (moveSpeed 1.9, 行动速度很快) * ships Move_Loop 0.80 s next to Run_Loop 0.53 s. Composes with setSkillIndex; a model without a Run cycle is unchanged. */ setRunMode(on) { this.runMode = !!on; this._applyRoles(); } /** The unit's own roles (the manifest's, its skill slot's clip and the Run cycle applied) under the form in force. */ _applyRoles() { const anims = this.entry?.anims || {}; const clip = Number.isInteger(this.skillIndex) && anims.skills ? anims.skills[String(this.skillIndex)] : null; const run = this.runMode && anims.run ? anims.run : null; const base = clip || run ? { ...anims } : anims; if (clip) base.skill = clip; if (run) base.move = run; this.baseRoles = base; this.roles = this.formRoles ? { ...base, ...this.formRoles } : base; } /** The unit's own roles: the manifest's with its skill slot's clip (setSkillIndex) and Run cycle — what a form ends in. */ _baseRoles() { return this.baseRoles || this.entry?.anims || {}; } /** * Another clip set of the same skeleton — an enemy's mode (render/units.js FORMS: 掠海漂移体's 爬行模式 plays its *_02 * clips), a 傀儡师's 替身: `roles` override the unit's own roles (null = back to them — the equipped skill's clip * included); `change` = a transition clip played once first (also while stunned: the pose it ends in is the one a stun * then holds). `end` = { clip, in, roles? } (game s): a closing clip played the same way so that it ends `in` s from * now, landing in `roles` — a leader's 重生 ends on its last clip while the sim still holds it, and the next form * starts on its own clips. */ setForm(roles, change = null, end = null) { const anims = this._baseRoles(); this.formRoles = roles || null; // kept over a later skill slot (_applyRoles) this.roles = roles ? { ...anims, ...roles } : anims; this.endClip = null; if (this.dead) return; if (end && this.has(end.clip) && end.in > 0) { this.endClip = end.clip; this.endAt = this.clock + Math.max(0, end.in - this.dur(end.clip)); this.endRoles = end.roles || null; } if (change && this.has(change)) this._change(change); else if (this.mode === 'base') this._play(this._baseName(), true); else if (this.mode === 'stun' && this.has(this.roles.stun?.loop)) this._play(this.roles.stun.loop, true); } /** * Re-pose on the clip set in force right after an immediate form switch (render/units.js STEALTH_FORMS: 假想敌:骨刺's * stealth bit; PR #365): a running attack goes on in the new set's attack clip at the same point relative to its * strike frame (a pending wind-up still strikes at windUntil), a stun / the resting state restarts on the new set's * clip without a crossfade, and the skeleton is posed at once — also while a freeze holds it (`frozen`). */ syncFormPose() { if (this.dead) return; if (this.mode === 'attack') { const track = this.spine.state.tracks[0]; const clip = this._attackClip(); if (!track || !clip) return; const dur = this.dur(clip.loop); const oldHit = this._hitTime(this.current, this.dur(this.current)); const hit = this._hitTime(clip.loop, dur); const start = clampN(track.trackTime + hit - oldHit, 0, dur); const lead = this.wound && this.windUntil != null ? Math.max(0, this.windUntil - this.clock) : 0; const ts = lead > 0 ? Math.max(0, hit - start) / lead : track.timeScale; const tailTs = lead > 0 ? this.windTs : ts; this._play(clip.loop, track.loop, { start, timeScale: ts, mix: 0 }); this.attackUntil = this.clock + lead + Math.max(0, dur - (lead > 0 ? hit : start)) / tailTs; } else if (this.mode === 'stun') { const name = this.has(this.roles.stun?.loop) ? this.roles.stun.loop : this._baseName(); this._play(name, true, { mix: 0 }); } else if (this.mode === 'base') { this._play(this._baseName(), true, { mix: 0 }); } else { return; } // Apply the new attachments even when normal updates are frozen. this.spine.update(0); this._posed = true; } /** Play a form's transition clip once; attacks and the resting state wait for it (mode 'change'). */ _change(clip) { if (this.mode !== 'change') this.stunWanted = this.mode === 'stun'; this.frozen = false; this.mode = 'change'; this._play(clip, false, { mix: 0.08 }); this.changeUntil = this.clock + this.dur(clip); } has(name) { return !!name && this.names.has(name); } dur(name) { const d = this.durations[name]; return typeof d === 'number' && d > 0 ? d : this._durFromData(name); } _durFromData(name) { try { const a = this.spine.spineData.findAnimation(name); return a && a.duration > 0 ? a.duration : 1; } catch { return 1; } } _idleName() { const sk = this.roles.skill; if (this.skillOn && sk && this.has(sk.idle)) return sk.idle; return this.has(this.roles.idle) ? this.roles.idle : (this.has('Idle') ? 'Idle' : [...this.names][0]); } _baseName() { if (this.base === 'move') { const mv = this.roles.move; if (mv && this.has(mv.loop)) return mv.loop; } return this._idleName(); } _play(name, loop, { timeScale = 1, mix, track = 0, start = 0 } = {}) { if (!this.has(name)) return false; const st = this.spine.state; const e = st.setAnimation(track, name, loop); if (e) { e.timeScale = timeScale; if (mix != null) e.mixDuration = mix; if (start) e.trackTime = start; } this.current = name; this._applied = false; return true; } _queue(name, loop, timeScale = 1) { 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; } /** Resting state from the snapshot. */ setBase(base) { if (this.dead) return; const b = base === 'move' || base === 'stun' ? base : 'idle'; // a mode change clip plays out first; the resting state it lands in is remembered if (this.mode === 'change') { this.stunWanted = b === 'stun'; if (b !== 'stun') this.base = b; return; } if (b === 'stun') { this._enterStun(); return; } if (this.mode === 'stun') this._leaveStun(); if (b === this.base && this.mode !== 'stun') return; this.base = b; if (this.mode === 'base') this._play(this._baseName(), true); } _enterStun() { if (this.mode === 'stun') return; this.mode = 'stun'; const s = this.roles.stun; if (s && this.has(s.loop)) { if (this.has(s.begin)) { this._play(s.begin, false); this._queue(s.loop, true); } else this._play(s.loop, true); } else { this.frozen = true; } } _leaveStun() { this.frozen = false; this.mode = 'base'; this._play(this._baseName(), true); } /** * An attack is due in `lead` game seconds (the renderer sees it ahead in the snapshot buffer): start the attack * clip from its wind-up so the strike frame lands when the attack event is rendered. Returns true once started; * false when it is still too early (call again next frame) or there is nothing to wind up. */ windUp(interval, lead, once = false) { if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change' || !(lead >= 0)) return false; const clip = this._attackClip(); if (!clip) return false; // One queued attack owns one wind-up. A later attack may begin while the previous recovery is still playing. if (this.mode === 'attack' && this.current === clip.loop && (once || this.wound)) return false; const loopDur = this.dur(clip.loop); // a one-off cast plays at the clip's own speed (one loop per clip length), whatever the attack rhythm const iv = once ? loopDur : clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8); const hit = this._hitTime(clip.loop, loopDur); // clip per attack: its own speed, sped up only when the attacks come quicker than the clip const plan = windUpPlan(loopDur, hit, this.clipPerAttack && !once ? Math.min(iv, loopDur) : iv, lead); if (!(hit > 0) || lead * plan.ts > hit + 1e-6) return false; if (!once) this.interval = iv; this.mode = 'attack'; this.attackUntil = this.clock + lead + Math.max(0, loopDur - hit) / plan.ts; this._play(clip.loop, false, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 }); this.windTs = plan.ts; this.windUntil = this.clock + lead; this.wound = true; return true; } /** * An attack happened now. `interval` = seconds between attacks (game time already scaled to real); `once` = a one-off * cast (no rhythm): the clip plays once at its own speed from its strike frame, then the resting state. */ attack(interval, once = false) { if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change') return; // a form change plays out if (!once) this.interval = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8); const clip = this._attackClip(); if (!clip) return; const loopDur = this.dur(clip.loop); const per = this.clipPerAttack && !once; // clip per attack: its own speed (ts 1), faster only when the attacks come quicker than the clip const ts = once ? 1 : per ? clampN(loopDur / Math.min(this.interval, loopDur), 1, 4) : clampN(loopDur / this.interval, 0.35, 4); const hit = this._hitTime(clip.loop, loopDur); // (a clip-per-attack actor still playing the previous attack's clip was not wound up for this one) const wasAttacking = this.mode === 'attack' && this.current === clip.loop && this.wound; this.mode = 'attack'; this.attackUntil = this.clock + Math.max(0, loopDur - hit) / ts; this.windUntil = null; this.wound = false; if (!wasAttacking) { // not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show the // strike frame now — unless a clip-per-attack actor's rhythm leaves room for the whole clip (interval ≥ clip): // then the clip plays complete from its wind-up at its own speed, the strike a little late (重犯's iron ball // lifts before the slam; #246 by @TsangAsuna, accepted by the owner on 2026-10-07) const whole = per && this.interval >= loopDur; if (whole) this.attackUntil = this.clock + loopDur / ts; this._play(clip.loop, false, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 }); } else { const e = this.spine.state.tracks[0]; if (e) { e.timeScale = ts; // Preserve operator hit-phase correction when a coarse/batched update crossed windUntil. // This adjusts the prepared single clip; it does not start another visual swing. if (!once && !per) { const t = e.trackTime % loopDur; let d = hit - t; if (d > loopDur / 2) d -= loopDur; else if (d < -loopDur / 2) d += loopDur; e.trackTime += d * 0.8; } } } } _attackClip() { const sk = this.roles.skill; if (this.skillOn && sk && this.has(sk.loop) && sk.via !== 'attack' && !this._skillIsBuffOnly()) return sk; const a = this.roles.attack; if (a && this.has(a.loop)) return a; return null; } // Skill loops that are pure stances (no OnAttack in the loop) are still valid attack visuals during a skill; // only an idle-typed skill loop is treated as buff-only. _skillIsBuffOnly() { const sk = this.roles.skill; return !!sk && sk.loop === this.roles.idle; } /** * The running skill's own idle clip when its attacks are its loop clip (anims skill.idle ≠ skill.loop: 折桠's * Skill_2_Idle beside the Skill_2_Loop jump attack, 史尔特尔's Skill_3_Idle, 耀骑士临光's Skill_3_Idle …): the pose * between its attacks. null otherwise — no skill on, no idle clip, or the loop is that idle (蕾缪安's S2 / S3). */ _skillIdle() { const sk = this.roles.skill; if (!this.skillOn || !sk || sk.idle === sk.loop || !this.has(sk.idle)) return null; return this._attackClip() === sk ? sk.idle : null; } _hitTime(anim, dur) { const hits = this.entry.hits && this.entry.hits[anim]; if (Array.isArray(hits) && hits.length && Number.isFinite(hits[0])) return clampN(hits[0], 0, dur); return dur * 0.5; } /** Skill active flag changed. */ setSkill(on) { on = !!on; if (on === this.skillOn || this.dead) return; this.skillOn = on; const sk = this.roles.skill; if (this.mode === 'stun' || this.mode === 'die') return; // a skill that ends as the unit (re)deploys — 乌尔比安's 【返回】 is a 【移动】 (sim Battle.moveRedeploy) right before // his S3's 'skill' off event — lets the deploy clip play out (then the plain idle) instead of cutting it with the End if (!on && this.mode === 'deploy') return; if (on && sk) { this.skillEndPending = false; this.skillClipOnce = false; if (this.has(sk.begin)) { this.mode = 'skillBegin'; this._play(sk.begin, false, { mix: 0.08 }); this.skillBeginUntil = this.clock + this.dur(sk.begin); // then the skill's own idle until an attack plays its loop (community report #23); without one, the loop const next = this._skillIdle() || (this.has(sk.loop) ? sk.loop : null); if (next) this._queue(next, true); } else if (!this.has(sk.idle) && this.has(sk.loop) && sk.via !== 'attack' && sk.loop !== this.roles.attack?.loop && !this._skillIsBuffOnly()) { // No Begin and no own Idle: the skill's clip IS its animation (德克萨斯 剑雨 — anims.skill {begin:null, // loop:'Skill', end:null}, the 2.17 s clip; 58 instant skills in all). Play it once for its own length. An // instant skill turns the flag off inside the same tick (sim skills.js fires 'skill' 1 and 0 together and only // holds the anim code SKILL for 0.5 s), so this window is what keeps it on screen: the actor used to fall // through to the base clip and show no skill animation at all (player report). this.mode = 'skillBegin'; this.skillClipOnce = true; this._play(sk.loop, false, { mix: 0.08 }); this.skillBeginUntil = this.clock + Math.max(SKILL_CLIP_MIN, this.dur(sk.loop)); } else if (this.mode === 'base') this._play(this._baseName(), true); } else if (!on && sk) { // an instant skill switches off while its own one-shot clip runs: let the clip finish — the state machine plays // its End clip (or the base) when the window ends — instead of cutting it with End / base right now if (this.skillClipOnce && this.clock < this.skillBeginUntil) { this.skillEndPending = true; return; } this.skillClipOnce = false; if (this.has(sk.end)) { this.mode = 'skillEnd'; this._play(sk.end, false, { mix: 0.08 }); this.skillEndUntil = this.clock + this.dur(sk.end); } else { this.mode = 'base'; this._play(this._baseName(), true); } } } deploy() { if (this.dead) return; const d = this.roles.deploy; if (this.has(d) && d !== this.roles.idle) { this.mode = 'deploy'; this._play(d, false, { mix: 0 }); this.deployAt = this.clock; this.deployUntil = this.clock + this.dur(d); } } /** Seconds into the deploy clip while it plays, else null (a model swapped mid-deploy carries it over: units.js). */ deployElapsed() { return this.mode === 'deploy' ? Math.max(0, this.clock - (this.deployAt || 0)) : null; } /** The skeleton's death clip, or null. */ dieClip() { const d = this.roles.die || (this.has('Die') ? 'Die' : null); return d && this.has(d) ? d : null; } /** * Play the death clip; returns its duration (0 when there is none). A skeleton without one (131 of the 135 Back * models, GitHub issue #25; a few idle-only summons and enemies) stops whatever looped — an attack, a skill or the idle — * and holds the first frame of its idle clip (frozen when it has none either) [ASSUMED look]: a dead unit never goes on attacking. A * knocked-out operator shows its fall with the Front model instead (render/units.js _wantsBack). */ die() { if (this.dead) return 0; this.dead = true; this.frozen = false; this.mode = 'die'; const d = this.dieClip(); if (d) { this._play(d, false, { mix: 0.05 }); return this.dur(d); } const idle = this.has(this.roles.idle) ? this.roles.idle : this.has('Idle') ? 'Idle' : null; if (idle) this._play(idle, false, { mix: 0.1, timeScale: 0 }); else this.frozen = true; 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; this.frozen = false; this.mode = 'base'; this.skillOn = false; this._play(this._baseName(), true); } update(dt) { this.clock += dt; if (this.endClip && this.clock >= this.endAt) { const clip = this.endClip; this.endClip = null; if (this.endRoles) { this.formRoles = this.endRoles; this.roles = { ...this._baseRoles(), ...this.endRoles }; } if (!this.dead) this._change(clip); } if (this.windUntil != null && this.clock >= this.windUntil) { // a compressed wind-up reached its strike frame: back to the rhythm speed (attack() also does it) this.windUntil = null; const e = this.spine.state.tracks[0]; if (e && this.mode === 'attack') e.timeScale = this.windTs; } switch (this.mode) { case 'attack': if (this.clock > this.attackUntil) { this.mode = 'base'; this.wound = false; const clip = this._attackClip() || this.roles.attack; // a skill with its own idle: back to that idle (its end clip is for the end of the skill, setSkill(false)) const toSkillIdle = clip === this.roles.skill && !!this._skillIdle(); if (clip && this.has(clip.end) && !toSkillIdle) { this._play(clip.end, false); this._queue(this._baseName(), true); } else this._play(this._baseName(), true, { mix: 0.15 }); } break; case 'skillBegin': if (this.clock >= this.skillBeginUntil) { const sk = this.roles.skill; const once = this.skillClipOnce; const pending = this.skillEndPending; this.skillClipOnce = false; this.skillEndPending = false; this.mode = 'base'; if (pending && this.has(sk?.end)) { // the skill switched off inside its own clip: its End clip closes it now this.mode = 'skillEnd'; this._play(sk.end, false, { mix: 0.08 }); this.skillEndUntil = this.clock + this.dur(sk.end); } else if (once || pending || !this.has(sk?.loop)) { // the one-shot clip is over (or the skill has no loop clip at all): rest. A Begin clip with a queued next // clip (the loop, or the skill's own idle) needs no play here — it is already on the track this._play(this._baseName(), true); } } break; case 'skillEnd': if (this.clock >= this.skillEndUntil) { this.mode = 'base'; this._play(this._baseName(), true); } break; case 'deploy': if (this.clock >= this.deployUntil) { this.mode = 'base'; this._play(this._baseName(), true); } break; case 'change': if (this.clock >= this.changeUntil) { this.mode = 'base'; if (this.stunWanted) { this.stunWanted = false; this._enterStun(); } else this._play(this._baseName(), true); } break; default: break; } // frozen (a stun without a stun clip, a death without a death or idle clip): the pose holds — but a model frozen before // it was ever posed (built while its unit is stunned or dead) is posed once at the current clip's first frame, never // left in its setup pose, which no clip shows and the impostor box does not cover (GitHub #542: 隐现's Back model // stands 900 units to the right of its feet in the setup pose) if (!this.frozen || !this._posed) { try { this.spine.update(this.frozen ? 0 : dt); this._posed = true; // (a first update that threw is tried again: no setup pose left on screen) } catch { /* a broken skeleton must not stop the frame */ } } if (this.clipped && !this.clipOn) this._eyeMaskFallback(); this._applied = true; this._appliedTint = this.spine.tint; } /** * The eyelids without the stencil masks (GitHub #177; PR #384 by @Convey123, re-implemented). On this roster the * clipping attachments are eyelids: the polygon is the eye's opening and the eyeball / eye-white meshes drawn after * it are clipped to it, so a closing eye (a blink, a knock-down) is hidden by the mask, not by the art. The masks * are off on a crowded field or below high quality (render/app.js pickClipping), and the eyeballs showed through * the closed lids (佩佩, 仇白, 隐德来希, 琳琅诗怀雅 …). While they are off, every slot a clip covers — walked in draw * order from the clipping slot to its endSlot, with the attachments in force (any skin), as pixi-spine does — is * hidden for the frame when the centre of what it draws lies outside the clip polygon [ASSUMED: all or nothing per * slot, an approximation of the mask's cut]. pixi-spine shows every attached slot container again on each update. */ _eyeMaskFallback() { const sp = this.spine, skel = sp && sp.skeleton, boxes = sp && sp.slotContainers; if (!skel || !Array.isArray(skel.drawOrder) || !boxes) return; this._showClipHidden(); // undo the last pass (a pixi-spine update re-shows them anyway; a frozen one does not) try { const buf = this._clipBuf || (this._clipBuf = { v: new Float32Array(64), poly: new Float32Array(16), c: [0, 0] }); let pn = -1, end = null; // pn: points of the clip in force (−1: none; < 3: degenerate, hides nothing) for (const slot of skel.drawOrder) { const att = typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment; if (isClip(att)) { const n = att.worldVerticesLength | 0; if (buf.poly.length < n) buf.poly = new Float32Array(n); if (n >= 6) att.computeWorldVertices(slot, 0, n, buf.poly, 0, 2); pn = n >> 1; end = att.endSlot && att.endSlot !== slot.data ? att.endSlot : null; continue; } if (pn < 0) continue; const box = boxes[slot.data.index]; if (pn >= 3 && att && box && slotCentre(slot, att, buf, buf.c) && !pointInPolygon(buf.c[0], buf.c[1], buf.poly, pn)) { box.visible = false; this._clipHidden.add(slot.data.index); } if (end && slot.data === end) { pn = -1; end = null; } } } catch { /* an unknown runtime shape must never stop the frame */ } } /** Show the slot containers the fallback hid (those still drawing an attachment). */ _showClipHidden() { if (!this._clipHidden.size) return; const slots = this.spine?.skeleton?.slots, boxes = this.spine?.slotContainers; for (const i of this._clipHidden) { const slot = slots && slots[i]; if (boxes && boxes[i] && slot && (typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment)) boxes[i].visible = true; } this._clipHidden.clear(); } /** * Skeleton-space bounds of clips `names` ("clip bounds" above; render/units.js _impBox): a Map name → rect (null: draws * nothing / no such clip; false: its sampling threw, not measurable) once every clip is sampled; null while some still * wait — they are queued here and render/app.js samples them (stepClipBounds); false when this runtime cannot be posed * off-screen (a test double). The actor counts as a user of its skeleton's record until it is destroyed. * @param {string[]} names */ clipBounds(names) { const sp = this.spine; const data = sp && sp.spineData; if (!data) return false; let rec = boundsOf.get(data); if (!rec || names.some((n) => !rec.rects.has(n))) rec = requestClipBounds(data, sp.skeleton && sp.skeleton.constructor, names); if (rec.failed) return false; if (this._bounds !== rec) { if (this._bounds) useBounds(this._bounds, -1); this._bounds = rec; useBounds(rec, 1); } const clips = new Map(); for (const n of names) { if (!rec.rects.has(n)) return null; clips.set(n, rec.rects.get(n)); } return clips; } /** Model height in skeleton units (setup-pose bounds, else a chibi default). */ get height() { const b = this.entry.bounds; if (b && Number.isFinite(b.height) && b.height > 20) return Math.min(b.height, 900); return 380; } destroy() { if (this._bounds) { useBounds(this._bounds, -1); this._bounds = null; } // the last user gone: its queue entry goes try { this.spine.destroy({ children: true, texture: false, baseTexture: false }); } catch { /* ignore */ } this.spine = null; } }