0.2.5-dev: merge claude/036-scene

# Conflicts:
#	CHANGELOG.md
#	docs/history/0.2.5.md
This commit is contained in:
sganggs committed 2026-10-12 15:00:10 +08:00
commit bb88031256
19 files changed
+1660 -22

No files matched your search

+3
View File
@@ -11,6 +11,9 @@ public/assets/
public/fonts/ public/fonts/
# tools/local-extract/extract.py → public/assets/local/** and this per-machine manifest # tools/local-extract/extract.py → public/assets/local/** and this per-machine manifest
# (optional: the client and the tests work without it; docs/DEPLOY.md §6 lists what falls back) # (optional: the client and the tests work without it; docs/DEPLOY.md §6 lists what falls back)
# Battlefield scenes (0.2.5): extracted files live under public/assets/local/scene/ (already ignored by
# public/assets/ above). Downloaded bundles live in .cache/official-scenes/ (already ignored by .cache/ below).
# The repository never stores those official files.
data/local-assets.json data/local-assets.json
# download caches, upstream tables, ledgers, reports, setup state # download caches, upstream tables, ledgers, reports, setup state
.cache/ .cache/
+2
View File
@@ -22,6 +22,8 @@
- 设置增加「官方伤害数字」(默认关闭)。打开后只按 PRTS「伤害红字(暴击)」显示红色整数;关闭时仍显示每一次命中(#412)。 - 设置增加「官方伤害数字」(默认关闭)。打开后只按 PRTS「伤害红字(暴击)」显示红色整数;关闭时仍显示每一次命中(#412)。
- 标题画面页脚增加「项目仓库」,在新标签页打开本项目仓库(#543)。 - 标题画面页脚增加「项目仓库」,在新标签页打开本项目仓库(#543)。
- 3D 棋盘在提取了官方场景时按战场显示沙地等 11 个场景,没有场景时仍用金属地块(PR #533,感谢 @andrew05060414)。完整包带上提取出的场景;精简包和源码可以在本机下载并提取,提取依赖缺失时继续用金属地块。
## 0.2.4 — 2026-10-11 ## 0.2.4 — 2026-10-11
### 规则(按官方) ### 规则(按官方)
+1
View File
@@ -165,6 +165,7 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee
| 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 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`) | | 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`) |
| Original token Spine overlays (40 summons: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼, 香槟炸弹) | local-client extraction (`tools/local-extract/extract.py TOKEN_SPINES`, optional); the original overlay is preferred, with any listed web model retained as fallback | `local/spine/token/{tokenId}/{stem}.*` (listed in `data/local-assets.json`) | | Original token Spine overlays (40 summons: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼, 香槟炸弹) | local-client extraction (`tools/local-extract/extract.py TOKEN_SPINES`, optional); the original overlay is preferred, with any listed web model retained as fallback | `local/spine/token/{tokenId}/{stem}.*` (listed in `data/local-assets.json`) |
| Official battlefield scenes (one per battlefield: meshes, lightmaps, albedo textures; DESIGN §15, DESIGN §30.17) | the scene bundles `scenes/activities/<season>/level_<stage>/…` (`tools/local-extract/extract.py --only scene`, optional). The client carries them only while the season runs; off season `tools/local-extract/fetch_scenes.py` downloads the bundles of the in-season version `26-04-13-12-31-42_e9b560` from the official CDN into `.cache/official-scenes/` (33 bundles, 22,801,308 bytes compressed on the Android list, md5-checked), and the materials / textures still come from the local client's theme bundles. The 0.2.5 full release zip includes the extracted files under `public/assets/local/scene/` when the maintainer extracted them before packaging; a lite zip and a source install may fetch and extract them (`node tools/setup.mjs --scenes`). The repository never stores the bundles or the extracted files. Without a scene the 3D board draws its procedural metal tiles | `local/scene/{stageId}/scene.json`, `scene.bin`, `lm{i}.*`; `local/scene/tex/{texture}.*` (listed in `data/local-assets.json`) |
| BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` (大厅/休整期 `act1autochess`, 开战 `act13side/m_bat_kazimierz2_{1,2}` — 骑士之日 / 无畏者; the 开战 track follows the round: `_2` 无畏者 rounds 1–7, `_1` 骑士之日 from round 8) | `audio/bgm/{file}.mp3` | | BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` (大厅/休整期 `act1autochess`, 开战 `act13side/m_bat_kazimierz2_{1,2}` — 骑士之日 / 无畏者; the 开战 track follows the round: `_2` 无畏者 rounds 1–7, `_1` 骑士之日 from round 8) | `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` |
| 干员战斗语音 | AA2 `voice` `audio/sound_beta_2/voice_cn/{charId}/cn_nn.mp3` (Chinese, `audio.voice`) and `voice/{charId}/cn_nn.mp3` (Japanese, `audio.voiceJp`: the same file names) — the lines `charword_table.json` lists (`placeType` = when the game plays one, `voiceAsset` = the path); `--voice-lang=jp|en|kr` fills `audio.voice` from `voice/`, `voice_en/`, `voice_kr/` instead | `audio/voice/cn/{charId}/{cn_nn}.mp3`, `audio/voice/jp/{charId}/{cn_nn}.mp3` (`audio/voice/{lang}/…` for another `--voice-lang`) | | 干员战斗语音 | AA2 `voice` `audio/sound_beta_2/voice_cn/{charId}/cn_nn.mp3` (Chinese, `audio.voice`) and `voice/{charId}/cn_nn.mp3` (Japanese, `audio.voiceJp`: the same file names) — the lines `charword_table.json` lists (`placeType` = when the game plays one, `voiceAsset` = the path); `--voice-lang=jp|en|kr` fills `audio.voice` from `voice/`, `voice_en/`, `voice_kr/` instead | `audio/voice/cn/{charId}/{cn_nn}.mp3`, `audio/voice/jp/{charId}/{cn_nn}.mp3` (`audio/voice/{lang}/…` for another `--voice-lang`) |
+5
View File
@@ -300,6 +300,7 @@ services:
| 内容 | 没有本地素材时 | | 内容 | 没有本地素材时 |
|---|---| |---|---|
| 官方 3D 棋盘(贴图、模型、地图特效) | 2D 棋盘,地块由程序绘制 | | 官方 3D 棋盘(贴图、模型、地图特效) | 2D 棋盘,地块由程序绘制 |
| 各战场的官方场景(沙地等 11 个战场,0.2.5 素材包;完整包在提取后带上,精简包和源码可在本机下载并提取,见下) | 3D 棋盘用程序生成的金属地块 |
| 部分官方界面图标与底板:交流按钮和表情面板的边框、暂停面板、装备替换窗口、干员调配界面、队友状态与漏怪标记、模组类型图标等 | 样式相近的替代图形、图标或文字 | | 部分官方界面图标与底板:交流按钮和表情面板的边框、暂停面板、装备替换窗口、干员调配界面、队友状态与漏怪标记、模组类型图标等 | 样式相近的替代图形、图标或文字 |
| 灼热 / 炽焰源石虫的官方模型 | 染成橙色 / 红橙色的普通源石虫 | | 灼热 / 炽焰源石虫的官方模型 | 染成橙色 / 红橙色的普通源石虫 |
| 39 个召唤物的官方模型(多数自选召唤物,以及凯瑟琳的爬行号·防护单元、凛御银灰的风雪之眼;公开镜像没有) | 召唤物头像(菱形底板) | | 39 个召唤物的官方模型(多数自选召唤物,以及凯瑟琳的爬行号·防护单元、凛御银灰的风雪之眼;公开镜像没有) | 召唤物头像(菱形底板) |
@@ -310,6 +311,8 @@ services:
**0.2.0 之前提取过的**:召唤物模型是 0.2.0 新增的提取项,旧的提取结果里没有(`node tools/setup.mjs` 会提示「缺少新版的自选召唤物模型」)。有客户端的电脑运行 `node tools/setup.mjs --local` 重新提取即可,只想补这一项也可以在提取用的 Python 环境里运行 `tools/local-extract/extract.py --only spine/token`(新文件写入 `public/assets/local/spine/token/`,清单里其他条目保持不变)。 **0.2.0 之前提取过的**:召唤物模型是 0.2.0 新增的提取项,旧的提取结果里没有(`node tools/setup.mjs` 会提示「缺少新版的自选召唤物模型」)。有客户端的电脑运行 `node tools/setup.mjs --local` 重新提取即可,只想补这一项也可以在提取用的 Python 环境里运行 `tools/local-extract/extract.py --only spine/token`(新文件写入 `public/assets/local/spine/token/`,清单里其他条目保持不变)。
**各战场的官方场景**(0.2.5 素材包,维护者 2026-10-12:可以进完整包;精简包和源码安装可以在本机下载):官方棋盘是每个战场一个做好的场景(11 个战场,其中 9 个是沙地)。场景包只在赛季开放期间随客户端分发,赛季外的客户端里没有。仓库不存放这些官方文件。有客户端的电脑可以 `node tools/setup.mjs --scenes`(询问或强制下载并提取;`--no-scenes` 跳过)。也可以分两步:`python3 tools/local-extract/fetch_scenes.py`(从官方 CDN 下载赛季版本 `26-04-13-12-31-42_e9b560` 的 33 个场景包,共 22,801,308 字节,放进 `.cache/official-scenes/`;`--list` 只看清单不下载),再用提取环境的 Python 运行 `tools/local-extract/extract.py --only scene`(材质和贴图仍取自本机客户端;`--game` 可指定客户端目录)。没有 Python 3.8+,或 UnityPy / lz4 / Pillow 没装好时,setup 只提示并继续,3D 棋盘用金属地块。没有客户端时贴图取不出来,从同一版本的完整包复制 `public/assets/local/` 和 `data/local-assets.json`。作者估计每个场景在进对局时另下载约 2–7 MB,各场景共用贴图约 3 MB;本机没有跑过提取,完整包因此增加的解压体积没有实测。
**3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。 **3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。
## 7. 打包发布(维护者) ## 7. 打包发布(维护者)
@@ -323,6 +326,8 @@ npm run package:lite -- --out <目录> # 精简包 Stronghold-Protocol-v<版
npm run package -- --update --from <旧版本的完整包>[,<…>] --out <目录> # 更新包 Stronghold-Protocol-v<版本>-update.zip npm run package -- --update --from <旧版本的完整包>[,<…>] --out <目录> # 更新包 Stronghold-Protocol-v<版本>-update.zip
``` ```
完整包在打包机已经提取战场场景时带上 `public/assets/local/scene/`(`--dry-run` 摘要里的 `battlefield scenes` 一行)。还没有提取时完整包仍能打,棋盘用金属地块。要带上 11 个官方场景,先运行 `python3 tools/local-extract/fetch_scenes.py`,再用提取环境的 Python 运行 `tools/local-extract/extract.py --only scene`(或 `node tools/setup.mjs --scenes`),然后再 `npm run package`。精简包不带场景。`.cache/official-scenes/` 在拒绝名单里,不会打进任何包。`tools/local-extract/scenes.py` 和 `fetch_scenes.py` 必须先提交:打包读的是 `git ls-files`,未提交的脚本不会进玩家包。
- **打进去的**:`git ls-files` 里的 `server/`、`shared/`、`data/`、`public/`(不含 `public/dev/`)、`packs/`(随仓库提交的内容包;只在本机安装、没提交的不打进去)、启动脚本、玩家会运行的工具(setup、vendor、fetch-assets 与 `tools/assets/`、doctor,以及 setup 调用的 `tools/local-extract/` 和 `crop-board-atlas.mjs`)、服务器和 fetch-assets 读取的 4 张研究数据表(`docs/research/` 的 `03-operators`、`05-enemies`、`05-maps`、`07-assets` 四个 JSON)、`package.json` / `package-lock.json`、许可证与说明(`LICENSE`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`README.md`、`CHANGELOG.md`)、`docs/PLAYING.md` 和本文;然后在临时目录里生成 `packs/index.json`(打进去的语言包和内容包的列表,供纯静态托管使用;服务器自己会实时列出,见 [PACKS.md](PACKS.md)),再 `npm ci --omit=dev` 装上运行依赖和 `public/vendor`。完整包再加上 `data/assets.json` 列出的素材、`public/fonts`,以及本地提取的 `public/assets/local/` 和 `data/local-assets.json`。磁盘上有、清单却没列出的文件不打进去(例如 0.2.0 移出自选的焰狐龙梓兰的旧素材)。日文干员语音(`audio.voiceJp`,`public/assets/audio/voice/jp/` 的 2674 个文件,约 85 MB,zip 后约 76 MB)默认也打进完整包;`tools/package.mjs` 里的开关 `FULL_ZIP_JP_VOICE` 改成 `false` 时,完整包(以及由它比较出的更新包)不带这些文件:玩家首次启动时 setup 会像精简包那样下载它们,下载完成前选「日本語」会播中文语音,更新包也不会删除玩家已有的日文语音。 - **打进去的**:`git ls-files` 里的 `server/`、`shared/`、`data/`、`public/`(不含 `public/dev/`)、`packs/`(随仓库提交的内容包;只在本机安装、没提交的不打进去)、启动脚本、玩家会运行的工具(setup、vendor、fetch-assets 与 `tools/assets/`、doctor,以及 setup 调用的 `tools/local-extract/` 和 `crop-board-atlas.mjs`)、服务器和 fetch-assets 读取的 4 张研究数据表(`docs/research/` 的 `03-operators`、`05-enemies`、`05-maps`、`07-assets` 四个 JSON)、`package.json` / `package-lock.json`、许可证与说明(`LICENSE`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`README.md`、`CHANGELOG.md`)、`docs/PLAYING.md` 和本文;然后在临时目录里生成 `packs/index.json`(打进去的语言包和内容包的列表,供纯静态托管使用;服务器自己会实时列出,见 [PACKS.md](PACKS.md)),再 `npm ci --omit=dev` 装上运行依赖和 `public/vendor`。完整包再加上 `data/assets.json` 列出的素材、`public/fonts`,以及本地提取的 `public/assets/local/` 和 `data/local-assets.json`。磁盘上有、清单却没列出的文件不打进去(例如 0.2.0 移出自选的焰狐龙梓兰的旧素材)。日文干员语音(`audio.voiceJp`,`public/assets/audio/voice/jp/` 的 2674 个文件,约 85 MB,zip 后约 76 MB)默认也打进完整包;`tools/package.mjs` 里的开关 `FULL_ZIP_JP_VOICE` 改成 `false` 时,完整包(以及由它比较出的更新包)不带这些文件:玩家首次启动时 setup 会像精简包那样下载它们,下载完成前选「日本語」会播中文语音,更新包也不会删除玩家已有的日文语音。
- **不打进去的**:`test/`、维护用的工具(数据构建、golden、botbench、i18n、导入检查、本工具等)、`scripts/make-windows-bundle.mjs`(Windows 便携包,见 [WINDOWS.md](WINDOWS.md))、其他文档、研究笔记和 `docs/img/`、`handoff/`、`.github/`、`types/`、lint / 编辑器 / Docker 配置。和 0.1.x 的整树打包(全部跟踪文件加上 `public/assets` 的全部内容)相比,0.2.0 的完整包少了约 640 个文件、解压后小约 26 MB,zip 小约 8 MB。 - **不打进去的**:`test/`、维护用的工具(数据构建、golden、botbench、i18n、导入检查、本工具等)、`scripts/make-windows-bundle.mjs`(Windows 便携包,见 [WINDOWS.md](WINDOWS.md))、其他文档、研究笔记和 `docs/img/`、`handoff/`、`.github/`、`types/`、lint / 编辑器 / Docker 配置。和 0.1.x 的整树打包(全部跟踪文件加上 `public/assets` 的全部内容)相比,0.2.0 的完整包少了约 640 个文件、解压后小约 26 MB,zip 小约 8 MB。
- **打包前的检查**(`--dry-run` 也全部做一遍):拒绝名单(`pv`、`review`、`.cache`、`.claude`、`.git`、`logs`、`.env`、`scripts/service.env.cmd`、`handoff`、`test` 等);每个打进去的模块的相对导入、玩家用的 npm 脚本(start / setup / doctor / launch / postinstall / vendor / assets)都指向包里的文件;完整包里 `data/assets.json` 和 `data/local-assets.json` 列出的文件都在(缺了先运行 `node tools/fetch-assets.mjs`);没有只差大小写的两个路径;包里的文件(二进制素材也查)不含个人目录路径(`/Users/…`、`C:\Users\…`、`/home/…`)或本机的账户名(运行时从系统读取;`SP_PACKAGE_SCAN_NAMES=a,b` 可以再加名字);打进去的已跟踪文件没有未提交的改动(重新生成的 `data/assets.json` 要先提交)。有任何问题都会列出原因、以非零状态结束,不写 zip;正式打包时还会核对临时目录里的文件和计划完全一致。 - **打包前的检查**(`--dry-run` 也全部做一遍):拒绝名单(`pv`、`review`、`.cache`、`.claude`、`.git`、`logs`、`.env`、`scripts/service.env.cmd`、`handoff`、`test` 等);每个打进去的模块的相对导入、玩家用的 npm 脚本(start / setup / doctor / launch / postinstall / vendor / assets)都指向包里的文件;完整包里 `data/assets.json` 和 `data/local-assets.json` 列出的文件都在(缺了先运行 `node tools/fetch-assets.mjs`);没有只差大小写的两个路径;包里的文件(二进制素材也查)不含个人目录路径(`/Users/…`、`C:\Users\…`、`/home/…`)或本机的账户名(运行时从系统读取;`SP_PACKAGE_SCAN_NAMES=a,b` 可以再加名字);打进去的已跟踪文件没有未提交的改动(重新生成的 `data/assets.json` 要先提交)。有任何问题都会列出原因、以非零状态结束,不写 zip;正式打包时还会核对临时目录里的文件和计划完全一致。
+1 -1
View File
@@ -5,7 +5,7 @@ This is the **single source of truth** for every implementer. Research lives in
Language: player-facing text is **Simplified Chinese** by default, with an English switch since 0.2.0 (§25.2, docs/I18N.md: UI strings through `t('中文')`, game texts from the official Chinese data or the official EN client's). Code, comments and identifiers are English. Language: player-facing text is **Simplified Chinese** by default, with an English switch since 0.2.0 (§25.2, docs/I18N.md: UI strings through `t('中文')`, game texts from the official Chinese data or the official EN client's). Code, comments and identifiers are English.
Current development state: **0.2.5-dev**, §30, the state described by this document. Its fixed gifts, already displayed operator offers, combat corrections Current development state: **0.2.5-dev**, §30, the state described by this document. Its fixed gifts, already displayed operator offers, combat corrections
and client/tool changes supersede the corresponding 0.2.4 rules below. It is not yet released. and client/tool changes supersede the corresponding 0.2.4 rules below. Official battlefield scenes (DESIGN §15, DESIGN §30.17) are optional 0.2.5 local art: the full bundle includes them when extracted, and a missing scene keeps the metal board. It is not yet released.
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history. Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
+6 -1
View File
@@ -63,7 +63,8 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
## 13. Local-client art (optional, host-side) ## 13. Local-client art (optional, host-side)
`tools/local-extract/extract.py` (Python: UnityPy + lz4 + Pillow; decodes Arknights' custom **LZ4AK** bundle compression via `aklz4.py`) extracts the art the web download does not include (some of it, most official HUD sprites among it, is on the public mirror too — §22.5) from a locally installed client (CrossOver Windows build or PlayCover iOS build) into `public/assets/local/**` and writes `data/local-assets.json` (`{ groups: { '<subdir>': { name: { path, w, h, kind } } } }`). Everything here is **optional**: clients must render correctly when the file or any entry is missing (fall back to the web assets / procedural art). The emotes and the guide pages below are on the public mirror too (§22.5): fetch-assets downloads them (data/assets.json `ui['emoticon/<dir>/<picId>']`, `ui['guide/<key>']`) and the client shows the local copy first, then that one (`data.js artUrls`); docs/DEPLOY.md §6 lists what still needs the client. `tools/local-extract/extract.py` (Python: UnityPy + lz4 + Pillow; decodes Arknights' custom **LZ4AK** bundle compression via `aklz4.py`) extracts the art the web download does not include (some of it, most official HUD sprites among it, is on the public mirror too — §22.5) from a locally installed client (CrossOver Windows build or PlayCover iOS build) into `public/assets/local/**` and writes `data/local-assets.json` (`{ groups: { '<subdir>': { name: { path, w, h, kind } } } }`). Everything here is **optional**: clients must render correctly when the file or any entry is missing (fall back to the web assets / procedural art). The emotes and the guide pages below are on the public mirror too (§22.5): fetch-assets downloads them (data/assets.json `ui['emoticon/<dir>/<picId>']`, `ui['guide/<key>']`) and the client shows the local copy first, then that one (`data.js artUrls`); docs/DEPLOY.md §6 lists what still needs the client.
- `map/autochess/TX_autochessi_*` — the real autochess board atlas (`_D` diffuse 2048² with floor tiles, gold-framed high-ground plates, REINFORCEMENTS bench pads, EQUIPMENTS pads, crates, gates; `_N/_M/_E` normal/mask/emission; `common_D` device icons; `BG` backdrop). `map/autochesssand/*` — the sand-theme variant. - `map/autochess/TX_autochessi_*` — the real autochess board atlas (`_D` diffuse 2048² with floor tiles, gold-framed high-ground plates, REINFORCEMENTS bench pads, EQUIPMENTS pads, crates, gates; `_N/_M/_E` normal/mask/emission; `common_D` device icons; `BG` backdrop). `map/autochesssand/*` — the textures and materials of the sand theme (the theme of nine of the eleven battlefield scenes, §15).
- `scene/<stageId>/` and `scene/tex/` — the official scene of a battlefield (§15 "Official battlefield scenes", DESIGN §30.17): `scene.json` (materials, key light, chunk records), `scene.bin` (the meshes in board space), the lightmap pages `lm<i>`, and the materials' albedo textures shared by the scenes. Read from the scene bundles `scenes/activities/<season>/level_<stage>/…`, which the client carries only while the season runs: `extract.py --only scene` takes them from the client or from `--scenes <dir>` / `.cache/official-scenes/*` (`tools/local-extract/fetch_scenes.py` downloads them from the official CDN's in-season version). The 0.2.5 full release zip includes the extracted files when the maintainer extracted them before packaging; a lite zip and a source install may fetch and extract them (`node tools/setup.mjs --scenes`). The repository never stores the bundles or the extracted files.
- `ui/{common,battle,outer}/*` — every sprite of the three official autochess UI bundles (tier chips I–VI, bond disc rings, frames, banners, HUD pieces, badges, backgrounds, mode art). - `ui/{common,battle,outer}/*` — every sprite of the three official autochess UI bundles (tier chips I–VI, bond disc rings, frames, banners, HUD pieces, badges, backgrounds, mode art).
- `emoticon/<dir>/<picId>.png` — the 36 official battle emotes, 6 themes (`shared/constants.js → EMOTE_THEMES / EMOTES`, `emoteArtPath`; data/emotes.json); also downloaded from the mirror (§22.5). - `emoticon/<dir>/<picId>.png` — the 36 official battle emotes, 6 themes (`shared/constants.js → EMOTE_THEMES / EMOTES`, `emoteArtPath`; data/emotes.json); also downloaded from the mirror (§22.5).
- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5). - `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5).
@@ -78,6 +79,10 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
Findings (2026-09-28): 卫戍协议 levels have no scene bundle (`mapId` null; the official hot-update list contains none). The client **builds the board at runtime** from the stage grid using the map theme `arts/maps/map_autochess` (materials `MT_autochess` (albedo `TX_autochessi_D` 2048², normal `_N`, metallic/gloss `_M`, emission `_E`), `MT_autochess_common` (`common_D/_E`: device/gate icons), `MT_autochess_Transparent` (`Transparent_D/N/M/E`), `MT_autochessi_BG` (`TX_autochessi_BG`)), the background meshes `arts/maps/map_autochess/bkg_mesh.ab` (`S_Background_common`, `S_Background_shadow`) and `arts/maps/common/meshes/s_background_common.ab`, device meshes (`s_common_box_01` crate, `s_wind_device` blower, …), plus the standard gate/objective effects. The level's `options.configBlackBoard` carries the official camera framing (`default_pos_x/y`, `default_size`, `enemy_pos_x/y`, …). Findings (2026-09-28): 卫戍协议 levels have no scene bundle (`mapId` null; the official hot-update list contains none). The client **builds the board at runtime** from the stage grid using the map theme `arts/maps/map_autochess` (materials `MT_autochess` (albedo `TX_autochessi_D` 2048², normal `_N`, metallic/gloss `_M`, emission `_E`), `MT_autochess_common` (`common_D/_E`: device/gate icons), `MT_autochess_Transparent` (`Transparent_D/N/M/E`), `MT_autochessi_BG` (`TX_autochessi_BG`)), the background meshes `arts/maps/map_autochess/bkg_mesh.ab` (`S_Background_common`, `S_Background_shadow`) and `arts/maps/common/meshes/s_background_common.ab`, device meshes (`s_common_box_01` crate, `s_wind_device` blower, …), plus the standard gate/objective effects. The level's `options.configBlackBoard` carries the official camera framing (`default_pos_x/y`, `default_size`, `enemy_pos_x/y`, …).
Correction (2026-10-11, GitHub #532; PR #533 by @andrew05060414): the first finding above was made off season and is wrong. While a season runs, the official hot-update list carries **one prebuilt scene per battlefield** — `scenes/activities/act1autochess/level_act1autochess_m01…m07` and `scenes/activities/act2autochess/level_act2autochess_m01…m04` (each with `…/lightingdata.ab` and `…_lighting_settings.ab`; Android versions `25-11-21-15-21-44_ee1197`, `26-03-13-06-11-47_14fb99`, `26-04-13-12-31-42_e9b560`); the lists of 2026-09-13 and 2026-10-08 have none, and no 卫戍协议 level data either. The asset index of those versions (`<hash>.idx`: each bundle's dependencies) gives each scene's art: act1 m01–m07 and act2 m02 / m04 depend on `arts/maps/map_autochesssand` (m05 and act2 m04 also on `map_doss_shore`), act2 m01 on `map_lakegarden` + `map_syracuse_metro`, act2 m03 on `map_kxmr` + `map_lm_center`. **No battlefield scene depends on `arts/maps/map_autochess`** (the metal theme the procedural board below draws; in the current client the 矢量突破 scenes `act3break` use it). A scene holds the logic tiles (`(row,col)#tileKey` at anchor-local (col, row, −height), high ground 0.4), the art under `GraphicHolder` (static parts merged into "Combined Mesh (root: scene)" objects, lightmapped: Shadowmask mode, one directional key light), and three sub-graphics `HIDDEN@1…3` (`MapSubGraphicHolder`) that the game switches on: the left normal field, the right normal field and the boss field — the areas of `layout.js AREAS`.
Official battlefield scenes (`board3d/load.js loadScenePack`, `scene.js setScenePack`, `materials.js sceneMaterial`): when `data/local-assets.json` lists `scene/<stageId>`, the 3D board draws that scene instead of the procedural tiles, cliffs and background plane; the devices, gate / objective boxes, field edges and terrain overlays stay as below. The base part is always drawn; a `HIDDEN@n` part is drawn when its centre lies in one of the built areas. Without the scene (not extracted, a load failure, an unknown `version`) the procedural metal board is drawn as before. Approximations: the official shaders are not reproduced — a lit material is albedo × (the lightmap's baked light + key light · N·L · the baked shadow mask), in linear light; normal, metallic and emission maps, the vertex-colour blend (`_HG_VERTEX_COLOR_BLEND_ON`), the sky box, the map effects (`mapeff_*`) and the post-processing profile are not drawn; `Torappu/Scene/StylizedWater` is the board's water shader in the material's colours; crates cast no shadow on a scene. **[ASSUMED]** `SCENE_LIGHT` (`direct` 0.8, `indirect` 0.4, `ambient` 0.2: tuned by eye against the PRTS screenshot of 战场#06, the official post-processing being unknown) and the RGBM reading of the lightmap pages (`scenes.py decode_rgbm`: Unity's linear-space decode, range 5). Owner decision (2026-10-12): these extracted files belong to the 0.2.5 asset pack and may ship in the full release zip; a lite package or a source install may download and extract them on that machine. Missing extractor dependencies keep the metal board. The repository still never stores the official files.
Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) underneath the existing Pixi layer: Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) underneath the existing Pixi layer:
- Geometry generated from `data/stages.json` like the official builder: LOW tiles = top quads; HIGH tiles = raised blocks with side walls; forbidden/void, fences, gates (red), objective (blue), hand/temp pads, special terrain; UVs mapped onto the official atlas regions (reuse/extend `public/assets/local/map/autochess/tiles.json`); normal + emission + metallic maps; lighting tuned to match official screenshots (key directional light, ambient/hemisphere, soft contact shadows); official background meshes & texture; device meshes (OBJ exported by `tools/local-extract`) with their textures; animated gate/objective effects. - Geometry generated from `data/stages.json` like the official builder: LOW tiles = top quads; HIGH tiles = raised blocks with side walls; forbidden/void, fences, gates (red), objective (blue), hand/temp pads, special terrain; UVs mapped onto the official atlas regions (reuse/extend `public/assets/local/map/autochess/tiles.json`); normal + emission + metallic maps; lighting tuned to match official screenshots (key directional light, ambient/hemisphere, soft contact shadows); official background meshes & texture; device meshes (OBJ exported by `tools/local-extract`) with their textures; animated gate/objective effects.
- One camera model drives both layers: `public/js/render/projection.js` keeps its public API (`worldToScreen`, `screenToWorld`/tile picking, presets, transitions) but derives it from a three.js `PerspectiveCamera` initialised from the official configBlackBoard framing, so Spine units, FX, drag/drop and hit-testing stay aligned with the 3D board. - One camera model drives both layers: `public/js/render/projection.js` keeps its public API (`worldToScreen`, `screenToWorld`/tile picking, presets, transitions) but derives it from a three.js `PerspectiveCamera` initialised from the official configBlackBoard framing, so Spine units, FX, drag/drop and hit-testing stay aligned with the 3D board.
+48
View File
@@ -366,3 +366,51 @@ replace 设置 → 问题反馈.
Tests: `test/sim/official-damage-numbers.test.js`, `test/ui/official-damage-numbers.test.js`, Tests: `test/sim/official-damage-numbers.test.js`, `test/ui/official-damage-numbers.test.js`,
`test/render/official-numbers.test.js`. Golden digests do not move: no new event kind is emitted, and the extra `test/render/official-numbers.test.js`. Golden digests do not move: no new event kind is emitted, and the extra
tuple field is not part of the digest. Player notes: PLAYING; client notes: design/client.md. tuple field is not part of the digest. Player notes: PLAYING; client notes: design/client.md.
### 30.17 Official battlefield scenes (#532)
Report: the web board draws the metal theme for every battlefield. The reporter asked for the sand theme.
PR #533 by @andrew05060414.
Findings (2026-10-11; the official hot-update lists and asset indexes, read from the CDN, and the scene bundles of
version `26-04-13-12-31-42_e9b560`):
- The official board is not assembled at run time. Every battlefield is a prebuilt scene bundle, shipped only while
the season runs. DESIGN §15 said otherwise from an off-season list and is corrected there.
- Which art a battlefield uses is the dependency list of its scene bundle: the sand theme for act1 m01–m07 and act2
m02 / m04, `map_lakegarden` + `map_syracuse_metro` for act2 m01, `map_kxmr` + `map_lm_center` for act2 m03. The
same in the 上半 version of 2025-11 and the 下半 versions of 2026-03 / 04. No battlefield uses the metal theme.
- The sand theme bundle holds no mesh because the models are baked into each scene ("Combined Mesh (root: scene)").
- A scene's three sub-graphics `HIDDEN@1…3` are the left normal field, the right normal field and the boss field.
- `python3 tools/local-extract/fetch_scenes.py --list` against that Android hot-update list names 33 bundles and
22,801,308 bytes. That is the compressed CDN download, not the extracted size in the full zip.
Change: `tools/local-extract/scenes.py` exports a battlefield's scene (meshes in board space, materials, key light,
lightmap pages with the baked shadow mask in alpha, albedo textures) to `public/assets/local/scene/<stageId>/`;
`extract.py --only scene` runs it, reading the scene bundles from the client, `--scenes <dir>` or
`.cache/official-scenes/*`; `tools/local-extract/fetch_scenes.py` downloads the in-season bundles from the official
CDN (md5-checked against that version's hot-update list). The 3D board draws a stage's scene when the local-art
manifest lists it (`board3d/load.js loadScenePack`, `scene.js setScenePack`); the procedural metal board stays the
fallback and still draws the devices, gates, field edges and terrain overlays.
Owner decision (2026-10-12): the eleven scenes belong to the 0.2.5 asset pack. The maintainer's full release zip
includes the extracted files. A lite package or a source install may download and extract them on that machine
(`node tools/setup.mjs --scenes`; `--no-scenes` skips). Missing Python 3.8+ or UnityPy / lz4 / Pillow is a warning,
and the metal board remains. The repository never stores the bundles or the extracted files (`.gitignore` already
ignores `public/assets/`, `data/local-assets.json` and `.cache/`, and names `.cache/official-scenes/`). Packaging
does not fail when the scenes are absent; the summary line `battlefield scenes` says whether this machine has them.
`scenes.py` and `fetch_scenes.py` ship in a player zip only after they are committed, because the packager reads
`git ls-files`.
No gameplay change: the scene is art under the same grid, camera and unit layer. Golden results are not moved by
this change.
**[ASSUMED]** the light gains `SCENE_LIGHT` (`range` 8.0, `ambient` 0.2, `direct` 0.8, `indirect` 0.4) and the RGBM
reading of the lightmap pages (Unity linear decode, range 5), both in DESIGN §15. Not done: the scene's normal /
metallic / emission maps, map effects, sky box and post-processing; the tile graphics a level switches at run time
(mire, tide) still use the remake's overlays. The extracted byte size inside a full zip was not measured here.
Tests: `test/render/board3d-scene.test.js` (the scene table against data/stages.json, board-space conversion, chunk
layout shared by scenes.py and load.js, sub-graphic groups, RGBM decode, the loader with a fake store, BoardScene
with a stub renderer: scene on / areas / fallback). `test/scene-assets.test.js` (setup's scene step, the full plan
ships extracted scene files, the lite plan ships none, git ignores the bundles).
+11 -3
View File
@@ -125,7 +125,7 @@ import { backdropTextures, shadowTexture, refreshTierChips, refreshDownLabels, s
import { TILE_H, TIER_COLORS, COLORS } from './style.js'; import { TILE_H, TIER_COLORS, COLORS } from './style.js';
import { loadBoardArt } from './boardArt.js'; import { loadBoardArt } from './boardArt.js';
import { ImpostorAtlas } from './impostor.js'; import { ImpostorAtlas } from './impostor.js';
import { loadThree, loadBoardPack, webgl2Available, boardArtListed } from './board3d/load.js'; import { loadThree, loadBoardPack, loadScenePack, webgl2Available, boardArtListed } from './board3d/load.js';
import { BoardScene } from './board3d/scene.js'; import { BoardScene } from './board3d/scene.js';
import { unionAreas } from './board3d/layout.js'; import { unionAreas } from './board3d/layout.js';
import { layoutPen, penSignature } from './pen.js'; import { layoutPen, penSignature } from './pen.js';
@@ -361,6 +361,14 @@ export async function createFieldView(host, options = {}) {
// lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far // lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far
const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 }; const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 };
setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); }); setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); });
/** The stage's official scene, once loaded (the procedural board shows meanwhile and when there is none). */
function useOfficialScene(st) {
const id = st && typeof st.id === 'string' ? st.id : '';
if (!id || !board3d) return;
loadScenePack(assets, id).then((scene) => {
if (scene && !destroyed && board3d && stageRec && stageRec.id === id) board3d.setScenePack(id, scene);
}).catch(() => {});
}
function enable3d(THREE, pack) { function enable3d(THREE, pack) {
if (destroyed || board3d || !THREE || !pack) return false; if (destroyed || board3d || !THREE || !pack) return false;
recover.THREE = THREE; recover.pack = pack; recover.THREE = THREE; recover.pack = pack;
@@ -380,7 +388,7 @@ export async function createFieldView(host, options = {}) {
tiles.setExternal(true); tiles.setExternal(true);
backdrop.visible = false; backdrop.visible = false;
b.setArea(boardArea(viewKind(camKind, camOpts))); b.setArea(boardArea(viewKind(camKind, camOpts)));
if (stageRec) b.setStage(stageRec); if (stageRec) { b.setStage(stageRec); useOfficialScene(stageRec); }
b.setFocus(camRect()); b.setFocus(camRect());
b.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); b.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null);
tiles.project(cam, true); tiles.project(cam, true);
@@ -630,7 +638,7 @@ export async function createFieldView(host, options = {}) {
stageRec = st; stageRec = st;
tiles.setStage(st); tiles.setStage(st);
tiles.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); tiles.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null);
if (board3d) { board3d.setStage(st); board3d.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); } if (board3d) { board3d.setStage(st); board3d.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); useOfficialScene(st); }
tiles.project(cam, true); tiles.project(cam, true);
// a pen laid out before the stage arrived (setPrep / a scouting board first) used the default zones and the old // a pen laid out before the stage arrived (setPrep / a scouting board first) used the default zones and the old
// tile heights: lay it out again on this stage // tile heights: lay it out again on this stage
+101 -1
View File
@@ -5,6 +5,9 @@
// → { key, images: { D, N?, R?, E?, common?, commonE?, BG?, wind?, gate?, waterN?, caustics?, noise? }, // → { key, images: { D, N?, R?, E?, common?, commonE?, BG?, wind?, gate?, waterN?, caustics?, noise? },
// meshes: { crate?, blower?, bgPlane?, gate: { startDown?, startUp?, startBack?, endDown?, endUp? } }, // meshes: { crate?, blower?, bgPlane?, gate: { startDown?, startUp?, startBack?, endDown?, endUp? } },
// tiles, uv, materials: { theme?, fx? } } // tiles, uv, materials: { theme?, fx? } }
// const scene = await loadScenePack(assets, stageId) // the stage's official scene (tools/local-extract/scenes.py),
// → { key, stage, doc, chunks: [{ material, lightmap, group, bounds, position, uv, uv1, normal, color, index }],
// groups: { name: [x0, y0, x1, y1] }, images: { tex: { name: img }, lm: [img|null] } } null when not extracted
// //
// Everything is optional except the diffuse atlas: a missing map or mesh only drops that feature (e.g. no normal // Everything is optional except the diffuse atlas: a missing map or mesh only drops that feature (e.g. no normal
// map → flat shading of the atlas; no crate mesh → a procedural box). URLs come from data/local-assets.json only // map → flat shading of the atlas; no crate mesh → a procedural box). URLs come from data/local-assets.json only
@@ -158,5 +161,102 @@ export function loadBoardPack(assets) {
return pending; return pending;
} }
/** Manifest group of a stage's official scene, and of the textures the scenes share. */
export const sceneGroup = (stageId) => `scene/${stageId}`;
export const SCENE_TEX_GROUP = 'scene/tex';
export const SCENE_VERSION = 1;
/** Bytes per vertex of a scene chunk: position f32×3, uv f32×2, lightmap uv f32×2, normal i8×4, colour u8×4. */
export const SCENE_VERTEX_BYTES = 36;
/**
* Typed-array views of the chunks of a scene.bin (scenes.py pack_chunk); null when a record does not fit the buffer.
* @param {object} doc scene.json
* @param {ArrayBuffer} buf scene.bin
*/
export function parseSceneChunks(doc, buf) {
if (!isObj(doc) || !Array.isArray(doc.chunks) || !(buf instanceof ArrayBuffer)) return null;
const out = [];
for (const c of doc.chunks) {
const n = c?.verts | 0, m = (c?.tris | 0) * 3, o = c?.offset | 0;
const end = o + n * SCENE_VERTEX_BYTES + m * (c.index32 ? 4 : 2);
if (!(n > 0) || !(m > 0) || o < 0 || o % 4 || end > buf.byteLength || typeof c.material !== 'string') return null;
let p = o;
const position = new Float32Array(buf, p, n * 3); p += n * 12;
const uv = new Float32Array(buf, p, n * 2); p += n * 8;
const uv1 = new Float32Array(buf, p, n * 2); p += n * 8;
const normal = new Int8Array(buf, p, n * 4); p += n * 4;
const color = new Uint8Array(buf, p, n * 4); p += n * 4;
const index = c.index32 ? new Uint32Array(buf, p, m) : new Uint16Array(buf, p, m);
out.push({
material: c.material, lightmap: Number.isInteger(c.lightmap) ? c.lightmap : -1, group: typeof c.group === 'string' ? c.group : '',
bounds: Array.isArray(c.bounds) ? c.bounds : null, position, uv, uv1, normal, color, index,
});
}
return out;
}
/** Ground rect [x0, y0, x1, y1] (board space) of every named group of chunks (the scene's switchable parts). */
export function sceneGroupRects(chunks) {
const out = {};
for (const c of chunks || []) {
if (!c.group || !c.bounds) continue;
const b = c.bounds, r = out[c.group];
out[c.group] = r ? [Math.min(r[0], b[0]), Math.min(r[1], b[1]), Math.max(r[2], b[3]), Math.max(r[3], b[4])] : [b[0], b[1], b[3], b[4]];
}
return out;
}
async function fetchBuffer(url) {
try { const r = await fetch(url, { cache: 'no-cache' }); return r.ok ? await r.arrayBuffer() : null; } catch { return null; }
}
const sceneCache = new Map();
/** Does the local-art manifest list the official scene of `stageId`? (manifest only) */
export async function sceneListed(assets, stageId) {
if (!stageId || !assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function') return false;
try { await assets.local(); } catch { return false; }
return !!assets.localUrl(sceneGroup(stageId), 'scene') && !!assets.localUrl(sceneGroup(stageId), 'bin');
}
/**
* Load a stage's official scene (scene.json + scene.bin + lightmap pages + albedo textures). A missing texture or
* lightmap page only drops that map; a missing or malformed scene gives null (→ the procedural board).
* @returns {Promise<object|null>}
*/
export function loadScenePack(assets, stageId) {
if (typeof stageId !== 'string' || !stageId) return Promise.resolve(null);
if (sceneCache.has(stageId)) return sceneCache.get(stageId);
const pending = (async () => {
if (!(await sceneListed(assets, stageId)) || typeof assets.image !== 'function') return null;
const url = (g, n) => { const u = assets.localUrl(g, n); return typeof u === 'string' && u ? encodeURI(u) : null; };
const group = sceneGroup(stageId);
const [doc, buf] = await Promise.all([fetchJson(url(group, 'scene')), fetchBuffer(url(group, 'bin'))]);
if (!isObj(doc) || doc.version !== SCENE_VERSION || !buf) return null;
const chunks = parseSceneChunks(doc, buf);
if (!chunks || !chunks.length) return null;
const img = async (u) => {
if (!u) return null;
const i = await assets.image(u, { retry: true }).catch(() => null);
return i && (i.width || i.naturalWidth) > 0 ? i : null;
};
const mats = isObj(doc.materials) ? doc.materials : {};
const names = [...new Set(Object.values(mats).map((m) => (isObj(m) && typeof m.map === 'string' ? m.map : null)).filter(Boolean))];
const pages = Math.max(0, Math.min(16, doc.lightmaps | 0));
const [texList, lm] = await Promise.all([
Promise.all(names.map((n) => img(url(SCENE_TEX_GROUP, n)))),
Promise.all(Array.from({ length: pages }, (_, i) => img(url(group, `lm${i}`)))),
]);
const tex = {};
names.forEach((n, i) => { if (texList[i]) tex[n] = texList[i]; });
return { key: `${url(group, 'scene')}#${doc.binBytes | 0}`, stage: stageId, doc, chunks, groups: sceneGroupRects(chunks), images: { tex, lm } };
})().catch((err) => { console.warn('[board3d] scene load failed', err); return null; }).then((pack) => {
if (!pack && sceneCache.get(stageId) === pending) sceneCache.delete(stageId);
return pack;
});
sceneCache.set(stageId, pending);
return pending;
}
/** Forget the cached pack and the three.js import attempt (tests / hot reload). */ /** Forget the cached pack and the three.js import attempt (tests / hot reload). */
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; } export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; sceneCache.clear(); }
+127
View File
@@ -8,6 +8,7 @@
// MT_autochessi_BG → unlit TX_autochessi_BG × 0.713 (Torappu/Unlit/Texture, _Color) // MT_autochessi_BG → unlit TX_autochessi_BG × 0.713 (Torappu/Unlit/Texture, _Color)
// MT_wind_device → unlit TX_wind_device (the blower mesh) // MT_wind_device → unlit TX_wind_device (the blower mesh)
// [opt]start_end_add / _ab → the gate / objective boxes: Torappu/Particles/Additive (2 · tint · tex) / AlphaBlend // [opt]start_end_add / _ab → the gate / objective boxes: Torappu/Particles/Additive (2 · tint · tex) / AlphaBlend
// the materials of an official battlefield scene (load.js loadScenePack) → sceneMaterial / sceneWaterMaterial
// Every lit material gets the "focus" spotlight: fragments outside the camera's field rect fade towards `dim` // Every lit material gets the "focus" spotlight: fragments outside the camera's field rect fade towards `dim`
// (a soft world-space falloff), so the active field reads like the original's lit board without re-building // (a soft world-space falloff), so the active field reads like the original's lit board without re-building
// geometry when the camera moves. // geometry when the camera moves.
@@ -343,6 +344,132 @@ export function smogMaterial(THREE, tex, focus) {
}); });
} }
/**
* Tuning of the official scene materials (sceneMaterial). The official shader (Torappu/Scene/StandardRealtimeShadow) is
* not reproduced: the albedo is lit by the baked indirect light of the scene's lightmap plus the scene's key light
* masked by its baked shadow, in linear light (Unity's Shadowmask lighting mode). `range` — the linear light a lightmap
* texel of 1.0 stands for when scene.json has no lightRange; [ASSUMED] `ambient` — the light of a mesh without a
* lightmap page; `direct` / `indirect` — gains of the two terms (1 = the scene's own values).
*/
export const SCENE_LIGHT = Object.freeze({ range: 8.0, ambient: 0.2, direct: 0.8, indirect: 0.4 });
const colorOf = (info, key, fallback = [1, 1, 1, 1]) => {
const c = info && info.colors && Array.isArray(info.colors[key]) ? info.colors[key] : fallback;
return [0, 1, 2, 3].map((i) => (Number.isFinite(c[i]) ? c[i] : fallback[i]));
};
const floatOf = (info, key, fallback) => (info && info.floats && Number.isFinite(info.floats[key]) ? info.floats[key] : fallback);
const hasKeyword = (info, kw) => !!(info && Array.isArray(info.keywords) && info.keywords.includes(kw));
/** Blend class of a scene material record (scene.json materials[name]): 'opaque' | 'cutout' | 'blend'. */
export function sceneBlend(info) {
if (hasKeyword(info, '_ALPHABLEND_ON') || hasKeyword(info, '_ALPHAPREMULTIPLY_ON')) return 'blend';
return hasKeyword(info, '_ALPHATEST_ON') ? 'cutout' : 'opaque';
}
/**
* A lit material of an official scene: albedo (`map`, × _Color) × (the lightmap's baked light + key light · N·L ·
* baked shadow (lightmap A, by _ShadowStrength)), alpha test / blend by the material's keywords, the focus falloff of
* the other board materials. `map` is an sRGB texture; `lightMap` holds (light / lightRange)^(1/2.2) in RGB (no colour
* space). Geometry attributes: position, normal, uv, aLm (lightmap uv), aColor.
* @param {{ map?: any, lightMap?: any, info?: object, lightRange?: number, light?: { dir: number[], color: number[], intensity: number } }} o
*/
export function sceneMaterial(THREE, o, focus) {
const info = o.info || null;
const blend = sceneBlend(info);
const L = o.light || { dir: [0.36, 0.45, 0.82], color: [1, 1, 1], intensity: 1 };
const k = (Number.isFinite(L.intensity) ? L.intensity : 1) * SCENE_LIGHT.direct;
const lin = (v) => Math.pow(Math.max(0, v), 2.2); // the scene's colours are authored in gamma space
const col = colorOf(info, '_Color');
const uniforms = {
uMap: { value: o.map || null }, uLm: { value: o.lightMap || null },
uColor: { value: new THREE.Vector4(lin(col[0]), lin(col[1]), lin(col[2]), col[3]) },
uCutoff: { value: blend === 'cutout' ? floatOf(info, '_Cutoff', 0.5) : 0 },
uLmScale: { value: o.lightMap ? (o.lightRange > 0 ? o.lightRange : SCENE_LIGHT.range) * SCENE_LIGHT.indirect : 0 },
uAmbient: { value: o.lightMap ? 0 : SCENE_LIGHT.ambient },
uShadow: { value: floatOf(info, '_ShadowStrength', 1) },
uLightDir: { value: new THREE.Vector3(...L.dir).normalize() },
uLightColor: { value: new THREE.Vector3(lin(L.color[0]) * k, lin(L.color[1]) * k, lin(L.color[2]) * k) },
...focus,
};
const m = new THREE.ShaderMaterial({
uniforms,
defines: { ...(o.map ? { SCENE_MAP: 1 } : {}), ...(o.lightMap ? { SCENE_LM: 1 } : {}) },
vertexShader: [
'attribute vec2 aLm;',
'varying vec2 vUv; varying vec2 vLm; varying vec3 vN; varying vec3 vWorld;',
'void main() {',
' vUv = uv; vLm = aLm;',
' vN = normalize(mat3(modelMatrix) * normal);',
' vec4 w = modelMatrix * vec4(position, 1.0);',
' vWorld = w.xyz;',
' gl_Position = projectionMatrix * viewMatrix * w;',
'}',
].join('\n'),
fragmentShader: [
'uniform sampler2D uMap; uniform sampler2D uLm; uniform vec4 uColor; uniform float uCutoff;',
'uniform float uLmScale; uniform float uAmbient; uniform float uShadow; uniform vec3 uLightDir; uniform vec3 uLightColor;',
FOCUS_GLSL,
'varying vec2 vUv; varying vec2 vLm; varying vec3 vN; varying vec3 vWorld;',
'void main() {',
' vec4 a = uColor;',
' #ifdef SCENE_MAP',
' a *= texture2D(uMap, vUv);',
' #endif',
' if (a.a < uCutoff) discard;',
' vec3 light = vec3(uAmbient); float shade = 1.0;',
' #ifdef SCENE_LM',
' vec4 lm = texture2D(uLm, vLm);',
' light += pow(lm.rgb, vec3(2.2)) * uLmScale; shade = mix(1.0, lm.a, uShadow);',
' #endif',
' vec3 n = normalize(gl_FrontFacing ? vN : -vN);',
' light += uLightColor * max(dot(n, uLightDir), 0.0) * shade;',
' gl_FragColor = vec4(a.rgb * light * focusMask(vWorld.xy), a.a);',
' #include <colorspace_fragment>',
'}',
].join('\n'),
transparent: blend === 'blend', depthWrite: blend !== 'blend',
});
m.userData.blend = blend;
return m;
}
/** A water surface of an official scene (Torappu/Scene/StylizedWater): the board's water shader in the material's colours. */
export function sceneWaterMaterial(THREE, tex, focus, info) {
const deep = colorOf(info, '_DeepWaterColor', [0.03, 0.2, 0.27, 0.7]), shallow = colorOf(info, '_ShallowWaterColor', [0.09, 0.46, 0.55, 0.5]);
const uniforms = {
uTime: { value: 0 }, uNormal: { value: tex.waterN || null }, uCaustics: { value: tex.caustics || null },
uDeep: { value: new THREE.Vector4(...deep) }, uShallow: { value: new THREE.Vector4(...shallow) }, ...focus,
};
return new THREE.ShaderMaterial({
uniforms,
defines: tex.waterN && tex.caustics ? { WATER_MAPS: 1 } : {},
vertexShader: TERRAIN_VERT,
fragmentShader: [
'uniform float uTime; uniform sampler2D uNormal; uniform sampler2D uCaustics; uniform vec4 uDeep; uniform vec4 uShallow;',
FOCUS_GLSL,
'varying vec2 vUv; varying vec3 vWorld;',
'void main() {',
' float diff = 0.5, spec = 0.0, ca = 0.0;',
' #ifdef WATER_MAPS',
' vec2 p = vWorld.xy * 0.45;',
' vec3 n1 = texture2D(uNormal, p + vec2(uTime * 0.03, uTime * 0.021)).xyz * 2.0 - 1.0;',
' vec3 n2 = texture2D(uNormal, p * 1.7 - vec2(uTime * 0.025, -uTime * 0.017)).xyz * 2.0 - 1.0;',
' vec3 n = normalize(vec3(n1.xy + n2.xy, 2.2));',
' vec3 L = normalize(vec3(-0.45, -0.3, 0.84));',
' diff = clamp(dot(n, L), 0.0, 1.0);',
' spec = pow(clamp(dot(reflect(-L, n), normalize(vec3(0.0, -0.5, 0.866))), 0.0, 1.0), 40.0);',
' ca = texture2D(uCaustics, vWorld.xy * 0.6 + n.xy * 0.08 + vec2(uTime * 0.02, 0.0)).r;',
' #endif',
' vec4 w = mix(uDeep, uShallow, 0.08 + 0.2 * diff);',
' vec3 c = pow(w.rgb, vec3(2.2)) + ca * vec3(0.03, 0.05, 0.05) + spec * vec3(0.5, 0.6, 0.65);',
' gl_FragColor = vec4(c * focusMask(vWorld.xy), clamp(w.a + 0.2, 0.0, 1.0));',
' #include <colorspace_fragment>',
'}',
].join('\n'),
transparent: true, depthWrite: false,
});
}
/** /**
* Soft studio environment for image-based light (PMREM of a canvas equirect: bright sky with light panels above the * Soft studio environment for image-based light (PMREM of a canvas equirect: bright sky with light panels above the
* horizon, dark void below): gives the glass hatches, steel and gold frames their sheen and fills the shadows. * horizon, dark void below): gives the glass hatches, steel and gold frames their sheen and fills the shadows.
+105 -7
View File
@@ -4,6 +4,8 @@
// //
// const board = new BoardScene(THREE, pack, { canvas, quality }) (pack = load.js loadBoardPack) // const board = new BoardScene(THREE, pack, { canvas, quality }) (pack = load.js loadBoardPack)
// board.setStage(stage) geometry from the stage grid (layout.js) + devices + gates + terrain // board.setStage(stage) geometry from the stage grid (layout.js) + devices + gates + terrain
// board.setScenePack(id, pack) the official scene of stage `id` (load.js loadScenePack): drawn instead of the
// procedural tiles and background while that stage is set (null → procedural)
// board.setArea(rects) the built areas (layout.js AREAS / areaFor: normal field + pen, 联防, boss) // board.setArea(rects) the built areas (layout.js AREAS / areaFor: normal field + pen, 联防, boss)
// board.setFocus(rect) the lit field (others fade, animated) // board.setFocus(rect) the lit field (others fade, animated)
// board.setBattleRect(rect|null) crates / turrets inside a running battle are sim units: static ones hide // board.setBattleRect(rect|null) crates / turrets inside a running battle are sim units: static ones hide
@@ -23,7 +25,7 @@ import { buildBoard, objToBoard, boxProjectUV, ROWS, COLS, DEVICE_H, AREAS } fro
import { surfaceUV } from './atlas.js'; import { surfaceUV } from './atlas.js';
import { import {
focusUniforms, makeTexture, boardMaterial, glassMaterial, decalMaterial, pipeMaterial, unlitMaterial, gateMaterial, glowMaterial, focusUniforms, makeTexture, boardMaterial, glassMaterial, decalMaterial, pipeMaterial, unlitMaterial, gateMaterial, glowMaterial,
waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap, waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap, sceneMaterial, sceneWaterMaterial,
} from './materials.js'; } from './materials.js';
import { syncThreeCamera } from '../projection.js'; import { syncThreeCamera } from '../projection.js';
@@ -53,6 +55,17 @@ export function gatePulse(t, phase = 0) {
return 0.134 + (0.229 - 0.134) * k; return 0.134 + (0.229 - 0.134) * k;
} }
/**
* Is a named part of an official scene (its ground rect [x0, y0, x1, y1]) shown with the built `areas`? The official
* scene keeps each field as a sub-graphic it switches on (HIDDEN@n: the left / right normal field, the boss field);
* a part is shown when its centre lies in one of the areas.
*/
export function sceneGroupVisible(rect, areas) {
if (!Array.isArray(rect) || rect.length < 4) return true;
const cx = (rect[0] + rect[2]) / 2, cy = (rect[1] + rect[3]) / 2;
return (areas || []).some((a) => cx >= a.c0 - 0.5 && cx <= a.c1 + 0.5 && cy >= a.r0 - 0.5 && cy <= a.r1 + 0.5);
}
const areaKey = (list) => (list || []).map((a) => `${a.r0},${a.r1},${a.c0},${a.c1}`).sort().join(';'); const areaKey = (list) => (list || []).map((a) => `${a.r0},${a.r1},${a.c0},${a.c1}`).sort().join(';');
const mergeInto = (list) => { const mergeInto = (list) => {
@@ -194,6 +207,10 @@ export class BoardScene {
this.scene.add(this.root, this.dynamic); this.scene.add(this.root, this.dynamic);
this.stageKey = null; this.stageKey = null;
this.stage = null; this.stage = null;
this.scenePacks = new Map(); // stage id → official scene pack (setScenePack)
this.sceneRes = new Map(); // pack key → { tex, lm, mats } GPU resources (kept across rebuilds)
this.official = null;
this.sceneWater = [];
this.area = AREAS.normal; this.area = AREAS.normal;
this.areaKey = areaKey(AREAS.normal); this.areaKey = areaKey(AREAS.normal);
this.battleRect = null; this.battleRect = null;
@@ -267,7 +284,8 @@ export class BoardScene {
/** Rebuild everything for a stage (no-op when the same stage object/grid is set again). */ /** Rebuild everything for a stage (no-op when the same stage object/grid is set again). */
setStage(stage) { setStage(stage) {
if (this.destroyed) return; if (this.destroyed) return;
const key = stage ? `${stage.id || ''}|${(stage.rows || []).join('/')}|${JSON.stringify((stage.devices || []).map((d) => [d.key, d.pos, d.active, d.dir]))}` : ''; const official = stage ? this.scenePacks.get(stage.id) || null : null;
const key = stage ? `${stage.id || ''}|${official ? official.key : ''}|${(stage.rows || []).join('/')}|${JSON.stringify((stage.devices || []).map((d) => [d.key, d.pos, d.active, d.dir]))}` : '';
if (key === this.stageKey) return; if (key === this.stageKey) return;
this.stageKey = key; this.stageKey = key;
this.stage = stage || null; this.stage = stage || null;
@@ -276,19 +294,96 @@ export class BoardScene {
const board = buildBoard(stage, { uv: this.pack?.uv || null, area: this.area }); const board = buildBoard(stage, { uv: this.pack?.uv || null, area: this.area });
this.board = board; this.board = board;
const M = this.meshes = {}; const M = this.meshes = {};
M.board = this._mesh(board.buckets.board, this.mat.board); this.official = official && this._buildScene(official) ? official : null;
M.glass = this._mesh(board.buckets.glass, this.mat.glass); if (!this.official) {
M.decal = this._mesh(board.buckets.decal, this.mat.decal, { cast: false }); M.board = this._mesh(board.buckets.board, this.mat.board);
M.pipe = this._mesh(board.buckets.pipe, this.mat.pipe); M.glass = this._mesh(board.buckets.glass, this.mat.glass);
M.decal = this._mesh(board.buckets.decal, this.mat.decal, { cast: false });
M.pipe = this._mesh(board.buckets.pipe, this.mat.pipe);
}
this._buildDevices(board); this._buildDevices(board);
this._buildGates(board); this._buildGates(board);
this._buildEdges(board); this._buildEdges(board);
this._buildTerrain(board); this._buildTerrain(board);
this._buildBackground(board); if (!this.official) this._buildBackground(board);
this._fitShadow(board); this._fitShadow(board);
this.renderer.shadowMap.needsUpdate = true; this.renderer.shadowMap.needsUpdate = true;
} }
/** Use (or drop, with null) the official scene of stage `id`; rebuilds when that stage is the current one. */
setScenePack(id, pack) {
if (this.destroyed || typeof id !== 'string' || !id) return false;
const prev = this.scenePacks.get(id) || null;
if (prev === (pack || null)) return false;
if (prev) this._disposeScene(prev);
if (pack) this.scenePacks.set(id, pack); else this.scenePacks.delete(id);
if (this.stage && this.stage.id === id) { this.stageKey = null; this.setStage(this.stage); }
return true;
}
/** Textures and materials of a scene pack (created once per pack, kept across rebuilds). */
_sceneResources(pack) {
let res = this.sceneRes.get(pack.key);
if (res) return res;
const T = this.THREE;
const aniso = Math.min(8, this.renderer.capabilities?.getMaxAnisotropy?.() || 1);
const tex = {}, lm = [];
for (const [name, img] of Object.entries(pack.images?.tex || {})) tex[name] = makeTexture(T, img, { aniso, repeat: true });
for (const img of pack.images?.lm || []) lm.push(img ? makeTexture(T, img, { srgb: false }) : null);
res = { tex, lm, mats: new Map() };
this.sceneRes.set(pack.key, res);
return res;
}
_disposeScene(pack) {
const res = this.sceneRes.get(pack.key);
if (!res) return;
for (const m of res.mats.values()) m.dispose();
for (const t of [...Object.values(res.tex), ...res.lm]) t?.dispose?.();
this.sceneRes.delete(pack.key);
}
/** The official scene's meshes: one per chunk of a shown part (the base + the fields of the built areas). */
_buildScene(pack) {
const T = this.THREE;
const res = this._sceneResources(pack);
const infos = pack.doc?.materials || {};
this.sceneWater = [];
let n = 0;
for (const c of pack.chunks || []) {
if (c.group && !sceneGroupVisible(pack.groups?.[c.group], this.area)) continue;
const info = infos[c.material] || null;
const water = info?.kind === 'water';
const mk = `${c.material}|${water ? '' : c.lightmap}`;
let mat = res.mats.get(mk);
if (!mat) {
mat = water
? sceneWaterMaterial(T, this.tex, this.focus, info)
: sceneMaterial(T, {
map: res.tex[info?.map] || null, lightMap: c.lightmap >= 0 ? (res.lm[c.lightmap] || null) : null, info,
light: pack.doc?.light || null, lightRange: pack.doc?.lightRange,
}, this.focus);
res.mats.set(mk, mat);
}
if (water) this.sceneWater.push(mat);
const g = new T.BufferGeometry();
g.setAttribute('position', new T.BufferAttribute(c.position, 3));
g.setAttribute('normal', new T.BufferAttribute(c.normal, 4, true));
g.setAttribute('uv', new T.BufferAttribute(c.uv, 2));
g.setAttribute('aLm', new T.BufferAttribute(c.uv1, 2));
g.setAttribute('aColor', new T.BufferAttribute(c.color, 4, true));
g.setIndex(new T.BufferAttribute(c.index, 1));
g.computeBoundingSphere();
const mesh = new T.Mesh(g, mat);
mesh.castShadow = false; mesh.receiveShadow = false;
mesh.renderOrder = water ? 1 : mat.userData.blend === 'blend' ? 2 : mat.userData.blend === 'cutout' ? -3 : -4;
mesh.matrixAutoUpdate = false; mesh.updateMatrix();
this.root.add(mesh);
this.meshes[`scene${n++}`] = mesh;
}
return n > 0;
}
_buildDevices(board) { _buildDevices(board) {
const crates = [], blowers = []; const crates = [], blowers = [];
this.staticCrates = []; this.staticCrates = [];
@@ -550,6 +645,7 @@ export class BoardScene {
for (let i = this.flashes.length - 1; i >= 0; i--) { const f = this.flashes[i]; f.t += dt; if (f.t > 1.2) this.flashes.splice(i, 1); else flash = Math.max(flash, 1 - f.t / 1.2); } for (let i = this.flashes.length - 1; i >= 0; i--) { const f = this.flashes[i]; f.t += dt; if (f.t > 1.2) this.flashes.splice(i, 1); else flash = Math.max(flash, 1 - f.t / 1.2); }
for (const m of [this.mat.gateEndAdd, this.mat.gateEndAb]) if (m) m.uniforms.uFlash.value.setRGB(flash, flash * 0.12, flash * 0.1); for (const m of [this.mat.gateEndAdd, this.mat.gateEndAb]) if (m) m.uniforms.uFlash.value.setRGB(flash, flash * 0.12, flash * 0.1);
for (const k of ['water', 'mire', 'infection', 'smog']) this.mat[k].uniforms.uTime.value = t; for (const k of ['water', 'mire', 'infection', 'smog']) this.mat[k].uniforms.uTime.value = t;
if (this.official) for (const m of this.sceneWater || []) m.uniforms.uTime.value = t;
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
this.frames++; this.frames++;
if (t0) this.lastMs = this.lastMs * 0.9 + ((typeof performance !== 'undefined' ? performance.now() : t0) - t0) * 0.1; if (t0) this.lastMs = this.lastMs * 0.9 + ((typeof performance !== 'undefined' ? performance.now() : t0) - t0) * 0.1;
@@ -566,6 +662,8 @@ export class BoardScene {
this.destroyed = true; this.destroyed = true;
for (const d of [...this.devices]) d.destroy(); for (const d of [...this.devices]) d.destroy();
this._clear(); this._clear();
for (const p of [...this.scenePacks.values()]) this._disposeScene(p);
this.scenePacks.clear();
this.mat.edge?.map?.dispose?.(); // canvas dash texture (not in this.tex) this.mat.edge?.map?.dispose?.(); // canvas dash texture (not in this.tex)
for (const m of Object.values(this.mat)) m?.dispose?.(); for (const m of Object.values(this.mat)) m?.dispose?.();
for (const t of Object.values(this.tex)) t?.dispose?.(); for (const t of Object.values(this.tex)) t?.dispose?.();
+394
View File
@@ -0,0 +1,394 @@
// test/render/board3d-scene.test.js — the official battlefield scenes (DESIGN §15): tools/local-extract/scenes.py
// (the scene table, the board-space conversion, the chunk records, the sub-graphic groups, the RGBM lightmap decode;
// run without UnityPy), fetch_scenes.py's bundle selection, the browser side (render/board3d/load.js chunk reader and
// loader with a fake store; scene.js BoardScene with a stub renderer: the scene replaces the procedural tiles and
// background, its parts follow the built areas, a missing scene keeps the procedural board), and — when scenes were
// extracted here — the files the manifest lists.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as THREE from 'three';
import { BoardScene, sceneGroupVisible } from '../../public/js/render/board3d/scene.js';
import { AREAS } from '../../public/js/render/board3d/layout.js';
import { resolveUvTable } from '../../public/js/render/board3d/atlas.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { sceneMaterial, sceneWaterMaterial, sceneBlend, focusUniforms, SCENE_LIGHT } from '../../public/js/render/board3d/materials.js';
import {
parseSceneChunks, sceneGroupRects, loadScenePack, sceneListed, resetBoardPack, sceneGroup, SCENE_TEX_GROUP, SCENE_VERSION, SCENE_VERTEX_BYTES,
} from '../../public/js/render/board3d/load.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const TOOL = path.join(ROOT, 'tools/local-extract');
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
const HAS_PIL = !!PY && spawnSync(PY, ['-c', 'import PIL'], { env: ENV }).status === 0;
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
const manifest = (() => { try { return JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8')); } catch { return null; } })();
function py(code) {
const src = `import sys, json, struct\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport scenes as s\n${code}`;
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
return JSON.parse(r.stdout);
}
/** One quad as a scene chunk record + its bytes (the layout of scenes.py pack_chunk). */
function quadChunk({ x0 = 0, y0 = 0, z = 0, material = 'M', lightmap = 0, group = '' } = {}) {
const n = 4, idx = [0, 1, 2, 0, 2, 3];
const buf = new ArrayBuffer(n * SCENE_VERTEX_BYTES + idx.length * 2);
new Float32Array(buf, 0, 12).set([x0, y0, z, x0 + 1, y0, z, x0 + 1, y0 + 1, z, x0, y0 + 1, z]);
new Float32Array(buf, 48, 8).set([0, 0, 1, 0, 1, 1, 0, 1]);
new Float32Array(buf, 80, 8).set([0, 0, 0.5, 0, 0.5, 0.5, 0, 0.5]);
new Int8Array(buf, 112, 16).set([0, 0, 127, 0, 0, 0, 127, 0, 0, 0, 127, 0, 0, 0, 127, 0]);
new Uint8Array(buf, 128, 16).fill(255);
new Uint16Array(buf, 144, 6).set(idx);
return { rec: { material, lightmap, group, verts: n, tris: 2, index32: false, bounds: [x0, y0, z, x0 + 1, y0 + 1, z] }, bytes: new Uint8Array(buf) };
}
/** scene.json + scene.bin of a few quads. */
function fakeScene(parts) {
const chunks = parts.map(quadChunk);
const total = chunks.reduce((a, c) => a + c.bytes.length, 0);
const bin = new Uint8Array(total);
let off = 0;
const recs = chunks.map((c) => { bin.set(c.bytes, off); const r = { ...c.rec, offset: off }; off += c.bytes.length; return r; });
const doc = {
version: SCENE_VERSION, stage: 'act2autochess_m02', lightmaps: 1, lightRange: 8, binBytes: total,
light: { dir: [0.36, 0.45, 0.82], color: [1, 0.93, 0.87], intensity: 1.1 },
materials: {
M: { shader: 'Torappu/Scene/StandardRealtimeShadow', keywords: [], colors: { _Color: [1, 1, 1, 1] }, floats: { _ShadowStrength: 0.9 }, textures: {}, kind: 'lit', map: 'TX_A' },
Cut: { shader: 'Torappu/Scene/StandardRealtimeShadow', keywords: ['_ALPHATEST_ON'], colors: {}, floats: { _Cutoff: 0.4 }, textures: {}, kind: 'lit', map: 'TX_A' },
W: { shader: 'Torappu/Scene/StylizedWater', keywords: [], colors: { _DeepWaterColor: [0, 0.27, 0.4, 0.67] }, floats: {}, textures: {}, kind: 'water' },
},
chunks: recs,
};
return { doc, bin: bin.buffer };
}
const PARTS = [
{ material: 'M', x0: 9, y0: 15 }, // the base (pen side)
{ material: 'W', x0: 20, y0: 12, lightmap: -1 }, // water of the base
{ material: 'M', x0: 4, y0: 9, group: 'HIDDEN@1' }, // left normal field
{ material: 'Cut', x0: 5, y0: 9, group: 'HIDDEN@1' },
{ material: 'M', x0: 16, y0: 9, group: 'HIDDEN@2' }, // right normal field
{ material: 'M', x0: 10, y0: 3, group: 'HIDDEN@3' }, // boss field
];
function fakeScenePack() {
const { doc, bin } = fakeScene(PARTS);
const chunks = parseSceneChunks(doc, bin);
const img = { width: 4, height: 4 };
return { key: 'scene-test', stage: doc.stage, doc, chunks, groups: sceneGroupRects(chunks), images: { tex: { TX_A: img }, lm: [img] } };
}
function stubRenderer() {
const calls = [];
return {
calls,
domElement: { addEventListener() {}, removeEventListener() {} },
shadowMap: { enabled: true, needsUpdate: false },
capabilities: { getMaxAnisotropy: () => 8 },
info: { render: { calls: 7, triangles: 1234 }, memory: { textures: 3, geometries: 5 } },
setClearColor() {}, setPixelRatio() {}, setSize() {}, render() {}, dispose() { calls.push(['dispose']); },
};
}
function fakePack() {
const img = { width: 4, height: 4 };
return { key: 'test', images: { D: img, common: img, gate: img, BG: img }, meshes: { gate: {} }, tiles: null, uv: resolveUvTable(null), materials: {} };
}
const sceneMeshes = (board) => Object.entries(board.meshes).filter(([k, m]) => k.startsWith('scene') && m).map(([, m]) => m);
describe('scenes.py (no UnityPy needed)', { skip: !PY && 'no python3' }, () => {
test('scene table: one prebuilt scene per battlefield of data/stages.json, with the theme bundles of its materials', () => {
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const { scenes } = JSON.parse(r.stdout);
assert.equal(scenes.sub, 'scene');
assert.equal(scenes.tex, SCENE_TEX_GROUP);
const ids = scenes.stages.map((x) => x.id);
const battlefields = Object.keys(stages).filter((id) => /^act\dautochess_m\d+$/.test(id)).sort();
assert.deepEqual([...ids].sort(), battlefields, 'every battlefield has a scene');
for (const st of scenes.stages) {
const season = st.id.replace(/_m\d+$/, '');
assert.equal(st.bundle, `scenes/activities/${season}/level_${st.id}/level_${st.id}.ab`);
assert.ok(st.deps.length > 0 && st.deps.every((d) => d.startsWith('arts/maps/') && d.endsWith('.ab')), st.id);
}
const sand = scenes.stages.filter((x) => x.deps.includes('arts/maps/map_autochesssand/res.ab')).map((x) => x.id);
assert.deepEqual(sand, ['act1autochess_m01', 'act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04', 'act1autochess_m05',
'act1autochess_m06', 'act1autochess_m07', 'act2autochess_m02', 'act2autochess_m04'], 'the sand-theme battlefields (official asset index)');
assert.ok(!scenes.stages.some((x) => x.deps.includes('arts/maps/map_autochess/res.ab')), 'no battlefield uses the metal theme');
});
test('--only scene / scene/<id> selects scenes; a client without scene bundles skips them', () => {
const out = py(`import extract as e
print(json.dumps({'all': len(e.scene_ids([])), 'scene': len(e.scene_ids(['scene'])), 'one': e.scene_ids(['scene/act2autochess_m02']),
'other': e.scene_ids(['emoticon']), 'missing': s.find_scene('act2autochess_m02', ['/no/such/root']) is None}))`);
assert.deepEqual(out, { all: 11, scene: 11, one: ['act2autochess_m02'], other: [], missing: true });
});
test('board space: Unity anchor-local (x, y, −z); transforms compose; a mirrored transform has a negative determinant', () => {
const out = py(`m = s.mul(s.trs((1, 2, 3), (0, 0, 0, 1), (2, 2, 2)), s.trs((1, 0, 0), (0, 0, 0, 1), (1, 1, 1)))
rot = s.trs((0, 0, 0), (0, 0, 0.7071068, 0.7071068), (1, 1, 1))
print(json.dumps({'board': s.to_board((-6.0, -4.0, -0.4), (-10.0, -9.0, 0.0)), 'm': [m[0][3], m[1][3], m[2][3], m[0][0]],
'rot': [round(rot[0][1], 4), round(rot[1][0], 4)], 'det': [s.det3(s.IDENTITY), s.det3(s.trs((0, 0, 0), (0, 0, 0, 1), (1, 1, -1)))]}))`);
assert.deepEqual(out.board, [4, 5, 0.4], 'tile (row 5, col 4) on high ground');
assert.deepEqual(out.m, [3, 2, 3, 2]);
assert.deepEqual(out.rot, [-1, 1], 'a quarter turn about z');
assert.deepEqual(out.det, [1, -1]);
});
test('pack_chunk: 36 bytes per vertex + 16-bit indices padded to 4 bytes (what load.js parseSceneChunks reads)', () => {
const out = py(`ch = {'pos': [(0, 0, 0), (1, 0, 0), (0, 1, 0.5)], 'uv': [(0, 0), (1, 0), (0, 1)], 'uv1': [(0.1, 0.2), (0.3, 0.2), (0.1, 0.4)],
'nrm': [(0, 0, 1)] * 3, 'col': [(1, 0.5, 0, 1)] * 3, 'idx': [0, 1, 2]}
data, idx32 = s.pack_chunk(ch)
print(json.dumps({'len': len(data), 'idx32': idx32, 'hex': data.hex()}))`);
assert.equal(out.idx32, false);
assert.equal(out.len, 3 * SCENE_VERTEX_BYTES + 8, '3 vertices, 3 indices (6 bytes) padded to 8');
const bin = Uint8Array.from(Buffer.from(out.hex, 'hex')).buffer;
const [c] = parseSceneChunks({ chunks: [{ material: 'M', lightmap: 0, group: '', verts: 3, tris: 1, offset: 0, index32: false, bounds: [0, 0, 0, 1, 1, 0.5] }] }, bin);
assert.deepEqual([...c.position], [0, 0, 0, 1, 0, 0, 0, 1, 0.5]);
assert.deepEqual([...c.uv1].map((v) => Math.round(v * 10) / 10), [0.1, 0.2, 0.3, 0.2, 0.1, 0.4]);
assert.deepEqual([...c.normal.slice(0, 4)], [0, 0, 127, 0]);
assert.deepEqual([...c.color.slice(0, 4)], [255, 128, 0, 255]);
assert.deepEqual([...c.index], [0, 1, 2]);
});
test('hidden_groups: the objects listed after each sub-graphic name of a MapSubGraphicHolder', () => {
const out = py(`def name(t):
b = t.encode(); return struct.pack('<i', len(b)) + b + b'\\0' * (-len(b) % 4)
ptr = lambda pid: struct.pack('<iq', 0, pid)
raw = struct.pack('<i', 2) + name('HIDDEN@1') + struct.pack('<ii', 1, 2) + ptr(11) + ptr(12) + struct.pack('<i', 1) + ptr(21) + struct.pack('<i', 0) \\
+ name('HIDDEN@2') + struct.pack('<ii', 1, 1) + ptr(13) + struct.pack('<i', 0) + ptr(99)
objs = {11: 1, 12: 1, 13: 1, 21: 1}
print(json.dumps({str(k): v for k, v in s.hidden_groups(raw, objs).items()}))`);
assert.deepEqual(out, { 11: 'HIDDEN@1', 12: 'HIDDEN@1', 21: 'HIDDEN@1', 13: 'HIDDEN@2' }, 'pointers to objects of the file; an unknown id is no pointer');
});
test('decode_rgbm: the RGBM light as (L / LIGHT_RANGE)^(1/2.2); a page without alpha is kept', { skip: !HAS_PIL && 'no Pillow' }, () => {
const out = py(`from PIL import Image
img = Image.new('RGBA', (2, 1)); img.putdata([(255, 128, 0, 51), (255, 255, 255, 255)])
d = s.decode_rgbm(img)
print(json.dumps({'mode': d.mode, 'px': list(d.getdata()), 'range': s.LIGHT_RANGE, 'plain': s.decode_rgbm(Image.new('RGB', (1, 1), (9, 8, 7))).getpixel((0, 0))}))`);
assert.equal(out.mode, 'RGB');
// texel (1, 0.5, 0, A = 0.2): L = 5^2.2 · 0.2^2.2 · rgb → stored (L / range)^(1/2.2) = (5^2.2 / range)^(1/2.2) · 0.2 · rgb^(1/2.2)
const k = (5 ** 2.2 / out.range) ** (1 / 2.2);
const want = [1, 128 / 255, 0].map((c) => Math.round(255 * Math.min(1, k * 0.2) * c ** (1 / 2.2)));
for (let i = 0; i < 3; i++) assert.ok(Math.abs(out.px[0][i] - want[i]) <= 2, `channel ${i}: ${out.px[0][i]} vs ${want[i]}`);
assert.deepEqual(out.px[1], [255, 255, 255], 'clamped at the range');
assert.deepEqual(out.plain, [9, 8, 7]);
});
test('fetch_scenes.py: CDN file names and the bundles of the scene table', () => {
const src = `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport fetch_scenes as f\nnames = ['arts/maps/map_autochess/res.ab', 'scenes/activities/act2autochess/level_act2autochess_m02/level_act2autochess_m02.ab', 'scenes/activities/act2autochess/level_act2autochess_m02/level_act2autochess_m02/lightingdata.ab', 'scenes/activities/act44side/level_act44side_01/level_act44side_01.ab']\nprint(json.dumps({'dat': f.dat_name('scenes/a/level_x/level_x.ab'), 'hash': f.dat_name('avg/a#1.ab'), 'wanted': f.wanted(names, ['act2autochess_m02'])}))`;
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const out = JSON.parse(r.stdout);
assert.equal(out.dat, 'scenes_a_level_x_level_x.dat');
assert.equal(out.hash, 'avg_a__1.dat');
assert.equal(out.wanted.length, 2, 'the scene and its lighting data, nothing else');
});
});
describe('scene chunks (load.js)', () => {
test('parseSceneChunks: typed-array views per chunk; a record that does not fit the buffer rejects the scene', () => {
const { doc, bin } = fakeScene(PARTS);
const chunks = parseSceneChunks(doc, bin);
assert.equal(chunks.length, PARTS.length);
assert.equal(chunks[0].position.length, 12);
assert.deepEqual([...chunks[2].position.slice(0, 3)], [4, 9, 0]);
assert.deepEqual([...chunks[0].index], [0, 1, 2, 0, 2, 3]);
assert.equal(chunks[1].lightmap, -1);
assert.equal(parseSceneChunks(doc, bin.slice(0, bin.byteLength - 4)), null, 'truncated bin');
assert.equal(parseSceneChunks({ chunks: [{ ...doc.chunks[0], offset: 2 }] }, bin), null, 'unaligned offset');
assert.equal(parseSceneChunks({ chunks: [{ ...doc.chunks[0], verts: 0 }] }, bin), null);
assert.equal(parseSceneChunks(null, bin), null);
assert.equal(parseSceneChunks(doc, null), null);
});
test('sceneGroupRects / sceneGroupVisible: the official sub-graphics are the fields of the built areas', () => {
const { doc, bin } = fakeScene(PARTS);
const rects = sceneGroupRects(parseSceneChunks(doc, bin));
assert.deepEqual(Object.keys(rects).sort(), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
assert.deepEqual(rects['HIDDEN@1'], [4, 9, 6, 10], 'union of the group\'s chunks');
const shown = (areas) => Object.keys(rects).filter((g) => sceneGroupVisible(rects[g], areas)).sort();
assert.deepEqual(shown(AREAS.normal), ['HIDDEN@1'], 'own field');
assert.deepEqual(shown(AREAS.unite), ['HIDDEN@1', 'HIDDEN@2'], 'both normal fields');
assert.deepEqual(shown(AREAS.boss), ['HIDDEN@3'], 'the boss field');
assert.deepEqual(shown(AREAS.all), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
assert.equal(sceneGroupVisible(null, AREAS.normal), true, 'a part without bounds is shown');
});
test('loadScenePack: scene.json + scene.bin + lightmap pages + textures through the store; null when not listed or malformed', async () => {
resetBoardPack();
const { doc, bin } = fakeScene(PARTS);
const group = sceneGroup('act2autochess_m02');
const urls = { [`${group}|scene`]: '/s/scene.json', [`${group}|bin`]: '/s/scene.bin', [`${group}|lm0`]: '/s/lm0.webp', [`${SCENE_TEX_GROUP}|TX_A`]: '/t/TX_A.webp' };
const seen = [];
const store = { local: async () => ({ groups: {} }), localUrl: (g, n) => urls[`${g}|${n}`] || null, image: async (u) => { seen.push(u); return { width: 8, height: 8 }; } };
const original = globalThis.fetch;
let json = doc;
globalThis.fetch = async (u) => (u === '/s/scene.json' ? { ok: true, json: async () => json } : u === '/s/scene.bin' ? { ok: true, arrayBuffer: async () => bin } : { ok: false });
try {
assert.equal(await sceneListed(store, 'act2autochess_m02'), true);
assert.equal(await sceneListed(store, 'act2autochess_m01'), false);
assert.equal(await loadScenePack(store, 'act2autochess_m01'), null, 'not extracted');
const pack = await loadScenePack(store, 'act2autochess_m02');
assert.equal(pack.chunks.length, PARTS.length);
assert.deepEqual(seen.sort(), ['/s/lm0.webp', '/t/TX_A.webp']);
assert.ok(pack.images.tex.TX_A && pack.images.lm[0]);
assert.deepEqual(Object.keys(pack.groups).sort(), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
assert.equal(await loadScenePack(store, 'act2autochess_m02'), pack, 'cached');
resetBoardPack();
json = { ...doc, version: SCENE_VERSION + 1 };
assert.equal(await loadScenePack(store, 'act2autochess_m02'), null, 'unknown version → the procedural board');
json = doc;
assert.ok(await loadScenePack(store, 'act2autochess_m02'), 'a failed load is not cached');
assert.equal(await loadScenePack(null, 'act2autochess_m02').catch(() => 'threw'), await loadScenePack(store, 'act2autochess_m02'), 'cached per stage');
} finally { globalThis.fetch = original; resetBoardPack(); }
});
});
describe('scene materials', () => {
test('sceneBlend: alpha test / blend by the Unity keywords', () => {
assert.equal(sceneBlend({ keywords: ['_NORMALMAP'] }), 'opaque');
assert.equal(sceneBlend({ keywords: ['_ALPHATEST_ON'] }), 'cutout');
assert.equal(sceneBlend({ keywords: ['_ALPHABLEND_ON', '_EMISSION'] }), 'blend');
assert.equal(sceneBlend(null), 'opaque');
});
test('sceneMaterial: linear albedo tint, lightmap range, key light, cutoff; unlit fallback without a lightmap page', () => {
const focus = focusUniforms(THREE);
const map = new THREE.Texture(), lm = new THREE.Texture();
const info = { keywords: ['_ALPHATEST_ON'], colors: { _Color: [0.5, 1, 1, 1] }, floats: { _Cutoff: 0.4, _ShadowStrength: 0.9 } };
const m = sceneMaterial(THREE, { map, lightMap: lm, info, lightRange: 8, light: { dir: [0, 0, 2], color: [1, 1, 1], intensity: 1.1 } }, focus);
assert.equal(m.uniforms.uMap.value, map);
assert.equal(m.uniforms.uLm.value, lm);
assert.ok(Math.abs(m.uniforms.uColor.value.x - 0.5 ** 2.2) < 1e-6, 'authored in gamma space');
assert.equal(m.uniforms.uCutoff.value, 0.4);
assert.equal(m.uniforms.uShadow.value, 0.9);
assert.equal(m.uniforms.uLmScale.value, 8 * SCENE_LIGHT.indirect);
assert.deepEqual(m.uniforms.uLightDir.value.toArray(), [0, 0, 1], 'normalised');
assert.ok(Math.abs(m.uniforms.uLightColor.value.x - 1.1 * SCENE_LIGHT.direct) < 1e-6);
assert.equal(m.uniforms.uFocus, focus.uFocus, 'the shared focus falloff');
assert.equal(m.transparent, false);
assert.ok(m.defines.SCENE_MAP && m.defines.SCENE_LM);
const bare = sceneMaterial(THREE, { info: { keywords: ['_ALPHABLEND_ON'] } }, focus);
assert.equal(bare.uniforms.uLmScale.value, 0);
assert.equal(bare.uniforms.uAmbient.value, SCENE_LIGHT.ambient);
assert.equal(bare.uniforms.uCutoff.value, 0);
assert.equal(bare.transparent, true);
assert.equal(bare.depthWrite, false);
assert.ok(!bare.defines.SCENE_MAP && !bare.defines.SCENE_LM);
const w = sceneWaterMaterial(THREE, {}, focus, { colors: { _DeepWaterColor: [0, 0.27, 0.4, 0.67] } });
assert.deepEqual(w.uniforms.uDeep.value.toArray(), [0, 0.27, 0.4, 0.67]);
assert.equal(w.transparent, true);
assert.ok(!w.defines.WATER_MAPS, 'no water maps: a flat tint');
});
});
describe('BoardScene with an official scene (stub renderer)', () => {
const stage = stages.act2autochess_m02;
test('the scene replaces the procedural tiles and background; devices, gates, edges and terrain stay', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setStage(stage);
assert.ok(board.meshes.board && board.meshes.bg, 'procedural board first');
assert.equal(board.official, null);
assert.equal(board.setScenePack(stage.id, fakeScenePack()), true);
assert.ok(board.official, 'official scene on');
assert.ok(!board.meshes.board && !board.meshes.glass && !board.meshes.decal && !board.meshes.pipe && !board.meshes.bg && !board.meshes.bgShadow);
assert.ok(board.meshes.crates && board.meshes.edges && board.meshes.mire, Object.keys(board.meshes).join());
const meshes = sceneMeshes(board);
assert.equal(meshes.length, 4, 'base ×2 + the left normal field ×2 (AREAS.normal)');
for (const m of meshes) {
assert.equal(m.geometry.getAttribute('position').count, 4);
assert.ok(m.geometry.getAttribute('aLm') && m.geometry.getAttribute('aColor').normalized && m.geometry.getAttribute('normal').normalized);
assert.equal(m.castShadow, false);
}
const water = meshes.find((m) => m.material.uniforms.uDeep);
assert.ok(water && water.material.transparent);
board.resize(1280, 720, 1);
board.render(presetCamera('normal', { width: 1280, height: 720 }), 2.5);
assert.equal(water.material.uniforms.uTime.value, 2.5, 'water animates');
board.destroy();
});
test('the scene\'s fields follow the built areas; materials and textures are reused across rebuilds', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
const pack = fakeScenePack();
board.setScenePack(stage.id, pack);
board.setStage(stage);
const lit = sceneMeshes(board).find((m) => m.material.uniforms.uMap).material;
assert.equal(lit.uniforms.uMap.value.colorSpace, THREE.SRGBColorSpace);
assert.equal(lit.uniforms.uLm.value.colorSpace, THREE.NoColorSpace);
board.setArea(AREAS.boss);
assert.equal(sceneMeshes(board).length, 3, 'base ×2 + the boss field');
board.setArea(AREAS.unite);
assert.equal(sceneMeshes(board).length, 5, 'base ×2 + both normal fields (2 + 1)');
assert.equal(sceneMeshes(board).find((m) => m.material.uniforms.uMap).material, lit, 'same material object');
assert.equal(board.sceneRes.size, 1);
board.destroy();
assert.equal(board.sceneRes.size, 0, 'scene resources disposed');
});
test('another stage, or dropping the scene, shows the procedural board again', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setScenePack(stage.id, fakeScenePack());
board.setStage(stages.act2autochess_m01);
assert.equal(board.official, null);
assert.ok(board.meshes.board && board.meshes.bg && !sceneMeshes(board).length, 'a stage without a scene');
board.setStage(stage);
assert.ok(board.official && !board.meshes.board);
assert.equal(board.setScenePack(stage.id, null), true);
assert.equal(board.official, null);
assert.ok(board.meshes.board && board.meshes.bg && !sceneMeshes(board).length);
assert.equal(board.setScenePack(stage.id, null), false, 'nothing to drop');
assert.equal(board.setScenePack('', fakeScenePack()), false);
board.destroy();
});
test('a scene whose chunks are all hidden parts of other areas keeps the procedural board', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
const { doc, bin } = fakeScene([{ material: 'M', x0: 10, y0: 3, group: 'HIDDEN@3' }]);
const chunks = parseSceneChunks(doc, bin);
board.setScenePack(stage.id, { key: 'k2', stage: stage.id, doc, chunks, groups: sceneGroupRects(chunks), images: { tex: {}, lm: [] } });
board.setStage(stage);
assert.equal(board.official, null);
assert.ok(board.meshes.board && board.meshes.bg);
board.destroy();
});
});
describe('extracted scenes (when scene bundles were extracted here)', { skip: !Object.keys(manifest?.groups || {}).some((g) => g.startsWith('scene/act')) && 'no scene in data/local-assets.json' }, () => {
const groups = manifest?.groups || {};
const file = (p) => path.join(ROOT, 'public', p.replace(/^\//, ''));
for (const g of Object.keys(groups).filter((k) => k.startsWith('scene/act'))) {
test(`${g}: scene.json records fit scene.bin; lightmap pages and textures are listed and on disk`, { skip: !existsSync(file(groups[g].scene?.path || '/x')) && 'files not on this machine' }, () => {
const doc = JSON.parse(readFileSync(file(groups[g].scene.path), 'utf8'));
const bin = readFileSync(file(groups[g].bin.path));
assert.equal(doc.version, SCENE_VERSION);
assert.equal(`scene/${doc.stage}`, g);
assert.equal(doc.binBytes, bin.length);
const chunks = parseSceneChunks(doc, bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.length));
assert.ok(chunks && chunks.length === doc.chunks.length && chunks.length > 0);
assert.ok(chunks.some((c) => !c.group), 'a base part');
for (const c of chunks) {
let max = 0;
for (const i of c.index) if (i > max) max = i;
assert.ok(max < c.position.length / 3, 'indices in range');
assert.ok(doc.materials[c.material], `material ${c.material}`);
if (c.lightmap >= 0) assert.ok(c.lightmap < doc.lightmaps && groups[g][`lm${c.lightmap}`], `lightmap page ${c.lightmap}`);
}
for (const m of Object.values(doc.materials)) if (m.map) assert.ok(groups[SCENE_TEX_GROUP]?.[m.map], `texture ${m.map}`);
const L = doc.light;
assert.ok(L && Math.abs(Math.hypot(...L.dir) - 1) < 1e-3 && L.dir[2] > 0, 'the key light shines down on the board');
});
}
});
+135
View File
@@ -0,0 +1,135 @@
// Official battlefield scenes in the 0.2.5 asset pack (PR #533, GitHub #532).
// The repository never stores the bundles or the extracted files. The full release zip includes
// public/assets/local/scene when the maintainer extracted it; a lite zip and a source install do not,
// and setup may fetch + extract on that machine. Missing extractor dependencies keep the metal board.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { artPlan, formatSummary, isPlayerFile, isRefused } from '../tools/package.mjs';
import { localGaps, sceneInstallPlan } from '../tools/setup.mjs';
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
test('sceneInstallPlan: fetch only with a client and extractor deps; otherwise the metal board', () => {
const present = sceneInstallPlan({ hasScenes: true, client: false });
assert.equal(present.run, false);
assert.equal(present.state, 'ok');
assert.match(present.detail, /金属地块/);
const check = sceneInstallPlan({ hasScenes: false, check: true, client: true, python: true });
assert.equal(check.run, false);
assert.equal(check.state, 'skip');
assert.match(check.detail, /--scenes/);
assert.match(check.detail, /金属地块/);
const noClient = sceneInstallPlan({ hasScenes: false, client: false, python: true });
assert.equal(noClient.run, false);
assert.equal(noClient.state, 'skip');
assert.match(noClient.detail, /完整包/);
assert.match(noClient.detail, /金属地块/);
const incomplete = sceneInstallPlan({ hasScenes: false, client: 'incomplete', python: true, force: true });
assert.equal(incomplete.run, false);
assert.equal(incomplete.state, 'warn');
assert.match(incomplete.detail, /金属地块/);
const noPy = sceneInstallPlan({ hasScenes: false, client: true, python: false });
assert.equal(noPy.run, false);
assert.equal(noPy.state, 'warn');
assert.match(noPy.detail, /Python/);
assert.match(noPy.detail, /金属地块/);
const noDeps = sceneInstallPlan({ hasScenes: false, client: true, python: true, deps: false });
assert.equal(noDeps.run, false);
assert.equal(noDeps.state, 'warn');
assert.match(noDeps.detail, /UnityPy/);
assert.match(noDeps.detail, /金属地块/);
const declined = sceneInstallPlan({ hasScenes: false, client: true, python: true, declined: true });
assert.equal(declined.run, false);
assert.equal(declined.state, 'skip');
const ask = sceneInstallPlan({ hasScenes: false, client: true, python: true });
assert.equal(ask.run, false);
assert.equal(ask.state, 'ask');
assert.match(ask.detail, /金属地块/);
for (const extra of [{ force: true, declined: true }, { yes: true }]) {
const go = sceneInstallPlan({ hasScenes: false, client: true, python: true, ...extra });
assert.equal(go.run, true, JSON.stringify(extra));
assert.match(go.detail, /金属地块/);
}
});
test('an older extraction is told it lacks the battlefield scenes', () => {
assert.equal(localGaps({ enemySpines: true, tokenSpines: true }), '');
assert.match(localGaps({ enemySpines: true, tokenSpines: true, scenes: false }), /各战场官方场景/);
assert.match(localGaps({ enemySpines: false, tokenSpines: false, scenes: false }), /各战场官方场景/);
});
test('official scene bundles and extracted scenes are not git files; the extractor scripts are', () => {
const gi = fs.readFileSync(path.join(ROOT, '.gitignore'), 'utf8');
assert.match(gi, /^public\/assets\/$/m);
assert.match(gi, /^data\/local-assets\.json$/m);
assert.match(gi, /^\.cache\/$/m);
assert.match(gi, /official-scenes/);
assert.equal(isRefused('.cache/official-scenes/26-04-13-12-31-42_e9b560/scenes/a.ab'), true);
assert.equal(isRefused('public/assets/local/scene/act2autochess_m02/scene.bin'), false);
assert.equal(isPlayerFile('public/assets/local/scene/act2autochess_m02/scene.bin'), false);
assert.equal(isPlayerFile('tools/local-extract/scenes.py'), true);
assert.equal(isPlayerFile('tools/local-extract/fetch_scenes.py'), true);
});
test('the full asset plan ships extracted scenes; the lite plan ships none', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-scenes-'));
try {
const sceneDir = path.join(dir, 'public/assets/local/scene/act2autochess_m02');
const texDir = path.join(dir, 'public/assets/local/scene/tex');
fs.mkdirSync(sceneDir, { recursive: true });
fs.mkdirSync(texDir, { recursive: true });
fs.mkdirSync(path.join(dir, 'data'), { recursive: true });
fs.writeFileSync(path.join(dir, 'data/assets.json'), '{}\n');
fs.writeFileSync(path.join(sceneDir, 'scene.bin'), 'mesh-bytes');
fs.writeFileSync(path.join(sceneDir, 'scene.json'), '{}\n');
fs.writeFileSync(path.join(texDir, 'TX_A.webp'), 'webp');
fs.writeFileSync(path.join(dir, 'data/local-assets.json'), JSON.stringify({
version: 1,
groups: {
'scene/act2autochess_m02': {
scene: { path: '/assets/local/scene/act2autochess_m02/scene.json' },
bin: { path: '/assets/local/scene/act2autochess_m02/scene.bin' },
},
'scene/tex': { TX_A: { path: '/assets/local/scene/tex/TX_A.webp' } },
},
}));
const full = artPlan(dir, { lite: false });
for (const f of [
'public/assets/local/scene/act2autochess_m02/scene.bin',
'public/assets/local/scene/act2autochess_m02/scene.json',
'public/assets/local/scene/tex/TX_A.webp',
'data/local-assets.json',
]) assert.ok(full.files.includes(f), f);
assert.ok(full.scenes.includes('public/assets/local/scene/act2autochess_m02/scene.bin'));
assert.equal(full.scenes.length, 3);
const lite = artPlan(dir, { lite: true });
assert.deepEqual(lite.files, []);
assert.deepEqual(lite.scenes, []);
const summary = formatSummary({
lite: false, version: '0.2.5-dev', tracked: [], art: [], localArt: true, generated: [], dropped: {}, orphans: [], held: [],
scanned: 0, names: 0, problems: [], scenes: { files: full.scenes, bytes: 9 + 3 + 4 },
bytes: { tracked: 0, art: 16, modules: 0, vendor: 0, total: 16, leftOut: 0, orphans: 0 },
});
assert.match(summary, /battlefield scenes: 3 files/);
const empty = formatSummary({
lite: false, version: '0.2.5-dev', tracked: [], art: [], localArt: true, generated: [], dropped: {}, orphans: [], held: [],
scanned: 0, names: 0, problems: [], scenes: { files: [], bytes: 0 },
bytes: { tracked: 0, art: 0, modules: 0, vendor: 0, total: 0, leftOut: 0, orphans: 0 },
});
assert.match(empty, /battlefield scenes: none/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+1 -1
View File
@@ -251,7 +251,7 @@ async function main() {
const local = checkLocal(); const local = checkLocal();
const client = findClient(null); const client = findClient(null);
row(local.manifest ? 'ok' : 'skip', '本地客户端美术(可选)', local.manifest row(local.manifest ? 'ok' : 'skip', '本地客户端美术(可选)', local.manifest
? `${local.count} 项${local.board3d ? ',3D 棋盘可用' : ',无棋盘贴图(2D 棋盘)'}${local.board3d && !local.tiles ? ';缺 tiles.json → node tools/setup.mjs' : ''}` ? `${local.count} 项${local.board3d ? ',3D 棋盘可用' : ',无棋盘贴图(2D 棋盘)'}${local.scenes ? ',已提取各战场官方场景' : ',无各战场官方场景(3D 棋盘用金属地块;node tools/setup.mjs --scenes)'}${local.board3d && !local.tiles ? ';缺 tiles.json → node tools/setup.mjs' : ''}`
: client ? `检测到 ${client.kind} 客户端 → node tools/setup.mjs --local` : `未提取:${LOCAL_ART_FALLBACK}(${LOCAL_ART_COPY_HINT})`); : client ? `检测到 ${client.kind} 客户端 → node tools/setup.mjs --local` : `未提取:${LOCAL_ART_FALLBACK}(${LOCAL_ART_COPY_HINT})`);
if (client || local.manifest) { if (client || local.manifest) {
const py = findPython(); const py = findPython();
+50 -2
View File
@@ -34,6 +34,10 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
source as <name>_rgb.png / _rough.png source as <name>_rgb.png / _rough.png
- WebP copies of the board textures every player downloads with the 3D board (WEBP), written next to their PNGs; - WebP copies of the board textures every player downloads with the 3D board (WEBP), written next to their PNGs;
the manifest lists the copy the manifest lists the copy
- the official battlefield scenes (scenes.py SCENES: one prebuilt scene per 卫戍协议 battlefield) → scene/<stageId>/
scene.json + scene.bin + lightmap pages and scene/tex/ (the materials' albedo textures). The scene bundles ship
only while the season runs: they are read from the client when it has them, else from --scenes <dir> (default
.cache/official-scenes/*, filled by tools/local-extract/fetch_scenes.py)
Usage: Usage:
python3 -m venv .venv && .venv/bin/pip install -r tools/local-extract/requirements.txt python3 -m venv .venv && .venv/bin/pip install -r tools/local-extract/requirements.txt
@@ -41,6 +45,7 @@ Usage:
python3 tools/local-extract/extract.py --print-jobs (the job table as JSON; needs no dependencies) python3 tools/local-extract/extract.py --print-jobs (the job table as JSON; needs no dependencies)
python3 tools/local-extract/extract.py --webp (only the WebP copies, from the PNGs already extracted — python3 tools/local-extract/extract.py --webp (only the WebP copies, from the PNGs already extracted —
e.g. the local art copied from a release bundle; needs Pillow) e.g. the local art copied from a release bundle; needs Pillow)
.venv/bin/python tools/local-extract/extract.py --only scene [--scenes <dir of in-season bundles>]
Writes <out>/**.png|.webp|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just Writes <out>/**.png|.webp|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just
the jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing the jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing
@@ -55,6 +60,7 @@ import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import scenes # noqa: E402 (the battlefield scene table and exporter; imports UnityPy only when it runs)
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
HOME = Path.home() HOME = Path.home()
@@ -722,6 +728,39 @@ def write_spine(objects, sda, sub, name, out_root, manifest, log):
return len(files) + len(pages) return len(files) + len(pages)
def scene_ids(only):
"""The stage ids of scenes.SCENES that run under --only (all with no prefix, `scene` or a parent of it)."""
return spine_ids(only, scenes.SCENE_SUB, [sid for sid, _deps in scenes.SCENES])
def scene_roots(ab_root, extra):
"""AB roots searched for the scene bundles, in order: the client, --scenes dirs, then every version kept under
.cache/official-scenes (newest name first)."""
kept = ROOT / '.cache' / 'official-scenes'
cached = sorted((p for p in kept.iterdir() if p.is_dir()), reverse=True) if kept.is_dir() else []
return [Path(ab_root), *[Path(p) for p in extra], *cached]
def export_scenes(ab_root, out_root, manifest, log, ids, roots):
"""scenes.SCENES (the ids in `ids`) → scene/<stageId>/ (scenes.export_scene); a stage without a scene bundle in
any root is skipped."""
root = Path(ab_root)
shader_deps = dep_bundles(root, {'Material'})
n = 0
for sid, deps in scenes.SCENES:
if sid not in ids:
continue
src = scenes.find_scene(sid, roots)
if src is None:
log(f'skip (no scene bundle: off-season client, see --scenes) {scenes.scene_bundle(sid)}')
continue
try:
n += scenes.export_scene(sid, deps, src, root, out_root, manifest, log, material_info, shader_deps)
except Exception as e: # one broken scene must not stop the others
log(f'FAIL scene {sid}: {e}')
return n
def export_enemy_spines(ab_root, out_root, manifest, log, ids=None): def export_enemy_spines(ab_root, out_root, manifest, log, ids=None):
"""ENEMY_SPINES (or `ids`) from the enemy art bundles (each bundle loaded on its own; stops once every model was """ENEMY_SPINES (or `ids`) from the enemy art bundles (each bundle loaded on its own; stops once every model was
found).""" found)."""
@@ -802,6 +841,9 @@ def main():
ap.add_argument('--manifest', default=str(ROOT / 'data/local-assets.json')) ap.add_argument('--manifest', default=str(ROOT / 'data/local-assets.json'))
ap.add_argument('--only', action='append', default=[], metavar='SUBDIR', ap.add_argument('--only', action='append', default=[], metavar='SUBDIR',
help='only run the jobs whose output subdir starts with this prefix (repeatable), e.g. emoticon') help='only run the jobs whose output subdir starts with this prefix (repeatable), e.g. emoticon')
ap.add_argument('--scenes', action='append', default=[], metavar='DIR',
help='an AB root holding the battlefield scene bundles of an in-season version (repeatable; '
'searched after --game and before .cache/official-scenes/*)')
ap.add_argument('--print-jobs', action='store_true', help='print the job table as JSON and exit') ap.add_argument('--print-jobs', action='store_true', help='print the job table as JSON and exit')
ap.add_argument('--webp', action='store_true', ap.add_argument('--webp', action='store_true',
help='only write the WebP copies of the board textures from the PNGs already under --out and list ' help='only write the WebP copies of the board textures from the PNGs already under --out and list '
@@ -816,7 +858,10 @@ def main():
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs, print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
'derived': derived, 'webp': webp, 'derived': derived, 'webp': webp,
'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB, 'ids': ENEMY_SPINES}, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB, 'ids': ENEMY_SPINES},
'tokenSpines': {'bundles': TOKEN_PACKS, 'sub': TOKEN_SPINE_SUB, 'ids': TOKEN_SPINES}}, 'tokenSpines': {'bundles': TOKEN_PACKS, 'sub': TOKEN_SPINE_SUB, 'ids': TOKEN_SPINES},
'scenes': {'sub': scenes.SCENE_SUB, 'tex': scenes.TEX_SUB,
'stages': [{'id': sid, 'bundle': scenes.scene_bundle(sid), 'deps': deps}
for sid, deps in scenes.SCENES]}},
ensure_ascii=False)) ensure_ascii=False))
return 0 return 0
if args.webp: if args.webp:
@@ -829,7 +874,8 @@ def main():
jobs = select_jobs(args.only) jobs = select_jobs(args.only)
enemy_ids = spine_ids(args.only, ENEMY_SPINE_SUB, ENEMY_SPINES) enemy_ids = spine_ids(args.only, ENEMY_SPINE_SUB, ENEMY_SPINES)
token_ids = spine_ids(args.only, TOKEN_SPINE_SUB, TOKEN_SPINES) token_ids = spine_ids(args.only, TOKEN_SPINE_SUB, TOKEN_SPINES)
if not jobs and not enemy_ids and not token_ids: stage_ids = scene_ids(args.only)
if not jobs and not enemy_ids and not token_ids and not stage_ids:
print(f'--only {args.only}: no job matches', file=sys.stderr) print(f'--only {args.only}: no job matches', file=sys.stderr)
return 2 return 2
out_root = Path(args.out) out_root = Path(args.out)
@@ -842,6 +888,8 @@ def main():
total += export_enemy_spines(ab_root, out_root, manifest, print, enemy_ids) total += export_enemy_spines(ab_root, out_root, manifest, print, enemy_ids)
if token_ids: if token_ids:
total += export_token_spines(ab_root, out_root, manifest, print, token_ids) total += export_token_spines(ab_root, out_root, manifest, print, token_ids)
if stage_ids:
total += export_scenes(ab_root, out_root, manifest, print, stage_ids, scene_roots(ab_root, args.scenes))
old = {} old = {}
if args.only and Path(args.manifest).exists(): if args.only and Path(args.manifest).exists():
try: try:
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Download the 卫戍协议 battlefield scene bundles of an in-season version from the official asset CDN (optional).
The scene bundles (scenes.py) ship only while the season runs: an off-season client has none, and the current
hot-update list does not name them. The CDN still serves the versions that had them; this fetches the bundles of
scenes.SCENES (scene, lighting data, lighting settings) into .cache/official-scenes/<version>/<bundle path>, where
extract.py --only scene finds them. Every file is checked against the md5 of that version's hot-update list.
Usage:
python3 tools/local-extract/fetch_scenes.py [--version 26-04-13-12-31-42_e9b560] [--platform Android] [--out <dir>]
python3 tools/local-extract/fetch_scenes.py --list (what would be downloaded; reads the hot-update list only)
VERSION is the last version of 盟约·下半 that carried all eleven scenes (the `Data:` version of the game-data commits
of that season). Needs no dependencies. Everything is (c) Hypergryph; for private, non-commercial fan use only.
"""
import argparse
import hashlib
import io
import json
import sys
import urllib.request
import zipfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import scenes # noqa: E402
ROOT = Path(__file__).resolve().parents[2]
VERSION = '26-04-13-12-31-42_e9b560'
PLATFORM = 'Android'
CDN = 'https://ak.hycdn.cn/assetbundle/official/{platform}/assets/{version}/'
def dat_name(bundle):
"""File name of a bundle on the CDN: the path without its extension, '/' → '_', '#' → '__', as .dat (a zip)."""
return bundle.rsplit('.', 1)[0].replace('/', '_').replace('#', '__') + '.dat'
def wanted(names, stage_ids):
"""The bundles of `names` (a hot-update list) that belong to the scenes of `stage_ids`, in list order."""
dirs = tuple(scenes.scene_bundle(sid).rsplit('/', 1)[0] + '/' for sid in stage_ids)
return [n for n in names if n.startswith(dirs)]
def get(url):
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=180) as r:
return r.read()
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--version', default=VERSION)
ap.add_argument('--platform', default=PLATFORM)
ap.add_argument('--out', help='output root (default .cache/official-scenes/<version>)')
ap.add_argument('--list', action='store_true', help='print the bundles and sizes, download nothing')
args = ap.parse_args()
base = CDN.format(platform=args.platform, version=args.version)
out = Path(args.out) if args.out else ROOT / '.cache' / 'official-scenes' / args.version
try:
infos = json.loads(get(base + 'hot_update_list.json'))['abInfos']
except Exception as e:
print(f'cannot read the hot-update list of {args.version} ({args.platform}): {e}', file=sys.stderr)
return 2
by_name = {x['name']: x for x in infos}
names = wanted(list(by_name), [sid for sid, _ in scenes.SCENES])
if not names:
print(f'version {args.version} lists no 卫戍协议 scene bundle (an off-season version?)', file=sys.stderr)
return 1
if args.list:
for n in names:
print(f'{by_name[n].get("totalSize", 0):>9} {n}')
print(f'{sum(by_name[n].get("totalSize", 0) for n in names):>9} total ({len(names)} bundles)')
return 0
bad = 0
for n in names:
dst = out / n
md5 = by_name[n].get('md5')
if dst.is_file() and hashlib.md5(dst.read_bytes()).hexdigest() == md5:
print(f'have {n}')
continue
try:
with zipfile.ZipFile(io.BytesIO(get(base + dat_name(n)))) as z:
blob = z.read(z.namelist()[0])
except Exception as e:
print(f'FAIL {n}: {e}', file=sys.stderr)
bad += 1
continue
if md5 and hashlib.md5(blob).hexdigest() != md5:
print(f'FAIL {n}: md5 mismatch', file=sys.stderr)
bad += 1
continue
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(blob)
print(f'got {n} ({len(blob)} bytes)')
print(f'done: {len(names) - bad}/{len(names)} bundles under {out}')
return 1 if bad else 0
if __name__ == '__main__':
sys.exit(main())
+457
View File
@@ -0,0 +1,457 @@
"""The official battlefield scenes of 卫戍协议 (DESIGN §15): every battlefield is a prebuilt Unity scene
(scenes/activities/<season>/level_<stage>/level_<stage>.ab, with …/lightingdata.ab beside it) whose tile models are
baked into "Combined Mesh (root: scene)" objects, plus loose meshes for the parts the game shows and hides.
The scene bundles ship only while the season runs (the hot-update list of an off-season client has none), so they are
read from the client's AB root when it has them and else from a directory of bundles kept from an in-season version
(extract.py --scenes <dir>, default .cache/official-scenes/*; tools/local-extract/fetch_scenes.py fills it). Materials
and their textures come from the theme bundles of the local client (SCENES lists each scene's, as the official asset
index records them: the scene bundle's dependencies).
Output per stage, under <out>/scene/<stageId>/ (manifest group scene/<stageId>):
scene.json { version, stage, light, lightmaps, materials, groups, chunks: [{ material, lightmap, group, verts,
tris, offset, index32, bounds }] }
scene.bin the chunks' vertex data in board space (x = column, y = row, z = height: Unity's anchor-local
(x, y, −z)); per chunk: position f32×3, uv f32×2, lightmap uv f32×2 (scale / offset applied),
normal i8×4, colour u8×4, indices u16|u32 (padded to 4 bytes)
lm<i>.png lightmap page i (+ a WebP copy, the one the manifest lists): RGB = the baked indirect light (Lightmap-<i>_comp_light, stored RGBM: decode_rgbm),
A = the key light's baked shadow (Lightmap-<i>_comp_shadowmask, R)
and the materials' albedo textures under <out>/scene/tex/ (manifest group scene/tex, shared by the scenes).
A chunk's `group` is '' for what the scene shows at rest and the sub-graphic name (HIDDEN@1 …, MapSubGraphicHolder)
for the meshes the game switches on later.
"""
import json
import re
import struct
from pathlib import Path
SAND = ['arts/maps/map_autochesssand/res.ab', 'arts/maps/map_survivaloasis_city/res.ab', 'arts/maps/map_yumen/res.ab']
SHORE = ['arts/maps/map_doss_shore/res.ab']
# (stage id, theme bundles of the local client that hold the scene's materials / textures / shared meshes): the
# dependencies of each scene bundle in the official asset index (Android 25-11-21-15-21-44_ee1197 for 盟约·上半,
# 26-03-13-06-11-47_14fb99 and 26-04-13-12-31-42_e9b560 for 盟约·下半 — the same in all three).
SCENES = [
('act1autochess_m01', SAND),
('act1autochess_m02', SAND),
('act1autochess_m03', SAND),
('act1autochess_m04', SAND),
('act1autochess_m05', SAND + SHORE),
('act1autochess_m06', SAND),
('act1autochess_m07', SAND),
('act2autochess_m01', ['arts/maps/map_lakegarden/res.ab', 'arts/maps/map_syracuse_metro/res.ab',
'arts/maps/common/meshes/s_background_common.ab']),
('act2autochess_m02', SAND),
('act2autochess_m03', ['arts/maps/map_kxmr/res.ab', 'arts/maps/map_kxmr/background_mesh/bg_kxmr.ab',
'arts/maps/map_lm_center/res.ab']),
('act2autochess_m04', SAND + SHORE),
]
# bundles every scene may reference besides its theme (shared water / noise textures)
COMMON_DEPS = ['arts/maps/effect.ab']
SCENE_SUB = 'scene'
TEX_SUB = 'scene/tex'
SCENE_VERSION = 1
# linear light value that a lightmap page's 1.0 stands for (decode_rgbm; scene.json lightRange)
LIGHT_RANGE = 8.0
HIDDEN_NAME = re.compile(r'^[A-Z_]+@\d+$')
WATER_SHADERS = ('StylizedWater', 'StandardWater', 'DynamicWaterTile')
def scene_bundle(stage_id):
"""Bundle path of a stage's scene, relative to an AB root (act1autochess_m01 → scenes/activities/act1autochess/…)."""
season = stage_id.rsplit('_', 1)[0]
return f'scenes/activities/{season}/level_{stage_id}/level_{stage_id}.ab'
def find_scene(stage_id, roots):
"""The first existing scene bundle of `stage_id` under `roots` (AB roots), or None."""
rel = scene_bundle(stage_id)
for root in roots:
p = Path(root) / rel
if p.is_file():
return p
return None
# ---- small pure-Python linear algebra (row-major 3×4 affine matrices: [r0, r1, r2], each [a, b, c, t]) -----------
def trs(pos, rot, scale):
"""Affine matrix of a Unity local transform (position, quaternion xyzw, scale)."""
x, y, z, w = rot
r = [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]
return [[r[i][0] * scale[0], r[i][1] * scale[1], r[i][2] * scale[2], pos[i]] for i in range(3)]
def mul(a, b):
"""a · b of two affine matrices."""
return [[a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j] + (a[i][3] if j == 3 else 0.0) for j in range(4)]
for i in range(3)]
def det3(m):
return (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]))
IDENTITY = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]
def to_board(p, anchor):
"""Unity world position → board space (x = column, y = row, z = height above the low ground)."""
return (p[0] - anchor[0], p[1] - anchor[1], -(p[2] - anchor[2]))
def pack_chunk(ch):
"""Binary record of a chunk (see the module docstring); returns (bytes, index32)."""
n = len(ch['pos'])
flat = lambda rows: [v for row in rows for v in row] # noqa: E731
out = bytearray()
out += struct.pack(f'<{n * 3}f', *flat(ch['pos']))
out += struct.pack(f'<{n * 2}f', *flat(ch['uv']))
out += struct.pack(f'<{n * 2}f', *flat(ch['uv1']))
out += struct.pack(f'<{n * 4}b', *flat([(*(max(-127, min(127, int(round(c * 127)))) for c in nr), 0) for nr in ch['nrm']]))
out += struct.pack(f'<{n * 4}B', *flat([tuple(max(0, min(255, int(round(c * 255)))) for c in col) for col in ch['col']]))
idx32 = n > 65535
out += struct.pack(f'<{len(ch["idx"])}{"I" if idx32 else "H"}', *ch['idx'])
while len(out) % 4:
out.append(0)
return bytes(out), idx32
def decode_rgbm(img):
"""RGB image of a baked lightmap page stored RGBM (Unity, linear colour space: light = 5^2.2 · A^2.2 · RGB):
the linear light L as (L / LIGHT_RANGE)^(1/2.2) per channel — = (5^2.2 / LIGHT_RANGE)^(1/2.2) · A · RGB^(1/2.2),
clamped to 1 (the renderer reads L = value^2.2 · LIGHT_RANGE). A page without alpha is taken as plain RGB."""
from PIL import Image, ImageChops
if img.mode != 'RGBA':
return img.convert('RGB')
k = (5.0 ** 2.2 / LIGHT_RANGE) ** (1 / 2.2)
r, g, b, a = img.split()
m = a.point([min(255, int(round(v * k))) for v in range(256)])
gamma = [int(round(255 * (v / 255) ** (1 / 2.2))) for v in range(256)]
return Image.merge('RGB', tuple(ImageChops.multiply(ch.point(gamma), m) for ch in (r, g, b)))
def save_image(img, out_dir, stem, sub):
"""Write `img` as <stem>.png and a WebP copy beside it (lossy at quality 95 with the alpha lossless and the RGB
under transparent texels kept, like extract.py WEBP: every player downloads these with the scene); returns the
manifest entry, which lists the copy (the PNG with a Pillow that has no WebP)."""
img.save(Path(out_dir) / f'{stem}.png')
entry = {'path': f'/assets/local/{sub}/{stem}.png', 'w': img.width, 'h': img.height}
try:
img.save(Path(out_dir) / f'{stem}.webp', 'WEBP', quality=95, method=6, alpha_quality=100, exact=True)
entry.update(path=f'/assets/local/{sub}/{stem}.webp', webp='lossy')
except Exception:
pass
return entry
def hidden_groups(raw, objects):
"""{GameObject path id: sub-graphic name} of a MapSubGraphicHolder's raw data: after each name (HIDDEN@n) come the
GameObjects and MeshTileGraphics of that group as PPtrs of this file. The script has no type tree in the bundle,
so the fields are walked as 4-byte words: a length-prefixed name, or a (file 0, path id) pointer to an object."""
out, group, i = {}, None, 0
body = raw
while i + 4 <= len(body):
v = struct.unpack_from('<i', body, i)[0]
if 0 < v < 64 and i + 4 + v <= len(body):
text = body[i + 4:i + 4 + v]
if all(32 <= b < 127 for b in text):
s = text.decode('ascii')
if HIDDEN_NAME.match(s):
group = s
i += 4 + ((v + 3) & ~3)
continue
if v == 0 and i + 12 <= len(body) and group:
pid = struct.unpack_from('<q', body, i + 4)[0]
target = objects.get(pid)
if target is not None:
out[pid] = group
i += 12
continue
i += 4
return out
def export_scene(stage_id, deps, scene_path, ab_root, out_root, manifest, log, material_info, shader_deps=()):
"""Write one stage's scene (see the module docstring). Returns the number of files written (0 on failure)."""
import aklz4 # noqa: F401 (registers the LZ4AK decoder)
import UnityPy
from UnityPy.helpers.MeshHelper import MeshHandler
from PIL import Image
try:
env = UnityPy.load(str(scene_path))
except Exception as e: # corrupt or unsupported bundle
log(f'FAIL load {scene_path}: {e}')
return 0
objects = {o.path_id: o for o in env.objects}
scene_objs = list(objects.values())
light_bundle = scene_path.with_suffix('') / 'lightingdata.ab'
extra = [light_bundle] if light_bundle.is_file() else []
for dep in [*(Path(ab_root) / d for d in [*deps, *COMMON_DEPS]), *shader_deps]:
if Path(dep).is_file():
extra.append(Path(dep))
else:
log(f' warn {stage_id}: dependency {dep} missing (its materials are skipped)')
for dep in extra:
try:
env.load_file(str(dep))
except Exception as e:
log(f' warn {stage_id}: dependency {Path(dep).name}: {e}')
# ---- hierarchy: world matrices, active flags, hidden groups ---------------------------------------------------
tf = {} # transform path id → (local matrix, father id, game object)
for o in scene_objs:
if o.type.name != 'Transform':
continue
t = o.read()
p, r, s = t.m_LocalPosition, t.m_LocalRotation, t.m_LocalScale
tf[o.path_id] = (trs((p.x, p.y, p.z), (r.x, r.y, r.z, r.w), (s.x, s.y, s.z)), t.m_Father.m_PathID, t.m_GameObject)
world_cache = {}
def world(tid):
if tid not in tf:
return IDENTITY
if tid not in world_cache:
local, father, _ = tf[tid]
world_cache[tid] = mul(world(father), local) if father else local
return world_cache[tid]
go_of_tf = {tid: rec[2].m_PathID for tid, rec in tf.items()}
tf_of_go = {gid: tid for tid, gid in go_of_tf.items()}
go_info = {} # game object path id → (name, active)
for o in scene_objs:
if o.type.name == 'GameObject':
g = o.read()
go_info[o.path_id] = (g.m_Name, bool(g.m_IsActive))
hidden = {}
for o in scene_objs:
if o.type.name != 'MonoBehaviour':
continue
raw = o.get_raw_data()
if b'@' not in raw:
continue
for pid, group in hidden_groups(raw, objects).items():
target = objects[pid]
if target.type.name == 'GameObject':
hidden[pid] = group
elif target.type.name == 'MonoBehaviour':
try:
hidden[target.read().m_GameObject.m_PathID] = group
except Exception:
pass
def state(gid):
"""(active in the hierarchy, hidden group of the object or its nearest grouped ancestor)."""
active, group, tid = True, None, tf_of_go.get(gid)
while tid in tf:
g = go_of_tf[tid]
active = active and go_info.get(g, ('', True))[1]
if group is None and g in hidden:
group = hidden[g]
tid = tf[tid][1]
return active, group
anchor = (0.0, 0.0, 0.0)
for gid, (name, _) in go_info.items():
if name == 'Anchor' and gid in tf_of_go:
m = world(tf_of_go[gid])
anchor = (m[0][3], m[1][3], m[2][3])
break
# ---- light ----------------------------------------------------------------------------------------------------
light = None
for o in scene_objs:
if o.type.name != 'Light':
continue
d = o.read_typetree()
if d.get('m_Type') != 1: # directional
continue
m = world(tf_of_go.get(d['m_GameObject']['m_PathID']))
fwd = (m[0][2], m[1][2], m[2][2]) # the light shines along its +z
n = (fwd[0] ** 2 + fwd[1] ** 2 + fwd[2] ** 2) ** 0.5 or 1.0
c = d.get('m_Color', {})
light = {'dir': [round(-fwd[0] / n, 5), round(-fwd[1] / n, 5), round(fwd[2] / n, 5)],
'color': [round(c.get(k, 1.0), 5) for k in 'rgb'], 'intensity': round(d.get('m_Intensity', 1.0), 5)}
break
# ---- renderers → chunks -----------------------------------------------------------------------------------------
mesh_cache, materials, mat_tex = {}, {}, {}
chunks = {} # (material, lightmap, group) → {'pos': [], …}
def mesh_data(pptr):
key = (getattr(pptr, 'm_FileID', 0), pptr.m_PathID)
if key not in mesh_cache:
try:
h = MeshHandler(pptr.read())
h.process()
mesh_cache[key] = (h.m_Vertices, h.m_Normals, h.m_UV0, h.m_UV1, h.m_Colors, h.get_triangles())
except Exception as e:
log(f' warn {stage_id}: mesh #{pptr.m_PathID}: {e}')
mesh_cache[key] = None
return mesh_cache[key]
def material_name(pptr):
if not pptr.m_PathID:
return None
try:
mat = pptr.read()
except Exception: # its bundle is not in the client
return None
name = mat.m_Name
if name not in materials:
info = material_info(mat)
shader = info.get('shader') or ''
info['kind'] = 'water' if any(w in shader for w in WATER_SHADERS) else 'lit'
materials[name] = info
try:
for slot, env_ in mat.m_SavedProperties.m_TexEnvs:
if slot == '_MainTex' and env_.m_Texture.m_PathID:
mat_tex[name] = env_.m_Texture
except Exception:
pass
return name
skipped = 0
for o in scene_objs:
if o.type.name != 'MeshRenderer':
continue
r = o.read()
gid = r.m_GameObject.m_PathID
mf = None
for c in r.m_GameObject.read().m_Component:
if c.component.type.name == 'MeshFilter':
mf = c.component.read()
if mf is None or not mf.m_Mesh.m_PathID:
continue
data = mesh_data(mf.m_Mesh)
if not data:
continue
verts, normals, uv0, uv1, colors, tris = data
active, group = state(gid)
if not active and group is None:
group = 'inactive'
sb = r.m_StaticBatchInfo
static = sb.subMeshCount > 0
first = sb.firstSubMesh if static else 0
m = IDENTITY if static else world(tf_of_go.get(gid))
# board space mirrors Unity's z: the triangle order is reversed to stay counter-clockwise (three.js front
# faces), unless the object's own transform already mirrors it
flip = det3(m) > 0
so = r.m_LightmapTilingOffset
st = (1.0, 1.0, 0.0, 0.0) if static else (so.x, so.y, so.z, so.w)
lm = r.m_LightmapIndex if r.m_LightmapIndex < 65534 else -1
luv = uv1 if uv1 else uv0
for k, mref in enumerate(r.m_Materials):
sub = first + k
if sub >= len(tris):
break
name = material_name(mref)
if not name:
skipped += 1
continue
ch = chunks.setdefault((name, lm, group or ''), {'pos': [], 'uv': [], 'uv1': [], 'nrm': [], 'col': [], 'idx': [], 'map': None})
remap = {}
for tri in tris[sub]:
tri = (tri[0], tri[2], tri[1]) if flip else tri
for vi in tri:
j = remap.get(vi)
if j is None:
j = remap[vi] = len(ch['pos'])
v = verts[vi]
p = (m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2] + m[0][3],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2] + m[1][3],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2] + m[2][3])
ch['pos'].append(to_board(p, anchor))
nv = normals[vi] if normals else (0.0, 0.0, -1.0)
nw = (m[0][0] * nv[0] + m[0][1] * nv[1] + m[0][2] * nv[2],
m[1][0] * nv[0] + m[1][1] * nv[1] + m[1][2] * nv[2],
m[2][0] * nv[0] + m[2][1] * nv[1] + m[2][2] * nv[2])
ln = (nw[0] ** 2 + nw[1] ** 2 + nw[2] ** 2) ** 0.5 or 1.0
ch['nrm'].append((nw[0] / ln, nw[1] / ln, -nw[2] / ln))
t0 = uv0[vi] if uv0 else (0.0, 0.0)
ch['uv'].append((t0[0], t0[1]))
t1 = luv[vi] if luv else (0.0, 0.0)
ch['uv1'].append((t1[0] * st[0] + st[2], t1[1] * st[1] + st[3]))
col = colors[vi] if colors else (1.0, 1.0, 1.0, 1.0)
ch['col'].append(tuple(col[:4]) if len(col) >= 4 else (*col[:3], 1.0))
ch['idx'].append(j)
if not chunks:
log(f' warn {stage_id}: no drawable mesh in {scene_path.name}')
return 0
# ---- write ------------------------------------------------------------------------------------------------------
out_dir = Path(out_root) / SCENE_SUB / stage_id
out_dir.mkdir(parents=True, exist_ok=True)
sub = f'{SCENE_SUB}/{stage_id}'
blob, records = bytearray(), []
for (name, lm, group), ch in sorted(chunks.items(), key=lambda kv: (kv[0][2], kv[0][0], kv[0][1])):
data, idx32 = pack_chunk(ch)
xs, ys, zs = zip(*ch['pos'])
records.append({'material': name, 'lightmap': lm, 'group': group, 'verts': len(ch['pos']), 'tris': len(ch['idx']) // 3,
'offset': len(blob), 'index32': idx32,
'bounds': [round(v, 3) for v in (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))]})
blob += data
n = 0
(out_dir / 'scene.bin').write_bytes(bytes(blob))
manifest.setdefault(sub, {})['bin'] = {'path': f'/assets/local/{sub}/scene.bin', 'kind': 'SceneData', 'bytes': len(blob)}
n += 1
# lightmap pages: light RGB + the shadow mask's R as alpha
pages = {}
for o in env.objects:
if o.type.name != 'Texture2D':
continue
try:
t = o.read()
except Exception:
continue
mm = re.match(r'^Lightmap-(\d+)_comp_(light|shadowmask)$', t.m_Name or '')
if mm:
pages.setdefault(int(mm.group(1)), {})[mm.group(2)] = t
used_pages = sorted({rec['lightmap'] for rec in records if rec['lightmap'] >= 0})
lightmaps = 0
for i in used_pages:
page = pages.get(i, {})
if 'light' not in page:
log(f' warn {stage_id}: lightmap {i} missing (drawn unlit)')
continue
try:
rgb = decode_rgbm(page['light'].image)
mask = page['shadowmask'].image.convert('RGB').getchannel('R').resize(rgb.size) if 'shadowmask' in page else Image.new('L', rgb.size, 255)
img = Image.merge('RGBA', (*rgb.split(), mask))
entry = save_image(img, out_dir, f'lm{i}', sub)
except Exception as e:
log(f' warn {stage_id}: lightmap {i}: {e}')
continue
manifest[sub][f'lm{i}'] = {**entry, 'kind': 'Lightmap'}
lightmaps = max(lightmaps, i + 1)
n += 1
# albedo textures of the materials (shared by the scenes)
tex_dir = Path(out_root) / TEX_SUB
tex_dir.mkdir(parents=True, exist_ok=True)
for name, pptr in sorted(mat_tex.items()):
try:
tex = pptr.read()
tname = re.sub(r'[^A-Za-z0-9_.\-\[\]]+', '_', tex.m_Name or f'tex_{pptr.m_PathID}')
materials[name]['map'] = tname
if tname in manifest.get(TEX_SUB, {}):
continue
manifest.setdefault(TEX_SUB, {})[tname] = {**save_image(tex.image, tex_dir, tname, TEX_SUB), 'kind': 'Texture2D'}
n += 1
except Exception as e:
log(f' warn {stage_id}: texture of {name}: {e}')
doc = {'version': SCENE_VERSION, 'stage': stage_id, 'bundle': scene_bundle(stage_id), 'light': light, 'lightmaps': lightmaps,
'lightRange': LIGHT_RANGE, 'materials': dict(sorted(materials.items())), 'groups': sorted({rec['group'] for rec in records}),
'chunks': records, 'binBytes': len(blob)}
(out_dir / 'scene.json').write_text(json.dumps(doc, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
manifest[sub]['scene'] = {'path': f'/assets/local/{sub}/scene.json', 'kind': 'Scene', 'chunks': len(records)}
n += 1
verts = sum(rec['verts'] for rec in records)
log(f'{scene_path.name}: {len(records)} chunks, {verts} vertices, {lightmaps} lightmaps -> {sub}'
+ (f' ({skipped} sub-meshes without a material)' if skipped else ''))
return n
+20 -3
View File
@@ -20,7 +20,9 @@
// THIRD-PARTY-NOTICES.md, README.md, CHANGELOG.md, docs/PLAYING.md and docs/DEPLOY.md. `npm ci --omit=dev` in the // THIRD-PARTY-NOTICES.md, README.md, CHANGELOG.md, docs/PLAYING.md and docs/DEPLOY.md. `npm ci --omit=dev` in the
// stage adds the production node_modules and (postinstall) public/vendor. The full zip adds the git-ignored art: the // stage adds the production node_modules and (postinstall) public/vendor. The full zip adds the git-ignored art: the
// files data/assets.json lists, public/fonts, and the local-client extraction (public/assets/local/, // files data/assets.json lists, public/fonts, and the local-client extraction (public/assets/local/,
// data/local-assets.json) when present — nothing else on disk, so art the data no longer lists (焰狐龙梓兰, left out of // data/local-assets.json) when present, including the 0.2.5 battlefield scenes under public/assets/local/scene/
// when this machine extracted them. Missing scenes are not an error: the board keeps the metal tiles, and the
// summary says so. Nothing else on disk ships, so art the data no longer lists (焰狐龙梓兰, left out of
// 自选 in 0.2.0, or files of an old mapping) never ships even when this machine still has it. FULL_ZIP_JP_VOICE (below) // 自选 in 0.2.0, or files of an old mapping) never ships even when this machine still has it. FULL_ZIP_JP_VOICE (below)
// decides whether the full zip carries the Japanese voice dub too (default: yes). // decides whether the full zip carries the Japanese voice dub too (default: yes).
// Left out: test/, the maintainer tools (build-data, golden, botbench, i18n, check-imports, this file …), // Left out: test/, the maintainer tools (build-data, golden, botbench, i18n, check-imports, this file …),
@@ -117,6 +119,8 @@ export const GITHUB_REPO = 'https://github.com/sganggs/Stronghold-Protocol';
export const FULL_ZIP_JP_VOICE = true; export const FULL_ZIP_JP_VOICE = true;
/** Where the JP dub lies (plan.mjs voiceAlt: audio/voice/<lang>/<charId>/<file>). */ /** Where the JP dub lies (plan.mjs voiceAlt: audio/voice/<lang>/<charId>/<file>). */
export const JP_VOICE_DIR = 'public/assets/audio/voice/jp/'; export const JP_VOICE_DIR = 'public/assets/audio/voice/jp/';
/** Extracted official battlefield scenes (0.2.5 asset pack). Already ignored with public/assets/. */
const SCENE_ART_PREFIX = 'public/assets/local/scene/';
/** /**
* Refused in a plan and in a stage: 0.1.x's list (owner-only notes, the promo project, caches, per-machine config) and * Refused in a plan and in a stage: 0.1.x's list (owner-only notes, the promo project, caches, per-machine config) and
@@ -232,9 +236,11 @@ export const manifestFiles = listedArtFiles;
* data/local-assets.json (when present). `missing`: listed files not on disk; `orphans`: files under public/assets * data/local-assets.json (when present). `missing`: listed files not on disk; `orphans`: files under public/assets
* that nothing lists (left out); `held`: listed files the zip holds back on purpose — the JP voice dub when `jpVoice` * that nothing lists (left out); `held`: listed files the zip holds back on purpose — the JP voice dub when `jpVoice`
* (FULL_ZIP_JP_VOICE) is off: neither shipped nor missing, and never deleted by an update. * (FULL_ZIP_JP_VOICE) is off: neither shipped nor missing, and never deleted by an update.
* `scenes`: the files under public/assets/local/scene/ (the 0.2.5 battlefield scenes). They are also in `files`.
* A lite plan and a machine that has not extracted them return `scenes: []`; packaging still succeeds.
*/ */
export function artPlan(root, { lite = false, jpVoice = FULL_ZIP_JP_VOICE } = {}) { export function artPlan(root, { lite = false, jpVoice = FULL_ZIP_JP_VOICE } = {}) {
if (lite) return { files: [], missing: [], orphans: [], held: [], local: false }; if (lite) return { files: [], missing: [], orphans: [], held: [], local: false, scenes: [] };
const listed = manifestFiles(readJson(path.join(root, 'data', 'assets.json')) ?? {}); const listed = manifestFiles(readJson(path.join(root, 'data', 'assets.json')) ?? {});
const local = isFile(path.join(root, 'data', 'local-assets.json')); const local = isFile(path.join(root, 'data', 'local-assets.json'));
if (local) for (const f of manifestFiles(readJson(path.join(root, 'data', 'local-assets.json')) ?? {})) listed.add(f); if (local) for (const f of manifestFiles(readJson(path.join(root, 'data', 'local-assets.json')) ?? {})) listed.add(f);
@@ -254,7 +260,11 @@ export function artPlan(root, { lite = false, jpVoice = FULL_ZIP_JP_VOICE } = {}
// without case: on Windows / macOS a file whose name differs only in case is the listed one (fetch-assets orphanFiles) // without case: on Windows / macOS a file whose name differs only in case is the listed one (fetch-assets orphanFiles)
const lower = new Set([...files, ...held].map((f) => f.toLowerCase())); const lower = new Set([...files, ...held].map((f) => f.toLowerCase()));
const orphans = listTree(root, 'public/assets').filter((f) => !lower.has(f.toLowerCase())); const orphans = listTree(root, 'public/assets').filter((f) => !lower.has(f.toLowerCase()));
return { files: [...files].sort(), missing: missing.sort(), orphans, held: held.sort(), local }; const sorted = [...files].sort();
return {
files: sorted, missing: missing.sort(), orphans, held: held.sort(), local,
scenes: sorted.filter((f) => f.startsWith(SCENE_ART_PREFIX)),
};
} }
const URL_MOUNTS = [['/data/', 'data/'], ['/shared/', 'shared/'], ['/sim/', 'server/sim/']]; const URL_MOUNTS = [['/data/', 'data/'], ['/shared/', 'shared/'], ['/sim/', 'server/sim/']];
@@ -524,6 +534,7 @@ export function plan(root, opts = {}) {
return { return {
version: pkg.version || '0.0.0', lite, tracked: keep, art: art.files, generated, files, dropped, orphans: art.orphans, version: pkg.version || '0.0.0', lite, tracked: keep, art: art.files, generated, files, dropped, orphans: art.orphans,
held: art.held, localArt: art.local, problems, names: names.length, scanned, held: art.held, localArt: art.local, problems, names: names.length, scanned,
scenes: { files: art.scenes || [], bytes: sumBytes(root, art.scenes || []) },
bytes: { bytes: {
tracked: trackedBytes, art: artBytes, modules: modulesBytes, vendor: vendorBytes, tracked: trackedBytes, art: artBytes, modules: modulesBytes, vendor: vendorBytes,
total: trackedBytes + artBytes + modulesBytes + vendorBytes, total: trackedBytes + artBytes + modulesBytes + vendorBytes,
@@ -542,6 +553,12 @@ export function formatSummary(p) {
]; ];
if (!p.lite) { if (!p.lite) {
lines.push(`art: ${p.art.length} files, ${MB(p.bytes.art)} (data/assets.json, public/fonts${p.localArt ? ', public/assets/local + data/local-assets.json' : '; no local-client art here'})`); lines.push(`art: ${p.art.length} files, ${MB(p.bytes.art)} (data/assets.json, public/fonts${p.localArt ? ', public/assets/local + data/local-assets.json' : '; no local-client art here'})`);
const sceneFiles = p.scenes?.files;
if (sceneFiles?.length) {
lines.push(`battlefield scenes: ${sceneFiles.length} files, ${MB(p.scenes.bytes || 0)} (0.2.5 asset pack, public/assets/local/scene; the metal board is the fallback when a stage has none)`);
} else if (p.scenes) {
lines.push('battlefield scenes: none on this machine (the full bundle ships the metal board; fetch_scenes.py then extract.py --only scene before packaging to include the 11 official scenes)');
}
} }
if (p.generated?.length) lines.push(`generated: ${p.generated.join(', ')} (the pack index of the shipped packs, for a static host)`); if (p.generated?.length) lines.push(`generated: ${p.generated.join(', ')} (the pack index of the shipped packs, for a static host)`);
lines.push(`production node_modules: ${MB(p.bytes.modules)} · public/vendor: ${MB(p.bytes.vendor)} (npm ci --omit=dev and its postinstall)`); lines.push(`production node_modules: ${MB(p.bytes.modules)} · public/vendor: ${MB(p.bytes.vendor)} (npm ci --omit=dev and its postinstall)`);
+92 -3
View File
@@ -15,6 +15,11 @@
// bottle or PlayCover on macOS, or --game <dir>) with tools/local-extract/extract.py in a project-local Python // bottle or PlayCover on macOS, or --game <dir>) with tools/local-extract/extract.py in a project-local Python
// venv (.venv-extract), then the board tile crops (tools/crop-board-atlas.mjs → tiles.json). Asked once on a // venv (.venv-extract), then the board tile crops (tools/crop-board-atlas.mjs → tiles.json). Asked once on a
// terminal (the answer is remembered in .cache/setup-state.json; without a terminal it is skipped); never fatal. // terminal (the answer is remembered in .cache/setup-state.json; without a terminal it is skipped); never fatal.
// 7. Optional: the eleven official battlefield scenes (0.2.5 asset pack). The full release zip already contains
// them when the maintainer extracted them. A lite package or a source install may download the in-season bundles
// (tools/local-extract/fetch_scenes.py) and extract them (extract.py --only scene). Missing Python or
// UnityPy / lz4 / Pillow is a warning: the 3D board keeps the metal tiles. --scenes does it without asking;
// --no-scenes skips it. The answer is remembered like step 6.
// //
// Options: // Options:
// --check report only, change nothing (exit 1 when something essential is missing) // --check report only, change nothing (exit 1 when something essential is missing)
@@ -22,8 +27,10 @@
// --asset-source=M direct (default) or mirror (opt-in; no public-IP lookup) // --asset-source=M direct (default) or mirror (opt-in; no public-IP lookup)
// SP_ASSET_SOURCE sets the default; SP_GITHUB_PROXY sets the HTTPS prefix (https://gh-proxy.com/) // SP_ASSET_SOURCE sets the default; SP_GITHUB_PROXY sets the HTTPS prefix (https://gh-proxy.com/)
// An empty SP_GITHUB_PROXY disables the proxy, including in mirror mode. // An empty SP_GITHUB_PROXY disables the proxy, including in mirror mode.
// --no-local skip the local-client detection and extraction // --no-local skip the local-client detection and extraction (the scene step still runs unless --no-scenes)
// --local extract from the local client without asking (re-extracts when already done) // --local extract from the local client without asking (re-extracts when already done)
// --scenes download and extract the official battlefield scenes without asking
// --no-scenes do not download or extract battlefield scenes (the metal board stays)
// --game <dir> AssetBundle root of the local client (…/StreamingAssets/AB/Windows or PlayCover …/Documents/Bundles) // --game <dir> AssetBundle root of the local client (…/StreamingAssets/AB/Windows or PlayCover …/Documents/Bundles)
// -y, --yes answer "yes" to every question // -y, --yes answer "yes" to every question
// --quiet fewer lines (used by scripts/launch.mjs) // --quiet fewer lines (used by scripts/launch.mjs)
@@ -58,6 +65,7 @@ export const VENDOR_OPTIONAL = ['three.core.js', 'three.module.js'];
const STATE_FILE = path.join(ROOT, '.cache', 'setup-state.json'); const STATE_FILE = path.join(ROOT, '.cache', 'setup-state.json');
export const VENV_DIR = path.join(ROOT, '.venv-extract'); export const VENV_DIR = path.join(ROOT, '.venv-extract');
const EXTRACT_PY = path.join(ROOT, 'tools', 'local-extract', 'extract.py'); const EXTRACT_PY = path.join(ROOT, 'tools', 'local-extract', 'extract.py');
const FETCH_SCENES_PY = path.join(ROOT, 'tools', 'local-extract', 'fetch_scenes.py');
const EXTRACT_REQ = path.join(ROOT, 'tools', 'local-extract', 'requirements.txt'); const EXTRACT_REQ = path.join(ROOT, 'tools', 'local-extract', 'requirements.txt');
const LOCAL_MANIFEST = path.join(ROOT, 'data', 'local-assets.json'); const LOCAL_MANIFEST = path.join(ROOT, 'data', 'local-assets.json');
const LOCAL_BOARD_ATLAS = path.join(ROOT, 'public', 'assets', 'local', 'map', 'autochess', 'TX_autochessi_D.png'); const LOCAL_BOARD_ATLAS = path.join(ROOT, 'public', 'assets', 'local', 'map', 'autochess', 'TX_autochessi_D.png');
@@ -217,7 +225,7 @@ export function checkLocal() {
const has = (prefix) => !!(m && m.groups && Object.keys(m.groups).some((g) => g.startsWith(prefix))); const has = (prefix) => !!(m && m.groups && Object.keys(m.groups).some((g) => g.startsWith(prefix)));
return { return {
manifest: !!m, count, board3d: exists(LOCAL_BOARD_ATLAS), tiles: exists(LOCAL_BOARD_TILES), enemySpines: has('spine/enemy/'), manifest: !!m, count, board3d: exists(LOCAL_BOARD_ATLAS), tiles: exists(LOCAL_BOARD_TILES), enemySpines: has('spine/enemy/'),
tokenSpines: has('spine/token/'), dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')), tokenSpines: has('spine/token/'), scenes: has('scene/act'), dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')),
}; };
} }
@@ -226,9 +234,47 @@ export function localGaps(local) {
const gaps = []; const gaps = [];
if (local && !local.enemySpines) gaps.push('灼热/炽焰源石虫模型'); if (local && !local.enemySpines) gaps.push('灼热/炽焰源石虫模型');
if (local && !local.tokenSpines) gaps.push('自选召唤物模型'); if (local && !local.tokenSpines) gaps.push('自选召唤物模型');
if (local && local.scenes === false) gaps.push('各战场官方场景');
return gaps.length ? `,缺少新版的${gaps.join('和')}` : ''; return gaps.length ? `,缺少新版的${gaps.join('和')}` : '';
} }
/**
* Whether setup should download and extract the official battlefield scenes (0.2.5 asset pack).
* The full release zip already contains them when the maintainer extracted them before packaging.
* A lite package or a source install may fetch the in-season bundles on this machine and extract them.
* Missing Python or UnityPy / lz4 / Pillow is not fatal: the 3D board keeps the metal tiles.
* `client` is true (autochess bundles present), 'incomplete' (a client dir without them) or false.
* @param {{ hasScenes?: boolean, client?: boolean|string, python?: boolean, deps?: boolean, check?: boolean, declined?: boolean, force?: boolean, yes?: boolean }} o
* @returns {{ run: boolean, state: 'ok'|'skip'|'warn'|'ask', detail: string }}
*/
export function sceneInstallPlan(o = {}) {
if (o.hasScenes && !o.force) {
return { run: false, state: 'ok', detail: '已提取各战场官方场景(0.2.5 素材;某个战场没有文件时仍用金属地块)' };
}
if (o.check) {
return { run: false, state: 'skip', detail: '未提取各战场官方场景:3D 棋盘用金属地块(node tools/setup.mjs --scenes 可在本机下载并提取)' };
}
if (o.client === 'incomplete') {
return { run: false, state: 'warn', detail: '客户端缺少卫戍协议资源,战场场景未提取。3D 棋盘继续用金属地块' };
}
if (!o.client) {
return { run: false, state: 'skip', detail: '没有本机客户端,不能提取战场场景(贴图仍取自客户端)。3D 棋盘用金属地块;完整包已带场景时可复制 public/assets/local 和 data/local-assets.json' };
}
if (!o.python) {
return { run: false, state: 'warn', detail: '没有 Python 3.8+,战场场景未提取。3D 棋盘继续用金属地块' };
}
if (o.deps === false) {
return { run: false, state: 'warn', detail: '提取依赖(UnityPy / lz4 / Pillow)没装好,战场场景未提取。3D 棋盘继续用金属地块' };
}
if (o.declined && !o.force && !o.yes) {
return { run: false, state: 'skip', detail: '已跳过战场场景(node tools/setup.mjs --scenes)。3D 棋盘用金属地块' };
}
if (!o.force && !o.yes) {
return { run: false, state: 'ask', detail: '可以下载赛季场景包并提取(约 23 MB)。失败或跳过时 3D 棋盘仍用金属地块' };
}
return { run: true, state: 'ok', detail: '将下载赛季场景包并提取;失败时 3D 棋盘仍用金属地块' };
}
/** /**
* Board tile crops of the extracted atlas (tools/crop-board-atlas.mjs → tiles.json, read by the 2D and 3D board * Board tile crops of the extracted atlas (tools/crop-board-atlas.mjs → tiles.json, read by the 2D and 3D board
* renderers; without it the client requests a missing file and keeps the procedural tiles). Output only on failure. * renderers; without it the client requests a missing file and keeps the procedural tiles). Output only on failure.
@@ -347,7 +393,7 @@ function ensureVenv(py, log) {
// --------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------
function parseArgs(argv) { function parseArgs(argv) {
const o = { check: false, assets: true, local: 'ask', game: null, yes: false, quiet: false, help: false, source: process.env.SP_ASSET_SOURCE || 'direct' }; const o = { check: false, assets: true, local: 'ask', scenes: 'ask', game: null, yes: false, quiet: false, help: false, source: process.env.SP_ASSET_SOURCE || 'direct' };
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const a = argv[i]; const a = argv[i];
if (a === '--check') o.check = true; if (a === '--check') o.check = true;
@@ -356,6 +402,8 @@ function parseArgs(argv) {
else if (a.startsWith('--asset-source=')) o.source = a.slice('--asset-source='.length); else if (a.startsWith('--asset-source=')) o.source = a.slice('--asset-source='.length);
else if (a === '--no-local') o.local = 'no'; else if (a === '--no-local') o.local = 'no';
else if (a === '--local') o.local = 'force'; else if (a === '--local') o.local = 'force';
else if (a === '--no-scenes') o.scenes = 'no';
else if (a === '--scenes') o.scenes = 'force';
else if (a === '--game') { o.game = argv[++i] || null; if (o.local !== 'no') o.local = 'force'; } else if (a === '--game') { o.game = argv[++i] || null; if (o.local !== 'no') o.local = 'force'; }
else if (a.startsWith('--game=')) { o.game = a.slice(7) || null; if (o.local !== 'no') o.local = 'force'; } else if (a.startsWith('--game=')) { o.game = a.slice(7) || null; if (o.local !== 'no') o.local = 'force'; }
else if (a === '-y' || a === '--yes') o.yes = true; else if (a === '-y' || a === '--yes') o.yes = true;
@@ -497,6 +545,47 @@ async function main() {
} }
} }
// 7. official battlefield scenes (optional; the full zip ships them, a lite / source install may fetch them)
if (opts.scenes !== 'no') {
const sceneLocal = checkLocal();
const sceneClient = findClient(opts.game);
const scenePy = findPython();
const clientFlag = sceneClient?.autochess ? true : (sceneClient ? 'incomplete' : false);
let decision = sceneInstallPlan({
hasScenes: sceneLocal.scenes, client: clientFlag, python: !!scenePy, check: opts.check,
declined: !!loadState().sceneDeclined, force: opts.scenes === 'force', yes: opts.yes,
});
if (decision.state === 'ask') {
log(`\n${c.cyan('▶')} 各战场的官方场景还没提取(11 个战场;赛季场景包约 23 MB,贴图仍取自本机客户端)。`);
log(' 下载并提取后,3D 棋盘按战场显示官方场景。没有提取依赖、下载失败或跳过时,仍用现在的金属地块。');
const answer = await ask('现在下载并提取吗?', true);
if (answer === null) decision = { run: false, state: 'skip', detail: '无终端,未询问、未提取战场场景。3D 棋盘用金属地块(需要时 node tools/setup.mjs --scenes)' };
else if (!answer) {
saveState({ sceneDeclined: true });
decision = { run: false, state: 'skip', detail: '已跳过战场场景(node tools/setup.mjs --scenes)。3D 棋盘用金属地块' };
} else decision = { run: true, state: 'ok', detail: decision.detail };
}
if (!decision.run) add(decision.state === 'ok' ? 'ok' : decision.state, '各战场官方场景(可选)', decision.detail);
else {
const venv = ensureVenv(scenePy, log);
if (!venv.ok) add('warn', '各战场官方场景(可选)', `${venv.why}。3D 棋盘继续用金属地块`);
else {
log(` ${c.cyan('▶')} 下载赛季场景包(python tools/local-extract/fetch_scenes.py,约 23 MB)`);
const fr = run(venv.python, [FETCH_SCENES_PY], { env: PY_ENV });
if (!fr.ok) add('warn', '各战场官方场景(可选)', `场景包下载未完成(退出码 ${fr.code})。3D 棋盘继续用金属地块;稍后可重试 node tools/setup.mjs --scenes`);
else {
log(` ${c.cyan('▶')} 提取战场场景(extract.py --only scene)`);
const er = run(venv.python, [EXTRACT_PY, '--game', sceneClient.path, '--only', 'scene'], { env: PY_ENV });
const afterScenes = checkLocal();
if (er.ok && afterScenes.scenes) {
add('ok', '各战场官方场景(可选)', '已提取;进入对局时按战场显示,没有的战场仍用金属地块');
saveState({ sceneDeclined: false });
} else add('warn', '各战场官方场景(可选)', `提取未成功(退出码 ${er.code})。3D 棋盘继续用金属地块`);
}
}
}
}
// summary // summary
log(c.bold('\n── 准备情况 ──────────────────────────────')); log(c.bold('\n── 准备情况 ──────────────────────────────'));
const width = Math.max(...summary.map((s) => displayWidth(s.label))) + 2; const width = Math.max(...summary.map((s) => displayWidth(s.label))) + 2;