Files
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

99 lines
3.6 KiB
JavaScript

// Spine 3.8 binary skeleton (.skel) inspection in Node, using the same parser
// the client ships (@pixi-spine/runtime-3.8, a dependency of pixi-spine).
//
// We only need metadata: animation names + durations, event names, the times
// of `OnAttack` events per animation (hit timing, research 07 §5.4) and the
// skeleton bounds. Attachments are created without textures, so no atlas or
// canvas is needed; attachment paths are checked against the atlas region names
// to detect mismatched downloads.
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const require = createRequire(join(root, 'package.json'));
let runtime = null;
/**
* Lazily load @pixi-spine/runtime-3.8.
* @returns {any} the runtime module
* @throws when pixi-spine is not installed (run `npm install`)
*/
function loadRuntime() {
if (runtime) return runtime;
runtime = require('@pixi-spine/runtime-3.8');
return runtime;
}
/**
* True when the Spine parser can be loaded.
* @returns {boolean}
*/
export function skelParserAvailable() {
try { loadRuntime(); return true; } catch { return false; }
}
function makeLoader(r, regions, missing) {
const check = (path) => { if (regions && !regions.has(path)) missing.add(path); };
return {
newRegionAttachment(skin, name, path) { check(path); return new r.RegionAttachment(name); },
newMeshAttachment(skin, name, path) { check(path); return new r.MeshAttachment(name); },
newBoundingBoxAttachment(skin, name) { return new r.BoundingBoxAttachment(name); },
newPathAttachment(skin, name) { return new r.PathAttachment(name); },
newPointAttachment(skin, name) { return new r.PointAttachment(name); },
newClippingAttachment(skin, name) { return new r.ClippingAttachment(name); },
};
}
const round3 = (x) => Math.round(Number(x) * 1000) / 1000;
/**
* @typedef {{ version: string, animations: string[], durations: Record<string, number>,
* events: string[], hits: Record<string, number[]>, bounds: {x:number,y:number,width:number,height:number}|null,
* missingRegions: string[] }} SkelInfo
*/
/**
* Parse a Spine 3.8 binary skeleton.
* @param {Uint8Array} bytes .skel file contents
* @param {Set<string>} [atlasRegions] region names of the matching atlas (optional, for validation)
* @returns {SkelInfo}
* @throws on malformed data
*/
export function parseSkel(bytes, atlasRegions) {
const r = loadRuntime();
const missing = new Set();
const bin = new r.SkeletonBinary(makeLoader(r, atlasRegions, missing));
const data = bin.readSkeletonData(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
const animations = data.animations.map((a) => a.name);
const durations = {};
const hits = {};
for (const a of data.animations) {
durations[a.name] = round3(a.duration);
for (const t of a.timelines) {
if (!t || !Array.isArray(t.events)) continue;
for (const e of t.events) {
const nm = e?.data?.name;
if (typeof nm === 'string' && /^onattack$/i.test(nm)) {
(hits[a.name] ||= []).push(round3(e.time));
}
}
}
if (hits[a.name]) hits[a.name].sort((x, y) => x - y);
}
const w = Number(data.width); const h = Number(data.height);
const bounds = Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0
? { x: round3(data.x), y: round3(data.y), width: round3(w), height: round3(h) }
: null;
return {
version: String(data.version ?? ''),
animations,
durations,
events: data.events.map((e) => e.name),
hits,
bounds,
missingRegions: [...missing].sort(),
};
}