feedback1d WN: the local-client slug models are an optional overlay; the manifest never depends on the extraction

data/assets.json pointed 灼热源石虫 / 炽焰源石虫 at /assets/local/ files that only exist
where tools/local-extract ran, with no fallback: without the extraction the
asset tests failed, setup re-ran fetch-assets (rewriting the tracked manifest)
and the slugs drew as icon diamonds.

- The web model stays the 源石虫 alias (spine + spineAliasOf). The official
  model is enemies[id].spineLocal: file names in the data/local-assets.json
  group spine/enemy/<id> plus the parsed metadata, from the committed
  tools/assets/local-enemy-spines.json (fetch-assets --local-spines refreshes
  it from the extracted files, read only). The manifest is byte-identical with
  or without the extraction and has no /assets/local/ URL.
- Client: assets.js spineEntry uses spineLocal only when the local manifest
  lists every file (createFieldView awaits assets.local() with the manifest);
  UnitView loads the web model when the local one fails.
- extract.py writes the atlas with size: / pma: true itself (the same text as
  atlas.mjs normalizeAtlas), so the extraction is usable without a fetch pass.
- setup notes when an older extraction lacks the enemy models.
This commit is contained in:
sganggs committed 2026-10-03 05:18:31 +08:00
1 parent f705797a86
commit 04634f5bf7
14 files changed
+633 -119

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
{
"about": "Spine metadata of the enemy models only the local client has (tools/local-extract/extract.py ENEMY_SPINES); data/assets.json enemies[id].spineLocal. Written by node tools/fetch-assets.mjs --local-spines (docs/ASSETS.md \"Enemy aliases\").",
"models": {
"enemy_1305_mhslim": {
"skel": "enemy_1305_mhslim.skel",
"atlas": "enemy_1305_mhslim.atlas",
"textures": [
"enemy_1305_mhslim.png"
],
"pma": true,
"anims": {
"idle": "Idle",
"deploy": "Idle",
"attack": {
"begin": null,
"loop": "Attack",
"end": null
},
"attackDown": null,
"skill": {
"begin": null,
"loop": "Attack",
"end": null,
"via": "attack",
"index": 0,
"idle": null
},
"die": "Die",
"move": {
"begin": null,
"loop": "Move",
"end": null
},
"stun": null
},
"animations": {
"Attack": 1,
"Default": 0,
"Die": 1,
"Idle": 2,
"Move": 1
},
"events": [
"OnAttack"
],
"hits": {
"Attack": [
0.667
]
},
"bounds": {
"x": -118.121,
"y": -1.969,
"width": 230,
"height": 164
}
},
"enemy_1305_mhslim_2": {
"skel": "enemy_1305_mhslim_2.skel",
"atlas": "enemy_1305_mhslim_2.atlas",
"textures": [
"enemy_1305_mhslim_2.png"
],
"pma": true,
"anims": {
"idle": "Idle",
"deploy": "Idle",
"attack": {
"begin": null,
"loop": "Attack",
"end": null
},
"attackDown": null,
"skill": {
"begin": null,
"loop": "Attack",
"end": null,
"via": "attack",
"index": 0,
"idle": null
},
"die": "Die",
"move": {
"begin": null,
"loop": "Move",
"end": null
},
"stun": null
},
"animations": {
"Attack": 1,
"Default": 0,
"Die": 1,
"Idle": 2,
"Move": 1
},
"events": [
"OnAttack"
],
"hits": {
"Attack": [
0.667
]
},
"bounds": {
"x": -118.121,
"y": -1.969,
"width": 230,
"height": 164
}
}
}
}
+14 -3
View File
@@ -1,7 +1,8 @@
// Manifest assembly: walks the plan template (tools/assets/plan.mjs), collects
// the single-file download alternatives, and resolves the template against the
// files that actually exist on disk into data/assets.json. Entries whose files
// are missing are dropped (never emitted as broken URLs).
// are missing are dropped (never emitted as broken URLs); a `literal(value)` node
// is emitted as it is (no files behind it).
import { existsSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
@@ -13,6 +14,15 @@ export const MANIFEST_VERSION = 1;
const isLeaf = (n) => !!n && typeof n === 'object' && Array.isArray(n.alts);
const isModelRef = (n) => !!n && typeof n === 'object' && typeof n.model === 'string' && Object.keys(n).length === 1;
const LITERAL = Symbol('literal');
const isLiteral = (n) => !!n && typeof n === 'object' && Object.hasOwn(n, LITERAL);
/**
* A template node emitted as it is (a JSON copy; null fields kept): data that is no file to download or resolve, e.g.
* the metadata of a local-client Spine model (plan.mjs enemies[id].spineLocal).
* @param {any} value JSON value
*/
export function literal(value) { return { [LITERAL]: JSON.parse(JSON.stringify(value)) }; }
/**
* Collect every leaf of the template with its dotted path.
@@ -23,7 +33,7 @@ const isModelRef = (n) => !!n && typeof n === 'object' && typeof n.model === 'st
*/
export function collectLeaves(node, path = '', out = []) {
if (isLeaf(node)) { out.push({ path, leaf: node }); return out; }
if (isModelRef(node) || !node || typeof node !== 'object') return out;
if (isModelRef(node) || isLiteral(node) || !node || typeof node !== 'object') return out;
for (const [k, v] of Object.entries(node)) collectLeaves(v, path ? `${path}.${k}` : k, out);
return out;
}
@@ -78,6 +88,7 @@ export function resolveTemplate(template, { root, spine, sourceOf = () => undefi
const files = new Set();
const walk = (node, path) => {
if (node === null || node === undefined) return undefined;
if (isLiteral(node)) return JSON.parse(JSON.stringify(node[LITERAL]));
if (isLeaf(node)) {
for (let i = 0; i < node.alts.length; i++) {
const a = node.alts[i];
@@ -110,7 +121,7 @@ export function resolveTemplate(template, { root, spine, sourceOf = () => undefi
}
return out;
};
const isContainer = (v) => v && typeof v === 'object' && !isLeaf(v) && !isModelRef(v);
const isContainer = (v) => v && typeof v === 'object' && !isLeaf(v) && !isModelRef(v) && !isLiteral(v);
const value = walk(template, '');
return { value, misses, fallbacks, files };
}
+20 -22
View File
@@ -5,7 +5,8 @@
// { alts: [{ rel, urls[], kind, bytes? }, …] } one file; the first alternative
// that ends up on disk wins (later alts are fallbacks: other URLs for the
// same file, or other sounds of the same bank);
// { model: '<key>' } a Spine model (skel + atlas + page PNGs) from plan.models.
// { model: '<key>' } a Spine model (skel + atlas + page PNGs) from plan.models;
// literal(value) a value emitted as it is (no files: enemies[id].spineLocal).
// Inputs are the research JSONs (docs/research/03, 05, 07), the official
// audio_data.json and Ark-Models' models_data.json.
//
@@ -19,10 +20,13 @@
import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs';
import { kindOf } from './formats.mjs';
import { pickUnitSfx, UI_SFX, BATTLE_SFX, resolveSpec } from './audio.mjs';
import { literal } from './manifest.mjs';
/**
* Enemies whose Spine no community dump carries: render them with another enemy's model (research 07 §5.6) — unless
* their own official model was extracted from the local client (`localEnemyModels`, tools/local-extract ENEMY_SPINES).
* Enemies whose Spine no community dump carries: the web model is another enemy's (research 07 §5.6). Their official
* models come from the local client only (tools/local-extract ENEMY_SPINES): `localEnemySpines` adds them as the
* optional `spineLocal` overlay, which the client draws when data/local-assets.json lists its files (user feedback
* after 0.1.0, D3: 灼热源石虫 / 炽焰源石虫 were drawn as the plain 源石虫 everywhere).
*/
export const ENEMY_SPINE_ALIAS = Object.freeze({
enemy_1305_mhslim: 'enemy_1007_slime',
@@ -203,13 +207,12 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
* @param {string[]} [p.extraEnemyIds] more enemy ids that can spawn (e.g. keys of data/enemies.json)
* @param {string[]} [p.extraTokenIds] more token ids (e.g. token_* keys of data/tokens.json)
* @param {Record<string,string>} [p.extraHandbook] enemyId → handbook/model id (e.g. from data/bosses.json)
* @param {Record<string,{dir:string, skel:string, atlas:string, pngs:string[]}>} [p.localEnemyModels] enemy models
* extracted from the local client, on disk under public/assets (spine.mjs findLocalEnemyModels): an enemy without an
* upstream model takes its own local one before any alias (user feedback after 0.1.0, D3: 灼热源石虫 / 炽焰源石虫
* were drawn as the plain 源石虫)
* @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localEnemySpines] metadata of the enemy models the
* local client has (the committed tools/assets/local-enemy-spines.json, never the disk): each planned enemy listed
* gets `spineLocal` = { group: 'spine/enemy/<id>', ...meta } beside its web `spine`
* @returns {{ template: any, models: Map<string, any>, notes: string[] }}
*/
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemyModels = {} }) {
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {} }) {
const notes = [];
/** @type {Map<string, any>} */
const models = new Map();
@@ -339,16 +342,6 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
pngs: [mk(png, rec07?.png)],
});
};
// an official model extracted from the local client (no URL: the files are on disk already)
const localModel = (eid) => {
const l = localEnemyModels?.[eid];
if (!l || typeof l.skel !== 'string' || typeof l.atlas !== 'string' || !Array.isArray(l.pngs) || !l.pngs.length) return null;
return addModel(`enemy:${eid}`, {
kind: 'enemy', dir: l.dir, pma: true, skillIndices: [0], baseUrl: null, local: true,
skel: alt(l.skel, []), atlas: { ...alt(l.atlas, []), mutable: true }, pngs: l.pngs.map((p) => alt(p, [])),
});
};
const ownModel = (eid) => arkModel(eid) || localModel(eid);
const baseIdOf = (eid) => { const m = /^(enemy_\d+_[a-z0-9]+?)_\d+$/i.exec(eid); return m ? m[1] : null; };
const enemyIdSet = new Set(collectEnemyIds({ assets07, enemies05, maps05, ops03 }));
for (const id of extraEnemyIds) if (typeof id === 'string' && /^enemy_\d+_[a-z0-9_]+$/i.test(id)) enemyIdSet.add(id);
@@ -362,9 +355,8 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
const iconAlts = [alt(`enemy/icon/${id}.png`, `${RAW.yuanyan}enemy/${id}.png`, bytesOf(icon07, `${RAW.yuanyan}enemy/${id}.png`))];
for (const other of [handbookOf.get(id), baseIdOf(id)]) if (other) iconAlts.push(alt(`enemy/icon/${id}.png`, `${RAW.yuanyan}enemy/${other}.png`));
e.icon = leaf(iconAlts);
// Spine: own model (upstream, else extracted from the local client), else alias chain (research 07 §5.6).
let spine = ownModel(id);
if (spine && localEnemyModels?.[id] && models.get(spine.model)?.local) notes.push(`${id}: Spine from the local client`);
// Spine: own model, else alias chain (research 07 §5.6).
let spine = arkModel(id);
if (!spine) {
const seen = new Set([id]);
const queue = [ENEMY_SPINE_ALIAS[id], baseIdOf(id), handbookOf.get(id)].filter(Boolean);
@@ -372,7 +364,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
const cand = queue.shift();
if (seen.has(cand)) continue;
seen.add(cand);
spine = ownModel(cand);
spine = arkModel(cand);
if (spine) e.spineAliasOf = cand;
else queue.push(...[ENEMY_SPINE_ALIAS[cand], baseIdOf(cand), handbookOf.get(cand)].filter(Boolean));
}
@@ -380,6 +372,12 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
else notes.push(`${id}: Spine aliased to ${e.spineAliasOf}`);
}
e.spine = spine;
// the official model from the local client, drawn instead of `spine` when the extraction is installed (optional)
const loc = localEnemySpines && Object.hasOwn(localEnemySpines, id) ? localEnemySpines[id] : null;
if (loc && typeof loc === 'object') {
e.spineLocal = literal({ group: `spine/enemy/${id}`, ...loc });
notes.push(`${id}: official Spine from the local client when extracted (spineLocal)`);
}
enemies[id] = e;
let banks = audio.unitBanks.get(id);
for (const other of [handbookOf.get(id), e.spineAliasOf, baseIdOf(id)]) {
+74 -7
View File
@@ -1,8 +1,14 @@
// Spine model pipeline: download skel/atlas/page PNGs, fetch extra atlas pages,
// normalize atlases (size:/pma:), parse skeletons and resolve animation roles.
// Produces the per-model `spine` entries of data/assets.json. Enemy models that
// no dump carries may come from the local client instead (findLocalEnemyModels:
// tools/local-extract/extract.py ENEMY_SPINES → public/assets/local/spine/enemy/).
// Produces the per-model `spine` entries of data/assets.json.
//
// Enemy models that no dump carries but the local client has (tools/local-extract/
// extract.py ENEMY_SPINES → public/assets/local/spine/enemy/<id>/, optional and
// git-ignored) are an overlay, never the manifest's `spine`: their metadata lives
// in the committed tools/assets/local-enemy-spines.json (loadLocalEnemySpines),
// refreshed from the extracted files by `fetch-assets --local-spines`
// (findLocalEnemyModels + localEnemySpineMeta, read only), so data/assets.json
// is the same with or without the extraction (ASSETS.md "Enemy aliases").
import { readFile, readdir, writeFile, stat, rename, mkdir, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path';
@@ -16,6 +22,8 @@ async function fileStat(p) { try { const s = await stat(p); return s.isFile() ?
/** Where tools/local-extract/extract.py writes enemy Spine models, under public/assets. */
export const LOCAL_ENEMY_SPINE_DIR = 'local/spine/enemy/';
/** Its data/local-assets.json group of an enemy (`spine/enemy/<id>`; the client resolves the file names through it). */
export const localEnemySpineGroup = (id) => `spine/enemy/${id}`;
/**
* Enemy Spine models extracted from the local client (tools/local-extract/extract.py ENEMY_SPINES): every
@@ -41,6 +49,67 @@ export async function findLocalEnemyModels(root) {
return out;
}
/**
* @typedef {{ skel: string, atlas: string, textures: string[], pma: boolean, anims: any, animations: Record<string, number>,
* events: string[], hits: Record<string, number[]>, bounds: any }} LocalSpineMeta file names relative to the model's
* data/local-assets.json group (localEnemySpineGroup), the rest as a manifest SpineEntry
*/
/**
* Spine metadata of the extracted enemy models (findLocalEnemyModels), parsed like processModels does — but read only:
* the atlas is sized / pma-normalized in memory (extract.py already writes it so), nothing on disk changes.
* @param {string} root absolute public/assets directory
* @param {Record<string, { dir: string, skel: string, atlas: string, pngs: string[] }>} found
* @returns {Promise<{ meta: Record<string, LocalSpineMeta>, problems: string[] }>}
*/
export async function localEnemySpineMeta(root, found) {
const meta = {};
const problems = [];
const base = (rel) => rel.slice(rel.lastIndexOf('/') + 1);
for (const id of Object.keys(found || {}).sort()) {
const m = found[id];
try {
const text = await readFile(join(root, m.atlas), 'utf8');
const sizes = new Map();
for (const page of atlasInfo(text).pages) {
const sz = pngSize(await readFile(join(root, m.dir + page)));
if (!sz) throw new Error(`invalid page ${page}`);
sizes.set(page, sz);
}
const norm = normalizeAtlas(text, { pageSize: (p) => sizes.get(p) || null, pma: true });
if (norm.missingSize.length) throw new Error(`cannot size pages ${norm.missingSize.join(',')}`);
const info = atlasInfo(norm.text);
if (!info.pages.length) throw new Error('atlas without pages');
const sk = parseSkel(await readFile(join(root, m.skel)), info.regions);
if (!sk.animations.length) throw new Error('skeleton has no animations');
if (sk.missingRegions?.length) problems.push(`${id}: ${sk.missingRegions.length} attachment(s) not in atlas (e.g. ${sk.missingRegions[0]})`);
meta[id] = {
skel: base(m.skel), atlas: base(m.atlas), textures: info.pages, pma: true,
anims: resolveRoles(sk.animations, { skillIndices: [0], durations: sk.durations }),
animations: sk.durations, events: sk.events, hits: sk.hits, bounds: sk.bounds,
};
} catch (e) {
problems.push(`${id}: ${e.message}`);
}
}
return { meta, problems };
}
/** Committed metadata of the local-client enemy models: tools/assets/local-enemy-spines.json. */
export const LOCAL_ENEMY_SPINES_FILE = 'tools/assets/local-enemy-spines.json';
/**
* The `models` of tools/assets/local-enemy-spines.json (enemyId → LocalSpineMeta); {} when the file is missing or bad.
* @param {string} path absolute path of the file
* @returns {Promise<Record<string, LocalSpineMeta>>}
*/
export async function loadLocalEnemySpines(path) {
try {
const j = JSON.parse(await readFile(path, 'utf8'));
return j && typeof j.models === 'object' && j.models && !Array.isArray(j.models) ? j.models : {};
} catch { return {}; }
}
/**
* @typedef {{ skel: string, atlas: string, textures: string[], pma: boolean, anims: any,
* animations: Record<string, number>, events: string[], hits: Record<string, number[]>, bounds: any }} SpineEntry
@@ -61,14 +130,12 @@ export async function processModels(models, { root, dl, cachePath, download = tr
const problems = [];
const list = [...models.values()];
if (download) {
// local-client models (`local`: findLocalEnemyModels) are on disk already: nothing to download
const remote = list.filter((m) => !m.local);
const jobs = [];
for (const m of remote) jobs.push(m.skel, m.atlas, ...m.pngs);
for (const m of list) jobs.push(m.skel, m.atlas, ...m.pngs);
await dl.run(jobs, 'spine');
// Atlases may reference more pages than the index lists.
const extra = [];
for (const m of remote) {
for (const m of list) {
const text = await readFile(join(root, m.atlas.rel), 'utf8').catch(() => null);
if (!text) continue;
for (const page of atlasInfo(text).pages) {
+51 -9
View File
@@ -5,9 +5,12 @@
// public/fonts/** Bender / Novecento (.otf/.ttf + .woff2) and fonts.css
// data/assets.json manifest used by the client (schema: docs/ASSETS.md)
//
// Enemy models no dump carries are taken from the local client when
// tools/local-extract/extract.py has extracted them (public/assets/local/spine/
// enemy/<id>/, ASSETS.md "Enemy aliases"); otherwise another enemy's model.
// Enemy models no dump carries get another enemy's model (ASSETS.md "Enemy
// aliases"); the official ones the local client has (tools/local-extract/
// extract.py ENEMY_SPINES, optional) are added as `spineLocal` from the
// committed tools/assets/local-enemy-spines.json — never from the disk, so the
// manifest is the same with or without the extraction. --local-spines rewrites
// that file from the extracted models (after a game update).
//
// Idempotent: existing files with the right size are skipped, so re-running is
// cheap. Downloads use ~16 parallel connections, 3 retries per source and a
@@ -15,7 +18,8 @@
// enemies); every skeleton is parsed to resolve animation roles.
//
// Usage: node tools/fetch-assets.mjs [--concurrency=16] [--force] [--offline]
// [--dry-run] [--refresh-index] [--prune] [--help]
// [--dry-run] [--refresh-index] [--prune]
// [--local-spines] [--help]
import { readFile, writeFile, mkdir, rename, readdir, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
@@ -25,7 +29,7 @@ import { Downloader } from './assets/downloader.mjs';
import { loadIndexes } from './assets/cache.mjs';
import { indexAudio } from './assets/audio.mjs';
import { buildPlan } from './assets/plan.mjs';
import { processModels, findLocalEnemyModels } from './assets/spine.mjs';
import { processModels, findLocalEnemyModels, localEnemySpineMeta, loadLocalEnemySpines, LOCAL_ENEMY_SPINES_FILE } from './assets/spine.mjs';
import { collectLeaves, downloadLeaves, resolveTemplate, totalBytes, contentHash, MANIFEST_VERSION } from './assets/manifest.mjs';
import { fontJobs, buildFonts } from './assets/fonts.mjs';
import { skelParserAvailable } from './assets/skel.mjs';
@@ -36,6 +40,7 @@ const FONTS = join(ROOT, 'public', 'fonts');
const CACHE = join(ROOT, '.cache');
const MANIFEST = join(ROOT, 'data', 'assets.json');
const REPORT = join(CACHE, 'assets-report.json');
const LOCAL_SPINES = join(ROOT, LOCAL_ENEMY_SPINES_FILE);
const HELP = `Usage: node tools/fetch-assets.mjs [options]
--concurrency=N parallel downloads (default 16)
@@ -44,15 +49,18 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
--dry-run print the plan and exit
--refresh-index re-download audio_data.json / models_data.json indexes
--prune delete files under public/assets that the manifest no longer references
(public/assets/local/** of tools/local-extract is never deleted)
--local-spines rewrite ${LOCAL_ENEMY_SPINES_FILE} from the enemy models extracted
by tools/local-extract/extract.py (public/assets/local/spine/enemy/)
--help this text`;
/**
* Parse CLI flags.
* @param {string[]} argv
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, help:boolean}}
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, localSpines:boolean, help:boolean}}
*/
function parseArgs(argv) {
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, help: false };
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, localSpines: false, help: false };
for (const a of argv) {
const [k, v] = a.split('=');
if (k === '--concurrency') o.concurrency = Math.max(1, Math.min(64, parseInt(v, 10) || 16));
@@ -61,6 +69,7 @@ function parseArgs(argv) {
else if (k === '--dry-run') o.dryRun = true;
else if (k === '--refresh-index') o.refreshIndex = true;
else if (k === '--prune') o.prune = true;
else if (k === '--local-spines') o.localSpines = true;
else if (k === '--help' || k === '-h') o.help = true;
else throw new Error(`unknown option ${a}\n${HELP}`);
}
@@ -140,6 +149,38 @@ function requiredMisses(m, charIds) {
return out;
}
/**
* Metadata of the local-client enemy models (the committed LOCAL_SPINES). With --local-spines it is rewritten from the
* models extracted under public/assets/local/spine/enemy/ (read only); otherwise extracted models whose metadata differs
* from the committed one only get a warning — the manifest never depends on what this machine extracted.
*/
async function syncLocalEnemySpines(opts) {
const committed = await loadLocalEnemySpines(LOCAL_SPINES);
const found = await findLocalEnemyModels(ASSETS);
if (!Object.keys(found).length) {
if (opts.localSpines) log(`[local-spines] no extracted enemy model under public/assets/local/spine/enemy/ — ${LOCAL_ENEMY_SPINES_FILE} kept`);
return committed;
}
const { meta, problems } = await localEnemySpineMeta(ASSETS, found);
for (const p of problems) log(`[local-spines] ${p}`);
if (opts.localSpines && !opts.dryRun) {
const models = { ...committed, ...meta };
const sorted = Object.fromEntries(Object.keys(models).sort().map((k) => [k, models[k]]));
const doc = {
about: 'Spine metadata of the enemy models only the local client has (tools/local-extract/extract.py ENEMY_SPINES); '
+ 'data/assets.json enemies[id].spineLocal. Written by node tools/fetch-assets.mjs --local-spines (docs/ASSETS.md "Enemy aliases").',
models: sorted,
};
await writeJsonAtomic(LOCAL_SPINES, doc, 2);
log(`[local-spines] ${Object.keys(meta).length} model(s) → ${LOCAL_ENEMY_SPINES_FILE}`);
return sorted;
}
for (const [id, m] of Object.entries(meta)) {
if (JSON.stringify(m) !== JSON.stringify(committed[id])) log(`[local-spines] ${id}: the extracted model differs from ${LOCAL_ENEMY_SPINES_FILE} (re-run with --local-spines to update it)`);
}
return committed;
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.help) { log(HELP); return 0; }
@@ -161,12 +202,13 @@ async function main() {
['data/enemies.json', 'data/tokens.json', 'data/bosses.json'].map((f) => readJson(f).catch(() => null)));
const extraHandbook = {};
for (const b of Object.values(dataBosses || {})) if (b?.enemyKey && typeof b.handbookId === 'string') extraHandbook[b.enemyKey] = b.handbookId;
const localEnemySpines = await syncLocalEnemySpines(opts);
const plan = buildPlan({
assets07, ops03, enemies05, maps05, audio, modelsData,
extraEnemyIds: Object.keys(dataEnemies || {}),
extraTokenIds: Object.keys(dataTokens || {}),
extraHandbook,
localEnemyModels: await findLocalEnemyModels(ASSETS),
localEnemySpines,
});
const leaves = collectLeaves(plan.template);
log(`[plan] ${leaves.length} files + ${plan.models.size} Spine models ` +
@@ -220,7 +262,7 @@ async function main() {
await writeJsonAtomic(MANIFEST, manifest);
// Orphans: files on disk that the manifest does not reference (e.g. after a mapping change). public/assets/local/**
// belongs to tools/local-extract (data/local-assets.json) and is never an orphan: --prune used to delete it.
// belongs to tools/local-extract (data/local-assets.json) and is never an orphan: --prune used to delete all of it.
const orphans = (await listFiles(ASSETS)).filter((r) => !resolved.files.has(r) && !r.startsWith('local/'));
if (opts.prune) for (const r of orphans) { try { await unlink(join(ASSETS, r)); } catch { /* ignore */ } }
+48 -7
View File
@@ -10,7 +10,9 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
- the autochess guidebook pages, battle projectile sprites and a few token/skin Spine models missing upstream
- the enemy battle Spine models no community dump carries (ENEMY_SPINES: 灼热源石虫 / 炽焰源石虫), from the enemy art
bundles (refs/arts/enm_art_*.ab) → spine/enemy/<enemyId>/<stem>.skel|.atlas + page PNGs with the [alpha] texture
merged in (premultiplied RGB + A, the Ark-Models format); tools/fetch-assets.mjs plans them in place of an alias
merged in (premultiplied RGB + A, the Ark-Models format; the atlas gets `size:` / `pma: true` like the fetched
enemies); the client draws them instead of the web alias once this manifest lists them (data/assets.json
enemies[id].spineLocal, docs/ASSETS.md "Enemy aliases")
- for the official 3D board (DESIGN §15): the map theme's Material parameters (map/<theme>/materials.json; shader
names resolved through the shaders/*.ab bundles), the background / device meshes as Wavefront OBJ
(mesh/<bundle>/<mesh>.obj; UnityPy's exporter, X mirrored into a right-handed frame) and their GameObject
@@ -94,12 +96,14 @@ JOBS = [
]
# Enemy battle Spine models that no community dump carries (isHarryh/Ark-Models lists them with an empty assetList, so
# tools/fetch-assets.mjs used to alias them to another enemy's skeleton): every enemy of data/enemies.json whose official
# battle prefab draws a skeleton of its own that is missing upstream (audited against the client's prefabs, user feedback
# after 0.1.0 report D3: 灼热源石虫 / 炽焰源石虫 — the ELEMENT faction's slugs — were drawn as the plain 源石虫). Read from
# the enemy art bundles: Assets/Torappu/Arts/Enemies/Spines/<…>/<id>_SkeletonData.asset → its skeleton TextAsset, the
# atlas TextAsset of its atlas asset and every page texture of the atlas materials (_MainTex, with its _AlphaTex merged
# in as A). Output: spine/enemy/<id>/ (manifest group spine/enemy/<id>).
# tools/fetch-assets.mjs aliases them to another enemy's skeleton — the web model): every enemy of data/enemies.json whose
# official battle prefab draws a skeleton of its own that is missing upstream (audited against the client's prefabs, user
# feedback after 0.1.0 report D3: 灼热源石虫 / 炽焰源石虫 — the ELEMENT faction's slugs — were drawn as the plain 源石虫).
# Read from the enemy art bundles: Assets/Torappu/Arts/Enemies/Spines/<…>/<id>_SkeletonData.asset → its skeleton
# TextAsset, the atlas TextAsset of its atlas asset and every page texture of the atlas materials (_MainTex, with its
# _AlphaTex merged in as A). Output: spine/enemy/<id>/ (manifest group spine/enemy/<id>); the client draws the model
# when the group lists every file of data/assets.json enemies[id].spineLocal (whose metadata
# tools/assets/local-enemy-spines.json keeps: `node tools/fetch-assets.mjs --local-spines` after a game update).
ENEMY_SPINES = ['enemy_1305_mhslim', 'enemy_1305_mhslim_2']
ENEMY_ART = 'refs/arts/enm_art_*.ab'
ENEMY_SPINE_SUB = 'spine/enemy'
@@ -426,6 +430,41 @@ def merge_alpha(rgb, alpha):
return Image.merge('RGBA', tuple(ImageChops.darker(ch, a) for ch in (r, g, b)) + (a,))
def normalize_atlas(text, sizes):
"""Spine atlas text with every page header carrying its real `size: w,h` (`sizes`: page name → (w, h)) and
`pma: true` (the pages are premultiplied: merge_alpha) — what tools/assets/atlas.mjs normalizeAtlas gives the fetched
enemy atlases, so the client loads the extracted model as it is. Page fields are the unindented `key: value` lines
right after a page name (a line after a blank line, or the first line); everything else is kept."""
lines = text.replace('\r\n', '\n').replace('\r', '\n').lstrip('\ufeff').split('\n')
out, i, n = [], 0, len(lines)
while i < n:
line = lines[i]
starts_page = line.strip() and ':' not in line and not line[:1].isspace() and (i == 0 or not lines[i - 1].strip())
if not starts_page:
out.append(line)
i += 1
continue
name = line.strip()
fields, j = [], i + 1
while j < n and lines[j].strip() and not lines[j][:1].isspace() and ':' in lines[j]:
fields.append(lines[j])
j += 1
keys = [f.split(':', 1)[0].strip() for f in fields]
size = sizes.get(name)
if size:
sz = f'size: {size[0]},{size[1]}'
if 'size' in keys:
fields[keys.index('size')] = sz
else:
fields.insert(0, sz)
if 'pma' not in keys:
fields.append('pma: true')
out.append(line)
out.extend(fields)
i = j
return '\n'.join(out)
def write_enemy_spine(objects, sda, eid, out_root, manifest, log):
"""Write one enemy model (skeleton, atlas, merged pages) of a SkeletonDataAsset typetree; returns the file count."""
get = lambda pptr: objects.get(pptr['m_PathID']) if pptr and not pptr.get('m_FileID') else None # noqa: E731
@@ -462,6 +501,8 @@ def write_enemy_spine(objects, sda, eid, out_root, manifest, log):
if f'{stem}.atlas' not in files or not pages:
log(f' warn {eid}: atlas or page textures missing')
return 0
sizes = {name: img.size for name, img in pages.items()}
files[f'{stem}.atlas'] = normalize_atlas(files[f'{stem}.atlas'].decode('utf-8'), sizes).encode('utf-8')
for fname, blob in files.items():
(out_dir / fname).write_bytes(blob)
manifest.setdefault(sub, {})[fname] = {'path': f'/assets/local/{sub}/{fname}', 'kind': 'TextAsset'}
+7 -3
View File
@@ -189,11 +189,15 @@ export function checkAssets() {
return { ok: present && missing.length === 0 && urls.length > 0, present, manifest: true, total: urls.length, missing: missing.length, sample: missing.slice(0, 5), bytes: Number(m.stats?.bytes) || 0 };
}
/** Local-client art (optional): manifest entry count and whether the 3D board atlas is on disk. */
/**
* Local-client art (optional): manifest entry count, whether the 3D board atlas is on disk and whether the extraction
* has the enemy models only the client has (`spine/enemy/*` groups, extract.py ENEMY_SPINES — added after 0.1.0).
*/
export function checkLocal() {
const m = readJson(LOCAL_MANIFEST);
const count = m && m.groups ? Object.values(m.groups).reduce((n, g) => n + Object.keys(g || {}).length, 0) : 0;
return { manifest: !!m, count, board3d: exists(LOCAL_BOARD_ATLAS), tiles: exists(LOCAL_BOARD_TILES), dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')) };
const enemySpines = !!(m && m.groups && Object.keys(m.groups).some((g) => g.startsWith('spine/enemy/')));
return { manifest: !!m, count, board3d: exists(LOCAL_BOARD_ATLAS), tiles: exists(LOCAL_BOARD_TILES), enemySpines, dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')) };
}
/**
@@ -423,7 +427,7 @@ async function main() {
add(already ? 'ok' : 'warn', '本地客户端美术(可选)', `${client.kind} 客户端缺少卫戍协议资源(请在游戏内下载全部资源):${client.path}`);
} else if (already && opts.local !== 'force') {
if (local.board3d && !local.tiles && !opts.check) cropBoardTiles(log);
add('ok', '本地客户端美术(可选)', `已提取 ${local.count} 项${local.board3d ? ',3D 棋盘可用' : ''}(重新提取:--local)`);
add('ok', '本地客户端美术(可选)', `已提取 ${local.count} 项${local.board3d ? ',3D 棋盘可用' : ''}${local.enemySpines ? '' : ',缺少新版的灼热/炽焰源石虫模型'}(重新提取:--local)`);
} else if (opts.check) {
add('skip', '本地客户端美术(可选)', `检测到 ${client.kind} 客户端,可运行 node tools/setup.mjs --local 提取`);
} else {