0.2.5-dev: merge claude/036-scene
# Conflicts: # CHANGELOG.md # docs/history/0.2.5.md
This commit is contained in:
commit
bb88031256
19 files changed
+1660
-22
No files matched your search
@@ -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/
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
- 设置增加「官方伤害数字」(默认关闭)。打开后只按 PRTS「伤害红字(暴击)」显示红色整数;关闭时仍显示每一次命中(#412)。
|
||||
- 标题画面页脚增加「项目仓库」,在新标签页打开本项目仓库(#543)。
|
||||
|
||||
- 3D 棋盘在提取了官方场景时按战场显示沙地等 11 个场景,没有场景时仍用金属地块(PR #533,感谢 @andrew05060414)。完整包带上提取出的场景;精简包和源码可以在本机下载并提取,提取依赖缺失时继续用金属地块。
|
||||
|
||||
## 0.2.4 — 2026-10-11
|
||||
|
||||
### 规则(按官方)
|
||||
|
||||
@@ -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/<season>/level_<stage>/…` (`tools/local-extract/extract.py --only scene`, optional). The client carries them only while the season runs; off season `tools/local-extract/fetch_scenes.py` downloads the bundles of the in-season version `26-04-13-12-31-42_e9b560` from the official CDN into `.cache/official-scenes/` (33 bundles, 22,801,308 bytes compressed on the Android list, md5-checked), and the materials / textures still come from the local client's theme bundles. The 0.2.5 full release zip includes the extracted files under `public/assets/local/scene/` when the maintainer extracted them before packaging; a lite zip and a source install may fetch and extract them (`node tools/setup.mjs --scenes`). The repository never stores the bundles or the extracted files. Without a scene the 3D board draws its procedural metal tiles | `local/scene/{stageId}/scene.json`, `scene.bin`, `lm{i}.*`; `local/scene/tex/{texture}.*` (listed in `data/local-assets.json`) |
|
||||
| BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` (大厅/休整期 `act1autochess`, 开战 `act13side/m_bat_kazimierz2_{1,2}` — 骑士之日 / 无畏者; the 开战 track follows the round: `_2` 无畏者 rounds 1–7, `_1` 骑士之日 from round 8) | `audio/bgm/{file}.mp3` |
|
||||
| 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`) |
|
||||
|
||||
@@ -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;正式打包时还会核对临时目录里的文件和计划完全一致。
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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: { '<subdir>': { name: { path, w, h, kind } } } }`). Everything here is **optional**: clients must render correctly when the file or any entry is missing (fall back to the web assets / procedural art). The emotes and the guide pages below are on the public mirror too (§22.5): fetch-assets downloads them (data/assets.json `ui['emoticon/<dir>/<picId>']`, `ui['guide/<key>']`) and the client shows the local copy first, then that one (`data.js artUrls`); docs/DEPLOY.md §6 lists what still needs the client.
|
||||
- `map/autochess/TX_autochessi_*` — the real autochess board atlas (`_D` diffuse 2048² with floor tiles, gold-framed high-ground plates, REINFORCEMENTS bench pads, EQUIPMENTS pads, crates, gates; `_N/_M/_E` normal/mask/emission; `common_D` device icons; `BG` backdrop). `map/autochesssand/*` — the sand-theme variant.
|
||||
- `map/autochess/TX_autochessi_*` — the real autochess board atlas (`_D` diffuse 2048² with floor tiles, gold-framed high-ground plates, REINFORCEMENTS bench pads, EQUIPMENTS pads, crates, gates; `_N/_M/_E` normal/mask/emission; `common_D` device icons; `BG` backdrop). `map/autochesssand/*` — the textures and materials of the sand theme (the theme of nine of the eleven battlefield scenes, §15).
|
||||
- `scene/<stageId>/` and `scene/tex/` — the official scene of a battlefield (§15 "Official battlefield scenes", DESIGN §30.17): `scene.json` (materials, key light, chunk records), `scene.bin` (the meshes in board space), the lightmap pages `lm<i>`, and the materials' albedo textures shared by the scenes. Read from the scene bundles `scenes/activities/<season>/level_<stage>/…`, which the client carries only while the season runs: `extract.py --only scene` takes them from the client or from `--scenes <dir>` / `.cache/official-scenes/*` (`tools/local-extract/fetch_scenes.py` downloads them from the official CDN's in-season version). The 0.2.5 full release zip includes the extracted files when the maintainer extracted them before packaging; a lite zip and a source install may fetch and extract them (`node tools/setup.mjs --scenes`). The repository never stores the bundles or the extracted files.
|
||||
- `ui/{common,battle,outer}/*` — every sprite of the three official autochess UI bundles (tier chips I–VI, bond disc rings, frames, banners, HUD pieces, badges, backgrounds, mode art).
|
||||
- `emoticon/<dir>/<picId>.png` — the 36 official battle emotes, 6 themes (`shared/constants.js → EMOTE_THEMES / EMOTES`, `emoteArtPath`; data/emotes.json); also downloaded from the mirror (§22.5).
|
||||
- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5).
|
||||
@@ -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 (`<hash>.idx`: each bundle's dependencies) gives each scene's art: act1 m01–m07 and act2 m02 / m04 depend on `arts/maps/map_autochesssand` (m05 and act2 m04 also on `map_doss_shore`), act2 m01 on `map_lakegarden` + `map_syracuse_metro`, act2 m03 on `map_kxmr` + `map_lm_center`. **No battlefield scene depends on `arts/maps/map_autochess`** (the metal theme the procedural board below draws; in the current client the 矢量突破 scenes `act3break` use it). A scene holds the logic tiles (`(row,col)#tileKey` at anchor-local (col, row, −height), high ground 0.4), the art under `GraphicHolder` (static parts merged into "Combined Mesh (root: scene)" objects, lightmapped: Shadowmask mode, one directional key light), and three sub-graphics `HIDDEN@1…3` (`MapSubGraphicHolder`) that the game switches on: the left normal field, the right normal field and the boss field — the areas of `layout.js AREAS`.
|
||||
|
||||
Official battlefield scenes (`board3d/load.js loadScenePack`, `scene.js setScenePack`, `materials.js sceneMaterial`): when `data/local-assets.json` lists `scene/<stageId>`, the 3D board draws that scene instead of the procedural tiles, cliffs and background plane; the devices, gate / objective boxes, field edges and terrain overlays stay as below. The base part is always drawn; a `HIDDEN@n` part is drawn when its centre lies in one of the built areas. Without the scene (not extracted, a load failure, an unknown `version`) the procedural metal board is drawn as before. Approximations: the official shaders are not reproduced — a lit material is albedo × (the lightmap's baked light + key light · N·L · the baked shadow mask), in linear light; normal, metallic and emission maps, the vertex-colour blend (`_HG_VERTEX_COLOR_BLEND_ON`), the sky box, the map effects (`mapeff_*`) and the post-processing profile are not drawn; `Torappu/Scene/StylizedWater` is the board's water shader in the material's colours; crates cast no shadow on a scene. **[ASSUMED]** `SCENE_LIGHT` (`direct` 0.8, `indirect` 0.4, `ambient` 0.2: tuned by eye against the PRTS screenshot of 战场#06, the official post-processing being unknown) and the RGBM reading of the lightmap pages (`scenes.py decode_rgbm`: Unity's linear-space decode, range 5). Owner decision (2026-10-12): these extracted files belong to the 0.2.5 asset pack and may ship in the full release zip; a lite package or a source install may download and extract them on that machine. Missing extractor dependencies keep the metal board. The repository still never stores the official files.
|
||||
|
||||
Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) underneath the existing Pixi layer:
|
||||
- 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.
|
||||
|
||||
@@ -366,3 +366,51 @@ replace 设置 → 问题反馈.
|
||||
Tests: `test/sim/official-damage-numbers.test.js`, `test/ui/official-damage-numbers.test.js`,
|
||||
`test/render/official-numbers.test.js`. Golden digests do not move: no new event kind is emitted, and the extra
|
||||
tuple field is not part of the digest. Player notes: PLAYING; client notes: design/client.md.
|
||||
|
||||
### 30.17 Official battlefield scenes (#532)
|
||||
|
||||
Report: the web board draws the metal theme for every battlefield. The reporter asked for the sand theme.
|
||||
PR #533 by @andrew05060414.
|
||||
|
||||
Findings (2026-10-11; the official hot-update lists and asset indexes, read from the CDN, and the scene bundles of
|
||||
version `26-04-13-12-31-42_e9b560`):
|
||||
|
||||
- The official board is not assembled at run time. Every battlefield is a prebuilt scene bundle, shipped only while
|
||||
the season runs. DESIGN §15 said otherwise from an off-season list and is corrected there.
|
||||
- Which art a battlefield uses is the dependency list of its scene bundle: the sand theme for act1 m01–m07 and act2
|
||||
m02 / m04, `map_lakegarden` + `map_syracuse_metro` for act2 m01, `map_kxmr` + `map_lm_center` for act2 m03. The
|
||||
same in the 上半 version of 2025-11 and the 下半 versions of 2026-03 / 04. No battlefield uses the metal theme.
|
||||
- The sand theme bundle holds no mesh because the models are baked into each scene ("Combined Mesh (root: scene)").
|
||||
- A scene's three sub-graphics `HIDDEN@1…3` are the left normal field, the right normal field and the boss field.
|
||||
- `python3 tools/local-extract/fetch_scenes.py --list` against that Android hot-update list names 33 bundles and
|
||||
22,801,308 bytes. That is the compressed CDN download, not the extracted size in the full zip.
|
||||
|
||||
Change: `tools/local-extract/scenes.py` exports a battlefield's scene (meshes in board space, materials, key light,
|
||||
lightmap pages with the baked shadow mask in alpha, albedo textures) to `public/assets/local/scene/<stageId>/`;
|
||||
`extract.py --only scene` runs it, reading the scene bundles from the client, `--scenes <dir>` or
|
||||
`.cache/official-scenes/*`; `tools/local-extract/fetch_scenes.py` downloads the in-season bundles from the official
|
||||
CDN (md5-checked against that version's hot-update list). The 3D board draws a stage's scene when the local-art
|
||||
manifest lists it (`board3d/load.js loadScenePack`, `scene.js setScenePack`); the procedural metal board stays the
|
||||
fallback and still draws the devices, gates, field edges and terrain overlays.
|
||||
|
||||
Owner decision (2026-10-12): the eleven scenes belong to the 0.2.5 asset pack. The maintainer's full release zip
|
||||
includes the extracted files. A lite package or a source install may download and extract them on that machine
|
||||
(`node tools/setup.mjs --scenes`; `--no-scenes` skips). Missing Python 3.8+ or UnityPy / lz4 / Pillow is a warning,
|
||||
and the metal board remains. The repository never stores the bundles or the extracted files (`.gitignore` already
|
||||
ignores `public/assets/`, `data/local-assets.json` and `.cache/`, and names `.cache/official-scenes/`). Packaging
|
||||
does not fail when the scenes are absent; the summary line `battlefield scenes` says whether this machine has them.
|
||||
`scenes.py` and `fetch_scenes.py` ship in a player zip only after they are committed, because the packager reads
|
||||
`git ls-files`.
|
||||
|
||||
No gameplay change: the scene is art under the same grid, camera and unit layer. Golden results are not moved by
|
||||
this change.
|
||||
|
||||
**[ASSUMED]** the light gains `SCENE_LIGHT` (`range` 8.0, `ambient` 0.2, `direct` 0.8, `indirect` 0.4) and the RGBM
|
||||
reading of the lightmap pages (Unity linear decode, range 5), both in DESIGN §15. Not done: the scene's normal /
|
||||
metallic / emission maps, map effects, sky box and post-processing; the tile graphics a level switches at run time
|
||||
(mire, tide) still use the remake's overlays. The extracted byte size inside a full zip was not measured here.
|
||||
|
||||
Tests: `test/render/board3d-scene.test.js` (the scene table against data/stages.json, board-space conversion, chunk
|
||||
layout shared by scenes.py and load.js, sub-graphic groups, RGBM decode, the loader with a fake store, BoardScene
|
||||
with a stub renderer: scene on / areas / fallback). `test/scene-assets.test.js` (setup's scene step, the full plan
|
||||
ships extracted scene files, the lite plan ships none, git ignores the bundles).
|
||||
+11
-3
@@ -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
|
||||
|
||||
@@ -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<object|null>}
|
||||
*/
|
||||
export function loadScenePack(assets, stageId) {
|
||||
if (typeof stageId !== 'string' || !stageId) return Promise.resolve(null);
|
||||
if (sceneCache.has(stageId)) return sceneCache.get(stageId);
|
||||
const pending = (async () => {
|
||||
if (!(await sceneListed(assets, stageId)) || typeof assets.image !== 'function') return null;
|
||||
const url = (g, n) => { const u = assets.localUrl(g, n); return typeof u === 'string' && u ? encodeURI(u) : null; };
|
||||
const group = sceneGroup(stageId);
|
||||
const [doc, buf] = await Promise.all([fetchJson(url(group, 'scene')), fetchBuffer(url(group, 'bin'))]);
|
||||
if (!isObj(doc) || doc.version !== SCENE_VERSION || !buf) return null;
|
||||
const chunks = parseSceneChunks(doc, buf);
|
||||
if (!chunks || !chunks.length) return null;
|
||||
const img = async (u) => {
|
||||
if (!u) return null;
|
||||
const i = await assets.image(u, { retry: true }).catch(() => null);
|
||||
return i && (i.width || i.naturalWidth) > 0 ? i : null;
|
||||
};
|
||||
const mats = isObj(doc.materials) ? doc.materials : {};
|
||||
const names = [...new Set(Object.values(mats).map((m) => (isObj(m) && typeof m.map === 'string' ? m.map : null)).filter(Boolean))];
|
||||
const pages = Math.max(0, Math.min(16, doc.lightmaps | 0));
|
||||
const [texList, lm] = await Promise.all([
|
||||
Promise.all(names.map((n) => img(url(SCENE_TEX_GROUP, n)))),
|
||||
Promise.all(Array.from({ length: pages }, (_, i) => img(url(group, `lm${i}`)))),
|
||||
]);
|
||||
const tex = {};
|
||||
names.forEach((n, i) => { if (texList[i]) tex[n] = texList[i]; });
|
||||
return { key: `${url(group, 'scene')}#${doc.binBytes | 0}`, stage: stageId, doc, chunks, groups: sceneGroupRects(chunks), images: { tex, lm } };
|
||||
})().catch((err) => { console.warn('[board3d] scene load failed', err); return null; }).then((pack) => {
|
||||
if (!pack && sceneCache.get(stageId) === pending) sceneCache.delete(stageId);
|
||||
return pack;
|
||||
});
|
||||
sceneCache.set(stageId, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Forget the cached pack and the three.js import attempt (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; }
|
||||
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; sceneCache.clear(); }
|
||||
@@ -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 <colorspace_fragment>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
transparent: blend === 'blend', depthWrite: blend !== 'blend',
|
||||
});
|
||||
m.userData.blend = blend;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** A water surface of an official scene (Torappu/Scene/StylizedWater): the board's water shader in the material's colours. */
|
||||
export function sceneWaterMaterial(THREE, tex, focus, info) {
|
||||
const deep = colorOf(info, '_DeepWaterColor', [0.03, 0.2, 0.27, 0.7]), shallow = colorOf(info, '_ShallowWaterColor', [0.09, 0.46, 0.55, 0.5]);
|
||||
const uniforms = {
|
||||
uTime: { value: 0 }, uNormal: { value: tex.waterN || null }, uCaustics: { value: tex.caustics || null },
|
||||
uDeep: { value: new THREE.Vector4(...deep) }, uShallow: { value: new THREE.Vector4(...shallow) }, ...focus,
|
||||
};
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
defines: tex.waterN && tex.caustics ? { WATER_MAPS: 1 } : {},
|
||||
vertexShader: TERRAIN_VERT,
|
||||
fragmentShader: [
|
||||
'uniform float uTime; uniform sampler2D uNormal; uniform sampler2D uCaustics; uniform vec4 uDeep; uniform vec4 uShallow;',
|
||||
FOCUS_GLSL,
|
||||
'varying vec2 vUv; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' float diff = 0.5, spec = 0.0, ca = 0.0;',
|
||||
' #ifdef WATER_MAPS',
|
||||
' vec2 p = vWorld.xy * 0.45;',
|
||||
' vec3 n1 = texture2D(uNormal, p + vec2(uTime * 0.03, uTime * 0.021)).xyz * 2.0 - 1.0;',
|
||||
' vec3 n2 = texture2D(uNormal, p * 1.7 - vec2(uTime * 0.025, -uTime * 0.017)).xyz * 2.0 - 1.0;',
|
||||
' vec3 n = normalize(vec3(n1.xy + n2.xy, 2.2));',
|
||||
' vec3 L = normalize(vec3(-0.45, -0.3, 0.84));',
|
||||
' diff = clamp(dot(n, L), 0.0, 1.0);',
|
||||
' spec = pow(clamp(dot(reflect(-L, n), normalize(vec3(0.0, -0.5, 0.866))), 0.0, 1.0), 40.0);',
|
||||
' ca = texture2D(uCaustics, vWorld.xy * 0.6 + n.xy * 0.08 + vec2(uTime * 0.02, 0.0)).r;',
|
||||
' #endif',
|
||||
' vec4 w = mix(uDeep, uShallow, 0.08 + 0.2 * diff);',
|
||||
' vec3 c = pow(w.rgb, vec3(2.2)) + ca * vec3(0.03, 0.05, 0.05) + spec * vec3(0.5, 0.6, 0.65);',
|
||||
' gl_FragColor = vec4(c * focusMask(vWorld.xy), clamp(w.a + 0.2, 0.0, 1.0));',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft studio environment for image-based light (PMREM of a canvas equirect: bright sky with light panels above the
|
||||
* horizon, dark void below): gives the glass hatches, steel and gold frames their sheen and fills the shadows.
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
// test/render/board3d-scene.test.js — the official battlefield scenes (DESIGN §15): tools/local-extract/scenes.py
|
||||
// (the scene table, the board-space conversion, the chunk records, the sub-graphic groups, the RGBM lightmap decode;
|
||||
// run without UnityPy), fetch_scenes.py's bundle selection, the browser side (render/board3d/load.js chunk reader and
|
||||
// loader with a fake store; scene.js BoardScene with a stub renderer: the scene replaces the procedural tiles and
|
||||
// background, its parts follow the built areas, a missing scene keeps the procedural board), and — when scenes were
|
||||
// extracted here — the files the manifest lists.
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as THREE from 'three';
|
||||
import { BoardScene, sceneGroupVisible } from '../../public/js/render/board3d/scene.js';
|
||||
import { AREAS } from '../../public/js/render/board3d/layout.js';
|
||||
import { resolveUvTable } from '../../public/js/render/board3d/atlas.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
import { sceneMaterial, sceneWaterMaterial, sceneBlend, focusUniforms, SCENE_LIGHT } from '../../public/js/render/board3d/materials.js';
|
||||
import {
|
||||
parseSceneChunks, sceneGroupRects, loadScenePack, sceneListed, resetBoardPack, sceneGroup, SCENE_TEX_GROUP, SCENE_VERSION, SCENE_VERTEX_BYTES,
|
||||
} from '../../public/js/render/board3d/load.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const TOOL = path.join(ROOT, 'tools/local-extract');
|
||||
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
|
||||
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
|
||||
const HAS_PIL = !!PY && spawnSync(PY, ['-c', 'import PIL'], { env: ENV }).status === 0;
|
||||
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
|
||||
const manifest = (() => { try { return JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8')); } catch { return null; } })();
|
||||
|
||||
function py(code) {
|
||||
const src = `import sys, json, struct\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport scenes as s\n${code}`;
|
||||
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return JSON.parse(r.stdout);
|
||||
}
|
||||
|
||||
/** One quad as a scene chunk record + its bytes (the layout of scenes.py pack_chunk). */
|
||||
function quadChunk({ x0 = 0, y0 = 0, z = 0, material = 'M', lightmap = 0, group = '' } = {}) {
|
||||
const n = 4, idx = [0, 1, 2, 0, 2, 3];
|
||||
const buf = new ArrayBuffer(n * SCENE_VERTEX_BYTES + idx.length * 2);
|
||||
new Float32Array(buf, 0, 12).set([x0, y0, z, x0 + 1, y0, z, x0 + 1, y0 + 1, z, x0, y0 + 1, z]);
|
||||
new Float32Array(buf, 48, 8).set([0, 0, 1, 0, 1, 1, 0, 1]);
|
||||
new Float32Array(buf, 80, 8).set([0, 0, 0.5, 0, 0.5, 0.5, 0, 0.5]);
|
||||
new Int8Array(buf, 112, 16).set([0, 0, 127, 0, 0, 0, 127, 0, 0, 0, 127, 0, 0, 0, 127, 0]);
|
||||
new Uint8Array(buf, 128, 16).fill(255);
|
||||
new Uint16Array(buf, 144, 6).set(idx);
|
||||
return { rec: { material, lightmap, group, verts: n, tris: 2, index32: false, bounds: [x0, y0, z, x0 + 1, y0 + 1, z] }, bytes: new Uint8Array(buf) };
|
||||
}
|
||||
|
||||
/** scene.json + scene.bin of a few quads. */
|
||||
function fakeScene(parts) {
|
||||
const chunks = parts.map(quadChunk);
|
||||
const total = chunks.reduce((a, c) => a + c.bytes.length, 0);
|
||||
const bin = new Uint8Array(total);
|
||||
let off = 0;
|
||||
const recs = chunks.map((c) => { bin.set(c.bytes, off); const r = { ...c.rec, offset: off }; off += c.bytes.length; return r; });
|
||||
const doc = {
|
||||
version: SCENE_VERSION, stage: 'act2autochess_m02', lightmaps: 1, lightRange: 8, binBytes: total,
|
||||
light: { dir: [0.36, 0.45, 0.82], color: [1, 0.93, 0.87], intensity: 1.1 },
|
||||
materials: {
|
||||
M: { shader: 'Torappu/Scene/StandardRealtimeShadow', keywords: [], colors: { _Color: [1, 1, 1, 1] }, floats: { _ShadowStrength: 0.9 }, textures: {}, kind: 'lit', map: 'TX_A' },
|
||||
Cut: { shader: 'Torappu/Scene/StandardRealtimeShadow', keywords: ['_ALPHATEST_ON'], colors: {}, floats: { _Cutoff: 0.4 }, textures: {}, kind: 'lit', map: 'TX_A' },
|
||||
W: { shader: 'Torappu/Scene/StylizedWater', keywords: [], colors: { _DeepWaterColor: [0, 0.27, 0.4, 0.67] }, floats: {}, textures: {}, kind: 'water' },
|
||||
},
|
||||
chunks: recs,
|
||||
};
|
||||
return { doc, bin: bin.buffer };
|
||||
}
|
||||
|
||||
const PARTS = [
|
||||
{ material: 'M', x0: 9, y0: 15 }, // the base (pen side)
|
||||
{ material: 'W', x0: 20, y0: 12, lightmap: -1 }, // water of the base
|
||||
{ material: 'M', x0: 4, y0: 9, group: 'HIDDEN@1' }, // left normal field
|
||||
{ material: 'Cut', x0: 5, y0: 9, group: 'HIDDEN@1' },
|
||||
{ material: 'M', x0: 16, y0: 9, group: 'HIDDEN@2' }, // right normal field
|
||||
{ material: 'M', x0: 10, y0: 3, group: 'HIDDEN@3' }, // boss field
|
||||
];
|
||||
|
||||
function fakeScenePack() {
|
||||
const { doc, bin } = fakeScene(PARTS);
|
||||
const chunks = parseSceneChunks(doc, bin);
|
||||
const img = { width: 4, height: 4 };
|
||||
return { key: 'scene-test', stage: doc.stage, doc, chunks, groups: sceneGroupRects(chunks), images: { tex: { TX_A: img }, lm: [img] } };
|
||||
}
|
||||
|
||||
function stubRenderer() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
domElement: { addEventListener() {}, removeEventListener() {} },
|
||||
shadowMap: { enabled: true, needsUpdate: false },
|
||||
capabilities: { getMaxAnisotropy: () => 8 },
|
||||
info: { render: { calls: 7, triangles: 1234 }, memory: { textures: 3, geometries: 5 } },
|
||||
setClearColor() {}, setPixelRatio() {}, setSize() {}, render() {}, dispose() { calls.push(['dispose']); },
|
||||
};
|
||||
}
|
||||
|
||||
function fakePack() {
|
||||
const img = { width: 4, height: 4 };
|
||||
return { key: 'test', images: { D: img, common: img, gate: img, BG: img }, meshes: { gate: {} }, tiles: null, uv: resolveUvTable(null), materials: {} };
|
||||
}
|
||||
|
||||
const sceneMeshes = (board) => Object.entries(board.meshes).filter(([k, m]) => k.startsWith('scene') && m).map(([, m]) => m);
|
||||
|
||||
describe('scenes.py (no UnityPy needed)', { skip: !PY && 'no python3' }, () => {
|
||||
test('scene table: one prebuilt scene per battlefield of data/stages.json, with the theme bundles of its materials', () => {
|
||||
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const { scenes } = JSON.parse(r.stdout);
|
||||
assert.equal(scenes.sub, 'scene');
|
||||
assert.equal(scenes.tex, SCENE_TEX_GROUP);
|
||||
const ids = scenes.stages.map((x) => x.id);
|
||||
const battlefields = Object.keys(stages).filter((id) => /^act\dautochess_m\d+$/.test(id)).sort();
|
||||
assert.deepEqual([...ids].sort(), battlefields, 'every battlefield has a scene');
|
||||
for (const st of scenes.stages) {
|
||||
const season = st.id.replace(/_m\d+$/, '');
|
||||
assert.equal(st.bundle, `scenes/activities/${season}/level_${st.id}/level_${st.id}.ab`);
|
||||
assert.ok(st.deps.length > 0 && st.deps.every((d) => d.startsWith('arts/maps/') && d.endsWith('.ab')), st.id);
|
||||
}
|
||||
const sand = scenes.stages.filter((x) => x.deps.includes('arts/maps/map_autochesssand/res.ab')).map((x) => x.id);
|
||||
assert.deepEqual(sand, ['act1autochess_m01', 'act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04', 'act1autochess_m05',
|
||||
'act1autochess_m06', 'act1autochess_m07', 'act2autochess_m02', 'act2autochess_m04'], 'the sand-theme battlefields (official asset index)');
|
||||
assert.ok(!scenes.stages.some((x) => x.deps.includes('arts/maps/map_autochess/res.ab')), 'no battlefield uses the metal theme');
|
||||
});
|
||||
|
||||
test('--only scene / scene/<id> selects scenes; a client without scene bundles skips them', () => {
|
||||
const out = py(`import extract as e
|
||||
print(json.dumps({'all': len(e.scene_ids([])), 'scene': len(e.scene_ids(['scene'])), 'one': e.scene_ids(['scene/act2autochess_m02']),
|
||||
'other': e.scene_ids(['emoticon']), 'missing': s.find_scene('act2autochess_m02', ['/no/such/root']) is None}))`);
|
||||
assert.deepEqual(out, { all: 11, scene: 11, one: ['act2autochess_m02'], other: [], missing: true });
|
||||
});
|
||||
|
||||
test('board space: Unity anchor-local (x, y, −z); transforms compose; a mirrored transform has a negative determinant', () => {
|
||||
const out = py(`m = s.mul(s.trs((1, 2, 3), (0, 0, 0, 1), (2, 2, 2)), s.trs((1, 0, 0), (0, 0, 0, 1), (1, 1, 1)))
|
||||
rot = s.trs((0, 0, 0), (0, 0, 0.7071068, 0.7071068), (1, 1, 1))
|
||||
print(json.dumps({'board': s.to_board((-6.0, -4.0, -0.4), (-10.0, -9.0, 0.0)), 'm': [m[0][3], m[1][3], m[2][3], m[0][0]],
|
||||
'rot': [round(rot[0][1], 4), round(rot[1][0], 4)], 'det': [s.det3(s.IDENTITY), s.det3(s.trs((0, 0, 0), (0, 0, 0, 1), (1, 1, -1)))]}))`);
|
||||
assert.deepEqual(out.board, [4, 5, 0.4], 'tile (row 5, col 4) on high ground');
|
||||
assert.deepEqual(out.m, [3, 2, 3, 2]);
|
||||
assert.deepEqual(out.rot, [-1, 1], 'a quarter turn about z');
|
||||
assert.deepEqual(out.det, [1, -1]);
|
||||
});
|
||||
|
||||
test('pack_chunk: 36 bytes per vertex + 16-bit indices padded to 4 bytes (what load.js parseSceneChunks reads)', () => {
|
||||
const out = py(`ch = {'pos': [(0, 0, 0), (1, 0, 0), (0, 1, 0.5)], 'uv': [(0, 0), (1, 0), (0, 1)], 'uv1': [(0.1, 0.2), (0.3, 0.2), (0.1, 0.4)],
|
||||
'nrm': [(0, 0, 1)] * 3, 'col': [(1, 0.5, 0, 1)] * 3, 'idx': [0, 1, 2]}
|
||||
data, idx32 = s.pack_chunk(ch)
|
||||
print(json.dumps({'len': len(data), 'idx32': idx32, 'hex': data.hex()}))`);
|
||||
assert.equal(out.idx32, false);
|
||||
assert.equal(out.len, 3 * SCENE_VERTEX_BYTES + 8, '3 vertices, 3 indices (6 bytes) padded to 8');
|
||||
const bin = Uint8Array.from(Buffer.from(out.hex, 'hex')).buffer;
|
||||
const [c] = parseSceneChunks({ chunks: [{ material: 'M', lightmap: 0, group: '', verts: 3, tris: 1, offset: 0, index32: false, bounds: [0, 0, 0, 1, 1, 0.5] }] }, bin);
|
||||
assert.deepEqual([...c.position], [0, 0, 0, 1, 0, 0, 0, 1, 0.5]);
|
||||
assert.deepEqual([...c.uv1].map((v) => Math.round(v * 10) / 10), [0.1, 0.2, 0.3, 0.2, 0.1, 0.4]);
|
||||
assert.deepEqual([...c.normal.slice(0, 4)], [0, 0, 127, 0]);
|
||||
assert.deepEqual([...c.color.slice(0, 4)], [255, 128, 0, 255]);
|
||||
assert.deepEqual([...c.index], [0, 1, 2]);
|
||||
});
|
||||
|
||||
test('hidden_groups: the objects listed after each sub-graphic name of a MapSubGraphicHolder', () => {
|
||||
const out = py(`def name(t):
|
||||
b = t.encode(); return struct.pack('<i', len(b)) + b + b'\\0' * (-len(b) % 4)
|
||||
ptr = lambda pid: struct.pack('<iq', 0, pid)
|
||||
raw = struct.pack('<i', 2) + name('HIDDEN@1') + struct.pack('<ii', 1, 2) + ptr(11) + ptr(12) + struct.pack('<i', 1) + ptr(21) + struct.pack('<i', 0) \\
|
||||
+ name('HIDDEN@2') + struct.pack('<ii', 1, 1) + ptr(13) + struct.pack('<i', 0) + ptr(99)
|
||||
objs = {11: 1, 12: 1, 13: 1, 21: 1}
|
||||
print(json.dumps({str(k): v for k, v in s.hidden_groups(raw, objs).items()}))`);
|
||||
assert.deepEqual(out, { 11: 'HIDDEN@1', 12: 'HIDDEN@1', 21: 'HIDDEN@1', 13: 'HIDDEN@2' }, 'pointers to objects of the file; an unknown id is no pointer');
|
||||
});
|
||||
|
||||
test('decode_rgbm: the RGBM light as (L / LIGHT_RANGE)^(1/2.2); a page without alpha is kept', { skip: !HAS_PIL && 'no Pillow' }, () => {
|
||||
const out = py(`from PIL import Image
|
||||
img = Image.new('RGBA', (2, 1)); img.putdata([(255, 128, 0, 51), (255, 255, 255, 255)])
|
||||
d = s.decode_rgbm(img)
|
||||
print(json.dumps({'mode': d.mode, 'px': list(d.getdata()), 'range': s.LIGHT_RANGE, 'plain': s.decode_rgbm(Image.new('RGB', (1, 1), (9, 8, 7))).getpixel((0, 0))}))`);
|
||||
assert.equal(out.mode, 'RGB');
|
||||
// texel (1, 0.5, 0, A = 0.2): L = 5^2.2 · 0.2^2.2 · rgb → stored (L / range)^(1/2.2) = (5^2.2 / range)^(1/2.2) · 0.2 · rgb^(1/2.2)
|
||||
const k = (5 ** 2.2 / out.range) ** (1 / 2.2);
|
||||
const want = [1, 128 / 255, 0].map((c) => Math.round(255 * Math.min(1, k * 0.2) * c ** (1 / 2.2)));
|
||||
for (let i = 0; i < 3; i++) assert.ok(Math.abs(out.px[0][i] - want[i]) <= 2, `channel ${i}: ${out.px[0][i]} vs ${want[i]}`);
|
||||
assert.deepEqual(out.px[1], [255, 255, 255], 'clamped at the range');
|
||||
assert.deepEqual(out.plain, [9, 8, 7]);
|
||||
});
|
||||
|
||||
test('fetch_scenes.py: CDN file names and the bundles of the scene table', () => {
|
||||
const src = `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport fetch_scenes as f\nnames = ['arts/maps/map_autochess/res.ab', 'scenes/activities/act2autochess/level_act2autochess_m02/level_act2autochess_m02.ab', 'scenes/activities/act2autochess/level_act2autochess_m02/level_act2autochess_m02/lightingdata.ab', 'scenes/activities/act44side/level_act44side_01/level_act44side_01.ab']\nprint(json.dumps({'dat': f.dat_name('scenes/a/level_x/level_x.ab'), 'hash': f.dat_name('avg/a#1.ab'), 'wanted': f.wanted(names, ['act2autochess_m02'])}))`;
|
||||
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.dat, 'scenes_a_level_x_level_x.dat');
|
||||
assert.equal(out.hash, 'avg_a__1.dat');
|
||||
assert.equal(out.wanted.length, 2, 'the scene and its lighting data, nothing else');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scene chunks (load.js)', () => {
|
||||
test('parseSceneChunks: typed-array views per chunk; a record that does not fit the buffer rejects the scene', () => {
|
||||
const { doc, bin } = fakeScene(PARTS);
|
||||
const chunks = parseSceneChunks(doc, bin);
|
||||
assert.equal(chunks.length, PARTS.length);
|
||||
assert.equal(chunks[0].position.length, 12);
|
||||
assert.deepEqual([...chunks[2].position.slice(0, 3)], [4, 9, 0]);
|
||||
assert.deepEqual([...chunks[0].index], [0, 1, 2, 0, 2, 3]);
|
||||
assert.equal(chunks[1].lightmap, -1);
|
||||
assert.equal(parseSceneChunks(doc, bin.slice(0, bin.byteLength - 4)), null, 'truncated bin');
|
||||
assert.equal(parseSceneChunks({ chunks: [{ ...doc.chunks[0], offset: 2 }] }, bin), null, 'unaligned offset');
|
||||
assert.equal(parseSceneChunks({ chunks: [{ ...doc.chunks[0], verts: 0 }] }, bin), null);
|
||||
assert.equal(parseSceneChunks(null, bin), null);
|
||||
assert.equal(parseSceneChunks(doc, null), null);
|
||||
});
|
||||
|
||||
test('sceneGroupRects / sceneGroupVisible: the official sub-graphics are the fields of the built areas', () => {
|
||||
const { doc, bin } = fakeScene(PARTS);
|
||||
const rects = sceneGroupRects(parseSceneChunks(doc, bin));
|
||||
assert.deepEqual(Object.keys(rects).sort(), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
|
||||
assert.deepEqual(rects['HIDDEN@1'], [4, 9, 6, 10], 'union of the group\'s chunks');
|
||||
const shown = (areas) => Object.keys(rects).filter((g) => sceneGroupVisible(rects[g], areas)).sort();
|
||||
assert.deepEqual(shown(AREAS.normal), ['HIDDEN@1'], 'own field');
|
||||
assert.deepEqual(shown(AREAS.unite), ['HIDDEN@1', 'HIDDEN@2'], 'both normal fields');
|
||||
assert.deepEqual(shown(AREAS.boss), ['HIDDEN@3'], 'the boss field');
|
||||
assert.deepEqual(shown(AREAS.all), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
|
||||
assert.equal(sceneGroupVisible(null, AREAS.normal), true, 'a part without bounds is shown');
|
||||
});
|
||||
|
||||
test('loadScenePack: scene.json + scene.bin + lightmap pages + textures through the store; null when not listed or malformed', async () => {
|
||||
resetBoardPack();
|
||||
const { doc, bin } = fakeScene(PARTS);
|
||||
const group = sceneGroup('act2autochess_m02');
|
||||
const urls = { [`${group}|scene`]: '/s/scene.json', [`${group}|bin`]: '/s/scene.bin', [`${group}|lm0`]: '/s/lm0.webp', [`${SCENE_TEX_GROUP}|TX_A`]: '/t/TX_A.webp' };
|
||||
const seen = [];
|
||||
const store = { local: async () => ({ groups: {} }), localUrl: (g, n) => urls[`${g}|${n}`] || null, image: async (u) => { seen.push(u); return { width: 8, height: 8 }; } };
|
||||
const original = globalThis.fetch;
|
||||
let json = doc;
|
||||
globalThis.fetch = async (u) => (u === '/s/scene.json' ? { ok: true, json: async () => json } : u === '/s/scene.bin' ? { ok: true, arrayBuffer: async () => bin } : { ok: false });
|
||||
try {
|
||||
assert.equal(await sceneListed(store, 'act2autochess_m02'), true);
|
||||
assert.equal(await sceneListed(store, 'act2autochess_m01'), false);
|
||||
assert.equal(await loadScenePack(store, 'act2autochess_m01'), null, 'not extracted');
|
||||
const pack = await loadScenePack(store, 'act2autochess_m02');
|
||||
assert.equal(pack.chunks.length, PARTS.length);
|
||||
assert.deepEqual(seen.sort(), ['/s/lm0.webp', '/t/TX_A.webp']);
|
||||
assert.ok(pack.images.tex.TX_A && pack.images.lm[0]);
|
||||
assert.deepEqual(Object.keys(pack.groups).sort(), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
|
||||
assert.equal(await loadScenePack(store, 'act2autochess_m02'), pack, 'cached');
|
||||
resetBoardPack();
|
||||
json = { ...doc, version: SCENE_VERSION + 1 };
|
||||
assert.equal(await loadScenePack(store, 'act2autochess_m02'), null, 'unknown version → the procedural board');
|
||||
json = doc;
|
||||
assert.ok(await loadScenePack(store, 'act2autochess_m02'), 'a failed load is not cached');
|
||||
assert.equal(await loadScenePack(null, 'act2autochess_m02').catch(() => 'threw'), await loadScenePack(store, 'act2autochess_m02'), 'cached per stage');
|
||||
} finally { globalThis.fetch = original; resetBoardPack(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('scene materials', () => {
|
||||
test('sceneBlend: alpha test / blend by the Unity keywords', () => {
|
||||
assert.equal(sceneBlend({ keywords: ['_NORMALMAP'] }), 'opaque');
|
||||
assert.equal(sceneBlend({ keywords: ['_ALPHATEST_ON'] }), 'cutout');
|
||||
assert.equal(sceneBlend({ keywords: ['_ALPHABLEND_ON', '_EMISSION'] }), 'blend');
|
||||
assert.equal(sceneBlend(null), 'opaque');
|
||||
});
|
||||
|
||||
test('sceneMaterial: linear albedo tint, lightmap range, key light, cutoff; unlit fallback without a lightmap page', () => {
|
||||
const focus = focusUniforms(THREE);
|
||||
const map = new THREE.Texture(), lm = new THREE.Texture();
|
||||
const info = { keywords: ['_ALPHATEST_ON'], colors: { _Color: [0.5, 1, 1, 1] }, floats: { _Cutoff: 0.4, _ShadowStrength: 0.9 } };
|
||||
const m = sceneMaterial(THREE, { map, lightMap: lm, info, lightRange: 8, light: { dir: [0, 0, 2], color: [1, 1, 1], intensity: 1.1 } }, focus);
|
||||
assert.equal(m.uniforms.uMap.value, map);
|
||||
assert.equal(m.uniforms.uLm.value, lm);
|
||||
assert.ok(Math.abs(m.uniforms.uColor.value.x - 0.5 ** 2.2) < 1e-6, 'authored in gamma space');
|
||||
assert.equal(m.uniforms.uCutoff.value, 0.4);
|
||||
assert.equal(m.uniforms.uShadow.value, 0.9);
|
||||
assert.equal(m.uniforms.uLmScale.value, 8 * SCENE_LIGHT.indirect);
|
||||
assert.deepEqual(m.uniforms.uLightDir.value.toArray(), [0, 0, 1], 'normalised');
|
||||
assert.ok(Math.abs(m.uniforms.uLightColor.value.x - 1.1 * SCENE_LIGHT.direct) < 1e-6);
|
||||
assert.equal(m.uniforms.uFocus, focus.uFocus, 'the shared focus falloff');
|
||||
assert.equal(m.transparent, false);
|
||||
assert.ok(m.defines.SCENE_MAP && m.defines.SCENE_LM);
|
||||
const bare = sceneMaterial(THREE, { info: { keywords: ['_ALPHABLEND_ON'] } }, focus);
|
||||
assert.equal(bare.uniforms.uLmScale.value, 0);
|
||||
assert.equal(bare.uniforms.uAmbient.value, SCENE_LIGHT.ambient);
|
||||
assert.equal(bare.uniforms.uCutoff.value, 0);
|
||||
assert.equal(bare.transparent, true);
|
||||
assert.equal(bare.depthWrite, false);
|
||||
assert.ok(!bare.defines.SCENE_MAP && !bare.defines.SCENE_LM);
|
||||
const w = sceneWaterMaterial(THREE, {}, focus, { colors: { _DeepWaterColor: [0, 0.27, 0.4, 0.67] } });
|
||||
assert.deepEqual(w.uniforms.uDeep.value.toArray(), [0, 0.27, 0.4, 0.67]);
|
||||
assert.equal(w.transparent, true);
|
||||
assert.ok(!w.defines.WATER_MAPS, 'no water maps: a flat tint');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BoardScene with an official scene (stub renderer)', () => {
|
||||
const stage = stages.act2autochess_m02;
|
||||
|
||||
test('the scene replaces the procedural tiles and background; devices, gates, edges and terrain stay', () => {
|
||||
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
|
||||
board.setStage(stage);
|
||||
assert.ok(board.meshes.board && board.meshes.bg, 'procedural board first');
|
||||
assert.equal(board.official, null);
|
||||
assert.equal(board.setScenePack(stage.id, fakeScenePack()), true);
|
||||
assert.ok(board.official, 'official scene on');
|
||||
assert.ok(!board.meshes.board && !board.meshes.glass && !board.meshes.decal && !board.meshes.pipe && !board.meshes.bg && !board.meshes.bgShadow);
|
||||
assert.ok(board.meshes.crates && board.meshes.edges && board.meshes.mire, Object.keys(board.meshes).join());
|
||||
const meshes = sceneMeshes(board);
|
||||
assert.equal(meshes.length, 4, 'base ×2 + the left normal field ×2 (AREAS.normal)');
|
||||
for (const m of meshes) {
|
||||
assert.equal(m.geometry.getAttribute('position').count, 4);
|
||||
assert.ok(m.geometry.getAttribute('aLm') && m.geometry.getAttribute('aColor').normalized && m.geometry.getAttribute('normal').normalized);
|
||||
assert.equal(m.castShadow, false);
|
||||
}
|
||||
const water = meshes.find((m) => m.material.uniforms.uDeep);
|
||||
assert.ok(water && water.material.transparent);
|
||||
board.resize(1280, 720, 1);
|
||||
board.render(presetCamera('normal', { width: 1280, height: 720 }), 2.5);
|
||||
assert.equal(water.material.uniforms.uTime.value, 2.5, 'water animates');
|
||||
board.destroy();
|
||||
});
|
||||
|
||||
test('the scene\'s fields follow the built areas; materials and textures are reused across rebuilds', () => {
|
||||
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
|
||||
const pack = fakeScenePack();
|
||||
board.setScenePack(stage.id, pack);
|
||||
board.setStage(stage);
|
||||
const lit = sceneMeshes(board).find((m) => m.material.uniforms.uMap).material;
|
||||
assert.equal(lit.uniforms.uMap.value.colorSpace, THREE.SRGBColorSpace);
|
||||
assert.equal(lit.uniforms.uLm.value.colorSpace, THREE.NoColorSpace);
|
||||
board.setArea(AREAS.boss);
|
||||
assert.equal(sceneMeshes(board).length, 3, 'base ×2 + the boss field');
|
||||
board.setArea(AREAS.unite);
|
||||
assert.equal(sceneMeshes(board).length, 5, 'base ×2 + both normal fields (2 + 1)');
|
||||
assert.equal(sceneMeshes(board).find((m) => m.material.uniforms.uMap).material, lit, 'same material object');
|
||||
assert.equal(board.sceneRes.size, 1);
|
||||
board.destroy();
|
||||
assert.equal(board.sceneRes.size, 0, 'scene resources disposed');
|
||||
});
|
||||
|
||||
test('another stage, or dropping the scene, shows the procedural board again', () => {
|
||||
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
|
||||
board.setScenePack(stage.id, fakeScenePack());
|
||||
board.setStage(stages.act2autochess_m01);
|
||||
assert.equal(board.official, null);
|
||||
assert.ok(board.meshes.board && board.meshes.bg && !sceneMeshes(board).length, 'a stage without a scene');
|
||||
board.setStage(stage);
|
||||
assert.ok(board.official && !board.meshes.board);
|
||||
assert.equal(board.setScenePack(stage.id, null), true);
|
||||
assert.equal(board.official, null);
|
||||
assert.ok(board.meshes.board && board.meshes.bg && !sceneMeshes(board).length);
|
||||
assert.equal(board.setScenePack(stage.id, null), false, 'nothing to drop');
|
||||
assert.equal(board.setScenePack('', fakeScenePack()), false);
|
||||
board.destroy();
|
||||
});
|
||||
|
||||
test('a scene whose chunks are all hidden parts of other areas keeps the procedural board', () => {
|
||||
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
|
||||
const { doc, bin } = fakeScene([{ material: 'M', x0: 10, y0: 3, group: 'HIDDEN@3' }]);
|
||||
const chunks = parseSceneChunks(doc, bin);
|
||||
board.setScenePack(stage.id, { key: 'k2', stage: stage.id, doc, chunks, groups: sceneGroupRects(chunks), images: { tex: {}, lm: [] } });
|
||||
board.setStage(stage);
|
||||
assert.equal(board.official, null);
|
||||
assert.ok(board.meshes.board && board.meshes.bg);
|
||||
board.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extracted scenes (when scene bundles were extracted here)', { skip: !Object.keys(manifest?.groups || {}).some((g) => g.startsWith('scene/act')) && 'no scene in data/local-assets.json' }, () => {
|
||||
const groups = manifest?.groups || {};
|
||||
const file = (p) => path.join(ROOT, 'public', p.replace(/^\//, ''));
|
||||
|
||||
for (const g of Object.keys(groups).filter((k) => k.startsWith('scene/act'))) {
|
||||
test(`${g}: scene.json records fit scene.bin; lightmap pages and textures are listed and on disk`, { skip: !existsSync(file(groups[g].scene?.path || '/x')) && 'files not on this machine' }, () => {
|
||||
const doc = JSON.parse(readFileSync(file(groups[g].scene.path), 'utf8'));
|
||||
const bin = readFileSync(file(groups[g].bin.path));
|
||||
assert.equal(doc.version, SCENE_VERSION);
|
||||
assert.equal(`scene/${doc.stage}`, g);
|
||||
assert.equal(doc.binBytes, bin.length);
|
||||
const chunks = parseSceneChunks(doc, bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.length));
|
||||
assert.ok(chunks && chunks.length === doc.chunks.length && chunks.length > 0);
|
||||
assert.ok(chunks.some((c) => !c.group), 'a base part');
|
||||
for (const c of chunks) {
|
||||
let max = 0;
|
||||
for (const i of c.index) if (i > max) max = i;
|
||||
assert.ok(max < c.position.length / 3, 'indices in range');
|
||||
assert.ok(doc.materials[c.material], `material ${c.material}`);
|
||||
if (c.lightmap >= 0) assert.ok(c.lightmap < doc.lightmaps && groups[g][`lm${c.lightmap}`], `lightmap page ${c.lightmap}`);
|
||||
}
|
||||
for (const m of Object.values(doc.materials)) if (m.map) assert.ok(groups[SCENE_TEX_GROUP]?.[m.map], `texture ${m.map}`);
|
||||
const L = doc.light;
|
||||
assert.ok(L && Math.abs(Math.hypot(...L.dir) - 1) < 1e-3 && L.dir[2] > 0, 'the key light shines down on the board');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// Official battlefield scenes in the 0.2.5 asset pack (PR #533, GitHub #532).
|
||||
// The repository never stores the bundles or the extracted files. The full release zip includes
|
||||
// public/assets/local/scene when the maintainer extracted it; a lite zip and a source install do not,
|
||||
// and setup may fetch + extract on that machine. Missing extractor dependencies keep the metal board.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { artPlan, formatSummary, isPlayerFile, isRefused } from '../tools/package.mjs';
|
||||
import { localGaps, sceneInstallPlan } from '../tools/setup.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
||||
|
||||
test('sceneInstallPlan: fetch only with a client and extractor deps; otherwise the metal board', () => {
|
||||
const present = sceneInstallPlan({ hasScenes: true, client: false });
|
||||
assert.equal(present.run, false);
|
||||
assert.equal(present.state, 'ok');
|
||||
assert.match(present.detail, /金属地块/);
|
||||
|
||||
const check = sceneInstallPlan({ hasScenes: false, check: true, client: true, python: true });
|
||||
assert.equal(check.run, false);
|
||||
assert.equal(check.state, 'skip');
|
||||
assert.match(check.detail, /--scenes/);
|
||||
assert.match(check.detail, /金属地块/);
|
||||
|
||||
const noClient = sceneInstallPlan({ hasScenes: false, client: false, python: true });
|
||||
assert.equal(noClient.run, false);
|
||||
assert.equal(noClient.state, 'skip');
|
||||
assert.match(noClient.detail, /完整包/);
|
||||
assert.match(noClient.detail, /金属地块/);
|
||||
|
||||
const incomplete = sceneInstallPlan({ hasScenes: false, client: 'incomplete', python: true, force: true });
|
||||
assert.equal(incomplete.run, false);
|
||||
assert.equal(incomplete.state, 'warn');
|
||||
assert.match(incomplete.detail, /金属地块/);
|
||||
|
||||
const noPy = sceneInstallPlan({ hasScenes: false, client: true, python: false });
|
||||
assert.equal(noPy.run, false);
|
||||
assert.equal(noPy.state, 'warn');
|
||||
assert.match(noPy.detail, /Python/);
|
||||
assert.match(noPy.detail, /金属地块/);
|
||||
|
||||
const noDeps = sceneInstallPlan({ hasScenes: false, client: true, python: true, deps: false });
|
||||
assert.equal(noDeps.run, false);
|
||||
assert.equal(noDeps.state, 'warn');
|
||||
assert.match(noDeps.detail, /UnityPy/);
|
||||
assert.match(noDeps.detail, /金属地块/);
|
||||
|
||||
const declined = sceneInstallPlan({ hasScenes: false, client: true, python: true, declined: true });
|
||||
assert.equal(declined.run, false);
|
||||
assert.equal(declined.state, 'skip');
|
||||
|
||||
const ask = sceneInstallPlan({ hasScenes: false, client: true, python: true });
|
||||
assert.equal(ask.run, false);
|
||||
assert.equal(ask.state, 'ask');
|
||||
assert.match(ask.detail, /金属地块/);
|
||||
|
||||
for (const extra of [{ force: true, declined: true }, { yes: true }]) {
|
||||
const go = sceneInstallPlan({ hasScenes: false, client: true, python: true, ...extra });
|
||||
assert.equal(go.run, true, JSON.stringify(extra));
|
||||
assert.match(go.detail, /金属地块/);
|
||||
}
|
||||
});
|
||||
|
||||
test('an older extraction is told it lacks the battlefield scenes', () => {
|
||||
assert.equal(localGaps({ enemySpines: true, tokenSpines: true }), '');
|
||||
assert.match(localGaps({ enemySpines: true, tokenSpines: true, scenes: false }), /各战场官方场景/);
|
||||
assert.match(localGaps({ enemySpines: false, tokenSpines: false, scenes: false }), /各战场官方场景/);
|
||||
});
|
||||
|
||||
test('official scene bundles and extracted scenes are not git files; the extractor scripts are', () => {
|
||||
const gi = fs.readFileSync(path.join(ROOT, '.gitignore'), 'utf8');
|
||||
assert.match(gi, /^public\/assets\/$/m);
|
||||
assert.match(gi, /^data\/local-assets\.json$/m);
|
||||
assert.match(gi, /^\.cache\/$/m);
|
||||
assert.match(gi, /official-scenes/);
|
||||
assert.equal(isRefused('.cache/official-scenes/26-04-13-12-31-42_e9b560/scenes/a.ab'), true);
|
||||
assert.equal(isRefused('public/assets/local/scene/act2autochess_m02/scene.bin'), false);
|
||||
assert.equal(isPlayerFile('public/assets/local/scene/act2autochess_m02/scene.bin'), false);
|
||||
assert.equal(isPlayerFile('tools/local-extract/scenes.py'), true);
|
||||
assert.equal(isPlayerFile('tools/local-extract/fetch_scenes.py'), true);
|
||||
});
|
||||
|
||||
test('the full asset plan ships extracted scenes; the lite plan ships none', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-scenes-'));
|
||||
try {
|
||||
const sceneDir = path.join(dir, 'public/assets/local/scene/act2autochess_m02');
|
||||
const texDir = path.join(dir, 'public/assets/local/scene/tex');
|
||||
fs.mkdirSync(sceneDir, { recursive: true });
|
||||
fs.mkdirSync(texDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'data'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'data/assets.json'), '{}\n');
|
||||
fs.writeFileSync(path.join(sceneDir, 'scene.bin'), 'mesh-bytes');
|
||||
fs.writeFileSync(path.join(sceneDir, 'scene.json'), '{}\n');
|
||||
fs.writeFileSync(path.join(texDir, 'TX_A.webp'), 'webp');
|
||||
fs.writeFileSync(path.join(dir, 'data/local-assets.json'), JSON.stringify({
|
||||
version: 1,
|
||||
groups: {
|
||||
'scene/act2autochess_m02': {
|
||||
scene: { path: '/assets/local/scene/act2autochess_m02/scene.json' },
|
||||
bin: { path: '/assets/local/scene/act2autochess_m02/scene.bin' },
|
||||
},
|
||||
'scene/tex': { TX_A: { path: '/assets/local/scene/tex/TX_A.webp' } },
|
||||
},
|
||||
}));
|
||||
const full = artPlan(dir, { lite: false });
|
||||
for (const f of [
|
||||
'public/assets/local/scene/act2autochess_m02/scene.bin',
|
||||
'public/assets/local/scene/act2autochess_m02/scene.json',
|
||||
'public/assets/local/scene/tex/TX_A.webp',
|
||||
'data/local-assets.json',
|
||||
]) assert.ok(full.files.includes(f), f);
|
||||
assert.ok(full.scenes.includes('public/assets/local/scene/act2autochess_m02/scene.bin'));
|
||||
assert.equal(full.scenes.length, 3);
|
||||
const lite = artPlan(dir, { lite: true });
|
||||
assert.deepEqual(lite.files, []);
|
||||
assert.deepEqual(lite.scenes, []);
|
||||
const summary = formatSummary({
|
||||
lite: false, version: '0.2.5-dev', tracked: [], art: [], localArt: true, generated: [], dropped: {}, orphans: [], held: [],
|
||||
scanned: 0, names: 0, problems: [], scenes: { files: full.scenes, bytes: 9 + 3 + 4 },
|
||||
bytes: { tracked: 0, art: 16, modules: 0, vendor: 0, total: 16, leftOut: 0, orphans: 0 },
|
||||
});
|
||||
assert.match(summary, /battlefield scenes: 3 files/);
|
||||
const empty = formatSummary({
|
||||
lite: false, version: '0.2.5-dev', tracked: [], art: [], localArt: true, generated: [], dropped: {}, orphans: [], held: [],
|
||||
scanned: 0, names: 0, problems: [], scenes: { files: [], bytes: 0 },
|
||||
bytes: { tracked: 0, art: 0, modules: 0, vendor: 0, total: 0, leftOut: 0, orphans: 0 },
|
||||
});
|
||||
assert.match(empty, /battlefield scenes: none/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+1
-1
@@ -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();
|
||||
|
||||
@@ -34,6 +34,10 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
|
||||
source as <name>_rgb.png / _rough.png
|
||||
- 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/<stageId>/
|
||||
scene.json + scene.bin + lightmap pages and scene/tex/ (the materials' albedo textures). The scene bundles ship
|
||||
only while the season runs: they are read from the client when it has them, else from --scenes <dir> (default
|
||||
.cache/official-scenes/*, filled by tools/local-extract/fetch_scenes.py)
|
||||
|
||||
Usage:
|
||||
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 <dir of in-season bundles>]
|
||||
|
||||
Writes <out>/**.png|.webp|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just
|
||||
the jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing
|
||||
@@ -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/<stageId>/ (scenes.export_scene); a stage without a scene bundle in
|
||||
any root is skipped."""
|
||||
root = Path(ab_root)
|
||||
shader_deps = dep_bundles(root, {'Material'})
|
||||
n = 0
|
||||
for sid, deps in scenes.SCENES:
|
||||
if sid not in ids:
|
||||
continue
|
||||
src = scenes.find_scene(sid, roots)
|
||||
if src is None:
|
||||
log(f'skip (no scene bundle: off-season client, see --scenes) {scenes.scene_bundle(sid)}')
|
||||
continue
|
||||
try:
|
||||
n += scenes.export_scene(sid, deps, src, root, out_root, manifest, log, material_info, shader_deps)
|
||||
except Exception as e: # one broken scene must not stop the others
|
||||
log(f'FAIL scene {sid}: {e}')
|
||||
return n
|
||||
|
||||
|
||||
def export_enemy_spines(ab_root, out_root, manifest, log, ids=None):
|
||||
"""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:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download the 卫戍协议 battlefield scene bundles of an in-season version from the official asset CDN (optional).
|
||||
|
||||
The scene bundles (scenes.py) ship only while the season runs: an off-season client has none, and the current
|
||||
hot-update list does not name them. The CDN still serves the versions that had them; this fetches the bundles of
|
||||
scenes.SCENES (scene, lighting data, lighting settings) into .cache/official-scenes/<version>/<bundle path>, where
|
||||
extract.py --only scene finds them. Every file is checked against the md5 of that version's hot-update list.
|
||||
|
||||
Usage:
|
||||
python3 tools/local-extract/fetch_scenes.py [--version 26-04-13-12-31-42_e9b560] [--platform Android] [--out <dir>]
|
||||
python3 tools/local-extract/fetch_scenes.py --list (what would be downloaded; reads the hot-update list only)
|
||||
|
||||
VERSION is the last version of 盟约·下半 that carried all eleven scenes (the `Data:` version of the game-data commits
|
||||
of that season). Needs no dependencies. Everything is (c) Hypergryph; for private, non-commercial fan use only.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import scenes # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
VERSION = '26-04-13-12-31-42_e9b560'
|
||||
PLATFORM = 'Android'
|
||||
CDN = 'https://ak.hycdn.cn/assetbundle/official/{platform}/assets/{version}/'
|
||||
|
||||
|
||||
def dat_name(bundle):
|
||||
"""File name of a bundle on the CDN: the path without its extension, '/' → '_', '#' → '__', as .dat (a zip)."""
|
||||
return bundle.rsplit('.', 1)[0].replace('/', '_').replace('#', '__') + '.dat'
|
||||
|
||||
|
||||
def wanted(names, stage_ids):
|
||||
"""The bundles of `names` (a hot-update list) that belong to the scenes of `stage_ids`, in list order."""
|
||||
dirs = tuple(scenes.scene_bundle(sid).rsplit('/', 1)[0] + '/' for sid in stage_ids)
|
||||
return [n for n in names if n.startswith(dirs)]
|
||||
|
||||
|
||||
def get(url):
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=180) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('--version', default=VERSION)
|
||||
ap.add_argument('--platform', default=PLATFORM)
|
||||
ap.add_argument('--out', help='output root (default .cache/official-scenes/<version>)')
|
||||
ap.add_argument('--list', action='store_true', help='print the bundles and sizes, download nothing')
|
||||
args = ap.parse_args()
|
||||
base = CDN.format(platform=args.platform, version=args.version)
|
||||
out = Path(args.out) if args.out else ROOT / '.cache' / 'official-scenes' / args.version
|
||||
try:
|
||||
infos = json.loads(get(base + 'hot_update_list.json'))['abInfos']
|
||||
except Exception as e:
|
||||
print(f'cannot read the hot-update list of {args.version} ({args.platform}): {e}', file=sys.stderr)
|
||||
return 2
|
||||
by_name = {x['name']: x for x in infos}
|
||||
names = wanted(list(by_name), [sid for sid, _ in scenes.SCENES])
|
||||
if not names:
|
||||
print(f'version {args.version} lists no 卫戍协议 scene bundle (an off-season version?)', file=sys.stderr)
|
||||
return 1
|
||||
if args.list:
|
||||
for n in names:
|
||||
print(f'{by_name[n].get("totalSize", 0):>9} {n}')
|
||||
print(f'{sum(by_name[n].get("totalSize", 0) for n in names):>9} total ({len(names)} bundles)')
|
||||
return 0
|
||||
bad = 0
|
||||
for n in names:
|
||||
dst = out / n
|
||||
md5 = by_name[n].get('md5')
|
||||
if dst.is_file() and hashlib.md5(dst.read_bytes()).hexdigest() == md5:
|
||||
print(f'have {n}')
|
||||
continue
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(get(base + dat_name(n)))) as z:
|
||||
blob = z.read(z.namelist()[0])
|
||||
except Exception as e:
|
||||
print(f'FAIL {n}: {e}', file=sys.stderr)
|
||||
bad += 1
|
||||
continue
|
||||
if md5 and hashlib.md5(blob).hexdigest() != md5:
|
||||
print(f'FAIL {n}: md5 mismatch', file=sys.stderr)
|
||||
bad += 1
|
||||
continue
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.write_bytes(blob)
|
||||
print(f'got {n} ({len(blob)} bytes)')
|
||||
print(f'done: {len(names) - bad}/{len(names)} bundles under {out}')
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,457 @@
|
||||
"""The official battlefield scenes of 卫戍协议 (DESIGN §15): every battlefield is a prebuilt Unity scene
|
||||
(scenes/activities/<season>/level_<stage>/level_<stage>.ab, with …/lightingdata.ab beside it) whose tile models are
|
||||
baked into "Combined Mesh (root: scene)" objects, plus loose meshes for the parts the game shows and hides.
|
||||
|
||||
The scene bundles ship only while the season runs (the hot-update list of an off-season client has none), so they are
|
||||
read from the client's AB root when it has them and else from a directory of bundles kept from an in-season version
|
||||
(extract.py --scenes <dir>, default .cache/official-scenes/*; tools/local-extract/fetch_scenes.py fills it). Materials
|
||||
and their textures come from the theme bundles of the local client (SCENES lists each scene's, as the official asset
|
||||
index records them: the scene bundle's dependencies).
|
||||
|
||||
Output per stage, under <out>/scene/<stageId>/ (manifest group scene/<stageId>):
|
||||
scene.json { version, stage, light, lightmaps, materials, groups, chunks: [{ material, lightmap, group, verts,
|
||||
tris, offset, index32, bounds }] }
|
||||
scene.bin the chunks' vertex data in board space (x = column, y = row, z = height: Unity's anchor-local
|
||||
(x, y, −z)); per chunk: position f32×3, uv f32×2, lightmap uv f32×2 (scale / offset applied),
|
||||
normal i8×4, colour u8×4, indices u16|u32 (padded to 4 bytes)
|
||||
lm<i>.png lightmap page i (+ a WebP copy, the one the manifest lists): RGB = the baked indirect light (Lightmap-<i>_comp_light, stored RGBM: decode_rgbm),
|
||||
A = the key light's baked shadow (Lightmap-<i>_comp_shadowmask, R)
|
||||
and the materials' albedo textures under <out>/scene/tex/ (manifest group scene/tex, shared by the scenes).
|
||||
A chunk's `group` is '' for what the scene shows at rest and the sub-graphic name (HIDDEN@1 …, MapSubGraphicHolder)
|
||||
for the meshes the game switches on later.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
SAND = ['arts/maps/map_autochesssand/res.ab', 'arts/maps/map_survivaloasis_city/res.ab', 'arts/maps/map_yumen/res.ab']
|
||||
SHORE = ['arts/maps/map_doss_shore/res.ab']
|
||||
# (stage id, theme bundles of the local client that hold the scene's materials / textures / shared meshes): the
|
||||
# dependencies of each scene bundle in the official asset index (Android 25-11-21-15-21-44_ee1197 for 盟约·上半,
|
||||
# 26-03-13-06-11-47_14fb99 and 26-04-13-12-31-42_e9b560 for 盟约·下半 — the same in all three).
|
||||
SCENES = [
|
||||
('act1autochess_m01', SAND),
|
||||
('act1autochess_m02', SAND),
|
||||
('act1autochess_m03', SAND),
|
||||
('act1autochess_m04', SAND),
|
||||
('act1autochess_m05', SAND + SHORE),
|
||||
('act1autochess_m06', SAND),
|
||||
('act1autochess_m07', SAND),
|
||||
('act2autochess_m01', ['arts/maps/map_lakegarden/res.ab', 'arts/maps/map_syracuse_metro/res.ab',
|
||||
'arts/maps/common/meshes/s_background_common.ab']),
|
||||
('act2autochess_m02', SAND),
|
||||
('act2autochess_m03', ['arts/maps/map_kxmr/res.ab', 'arts/maps/map_kxmr/background_mesh/bg_kxmr.ab',
|
||||
'arts/maps/map_lm_center/res.ab']),
|
||||
('act2autochess_m04', SAND + SHORE),
|
||||
]
|
||||
# bundles every scene may reference besides its theme (shared water / noise textures)
|
||||
COMMON_DEPS = ['arts/maps/effect.ab']
|
||||
SCENE_SUB = 'scene'
|
||||
TEX_SUB = 'scene/tex'
|
||||
SCENE_VERSION = 1
|
||||
# linear light value that a lightmap page's 1.0 stands for (decode_rgbm; scene.json lightRange)
|
||||
LIGHT_RANGE = 8.0
|
||||
HIDDEN_NAME = re.compile(r'^[A-Z_]+@\d+$')
|
||||
WATER_SHADERS = ('StylizedWater', 'StandardWater', 'DynamicWaterTile')
|
||||
|
||||
|
||||
def scene_bundle(stage_id):
|
||||
"""Bundle path of a stage's scene, relative to an AB root (act1autochess_m01 → scenes/activities/act1autochess/…)."""
|
||||
season = stage_id.rsplit('_', 1)[0]
|
||||
return f'scenes/activities/{season}/level_{stage_id}/level_{stage_id}.ab'
|
||||
|
||||
|
||||
def find_scene(stage_id, roots):
|
||||
"""The first existing scene bundle of `stage_id` under `roots` (AB roots), or None."""
|
||||
rel = scene_bundle(stage_id)
|
||||
for root in roots:
|
||||
p = Path(root) / rel
|
||||
if p.is_file():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
# ---- small pure-Python linear algebra (row-major 3×4 affine matrices: [r0, r1, r2], each [a, b, c, t]) -----------
|
||||
|
||||
def trs(pos, rot, scale):
|
||||
"""Affine matrix of a Unity local transform (position, quaternion xyzw, scale)."""
|
||||
x, y, z, w = rot
|
||||
r = [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
||||
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
||||
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]
|
||||
return [[r[i][0] * scale[0], r[i][1] * scale[1], r[i][2] * scale[2], pos[i]] for i in range(3)]
|
||||
|
||||
|
||||
def mul(a, b):
|
||||
"""a · b of two affine matrices."""
|
||||
return [[a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j] + (a[i][3] if j == 3 else 0.0) for j in range(4)]
|
||||
for i in range(3)]
|
||||
|
||||
|
||||
def det3(m):
|
||||
return (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]))
|
||||
|
||||
|
||||
IDENTITY = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]
|
||||
|
||||
|
||||
def to_board(p, anchor):
|
||||
"""Unity world position → board space (x = column, y = row, z = height above the low ground)."""
|
||||
return (p[0] - anchor[0], p[1] - anchor[1], -(p[2] - anchor[2]))
|
||||
|
||||
|
||||
def pack_chunk(ch):
|
||||
"""Binary record of a chunk (see the module docstring); returns (bytes, index32)."""
|
||||
n = len(ch['pos'])
|
||||
flat = lambda rows: [v for row in rows for v in row] # noqa: E731
|
||||
out = bytearray()
|
||||
out += struct.pack(f'<{n * 3}f', *flat(ch['pos']))
|
||||
out += struct.pack(f'<{n * 2}f', *flat(ch['uv']))
|
||||
out += struct.pack(f'<{n * 2}f', *flat(ch['uv1']))
|
||||
out += struct.pack(f'<{n * 4}b', *flat([(*(max(-127, min(127, int(round(c * 127)))) for c in nr), 0) for nr in ch['nrm']]))
|
||||
out += struct.pack(f'<{n * 4}B', *flat([tuple(max(0, min(255, int(round(c * 255)))) for c in col) for col in ch['col']]))
|
||||
idx32 = n > 65535
|
||||
out += struct.pack(f'<{len(ch["idx"])}{"I" if idx32 else "H"}', *ch['idx'])
|
||||
while len(out) % 4:
|
||||
out.append(0)
|
||||
return bytes(out), idx32
|
||||
|
||||
|
||||
def decode_rgbm(img):
|
||||
"""RGB image of a baked lightmap page stored RGBM (Unity, linear colour space: light = 5^2.2 · A^2.2 · RGB):
|
||||
the linear light L as (L / LIGHT_RANGE)^(1/2.2) per channel — = (5^2.2 / LIGHT_RANGE)^(1/2.2) · A · RGB^(1/2.2),
|
||||
clamped to 1 (the renderer reads L = value^2.2 · LIGHT_RANGE). A page without alpha is taken as plain RGB."""
|
||||
from PIL import Image, ImageChops
|
||||
if img.mode != 'RGBA':
|
||||
return img.convert('RGB')
|
||||
k = (5.0 ** 2.2 / LIGHT_RANGE) ** (1 / 2.2)
|
||||
r, g, b, a = img.split()
|
||||
m = a.point([min(255, int(round(v * k))) for v in range(256)])
|
||||
gamma = [int(round(255 * (v / 255) ** (1 / 2.2))) for v in range(256)]
|
||||
return Image.merge('RGB', tuple(ImageChops.multiply(ch.point(gamma), m) for ch in (r, g, b)))
|
||||
|
||||
|
||||
def save_image(img, out_dir, stem, sub):
|
||||
"""Write `img` as <stem>.png and a WebP copy beside it (lossy at quality 95 with the alpha lossless and the RGB
|
||||
under transparent texels kept, like extract.py WEBP: every player downloads these with the scene); returns the
|
||||
manifest entry, which lists the copy (the PNG with a Pillow that has no WebP)."""
|
||||
img.save(Path(out_dir) / f'{stem}.png')
|
||||
entry = {'path': f'/assets/local/{sub}/{stem}.png', 'w': img.width, 'h': img.height}
|
||||
try:
|
||||
img.save(Path(out_dir) / f'{stem}.webp', 'WEBP', quality=95, method=6, alpha_quality=100, exact=True)
|
||||
entry.update(path=f'/assets/local/{sub}/{stem}.webp', webp='lossy')
|
||||
except Exception:
|
||||
pass
|
||||
return entry
|
||||
|
||||
|
||||
def hidden_groups(raw, objects):
|
||||
"""{GameObject path id: sub-graphic name} of a MapSubGraphicHolder's raw data: after each name (HIDDEN@n) come the
|
||||
GameObjects and MeshTileGraphics of that group as PPtrs of this file. The script has no type tree in the bundle,
|
||||
so the fields are walked as 4-byte words: a length-prefixed name, or a (file 0, path id) pointer to an object."""
|
||||
out, group, i = {}, None, 0
|
||||
body = raw
|
||||
while i + 4 <= len(body):
|
||||
v = struct.unpack_from('<i', body, i)[0]
|
||||
if 0 < v < 64 and i + 4 + v <= len(body):
|
||||
text = body[i + 4:i + 4 + v]
|
||||
if all(32 <= b < 127 for b in text):
|
||||
s = text.decode('ascii')
|
||||
if HIDDEN_NAME.match(s):
|
||||
group = s
|
||||
i += 4 + ((v + 3) & ~3)
|
||||
continue
|
||||
if v == 0 and i + 12 <= len(body) and group:
|
||||
pid = struct.unpack_from('<q', body, i + 4)[0]
|
||||
target = objects.get(pid)
|
||||
if target is not None:
|
||||
out[pid] = group
|
||||
i += 12
|
||||
continue
|
||||
i += 4
|
||||
return out
|
||||
|
||||
|
||||
def export_scene(stage_id, deps, scene_path, ab_root, out_root, manifest, log, material_info, shader_deps=()):
|
||||
"""Write one stage's scene (see the module docstring). Returns the number of files written (0 on failure)."""
|
||||
import aklz4 # noqa: F401 (registers the LZ4AK decoder)
|
||||
import UnityPy
|
||||
from UnityPy.helpers.MeshHelper import MeshHandler
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
env = UnityPy.load(str(scene_path))
|
||||
except Exception as e: # corrupt or unsupported bundle
|
||||
log(f'FAIL load {scene_path}: {e}')
|
||||
return 0
|
||||
objects = {o.path_id: o for o in env.objects}
|
||||
scene_objs = list(objects.values())
|
||||
light_bundle = scene_path.with_suffix('') / 'lightingdata.ab'
|
||||
extra = [light_bundle] if light_bundle.is_file() else []
|
||||
for dep in [*(Path(ab_root) / d for d in [*deps, *COMMON_DEPS]), *shader_deps]:
|
||||
if Path(dep).is_file():
|
||||
extra.append(Path(dep))
|
||||
else:
|
||||
log(f' warn {stage_id}: dependency {dep} missing (its materials are skipped)')
|
||||
for dep in extra:
|
||||
try:
|
||||
env.load_file(str(dep))
|
||||
except Exception as e:
|
||||
log(f' warn {stage_id}: dependency {Path(dep).name}: {e}')
|
||||
|
||||
# ---- hierarchy: world matrices, active flags, hidden groups ---------------------------------------------------
|
||||
tf = {} # transform path id → (local matrix, father id, game object)
|
||||
for o in scene_objs:
|
||||
if o.type.name != 'Transform':
|
||||
continue
|
||||
t = o.read()
|
||||
p, r, s = t.m_LocalPosition, t.m_LocalRotation, t.m_LocalScale
|
||||
tf[o.path_id] = (trs((p.x, p.y, p.z), (r.x, r.y, r.z, r.w), (s.x, s.y, s.z)), t.m_Father.m_PathID, t.m_GameObject)
|
||||
world_cache = {}
|
||||
|
||||
def world(tid):
|
||||
if tid not in tf:
|
||||
return IDENTITY
|
||||
if tid not in world_cache:
|
||||
local, father, _ = tf[tid]
|
||||
world_cache[tid] = mul(world(father), local) if father else local
|
||||
return world_cache[tid]
|
||||
|
||||
go_of_tf = {tid: rec[2].m_PathID for tid, rec in tf.items()}
|
||||
tf_of_go = {gid: tid for tid, gid in go_of_tf.items()}
|
||||
go_info = {} # game object path id → (name, active)
|
||||
for o in scene_objs:
|
||||
if o.type.name == 'GameObject':
|
||||
g = o.read()
|
||||
go_info[o.path_id] = (g.m_Name, bool(g.m_IsActive))
|
||||
hidden = {}
|
||||
for o in scene_objs:
|
||||
if o.type.name != 'MonoBehaviour':
|
||||
continue
|
||||
raw = o.get_raw_data()
|
||||
if b'@' not in raw:
|
||||
continue
|
||||
for pid, group in hidden_groups(raw, objects).items():
|
||||
target = objects[pid]
|
||||
if target.type.name == 'GameObject':
|
||||
hidden[pid] = group
|
||||
elif target.type.name == 'MonoBehaviour':
|
||||
try:
|
||||
hidden[target.read().m_GameObject.m_PathID] = group
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def state(gid):
|
||||
"""(active in the hierarchy, hidden group of the object or its nearest grouped ancestor)."""
|
||||
active, group, tid = True, None, tf_of_go.get(gid)
|
||||
while tid in tf:
|
||||
g = go_of_tf[tid]
|
||||
active = active and go_info.get(g, ('', True))[1]
|
||||
if group is None and g in hidden:
|
||||
group = hidden[g]
|
||||
tid = tf[tid][1]
|
||||
return active, group
|
||||
|
||||
anchor = (0.0, 0.0, 0.0)
|
||||
for gid, (name, _) in go_info.items():
|
||||
if name == 'Anchor' and gid in tf_of_go:
|
||||
m = world(tf_of_go[gid])
|
||||
anchor = (m[0][3], m[1][3], m[2][3])
|
||||
break
|
||||
|
||||
# ---- light ----------------------------------------------------------------------------------------------------
|
||||
light = None
|
||||
for o in scene_objs:
|
||||
if o.type.name != 'Light':
|
||||
continue
|
||||
d = o.read_typetree()
|
||||
if d.get('m_Type') != 1: # directional
|
||||
continue
|
||||
m = world(tf_of_go.get(d['m_GameObject']['m_PathID']))
|
||||
fwd = (m[0][2], m[1][2], m[2][2]) # the light shines along its +z
|
||||
n = (fwd[0] ** 2 + fwd[1] ** 2 + fwd[2] ** 2) ** 0.5 or 1.0
|
||||
c = d.get('m_Color', {})
|
||||
light = {'dir': [round(-fwd[0] / n, 5), round(-fwd[1] / n, 5), round(fwd[2] / n, 5)],
|
||||
'color': [round(c.get(k, 1.0), 5) for k in 'rgb'], 'intensity': round(d.get('m_Intensity', 1.0), 5)}
|
||||
break
|
||||
|
||||
# ---- renderers → chunks -----------------------------------------------------------------------------------------
|
||||
mesh_cache, materials, mat_tex = {}, {}, {}
|
||||
chunks = {} # (material, lightmap, group) → {'pos': [], …}
|
||||
|
||||
def mesh_data(pptr):
|
||||
key = (getattr(pptr, 'm_FileID', 0), pptr.m_PathID)
|
||||
if key not in mesh_cache:
|
||||
try:
|
||||
h = MeshHandler(pptr.read())
|
||||
h.process()
|
||||
mesh_cache[key] = (h.m_Vertices, h.m_Normals, h.m_UV0, h.m_UV1, h.m_Colors, h.get_triangles())
|
||||
except Exception as e:
|
||||
log(f' warn {stage_id}: mesh #{pptr.m_PathID}: {e}')
|
||||
mesh_cache[key] = None
|
||||
return mesh_cache[key]
|
||||
|
||||
def material_name(pptr):
|
||||
if not pptr.m_PathID:
|
||||
return None
|
||||
try:
|
||||
mat = pptr.read()
|
||||
except Exception: # its bundle is not in the client
|
||||
return None
|
||||
name = mat.m_Name
|
||||
if name not in materials:
|
||||
info = material_info(mat)
|
||||
shader = info.get('shader') or ''
|
||||
info['kind'] = 'water' if any(w in shader for w in WATER_SHADERS) else 'lit'
|
||||
materials[name] = info
|
||||
try:
|
||||
for slot, env_ in mat.m_SavedProperties.m_TexEnvs:
|
||||
if slot == '_MainTex' and env_.m_Texture.m_PathID:
|
||||
mat_tex[name] = env_.m_Texture
|
||||
except Exception:
|
||||
pass
|
||||
return name
|
||||
|
||||
skipped = 0
|
||||
for o in scene_objs:
|
||||
if o.type.name != 'MeshRenderer':
|
||||
continue
|
||||
r = o.read()
|
||||
gid = r.m_GameObject.m_PathID
|
||||
mf = None
|
||||
for c in r.m_GameObject.read().m_Component:
|
||||
if c.component.type.name == 'MeshFilter':
|
||||
mf = c.component.read()
|
||||
if mf is None or not mf.m_Mesh.m_PathID:
|
||||
continue
|
||||
data = mesh_data(mf.m_Mesh)
|
||||
if not data:
|
||||
continue
|
||||
verts, normals, uv0, uv1, colors, tris = data
|
||||
active, group = state(gid)
|
||||
if not active and group is None:
|
||||
group = 'inactive'
|
||||
sb = r.m_StaticBatchInfo
|
||||
static = sb.subMeshCount > 0
|
||||
first = sb.firstSubMesh if static else 0
|
||||
m = IDENTITY if static else world(tf_of_go.get(gid))
|
||||
# board space mirrors Unity's z: the triangle order is reversed to stay counter-clockwise (three.js front
|
||||
# faces), unless the object's own transform already mirrors it
|
||||
flip = det3(m) > 0
|
||||
so = r.m_LightmapTilingOffset
|
||||
st = (1.0, 1.0, 0.0, 0.0) if static else (so.x, so.y, so.z, so.w)
|
||||
lm = r.m_LightmapIndex if r.m_LightmapIndex < 65534 else -1
|
||||
luv = uv1 if uv1 else uv0
|
||||
for k, mref in enumerate(r.m_Materials):
|
||||
sub = first + k
|
||||
if sub >= len(tris):
|
||||
break
|
||||
name = material_name(mref)
|
||||
if not name:
|
||||
skipped += 1
|
||||
continue
|
||||
ch = chunks.setdefault((name, lm, group or ''), {'pos': [], 'uv': [], 'uv1': [], 'nrm': [], 'col': [], 'idx': [], 'map': None})
|
||||
remap = {}
|
||||
for tri in tris[sub]:
|
||||
tri = (tri[0], tri[2], tri[1]) if flip else tri
|
||||
for vi in tri:
|
||||
j = remap.get(vi)
|
||||
if j is None:
|
||||
j = remap[vi] = len(ch['pos'])
|
||||
v = verts[vi]
|
||||
p = (m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2] + m[0][3],
|
||||
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2] + m[1][3],
|
||||
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2] + m[2][3])
|
||||
ch['pos'].append(to_board(p, anchor))
|
||||
nv = normals[vi] if normals else (0.0, 0.0, -1.0)
|
||||
nw = (m[0][0] * nv[0] + m[0][1] * nv[1] + m[0][2] * nv[2],
|
||||
m[1][0] * nv[0] + m[1][1] * nv[1] + m[1][2] * nv[2],
|
||||
m[2][0] * nv[0] + m[2][1] * nv[1] + m[2][2] * nv[2])
|
||||
ln = (nw[0] ** 2 + nw[1] ** 2 + nw[2] ** 2) ** 0.5 or 1.0
|
||||
ch['nrm'].append((nw[0] / ln, nw[1] / ln, -nw[2] / ln))
|
||||
t0 = uv0[vi] if uv0 else (0.0, 0.0)
|
||||
ch['uv'].append((t0[0], t0[1]))
|
||||
t1 = luv[vi] if luv else (0.0, 0.0)
|
||||
ch['uv1'].append((t1[0] * st[0] + st[2], t1[1] * st[1] + st[3]))
|
||||
col = colors[vi] if colors else (1.0, 1.0, 1.0, 1.0)
|
||||
ch['col'].append(tuple(col[:4]) if len(col) >= 4 else (*col[:3], 1.0))
|
||||
ch['idx'].append(j)
|
||||
if not chunks:
|
||||
log(f' warn {stage_id}: no drawable mesh in {scene_path.name}')
|
||||
return 0
|
||||
|
||||
# ---- write ------------------------------------------------------------------------------------------------------
|
||||
out_dir = Path(out_root) / SCENE_SUB / stage_id
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
sub = f'{SCENE_SUB}/{stage_id}'
|
||||
blob, records = bytearray(), []
|
||||
for (name, lm, group), ch in sorted(chunks.items(), key=lambda kv: (kv[0][2], kv[0][0], kv[0][1])):
|
||||
data, idx32 = pack_chunk(ch)
|
||||
xs, ys, zs = zip(*ch['pos'])
|
||||
records.append({'material': name, 'lightmap': lm, 'group': group, 'verts': len(ch['pos']), 'tris': len(ch['idx']) // 3,
|
||||
'offset': len(blob), 'index32': idx32,
|
||||
'bounds': [round(v, 3) for v in (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))]})
|
||||
blob += data
|
||||
n = 0
|
||||
(out_dir / 'scene.bin').write_bytes(bytes(blob))
|
||||
manifest.setdefault(sub, {})['bin'] = {'path': f'/assets/local/{sub}/scene.bin', 'kind': 'SceneData', 'bytes': len(blob)}
|
||||
n += 1
|
||||
|
||||
# lightmap pages: light RGB + the shadow mask's R as alpha
|
||||
pages = {}
|
||||
for o in env.objects:
|
||||
if o.type.name != 'Texture2D':
|
||||
continue
|
||||
try:
|
||||
t = o.read()
|
||||
except Exception:
|
||||
continue
|
||||
mm = re.match(r'^Lightmap-(\d+)_comp_(light|shadowmask)$', t.m_Name or '')
|
||||
if mm:
|
||||
pages.setdefault(int(mm.group(1)), {})[mm.group(2)] = t
|
||||
used_pages = sorted({rec['lightmap'] for rec in records if rec['lightmap'] >= 0})
|
||||
lightmaps = 0
|
||||
for i in used_pages:
|
||||
page = pages.get(i, {})
|
||||
if 'light' not in page:
|
||||
log(f' warn {stage_id}: lightmap {i} missing (drawn unlit)')
|
||||
continue
|
||||
try:
|
||||
rgb = decode_rgbm(page['light'].image)
|
||||
mask = page['shadowmask'].image.convert('RGB').getchannel('R').resize(rgb.size) if 'shadowmask' in page else Image.new('L', rgb.size, 255)
|
||||
img = Image.merge('RGBA', (*rgb.split(), mask))
|
||||
entry = save_image(img, out_dir, f'lm{i}', sub)
|
||||
except Exception as e:
|
||||
log(f' warn {stage_id}: lightmap {i}: {e}')
|
||||
continue
|
||||
manifest[sub][f'lm{i}'] = {**entry, 'kind': 'Lightmap'}
|
||||
lightmaps = max(lightmaps, i + 1)
|
||||
n += 1
|
||||
|
||||
# albedo textures of the materials (shared by the scenes)
|
||||
tex_dir = Path(out_root) / TEX_SUB
|
||||
tex_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name, pptr in sorted(mat_tex.items()):
|
||||
try:
|
||||
tex = pptr.read()
|
||||
tname = re.sub(r'[^A-Za-z0-9_.\-\[\]]+', '_', tex.m_Name or f'tex_{pptr.m_PathID}')
|
||||
materials[name]['map'] = tname
|
||||
if tname in manifest.get(TEX_SUB, {}):
|
||||
continue
|
||||
manifest.setdefault(TEX_SUB, {})[tname] = {**save_image(tex.image, tex_dir, tname, TEX_SUB), 'kind': 'Texture2D'}
|
||||
n += 1
|
||||
except Exception as e:
|
||||
log(f' warn {stage_id}: texture of {name}: {e}')
|
||||
|
||||
doc = {'version': SCENE_VERSION, 'stage': stage_id, 'bundle': scene_bundle(stage_id), 'light': light, 'lightmaps': lightmaps,
|
||||
'lightRange': LIGHT_RANGE, 'materials': dict(sorted(materials.items())), 'groups': sorted({rec['group'] for rec in records}),
|
||||
'chunks': records, 'binBytes': len(blob)}
|
||||
(out_dir / 'scene.json').write_text(json.dumps(doc, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
|
||||
manifest[sub]['scene'] = {'path': f'/assets/local/{sub}/scene.json', 'kind': 'Scene', 'chunks': len(records)}
|
||||
n += 1
|
||||
verts = sum(rec['verts'] for rec in records)
|
||||
log(f'{scene_path.name}: {len(records)} chunks, {verts} vertices, {lightmaps} lightmaps -> {sub}'
|
||||
+ (f' ({skipped} sub-meshes without a material)' if skipped else ''))
|
||||
return n
|
||||
+20
-3
@@ -20,7 +20,9 @@
|
||||
// THIRD-PARTY-NOTICES.md, README.md, CHANGELOG.md, docs/PLAYING.md and docs/DEPLOY.md. `npm ci --omit=dev` in the
|
||||
// stage adds the production node_modules and (postinstall) public/vendor. The full zip adds the git-ignored art: the
|
||||
// files data/assets.json lists, public/fonts, and the local-client extraction (public/assets/local/,
|
||||
// data/local-assets.json) when present — nothing else on disk, so art the data no longer lists (焰狐龙梓兰, left out of
|
||||
// data/local-assets.json) when present, including the 0.2.5 battlefield scenes under public/assets/local/scene/
|
||||
// when this machine extracted them. Missing scenes are not an error: the board keeps the metal tiles, and the
|
||||
// summary says so. Nothing else on disk ships, so art the data no longer lists (焰狐龙梓兰, left out of
|
||||
// 自选 in 0.2.0, or files of an old mapping) never ships even when this machine still has it. FULL_ZIP_JP_VOICE (below)
|
||||
// decides whether the full zip carries the Japanese voice dub too (default: yes).
|
||||
// Left out: test/, the maintainer tools (build-data, golden, botbench, i18n, check-imports, this file …),
|
||||
@@ -117,6 +119,8 @@ export const GITHUB_REPO = 'https://github.com/sganggs/Stronghold-Protocol';
|
||||
export const FULL_ZIP_JP_VOICE = true;
|
||||
/** Where the JP dub lies (plan.mjs voiceAlt: audio/voice/<lang>/<charId>/<file>). */
|
||||
export const JP_VOICE_DIR = 'public/assets/audio/voice/jp/';
|
||||
/** Extracted official battlefield scenes (0.2.5 asset pack). Already ignored with public/assets/. */
|
||||
const SCENE_ART_PREFIX = 'public/assets/local/scene/';
|
||||
|
||||
/**
|
||||
* Refused in a plan and in a stage: 0.1.x's list (owner-only notes, the promo project, caches, per-machine config) and
|
||||
@@ -232,9 +236,11 @@ export const manifestFiles = listedArtFiles;
|
||||
* data/local-assets.json (when present). `missing`: listed files not on disk; `orphans`: files under public/assets
|
||||
* that nothing lists (left out); `held`: listed files the zip holds back on purpose — the JP voice dub when `jpVoice`
|
||||
* (FULL_ZIP_JP_VOICE) is off: neither shipped nor missing, and never deleted by an update.
|
||||
* `scenes`: the files under public/assets/local/scene/ (the 0.2.5 battlefield scenes). They are also in `files`.
|
||||
* A lite plan and a machine that has not extracted them return `scenes: []`; packaging still succeeds.
|
||||
*/
|
||||
export function artPlan(root, { lite = false, jpVoice = FULL_ZIP_JP_VOICE } = {}) {
|
||||
if (lite) return { files: [], missing: [], orphans: [], held: [], local: false };
|
||||
if (lite) return { files: [], missing: [], orphans: [], held: [], local: false, scenes: [] };
|
||||
const listed = manifestFiles(readJson(path.join(root, 'data', 'assets.json')) ?? {});
|
||||
const local = isFile(path.join(root, 'data', 'local-assets.json'));
|
||||
if (local) for (const f of manifestFiles(readJson(path.join(root, 'data', 'local-assets.json')) ?? {})) listed.add(f);
|
||||
@@ -254,7 +260,11 @@ export function artPlan(root, { lite = false, jpVoice = FULL_ZIP_JP_VOICE } = {}
|
||||
// without case: on Windows / macOS a file whose name differs only in case is the listed one (fetch-assets orphanFiles)
|
||||
const lower = new Set([...files, ...held].map((f) => f.toLowerCase()));
|
||||
const orphans = listTree(root, 'public/assets').filter((f) => !lower.has(f.toLowerCase()));
|
||||
return { files: [...files].sort(), missing: missing.sort(), orphans, held: held.sort(), local };
|
||||
const sorted = [...files].sort();
|
||||
return {
|
||||
files: sorted, missing: missing.sort(), orphans, held: held.sort(), local,
|
||||
scenes: sorted.filter((f) => f.startsWith(SCENE_ART_PREFIX)),
|
||||
};
|
||||
}
|
||||
|
||||
const URL_MOUNTS = [['/data/', 'data/'], ['/shared/', 'shared/'], ['/sim/', 'server/sim/']];
|
||||
@@ -524,6 +534,7 @@ export function plan(root, opts = {}) {
|
||||
return {
|
||||
version: pkg.version || '0.0.0', lite, tracked: keep, art: art.files, generated, files, dropped, orphans: art.orphans,
|
||||
held: art.held, localArt: art.local, problems, names: names.length, scanned,
|
||||
scenes: { files: art.scenes || [], bytes: sumBytes(root, art.scenes || []) },
|
||||
bytes: {
|
||||
tracked: trackedBytes, art: artBytes, modules: modulesBytes, vendor: vendorBytes,
|
||||
total: trackedBytes + artBytes + modulesBytes + vendorBytes,
|
||||
@@ -542,6 +553,12 @@ export function formatSummary(p) {
|
||||
];
|
||||
if (!p.lite) {
|
||||
lines.push(`art: ${p.art.length} files, ${MB(p.bytes.art)} (data/assets.json, public/fonts${p.localArt ? ', public/assets/local + data/local-assets.json' : '; no local-client art here'})`);
|
||||
const sceneFiles = p.scenes?.files;
|
||||
if (sceneFiles?.length) {
|
||||
lines.push(`battlefield scenes: ${sceneFiles.length} files, ${MB(p.scenes.bytes || 0)} (0.2.5 asset pack, public/assets/local/scene; the metal board is the fallback when a stage has none)`);
|
||||
} else if (p.scenes) {
|
||||
lines.push('battlefield scenes: none on this machine (the full bundle ships the metal board; fetch_scenes.py then extract.py --only scene before packaging to include the 11 official scenes)');
|
||||
}
|
||||
}
|
||||
if (p.generated?.length) lines.push(`generated: ${p.generated.join(', ')} (the pack index of the shipped packs, for a static host)`);
|
||||
lines.push(`production node_modules: ${MB(p.bytes.modules)} · public/vendor: ${MB(p.bytes.vendor)} (npm ci --omit=dev and its postinstall)`);
|
||||
|
||||
+92
-3
@@ -15,6 +15,11 @@
|
||||
// bottle or PlayCover on macOS, or --game <dir>) with tools/local-extract/extract.py in a project-local Python
|
||||
// venv (.venv-extract), then the board tile crops (tools/crop-board-atlas.mjs → tiles.json). Asked once on a
|
||||
// terminal (the answer is remembered in .cache/setup-state.json; without a terminal it is skipped); never fatal.
|
||||
// 7. Optional: the eleven official battlefield scenes (0.2.5 asset pack). The full release zip already contains
|
||||
// them when the maintainer extracted them. A lite package or a source install may download the in-season bundles
|
||||
// (tools/local-extract/fetch_scenes.py) and extract them (extract.py --only scene). Missing Python or
|
||||
// UnityPy / lz4 / Pillow is a warning: the 3D board keeps the metal tiles. --scenes does it without asking;
|
||||
// --no-scenes skips it. The answer is remembered like step 6.
|
||||
//
|
||||
// Options:
|
||||
// --check report only, change nothing (exit 1 when something essential is missing)
|
||||
@@ -22,8 +27,10 @@
|
||||
// --asset-source=M direct (default) or mirror (opt-in; no public-IP lookup)
|
||||
// SP_ASSET_SOURCE sets the default; SP_GITHUB_PROXY sets the HTTPS prefix (https://gh-proxy.com/)
|
||||
// An empty SP_GITHUB_PROXY disables the proxy, including in mirror mode.
|
||||
// --no-local skip the local-client detection and extraction
|
||||
// --no-local skip the local-client detection and extraction (the scene step still runs unless --no-scenes)
|
||||
// --local extract from the local client without asking (re-extracts when already done)
|
||||
// --scenes download and extract the official battlefield scenes without asking
|
||||
// --no-scenes do not download or extract battlefield scenes (the metal board stays)
|
||||
// --game <dir> AssetBundle root of the local client (…/StreamingAssets/AB/Windows or PlayCover …/Documents/Bundles)
|
||||
// -y, --yes answer "yes" to every question
|
||||
// --quiet fewer lines (used by scripts/launch.mjs)
|
||||
@@ -58,6 +65,7 @@ export const VENDOR_OPTIONAL = ['three.core.js', 'three.module.js'];
|
||||
const STATE_FILE = path.join(ROOT, '.cache', 'setup-state.json');
|
||||
export const VENV_DIR = path.join(ROOT, '.venv-extract');
|
||||
const EXTRACT_PY = path.join(ROOT, 'tools', 'local-extract', 'extract.py');
|
||||
const FETCH_SCENES_PY = path.join(ROOT, 'tools', 'local-extract', 'fetch_scenes.py');
|
||||
const EXTRACT_REQ = path.join(ROOT, 'tools', 'local-extract', 'requirements.txt');
|
||||
const LOCAL_MANIFEST = path.join(ROOT, 'data', 'local-assets.json');
|
||||
const LOCAL_BOARD_ATLAS = path.join(ROOT, 'public', 'assets', 'local', 'map', 'autochess', 'TX_autochessi_D.png');
|
||||
@@ -217,7 +225,7 @@ export function checkLocal() {
|
||||
const has = (prefix) => !!(m && m.groups && Object.keys(m.groups).some((g) => g.startsWith(prefix)));
|
||||
return {
|
||||
manifest: !!m, count, board3d: exists(LOCAL_BOARD_ATLAS), tiles: exists(LOCAL_BOARD_TILES), enemySpines: has('spine/enemy/'),
|
||||
tokenSpines: has('spine/token/'), dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')),
|
||||
tokenSpines: has('spine/token/'), scenes: has('scene/act'), dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,9 +234,47 @@ export function localGaps(local) {
|
||||
const gaps = [];
|
||||
if (local && !local.enemySpines) gaps.push('灼热/炽焰源石虫模型');
|
||||
if (local && !local.tokenSpines) gaps.push('自选召唤物模型');
|
||||
if (local && local.scenes === false) gaps.push('各战场官方场景');
|
||||
return gaps.length ? `,缺少新版的${gaps.join('和')}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether setup should download and extract the official battlefield scenes (0.2.5 asset pack).
|
||||
* The full release zip already contains them when the maintainer extracted them before packaging.
|
||||
* A lite package or a source install may fetch the in-season bundles on this machine and extract them.
|
||||
* Missing Python or UnityPy / lz4 / Pillow is not fatal: the 3D board keeps the metal tiles.
|
||||
* `client` is true (autochess bundles present), 'incomplete' (a client dir without them) or false.
|
||||
* @param {{ hasScenes?: boolean, client?: boolean|string, python?: boolean, deps?: boolean, check?: boolean, declined?: boolean, force?: boolean, yes?: boolean }} o
|
||||
* @returns {{ run: boolean, state: 'ok'|'skip'|'warn'|'ask', detail: string }}
|
||||
*/
|
||||
export function sceneInstallPlan(o = {}) {
|
||||
if (o.hasScenes && !o.force) {
|
||||
return { run: false, state: 'ok', detail: '已提取各战场官方场景(0.2.5 素材;某个战场没有文件时仍用金属地块)' };
|
||||
}
|
||||
if (o.check) {
|
||||
return { run: false, state: 'skip', detail: '未提取各战场官方场景:3D 棋盘用金属地块(node tools/setup.mjs --scenes 可在本机下载并提取)' };
|
||||
}
|
||||
if (o.client === 'incomplete') {
|
||||
return { run: false, state: 'warn', detail: '客户端缺少卫戍协议资源,战场场景未提取。3D 棋盘继续用金属地块' };
|
||||
}
|
||||
if (!o.client) {
|
||||
return { run: false, state: 'skip', detail: '没有本机客户端,不能提取战场场景(贴图仍取自客户端)。3D 棋盘用金属地块;完整包已带场景时可复制 public/assets/local 和 data/local-assets.json' };
|
||||
}
|
||||
if (!o.python) {
|
||||
return { run: false, state: 'warn', detail: '没有 Python 3.8+,战场场景未提取。3D 棋盘继续用金属地块' };
|
||||
}
|
||||
if (o.deps === false) {
|
||||
return { run: false, state: 'warn', detail: '提取依赖(UnityPy / lz4 / Pillow)没装好,战场场景未提取。3D 棋盘继续用金属地块' };
|
||||
}
|
||||
if (o.declined && !o.force && !o.yes) {
|
||||
return { run: false, state: 'skip', detail: '已跳过战场场景(node tools/setup.mjs --scenes)。3D 棋盘用金属地块' };
|
||||
}
|
||||
if (!o.force && !o.yes) {
|
||||
return { run: false, state: 'ask', detail: '可以下载赛季场景包并提取(约 23 MB)。失败或跳过时 3D 棋盘仍用金属地块' };
|
||||
}
|
||||
return { run: true, state: 'ok', detail: '将下载赛季场景包并提取;失败时 3D 棋盘仍用金属地块' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Board tile crops of the extracted atlas (tools/crop-board-atlas.mjs → tiles.json, read by the 2D and 3D board
|
||||
* renderers; without it the client requests a missing file and keeps the procedural tiles). Output only on failure.
|
||||
@@ -347,7 +393,7 @@ function ensureVenv(py, log) {
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
function parseArgs(argv) {
|
||||
const o = { check: false, assets: true, local: 'ask', game: null, yes: false, quiet: false, help: false, source: process.env.SP_ASSET_SOURCE || 'direct' };
|
||||
const o = { check: false, assets: true, local: 'ask', scenes: 'ask', game: null, yes: false, quiet: false, help: false, source: process.env.SP_ASSET_SOURCE || 'direct' };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--check') o.check = true;
|
||||
@@ -356,6 +402,8 @@ function parseArgs(argv) {
|
||||
else if (a.startsWith('--asset-source=')) o.source = a.slice('--asset-source='.length);
|
||||
else if (a === '--no-local') o.local = 'no';
|
||||
else if (a === '--local') o.local = 'force';
|
||||
else if (a === '--no-scenes') o.scenes = 'no';
|
||||
else if (a === '--scenes') o.scenes = 'force';
|
||||
else if (a === '--game') { o.game = argv[++i] || null; if (o.local !== 'no') o.local = 'force'; }
|
||||
else if (a.startsWith('--game=')) { o.game = a.slice(7) || null; if (o.local !== 'no') o.local = 'force'; }
|
||||
else if (a === '-y' || a === '--yes') o.yes = true;
|
||||
@@ -497,6 +545,47 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 7. official battlefield scenes (optional; the full zip ships them, a lite / source install may fetch them)
|
||||
if (opts.scenes !== 'no') {
|
||||
const sceneLocal = checkLocal();
|
||||
const sceneClient = findClient(opts.game);
|
||||
const scenePy = findPython();
|
||||
const clientFlag = sceneClient?.autochess ? true : (sceneClient ? 'incomplete' : false);
|
||||
let decision = sceneInstallPlan({
|
||||
hasScenes: sceneLocal.scenes, client: clientFlag, python: !!scenePy, check: opts.check,
|
||||
declined: !!loadState().sceneDeclined, force: opts.scenes === 'force', yes: opts.yes,
|
||||
});
|
||||
if (decision.state === 'ask') {
|
||||
log(`\n${c.cyan('▶')} 各战场的官方场景还没提取(11 个战场;赛季场景包约 23 MB,贴图仍取自本机客户端)。`);
|
||||
log(' 下载并提取后,3D 棋盘按战场显示官方场景。没有提取依赖、下载失败或跳过时,仍用现在的金属地块。');
|
||||
const answer = await ask('现在下载并提取吗?', true);
|
||||
if (answer === null) decision = { run: false, state: 'skip', detail: '无终端,未询问、未提取战场场景。3D 棋盘用金属地块(需要时 node tools/setup.mjs --scenes)' };
|
||||
else if (!answer) {
|
||||
saveState({ sceneDeclined: true });
|
||||
decision = { run: false, state: 'skip', detail: '已跳过战场场景(node tools/setup.mjs --scenes)。3D 棋盘用金属地块' };
|
||||
} else decision = { run: true, state: 'ok', detail: decision.detail };
|
||||
}
|
||||
if (!decision.run) add(decision.state === 'ok' ? 'ok' : decision.state, '各战场官方场景(可选)', decision.detail);
|
||||
else {
|
||||
const venv = ensureVenv(scenePy, log);
|
||||
if (!venv.ok) add('warn', '各战场官方场景(可选)', `${venv.why}。3D 棋盘继续用金属地块`);
|
||||
else {
|
||||
log(` ${c.cyan('▶')} 下载赛季场景包(python tools/local-extract/fetch_scenes.py,约 23 MB)`);
|
||||
const fr = run(venv.python, [FETCH_SCENES_PY], { env: PY_ENV });
|
||||
if (!fr.ok) add('warn', '各战场官方场景(可选)', `场景包下载未完成(退出码 ${fr.code})。3D 棋盘继续用金属地块;稍后可重试 node tools/setup.mjs --scenes`);
|
||||
else {
|
||||
log(` ${c.cyan('▶')} 提取战场场景(extract.py --only scene)`);
|
||||
const er = run(venv.python, [EXTRACT_PY, '--game', sceneClient.path, '--only', 'scene'], { env: PY_ENV });
|
||||
const afterScenes = checkLocal();
|
||||
if (er.ok && afterScenes.scenes) {
|
||||
add('ok', '各战场官方场景(可选)', '已提取;进入对局时按战场显示,没有的战场仍用金属地块');
|
||||
saveState({ sceneDeclined: false });
|
||||
} else add('warn', '各战场官方场景(可选)', `提取未成功(退出码 ${er.code})。3D 棋盘继续用金属地块`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// summary
|
||||
log(c.bold('\n── 准备情况 ──────────────────────────────'));
|
||||
const width = Math.max(...summary.map((s) => displayWidth(s.label))) + 2;
|
||||
|
||||
Reference in new issue
Block a user