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

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

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

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"version":1,"hash":"bd5e373accda","generator":"tools/fetch-assets.mjs","stats":{"files":3965,"bytes":259785665,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":531,"bonds":23,"items":59,"bands":40,"skills":317,"ui":546,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated {"version":1,"hash":"dce1fe3af4fb","generator":"tools/fetch-assets.mjs","stats":{"files":3959,"bytes":259726913,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":546,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
+21 -8
View File
@@ -21,6 +21,7 @@ npm run assets # = node tools/vendor.mjs && node tools/fetch-assets.mjs
| `--dry-run` | Print the plan (file and model counts, alias notes) and exit. | | `--dry-run` | Print the plan (file and model counts, alias notes) and exit. |
| `--refresh-index` | Re-download the two upstream indexes: `audio_data.json` and `models_data.json`. | | `--refresh-index` | Re-download the two upstream indexes: `audio_data.json` and `models_data.json`. |
| `--prune` | Delete files under `public/assets/` that the manifest no longer references, for example after a mapping change. Without this flag they are only listed in the report. `public/assets/local/` (written by `tools/local-extract`) is never pruned. | | `--prune` | Delete files under `public/assets/` that the manifest no longer references, for example after a mapping change. Without this flag they are only listed in the report. `public/assets/local/` (written by `tools/local-extract`) is never pruned. |
| `--local-spines` | Rewrite `tools/assets/local-enemy-spines.json` (the metadata of the enemy models only the local client has, see "Enemy aliases") from the models `tools/local-extract/extract.py` extracted to `public/assets/local/spine/enemy/`. Run it after a game update changed them; without it the committed file is used and a differing extraction only gets a warning. |
The script is **idempotent**. A file on disk is kept, not re-downloaded, when any one of these holds: The script is **idempotent**. A file on disk is kept, not re-downloaded, when any one of these holds:
- its size matches the ledger entry from a previous download (`.cache/assets-ledger.json`); - its size matches the ledger entry from a previous download (`.cache/assets-ledger.json`);
@@ -71,7 +72,7 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee
| Operator battle Spine (Front, Back) | fexli/ArknightsResource `spine/{id}/{id}/{Front,Back}/` | `spine/op/{charId}/{front,back}/{stem}.{skel,atlas,png}` | | Operator battle Spine (Front, Back) | fexli/ArknightsResource `spine/{id}/{id}/{Front,Back}/` | `spine/op/{charId}/{front,back}/{stem}.{skel,atlas,png}` |
| Token Spine | fexli: the default model, or else the first skin variant (`spine/{tokenId}/{variant}/Spine/`) | `spine/token/{tokenId}/{stem}.*` | | Token Spine | fexli: the default model, or else the first skin variant (`spine/{tokenId}/{variant}/Spine/`) | `spine/token/{tokenId}/{stem}.*` |
| Enemy Spine (PC build, premultiplied alpha) | isHarryh/Ark-Models `models_enemies/{key}/`, file names from `models_data.json` | `spine/enemy/{enemyId}/{stem}.*` | | Enemy Spine (PC build, premultiplied alpha) | isHarryh/Ark-Models `models_enemies/{key}/`, file names from `models_data.json` | `spine/enemy/{enemyId}/{stem}.*` |
| Enemy Spine that no dump carries (灼热源石虫 / 炽焰源石虫) | the local client, when `tools/local-extract/extract.py` (`ENEMY_SPINES`) has extracted it; not downloaded | `local/spine/enemy/{enemyId}/{stem}.*` | | Enemy Spine that no dump carries (灼热源石虫 / 炽焰源石虫) | the local client only (`tools/local-extract/extract.py ENEMY_SPINES`, optional); never downloaded and never required: an overlay of the web alias (`enemies[id].spineLocal`) | `local/spine/enemy/{enemyId}/{stem}.*` (listed in `data/local-assets.json`) |
| BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` | `audio/bgm/{file}.mp3` | | BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` | `audio/bgm/{file}.mp3` |
| SFX (UI, battle, per unit) | AA2 `voice` `audio/sound_beta_2/**`, mapped from `audio_data.json` banks | `audio/sfx/{same sub-path}.mp3` | | SFX (UI, battle, per unit) | AA2 `voice` `audio/sound_beta_2/**`, mapped from `audio_data.json` banks | `audio/sfx/{same sub-path}.mp3` |
| Fonts: Bender Regular and Light, Novecento Wide | TimWangZi/The-font-of-Arknights | `public/fonts/*.{otf,ttf,woff2}`, `public/fonts/fonts.css` | | Fonts: Bender Regular and Light, Novecento Wide | TimWangZi/The-font-of-Arknights | `public/fonts/*.{otf,ttf,woff2}`, `public/fonts/fonts.css` |
@@ -126,7 +127,10 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
stats: { files, bytes, chars, charsWithBack, enemies, enemiesWithSpine, tokens, tokensWithSpine, stats: { files, bytes, chars, charsWithBack, enemies, enemiesWithSpine, tokens, tokensWithSpine,
spineModels, bonds, items, bands, skills, ui, sfxUnits }, spineModels, bonds, items, bands, skills, ui, sfxUnits },
chars: { [charId]: { avatar, avatarE2?, portrait, portraitE2?, spine: { front: Spine, back?: Spine } } }, chars: { [charId]: { avatar, avatarE2?, portrait, portraitE2?, spine: { front: Spine, back?: Spine } } },
enemies: { [enemyId]: { icon, spine?: Spine, spineAliasOf?: enemyId } }, enemies: { [enemyId]: { icon, spine?: Spine, spineAliasOf?: enemyId,
spineLocal?: { group, skel, atlas, textures, …Spine } } },
// spineLocal: an optional local-client model; file names in a data/local-assets.json group,
// not URLs, and always emitted (independent of the disk) — "Enemy aliases" below
tokens: { [tokenId]: { owner: charId|null, avatar?, spine?: Spine, spineVariant?: string } }, tokens: { [tokenId]: { owner: charId|null, avatar?, spine?: Spine, spineVariant?: string } },
bonds: { [bondId]: url }, // white glyphs; tint in CSS/canvas bonds: { [bondId]: url }, // white glyphs; tint in CSS/canvas
items: { [trapId]: url }, items: { [trapId]: url },
@@ -209,12 +213,21 @@ Other renderer rules from research 07 §5.4–5.5:
- **Model size:** every skeleton is drawn at one `UNIT.modelScale` (render/style.js, 320 skeleton units per tile), which stands for the official standard. The official client also scales each enemy model in its battle prefab: the Graphic / FaceSwitcher / Spine transforms multiply to 0.27 for most enemies and for the operators' battle skins, but not for all of them. For example, 威龙 is 0.16, 妖怪 0.20 and 青铜镜 0.6. The skeletons themselves carry no such scale, because every enemy SkeletonDataAsset uses 0.01. So an enemy is drawn × data/enemies.json `modelScale` (its prefab's product ÷ 0.27, see docs/DATA.md; user playtest #6: 威龙 used to be drawn 1.35× a 妖怪 instead of 1.08×), and its HP bar sits on that model: at its setup-pose bounds' height × the same factors, or, for a skeleton without bounds, at the chibi headroom × `modelScale` (bosses 2.2 tiles). `tools/local-extract/enemy_scales.py` reads the products from a local client, and `tools/build-data.mjs MODEL_SCALES` keeps them. - **Model size:** every skeleton is drawn at one `UNIT.modelScale` (render/style.js, 320 skeleton units per tile), which stands for the official standard. The official client also scales each enemy model in its battle prefab: the Graphic / FaceSwitcher / Spine transforms multiply to 0.27 for most enemies and for the operators' battle skins, but not for all of them. For example, 威龙 is 0.16, 妖怪 0.20 and 青铜镜 0.6. The skeletons themselves carry no such scale, because every enemy SkeletonDataAsset uses 0.01. So an enemy is drawn × data/enemies.json `modelScale` (its prefab's product ÷ 0.27, see docs/DATA.md; user playtest #6: 威龙 used to be drawn 1.35× a 妖怪 instead of 1.08×), and its HP bar sits on that model: at its setup-pose bounds' height × the same factors, or, for a skeleton without bounds, at the chibi headroom × `modelScale` (bosses 2.2 tiles). `tools/local-extract/enemy_scales.py` reads the products from a local client, and `tools/build-data.mjs MODEL_SCALES` keeps them.
- **Enemy aliases:** `enemies[id].spineAliasOf` means the model belongs to another enemy. Two cases: - **Enemy aliases:** `enemies[id].spineAliasOf` means the model belongs to another enemy. Two cases:
- `_2` variants whose official prefab is the base one (鸭爵, 高普尼克, 流泪小子, 圆仔, 假想敌:胄, 假想敌:铳): the base model, as in the game. - `_2` variants whose official prefab is the base one (鸭爵, 高普尼克, 流泪小子, 圆仔, 假想敌:胄, 假想敌:铳): the base model, as in the game.
- an enemy whose own model no dump carries and that was not extracted from a local client: 灼热源石虫 / 炽焰源石虫 - an enemy whose own model no dump carries: 灼热源石虫 / 炽焰源石虫 (`enemy_1305_mhslim` / `_2`) use the plain 源石虫 on
(`enemy_1305_mhslim` / `_2`) fall back to the plain 源石虫 (`plan.mjs ENEMY_SPINE_ALIAS`). Their official skeletons the web (`plan.mjs ENEMY_SPINE_ALIAS`). Their official skeletons only exist in the client's enemy art bundles
are in the client's enemy art bundles (`refs/arts/enm_art_*.ab`); `tools/local-extract/extract.py` writes them to (`refs/arts/enm_art_*.ab`), so they are an optional **overlay**, `enemies[id].spineLocal` = `{ group, skel, atlas,
`public/assets/local/spine/enemy/{enemyId}/` (page textures with their `[alpha]` texture merged in: premultiplied RGB textures, pma, anims, animations, events, hits, bounds }` (file names in the `data/local-assets.json` group
+ A, like Ark-Models), and `npm run assets` then plans that model instead of the alias (`spine.mjs `spine/enemy/{enemyId}`; the rest as a `spine` entry):
findLocalEnemyModels`, `plan.mjs localEnemyModels`; an upstream model still wins). The release bundle carries them. - `tools/local-extract/extract.py` writes the model to `public/assets/local/spine/enemy/{enemyId}/` (page textures
with their `[alpha]` texture merged in: premultiplied RGB + A like Ark-Models; the atlas gets `size:` and
`pma: true`) and lists its files in `data/local-assets.json`.
- The metadata comes from the committed `tools/assets/local-enemy-spines.json` (`fetch-assets --local-spines`
parses the extracted models into it), never from the disk: `data/assets.json` is byte-identical with or without
the extraction, it has no `/assets/local/` URL, and setup / doctor / the manifest tests never miss these files.
- The client (`assets.js spineEntry`, with `assets.local()`, which `createFieldView` awaits with the manifest) draws
the official model when the local manifest lists every file of it; otherwise, or when it fails to load
(`UnitView`: the entry's `fallback`), the web alias. A release bundle carries the models when it is zipped from a
checkout where the extraction ran.
User feedback after 0.1.0 (D3: "所有特殊源石虫的模型全表现为普通源石虫"): the ELEMENT faction spawns up to ten of them a User feedback after 0.1.0 (D3: "所有特殊源石虫的模型全表现为普通源石虫"): the ELEMENT faction spawns up to ten of them a
round. A 2026-10-03 audit of every enemy of `data/enemies.json` (249) against the client's battle prefabs (the round. A 2026-10-03 audit of every enemy of `data/enemies.json` (249) against the client's battle prefabs (the
skeleton each prefab's Spine renderer draws) found no other enemy drawn with another enemy's model; 伊利昂的木驮兽 skeleton each prefab's Spine renderer draws) found no other enemy drawn with another enemy's model; 伊利昂的木驮兽
+39 -5
View File
@@ -11,6 +11,8 @@
// assets.spine.release(entry); // assets.spine.release(entry);
// await assets.local(); // optional local-client art manifest (data/local-assets.json, // await assets.local(); // optional local-client art manifest (data/local-assets.json,
// assets.localUrl('map/autochess', 'TX_autochessi_D') DESIGN §13) → URL or null (never required) // assets.localUrl('map/autochess', 'TX_autochessi_D') DESIGN §13) → URL or null (never required)
// assets.spineEntry('enemy_1305_mhslim') // its official model from the local client once local() listed
// // it (manifest spineLocal), else the web model; `.fallback`
// //
// Every URL helper is also exported as a pure function taking the manifest first (`avatarUrl(manifest, …)`), // Every URL helper is also exported as a pure function taking the manifest first (`avatarUrl(manifest, …)`),
// so it can be unit tested without a browser. Helpers never throw on unknown ids — they return null and the // so it can be unit tested without a browser. Helpers never throw on unknown ids — they return null and the
@@ -116,6 +118,10 @@ export function subProfIconUrl(m, sub) {
* Spine manifest entry for a unit asset id (operator charId, token id, enemy id). `opts.back` asks for the Back * Spine manifest entry for a unit asset id (operator charId, token id, enemy id). `opts.back` asks for the Back
* model (operators only; falls back to Front). Enemy aliases are resolved transparently. Returns the Spine * model (operators only; falls back to Front). Enemy aliases are resolved transparently. Returns the Spine
* object of docs/ASSETS.md or null. * object of docs/ASSETS.md or null.
* `opts.local` (the data/local-assets.json manifest): an enemy whose official model only the local client has
* (`spineLocal`, e.g. 灼热源石虫 — user feedback after 0.1.0, D3) gets that model when the manifest lists every one of
* its files; the returned entry's `fallback` is the web model (aliased), for a load failure (DESIGN §13: local art is
* optional, everything works without it).
*/ */
export function spineEntry(m, id, opts) { export function spineEntry(m, id, opts) {
const s = str(id); const s = str(id);
@@ -129,14 +135,42 @@ export function spineEntry(m, id, opts) {
if (tk) return validSpine(tk.spine) ? tk.spine : null; if (tk) return validSpine(tk.spine) ? tk.spine : null;
const en = get(get(m, 'enemies'), s); const en = get(get(m, 'enemies'), s);
if (en) { if (en) {
if (validSpine(en.spine)) return en.spine; let web = null;
const alias = str(en.spineAliasOf); if (validSpine(en.spine)) web = en.spine;
const al = alias ? get(get(m, 'enemies'), alias) : null; else {
return al && validSpine(al.spine) ? al.spine : null; const alias = str(en.spineAliasOf);
const al = alias ? get(get(m, 'enemies'), alias) : null;
web = al && validSpine(al.spine) ? al.spine : null;
}
return (opts && opts.local && localSpineEntry(en.spineLocal, opts.local, web)) || web;
} }
return null; return null;
} }
const localSpines = new WeakMap(); // spineLocal record → { local, web, entry } (one entry object per manifest pair)
/**
* A manifest `spineLocal` (`{ group, skel, atlas, textures[], pma, anims, … }`, file names in the data/local-assets.json
* group) as a Spine entry with URLs, when `local` lists every file — skeleton and atlas side by side (pixi-spine finds
* the atlas by the skeleton's name); else null. `fallback` = `web`.
*/
export function localSpineEntry(sl, local, web) {
if (!isObj(sl) || !isObj(local)) return null;
const c = localSpines.get(sl);
if (c && c.local === local && c.web === web) return c.entry;
const g = str(sl.group);
const url = (name) => (g ? localAssetUrl(local, g, name) : null);
const skel = url(sl.skel), atlas = url(sl.atlas);
const textures = Array.isArray(sl.textures) ? sl.textures.map(url) : [];
let entry = null;
if (skel && atlas && skel.replace(/\.skel$/, '.atlas') === atlas && textures.length && textures.every(Boolean)) {
entry = { skel, atlas, textures, pma: !!sl.pma, anims: sl.anims, animations: sl.animations, events: sl.events, hits: sl.hits, bounds: sl.bounds, local: true, fallback: web || null };
if (!validSpine(entry)) entry = null;
}
localSpines.set(sl, { local, web, entry });
return entry;
}
/** Whether an operator/token/enemy has a Back model. */ /** Whether an operator/token/enemy has a Back model. */
export function hasBackSpine(m, id) { export function hasBackSpine(m, id) {
const ch = get(get(m, 'chars'), str(id) || '') || get(get(m, 'chars'), baseCharId(id) || ''); const ch = get(get(m, 'chars'), str(id) || '') || get(get(m, 'chars'), baseCharId(id) || '');
@@ -651,7 +685,7 @@ export function createAssets(options) {
subProfIcon: (s) => subProfIconUrl(m(), s), subProfIcon: (s) => subProfIconUrl(m(), s),
ui: (name) => uiUrl(m(), name), ui: (name) => uiUrl(m(), name),
picture: (id) => unitPictureUrl(m(), id), picture: (id) => unitPictureUrl(m(), id),
spineEntry: (id, o) => spineEntry(m(), id, o), spineEntry: (id, o) => spineEntry(m(), id, localManifest ? { ...o, local: localManifest } : o),
hasBack: (id) => hasBackSpine(m(), id), hasBack: (id) => hasBackSpine(m(), id),
audio: { audio: {
bgm: (kind) => bgmEntry(m(), kind), bgm: (kind) => bgmEntry(m(), kind),
+8 -3
View File
@@ -75,8 +75,10 @@
// three.js PerspectiveCamera is synced from it every change. Missing art / three / WebGL2, a failed init or a lost // three.js PerspectiveCamera is synced from it every change. Missing art / three / WebGL2, a failed init or a lost
// context fall back to the 2D atlas board. `opts.board`: 'auto' (default) | '3d' | '2d'; `?board=2d|3d` in the URL // context fall back to the 2D atlas board. `opts.board`: 'auto' (default) | '3d' | '2d'; `?board=2d|3d` in the URL
// overrides (dev; '3d' also accepts a slow / software GPU). stats().board3d → { on, calls, triangles, cpuMs }. // overrides (dev; '3d' also accepts a slow / software GPU). stats().board3d → { on, calls, triangles, cpuMs }.
// three.js is only downloaded when the local-art manifest lists the board atlas. The built 3D area, the drawn 2D rows // three.js is only downloaded when the local-art manifest lists the board atlas. That manifest is awaited with the asset
// and the lit rect follow `viewKind` (a 'prep' camera on the boss rows = the Final Assault prep = the boss field). // manifest (≤ 4 s), so an enemy whose model only the local client has draws it (assets.js spineEntry). The built 3D
// area, the drawn 2D rows and the lit rect follow `viewKind` (a 'prep' camera on the boss rows = the Final Assault
// prep = the boss field).
// Battle device boxes (`ctx.createBox`) follow the board layer (switchableBox), so a 3D ⇄ 2D switch keeps crates. // Battle device boxes (`ctx.createBox`) follow the board layer (switchableBox), so a 3D ⇄ 2D switch keeps crates.
// Crowds and clipped Spine skeletons render through the shared impostor atlas (render/impostor.js), flushed once per // Crowds and clipped Spine skeletons render through the shared impostor atlas (render/impostor.js), flushed once per
// frame before the main pass. // frame before the main pass.
@@ -357,7 +359,10 @@ export async function createFieldView(host, options = {}) {
const artListed = want3d ? boardArtListed(assets).catch(() => false) : Promise.resolve(false); const artListed = want3d ? boardArtListed(assets).catch(() => false) : Promise.resolve(false);
const threePromise = artListed.then((ok) => (ok ? loadThree() : null)); const threePromise = artListed.then((ok) => (ok ? loadThree() : null));
const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null)); const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null));
await withTimeout(Promise.resolve(assets.ready ? assets.ready() : null).catch(() => {}), 4000); // the manifest, and the optional local-art manifest in parallel: unit views pick an enemy's local-client model by it
// (assets.js spineEntry, DESIGN §13 — 灼热源石虫 / 炽焰源石虫); absent or slow, they draw the web models
await withTimeout(Promise.all([assets.ready ? assets.ready() : null, assets.local ? assets.local() : null]
.map((p) => Promise.resolve(p).catch(() => {}))), 4000);
// web fonts for the bitmap damage numbers / tier chips (never block long) // web fonts for the bitmap damage numbers / tier chips (never block long)
try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500); } catch { /* ignore */ } try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500); } catch { /* ignore */ }
+20 -5
View File
@@ -4,7 +4,9 @@
// the avatar-in-rarity-diamond fallback) + HUD container (bar layer: HP bar with delayed "ghost" damage, SP bar // the avatar-in-rarity-diamond fallback) + HUD container (bar layer: HP bar with delayed "ghost" damage, SP bar
// with ready glow / draining skill bar, tier chip, status icons, blocked marker). The fallback shows at once and // with ready glow / draining skill bar, tier chip, status icons, blocked marker). The fallback shows at once and
// cross-fades to the Spine model when it has loaded; missing or failed models keep the fallback forever (the // cross-fades to the Spine model when it has loaded; missing or failed models keep the fallback forever (the
// game never blocks on Spine). Every bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw calls. // game never blocks on Spine) — except an optional local-client model (assets.js spineEntry `fallback`, DESIGN §13:
// 灼热源石虫 / 炽焰源石虫), which falls back to the web model first. Every bar is a tinted Texture.WHITE sprite, so
// HUDs batch into few draw calls.
// //
// Placement: feet anchored at world (x, y, z); scale = camera px-per-tile at the feet × UNIT.modelScale, so // Placement: feet anchored at world (x, y, z); scale = camera px-per-tile at the feet × UNIT.modelScale, so
// chibis shrink with distance like the original. An enemy's model is also scaled by its official prefab factor // chibis shrink with distance like the original. An enemy's model is also scaled by its official prefab factor
@@ -308,10 +310,19 @@ export class UnitView {
const back = this._wantsBack(); const back = this._wantsBack();
const entry = id ? a.spineEntry(id, { back }) : null; const entry = id ? a.spineEntry(id, { back }) : null;
if (!entry || this.ctx.settings?.quality === 'low' && this.isEnemy && !this.isBoss && this.ctx.crowded?.()) return; if (!entry || this.ctx.settings?.quality === 'low' && this.isEnemy && !this.isBoss && this.ctx.crowded?.()) return;
// every acquire is paired with exactly one release: a superseded / failed / post-destroy load releases its own
// entry; the displayed model's entry (`_actorEntry`) is released when that model is replaced or destroyed
this.entry = entry;
this.entryBack = back; this.entryBack = back;
this._acquireSpine(entry, id);
}
/**
* Load `entry` and show it. Every acquire is paired with exactly one release: a superseded / failed / post-destroy
* load releases its own entry; the displayed model's entry (`_actorEntry`) is released when that model is replaced or
* destroyed. A model that fails to load keeps the fallback diamond — unless the entry names a `fallback` (an optional
* local-client model, assets.js spineEntry: DESIGN §13), which is loaded in its place.
*/
_acquireSpine(entry, id) {
const a = this.ctx.assets;
this.entry = entry;
const req = this._spineReq = (this._spineReq || 0) + 1; const req = this._spineReq = (this._spineReq || 0) + 1;
a.spine.acquire(entry).then((data) => { a.spine.acquire(entry).then((data) => {
if (this.destroyed || req !== this._spineReq) { this._releaseEntry(entry); return; } if (this.destroyed || req !== this._spineReq) { this._releaseEntry(entry); return; }
@@ -345,7 +356,11 @@ export class UnitView {
if (this.flags & UF.SKILL) this.actor.setSkill(true); if (this.flags & UF.SKILL) this.actor.setSkill(true);
this.actor.setBase(this._baseFromAnim()); this.actor.setBase(this._baseFromAnim());
} }
}, () => { this._releaseEntry(entry); /* keep the fallback */ }); }, () => {
this._releaseEntry(entry);
if (entry.fallback && !this.destroyed && req === this._spineReq) this._acquireSpine(entry.fallback, id);
// else keep the fallback diamond
});
} }
_formSpec() { _formSpec() {
+10 -1
View File
@@ -2,7 +2,8 @@
// data/assets.json. The pure helpers (animation-role resolver, atlas // data/assets.json. The pure helpers (animation-role resolver, atlas
// normalizer, PNG/WOFF2/audio helpers, plan id sets) are always tested; the // normalizer, PNG/WOFF2/audio helpers, plan id sets) are always tested; the
// on-disk checks run only when public/assets exists (it is git-ignored and // on-disk checks run only when public/assets exists (it is git-ignored and
// produced by `npm run assets`). // produced by `npm run assets`); the optional local-client enemy models
// (spineLocal, tools/local-extract) only when data/local-assets.json lists them.
import { test, describe } from 'node:test'; import { test, describe } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -19,6 +20,7 @@ import { assetToPath, pickUnitSfx, indexAudio } from '../tools/assets/audio.mjs'
import { mirrorUrl, safeName, encodePath } from '../tools/assets/sources.mjs'; import { mirrorUrl, safeName, encodePath } from '../tools/assets/sources.mjs';
import { collectEnemyIds, skillIndicesByChar } from '../tools/assets/plan.mjs'; import { collectEnemyIds, skillIndicesByChar } from '../tools/assets/plan.mjs';
import { resolveTemplate, collectLeaves } from '../tools/assets/manifest.mjs'; import { resolveTemplate, collectLeaves } from '../tools/assets/manifest.mjs';
import { spineEntry } from '../public/js/assets.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PUBLIC = join(ROOT, 'public'); const PUBLIC = join(ROOT, 'public');
@@ -583,6 +585,13 @@ describe('generated manifest data/assets.json', () => {
for (const e of Object.values(manifest.enemies)) if (e.spine) models.set(e.spine.skel, e.spine); for (const e of Object.values(manifest.enemies)) if (e.spine) models.set(e.spine.skel, e.spine);
for (const t of Object.values(manifest.tokens)) if (t.spine) models.set(t.spine.skel, t.spine); for (const t of Object.values(manifest.tokens)) if (t.spine) models.set(t.spine.skel, t.spine);
assert.ok(models.size > 400); assert.ok(models.size > 400);
// the official enemy models of the local client, as the client resolves them (DESIGN §13: only when listed)
const localPath = join(ROOT, 'data', 'local-assets.json');
const local = existsSync(localPath) ? JSON.parse(readFileSync(localPath, 'utf8')) : null;
for (const id of Object.keys(manifest.enemies)) {
const s = local ? spineEntry(manifest, id, { local }) : null;
if (s?.local && [s.skel, s.atlas, ...s.textures].every((u) => existsSync(join(PUBLIC, u)))) models.set(s.skel, s);
}
for (const s of models.values()) { for (const s of models.values()) {
const dir = dirname(s.atlas); const dir = dirname(s.atlas);
// pixi-spine finds the atlas by swapping the extension of the skel URL // pixi-spine finds the atlas by swapping the extension of the skel URL
+207 -45
View File
@@ -1,59 +1,151 @@
// test/feedback1d-models.test.js — community report D3 after 0.1.0 ("所有特殊源石虫的模型全表现为普通源石虫"): 灼热源石虫 / // test/feedback1d-models.test.js — community report D3 after 0.1.0 ("所有特殊源石虫的模型全表现为普通源石虫"): 灼热源石虫 /
// 炽焰源石虫 (enemy_1305_mhslim / _2, the ELEMENT faction's slugs — up to 10 a round) were drawn with the plain 源石虫 // 炽焰源石虫 (enemy_1305_mhslim / _2, the ELEMENT faction's slugs — up to 10 a round) were drawn with the plain 源石虫
// skeleton because no community dump carries their models (Ark-Models lists them with an empty assetList) and the // skeleton because no community dump carries their models (Ark-Models lists them with an empty assetList), so the
// asset plan aliased them to enemy_1007_slime. Their official skeletons come from the local client now // asset plan aliased them to enemy_1007_slime. Their official skeletons only exist in the local client
// (tools/local-extract/extract.py ENEMY_SPINES → public/assets/local/spine/enemy/<id>/) and tools/fetch-assets.mjs // (tools/local-extract/extract.py ENEMY_SPINES → public/assets/local/spine/enemy/<id>/, optional and git-ignored), so
// plans such a model in place of the alias (tools/assets/spine.mjs findLocalEnemyModels, plan.mjs localEnemyModels). // they are an OVERLAY: data/assets.json keeps the alias as the web model (`spine` + `spineAliasOf`) and adds
// `spineLocal` (file names in the data/local-assets.json group + the parsed metadata, from the committed
// tools/assets/local-enemy-spines.json — never from the disk, so the manifest is the same with or without the
// extraction). The client (assets.js spineEntry) draws the official model when data/local-assets.json lists every one
// of its files and falls back to the web model when it fails to load (DESIGN §13: local art is optional).
// 高能 / 冰爆 / 简饲源石虫 and “庞贝” always had their own models (checked in headless Chrome). // 高能 / 冰爆 / 简饲源石虫 and “庞贝” always had their own models (checked in headless Chrome).
import { test, describe } from 'node:test'; import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { existsSync, readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { existsSync, readFileSync, mkdtempSync, mkdirSync, writeFileSync, copyFileSync, rmSync, statSync } from 'node:fs';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { buildPlan } from '../tools/assets/plan.mjs'; import { buildPlan } from '../tools/assets/plan.mjs';
import { findLocalEnemyModels } from '../tools/assets/spine.mjs'; import { resolveTemplate, collectLeaves } from '../tools/assets/manifest.mjs';
import { findLocalEnemyModels, localEnemySpineMeta, loadLocalEnemySpines, LOCAL_ENEMY_SPINES_FILE, localEnemySpineGroup } from '../tools/assets/spine.mjs';
import { indexAudio } from '../tools/assets/audio.mjs'; import { indexAudio } from '../tools/assets/audio.mjs';
import { createAssets, spineEntry, validSpine } from '../public/js/assets.js';
import { installFakePixi, fakeViewCtx } from './render/fakepixi.js';
import { presetCamera } from '../public/js/render/projection.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ASSETS = path.join(ROOT, 'public/assets');
const readJson = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8')); const readJson = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8'));
const SLUGS = ['enemy_1305_mhslim', 'enemy_1305_mhslim_2']; const SLUGS = ['enemy_1305_mhslim', 'enemy_1305_mhslim_2'];
const COMMITTED = readJson(LOCAL_ENEMY_SPINES_FILE).models;
const MANIFEST = readJson('data/assets.json');
/** The real research inputs; Ark-Models carries the plain slug only (its index lists the special ones empty). */ /** The real research inputs; Ark-Models carries the plain slug only (its index lists the special ones empty). */
function plan(localEnemyModels) { function plan(localEnemySpines) {
const modelsData = { data: { '1007_slime': { assetList: { '.skel': 'enemy_1007_slime.skel', '.atlas': 'enemy_1007_slime.atlas', '.png': 'enemy_1007_slime.png' } }, const modelsData = { data: { '1007_slime': { assetList: { '.skel': 'enemy_1007_slime.skel', '.atlas': 'enemy_1007_slime.atlas', '.png': 'enemy_1007_slime.png' } },
'1305_mhslim': { assetList: {} }, '1305_mhslim_2': { assetList: {} } } }; '1305_mhslim': { assetList: {} }, '1305_mhslim_2': { assetList: {} } } };
return buildPlan({ return buildPlan({
assets07: readJson('docs/research/07-assets.json'), ops03: readJson('docs/research/03-operators.json'), assets07: readJson('docs/research/07-assets.json'), ops03: readJson('docs/research/03-operators.json'),
enemies05: readJson('docs/research/05-enemies.json'), maps05: readJson('docs/research/05-maps.json'), enemies05: readJson('docs/research/05-enemies.json'), maps05: readJson('docs/research/05-maps.json'),
audio: indexAudio({}), modelsData, extraEnemyIds: SLUGS, localEnemyModels, audio: indexAudio({}), modelsData, extraEnemyIds: SLUGS, localEnemySpines,
}); });
} }
describe('D3: special Originium slugs get their own official models', () => { /** A data/local-assets.json listing the extracted files of `ids` (extract.py's group layout). */
test('a local-client model is planned instead of the alias to the plain 源石虫', () => { function localManifest(ids, drop = null) {
const groups = {};
for (const id of ids) {
const g = groups[localEnemySpineGroup(id)] = {};
const m = COMMITTED[id];
for (const f of [m.skel, m.atlas, ...m.textures]) if (f !== drop) g[f] = { path: `/assets/local/spine/enemy/${id}/${f}`, kind: f.endsWith('.png') ? 'Texture2D' : 'TextAsset' };
}
return { version: 1, source: 'local-client', groups };
}
describe('D3: the official slug models are an optional overlay of the web alias', () => {
test('plan: the web model stays the 源石虫 alias; spineLocal comes from the committed metadata as it is', () => {
const before = plan(undefined); const before = plan(undefined);
for (const id of SLUGS) assert.equal(before.template.enemies[id].spineAliasOf, 'enemy_1007_slime', `${id}: no local model → the alias stays`);
const local = {};
for (const id of SLUGS) local[id] = { dir: `local/spine/enemy/${id}/`, skel: `local/spine/enemy/${id}/${id}.skel`, atlas: `local/spine/enemy/${id}/${id}.atlas`, pngs: [`local/spine/enemy/${id}/${id}.png`] };
const p = plan(local);
for (const id of SLUGS) { for (const id of SLUGS) {
const e = p.template.enemies[id]; assert.equal(before.template.enemies[id].spineAliasOf, 'enemy_1007_slime');
assert.equal(e.spineAliasOf, undefined, `${id} is not drawn with another enemy's model`); assert.equal(before.template.enemies[id].spineLocal, undefined, 'no metadata → no overlay');
assert.deepEqual(e.spine, { model: `enemy:${id}` });
const m = p.models.get(`enemy:${id}`);
assert.equal(m.skel.rel, `local/spine/enemy/${id}/${id}.skel`);
assert.equal(m.atlas.rel, `local/spine/enemy/${id}/${id}.atlas`);
assert.deepEqual(m.pngs.map((x) => x.rel), [`local/spine/enemy/${id}/${id}.png`]);
assert.equal(m.pma, true, 'premultiplied like the Ark-Models enemies (atlas gets pma: true)');
assert.deepEqual(m.skel.urls, [], 'on disk only: nothing to download');
} }
assert.ok(!p.notes.some((n) => /enemy_1305_mhslim.*aliased/.test(n)), 'no alias note'); const p = plan(COMMITTED);
// an upstream model still wins over a local copy const slime = { skel: '/assets/spine/enemy/enemy_1007_slime/enemy_1007_slime.skel', atlas: '/assets/spine/enemy/enemy_1007_slime/enemy_1007_slime.atlas', textures: [], anims: {} };
const up = plan({ enemy_1007_slime: { dir: 'local/spine/enemy/enemy_1007_slime/', skel: 'local/spine/enemy/enemy_1007_slime/x.skel', atlas: 'local/spine/enemy/enemy_1007_slime/x.atlas', pngs: ['local/spine/enemy/enemy_1007_slime/x.png'] } }); const tmp = mkdtempSync(path.join(tmpdir(), 'sp-plan-'));
assert.equal(up.models.get('enemy:enemy_1007_slime').skel.rel, 'spine/enemy/enemy_1007_slime/enemy_1007_slime.skel'); try {
const { value } = resolveTemplate({ enemies: Object.fromEntries(SLUGS.map((id) => [id, p.template.enemies[id]])) },
{ root: tmp, spine: new Map([['enemy:enemy_1007_slime', slime]]) });
for (const id of SLUGS) {
const e = value.enemies[id];
assert.equal(e.spineAliasOf, 'enemy_1007_slime', `${id}: the web model (works without the local client)`);
assert.equal(e.spine, slime);
assert.deepEqual(e.spineLocal, { group: `spine/enemy/${id}`, ...COMMITTED[id] }, `${id}: the overlay, null fields kept`);
assert.equal(e.spineLocal.anims.attack.begin, null);
}
} finally { rmSync(tmp, { recursive: true, force: true }); }
assert.ok(!collectLeaves(p.template).some((l) => /spineLocal/.test(l.path)), 'the overlay is no file to download');
assert.ok(p.notes.some((n) => /enemy_1305_mhslim: official Spine from the local client/.test(n)));
});
test('the committed metadata: both ELEMENT slugs, file names of their extract.py group, roles resolved', () => {
assert.deepEqual(Object.keys(COMMITTED).sort(), SLUGS);
for (const id of SLUGS) {
const m = COMMITTED[id];
assert.equal(m.skel, `${id}.skel`);
assert.equal(m.atlas, `${id}.atlas`, 'next to the skeleton (pixi-spine finds the atlas by its name)');
assert.deepEqual(m.textures, [`${id}.png`]);
assert.equal(m.pma, true, 'premultiplied pages (extract.py merge_alpha)');
for (const role of ['idle', 'die']) assert.ok(m.anims[role] in m.animations, `${id} ${role}`);
assert.ok(m.anims.move.loop in m.animations && m.anims.attack.loop in m.animations, `${id} move / attack`);
assert.ok(m.hits.Attack?.length, `${id}: the OnAttack frame`);
}
});
test('data/assets.json never depends on the local extraction (no /assets/local/ URL; the web model always there)', () => {
const urls = [];
const walk = (n) => { if (typeof n === 'string') { if (n.includes('/assets/local/')) urls.push(n); } else if (n && typeof n === 'object') Object.values(n).forEach(walk); };
walk(MANIFEST);
assert.deepEqual(urls, [], 'setup / doctor / the manifest tests count every /assets/ URL as required');
for (const [id, e] of Object.entries(MANIFEST.enemies)) {
if (!e.spineLocal) continue;
assert.ok(SLUGS.includes(id), id);
assert.deepEqual(e.spineLocal, { group: `spine/enemy/${id}`, ...COMMITTED[id] }, `${id}: the committed metadata`);
assert.ok(validSpine(spineEntry(MANIFEST, id)), `${id}: a web model without the local client`);
assert.equal(MANIFEST.enemies[id].spineAliasOf, 'enemy_1007_slime');
assert.deepEqual(spineEntry(MANIFEST, id), MANIFEST.enemies.enemy_1007_slime.spine, `${id}: the plain 源石虫 on the web`);
}
for (const id of SLUGS) assert.ok(MANIFEST.enemies[id].spineLocal, `${id} has its overlay`);
});
test('the committed metadata is what the extracted models parse to', { skip: !SLUGS.every((id) => existsSync(path.join(ASSETS, 'local/spine/enemy', id))) && 'enemy models not extracted (tools/local-extract)' }, async () => {
const found = await findLocalEnemyModels(ASSETS);
const { meta, problems } = await localEnemySpineMeta(ASSETS, found);
assert.deepEqual(problems, []);
for (const id of SLUGS) assert.deepEqual(meta[id], COMMITTED[id], `${id}: re-run node tools/fetch-assets.mjs --local-spines`);
});
test('localEnemySpineMeta parses an extracted model like the pipeline and writes nothing', { skip: !existsSync(path.join(ASSETS, 'spine/enemy/enemy_1007_slime')) && 'public/assets not downloaded' }, async () => {
// the plain slug's fetched files stand in for an extraction (same layout: <id>.skel / .atlas / page PNGs)
const dir = mkdtempSync(path.join(tmpdir(), 'sp-meta-'));
try {
const src = path.join(ASSETS, 'spine/enemy/enemy_1007_slime');
const dst = path.join(dir, 'local/spine/enemy/enemy_9999_slime');
mkdirSync(dst, { recursive: true });
for (const f of ['enemy_1007_slime.skel', 'enemy_1007_slime.atlas', 'enemy_1007_slime.png']) copyFileSync(path.join(src, f), path.join(dst, f));
const atlasBefore = readFileSync(path.join(dst, 'enemy_1007_slime.atlas'));
const mtime = statSync(path.join(dst, 'enemy_1007_slime.atlas')).mtimeMs;
const { meta, problems } = await localEnemySpineMeta(dir, await findLocalEnemyModels(dir));
assert.deepEqual(problems, []);
const web = MANIFEST.enemies.enemy_1007_slime.spine;
assert.deepEqual(meta.enemy_9999_slime, {
skel: 'enemy_1007_slime.skel', atlas: 'enemy_1007_slime.atlas', textures: ['enemy_1007_slime.png'], pma: true,
anims: web.anims, animations: web.animations, events: web.events, hits: web.hits, bounds: web.bounds,
});
assert.ok(readFileSync(path.join(dst, 'enemy_1007_slime.atlas')).equals(atlasBefore), 'read only');
assert.equal(statSync(path.join(dst, 'enemy_1007_slime.atlas')).mtimeMs, mtime);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('loadLocalEnemySpines: the models of the committed file, {} when it is missing or bad', async () => {
assert.deepEqual(await loadLocalEnemySpines(path.join(ROOT, LOCAL_ENEMY_SPINES_FILE)), COMMITTED);
assert.deepEqual(await loadLocalEnemySpines(path.join(ROOT, 'no-such-file.json')), {});
const dir = mkdtempSync(path.join(tmpdir(), 'sp-lse-'));
try {
writeFileSync(path.join(dir, 'a.json'), '{"models":[1]}');
assert.deepEqual(await loadLocalEnemySpines(path.join(dir, 'a.json')), {});
} finally { rmSync(dir, { recursive: true, force: true }); }
}); });
test('findLocalEnemyModels: skeleton + same-stem atlas + page PNGs under local/spine/enemy/<id>/', async () => { test('findLocalEnemyModels: skeleton + same-stem atlas + page PNGs under local/spine/enemy/<id>/', async () => {
@@ -78,21 +170,74 @@ describe('D3: special Originium slugs get their own official models', () => {
assert.deepEqual(await findLocalEnemyModels(path.join(dir, 'missing')), {}); assert.deepEqual(await findLocalEnemyModels(path.join(dir, 'missing')), {});
} finally { rmSync(dir, { recursive: true, force: true }); } } finally { rmSync(dir, { recursive: true, force: true }); }
}); });
});
test('the generated manifest never aliases an enemy whose own local model is on disk', { skip: !existsSync(path.join(ROOT, 'public/assets')) && 'public/assets not downloaded' }, () => { describe('D3 client: the official model only when data/local-assets.json lists it', () => {
const m = readJson('data/assets.json'); const webOf = (id) => MANIFEST.enemies[id].spine; // the plain 源石虫's model (spineAliasOf enemy_1007_slime)
for (const [id, e] of Object.entries(m.enemies)) {
if (!e.spineAliasOf) continue; test('assets.js spineEntry: every file listed → the official model (web fallback); anything missing → the web alias', () => {
assert.ok(!existsSync(path.join(ROOT, 'public/assets/local/spine/enemy', id)), `${id}: aliased to ${e.spineAliasOf} although its own model was extracted — re-run npm run assets`); const local = localManifest(SLUGS);
}
for (const id of SLUGS) { for (const id of SLUGS) {
if (!existsSync(path.join(ROOT, 'public/assets/local/spine/enemy', id))) continue; const web = webOf(id);
const sp = m.enemies[id].spine; const e = spineEntry(MANIFEST, id, { local });
assert.ok(sp && sp.skel.endsWith(`/${id}/${id}.skel`), `${id} drawn with its own skeleton (${sp && sp.skel})`); assert.equal(e.skel, `/assets/local/spine/enemy/${id}/${id}.skel`);
assert.equal(sp.pma, true); assert.equal(e.atlas, `/assets/local/spine/enemy/${id}/${id}.atlas`);
for (const u of [sp.skel, sp.atlas, ...sp.textures]) assert.ok(existsSync(path.join(ROOT, 'public', u)), u); assert.deepEqual(e.textures, [`/assets/local/spine/enemy/${id}/${id}.png`]);
assert.ok(sp.anims.idle && sp.anims.move && sp.anims.attack && sp.anims.die, `${id}: roles resolved`); assert.equal(e.pma, true);
assert.deepEqual(e.anims, COMMITTED[id].anims);
assert.deepEqual(e.hits, COMMITTED[id].hits);
assert.equal(e.fallback, web, 'the plain 源石虫 if the official model fails to load');
assert.equal(spineEntry(MANIFEST, id, { local }), e, 'one entry object per manifest pair');
assert.equal(spineEntry(MANIFEST, id), web, 'no local manifest → the web model');
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([]) }), web, 'not extracted → the web model');
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([id], `${id}.png`) }), web, 'a page missing → the web model');
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([id], `${id}.atlas`) }), web, 'the atlas missing → the web model');
} }
assert.equal(spineEntry(MANIFEST, 'enemy_1007_slime', { local }), webOf('enemy_1007_slime'), 'other enemies unchanged');
// the store hands the local manifest over once local() has it
const store = createAssets({ manifest: MANIFEST, localManifest: local });
assert.equal(store.spineEntry(SLUGS[0]).skel, `/assets/local/spine/enemy/${SLUGS[0]}/${SLUGS[0]}.skel`);
assert.equal(createAssets({ manifest: MANIFEST }).spineEntry(SLUGS[0]), webOf(SLUGS[0]), 'before / without local(): the web model');
});
test('render/app.js waits for the local manifest with the asset manifest before building unit views', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(src, /await withTimeout\(Promise\.all\(\[assets\.ready \? assets\.ready\(\) : null, assets\.local \? assets\.local\(\) : null\]/);
});
describe('UnitView', () => {
let fake, UnitView;
before(async () => { fake = installFakePixi(); ({ UnitView } = await import('../public/js/render/units.js')); });
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const info = (id) => ({ id: 3, side: 'enemy', kind: 'enemy', defId: id, spine: id, tier: 1, x: 6, y: 10, maxHp: 1000 });
test('an official model that fails to load falls back to the web model (never the icon diamond)', async () => {
const local = localManifest(SLUGS);
const asked = [], released = [];
const store = (fail) => ({
picture: () => null, image: async () => null,
spineEntry: (id) => spineEntry(MANIFEST, id, { local }),
spine: {
acquire: async (e) => { asked.push(e.skel); if (fail(e)) throw new Error('404'); return { animations: Object.keys(e.animations).map((name) => ({ name })) }; },
release: (e) => released.push(e.skel),
},
});
const id = SLUGS[0];
const web = webOf(id);
const ok = new UnitView(fakeViewCtx(fake.P, { assets: store(() => false), cam }), info(id));
await tick(); await tick();
assert.equal(ok.actor?.entry.skel, `/assets/local/spine/enemy/${id}/${id}.skel`, 'the official model');
assert.equal(ok.actor.entry.anims.move.loop, 'Move', 'with its own clips');
asked.length = 0;
const bad = new UnitView(fakeViewCtx(fake.P, { assets: store((e) => e.local), cam }), info(id));
await tick(); await tick(); await tick();
assert.deepEqual(asked, [`/assets/local/spine/enemy/${id}/${id}.skel`, web.skel], 'the official model, then the web one');
assert.ok(released.includes(`/assets/local/spine/enemy/${id}/${id}.skel`), 'the failed acquire is released');
assert.equal(bad.actor?.entry, web, 'drawn with the web model');
assert.equal(bad.entry, web);
});
}); });
}); });
@@ -100,18 +245,35 @@ const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).sta
describe('D3: tools/local-extract/extract.py enemy Spine job (no UnityPy needed)', { skip: !PY && 'no python3' }, () => { describe('D3: tools/local-extract/extract.py enemy Spine job (no UnityPy needed)', { skip: !PY && 'no python3' }, () => {
const TOOL = path.join(ROOT, 'tools/local-extract'); const TOOL = path.join(ROOT, 'tools/local-extract');
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' }; const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
const py = (body) => {
const o = spawnSync(PY, ['-c', `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport extract as e\n${body}`], { encoding: 'utf8', env: ENV });
assert.equal(o.status, 0, o.stderr);
return JSON.parse(o.stdout);
};
test('--print-jobs lists the enemy models; container matching and --only selection', () => { test('--print-jobs lists the enemy models; container matching and --only selection', () => {
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV }); const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr); assert.equal(r.status, 0, r.stderr);
assert.deepEqual(JSON.parse(r.stdout).enemySpines, { bundles: 'refs/arts/enm_art_*.ab', sub: 'spine/enemy', ids: SLUGS }); assert.deepEqual(JSON.parse(r.stdout).enemySpines, { bundles: 'refs/arts/enm_art_*.ab', sub: 'spine/enemy', ids: SLUGS });
const src = `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport extract as e\nW = set(e.ENEMY_SPINES)\n` const out = py(`W = set(e.ENEMY_SPINES)\n`
+ `print(json.dumps([e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1305_mhslim/2/enemy_1305_mhslim_2_SkeletonData.asset', W),` + `print(json.dumps([e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1305_mhslim/2/enemy_1305_mhslim_2_SkeletonData.asset', W),`
+ ` e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1305_mhslim/1/enemy_1305_mhslim_SkeletonData.asset', W),` + ` e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1305_mhslim/1/enemy_1305_mhslim_SkeletonData.asset', W),`
+ ` e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1007_slime/enemy_1007_slime_SkeletonData.asset', W),` + ` e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1007_slime/enemy_1007_slime_SkeletonData.asset', W),`
+ ` e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1305_mhslim/1/enemy_1305_mhslim_Material.mat', W),` + ` e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1305_mhslim/1/enemy_1305_mhslim_Material.mat', W),`
+ ` [e.wants_sub(o, 'spine/enemy') for o in ([], ['spine'], ['spine/enemy'], ['spine/enemy/enemy_1305_mhslim'], ['spine/token_x'], ['map'])]]))`; + ` [e.wants_sub(o, 'spine/enemy') for o in ([], ['spine'], ['spine/enemy'], ['spine/enemy/enemy_1305_mhslim'], ['spine/token_x'], ['map'])]]))`);
const o = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV }); assert.deepEqual(out, ['enemy_1305_mhslim_2', 'enemy_1305_mhslim', null, null, [true, true, true, true, false, false]]);
assert.equal(o.status, 0, o.stderr); });
assert.deepEqual(JSON.parse(o.stdout), ['enemy_1305_mhslim_2', 'enemy_1305_mhslim', null, null, [true, true, true, true, false, false]]);
test('normalize_atlas: the real page size and pma: true, like the fetched enemy atlases (the client loads it as is)', async () => {
const { normalizeAtlas } = await import('../tools/assets/atlas.mjs');
// the client's atlas text of 灼热源石虫 (leading blank line, size, no pma), shortened
const raw = '\nenemy_1305_mhslim.png\nsize: 256,256\nformat: RGBA8888\nfilter: Linear,Linear\nrepeat: none\nC_Body_1\n rotate: false\n xy: 2, 159\n size: 55, 64\n orig: 55, 64\n offset: 0, 0\n index: -1\n';
const sized = py(`print(json.dumps(e.normalize_atlas(${JSON.stringify(raw)}, {'enemy_1305_mhslim.png': (256, 256)})))`);
assert.equal(sized, normalizeAtlas(raw, { pageSize: () => ({ width: 256, height: 256 }), pma: true }).text, 'the same text as tools/assets/atlas.mjs');
assert.match(sized, /repeat: none\npma: true\nC_Body_1\n {2}rotate: false\n {2}xy: 2, 159\n {2}size: 55, 64/, 'region fields untouched');
const noSize = raw.replace('size: 256,256\n', '');
const fixed = py(`print(json.dumps(e.normalize_atlas(${JSON.stringify(noSize)}, {'enemy_1305_mhslim.png': (512, 256)})))`);
assert.match(fixed, /\nenemy_1305_mhslim\.png\nsize: 512,256\nformat: RGBA8888/);
assert.equal(py(`print(json.dumps(e.normalize_atlas(${JSON.stringify(sized)}, {'enemy_1305_mhslim.png': (256, 256)})))`), sized, 'idempotent');
}); });
}); });
+113
View File
@@ -0,0 +1,113 @@
{
"about": "Spine metadata of the enemy models only the local client has (tools/local-extract/extract.py ENEMY_SPINES); data/assets.json enemies[id].spineLocal. Written by node tools/fetch-assets.mjs --local-spines (docs/ASSETS.md \"Enemy aliases\").",
"models": {
"enemy_1305_mhslim": {
"skel": "enemy_1305_mhslim.skel",
"atlas": "enemy_1305_mhslim.atlas",
"textures": [
"enemy_1305_mhslim.png"
],
"pma": true,
"anims": {
"idle": "Idle",
"deploy": "Idle",
"attack": {
"begin": null,
"loop": "Attack",
"end": null
},
"attackDown": null,
"skill": {
"begin": null,
"loop": "Attack",
"end": null,
"via": "attack",
"index": 0,
"idle": null
},
"die": "Die",
"move": {
"begin": null,
"loop": "Move",
"end": null
},
"stun": null
},
"animations": {
"Attack": 1,
"Default": 0,
"Die": 1,
"Idle": 2,
"Move": 1
},
"events": [
"OnAttack"
],
"hits": {
"Attack": [
0.667
]
},
"bounds": {
"x": -118.121,
"y": -1.969,
"width": 230,
"height": 164
}
},
"enemy_1305_mhslim_2": {
"skel": "enemy_1305_mhslim_2.skel",
"atlas": "enemy_1305_mhslim_2.atlas",
"textures": [
"enemy_1305_mhslim_2.png"
],
"pma": true,
"anims": {
"idle": "Idle",
"deploy": "Idle",
"attack": {
"begin": null,
"loop": "Attack",
"end": null
},
"attackDown": null,
"skill": {
"begin": null,
"loop": "Attack",
"end": null,
"via": "attack",
"index": 0,
"idle": null
},
"die": "Die",
"move": {
"begin": null,
"loop": "Move",
"end": null
},
"stun": null
},
"animations": {
"Attack": 1,
"Default": 0,
"Die": 1,
"Idle": 2,
"Move": 1
},
"events": [
"OnAttack"
],
"hits": {
"Attack": [
0.667
]
},
"bounds": {
"x": -118.121,
"y": -1.969,
"width": 230,
"height": 164
}
}
}
}
+14 -3
View File
@@ -1,7 +1,8 @@
// Manifest assembly: walks the plan template (tools/assets/plan.mjs), collects // Manifest assembly: walks the plan template (tools/assets/plan.mjs), collects
// the single-file download alternatives, and resolves the template against the // the single-file download alternatives, and resolves the template against the
// files that actually exist on disk into data/assets.json. Entries whose files // 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 { existsSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto'; 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 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 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. * 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 = []) { export function collectLeaves(node, path = '', out = []) {
if (isLeaf(node)) { out.push({ path, leaf: node }); return 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); for (const [k, v] of Object.entries(node)) collectLeaves(v, path ? `${path}.${k}` : k, out);
return out; return out;
} }
@@ -78,6 +88,7 @@ export function resolveTemplate(template, { root, spine, sourceOf = () => undefi
const files = new Set(); const files = new Set();
const walk = (node, path) => { const walk = (node, path) => {
if (node === null || node === undefined) return undefined; if (node === null || node === undefined) return undefined;
if (isLiteral(node)) return JSON.parse(JSON.stringify(node[LITERAL]));
if (isLeaf(node)) { if (isLeaf(node)) {
for (let i = 0; i < node.alts.length; i++) { for (let i = 0; i < node.alts.length; i++) {
const a = node.alts[i]; const a = node.alts[i];
@@ -110,7 +121,7 @@ export function resolveTemplate(template, { root, spine, sourceOf = () => undefi
} }
return out; 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, ''); const value = walk(template, '');
return { value, misses, fallbacks, files }; return { value, misses, fallbacks, files };
} }
+20 -22
View File
@@ -5,7 +5,8 @@
// { alts: [{ rel, urls[], kind, bytes? }, …] } one file; the first alternative // { alts: [{ rel, urls[], kind, bytes? }, …] } one file; the first alternative
// that ends up on disk wins (later alts are fallbacks: other URLs for the // that ends up on disk wins (later alts are fallbacks: other URLs for the
// same file, or other sounds of the same bank); // 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 // Inputs are the research JSONs (docs/research/03, 05, 07), the official
// audio_data.json and Ark-Models' models_data.json. // audio_data.json and Ark-Models' models_data.json.
// //
@@ -19,10 +20,13 @@
import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs'; import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs';
import { kindOf } from './formats.mjs'; import { kindOf } from './formats.mjs';
import { pickUnitSfx, UI_SFX, BATTLE_SFX, resolveSpec } from './audio.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 * Enemies whose Spine no community dump carries: the web model is another enemy's (research 07 §5.6). Their official
* their own official model was extracted from the local client (`localEnemyModels`, tools/local-extract ENEMY_SPINES). * 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({ export const ENEMY_SPINE_ALIAS = Object.freeze({
enemy_1305_mhslim: 'enemy_1007_slime', 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.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 {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,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 * @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localEnemySpines] metadata of the enemy models the
* extracted from the local client, on disk under public/assets (spine.mjs findLocalEnemyModels): an enemy without an * local client has (the committed tools/assets/local-enemy-spines.json, never the disk): each planned enemy listed
* upstream model takes its own local one before any alias (user feedback after 0.1.0, D3: 灼热源石虫 / 炽焰源石虫 * gets `spineLocal` = { group: 'spine/enemy/<id>', ...meta } beside its web `spine`
* were drawn as the plain 源石虫)
* @returns {{ template: any, models: Map<string, any>, notes: string[] }} * @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 = []; const notes = [];
/** @type {Map<string, any>} */ /** @type {Map<string, any>} */
const models = new Map(); const models = new Map();
@@ -339,16 +342,6 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
pngs: [mk(png, rec07?.png)], 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 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 })); 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); 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`))]; 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`)); 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); e.icon = leaf(iconAlts);
// Spine: own model (upstream, else extracted from the local client), else alias chain (research 07 §5.6). // Spine: own model, else alias chain (research 07 §5.6).
let spine = ownModel(id); let spine = arkModel(id);
if (spine && localEnemyModels?.[id] && models.get(spine.model)?.local) notes.push(`${id}: Spine from the local client`);
if (!spine) { if (!spine) {
const seen = new Set([id]); const seen = new Set([id]);
const queue = [ENEMY_SPINE_ALIAS[id], baseIdOf(id), handbookOf.get(id)].filter(Boolean); 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(); const cand = queue.shift();
if (seen.has(cand)) continue; if (seen.has(cand)) continue;
seen.add(cand); seen.add(cand);
spine = ownModel(cand); spine = arkModel(cand);
if (spine) e.spineAliasOf = cand; if (spine) e.spineAliasOf = cand;
else queue.push(...[ENEMY_SPINE_ALIAS[cand], baseIdOf(cand), handbookOf.get(cand)].filter(Boolean)); 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}`); else notes.push(`${id}: Spine aliased to ${e.spineAliasOf}`);
} }
e.spine = spine; 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; enemies[id] = e;
let banks = audio.unitBanks.get(id); let banks = audio.unitBanks.get(id);
for (const other of [handbookOf.get(id), e.spineAliasOf, baseIdOf(id)]) { for (const other of [handbookOf.get(id), e.spineAliasOf, baseIdOf(id)]) {
+74 -7
View File
@@ -1,8 +1,14 @@
// Spine model pipeline: download skel/atlas/page PNGs, fetch extra atlas pages, // Spine model pipeline: download skel/atlas/page PNGs, fetch extra atlas pages,
// normalize atlases (size:/pma:), parse skeletons and resolve animation roles. // normalize atlases (size:/pma:), parse skeletons and resolve animation roles.
// Produces the per-model `spine` entries of data/assets.json. Enemy models that // Produces the per-model `spine` entries of data/assets.json.
// no dump carries may come from the local client instead (findLocalEnemyModels: //
// tools/local-extract/extract.py ENEMY_SPINES → public/assets/local/spine/enemy/). // 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 { readFile, readdir, writeFile, stat, rename, mkdir, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path'; 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. */ /** Where tools/local-extract/extract.py writes enemy Spine models, under public/assets. */
export const LOCAL_ENEMY_SPINE_DIR = 'local/spine/enemy/'; 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 * 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; 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, * @typedef {{ skel: string, atlas: string, textures: string[], pma: boolean, anims: any,
* animations: Record<string, number>, events: string[], hits: Record<string, number[]>, bounds: any }} SpineEntry * 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 problems = [];
const list = [...models.values()]; const list = [...models.values()];
if (download) { if (download) {
// local-client models (`local`: findLocalEnemyModels) are on disk already: nothing to download
const remote = list.filter((m) => !m.local);
const jobs = []; 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'); await dl.run(jobs, 'spine');
// Atlases may reference more pages than the index lists. // Atlases may reference more pages than the index lists.
const extra = []; const extra = [];
for (const m of remote) { for (const m of list) {
const text = await readFile(join(root, m.atlas.rel), 'utf8').catch(() => null); const text = await readFile(join(root, m.atlas.rel), 'utf8').catch(() => null);
if (!text) continue; if (!text) continue;
for (const page of atlasInfo(text).pages) { for (const page of atlasInfo(text).pages) {
+51 -9
View File
@@ -5,9 +5,12 @@
// public/fonts/** Bender / Novecento (.otf/.ttf + .woff2) and fonts.css // public/fonts/** Bender / Novecento (.otf/.ttf + .woff2) and fonts.css
// data/assets.json manifest used by the client (schema: docs/ASSETS.md) // data/assets.json manifest used by the client (schema: docs/ASSETS.md)
// //
// Enemy models no dump carries are taken from the local client when // Enemy models no dump carries get another enemy's model (ASSETS.md "Enemy
// tools/local-extract/extract.py has extracted them (public/assets/local/spine/ // aliases"); the official ones the local client has (tools/local-extract/
// enemy/<id>/, ASSETS.md "Enemy aliases"); otherwise another enemy's model. // 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 // 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 // cheap. Downloads use ~16 parallel connections, 3 retries per source and a
@@ -15,7 +18,8 @@
// enemies); every skeleton is parsed to resolve animation roles. // enemies); every skeleton is parsed to resolve animation roles.
// //
// Usage: node tools/fetch-assets.mjs [--concurrency=16] [--force] [--offline] // 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 { readFile, writeFile, mkdir, rename, readdir, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
@@ -25,7 +29,7 @@ import { Downloader } from './assets/downloader.mjs';
import { loadIndexes } from './assets/cache.mjs'; import { loadIndexes } from './assets/cache.mjs';
import { indexAudio } from './assets/audio.mjs'; import { indexAudio } from './assets/audio.mjs';
import { buildPlan } from './assets/plan.mjs'; import { buildPlan } from './assets/plan.mjs';
import { processModels, findLocalEnemyModels } from './assets/spine.mjs'; import { processModels, findLocalEnemyModels, localEnemySpineMeta, loadLocalEnemySpines, LOCAL_ENEMY_SPINES_FILE } from './assets/spine.mjs';
import { collectLeaves, downloadLeaves, resolveTemplate, totalBytes, contentHash, MANIFEST_VERSION } from './assets/manifest.mjs'; import { collectLeaves, downloadLeaves, resolveTemplate, totalBytes, contentHash, MANIFEST_VERSION } from './assets/manifest.mjs';
import { fontJobs, buildFonts } from './assets/fonts.mjs'; import { fontJobs, buildFonts } from './assets/fonts.mjs';
import { skelParserAvailable } from './assets/skel.mjs'; import { skelParserAvailable } from './assets/skel.mjs';
@@ -36,6 +40,7 @@ const FONTS = join(ROOT, 'public', 'fonts');
const CACHE = join(ROOT, '.cache'); const CACHE = join(ROOT, '.cache');
const MANIFEST = join(ROOT, 'data', 'assets.json'); const MANIFEST = join(ROOT, 'data', 'assets.json');
const REPORT = join(CACHE, 'assets-report.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] const HELP = `Usage: node tools/fetch-assets.mjs [options]
--concurrency=N parallel downloads (default 16) --concurrency=N parallel downloads (default 16)
@@ -44,15 +49,18 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
--dry-run print the plan and exit --dry-run print the plan and exit
--refresh-index re-download audio_data.json / models_data.json indexes --refresh-index re-download audio_data.json / models_data.json indexes
--prune delete files under public/assets that the manifest no longer references --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`; --help this text`;
/** /**
* Parse CLI flags. * Parse CLI flags.
* @param {string[]} argv * @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) { 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) { for (const a of argv) {
const [k, v] = a.split('='); const [k, v] = a.split('=');
if (k === '--concurrency') o.concurrency = Math.max(1, Math.min(64, parseInt(v, 10) || 16)); if (k === '--concurrency') o.concurrency = Math.max(1, Math.min(64, parseInt(v, 10) || 16));
@@ -61,6 +69,7 @@ function parseArgs(argv) {
else if (k === '--dry-run') o.dryRun = true; else if (k === '--dry-run') o.dryRun = true;
else if (k === '--refresh-index') o.refreshIndex = true; else if (k === '--refresh-index') o.refreshIndex = true;
else if (k === '--prune') o.prune = 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 if (k === '--help' || k === '-h') o.help = true;
else throw new Error(`unknown option ${a}\n${HELP}`); else throw new Error(`unknown option ${a}\n${HELP}`);
} }
@@ -140,6 +149,38 @@ function requiredMisses(m, charIds) {
return out; 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() { async function main() {
const opts = parseArgs(process.argv.slice(2)); const opts = parseArgs(process.argv.slice(2));
if (opts.help) { log(HELP); return 0; } if (opts.help) { log(HELP); return 0; }
@@ -161,12 +202,13 @@ async function main() {
['data/enemies.json', 'data/tokens.json', 'data/bosses.json'].map((f) => readJson(f).catch(() => null))); ['data/enemies.json', 'data/tokens.json', 'data/bosses.json'].map((f) => readJson(f).catch(() => null)));
const extraHandbook = {}; const extraHandbook = {};
for (const b of Object.values(dataBosses || {})) if (b?.enemyKey && typeof b.handbookId === 'string') extraHandbook[b.enemyKey] = b.handbookId; 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({ const plan = buildPlan({
assets07, ops03, enemies05, maps05, audio, modelsData, assets07, ops03, enemies05, maps05, audio, modelsData,
extraEnemyIds: Object.keys(dataEnemies || {}), extraEnemyIds: Object.keys(dataEnemies || {}),
extraTokenIds: Object.keys(dataTokens || {}), extraTokenIds: Object.keys(dataTokens || {}),
extraHandbook, extraHandbook,
localEnemyModels: await findLocalEnemyModels(ASSETS), localEnemySpines,
}); });
const leaves = collectLeaves(plan.template); const leaves = collectLeaves(plan.template);
log(`[plan] ${leaves.length} files + ${plan.models.size} Spine models ` + log(`[plan] ${leaves.length} files + ${plan.models.size} Spine models ` +
@@ -220,7 +262,7 @@ async function main() {
await writeJsonAtomic(MANIFEST, manifest); await writeJsonAtomic(MANIFEST, manifest);
// Orphans: files on disk that the manifest does not reference (e.g. after a mapping change). public/assets/local/** // Orphans: files on disk that the manifest does not reference (e.g. after a mapping change). public/assets/local/**
// belongs to tools/local-extract (data/local-assets.json) and is never an orphan: --prune used to delete it. // belongs to tools/local-extract (data/local-assets.json) and is never an orphan: --prune used to delete all of it.
const orphans = (await listFiles(ASSETS)).filter((r) => !resolved.files.has(r) && !r.startsWith('local/')); 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 */ } } if (opts.prune) for (const r of orphans) { try { await unlink(join(ASSETS, r)); } catch { /* ignore */ } }
+48 -7
View File
@@ -10,7 +10,9 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
- the autochess guidebook pages, battle projectile sprites and a few token/skin Spine models missing upstream - the 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 - 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 bundles (refs/arts/enm_art_*.ab) → spine/enemy/<enemyId>/<stem>.skel|.atlas + page PNGs with the [alpha] texture
merged in (premultiplied RGB + A, the Ark-Models format); tools/fetch-assets.mjs plans them in place of an alias merged in (premultiplied RGB + A, the Ark-Models format; the atlas gets `size:` / `pma: true` like the fetched
enemies); the client draws them instead of the web alias once this manifest lists them (data/assets.json
enemies[id].spineLocal, docs/ASSETS.md "Enemy aliases")
- for the official 3D board (DESIGN §15): the map theme's Material parameters (map/<theme>/materials.json; shader - 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 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 (mesh/<bundle>/<mesh>.obj; UnityPy's exporter, X mirrored into a right-handed frame) and their GameObject
@@ -94,12 +96,14 @@ JOBS = [
] ]
# Enemy battle Spine models that no community dump carries (isHarryh/Ark-Models lists them with an empty assetList, so # Enemy battle Spine models that no community dump carries (isHarryh/Ark-Models lists them with an empty assetList, so
# tools/fetch-assets.mjs used to alias them to another enemy's skeleton): every enemy of data/enemies.json whose official # tools/fetch-assets.mjs aliases them to another enemy's skeleton — the web model): every enemy of data/enemies.json whose
# battle prefab draws a skeleton of its own that is missing upstream (audited against the client's prefabs, user feedback # official battle prefab draws a skeleton of its own that is missing upstream (audited against the client's prefabs, user
# after 0.1.0 report D3: 灼热源石虫 / 炽焰源石虫 — the ELEMENT faction's slugs — were drawn as the plain 源石虫). Read from # feedback after 0.1.0 report D3: 灼热源石虫 / 炽焰源石虫 — the ELEMENT faction's slugs — were drawn as the plain 源石虫).
# the enemy art bundles: Assets/Torappu/Arts/Enemies/Spines/<…>/<id>_SkeletonData.asset → its skeleton TextAsset, the # Read from the enemy art bundles: Assets/Torappu/Arts/Enemies/Spines/<…>/<id>_SkeletonData.asset → its skeleton
# atlas TextAsset of its atlas asset and every page texture of the atlas materials (_MainTex, with its _AlphaTex merged # TextAsset, the atlas TextAsset of its atlas asset and every page texture of the atlas materials (_MainTex, with its
# in as A). Output: spine/enemy/<id>/ (manifest group spine/enemy/<id>). # _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_SPINES = ['enemy_1305_mhslim', 'enemy_1305_mhslim_2']
ENEMY_ART = 'refs/arts/enm_art_*.ab' ENEMY_ART = 'refs/arts/enm_art_*.ab'
ENEMY_SPINE_SUB = 'spine/enemy' ENEMY_SPINE_SUB = 'spine/enemy'
@@ -426,6 +430,41 @@ def merge_alpha(rgb, alpha):
return Image.merge('RGBA', tuple(ImageChops.darker(ch, a) for ch in (r, g, b)) + (a,)) 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): 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.""" """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 get = lambda pptr: objects.get(pptr['m_PathID']) if pptr and not pptr.get('m_FileID') else None # noqa: E731
@@ -462,6 +501,8 @@ def write_enemy_spine(objects, sda, eid, out_root, manifest, log):
if f'{stem}.atlas' not in files or not pages: if f'{stem}.atlas' not in files or not pages:
log(f' warn {eid}: atlas or page textures missing') log(f' warn {eid}: atlas or page textures missing')
return 0 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(): for fname, blob in files.items():
(out_dir / fname).write_bytes(blob) (out_dir / fname).write_bytes(blob)
manifest.setdefault(sub, {})[fname] = {'path': f'/assets/local/{sub}/{fname}', 'kind': 'TextAsset'} manifest.setdefault(sub, {})[fname] = {'path': f'/assets/local/{sub}/{fname}', 'kind': 'TextAsset'}
+7 -3
View File
@@ -189,11 +189,15 @@ export function checkAssets() {
return { ok: present && missing.length === 0 && urls.length > 0, present, manifest: true, total: urls.length, missing: missing.length, sample: missing.slice(0, 5), bytes: Number(m.stats?.bytes) || 0 }; 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() { export function checkLocal() {
const m = readJson(LOCAL_MANIFEST); 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 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}`); add(already ? 'ok' : 'warn', '本地客户端美术(可选)', `${client.kind} 客户端缺少卫戍协议资源(请在游戏内下载全部资源):${client.path}`);
} else if (already && opts.local !== 'force') { } else if (already && opts.local !== 'force') {
if (local.board3d && !local.tiles && !opts.check) cropBoardTiles(log); 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) { } else if (opts.check) {
add('skip', '本地客户端美术(可选)', `检测到 ${client.kind} 客户端,可运行 node tools/setup.mjs --local 提取`); add('skip', '本地客户端美术(可选)', `检测到 ${client.kind} 客户端,可运行 node tools/setup.mjs --local 提取`);
} else { } else {