feedback5 WX: the 39 summon models no web dump carries come from the local client (extract.py TOKEN_SPINES) and are drawn instead of the avatar diamond when data/local-assets.json lists them
- tools/local-extract/extract.py TOKEN_SPINES (`--only spine/token`): each token's battle prefab in the battle token packs (pkgrps/btl_pfb_tokens_*.ab; the Windows build has all 39, the iOS build lacks btl_pfb_tokens_0), the skeleton of its Front (or only) Spine renderer, written to public/assets/local/spine/token/<id>/ like the enemy models (sized pma atlas, premultiplied pages; the newer RGBA pages keep their own alpha). The enemy writer is shared (write_spine; the two enemy models re-extract byte for byte). The skinpack job of 乌尔比安's block is gone: it extracted nothing, and that token draws no model in the game (nor do 圣聆初雪's, 酒神's, 贝洛内's and 予愿安洁莉娜's hidden tokens: no Spine renderer, no avatar in either install). - tools/assets/local-token-spines.json (committed, `fetch-assets --local-spines`) → data/assets.json tokens[id].spineLocal for 35 自选 summons, 凯瑟琳's 爬行号·防护单元 and 凛御银灰's three 风雪之眼; the manifest is the same with or without the extraction (JSON diff: only the 39 spineLocal entries and the hash). spine.mjs LOCAL_SPINE_ROLES maps clips the resolver cannot read (电弧's 戴乌 C_Skill1_*, 酒神's 本能的召唤 Loop / End, 白铁's platforms End, 战术锚点 on its Default pose) [ASSUMED where named]. - assets.js spineEntry: a token resolves its spineLocal like an enemy (no web model behind it: the diamond stays when it is not listed or fails to load). Scale as the web tokens (UNIT.modelScale). - setup reports an extraction without the summon models; docs: ASSETS.md "Token models from the local client", DEPLOY.md §6, DESIGN §13 (design/client.md), README. - Tests: test/local-token-models.test.js (new), test/local-extract.test.js (job table, prefab walk, alpha merge), test/assets.test.js (the local token models load like the client does), test/package.test.js (the full zip ships them), test/static-local-art.test.js. Golden unchanged.
This commit is contained in:
1 parent
83d76e507d
commit
575333137c
17 files changed
+2738
-111
No files matched your search
File diff suppressed because it is too large.
Load diff
+12
-2
@@ -6,7 +6,7 @@
|
||||
// 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;
|
||||
// literal(value) a value emitted as it is (no files: enemies[id].spineLocal).
|
||||
// literal(value) a value emitted as it is (no files: enemies[id] / tokens[id].spineLocal).
|
||||
// Inputs are the research JSONs (docs/research/03, 05, 07), the official
|
||||
// audio_data.json and Ark-Models' models_data.json.
|
||||
//
|
||||
@@ -305,6 +305,9 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
|
||||
* @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`
|
||||
* @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localTokenSpines] the same for the token (summon)
|
||||
* models (tools/assets/local-token-spines.json): each planned token listed gets `spineLocal` = { group:
|
||||
* 'spine/token/<id>', ...meta } beside its web `spine`, if any (most have none: the client drew the avatar)
|
||||
* @param {Record<string, any>} [p.extraOperators] charId → { subProfessionId, nationId, skills: [{ index, skillId, iconId }] }
|
||||
* of characters research 07 does not list (the 自选 owned-6★ picks, data/backups.json `units`): planned like the pool
|
||||
* operators from patternOperator
|
||||
@@ -314,7 +317,8 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
|
||||
*/
|
||||
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, charword = null, voiceLang = 'cn',
|
||||
voiceSlots = VOICE_BATTLE_SLOTS,
|
||||
extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {}, extraOperators = {}, moduleTypes = [] }) {
|
||||
extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {}, localTokenSpines = {}, extraOperators = {},
|
||||
moduleTypes = [] }) {
|
||||
const notes = [];
|
||||
/** @type {Map<string, any>} */
|
||||
const models = new Map();
|
||||
@@ -417,6 +421,12 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
|
||||
if (model) entry.spineVariant = v;
|
||||
}
|
||||
entry.spine = model;
|
||||
// the official model from the local client, drawn instead of the avatar (or of `spine`) when extracted (optional)
|
||||
const loc = localTokenSpines && Object.hasOwn(localTokenSpines, id) ? localTokenSpines[id] : null;
|
||||
if (loc && typeof loc === 'object') {
|
||||
entry.spineLocal = literal({ group: `spine/token/${id}`, ...loc });
|
||||
notes.push(`${id}: official Spine from the local client when extracted (spineLocal)`);
|
||||
}
|
||||
tokens[id] = entry;
|
||||
const { roles: u, mix } = unitSounds(audio, pickUnitSfx(audio.unitBanks.get(id)));
|
||||
if (mix) u.mix = mix;
|
||||
|
||||
+104
-21
@@ -2,13 +2,15 @@
|
||||
// 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),
|
||||
// Enemy and token models that no dump carries but the local client has (tools/local-extract/
|
||||
// extract.py ENEMY_SPINES → public/assets/local/spine/enemy/<id>/, TOKEN_SPINES →
|
||||
// public/assets/local/spine/token/<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 / local-token-spines.json (loadLocalSpines),
|
||||
// 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").
|
||||
// (findLocalEnemyModels / findLocalTokenModels + localSpineMeta, read only), so
|
||||
// data/assets.json is the same with or without the extraction (ASSETS.md "Enemy aliases",
|
||||
// "Token models from the local client").
|
||||
|
||||
import { readFile, readdir, writeFile, stat, rename, mkdir, unlink } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
@@ -24,20 +26,26 @@ async function fileStat(p) { try { const s = await stat(p); return s.isFile() ?
|
||||
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}`;
|
||||
/** Where tools/local-extract/extract.py writes token (summon) Spine models, under public/assets. */
|
||||
export const LOCAL_TOKEN_SPINE_DIR = 'local/spine/token/';
|
||||
/** Its data/local-assets.json group of a token (`spine/token/<id>`). */
|
||||
export const localTokenSpineGroup = (id) => `spine/token/${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.
|
||||
* Spine models extracted from the local client under `base` (a LOCAL_*_SPINE_DIR): every `<base><id>/` whose id matches
|
||||
* `idRe` holding a skeleton, the atlas of the same stem and at least one page PNG.
|
||||
* @param {string} root absolute public/assets directory
|
||||
* @param {string} base model folder under root, with a trailing slash
|
||||
* @param {RegExp} idRe the ids to take
|
||||
* @returns {Promise<Record<string, { dir: string, skel: string, atlas: string, pngs: string[] }>>} paths relative to root
|
||||
*/
|
||||
export async function findLocalEnemyModels(root) {
|
||||
async function findLocalModels(root, base, idRe) {
|
||||
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; }
|
||||
try { ids = (await readdir(join(root, base), { 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}/`;
|
||||
if (!idRe.test(id)) continue;
|
||||
const dir = `${base}${id}/`;
|
||||
let files = [];
|
||||
try { files = (await readdir(join(root, dir))).sort(); } catch { continue; }
|
||||
const skel = files.find((f) => f.endsWith('.skel'));
|
||||
@@ -50,19 +58,85 @@ export async function findLocalEnemyModels(root) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
* 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
|
||||
*/
|
||||
export const findLocalEnemyModels = (root) => findLocalModels(root, LOCAL_ENEMY_SPINE_DIR, /^enemy_\d+_[a-z0-9_]+$/i);
|
||||
/**
|
||||
* Token Spine models extracted from the local client (tools/local-extract/extract.py TOKEN_SPINES): every
|
||||
* `local/spine/token/<tokenId>/` holding a skeleton, the atlas of the same stem and at least one page PNG.
|
||||
* @param {string} root absolute public/assets directory
|
||||
*/
|
||||
export const findLocalTokenModels = (root) => findLocalModels(root, LOCAL_TOKEN_SPINE_DIR, /^token_\d+_[a-z0-9_]+$/i);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @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 / localTokenSpineGroup), the rest as a manifest SpineEntry
|
||||
*/
|
||||
|
||||
const clipOf = (loop, via) => (via ? { begin: null, loop, end: null, via } : { begin: null, loop, end: null });
|
||||
/**
|
||||
* Role fixes of local models whose clip names the resolver (tools/assets/anim-roles.mjs, by name) cannot read: model id →
|
||||
* roles replacing the resolved ones (the others are kept); localSpineMeta applies them, so the committed metadata carries
|
||||
* them. A fix naming a clip the skeleton lacks is dropped and reported.
|
||||
* - 电弧's 戴乌 (token_10051_radian_tower1): its clips are C_Skill1_Start / _Idle / _Attack / _Die beside a 0 s C_Default
|
||||
* pose, which the resolver took for its idle, deploy and attack;
|
||||
* - 酒神's 本能的召唤 (token_10054_phatm2_encdool): Start, Loop (5 s), End and a 0 s Default — idle on the Loop and the
|
||||
* End as it goes [ASSUMED: by the clip names];
|
||||
* - 白铁's 白铁™多功能平台 (token_10027_ironmn_pile1 / pile2): Start, Idle, End — the End as it goes [ASSUMED: by the
|
||||
* clip name; the resolver finds no Die clip];
|
||||
* - 凯尔希·思衡托's 战术锚点 (token_10068_kalts2_mtship): its Start / Idle / Die clips hide its one attachment (the official
|
||||
* battle shows the anchor through other means), so it would be an empty tile; it stays on the 0 s Default pose, the
|
||||
* skeleton's own white anchor mark, and fades out as it goes [ASSUMED].
|
||||
*/
|
||||
export const LOCAL_SPINE_ROLES = Object.freeze({
|
||||
token_10051_radian_tower1: {
|
||||
idle: 'C_Skill1_Idle', deploy: 'C_Skill1_Start', attack: clipOf('C_Skill1_Attack'),
|
||||
skill: { ...clipOf('C_Skill1_Attack', 'attack'), index: 0, idle: null }, die: 'C_Skill1_Die',
|
||||
},
|
||||
token_10054_phatm2_encdool: {
|
||||
idle: 'Loop', attack: clipOf('Loop', 'idle'), skill: { ...clipOf('Loop', 'attack'), index: 0, idle: null }, die: 'End',
|
||||
},
|
||||
token_10027_ironmn_pile1: { die: 'End' },
|
||||
token_10027_ironmn_pile2: { die: 'End' },
|
||||
token_10068_kalts2_mtship: {
|
||||
idle: 'Default', deploy: 'Default', attack: clipOf('Default', 'idle'), skill: { ...clipOf('Default', 'attack'), index: 0, idle: null },
|
||||
die: null,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* `roles` with the fix of LOCAL_SPINE_ROLES applied: each fixed role whose clips all exist in `durations` replaces the
|
||||
* resolved one; `missing` lists the roles left as resolved because a clip is missing.
|
||||
* @param {any} roles resolveRoles output
|
||||
* @param {Record<string, any>|undefined} fix
|
||||
* @param {Record<string, number>} durations the skeleton's clips
|
||||
* @returns {{ roles: any, missing: string[] }}
|
||||
*/
|
||||
export function applyRoleFix(roles, fix, durations) {
|
||||
if (!fix) return { roles, missing: [] };
|
||||
const out = { ...roles };
|
||||
const missing = [];
|
||||
const has = (n) => n == null || Object.hasOwn(durations || {}, n);
|
||||
for (const [role, v] of Object.entries(fix)) {
|
||||
const names = typeof v === 'string' ? [v] : v && typeof v === 'object' ? [v.begin, v.loop, v.end, v.idle] : [null];
|
||||
if (names.every(has)) out[role] = v;
|
||||
else missing.push(role);
|
||||
}
|
||||
return { roles: out, missing };
|
||||
}
|
||||
|
||||
/**
|
||||
* Spine metadata of extracted models (findLocalEnemyModels / findLocalTokenModels), 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.
|
||||
* Roles are resolved for skill index 0, like the web enemy and token models, then LOCAL_SPINE_ROLES applied.
|
||||
* @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) {
|
||||
export async function localSpineMeta(root, found) {
|
||||
const meta = {};
|
||||
const problems = [];
|
||||
const base = (rel) => rel.slice(rel.lastIndexOf('/') + 1);
|
||||
@@ -83,9 +157,11 @@ export async function localEnemySpineMeta(root, found) {
|
||||
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]})`);
|
||||
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: [0], durations: sk.durations }), LOCAL_SPINE_ROLES[id], sk.durations);
|
||||
if (fixed.missing.length) problems.push(`${id}: role fix not applied (clip missing) for ${fixed.missing.join(', ')}`);
|
||||
meta[id] = {
|
||||
skel: base(m.skel), atlas: base(m.atlas), textures: info.pages, pma: true,
|
||||
anims: resolveRoles(sk.animations, { skillIndices: [0], durations: sk.durations }),
|
||||
anims: fixed.roles,
|
||||
animations: sk.durations, events: sk.events, hits: sk.hits, bounds: sk.bounds,
|
||||
};
|
||||
} catch (e) {
|
||||
@@ -94,21 +170,28 @@ export async function localEnemySpineMeta(root, found) {
|
||||
}
|
||||
return { meta, problems };
|
||||
}
|
||||
/** The enemy models' name for localSpineMeta (one parse for every local model). */
|
||||
export const localEnemySpineMeta = localSpineMeta;
|
||||
|
||||
/** 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';
|
||||
/** Committed metadata of the local-client token models: tools/assets/local-token-spines.json. */
|
||||
export const LOCAL_TOKEN_SPINES_FILE = 'tools/assets/local-token-spines.json';
|
||||
|
||||
/**
|
||||
* The `models` of tools/assets/local-enemy-spines.json (enemyId → LocalSpineMeta); {} when the file is missing or bad.
|
||||
* The `models` of a committed local-model metadata file (LOCAL_ENEMY_SPINES_FILE / LOCAL_TOKEN_SPINES_FILE: id →
|
||||
* 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) {
|
||||
export async function loadLocalSpines(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 {}; }
|
||||
}
|
||||
/** loadLocalSpines of tools/assets/local-enemy-spines.json (the enemies' name for it). */
|
||||
export const loadLocalEnemySpines = loadLocalSpines;
|
||||
|
||||
/**
|
||||
* @typedef {{ skel: string, atlas: string, textures: string[], pma: boolean, anims: any,
|
||||
|
||||
+49
-24
@@ -9,8 +9,11 @@
|
||||
// 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).
|
||||
// manifest is the same with or without the extraction. The token (summon)
|
||||
// models no dump carries (extract.py TOKEN_SPINES) likewise, from
|
||||
// tools/assets/local-token-spines.json (ASSETS.md "Token models from the local
|
||||
// client"). --local-spines rewrites both files 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 direct source,
|
||||
@@ -51,7 +54,10 @@ import { normalizeProxyPrefix } from './assets/sources.mjs';
|
||||
import { loadIndexes } from './assets/cache.mjs';
|
||||
import { indexAudio, VOICE_DIRS } from './assets/audio.mjs';
|
||||
import { buildPlan } from './assets/plan.mjs';
|
||||
import { processModels, findLocalEnemyModels, localEnemySpineMeta, loadLocalEnemySpines, LOCAL_ENEMY_SPINES_FILE } from './assets/spine.mjs';
|
||||
import {
|
||||
processModels, findLocalEnemyModels, findLocalTokenModels, localSpineMeta, loadLocalSpines, LOCAL_ENEMY_SPINES_FILE,
|
||||
LOCAL_TOKEN_SPINES_FILE, LOCAL_ENEMY_SPINE_DIR, LOCAL_TOKEN_SPINE_DIR,
|
||||
} from './assets/spine.mjs';
|
||||
import { collectLeaves, downloadLeaves, resolveTemplate, totalBytes, contentHash, droppedEntries, MANIFEST_VERSION } from './assets/manifest.mjs';
|
||||
import { fontJobs, buildFonts } from './assets/fonts.mjs';
|
||||
import { skelParserAvailable } from './assets/skel.mjs';
|
||||
@@ -62,7 +68,22 @@ 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);
|
||||
/**
|
||||
* The local-client model overlays (spineLocal): the committed metadata file, where extract.py writes the models (under
|
||||
* public/assets), how to find them, and the file's `about` line.
|
||||
*/
|
||||
const LOCAL_SPINE_KINDS = {
|
||||
enemy: {
|
||||
file: LOCAL_ENEMY_SPINES_FILE, dir: LOCAL_ENEMY_SPINE_DIR, find: findLocalEnemyModels, what: 'enemy',
|
||||
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").',
|
||||
},
|
||||
token: {
|
||||
file: LOCAL_TOKEN_SPINES_FILE, dir: LOCAL_TOKEN_SPINE_DIR, find: findLocalTokenModels, what: 'token',
|
||||
about: 'Spine metadata of the token (summon) models only the local client has (tools/local-extract/extract.py TOKEN_SPINES); '
|
||||
+ 'data/assets.json tokens[id].spineLocal. Written by node tools/fetch-assets.mjs --local-spines (docs/ASSETS.md "Token models from the local client").',
|
||||
},
|
||||
};
|
||||
|
||||
const HELP = `Usage: node tools/fetch-assets.mjs [options]
|
||||
--concurrency=N parallel downloads (default 16)
|
||||
@@ -80,8 +101,9 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
|
||||
(without it such a run keeps the current manifest, lists the entries and exits 1)
|
||||
--add-only download only files missing on disk; never re-download, rewrite or delete an existing
|
||||
file, no font rebuild (a worktree sharing public/assets and public/fonts)
|
||||
--local-spines rewrite ${LOCAL_ENEMY_SPINES_FILE} from the enemy models extracted
|
||||
by tools/local-extract/extract.py (public/assets/local/spine/enemy/)
|
||||
--local-spines rewrite ${LOCAL_ENEMY_SPINES_FILE} and ${LOCAL_TOKEN_SPINES_FILE} from the
|
||||
enemy and token models extracted by tools/local-extract/extract.py
|
||||
(public/assets/local/spine/enemy/, public/assets/local/spine/token/)
|
||||
--help this text
|
||||
Environment: SP_ASSET_SOURCE sets the default source; SP_GITHUB_PROXY sets the
|
||||
HTTPS mirror prefix (default https://gh-proxy.com/; empty disables the proxy).
|
||||
@@ -247,33 +269,32 @@ function requiredMisses(m, charIds) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Metadata of the local-client models of one kind (LOCAL_SPINE_KINDS: the committed file). With --local-spines it is
|
||||
* rewritten from the models extracted under public/assets/local/spine/<kind>/ (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.
|
||||
* @param {{ localSpines: boolean, dryRun: boolean }} opts
|
||||
* @param {'enemy'|'token'} kind
|
||||
*/
|
||||
async function syncLocalEnemySpines(opts) {
|
||||
const committed = await loadLocalEnemySpines(LOCAL_SPINES);
|
||||
const found = await findLocalEnemyModels(ASSETS);
|
||||
async function syncLocalSpines(opts, kind) {
|
||||
const k = LOCAL_SPINE_KINDS[kind];
|
||||
const committed = await loadLocalSpines(join(ROOT, k.file));
|
||||
const found = await k.find(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`);
|
||||
if (opts.localSpines) log(`[local-spines] no extracted ${k.what} model under public/assets/${k.dir} — ${k.file} kept`);
|
||||
return committed;
|
||||
}
|
||||
const { meta, problems } = await localEnemySpineMeta(ASSETS, found);
|
||||
const { meta, problems } = await localSpineMeta(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}`);
|
||||
const sorted = Object.fromEntries(Object.keys(models).sort().map((id) => [id, models[id]]));
|
||||
await writeJsonAtomic(join(ROOT, k.file), { about: k.about, models: sorted }, 2);
|
||||
log(`[local-spines] ${Object.keys(meta).length} ${k.what} model(s) → ${k.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)`);
|
||||
if (JSON.stringify(m) !== JSON.stringify(committed[id])) log(`[local-spines] ${id}: the extracted model differs from ${k.file} (re-run with --local-spines to update it)`);
|
||||
}
|
||||
return committed;
|
||||
}
|
||||
@@ -304,7 +325,8 @@ async function main() {
|
||||
const extras = dataExtras(dataBackups, dataChess);
|
||||
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 localEnemySpines = await syncLocalSpines(opts, 'enemy');
|
||||
const localTokenSpines = await syncLocalSpines(opts, 'token');
|
||||
const plan = buildPlan({
|
||||
assets07, ops03, enemies05, maps05, audio, modelsData, charword, voiceLang: opts.voiceLang,
|
||||
// default: only the slots a battle can play (plan.mjs VOICE_BATTLE_SLOTS); --voice-all takes the whole official set
|
||||
@@ -313,6 +335,7 @@ async function main() {
|
||||
extraTokenIds: [...Object.keys(dataTokens || {}), ...extras.tokenIds],
|
||||
extraHandbook,
|
||||
localEnemySpines,
|
||||
localTokenSpines,
|
||||
extraOperators: extras.extraOperators,
|
||||
moduleTypes: extras.moduleTypes,
|
||||
});
|
||||
@@ -409,6 +432,8 @@ async function main() {
|
||||
log(`on disk (manifest) : ${mb(s.bytes)} in ${s.files} files`);
|
||||
log(`chars ${s.chars} (Back model ${s.charsWithBack}) · enemies ${s.enemies} (Spine ${s.enemiesWithSpine}) · tokens ${s.tokens} (Spine ${s.tokensWithSpine}) · Spine models ${s.spineModels}`);
|
||||
log(`bonds ${s.bonds} · items ${s.items} · bands ${s.bands} · skill icons ${s.skills} · UI ${s.ui} · units with SFX ${s.sfxUnits}`);
|
||||
const overlays = (o) => Object.values(o || {}).filter((e) => e?.spineLocal).length;
|
||||
log(`local-client models (spineLocal, drawn when extracted): enemies ${overlays(manifest.enemies)} · tokens ${overlays(manifest.tokens)}`);
|
||||
log(`operator battle voice: ${s.voiceChars} charIds (--voice-lang=${opts.voiceLang})`);
|
||||
log(`fonts: ${Object.values(fonts.files).map((f) => f.woff2 || f.original).join(', ') || 'none'}`);
|
||||
if (resolved.fallbacks.length) { log(`fallbacks used (${resolved.fallbacks.length}):`); for (const f of resolved.fallbacks.slice(0, 20)) log(` ${f}`); }
|
||||
|
||||
+213
-35
@@ -7,7 +7,7 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
|
||||
- every Sprite of the three autochess UI bundles (HUD frames, badges, banners, icons)
|
||||
- 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 autochess guidebook pages and battle projectile sprites
|
||||
(the emotes and the guidebook pages are on the public mirror too: tools/fetch-assets.mjs downloads them, and the
|
||||
client uses these local copies first — GitHub issue #42)
|
||||
- the enemy battle Spine models no community dump carries (ENEMY_SPINES: 灼热源石虫 / 炽焰源石虫), from the enemy art
|
||||
@@ -15,6 +15,11 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
|
||||
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")
|
||||
- the token (summon) battle Spine models no community dump carries (TOKEN_SPINES: most 自选 summons, 凯瑟琳's
|
||||
爬行号·防护单元, 凛御银灰's 风雪之眼), from the battle token prefabs (pkgrps/btl_pfb_tokens_*.ab: the skeleton
|
||||
the prefab's Front / only Spine renderer draws) → spine/token/<tokenId>/<stem>.skel|.atlas + merged page PNGs, the
|
||||
same format; drawn instead of the avatar diamond once this manifest lists them (data/assets.json
|
||||
tokens[id].spineLocal)
|
||||
- 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,7 +99,6 @@ JOBS = [
|
||||
('battle/prefabs/[uc]projectiles.ab', 'projectiles', {'Sprite', 'Texture2D'}),
|
||||
# official module (uniequip) type icons, keyed by lower-case type name (e.g. 'mar-x')
|
||||
('spritepack/ui_equip_type_hub_h2_0.ab', 'module', {'Sprite'}),
|
||||
('skinpack/token_10039_ulpia_block.ab', 'spine/token_10039_ulpia_block', {'TextAsset', 'Texture2D'}),
|
||||
*[(rel, f'mesh/{sub}', {'Mesh', 'GameObject', 'Material', 'Texture2D'}) for rel, sub in MESH_BUNDLES],
|
||||
('arts/effects/[pack]map.ab', 'map/fx', {'Mesh', 'GameObject', 'Material', 'Texture2D'}),
|
||||
('arts/maps/common/res.ab', 'map/common', {'Material', 'Texture2D'}, r'^(TX|MT)_wind_device$'),
|
||||
@@ -114,6 +118,35 @@ ENEMY_SPINES = ['enemy_1305_mhslim', 'enemy_1305_mhslim_2']
|
||||
ENEMY_ART = 'refs/arts/enm_art_*.ab'
|
||||
ENEMY_SPINE_SUB = 'spine/enemy'
|
||||
|
||||
# Token (summon) battle Spine models that no community dump carries: fexli/ArknightsResource has most of the 自选
|
||||
# summons (data/backups.json `tokens`) only as skin variants or not at all, so tools/fetch-assets.mjs reports "missing
|
||||
# skel" for them and the client drew the avatar diamond. Every token of data/tokens.json and data/backups.json whose
|
||||
# battle prefab draws a Spine model and that has no web model (audited 2026-10-06 against both local installs). Read from
|
||||
# the battle token packs (dyn/battle/prefabs/[uc]tokens/<id>.prefab): the SkeletonDataAsset of the prefab's Spine
|
||||
# renderer — of a directional token (FaceSwitcher with Front / Back / Down renderers, each its own skeleton under the
|
||||
# same name) the Front one, the model the fetched tokens use too (plan.mjs: Spine/ or Front/) — with its skeleton,
|
||||
# atlas and page textures as in ENEMY_SPINES. The Windows build carries every one (pkgrps/btl_pfb_tokens_0/4/5.ab; the
|
||||
# iOS build lacks btl_pfb_tokens_0, and its pages are ASTC rather than BC7). Output: spine/token/<id>/ (manifest group
|
||||
# spine/token/<id>); the client draws the model when the group lists every file of data/assets.json
|
||||
# tokens[id].spineLocal (metadata in tools/assets/local-token-spines.json, `node tools/fetch-assets.mjs --local-spines`).
|
||||
# Not here: the tokens whose prefab draws nothing (an EmptyAnimator in place of the Spine renderer, displayType HIDDEN
|
||||
# or an effect only, and no avatar in either install's asset index) — 乌尔比安's 从不混淆的方向, 圣聆初雪's frozen
|
||||
# protection point, 酒神's 迷狂牢笼, 贝洛内's 牵绊, 予愿安洁莉娜's “一会儿见!” (token_10039 / 10058 / 10055 / 10065 / 10071).
|
||||
TOKEN_SPINES = [
|
||||
'token_10002_kalts_mon3tr', 'token_10003_cgbird_bird', 'token_10005_mgllan_drone1', 'token_10005_mgllan_drone2',
|
||||
'token_10005_mgllan_drone3', 'token_10007_phatom_twin', 'token_10008_cqbw_box', 'token_10009_weedy_cannon',
|
||||
'token_10020_ling_soul1', 'token_10020_ling_soul2', 'token_10020_ling_soul3', 'token_10024_ebnhlz_rcube',
|
||||
'token_10025_doroth_recttp', 'token_10026_bgsnow_subbow', 'token_10027_ironmn_pile1', 'token_10027_ironmn_pile2',
|
||||
'token_10027_ironmn_pile3', 'token_10029_slent2_protrb', 'token_10032_jesca2_jckshd', 'token_10034_ray_sndbst',
|
||||
'token_10035_wisdel_wward', 'token_10041_cathy_catsld', 'token_10043_necras_skeltn', 'token_10050_monstr_prosts',
|
||||
'token_10051_radian_tower1', 'token_10052_radian_tower2', 'token_10053_radian_tower3', 'token_10054_phatm2_encdool',
|
||||
'token_10057_svash2_eagle1', 'token_10057_svash2_eagle2', 'token_10057_svash2_eagle3', 'token_10059_nasti_nstdef',
|
||||
'token_10060_nasti_nstchr', 'token_10061_nasti_nstbld', 'token_10064_wang_stone1', 'token_10066_closur_ourbase',
|
||||
'token_10068_kalts2_mtship', 'token_10069_mcnist_mcgraf', 'token_10070_aphris_pc',
|
||||
]
|
||||
TOKEN_PACKS = 'pkgrps/btl_pfb_tokens_*.ab'
|
||||
TOKEN_SPINE_SUB = 'spine/token'
|
||||
|
||||
# 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.
|
||||
@@ -192,6 +225,73 @@ def enemy_spine_id(container, wanted):
|
||||
return m.group(1) if m and m.group(1) in wanted else None
|
||||
|
||||
|
||||
def token_prefab_id(container, wanted):
|
||||
"""The token id of a battle token prefab container path (dyn/battle/prefabs/[uc]tokens/<id>.prefab) when it is one
|
||||
of `wanted`, else None (a skin's prefab lives under battle/prefabs/skins/character/…)."""
|
||||
m = re.search(r'(?:^|/)battle/prefabs/(?:\[uc\])?tokens/([^/]+)\.prefab$', container or '', re.I)
|
||||
return m.group(1) if m and m.group(1) in wanted else None
|
||||
|
||||
|
||||
def spine_ids(only, sub, ids):
|
||||
"""The ids of a Spine model list (ENEMY_SPINES / TOKEN_SPINES, written to `<sub>/<id>`) that run under --only: all of
|
||||
them with no prefix, `sub` or a parent of it; else those whose output dir is a prefix or below one."""
|
||||
if not wants_sub(only, sub):
|
||||
return []
|
||||
if not only or any(sub == p.rstrip('/') or sub.startswith(p.rstrip('/') + '/') for p in only):
|
||||
return list(ids)
|
||||
return [i for i in ids if any(f'{sub}/{i}' == p.rstrip('/') or f'{sub}/{i}'.startswith(p.rstrip('/') + '/')
|
||||
for p in only)]
|
||||
|
||||
|
||||
def prefab_spine_nodes(root, objects):
|
||||
"""The Spine renderers of a prefab, depth first: [(GameObject name, active, skeletonDataAsset PPtr)] for every
|
||||
MonoBehaviour holding a `skeletonDataAsset` (Spine's SkeletonAnimation). `root` is the root GameObject's type tree,
|
||||
`objects` the bundle's {path id: object}; references into other bundles are skipped."""
|
||||
out = []
|
||||
|
||||
def local(pptr):
|
||||
return objects.get(pptr.get('m_PathID')) if pptr and not pptr.get('m_FileID') else None
|
||||
|
||||
def walk(go, active, depth):
|
||||
tr, refs = None, []
|
||||
for c in go.get('m_Component') or []:
|
||||
o = local(c.get('component'))
|
||||
if o is None:
|
||||
continue
|
||||
try:
|
||||
if o.type.name == 'Transform':
|
||||
tr = o.read_typetree()
|
||||
elif o.type.name == 'MonoBehaviour':
|
||||
sda = o.read_typetree().get('skeletonDataAsset')
|
||||
if sda and sda.get('m_PathID'):
|
||||
refs.append(sda)
|
||||
except Exception: # a script without a readable type tree is no Spine renderer we could use
|
||||
continue
|
||||
active = active and bool(go.get('m_IsActive', 1))
|
||||
out.extend((go.get('m_Name') or '', active, ref) for ref in refs)
|
||||
if tr is None or depth >= 12:
|
||||
return
|
||||
for ch in tr.get('m_Children') or []:
|
||||
t = local(ch)
|
||||
g = local(t.read_typetree().get('m_GameObject')) if t is not None else None
|
||||
if g is not None:
|
||||
walk(g.read_typetree(), active, depth + 1)
|
||||
|
||||
walk(root, True, 0)
|
||||
return out
|
||||
|
||||
|
||||
def pick_spine_node(nodes):
|
||||
"""The renderer the remake draws among prefab_spine_nodes: of a directional token (a FaceSwitcher with Front / Back /
|
||||
Down renderers) the Front one, else one named Spine, else the first — active renderers first; None when empty."""
|
||||
live = [n for n in nodes if n[1]] or list(nodes)
|
||||
for want in ('Front', 'Spine'):
|
||||
hit = next((n for n in live if n[0] == want), None)
|
||||
if hit:
|
||||
return hit
|
||||
return live[0] if live else None
|
||||
|
||||
|
||||
def _num(v, nd=6):
|
||||
"""JSON-safe rounded float (NaN / inf → 0)."""
|
||||
try:
|
||||
@@ -494,13 +594,34 @@ def _text_bytes(text_asset):
|
||||
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)."""
|
||||
def merge_alpha(rgb, alpha, straight=False):
|
||||
"""Premultiplied RGBA page of a Spine atlas texture: RGB of the main texture and A from its [alpha] texture (the
|
||||
split pages: _UseAlphaTex 1), else the main texture's own alpha (the newer RGBA pages: _UseAlphaTex 0); RGB is
|
||||
clamped to A (block-compression bleed) — the client's Spine material reads premultiplied input
|
||||
(_StraightAlphaInput 0) — or, for a material with _StraightAlphaInput 1 (`straight`), multiplied by A."""
|
||||
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,))
|
||||
if alpha is not None:
|
||||
a = alpha.convert('L').resize(rgb.size)
|
||||
elif rgb.mode in ('RGBA', 'LA', 'PA') or 'transparency' in rgb.info:
|
||||
a = rgb.convert('RGBA').getchannel('A')
|
||||
else:
|
||||
a = Image.new('L', rgb.size, 255)
|
||||
op = ImageChops.multiply if straight else ImageChops.darker
|
||||
return Image.merge('RGBA', tuple(op(ch, a) for ch in (r, g, b)) + (a,))
|
||||
|
||||
|
||||
def _is_page_line(lines, i):
|
||||
"""Whether line i of a Spine atlas starts a page: a page name is an unindented line without ':' that comes first
|
||||
or after a blank line (a region name follows its page's or the previous region's fields)."""
|
||||
line = lines[i]
|
||||
return bool(line.strip()) and ':' not in line and not line[:1].isspace() and (i == 0 or not lines[i - 1].strip())
|
||||
|
||||
|
||||
def atlas_pages(text):
|
||||
"""The page names (image files) of a Spine atlas text, in order."""
|
||||
lines = text.replace('\r\n', '\n').replace('\r', '\n').lstrip('\ufeff').split('\n')
|
||||
return [lines[i].strip() for i in range(len(lines)) if _is_page_line(lines, i)]
|
||||
|
||||
|
||||
def normalize_atlas(text, sizes):
|
||||
@@ -512,8 +633,7 @@ def normalize_atlas(text, sizes):
|
||||
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:
|
||||
if not _is_page_line(lines, i):
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
@@ -538,21 +658,21 @@ def normalize_atlas(text, sizes):
|
||||
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."""
|
||||
def write_spine(objects, sda, sub, name, out_root, manifest, log):
|
||||
"""Write one Spine model of a SkeletonDataAsset type tree — its skeleton TextAsset, its atlas TextAsset(s) (sized,
|
||||
`pma: true`: normalize_atlas) and the page textures of the atlas materials (_MainTex with its _AlphaTex merged in,
|
||||
merge_alpha) — to <out_root>/<sub>/ as <stem>.skel, <stem>.atlas (one stem: pixi-spine finds the atlas by the
|
||||
skeleton's name) and <page>.png, and list them in the manifest group `sub`. Returns the file count; 0 when a part
|
||||
is missing or the textures do not match the atlas pages (nothing is written then). `name` labels the log lines and
|
||||
names a skeleton without a name."""
|
||||
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')
|
||||
log(f' warn {name}: 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 = {}
|
||||
stem = safe_name(getattr(ta, 'm_Name', '') or name).removesuffix('.skel')
|
||||
texts, pages = [], {}
|
||||
for aref in sda.get('atlasAssets') or []:
|
||||
aa = get(aref)
|
||||
if aa is None:
|
||||
@@ -560,22 +680,34 @@ def write_enemy_spine(objects, sda, eid, out_root, manifest, log):
|
||||
at = aa.read_typetree()
|
||||
af = get(at.get('atlasFile'))
|
||||
if af is not None:
|
||||
files[f'{stem}.atlas'] = _text_bytes(af.read())
|
||||
texts.append(_text_bytes(af.read()).decode('utf-8', 'replace'))
|
||||
for mref in at.get('materials') or []:
|
||||
mo = get(mref)
|
||||
if mo is None:
|
||||
continue
|
||||
texs = dict(mo.read_typetree()['m_SavedProperties']['m_TexEnvs'])
|
||||
props = mo.read_typetree()['m_SavedProperties']
|
||||
texs = dict(props['m_TexEnvs'])
|
||||
straight = dict(props.get('m_Floats') or []).get('_StraightAlphaInput') == 1
|
||||
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')
|
||||
pages[safe_name(mt.m_Name) + '.png'] = merge_alpha(mt.image, alpha.read().image if alpha is not None else None,
|
||||
straight)
|
||||
if not texts or not pages:
|
||||
log(f' warn {name}: 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')
|
||||
atlas = texts[0] if len(texts) == 1 else '\n\n'.join(t.strip('\n') for t in texts) + '\n'
|
||||
names = atlas_pages(atlas)
|
||||
if set(names) != set(pages):
|
||||
if len(names) != 1 or len(pages) != 1:
|
||||
log(f' warn {name}: atlas pages {names} do not match the textures {sorted(pages)}')
|
||||
return 0
|
||||
pages = {names[0]: next(iter(pages.values()))} # one page under another name: saved as the atlas names it
|
||||
files = {f'{stem}.skel': _text_bytes(ta),
|
||||
f'{stem}.atlas': normalize_atlas(atlas, {p: img.size for p, img in pages.items()}).encode('utf-8')}
|
||||
out_dir = Path(out_root) / sub
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for fname, blob in files.items():
|
||||
(out_dir / fname).write_bytes(blob)
|
||||
manifest.setdefault(sub, {})[fname] = {'path': f'/assets/local/{sub}/{fname}', 'kind': 'TextAsset'}
|
||||
@@ -587,10 +719,11 @@ def write_enemy_spine(objects, sda, eid, out_root, manifest, log):
|
||||
|
||||
|
||||
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)."""
|
||||
"""ENEMY_SPINES (or `ids`) 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
|
||||
wanted, n = set(ENEMY_SPINES if ids is None else ids), 0
|
||||
for f in sorted(Path(ab_root).glob(ENEMY_ART)):
|
||||
if not wanted:
|
||||
break
|
||||
@@ -605,7 +738,7 @@ def export_enemy_spines(ab_root, out_root, manifest, log, ids=None):
|
||||
if not eid:
|
||||
continue
|
||||
try:
|
||||
k = write_enemy_spine(objects, ref.read_typetree(), eid, out_root, manifest, log)
|
||||
k = write_spine(objects, ref.read_typetree(), f'{ENEMY_SPINE_SUB}/{eid}', eid, out_root, manifest, log)
|
||||
except Exception as e:
|
||||
log(f' warn {eid}: {e}')
|
||||
continue
|
||||
@@ -618,6 +751,46 @@ def export_enemy_spines(ab_root, out_root, manifest, log, ids=None):
|
||||
return n
|
||||
|
||||
|
||||
def export_token_spines(ab_root, out_root, manifest, log, ids=None):
|
||||
"""TOKEN_SPINES (or `ids`) from the battle token packs: each token's prefab → the SkeletonDataAsset its Front / only
|
||||
Spine renderer draws (pick_spine_node) → write_spine. Each pack is loaded on its own; stops once every model was
|
||||
found."""
|
||||
import aklz4 # noqa: F401 (registers the LZ4AK decoder)
|
||||
import UnityPy
|
||||
wanted, n = set(TOKEN_SPINES if ids is None else ids), 0
|
||||
for f in sorted(Path(ab_root).glob(TOKEN_PACKS)):
|
||||
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():
|
||||
tid = token_prefab_id(name, wanted)
|
||||
if not tid:
|
||||
continue
|
||||
sub = f'{TOKEN_SPINE_SUB}/{tid}'
|
||||
try:
|
||||
node = pick_spine_node(prefab_spine_nodes(ref.read_typetree(), objects))
|
||||
sda = objects.get(node[2]['m_PathID']) if node and not node[2].get('m_FileID') else None
|
||||
if sda is None:
|
||||
log(f' warn {tid}: its prefab has no Spine renderer in this bundle')
|
||||
continue
|
||||
k = write_spine(objects, sda.read_typetree(), sub, tid, out_root, manifest, log)
|
||||
except Exception as e:
|
||||
log(f' warn {tid}: {e}')
|
||||
continue
|
||||
if k:
|
||||
wanted.discard(tid)
|
||||
n += k
|
||||
log(f'{f.relative_to(ab_root).as_posix()}: {tid} ({node[0]}) -> {sub} ({k} files)')
|
||||
for tid in sorted(wanted):
|
||||
log(f'skip (not found) token Spine {tid}')
|
||||
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)')
|
||||
@@ -637,8 +810,10 @@ def main():
|
||||
derived = [{'sub': sub, 'from': src, 'derive': kind, 'name': name} for sub, src, kind, name in DERIVED]
|
||||
webp = [{'sub': sub, 'name': name, 'mode': mode} for sub, name, mode in WEBP]
|
||||
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
|
||||
'derived': derived, 'webp': webp, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB,
|
||||
'ids': ENEMY_SPINES}}, ensure_ascii=False))
|
||||
'derived': derived, 'webp': webp,
|
||||
'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB, 'ids': ENEMY_SPINES},
|
||||
'tokenSpines': {'bundles': TOKEN_PACKS, 'sub': TOKEN_SPINE_SUB, 'ids': TOKEN_SPINES}},
|
||||
ensure_ascii=False))
|
||||
return 0
|
||||
if args.webp:
|
||||
return webp_only(Path(args.out), Path(args.manifest), print)
|
||||
@@ -648,8 +823,9 @@ def main():
|
||||
print('No Arknights install found. Pass --game <AssetBundle root>.', file=sys.stderr)
|
||||
return 2
|
||||
jobs = select_jobs(args.only)
|
||||
enemy_spines = wants_sub(args.only, ENEMY_SPINE_SUB)
|
||||
if not jobs and not enemy_spines:
|
||||
enemy_ids = spine_ids(args.only, ENEMY_SPINE_SUB, ENEMY_SPINES)
|
||||
token_ids = spine_ids(args.only, TOKEN_SPINE_SUB, TOKEN_SPINES)
|
||||
if not jobs and not enemy_ids and not token_ids:
|
||||
print(f'--only {args.only}: no job matches', file=sys.stderr)
|
||||
return 2
|
||||
out_root = Path(args.out)
|
||||
@@ -658,8 +834,10 @@ 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)
|
||||
if enemy_ids:
|
||||
total += export_enemy_spines(ab_root, out_root, manifest, print, enemy_ids)
|
||||
if token_ids:
|
||||
total += export_token_spines(ab_root, out_root, manifest, print, token_ids)
|
||||
old = {}
|
||||
if args.only and Path(args.manifest).exists():
|
||||
try:
|
||||
|
||||
+17
-5
@@ -198,20 +198,32 @@ export function checkAssets() {
|
||||
* the 玩法说明 pages are not in the list: step 5 downloads them from the public mirror with the other assets (GitHub
|
||||
* issue #42). Shown by setup and doctor.
|
||||
*/
|
||||
export const LOCAL_ART_FALLBACK = '3D 棋盘改用 2D,部分官方界面图标和灼热/炽焰源石虫模型用替代样式';
|
||||
export const LOCAL_ART_FALLBACK = '3D 棋盘改用 2D,部分官方界面图标、灼热/炽焰源石虫模型和多数自选召唤物模型用替代样式';
|
||||
/** Where a machine without the client gets the local art (docs/DEPLOY.md §6「本地客户端素材」); shown by doctor (setup's row,
|
||||
* printed on every start by scripts/launch.mjs, only points to that section). */
|
||||
export const LOCAL_ART_COPY_HINT = '没有客户端的服务器可以从同一版本的整合包(完整包)复制 public/assets/local 和 data/local-assets.json';
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* has the enemy and token models only the client has (`spine/enemy/*` groups, extract.py ENEMY_SPINES — added after
|
||||
* 0.1.0; `spine/token/*` groups, TOKEN_SPINES — added in 0.2.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;
|
||||
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')) };
|
||||
const has = (prefix) => !!(m && m.groups && Object.keys(m.groups).some((g) => g.startsWith(prefix)));
|
||||
return {
|
||||
manifest: !!m, count, board3d: exists(LOCAL_BOARD_ATLAS), tiles: exists(LOCAL_BOARD_TILES), enemySpines: has('spine/enemy/'),
|
||||
tokenSpines: has('spine/token/'), dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')),
|
||||
};
|
||||
}
|
||||
|
||||
/** What an extraction made by an older extract.py lacks (setup's 本地客户端美术 row; re-extract with --local). */
|
||||
export function localGaps(local) {
|
||||
const gaps = [];
|
||||
if (local && !local.enemySpines) gaps.push('灼热/炽焰源石虫模型');
|
||||
if (local && !local.tokenSpines) gaps.push('自选召唤物模型');
|
||||
return gaps.length ? `,缺少新版的${gaps.join('和')}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -445,7 +457,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.enemySpines ? '' : ',缺少新版的灼热/炽焰源石虫模型'}(重新提取:--local)`);
|
||||
add('ok', '本地客户端美术(可选)', `已提取 ${local.count} 项${local.board3d ? ',3D 棋盘可用' : ''}${localGaps(local)}(重新提取:--local)`);
|
||||
} else if (opts.check) {
|
||||
add('skip', '本地客户端美术(可选)', `检测到 ${client.kind} 客户端,可运行 node tools/setup.mjs --local 提取`);
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user