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:
1 parent
f705797a86
commit
04634f5bf7
14 files changed
+633
-119
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user