diff --git a/.gitignore b/.gitignore index d5ba9a6c..f09e5f36 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ public/assets/ public/fonts/ # tools/local-extract/extract.py → public/assets/local/** and this per-machine manifest # (optional: the client and the tests work without it; docs/DEPLOY.md §6 lists what falls back) +# Battlefield scenes (0.2.5): extracted files live under public/assets/local/scene/ (already ignored by +# public/assets/ above). Downloaded bundles live in .cache/official-scenes/ (already ignored by .cache/ below). +# The repository never stores those official files. data/local-assets.json # download caches, upstream tables, ledgers, reports, setup state .cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 109841cc..eb3e47a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - 修正韩语界面和维多利亚英文按装备逐件加成的表述。 - 修复干员一次攻击后模型继续循环、多挥一次的问题,保留迟到消息的现有前摇同步方式。 - 自在重生后的无敌期不再被普攻或需要可选目标的自动技能锁定;修复 AI 托管遮罩挡住“在此页面继续”按钮。 +- 3D 棋盘在提取了官方场景时按战场显示沙地等 11 个场景,没有场景时仍用金属地块(PR #533,感谢 @andrew05060414)。完整包带上提取出的场景;精简包和源码可以在本机下载并提取,提取依赖缺失时继续用金属地块。 ## 0.2.4 — 2026-10-11 diff --git a/docs/ASSETS.md b/docs/ASSETS.md index a7e0410f..2467d4dc 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -165,6 +165,7 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee | Enemy Spine (PC build, premultiplied alpha) | isHarryh/Ark-Models `models_enemies/{key}/`, file names from `models_data.json` | `spine/enemy/{enemyId}/{stem}.*` | | Enemy Spine 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`) | +| Official battlefield scenes (one per battlefield: meshes, lightmaps, albedo textures; DESIGN §15, DESIGN §30.17) | the scene bundles `scenes/activities//level_/…` (`tools/local-extract/extract.py --only scene`, optional). The client carries them only while the season runs; off season `tools/local-extract/fetch_scenes.py` downloads the bundles of the in-season version `26-04-13-12-31-42_e9b560` from the official CDN into `.cache/official-scenes/` (33 bundles, 22,801,308 bytes compressed on the Android list, md5-checked), and the materials / textures still come from the local client's theme bundles. The 0.2.5 full release zip includes the extracted files under `public/assets/local/scene/` when the maintainer extracted them before packaging; a lite zip and a source install may fetch and extract them (`node tools/setup.mjs --scenes`). The repository never stores the bundles or the extracted files. Without a scene the 3D board draws its procedural metal tiles | `local/scene/{stageId}/scene.json`, `scene.bin`, `lm{i}.*`; `local/scene/tex/{texture}.*` (listed in `data/local-assets.json`) | | BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` (大厅/休整期 `act1autochess`, 开战 `act13side/m_bat_kazimierz2_{1,2}` — 骑士之日 / 无畏者; the 开战 track follows the round: `_2` 无畏者 rounds 1–7, `_1` 骑士之日 from round 8) | `audio/bgm/{file}.mp3` | | 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`) | diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 0de0e6a5..d5e0036a 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -300,6 +300,7 @@ services: | 内容 | 没有本地素材时 | |---|---| | 官方 3D 棋盘(贴图、模型、地图特效) | 2D 棋盘,地块由程序绘制 | +| 各战场的官方场景(沙地等 11 个战场,0.2.5 素材包;完整包在提取后带上,精简包和源码可在本机下载并提取,见下) | 3D 棋盘用程序生成的金属地块 | | 部分官方界面图标与底板:交流按钮和表情面板的边框、暂停面板、装备替换窗口、干员调配界面、队友状态与漏怪标记、模组类型图标等 | 样式相近的替代图形、图标或文字 | | 灼热 / 炽焰源石虫的官方模型 | 染成橙色 / 红橙色的普通源石虫 | | 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.5 素材包,维护者 2026-10-12:可以进完整包;精简包和源码安装可以在本机下载):官方棋盘是每个战场一个做好的场景(11 个战场,其中 9 个是沙地)。场景包只在赛季开放期间随客户端分发,赛季外的客户端里没有。仓库不存放这些官方文件。有客户端的电脑可以 `node tools/setup.mjs --scenes`(询问或强制下载并提取;`--no-scenes` 跳过)。也可以分两步:`python3 tools/local-extract/fetch_scenes.py`(从官方 CDN 下载赛季版本 `26-04-13-12-31-42_e9b560` 的 33 个场景包,共 22,801,308 字节,放进 `.cache/official-scenes/`;`--list` 只看清单不下载),再用提取环境的 Python 运行 `tools/local-extract/extract.py --only scene`(材质和贴图仍取自本机客户端;`--game` 可指定客户端目录)。没有 Python 3.8+,或 UnityPy / lz4 / Pillow 没装好时,setup 只提示并继续,3D 棋盘用金属地块。没有客户端时贴图取不出来,从同一版本的完整包复制 `public/assets/local/` 和 `data/local-assets.json`。作者估计每个场景在进对局时另下载约 2–7 MB,各场景共用贴图约 3 MB;本机没有跑过提取,完整包因此增加的解压体积没有实测。 + **3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。 ## 7. 打包发布(维护者) @@ -323,6 +326,8 @@ npm run package:lite -- --out <目录> # 精简包 Stronghold-Protocol-v<版 npm run package -- --update --from <旧版本的完整包>[,<…>] --out <目录> # 更新包 Stronghold-Protocol-v<版本>-update.zip ``` +完整包在打包机已经提取战场场景时带上 `public/assets/local/scene/`(`--dry-run` 摘要里的 `battlefield scenes` 一行)。还没有提取时完整包仍能打,棋盘用金属地块。要带上 11 个官方场景,先运行 `python3 tools/local-extract/fetch_scenes.py`,再用提取环境的 Python 运行 `tools/local-extract/extract.py --only scene`(或 `node tools/setup.mjs --scenes`),然后再 `npm run package`。精简包不带场景。`.cache/official-scenes/` 在拒绝名单里,不会打进任何包。`tools/local-extract/scenes.py` 和 `fetch_scenes.py` 必须先提交:打包读的是 `git ls-files`,未提交的脚本不会进玩家包。 + - **打进去的**:`git ls-files` 里的 `server/`、`shared/`、`data/`、`public/`(不含 `public/dev/`)、`packs/`(随仓库提交的内容包;只在本机安装、没提交的不打进去)、启动脚本、玩家会运行的工具(setup、vendor、fetch-assets 与 `tools/assets/`、doctor,以及 setup 调用的 `tools/local-extract/` 和 `crop-board-atlas.mjs`)、服务器和 fetch-assets 读取的 4 张研究数据表(`docs/research/` 的 `03-operators`、`05-enemies`、`05-maps`、`07-assets` 四个 JSON)、`package.json` / `package-lock.json`、许可证与说明(`LICENSE`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`README.md`、`CHANGELOG.md`)、`docs/PLAYING.md` 和本文;然后在临时目录里生成 `packs/index.json`(打进去的语言包和内容包的列表,供纯静态托管使用;服务器自己会实时列出,见 [PACKS.md](PACKS.md)),再 `npm ci --omit=dev` 装上运行依赖和 `public/vendor`。完整包再加上 `data/assets.json` 列出的素材、`public/fonts`,以及本地提取的 `public/assets/local/` 和 `data/local-assets.json`。磁盘上有、清单却没列出的文件不打进去(例如 0.2.0 移出自选的焰狐龙梓兰的旧素材)。日文干员语音(`audio.voiceJp`,`public/assets/audio/voice/jp/` 的 2674 个文件,约 85 MB,zip 后约 76 MB)默认也打进完整包;`tools/package.mjs` 里的开关 `FULL_ZIP_JP_VOICE` 改成 `false` 时,完整包(以及由它比较出的更新包)不带这些文件:玩家首次启动时 setup 会像精简包那样下载它们,下载完成前选「日本語」会播中文语音,更新包也不会删除玩家已有的日文语音。 - **不打进去的**:`test/`、维护用的工具(数据构建、golden、botbench、i18n、导入检查、本工具等)、`scripts/make-windows-bundle.mjs`(Windows 便携包,见 [WINDOWS.md](WINDOWS.md))、其他文档、研究笔记和 `docs/img/`、`handoff/`、`.github/`、`types/`、lint / 编辑器 / Docker 配置。和 0.1.x 的整树打包(全部跟踪文件加上 `public/assets` 的全部内容)相比,0.2.0 的完整包少了约 640 个文件、解压后小约 26 MB,zip 小约 8 MB。 - **打包前的检查**(`--dry-run` 也全部做一遍):拒绝名单(`pv`、`review`、`.cache`、`.claude`、`.git`、`logs`、`.env`、`scripts/service.env.cmd`、`handoff`、`test` 等);每个打进去的模块的相对导入、玩家用的 npm 脚本(start / setup / doctor / launch / postinstall / vendor / assets)都指向包里的文件;完整包里 `data/assets.json` 和 `data/local-assets.json` 列出的文件都在(缺了先运行 `node tools/fetch-assets.mjs`);没有只差大小写的两个路径;包里的文件(二进制素材也查)不含个人目录路径(`/Users/…`、`C:\Users\…`、`/home/…`)或本机的账户名(运行时从系统读取;`SP_PACKAGE_SCAN_NAMES=a,b` 可以再加名字);打进去的已跟踪文件没有未提交的改动(重新生成的 `data/assets.json` 要先提交)。有任何问题都会列出原因、以非零状态结束,不写 zip;正式打包时还会核对临时目录里的文件和计划完全一致。 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 05ff58d3..756c332e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -5,7 +5,7 @@ This is the **single source of truth** for every implementer. Research lives in Language: player-facing text is **Simplified Chinese** by default, with an English switch since 0.2.0 (§25.2, docs/I18N.md: UI strings through `t('中文')`, game texts from the official Chinese data or the official EN client's). Code, comments and identifiers are English. Current development state: **0.2.5-dev**, §30, the state described by this document. Its fixed gifts, already displayed operator offers, combat corrections -and client/tool changes supersede the corresponding 0.2.4 rules below. It is not yet released. +and client/tool changes supersede the corresponding 0.2.4 rules below. Official battlefield scenes (DESIGN §15, DESIGN §30.17) are optional 0.2.5 local art: the full bundle includes them when extracted, and a missing scene keeps the metal board. It is not yet released. Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history. diff --git a/docs/design/client.md b/docs/design/client.md index 1b1d1040..aafa23e5 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -63,7 +63,8 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer ## 13. Local-client art (optional, host-side) `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: { '': { 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//']`, `ui['guide/']`) 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//` and `scene/tex/` — the official scene of a battlefield (§15 "Official battlefield scenes", DESIGN §30.17): `scene.json` (materials, key light, chunk records), `scene.bin` (the meshes in board space), the lightmap pages `lm`, and the materials' albedo textures shared by the scenes. Read from the scene bundles `scenes/activities//level_/…`, which the client carries only while the season runs: `extract.py --only scene` takes them from the client or from `--scenes ` / `.cache/official-scenes/*` (`tools/local-extract/fetch_scenes.py` downloads them from the official CDN's in-season version). The 0.2.5 full release zip includes the extracted files when the maintainer extracted them before packaging; a lite zip and a source install may fetch and extract them (`node tools/setup.mjs --scenes`). The repository never stores the bundles or the extracted files. - `ui/{common,battle,outer}/*` — every sprite of the three official autochess UI bundles (tier chips I–VI, bond disc rings, frames, banners, HUD pieces, badges, backgrounds, mode art). - `emoticon//.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). @@ -78,6 +79,10 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer Findings (2026-09-28): 卫戍协议 levels have no scene bundle (`mapId` null; the official hot-update list contains none). The client **builds the board at runtime** from the stage grid using the map theme `arts/maps/map_autochess` (materials `MT_autochess` (albedo `TX_autochessi_D` 2048², normal `_N`, metallic/gloss `_M`, emission `_E`), `MT_autochess_common` (`common_D/_E`: device/gate icons), `MT_autochess_Transparent` (`Transparent_D/N/M/E`), `MT_autochessi_BG` (`TX_autochessi_BG`)), the background meshes `arts/maps/map_autochess/bkg_mesh.ab` (`S_Background_common`, `S_Background_shadow`) and `arts/maps/common/meshes/s_background_common.ab`, device meshes (`s_common_box_01` crate, `s_wind_device` blower, …), plus the standard gate/objective effects. The level's `options.configBlackBoard` carries the official camera framing (`default_pos_x/y`, `default_size`, `enemy_pos_x/y`, …). +Correction (2026-10-11, GitHub #532; PR #533 by @andrew05060414): the first finding above was made off season and is wrong. While a season runs, the official hot-update list carries **one prebuilt scene per battlefield** — `scenes/activities/act1autochess/level_act1autochess_m01…m07` and `scenes/activities/act2autochess/level_act2autochess_m01…m04` (each with `…/lightingdata.ab` and `…_lighting_settings.ab`; Android versions `25-11-21-15-21-44_ee1197`, `26-03-13-06-11-47_14fb99`, `26-04-13-12-31-42_e9b560`); the lists of 2026-09-13 and 2026-10-08 have none, and no 卫戍协议 level data either. The asset index of those versions (`.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/`, the 3D board draws that scene instead of the procedural tiles, cliffs and background plane; the devices, gate / objective boxes, field edges and terrain overlays stay as below. The base part is always drawn; a `HIDDEN@n` part is drawn when its centre lies in one of the built areas. Without the scene (not extracted, a load failure, an unknown `version`) the procedural metal board is drawn as before. Approximations: the official shaders are not reproduced — a lit material is albedo × (the lightmap's baked light + key light · N·L · the baked shadow mask), in linear light; normal, metallic and emission maps, the vertex-colour blend (`_HG_VERTEX_COLOR_BLEND_ON`), the sky box, the map effects (`mapeff_*`) and the post-processing profile are not drawn; `Torappu/Scene/StylizedWater` is the board's water shader in the material's colours; crates cast no shadow on a scene. **[ASSUMED]** `SCENE_LIGHT` (`direct` 0.8, `indirect` 0.4, `ambient` 0.2: tuned by eye against the PRTS screenshot of 战场#06, the official post-processing being unknown) and the RGBM reading of the lightmap pages (`scenes.py decode_rgbm`: Unity's linear-space decode, range 5). Owner decision (2026-10-12): these extracted files belong to the 0.2.5 asset pack and may ship in the full release zip; a lite package or a source install may download and extract them on that machine. Missing extractor dependencies keep the metal board. The repository still never stores the official files. + Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) underneath the existing Pixi layer: - 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. diff --git a/docs/history/0.2.5.md b/docs/history/0.2.5.md index 9ebb34fe..4eef4764 100644 --- a/docs/history/0.2.5.md +++ b/docs/history/0.2.5.md @@ -271,3 +271,51 @@ the preceding digest exactly; the changed partner/side also changes preparation All five already differed from 0.2.4, so the final release-relative total remains 45 scenarios (§30.9 plus these additional pairing effects). There are no new corpus cases or removals. The other follow-up fixes do not move this corpus; their dedicated regressions cover the reported and newly found cases. + +### 30.17 Official battlefield scenes (#532) + +Report: the web board draws the metal theme for every battlefield. The reporter asked for the sand theme. +PR #533 by @andrew05060414. + +Findings (2026-10-11; the official hot-update lists and asset indexes, read from the CDN, and the scene bundles of +version `26-04-13-12-31-42_e9b560`): + +- The official board is not assembled at run time. Every battlefield is a prebuilt scene bundle, shipped only while + the season runs. DESIGN §15 said otherwise from an off-season list and is corrected there. +- Which art a battlefield uses is the dependency list of its scene bundle: the sand theme for act1 m01–m07 and act2 + m02 / m04, `map_lakegarden` + `map_syracuse_metro` for act2 m01, `map_kxmr` + `map_lm_center` for act2 m03. The + same in the 上半 version of 2025-11 and the 下半 versions of 2026-03 / 04. No battlefield uses the metal theme. +- The sand theme bundle holds no mesh because the models are baked into each scene ("Combined Mesh (root: scene)"). +- A scene's three sub-graphics `HIDDEN@1…3` are the left normal field, the right normal field and the boss field. +- `python3 tools/local-extract/fetch_scenes.py --list` against that Android hot-update list names 33 bundles and + 22,801,308 bytes. That is the compressed CDN download, not the extracted size in the full zip. + +Change: `tools/local-extract/scenes.py` exports a battlefield's scene (meshes in board space, materials, key light, +lightmap pages with the baked shadow mask in alpha, albedo textures) to `public/assets/local/scene//`; +`extract.py --only scene` runs it, reading the scene bundles from the client, `--scenes ` or +`.cache/official-scenes/*`; `tools/local-extract/fetch_scenes.py` downloads the in-season bundles from the official +CDN (md5-checked against that version's hot-update list). The 3D board draws a stage's scene when the local-art +manifest lists it (`board3d/load.js loadScenePack`, `scene.js setScenePack`); the procedural metal board stays the +fallback and still draws the devices, gates, field edges and terrain overlays. + +Owner decision (2026-10-12): the eleven scenes belong to the 0.2.5 asset pack. The maintainer's full release zip +includes the extracted files. A lite package or a source install may download and extract them on that machine +(`node tools/setup.mjs --scenes`; `--no-scenes` skips). Missing Python 3.8+ or UnityPy / lz4 / Pillow is a warning, +and the metal board remains. The repository never stores the bundles or the extracted files (`.gitignore` already +ignores `public/assets/`, `data/local-assets.json` and `.cache/`, and names `.cache/official-scenes/`). Packaging +does not fail when the scenes are absent; the summary line `battlefield scenes` says whether this machine has them. +`scenes.py` and `fetch_scenes.py` ship in a player zip only after they are committed, because the packager reads +`git ls-files`. + +No gameplay change: the scene is art under the same grid, camera and unit layer. Golden results are not moved by +this change. + +**[ASSUMED]** the light gains `SCENE_LIGHT` (`range` 8.0, `ambient` 0.2, `direct` 0.8, `indirect` 0.4) and the RGBM +reading of the lightmap pages (Unity linear decode, range 5), both in DESIGN §15. Not done: the scene's normal / +metallic / emission maps, map effects, sky box and post-processing; the tile graphics a level switches at run time +(mire, tide) still use the remake's overlays. The extracted byte size inside a full zip was not measured here. + +Tests: `test/render/board3d-scene.test.js` (the scene table against data/stages.json, board-space conversion, chunk +layout shared by scenes.py and load.js, sub-graphic groups, RGBM decode, the loader with a fake store, BoardScene +with a stub renderer: scene on / areas / fallback). `test/scene-assets.test.js` (setup's scene step, the full plan +ships extracted scene files, the lite plan ships none, git ignores the bundles). diff --git a/public/js/render/app.js b/public/js/render/app.js index d457dbd2..b6d4eb5b 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -125,7 +125,7 @@ import { backdropTextures, shadowTexture, refreshTierChips, refreshDownLabels, s import { TILE_H, TIER_COLORS, COLORS } from './style.js'; import { loadBoardArt } from './boardArt.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 { unionAreas } from './board3d/layout.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 const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 }; 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) { if (destroyed || board3d || !THREE || !pack) return false; recover.THREE = THREE; recover.pack = pack; @@ -380,7 +388,7 @@ export async function createFieldView(host, options = {}) { tiles.setExternal(true); backdrop.visible = false; b.setArea(boardArea(viewKind(camKind, camOpts))); - if (stageRec) b.setStage(stageRec); + if (stageRec) { b.setStage(stageRec); useOfficialScene(stageRec); } b.setFocus(camRect()); b.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); tiles.project(cam, true); @@ -630,7 +638,7 @@ export async function createFieldView(host, options = {}) { stageRec = st; tiles.setStage(st); 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); // 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 diff --git a/public/js/render/board3d/load.js b/public/js/render/board3d/load.js index 6d29062b..5303e9b2 100644 --- a/public/js/render/board3d/load.js +++ b/public/js/render/board3d/load.js @@ -5,6 +5,9 @@ // → { key, images: { D, N?, R?, E?, common?, commonE?, BG?, wind?, gate?, waterN?, caustics?, noise? }, // meshes: { crate?, blower?, bgPlane?, gate: { startDown?, startUp?, startBack?, endDown?, endUp? } }, // 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 // map → flat shading of the atlas; no crate mesh → a procedural box). URLs come from data/local-assets.json only @@ -158,5 +161,102 @@ export function loadBoardPack(assets) { return pending; } +/** 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} + */ +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). */ -export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; } +export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; sceneCache.clear(); } diff --git a/public/js/render/board3d/materials.js b/public/js/render/board3d/materials.js index b122bebf..79ec86c9 100644 --- a/public/js/render/board3d/materials.js +++ b/public/js/render/board3d/materials.js @@ -8,6 +8,7 @@ // MT_autochessi_BG → unlit TX_autochessi_BG × 0.713 (Torappu/Unlit/Texture, _Color) // 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 +// 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` // (a soft world-space falloff), so the active field reads like the original's lit board without re-building // 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 ', + '}', + ].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 ', + '}', + ].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 * horizon, dark void below): gives the glass hatches, steel and gold frames their sheen and fills the shadows. diff --git a/public/js/render/board3d/scene.js b/public/js/render/board3d/scene.js index 0543a0c3..0e08ce03 100644 --- a/public/js/render/board3d/scene.js +++ b/public/js/render/board3d/scene.js @@ -4,6 +4,8 @@ // // 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.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.setFocus(rect) the lit field (others fade, animated) // 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 { 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'; import { syncThreeCamera } from '../projection.js'; @@ -53,6 +55,17 @@ export function gatePulse(t, phase = 0) { 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 mergeInto = (list) => { @@ -194,6 +207,10 @@ export class BoardScene { this.scene.add(this.root, this.dynamic); this.stageKey = null; this.stage = null; + this.scenePacks = new Map(); // stage id → official scene pack (setScenePack) + this.sceneRes = new Map(); // pack key → { tex, lm, mats } GPU resources (kept across rebuilds) + this.official = null; + this.sceneWater = []; this.area = AREAS.normal; this.areaKey = areaKey(AREAS.normal); this.battleRect = null; @@ -267,7 +284,8 @@ export class BoardScene { /** Rebuild everything for a stage (no-op when the same stage object/grid is set again). */ setStage(stage) { 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; this.stageKey = key; this.stage = stage || null; @@ -276,19 +294,96 @@ export class BoardScene { const board = buildBoard(stage, { uv: this.pack?.uv || null, area: this.area }); this.board = board; const M = this.meshes = {}; - M.board = this._mesh(board.buckets.board, this.mat.board); - 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.official = official && this._buildScene(official) ? official : null; + if (!this.official) { + M.board = this._mesh(board.buckets.board, this.mat.board); + 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._buildGates(board); this._buildEdges(board); this._buildTerrain(board); - this._buildBackground(board); + if (!this.official) this._buildBackground(board); this._fitShadow(board); this.renderer.shadowMap.needsUpdate = true; } + /** Use (or drop, with null) the official scene of stage `id`; rebuilds when that stage is the current one. */ + setScenePack(id, pack) { + if (this.destroyed || typeof id !== 'string' || !id) return false; + const prev = this.scenePacks.get(id) || null; + if (prev === (pack || null)) return false; + if (prev) this._disposeScene(prev); + if (pack) this.scenePacks.set(id, pack); else this.scenePacks.delete(id); + if (this.stage && this.stage.id === id) { this.stageKey = null; this.setStage(this.stage); } + return true; + } + + /** Textures and materials of a scene pack (created once per pack, kept across rebuilds). */ + _sceneResources(pack) { + let res = this.sceneRes.get(pack.key); + if (res) return res; + const T = this.THREE; + const aniso = Math.min(8, this.renderer.capabilities?.getMaxAnisotropy?.() || 1); + const tex = {}, lm = []; + for (const [name, img] of Object.entries(pack.images?.tex || {})) tex[name] = makeTexture(T, img, { aniso, repeat: true }); + for (const img of pack.images?.lm || []) lm.push(img ? makeTexture(T, img, { srgb: false }) : null); + res = { tex, lm, mats: new Map() }; + this.sceneRes.set(pack.key, res); + return res; + } + + _disposeScene(pack) { + const res = this.sceneRes.get(pack.key); + if (!res) return; + for (const m of res.mats.values()) m.dispose(); + for (const t of [...Object.values(res.tex), ...res.lm]) t?.dispose?.(); + this.sceneRes.delete(pack.key); + } + + /** The official scene's meshes: one per chunk of a shown part (the base + the fields of the built areas). */ + _buildScene(pack) { + const T = this.THREE; + const res = this._sceneResources(pack); + const infos = pack.doc?.materials || {}; + this.sceneWater = []; + let n = 0; + for (const c of pack.chunks || []) { + if (c.group && !sceneGroupVisible(pack.groups?.[c.group], this.area)) continue; + const info = infos[c.material] || null; + const water = info?.kind === 'water'; + const mk = `${c.material}|${water ? '' : c.lightmap}`; + let mat = res.mats.get(mk); + if (!mat) { + mat = water + ? sceneWaterMaterial(T, this.tex, this.focus, info) + : sceneMaterial(T, { + map: res.tex[info?.map] || null, lightMap: c.lightmap >= 0 ? (res.lm[c.lightmap] || null) : null, info, + light: pack.doc?.light || null, lightRange: pack.doc?.lightRange, + }, this.focus); + res.mats.set(mk, mat); + } + if (water) this.sceneWater.push(mat); + const g = new T.BufferGeometry(); + g.setAttribute('position', new T.BufferAttribute(c.position, 3)); + g.setAttribute('normal', new T.BufferAttribute(c.normal, 4, true)); + g.setAttribute('uv', new T.BufferAttribute(c.uv, 2)); + g.setAttribute('aLm', new T.BufferAttribute(c.uv1, 2)); + g.setAttribute('aColor', new T.BufferAttribute(c.color, 4, true)); + g.setIndex(new T.BufferAttribute(c.index, 1)); + g.computeBoundingSphere(); + const mesh = new T.Mesh(g, mat); + mesh.castShadow = false; mesh.receiveShadow = false; + mesh.renderOrder = water ? 1 : mat.userData.blend === 'blend' ? 2 : mat.userData.blend === 'cutout' ? -3 : -4; + mesh.matrixAutoUpdate = false; mesh.updateMatrix(); + this.root.add(mesh); + this.meshes[`scene${n++}`] = mesh; + } + return n > 0; + } + _buildDevices(board) { const crates = [], blowers = []; this.staticCrates = []; @@ -550,6 +645,7 @@ export class BoardScene { for (let i = this.flashes.length - 1; i >= 0; i--) { const f = this.flashes[i]; f.t += dt; if (f.t > 1.2) this.flashes.splice(i, 1); else flash = Math.max(flash, 1 - f.t / 1.2); } for (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; + if (this.official) for (const m of this.sceneWater || []) m.uniforms.uTime.value = t; this.renderer.render(this.scene, this.camera); this.frames++; if (t0) this.lastMs = this.lastMs * 0.9 + ((typeof performance !== 'undefined' ? performance.now() : t0) - t0) * 0.1; @@ -566,6 +662,8 @@ export class BoardScene { this.destroyed = true; for (const d of [...this.devices]) d.destroy(); 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) for (const m of Object.values(this.mat)) m?.dispose?.(); for (const t of Object.values(this.tex)) t?.dispose?.(); diff --git a/test/render/board3d-scene.test.js b/test/render/board3d-scene.test.js new file mode 100644 index 00000000..ee87eed4 --- /dev/null +++ b/test/render/board3d-scene.test.js @@ -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/ 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(' { + 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'); + }); + } +}); diff --git a/test/scene-assets.test.js b/test/scene-assets.test.js new file mode 100644 index 00000000..dd74a2e6 --- /dev/null +++ b/test/scene-assets.test.js @@ -0,0 +1,135 @@ +// Official battlefield scenes in the 0.2.5 asset pack (PR #533, GitHub #532). +// The repository never stores the bundles or the extracted files. The full release zip includes +// public/assets/local/scene when the maintainer extracted it; a lite zip and a source install do not, +// and setup may fetch + extract on that machine. Missing extractor dependencies keep the metal board. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { artPlan, formatSummary, isPlayerFile, isRefused } from '../tools/package.mjs'; +import { localGaps, sceneInstallPlan } from '../tools/setup.mjs'; + +const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); + +test('sceneInstallPlan: fetch only with a client and extractor deps; otherwise the metal board', () => { + const present = sceneInstallPlan({ hasScenes: true, client: false }); + assert.equal(present.run, false); + assert.equal(present.state, 'ok'); + assert.match(present.detail, /金属地块/); + + const check = sceneInstallPlan({ hasScenes: false, check: true, client: true, python: true }); + assert.equal(check.run, false); + assert.equal(check.state, 'skip'); + assert.match(check.detail, /--scenes/); + assert.match(check.detail, /金属地块/); + + const noClient = sceneInstallPlan({ hasScenes: false, client: false, python: true }); + assert.equal(noClient.run, false); + assert.equal(noClient.state, 'skip'); + assert.match(noClient.detail, /完整包/); + assert.match(noClient.detail, /金属地块/); + + const incomplete = sceneInstallPlan({ hasScenes: false, client: 'incomplete', python: true, force: true }); + assert.equal(incomplete.run, false); + assert.equal(incomplete.state, 'warn'); + assert.match(incomplete.detail, /金属地块/); + + const noPy = sceneInstallPlan({ hasScenes: false, client: true, python: false }); + assert.equal(noPy.run, false); + assert.equal(noPy.state, 'warn'); + assert.match(noPy.detail, /Python/); + assert.match(noPy.detail, /金属地块/); + + const noDeps = sceneInstallPlan({ hasScenes: false, client: true, python: true, deps: false }); + assert.equal(noDeps.run, false); + assert.equal(noDeps.state, 'warn'); + assert.match(noDeps.detail, /UnityPy/); + assert.match(noDeps.detail, /金属地块/); + + const declined = sceneInstallPlan({ hasScenes: false, client: true, python: true, declined: true }); + assert.equal(declined.run, false); + assert.equal(declined.state, 'skip'); + + const ask = sceneInstallPlan({ hasScenes: false, client: true, python: true }); + assert.equal(ask.run, false); + assert.equal(ask.state, 'ask'); + assert.match(ask.detail, /金属地块/); + + for (const extra of [{ force: true, declined: true }, { yes: true }]) { + const go = sceneInstallPlan({ hasScenes: false, client: true, python: true, ...extra }); + assert.equal(go.run, true, JSON.stringify(extra)); + assert.match(go.detail, /金属地块/); + } +}); + +test('an older extraction is told it lacks the battlefield scenes', () => { + assert.equal(localGaps({ enemySpines: true, tokenSpines: true }), ''); + assert.match(localGaps({ enemySpines: true, tokenSpines: true, scenes: false }), /各战场官方场景/); + assert.match(localGaps({ enemySpines: false, tokenSpines: false, scenes: false }), /各战场官方场景/); +}); + +test('official scene bundles and extracted scenes are not git files; the extractor scripts are', () => { + const gi = fs.readFileSync(path.join(ROOT, '.gitignore'), 'utf8'); + assert.match(gi, /^public\/assets\/$/m); + assert.match(gi, /^data\/local-assets\.json$/m); + assert.match(gi, /^\.cache\/$/m); + assert.match(gi, /official-scenes/); + assert.equal(isRefused('.cache/official-scenes/26-04-13-12-31-42_e9b560/scenes/a.ab'), true); + assert.equal(isRefused('public/assets/local/scene/act2autochess_m02/scene.bin'), false); + assert.equal(isPlayerFile('public/assets/local/scene/act2autochess_m02/scene.bin'), false); + assert.equal(isPlayerFile('tools/local-extract/scenes.py'), true); + assert.equal(isPlayerFile('tools/local-extract/fetch_scenes.py'), true); +}); + +test('the full asset plan ships extracted scenes; the lite plan ships none', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-scenes-')); + try { + const sceneDir = path.join(dir, 'public/assets/local/scene/act2autochess_m02'); + const texDir = path.join(dir, 'public/assets/local/scene/tex'); + fs.mkdirSync(sceneDir, { recursive: true }); + fs.mkdirSync(texDir, { recursive: true }); + fs.mkdirSync(path.join(dir, 'data'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'data/assets.json'), '{}\n'); + fs.writeFileSync(path.join(sceneDir, 'scene.bin'), 'mesh-bytes'); + fs.writeFileSync(path.join(sceneDir, 'scene.json'), '{}\n'); + fs.writeFileSync(path.join(texDir, 'TX_A.webp'), 'webp'); + fs.writeFileSync(path.join(dir, 'data/local-assets.json'), JSON.stringify({ + version: 1, + groups: { + 'scene/act2autochess_m02': { + scene: { path: '/assets/local/scene/act2autochess_m02/scene.json' }, + bin: { path: '/assets/local/scene/act2autochess_m02/scene.bin' }, + }, + 'scene/tex': { TX_A: { path: '/assets/local/scene/tex/TX_A.webp' } }, + }, + })); + const full = artPlan(dir, { lite: false }); + for (const f of [ + 'public/assets/local/scene/act2autochess_m02/scene.bin', + 'public/assets/local/scene/act2autochess_m02/scene.json', + 'public/assets/local/scene/tex/TX_A.webp', + 'data/local-assets.json', + ]) assert.ok(full.files.includes(f), f); + assert.ok(full.scenes.includes('public/assets/local/scene/act2autochess_m02/scene.bin')); + assert.equal(full.scenes.length, 3); + const lite = artPlan(dir, { lite: true }); + assert.deepEqual(lite.files, []); + assert.deepEqual(lite.scenes, []); + const summary = formatSummary({ + lite: false, version: '0.2.5-dev', tracked: [], art: [], localArt: true, generated: [], dropped: {}, orphans: [], held: [], + scanned: 0, names: 0, problems: [], scenes: { files: full.scenes, bytes: 9 + 3 + 4 }, + bytes: { tracked: 0, art: 16, modules: 0, vendor: 0, total: 16, leftOut: 0, orphans: 0 }, + }); + assert.match(summary, /battlefield scenes: 3 files/); + const empty = formatSummary({ + lite: false, version: '0.2.5-dev', tracked: [], art: [], localArt: true, generated: [], dropped: {}, orphans: [], held: [], + scanned: 0, names: 0, problems: [], scenes: { files: [], bytes: 0 }, + bytes: { tracked: 0, art: 0, modules: 0, vendor: 0, total: 0, leftOut: 0, orphans: 0 }, + }); + assert.match(empty, /battlefield scenes: none/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tools/doctor.mjs b/tools/doctor.mjs index e7f59e7e..2d163ed6 100755 --- a/tools/doctor.mjs +++ b/tools/doctor.mjs @@ -251,7 +251,7 @@ async function main() { const local = checkLocal(); const client = findClient(null); row(local.manifest ? 'ok' : 'skip', '本地客户端美术(可选)', local.manifest - ? `${local.count} 项${local.board3d ? ',3D 棋盘可用' : ',无棋盘贴图(2D 棋盘)'}${local.board3d && !local.tiles ? ';缺 tiles.json → node tools/setup.mjs' : ''}` + ? `${local.count} 项${local.board3d ? ',3D 棋盘可用' : ',无棋盘贴图(2D 棋盘)'}${local.scenes ? ',已提取各战场官方场景' : ',无各战场官方场景(3D 棋盘用金属地块;node tools/setup.mjs --scenes)'}${local.board3d && !local.tiles ? ';缺 tiles.json → node tools/setup.mjs' : ''}` : client ? `检测到 ${client.kind} 客户端 → node tools/setup.mjs --local` : `未提取:${LOCAL_ART_FALLBACK}(${LOCAL_ART_COPY_HINT})`); if (client || local.manifest) { const py = findPython(); diff --git a/tools/local-extract/extract.py b/tools/local-extract/extract.py index 45c607dd..129d18e4 100644 --- a/tools/local-extract/extract.py +++ b/tools/local-extract/extract.py @@ -34,6 +34,10 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l source as _rgb.png / _rough.png - 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 official battlefield scenes (scenes.py SCENES: one prebuilt scene per 卫戍协议 battlefield) → scene// + 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 (default + .cache/official-scenes/*, filled by tools/local-extract/fetch_scenes.py) Usage: python3 -m venv .venv && .venv/bin/pip install -r tools/local-extract/requirements.txt @@ -41,6 +45,7 @@ Usage: python3 tools/local-extract/extract.py --print-jobs (the job table as JSON; needs no dependencies) python3 tools/local-extract/extract.py --webp (only the WebP copies, from the PNGs already extracted — e.g. the local art copied from a release bundle; needs Pillow) + .venv/bin/python tools/local-extract/extract.py --only scene [--scenes ] Writes /**.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 @@ -55,6 +60,7 @@ import sys from pathlib import Path 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] HOME = Path.home() @@ -722,6 +728,39 @@ def write_spine(objects, sda, sub, name, out_root, manifest, log): return len(files) + len(pages) +def scene_ids(only): + """The stage ids of scenes.SCENES that run under --only (all with no prefix, `scene` or a parent of it).""" + return spine_ids(only, scenes.SCENE_SUB, [sid for sid, _deps in scenes.SCENES]) + + +def scene_roots(ab_root, extra): + """AB roots searched for the scene bundles, in order: the client, --scenes dirs, then every version kept under + .cache/official-scenes (newest name first).""" + kept = ROOT / '.cache' / 'official-scenes' + cached = sorted((p for p in kept.iterdir() if p.is_dir()), reverse=True) if kept.is_dir() else [] + return [Path(ab_root), *[Path(p) for p in extra], *cached] + + +def export_scenes(ab_root, out_root, manifest, log, ids, roots): + """scenes.SCENES (the ids in `ids`) → scene// (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): """ENEMY_SPINES (or `ids`) from the enemy art bundles (each bundle loaded on its own; stops once every model was found).""" @@ -802,6 +841,9 @@ def main(): ap.add_argument('--manifest', default=str(ROOT / 'data/local-assets.json')) 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') + 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('--webp', action='store_true', help='only write the WebP copies of the board textures from the PNGs already under --out and list ' @@ -816,7 +858,10 @@ def main(): print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs, 'derived': derived, 'webp': webp, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB, 'ids': ENEMY_SPINES}, - 'tokenSpines': {'bundles': TOKEN_PACKS, 'sub': TOKEN_SPINE_SUB, 'ids': TOKEN_SPINES}}, + '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)) return 0 if args.webp: @@ -829,7 +874,8 @@ def main(): jobs = select_jobs(args.only) enemy_ids = spine_ids(args.only, ENEMY_SPINE_SUB, ENEMY_SPINES) token_ids = spine_ids(args.only, TOKEN_SPINE_SUB, TOKEN_SPINES) - if not jobs and not enemy_ids and not token_ids: + 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) return 2 out_root = Path(args.out) @@ -842,6 +888,8 @@ def main(): total += export_enemy_spines(ab_root, out_root, manifest, print, enemy_ids) if token_ids: total += export_token_spines(ab_root, out_root, manifest, print, token_ids) + if stage_ids: + total += export_scenes(ab_root, out_root, manifest, print, stage_ids, scene_roots(ab_root, args.scenes)) old = {} if args.only and Path(args.manifest).exists(): try: diff --git a/tools/local-extract/fetch_scenes.py b/tools/local-extract/fetch_scenes.py new file mode 100644 index 00000000..099d59d1 --- /dev/null +++ b/tools/local-extract/fetch_scenes.py @@ -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//, 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 ] + 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/)') + 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()) diff --git a/tools/local-extract/scenes.py b/tools/local-extract/scenes.py new file mode 100644 index 00000000..7081a965 --- /dev/null +++ b/tools/local-extract/scenes.py @@ -0,0 +1,457 @@ +"""The official battlefield scenes of 卫戍协议 (DESIGN §15): every battlefield is a prebuilt Unity scene +(scenes/activities//level_/level_.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 , 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 /scene// (manifest group scene/): + 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.png lightmap page i (+ a WebP copy, the one the manifest lists): RGB = the baked indirect light (Lightmap-_comp_light, stored RGBM: decode_rgbm), + A = the key light's baked shadow (Lightmap-_comp_shadowmask, R) +and the materials' albedo textures under /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 .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('