feedback1 integrate: WN 灼热/炽焰源石虫 models (local overlay, tinted alias); 暴鸰 bomb drop
# Conflicts: # docs/SIM.md # public/js/render/app.js # public/js/render/spine.js # public/js/render/units.js # server/sim/content/enemies.js # server/sim/snapshot.js
This commit is contained in:
commit
4b15919185
26 files changed
+1383
-90
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 };
|
||||
}
|
||||
|
||||
+19
-3
@@ -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,8 +20,14 @@
|
||||
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 with no Spine anywhere: render them with another enemy's model (research 07 §5.6). */
|
||||
/**
|
||||
* 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',
|
||||
enemy_1305_mhslim_2: 'enemy_1007_slime',
|
||||
@@ -200,9 +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, 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 = {} }) {
|
||||
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {} }) {
|
||||
const notes = [];
|
||||
/** @type {Map<string, any>} */
|
||||
const models = new Map();
|
||||
@@ -362,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)]) {
|
||||
|
||||
+99
-1
@@ -1,8 +1,16 @@
|
||||
// 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 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, writeFile, stat, rename, mkdir, unlink } from 'node:fs/promises';
|
||||
import { readFile, readdir, writeFile, stat, rename, mkdir, unlink } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { assetUrl, safeName, urlDir } from './sources.mjs';
|
||||
import { kindOf, pngSize } from './formats.mjs';
|
||||
@@ -12,6 +20,96 @@ import { resolveRoles } from './anim-roles.mjs';
|
||||
|
||||
async function fileStat(p) { try { const s = await stat(p); return s.isFile() ? s : null; } catch { return null; } }
|
||||
|
||||
/** 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
|
||||
* `local/spine/enemy/<enemyId>/` holding a skeleton, the atlas of the same stem and at least one page PNG.
|
||||
* @param {string} root absolute public/assets directory
|
||||
* @returns {Promise<Record<string, { dir: string, skel: string, atlas: string, pngs: string[] }>>} paths relative to root
|
||||
*/
|
||||
export async function findLocalEnemyModels(root) {
|
||||
const out = {};
|
||||
let ids = [];
|
||||
try { ids = (await readdir(join(root, LOCAL_ENEMY_SPINE_DIR), { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name); } catch { return out; }
|
||||
for (const id of ids.sort()) {
|
||||
if (!/^enemy_\d+_[a-z0-9_]+$/i.test(id)) continue;
|
||||
const dir = `${LOCAL_ENEMY_SPINE_DIR}${id}/`;
|
||||
let files = [];
|
||||
try { files = (await readdir(join(root, dir))).sort(); } catch { continue; }
|
||||
const skel = files.find((f) => f.endsWith('.skel'));
|
||||
const stem = skel ? skel.slice(0, -'.skel'.length) : null;
|
||||
const pngs = files.filter((f) => f.endsWith('.png'));
|
||||
if (!stem || !files.includes(`${stem}.atlas`) || !pngs.length) continue;
|
||||
out[id] = { dir, skel: dir + skel, atlas: `${dir}${stem}.atlas`, pngs: pngs.map((f) => dir + f) };
|
||||
}
|
||||
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
|
||||
|
||||
+54
-6
@@ -5,13 +5,21 @@
|
||||
// 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 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
|
||||
// jsDelivr mirror fallback. Spine atlases get `size:` (and `pma: true` for
|
||||
// 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';
|
||||
@@ -21,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 } 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';
|
||||
@@ -32,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)
|
||||
@@ -40,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));
|
||||
@@ -57,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}`);
|
||||
}
|
||||
@@ -136,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; }
|
||||
@@ -157,11 +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,
|
||||
localEnemySpines,
|
||||
});
|
||||
const leaves = collectLeaves(plan.template);
|
||||
log(`[plan] ${leaves.length} files + ${plan.models.size} Spine models ` +
|
||||
@@ -214,8 +261,9 @@ async function main() {
|
||||
};
|
||||
await writeJsonAtomic(MANIFEST, manifest);
|
||||
|
||||
// Orphans: files on disk that the manifest does not reference (e.g. after a mapping change).
|
||||
const orphans = (await listFiles(ASSETS)).filter((r) => !resolved.files.has(r));
|
||||
// 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 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 */ } }
|
||||
|
||||
const charIds = Object.keys(assets07.operators || {});
|
||||
|
||||
@@ -8,6 +8,11 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
|
||||
- the 6 in-match emoticon themes of 盟约 (activity_table autoChessData.enabledEmoticonThemeIdList; only the
|
||||
*_battle sprites, keyed by display_meta_table picId: emoticon/<dir>/<picId>.png, see data/emotes.json)
|
||||
- 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; 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
|
||||
@@ -90,6 +95,19 @@ JOBS = [
|
||||
('arts/maps/effect.ab', 'map/water', {'Texture2D'}, r'water_normal|Caustics|WaterNoise|noise_clouds|Water_Foam|SmoothWaves'),
|
||||
]
|
||||
|
||||
# Enemy battle Spine models that no community dump carries (isHarryh/Ark-Models lists them with an empty assetList, so
|
||||
# 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'
|
||||
|
||||
# Derived three.js maps: (output subdir, source texture, kind, output name). 'normal_rg' rebuilds Z of a two-channel
|
||||
# (BC5) normal map into an RGB tangent-space map; 'rough_from_gloss' turns Unity's metallic/gloss map (smoothness in
|
||||
# A) into a roughness (G) / metalness (B) map. Written after the job that exported the source texture.
|
||||
@@ -120,6 +138,14 @@ def job_parts(job):
|
||||
return rel, sub, kinds, keep
|
||||
|
||||
|
||||
def wants_sub(only, sub):
|
||||
"""Whether output subdir `sub` runs under --only: no prefixes, or one prefix is `sub`, a parent of it or below it."""
|
||||
if not only:
|
||||
return True
|
||||
return any(sub == p.rstrip('/') or sub.startswith(p.rstrip('/') + '/') or p.rstrip('/').startswith(sub + '/')
|
||||
for p in only)
|
||||
|
||||
|
||||
def select_jobs(only):
|
||||
"""The jobs whose output subdir starts with one of the `only` prefixes (all jobs when `only` is empty)."""
|
||||
if not only:
|
||||
@@ -127,6 +153,13 @@ def select_jobs(only):
|
||||
return [j for j in JOBS if any(j[1] == p.rstrip('/') or j[1].startswith(p.rstrip('/') + '/') for p in only)]
|
||||
|
||||
|
||||
def enemy_spine_id(container, wanted):
|
||||
"""The enemy id of an enemy SkeletonData container path (Assets/Torappu/Arts/Enemies/Spines/<dir>[/<n>]/<id>_
|
||||
SkeletonData.asset) when it is one of `wanted`, else None."""
|
||||
m = re.search(r'/Enemies/Spines/(?:[^/]+/)+([^/]+)_SkeletonData\.asset$', container or '', re.I)
|
||||
return m.group(1) if m and m.group(1) in wanted else None
|
||||
|
||||
|
||||
def _num(v, nd=6):
|
||||
"""JSON-safe rounded float (NaN / inf → 0)."""
|
||||
try:
|
||||
@@ -383,6 +416,135 @@ def export_bundle(ab_root, job, out_root, manifest, log):
|
||||
return n
|
||||
|
||||
|
||||
def _text_bytes(text_asset):
|
||||
raw = text_asset.m_Script
|
||||
return raw.encode('utf-8', 'surrogateescape') if isinstance(raw, str) else bytes(raw)
|
||||
|
||||
|
||||
def merge_alpha(rgb, alpha):
|
||||
"""RGBA page of a split texture: RGB of the main texture (premultiplied: the client's Spine material has
|
||||
_StraightAlphaInput 0) and A from its [alpha] texture; RGB is clamped to A (block-compression bleed)."""
|
||||
from PIL import Image, ImageChops
|
||||
r, g, b = rgb.convert('RGB').split()
|
||||
a = alpha.convert('L').resize(rgb.size) if alpha is not None else Image.new('L', rgb.size, 255)
|
||||
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
|
||||
sub = f'{ENEMY_SPINE_SUB}/{eid}'
|
||||
out_dir = Path(out_root) / sub
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
files = {}
|
||||
skel = get(sda.get('skeletonJSON'))
|
||||
if skel is None:
|
||||
log(f' warn {eid}: skeleton TextAsset not in this bundle')
|
||||
return 0
|
||||
ta = skel.read()
|
||||
stem = safe_name(getattr(ta, 'm_Name', '') or eid).removesuffix('.skel')
|
||||
files[f'{stem}.skel'] = _text_bytes(ta)
|
||||
pages = {}
|
||||
for aref in sda.get('atlasAssets') or []:
|
||||
aa = get(aref)
|
||||
if aa is None:
|
||||
continue
|
||||
at = aa.read_typetree()
|
||||
af = get(at.get('atlasFile'))
|
||||
if af is not None:
|
||||
files[f'{stem}.atlas'] = _text_bytes(af.read())
|
||||
for mref in at.get('materials') or []:
|
||||
mo = get(mref)
|
||||
if mo is None:
|
||||
continue
|
||||
texs = dict(mo.read_typetree()['m_SavedProperties']['m_TexEnvs'])
|
||||
main, alpha = get(texs.get('_MainTex', {}).get('m_Texture')), get(texs.get('_AlphaTex', {}).get('m_Texture'))
|
||||
if main is None:
|
||||
continue
|
||||
mt = main.read()
|
||||
pages[safe_name(mt.m_Name) + '.png'] = merge_alpha(mt.image, alpha.read().image if alpha is not None else None)
|
||||
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'}
|
||||
for fname, img in pages.items():
|
||||
img.save(out_dir / fname)
|
||||
manifest.setdefault(sub, {})[fname] = {'path': f'/assets/local/{sub}/{fname}', 'w': img.width, 'h': img.height,
|
||||
'kind': 'Texture2D'}
|
||||
return len(files) + len(pages)
|
||||
|
||||
|
||||
def export_enemy_spines(ab_root, out_root, manifest, log, ids=None):
|
||||
"""ENEMY_SPINES from the enemy art bundles (each bundle loaded on its own; stops once every model was found)."""
|
||||
import aklz4 # noqa: F401 (registers the LZ4AK decoder)
|
||||
import UnityPy
|
||||
wanted, n = set(ids or ENEMY_SPINES), 0
|
||||
for f in sorted(Path(ab_root).glob(ENEMY_ART)):
|
||||
if not wanted:
|
||||
break
|
||||
try:
|
||||
env = UnityPy.load(str(f))
|
||||
except Exception as e: # corrupt or unsupported bundle: report and continue
|
||||
log(f'FAIL load {f.name}: {e}')
|
||||
continue
|
||||
objects = {o.path_id: o for o in env.objects}
|
||||
for name, ref in env.container.items():
|
||||
eid = enemy_spine_id(name, wanted)
|
||||
if not eid:
|
||||
continue
|
||||
try:
|
||||
k = write_enemy_spine(objects, ref.read_typetree(), eid, out_root, manifest, log)
|
||||
except Exception as e:
|
||||
log(f' warn {eid}: {e}')
|
||||
continue
|
||||
if k:
|
||||
wanted.discard(eid)
|
||||
n += k
|
||||
log(f'{f.relative_to(ab_root).as_posix()}: {eid} -> {ENEMY_SPINE_SUB}/{eid} ({k} files)')
|
||||
for eid in sorted(wanted):
|
||||
log(f'skip (not found) enemy Spine {eid}')
|
||||
return n
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('--game', help='AssetBundle root (…/StreamingAssets/AB/Windows or …/Documents/Bundles)')
|
||||
@@ -398,7 +560,8 @@ def main():
|
||||
for rel, sub, kinds, keep in map(job_parts, JOBS)]
|
||||
derived = [{'sub': sub, 'from': src, 'derive': kind, 'name': name} for sub, src, kind, name in DERIVED]
|
||||
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
|
||||
'derived': derived}, ensure_ascii=False))
|
||||
'derived': derived, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB,
|
||||
'ids': ENEMY_SPINES}}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
ab_root = Path(args.game) if args.game else next((p for p in CANDIDATES if p.exists()), None)
|
||||
@@ -406,7 +569,8 @@ def main():
|
||||
print('No Arknights install found. Pass --game <AssetBundle root>.', file=sys.stderr)
|
||||
return 2
|
||||
jobs = select_jobs(args.only)
|
||||
if not jobs:
|
||||
enemy_spines = wants_sub(args.only, ENEMY_SPINE_SUB)
|
||||
if not jobs and not enemy_spines:
|
||||
print(f'--only {args.only}: no job matches', file=sys.stderr)
|
||||
return 2
|
||||
out_root = Path(args.out)
|
||||
@@ -415,6 +579,8 @@ def main():
|
||||
print(f'AB root: {ab_root}')
|
||||
for job in jobs:
|
||||
total += export_bundle(ab_root, job, out_root, manifest, print)
|
||||
if enemy_spines:
|
||||
total += export_enemy_spines(ab_root, out_root, manifest, print)
|
||||
old = {}
|
||||
if args.only and Path(args.manifest).exists():
|
||||
try:
|
||||
|
||||
+7
-3
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user