3D 棋盘按战场显示官方场景(沙地等 11 个战场),场景由本地提取脚本从赛季场景包导出,没有时回退现有金属棋盘 (#532)

This commit is contained in:
Andrew committed 2026-10-11 19:15:50 -04:00
1 parent bc50cac01f
commit 1fe819478e
13 files changed
+1389 -14

No files matched your search

+1
View File
@@ -160,6 +160,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) | 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, 23 MB, md5-checked), and the materials / textures still come from the local client's theme bundles. Never required: the 3D board draws its procedural tiles without them | `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`) |
+3
View File
@@ -300,6 +300,7 @@ services:
| 内容 | 没有本地素材时 | | 内容 | 没有本地素材时 |
|---|---| |---|---|
| 官方 3D 棋盘(贴图、模型、地图特效) | 2D 棋盘,地块由程序绘制 | | 官方 3D 棋盘(贴图、模型、地图特效) | 2D 棋盘,地块由程序绘制 |
| 各战场的官方场景(沙地等,每个战场一个;需要另外准备场景包,见下) | 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/`,清单里其他条目保持不变)。
**各战场的官方场景**:官方的棋盘是每个战场一个做好的场景(11 个战场里 9 个是沙地),场景包只在赛季开放期间随客户端分发,赛季外的客户端里没有。需要时在提取用的 Python 环境里先运行 `python tools/local-extract/fetch_scenes.py`(从官方 CDN 下载赛季版本的 33 个场景包,约 23 MB,放进 `.cache/official-scenes/`;加 `--list` 只看清单不下载),再运行 `python tools/local-extract/extract.py --only scene`(材质和贴图仍取自本机客户端)。提取后 3D 棋盘会按战场显示官方场景;没有提取时照常显示金属地块。每个场景玩家进入对局时下载约 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. 打包发布(维护者)
+1
View File
@@ -36,3 +36,4 @@ normative lines it rewrote).
| §27 | [history/0.2.2.md](history/0.2.2.md) | 0.2.2 — after the 0.2.1 release (2026-10-09) | | §27 | [history/0.2.2.md](history/0.2.2.md) | 0.2.2 — after the 0.2.1 release (2026-10-09) |
| §28 | [history/0.2.3.md](history/0.2.3.md) | 0.2.3 — after the 0.2.2 release (2026-10-10) | | §28 | [history/0.2.3.md](history/0.2.3.md) | 0.2.3 — after the 0.2.2 release (2026-10-10) |
| §29 | [history/0.2.4.md](history/0.2.4.md) | 0.2.4 — after the 0.2.3 release (2026-10-11) | | §29 | [history/0.2.4.md](history/0.2.4.md) | 0.2.4 — after the 0.2.3 release (2026-10-11) |
| §30 | [history/0.2.5.md](history/0.2.5.md) | 0.2.5 — after the 0.2.4 release |
+6 -1
View File
@@ -57,7 +57,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"): `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).
- `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).
@@ -72,6 +73,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): 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).
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.
+39
View File
@@ -0,0 +1,39 @@
# DESIGN §30 — 0.2.5
Part of [DESIGN.md](../DESIGN.md). This chapter records the changes after the 0.2.4 release.
## 30. After the 0.2.4 release
### 30.1 Official battlefield scenes (#532)
Report: the web board draws the metal theme for every battlefield, which looks unlike the official client; the
reporter asked for the sand theme.
Findings (2026-10-11; evidence: 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 — §15 said otherwise from an off-season list and is corrected.
- 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.
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 board stays the fallback
and still draws the devices, gates, field edges and terrain overlays. All eleven battlefields extract and draw.
No gameplay change: the scene is art under the same grid, camera and unit layer; golden results are untouched.
**[ASSUMED]** the light gains `SCENE_LIGHT` and the RGBM reading of the lightmap pages (§15); the approximations are
listed there. 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.
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, and the extracted files when present).
+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
+99 -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,100 @@ 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.
+100 -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,9 @@ 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.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 +283,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 +293,92 @@ 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: res.lm[c.lightmap] || 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 +640,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 +657,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');
});
}
});
+50 -2
View File
@@ -33,6 +33,10 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
roughness map in G (1 − smoothness; metalness B = 0), written next to the source as <name>_rgb.png / _rough.png roughness map in G (1 − smoothness; metalness B = 0), written next to the 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
@@ -40,6 +44,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
@@ -54,6 +59,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()
@@ -718,6 +724,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, _ 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)."""
@@ -798,6 +837,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 '
@@ -812,7 +854,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:
@@ -825,7 +870,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)
@@ -838,6 +884,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