feedback2: merge WE — setup downloads the battle emotes and 玩法说明 pages from the public mirror, accurate deployment docs (GitHub #42); English title Stronghold Protocol: Alliance; doctor comment after PR #27
This commit is contained in:
commit
7ab852ef57
27 files changed
+441
-88
No files matched your search
+2
-2
@@ -4,11 +4,11 @@ node_modules/
|
||||
public/vendor/
|
||||
|
||||
# --- game assets: (c) Hypergryph / Yostar, fetched or extracted locally, NEVER committed ----------
|
||||
# tools/fetch-assets.mjs → public/assets/** (~250 MB) and public/fonts/** (+ fonts.css)
|
||||
# tools/fetch-assets.mjs → public/assets/** (~270 MB) and public/fonts/** (+ fonts.css)
|
||||
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)
|
||||
# (optional: the client and the tests work without it; docs/DEPLOY.md §6 lists what falls back)
|
||||
data/local-assets.json
|
||||
# download caches, upstream tables, ledgers, reports, setup state
|
||||
.cache/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 卫戍协议:盟约 · Stronghold Protocol: Covenant
|
||||
# 卫戍协议:盟约 · Stronghold Protocol: Alliance
|
||||
|
||||
《明日方舟》季节性自走棋塔防玩法「卫戍协议:盟约」的**非官方同人复刻**:浏览器即开即玩,单人或 1–4 人联机合作。
|
||||
|
||||
@@ -80,13 +80,13 @@ English summary: [below](#english).
|
||||
git clone https://github.com/sganggs/Stronghold-Protocol.git
|
||||
cd Stronghold-Protocol
|
||||
npm install # 安装依赖(postinstall 会把 pixi / preact / three 复制到 public/vendor)
|
||||
npm run setup # 检查环境,并从公开镜像下载约 250 MB 美术 / 音频(可中断,再次运行会续传)
|
||||
npm run setup # 检查环境,并从公开镜像下载约 270 MB 美术 / 音频(可中断,再次运行会续传)
|
||||
npm start # 启动服务器:http://localhost:3000
|
||||
```
|
||||
|
||||
也可以直接运行启动脚本(Windows `scripts\start-windows.bat`,macOS / Linux `scripts/start.sh`):首次会自动安装依赖、下载素材,然后启动服务器并打开浏览器。
|
||||
|
||||
- **官方 3D 棋盘**需要从本机的《明日方舟》PC 客户端提取贴图(Windows 原生客户端、macOS 的 CrossOver 或 PlayCover)。`npm run setup` 检测到客户端时会询问是否提取(需要 Python 3.8+,依赖装在项目内的 `.venv-extract`,不影响系统);之后可以用 `node tools/setup.mjs --local` 重新提取,或用 `--game "<…/StreamingAssets/AB/Windows>"` 指定路径。没有客户端时自动使用 2D 棋盘,其他功能不受影响。
|
||||
- **本地客户端素材(可选)**:官方 3D 棋盘、部分官方界面图标(交流按钮与表情面板的边框、模组类型图标等)和灼热 / 炽焰源石虫的官方模型需要从本机的《明日方舟》PC 客户端提取(Windows 原生客户端、macOS 的 CrossOver 或 PlayCover)。`npm run setup` 检测到客户端时会询问是否提取(需要 Python 3.8+,依赖装在项目内的 `.venv-extract`,不影响系统);之后可以用 `node tools/setup.mjs --local` 重新提取,或用 `--game "<…/StreamingAssets/AB/Windows>"` 指定路径。没有客户端时游戏照常运行,这几样换成替代样式:2D 棋盘、样式相近的图标、染色的普通源石虫。表情和「玩法说明」的教程图随上面的素材一起从公开镜像下载,不需要客户端。没有客户端的服务器(例如 Linux VPS)也可以从**同一版本**的整合包里复制 `public/assets/local/` 和 `data/local-assets.json`,见 [docs/DEPLOY.md](docs/DEPLOY.md) 的「本地客户端素材」。
|
||||
- 素材下载优先使用 GitHub,失败时自动改用 jsDelivr 镜像。
|
||||
- `npm run doctor`(即 `node tools/doctor.mjs`)可以随时诊断:Node 版本、素材是否完整、端口占用、局域网地址和防火墙。
|
||||
|
||||
@@ -230,9 +230,9 @@ RENDER_E2E=1 node --test 'test/render/*.browser.test.js' # 渲染测试,部
|
||||
|
||||
## English
|
||||
|
||||
An **unofficial, non-commercial fan remake** of Arknights' seasonal auto-chess tower-defense mode *Stronghold Protocol: Covenant*, played in the browser: solo, or 1–4 player co-op (AI teammates can fill seats). Combat is simulated in each player's browser, so a low-power PC can host.
|
||||
An **unofficial, non-commercial fan remake** of Arknights' seasonal auto-chess tower-defense mode *Stronghold Protocol: Alliance*, played in the browser: solo, or 1–4 player co-op (AI teammates can fill seats). Combat is simulated in each player's browser, so a low-power PC can host.
|
||||
|
||||
- **Run:** download the all-in-one bundle from [Releases](../../releases/latest), install Node.js 22 or 24, then double-click `scripts\start-windows.bat` (Windows) or run `./scripts/start.sh` (macOS / Linux) and open <http://localhost:3000>. From source: `npm install && npm run setup && npm start` (setup downloads ~250 MB of art from public mirrors; the official 3D board needs a local Arknights client to extract, otherwise the 2D board is used).
|
||||
- **Run:** download the all-in-one bundle from [Releases](../../releases/latest), install Node.js 22 or 24, then double-click `scripts\start-windows.bat` (Windows) or run `./scripts/start.sh` (macOS / Linux) and open <http://localhost:3000>. From source: `npm install && npm run setup && npm start` (setup downloads ~270 MB of art from public mirrors, the emotes and the how-to-play pages included; the official 3D board, some official HUD icons and two enemy models are extracted from a local Arknights client — without one the game uses the 2D board and look-alike stand-ins, and a server can copy `public/assets/local/` and `data/local-assets.json` from the release bundle of the same version).
|
||||
- **Play with friends:** create a co-op room and share the 4-letter key or the `?room=KEY` link. On a LAN, use the address printed at start; otherwise use a virtual-LAN tool, a tunnel or a VPS — see [docs/DEPLOY.md](docs/DEPLOY.md).
|
||||
- **Disclaimer:** not affiliated with or endorsed by Hypergryph or Yostar. All Arknights names, art, audio, text and data are © their respective owners and are **not** covered by this project's GPL licence. For study and personal non-commercial use only — no selling, paid distribution, paid servers or monetisation of any kind. Content will be removed on request of the rights holders. Provided "as is", without warranty.
|
||||
- **License:** code GPL-3.0-or-later ([LICENSE](LICENSE)); game assets excluded.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":1,"hash":"dce1fe3af4fb","generator":"tools/fetch-assets.mjs","stats":{"files":3959,"bytes":259726913,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":546,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
|
||||
{"version":1,"hash":"375e961f7569","generator":"tools/fetch-assets.mjs","stats":{"files":4014,"bytes":282091345,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":601,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
|
||||
+7
-3
@@ -48,7 +48,7 @@ How downloads are fetched:
|
||||
- A manifest entry with fallbacks (for example an enemy icon that falls back to its base enemy's icon) only moves on to the next alternative after a **definitive 404**. When the primary fails transiently (network error, 5xx or an invalid payload after all retries), no fallback is fetched. The path is listed under `downloadErrors` in the report, and the next run retries the primary.
|
||||
- A skeleton that fails to parse is deleted and removed from the ledger, so the next online run downloads it again.
|
||||
|
||||
The first run downloads about **242 MiB in about 3,700 files**. It took 134 s on a ~3 MB/s link. A re-run takes about 1 s.
|
||||
The first run downloads about **269 MiB in about 4,000 files** (it took 134 s on a ~3 MB/s link before the 55 emote and 玩法说明 files, 21.3 MiB, were added). A re-run takes about 1 s.
|
||||
|
||||
Outputs:
|
||||
- `data/assets.json`: the manifest (committed).
|
||||
@@ -79,6 +79,7 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee
|
||||
| Band (strategy) icons | AA2 `…/arts/bandicon/` | `band/{bandId}.png` |
|
||||
| Profession, sub-profession and battle-card icons | AA2 `arts/profession_hub`, `arts/ui/subprofessionicon`, `ui_battle_new/battlecard` | `prof/icon_{p}.png`, `prof/large_{p}.png`, `prof/battlecard_{p}.png`, `prof/sub/{subProfessionId}.png` |
|
||||
| UI sprites (see below) | AA2 `ui/autochess/**`, `arts/**`, `activity/[uc]act2autochess/**`, `battle/[pack]common/sprites` | `ui/{group}/{key}.png` |
|
||||
| The 36 battle emotes and the 19 玩法说明 (tutorial) pages, which `tools/local-extract` also extracts (GitHub issue #42: without the local client a server showed default emote icons) | AA2 `cn` `ui/emoticon/theme/[uc]{themeId}/icon/{picId}.png` (`shared/constants.js EMOTE_CATALOG`) and `arts/guidebookpages/[pack]autochess/{key}.png` (1024², shown at 16:9 like the local copies) | `ui/emoticon/{dir}/{picId}.png`, `ui/guide/{key}.png` |
|
||||
| Operator battle Spine (Front, Back) | fexli/ArknightsResource `spine/{id}/{id}/{Front,Back}/` | `spine/op/{charId}/{front,back}/{stem}.{skel,atlas,png}` |
|
||||
| Token Spine | fexli: the default model, or else the first skin variant (`spine/{tokenId}/{variant}/Spine/`) | `spine/token/{tokenId}/{stem}.*` |
|
||||
| Enemy Spine (PC build, premultiplied alpha) | isHarryh/Ark-Models `models_enemies/{key}/`, file names from `models_data.json` | `spine/enemy/{enemyId}/{stem}.*` |
|
||||
@@ -107,7 +108,7 @@ The `stem` of a Spine model is the upstream file name. Two examples: `char_107_l
|
||||
- **UI:**
|
||||
- every group from `07-assets.json → autochessUi`: rarity, elite and chess-level sprites, the shop panel and cards, HUD, bond board, equip slot, round dialog, band choose, settlement, prepare backdrop;
|
||||
- `arts` (rarity stars, elite icons, the camp logos of pool nations, the loading illustrations used by the act2 modes, battle common sprites, act2 entry backdrops and season logo, item rarity frames);
|
||||
- extras: mode choice art, battle-ready backdrops, battle UI (speed, pause, HP slider, attack range, boss avatar frame, skill ready), `empty_skill`, the 机变 panel and cards, the equip-replace dialog, the bond detail dialog, the prep-ready panel, stage-info titles.
|
||||
- extras (`tools/assets/plan.mjs UI_EXTRAS`): mode choice art, battle-ready backdrops, battle UI (speed, pause, HP slider, attack range, boss avatar frame, skill ready), `empty_skill`, the 机变 panel and cards, the equip-replace dialog, the bond detail dialog, the prep-ready panel, stage-info titles, the 36 battle emotes (`emoticon/{dir}/{picId}`) and the 19 玩法说明 pages (`guide/{key}`) — these two keyed by the group and name of `data/local-assets.json`.
|
||||
|
||||
## Post-processing
|
||||
|
||||
@@ -147,7 +148,9 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
|
||||
bands: { [bandId]: url },
|
||||
skills: { [iconId]: url }, // iconId = skill_table iconId ?? skillId
|
||||
skillsById: { [skillId]: iconId },
|
||||
ui: { ['group/key']: url }, // e.g. 'hudPanel/icon_hp', 'shopCard/frame_lv1', 'loading/loading_ac_core'
|
||||
ui: { ['group/key']: url }, // e.g. 'hudPanel/icon_hp', 'shopCard/frame_lv1', 'loading/loading_ac_core';
|
||||
// 'emoticon/basic/pic_happy_battle', 'guide/autochess_home_1': the data/local-assets.json
|
||||
// group + name of the same picture (the client takes the local one first)
|
||||
prof: { icon: {caster…warrior}, large: {…}, battlecard: {…, token}, sub: {[subProfessionId]: url} },
|
||||
audio: {
|
||||
bgm: { lobby, prep, combat, boss: { intro?, loop } }, // intro then crossfade to loop (1 s)
|
||||
@@ -269,6 +272,7 @@ Other renderer rules from research 07 §5.4–5.5:
|
||||
|
||||
### Other fallbacks
|
||||
|
||||
- **Emotes and 玩法说明 pages** (`public/js/data.js artUrls / nextArtUrl`, `ui/guide.js guideStage`): the local-client picture (`data/local-assets.json`) first, then the mirror copy (`ui['emoticon/…']`, `ui['guide/…']`), each tried in turn when one fails to load; when none is left — none listed, or every copy failed (for example data/assets.json lists the downloaded pages but the files are not on disk yet: a `git pull` and restart without setup) — the neutral emote glyph, and for a page the official tips text (`config.tips`). The rest of the local-client art (the 3D board, the official HUD sprites, module type icons, the two enemy models above) is not downloaded: the client looks it up in `data/local-assets.json` only (most of the HUD sprites are on the mirror too, DESIGN §22.5); docs/DEPLOY.md §6 lists what falls back without it.
|
||||
- **Tokens:**
|
||||
- Without an avatar, use `chars[owner].avatar` with a 召唤物 badge, or `prof.battlecard.token`.
|
||||
- Without a Spine, draw the avatar sprite with a bob tween.
|
||||
|
||||
+21
-4
@@ -10,7 +10,7 @@
|
||||
| 服务器 CPU | 战斗在各玩家浏览器里模拟(DESIGN §14),服务器只负责回合、经济和校验:**每个房间每个作战回合约 1 ms CPU**。AI 队友 / 掉线玩家的战场由服务器模拟:作战开始时 3 个 AI 战场在开发机上约 0.2–0.5 s CPU,小主机上可能要几秒(分成 8 ms 小片执行,不会卡住其他房间)。`SP_VERIFY=all` 会复算每个真人战场,CPU 明显增加,小主机建议保持 `off` 或 `sample`。 |
|
||||
| 服务器内存 | 空闲约 100 MB,每个进行中的对局再增加几 MB。 |
|
||||
| 网络 | 4 人对局中服务器每回合下行约 0.25 MB(DESIGN §14 实测)。首次进入游戏时浏览器要从主机下载所需的图片 / Spine 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。 |
|
||||
| 磁盘 | 素材约 250 MB(`public/assets`)+ 依赖约 125 MB(`node_modules`);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图。 |
|
||||
| 磁盘 | 素材约 270 MB(`public/assets`)+ 依赖约 125 MB(`node_modules`);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。 |
|
||||
| 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 |
|
||||
|
||||
服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。
|
||||
@@ -31,7 +31,7 @@
|
||||
```powershell
|
||||
git clone https://github.com/sganggs/Stronghold-Protocol.git C:\Stronghold-Protocol
|
||||
```
|
||||
3. 双击 `C:\Stronghold-Protocol\scripts\start-windows.bat`。首次会:安装依赖(`npm ci`;完整包已含,跳过)→ 复制前端库 → 下载约 250 MB 素材(完整包已含,跳过;显示进度,中断后再次启动会续传)→ 若检测到本机的明日方舟客户端,询问是否提取官方贴图(可跳过)→ 启动服务器并打开浏览器。
|
||||
3. 双击 `C:\Stronghold-Protocol\scripts\start-windows.bat`。首次会:安装依赖(`npm ci`;完整包已含,跳过)→ 复制前端库 → 下载约 270 MB 素材(完整包已含,跳过;显示进度,中断后再次启动会续传)→ 若检测到本机的明日方舟客户端,询问是否提取官方贴图(可跳过)→ 启动服务器并打开浏览器。
|
||||
4. 窗口里会打印朋友可用的地址,例如 `http://192.168.1.23:3000`。用另一台设备打开它确认能进入。关闭窗口即停止服务器。
|
||||
|
||||
等价的手动命令:`npm ci`、`node tools/setup.mjs`、`npm start`。
|
||||
@@ -237,6 +237,23 @@ services:
|
||||
| 朋友打不开页面 | 防火墙规则 / 网络类型(1.2);确认用的是 `LAN` 地址而不是 `localhost`;访客 Wi-Fi 常开启「AP 隔离」;不在同一网络请看第 2 节 |
|
||||
| 画面是占位图、没有声音 | 素材没下完:重新运行 `node tools/setup.mjs`(会续传);缺失明细在 `.cache/assets-report.json`。GitHub 原始地址访问失败时会自动改用 jsDelivr 镜像 |
|
||||
| 素材下载很慢 / 失败 | 网络问题可随时中断,重新运行会跳过已完成的文件;`node tools/fetch-assets.mjs --concurrency=4` 降低并发。有文件没下载成功时,素材清单 `data/assets.json` 保持不变(脚本列出缺少的条目并以非零状态结束;游戏里缺的图片用占位图,缺的声音不播放),重新运行即可补齐 |
|
||||
| 本地提取失败 | 不影响游戏。确认客户端已下载全部资源;Python 版本太新导致依赖安装失败时,安装 Python 3.12 后删除 `.venv-extract` 再运行 `node tools/setup.mjs --local` |
|
||||
| 3D 棋盘没出现 | 需要本地提取的棋盘贴图(`node tools/doctor.mjs` 会显示「3D 棋盘可用」),以及支持 WebGL2 的浏览器 |
|
||||
| 表情显示成默认图标、「玩法说明」只有文字要点 | 素材没下载完整:重新运行 `node tools/setup.mjs`(表情和教程图随其他素材一起从公开镜像下载,不需要客户端);缺失明细在 `.cache/assets-report.json` |
|
||||
| 本地提取失败 | 游戏照常运行,只是第 6 节表格里的几样换成替代样式。确认客户端已下载全部资源;Python 版本太新导致依赖安装失败时,安装 Python 3.12 后删除 `.venv-extract` 再运行 `node tools/setup.mjs --local` |
|
||||
| 3D 棋盘没出现 | 需要本地提取的棋盘贴图(`node tools/doctor.mjs` 会显示「3D 棋盘可用」),以及支持 WebGL2 的浏览器。没有客户端的服务器可以从同一版本的整合包复制本地素材(第 6 节) |
|
||||
| 断线 | 同盟模拟 10 分钟内、独立模拟 24 小时内(`config.constants.singleReconnectTime`)用同一浏览器重新打开页面,自动回到原座位。同盟掉线期间按原阵容自动作战、到时自动准备(不会代为购买;想让 AI 代打请用「离开模拟 → 暂离(AI 托管)」);独立模拟不计时,等你回来 |
|
||||
|
||||
## 6. 本地客户端素材(可选)
|
||||
|
||||
`public/assets/local/` 和 `data/local-assets.json` 是从本机安装的《明日方舟》客户端里提取的官方素材(`tools/local-extract`,DESIGN §13):`node tools/setup.mjs` 检测到客户端时会询问是否提取,之后可以用 `node tools/setup.mjs --local` 重新提取,或用 `--game "<…/StreamingAssets/AB/Windows>"` 指定客户端目录。setup 从公开镜像下载的素材不包含这部分,所以在没有客户端的电脑上(例如 Linux 服务器)从源码部署时不会有它;Releases 的完整包里已经带上了。
|
||||
|
||||
没有本地素材时游戏照常运行,只是下面几样换成替代样式:
|
||||
|
||||
| 内容 | 没有本地素材时 |
|
||||
|---|---|
|
||||
| 官方 3D 棋盘(贴图、模型、地图特效) | 2D 棋盘,地块由程序绘制 |
|
||||
| 部分官方界面图标与底板:交流按钮和表情面板的边框、暂停面板、装备替换窗口、干员调配界面、队友状态与漏怪标记、模组类型图标等 | 样式相近的替代图形、图标或文字 |
|
||||
| 灼热 / 炽焰源石虫的官方模型 | 染成橙色 / 红橙色的普通源石虫 |
|
||||
|
||||
表情(6 套 × 6 个)和「玩法说明」的 19 页教程图公开镜像也有:`node tools/setup.mjs` 会和其他素材一起下载(约 21 MB),不需要客户端;有本地素材时优先显示本地的。
|
||||
|
||||
**没有客户端的服务器**想要上表中的官方素材:从**同一版本**的完整包([Releases](https://github.com/sganggs/Stronghold-Protocol/releases))里,把 `public/assets/local/` 文件夹和 `data/local-assets.json` 复制到服务器项目目录下的相同位置。服务器每次请求都会重新读取这两处,不必重启,玩家刷新页面即可。一定要用与服务器代码相同版本的完整包:各版本提取的内容和清单可能不同(例如灼热 / 炽焰源石虫的模型是 0.1.0 之后才加入的),混用其他版本的文件会缺图或用错图。复制后 `node tools/doctor.mjs` 会显示本地素材的条目数和「3D 棋盘可用」。
|
||||
+13
-5
@@ -516,11 +516,11 @@ 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 art that no web source has 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).
|
||||
`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.
|
||||
- `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/{basic,basic_2}/pic_<id>_battle.png` — the 12 official battle emotes (`shared/constants.js → EMOTES`, `emoteArtPath`).
|
||||
- `guide/autochess_{home,shop,handbook}_N.png` — the official tutorial pages; stored squashed to 1024² — **display at 16:9**.
|
||||
- `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).
|
||||
- `projectiles/*` — battle projectile sprites.
|
||||
- `spine/enemy/<id>/` — enemy models no web dump carries (灼热 / 炽焰源石虫, `extract.py ENEMY_SPINES`): manifest `enemies[id].spineLocal` (metadata from the committed `tools/assets/local-enemy-spines.json`); drawn instead of the tinted web alias when every file is listed (§21.14).
|
||||
- `module/<TYPE>` — the official module (uniequip) TYPE icons (white glyphs; keys are the client's mixed-case file names, e.g. `PRI-X`, `mar-x`, `isw-a`), matched **case-insensitively** against a ModuleRecord's `typeName` by `assetUrls.moduleTypeIconUrl(local, typeName)` (Greek type letters map to the file's Latin letter: `ISW-α` → `isw-a`) — shown on the 干员调配 module cards / module info, the detail panel's 模组 row and the shop card's module tag; lettered tiles / type text without them.
|
||||
@@ -1508,9 +1508,17 @@ TBD-22-intro
|
||||
TBD-22.4
|
||||
|
||||
|
||||
### 22.5 Emotes and guide pages without a local client; deployment docs (#42); the English title (#38)
|
||||
### 22.5 Emotes and guide pages without a local client; deployment docs (#42); the English title (#38) — `tools/assets/plan.mjs UI_EXTRAS / GUIDE_PAGES`, data/assets.json `ui['emoticon/<dir>/<picId>']` / `ui['guide/<key>']`, `data.js artUrls / nextArtUrl`, `ui/emotes.js emoteArtUrls / EmoteArt`, `ui/guide.js guidePages / GuideHost`, `tools/setup.mjs LOCAL_ART_FALLBACK / LOCAL_ART_COPY_HINT`, `tools/doctor.mjs`, README, docs/DEPLOY.md §6, `server/index.js` banner, package.json `name`
|
||||
|
||||
TBD-22.5
|
||||
- **#42 seen**: "游玩时左下角的六页表情全部显示为默认图标" — a Linux server deployed from source (git clone, setup with the asset download, Node 22); copying `public/assets/local/` and `data/local-assets.json` from the release bundle fixed it. **Real**: both are extracted from a locally installed client (§13; setup extracts only when it finds one), and the emote art and the 玩法说明 pages came from there only, so a source deploy without the client drew the neutral glyph for all 36 emotes and the official tips text instead of the 19 pages. README, DEPLOY.md, setup and doctor said the local art did not matter ("不影响游戏", "其他功能不受影响") — wrong: the 3D board, part of the official HUD art and two enemy models fall back too.
|
||||
- **Source**: the mirror the pipeline already uses (ArknightsAssets2 `cn`, assets/dyn) has all 55 files — the six theme bundles' `ui/emoticon/theme/[uc]<themeId>/icon/<picId>.png` (the 36 battle sprites, keyed by display_meta_table picId) and `arts/guidebookpages/[pack]autochess/autochess_{home 1–9, shop 1–6, handbook 1–4}.png`. Checked 2026-10-03: every URL answers 200 on raw.githubusercontent.com and on the jsDelivr fallback; the same pictures as the local extraction, each at the size of its local copy (all 55 compared; the pages 1024² squashed — still shown at 16:9), pixel differences only from the platforms' texture compression.
|
||||
- **Now**: the asset plan downloads them like any other UI sprite (UI_EXTRAS, built from shared/constants.js EMOTE_CATALOG and GUIDE_PAGES — a test keeps GUIDE_PAGES equal to guide.js GUIDE_CHAPTERS), keyed by the data/local-assets.json group and name: `ui['emoticon/<dir>/<picId>']` → `/assets/ui/emoticon/<dir>/<picId>.png`, `ui['guide/<key>']` → `/assets/ui/guide/<key>.png` (55 files, 21.3 MiB; the download ≈ 270 MB, was ≈ 250 MB). The committed data/assets.json lists them (regenerated by a real fetch-assets run: only these 55 entries, `hash` and `stats` changed), so `npm run setup` — which the start scripts run on every start — on an install made before them finds 55 files missing and runs fetch-assets; §21.25's conventions hold: ledger, retries, the jsDelivr fallback, and a run where they fail keeps the committed manifest (shrink guard), so the next setup tries again. The client looks both manifests up by the same names (`data.js artUrls`): the local-client picture first, then the mirror copy; `EmoteArt` and the guide viewer move to the next URL when an image fails to load (`nextArtUrl`: a copied data/local-assets.json without its files falls through to the mirror copy); when none is left they keep 0.1.1's fallback — the glyph, and for a 玩法说明 page the official tips text (`guideStage`), also when data/assets.json lists the downloaded pages but the files are not on disk (a `git pull` and restart without setup, an older asset folder, a failed download the shrink guard kept); the chapter tabs, dots and keys still reach the other pages. `localAsset` stays local-only.
|
||||
- **Docs now**: README, docs/DEPLOY.md (new §6 本地客户端素材, the 排错 rows), docs/PLAYING.md, docs/ASSETS.md and the setup / doctor messages say what still needs the client — the official 3D board (→ the 2D board with procedural tiles), part of the official HUD art (the emote button and panel frames, the pause plate, the equip-replace dialog, the loadout screen, the team status and miss tags, module type icons … → CSS look-alikes, icons or text) and 灼热 / 炽焰源石虫's models (→ the tinted web alias, §21.14) — and README, DEPLOY §6 and doctor that a server without the client can copy `public/assets/local/` and `data/local-assets.json` from the release bundle of the **same version** (both are read per request: no restart); setup's row, which the launcher prints on every start, only points to DEPLOY §6. The Docker parts are unchanged (deferred by the owner).
|
||||
- **Not done here**: the mirror also has most sprites of the three autochess UI bundles that the local `ui/battle` / `ui/common` / `ui/outer` groups hold (by name 377 / 389, 137 / 160 and 659 / 659 under `ui/autochess/[uc]autochess{battle,common,outer}/**`; the emote panel frames, `matte_pause` and `bg_miss_enemy` checked at the local sizes), so the HUD row of DEPLOY §6 could shrink in a follow-up.
|
||||
- **#38, the English title** — **official**: EN client data, activity_table `basicInfo.act2autochess.name` = "Stronghold Protocol: Alliance" (CN 卫戍协议:盟约; ArknightsAssets/ArknightsGamedata `en/gamedata/excel/activity_table.json`, 2026-09-29 — Kengxxiao/ArknightsGameData_YoStar stops at EN client 31.5.80, 2025-11-13, before this season, with act1vautochess "Stronghold Protocol" only). **Now**: the README title and English summary, the server start-up banner and the package name (`stronghold-protocol-alliance`, package.json and the lockfile) say Alliance instead of "Covenant"; the Chinese title 卫戍协议:盟约 and the repository name stay. "Covenant" remains only in 新约能天使's official appellation ("Exusiai the New Covenant") and its buff key `angel2:covenant`. Only the title is settled here: #38 itself (a full English UI) stays open.
|
||||
- **PR #27 follow-up**: tools/doctor.mjs's comment now says directly that tun0 / tap0 are matched first by VPN_IF (substring `tun\d` / `tap`) and classed as vpn — it spoke of no longer listing `^tun\d`, which master's VIRTUAL_IF never had; comment only.
|
||||
- [ASSUMED]: none.
|
||||
- Tests: `test/assets.test.js` (the 55 plan entries and URLs, a rebuilt manifest, the committed manifest lists them), `test/ui/localArt.test.js` (the lookup order, a server without the client, `guideStage`, GUIDE_PAGES = GUIDE_CHAPTERS), `test/fetch-assets-shrink.test.js` (a failed mirror run keeps the manifest), `test/static-local-art.test.js` (the docs and messages), `test/version.test.js` (the English title), `test/doctor.test.js`.
|
||||
|
||||
|
||||
### 22.6 钩索师 and 推击手 on 高台 (#32 item 4) — `tools/build-data.mjs PLACE_ON_RANGED_RE / branchTraitText / validateAll`, chess.json `placement`, `match/board.js positionClass / basePositionClass`, `ui/gameLogic.js piecePosition`, `match/bot.js isBlocker / planLayoutSteps`, `tools/balance.mjs`
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
「卫戍协议:盟约」是自走棋 + 塔防:休整期在调度中心招募干员、摆阵、配装备,作战期干员自动部署并迎击从红门涌来的敌人,漏过去的敌人扣目标生命值(LP)。同盟模拟中 1–4 人**合作**(没有 PvP),撑到第 14 回合打倒敌方领袖即胜利。
|
||||
|
||||
本文数值来自官方数据表(`data/config.json` 等)与 [research](research/00-INDEX.md);游戏内左下角 📖「玩法说明」有官方教程图(需本地提取)。操作方式见 [README · 操作](../README.md#操作)。
|
||||
本文数值来自官方数据表(`data/config.json` 等)与 [research](research/00-INDEX.md);游戏内左下角 📖「玩法说明」有官方教程图(随素材一起下载;没下载到时显示文字要点)。操作方式见 [README · 操作](../README.md#操作)。
|
||||
|
||||
## 1. 一局的流程
|
||||
|
||||
|
||||
@@ -289,6 +289,8 @@ All in `StreamingAssets/AB/Windows/ui/emoticon/theme/`:
|
||||
|
||||
The autochess **room** pics (pic_hello, pic_question, pic_waiting, pic_working, pic_sorry_2, …) are **not** in any local bundle. Keep room emotes out of scope, or reuse the battle glyphs.
|
||||
|
||||
The public mirror (ArknightsAssets2 `cn`, `ui/emoticon/theme/[uc]<themeId>/icon/<picId>.png`) has the same 36 battle sprites, at the same sizes (checked 2026-10-03); since v0.1.2 `tools/fetch-assets.mjs` downloads them and the client uses them when the local art is absent (DESIGN §22.5, GitHub issue #42).
|
||||
|
||||
---
|
||||
|
||||
## 5. Other prep interactions we may have missed
|
||||
|
||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "stronghold-protocol-covenant",
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stronghold-protocol-covenant",
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.1.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "stronghold-protocol-covenant",
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"description": "明日方舟「卫戍协议:盟约」非官方同人联机网页复刻(1–4 人合作,Node.js 服务器 + 浏览器客户端)",
|
||||
|
||||
@@ -61,8 +61,6 @@
|
||||
.guide__page img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; user-select: none; opacity: 0; transition: opacity 160ms ease; }
|
||||
.guide__page.is-loaded img { opacity: 1; }
|
||||
.guide__loading { position: absolute; inset: 0; display: grid; place-items: center; }
|
||||
.guide__missing { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: .1rem; color: var(--text-lo); font-size: .2rem; }
|
||||
.guide__missing .icon { width: .3rem; height: .3rem; }
|
||||
.guide__nav {
|
||||
position: absolute; top: 0; bottom: 0; z-index: 2; width: 1rem; display: grid; place-items: center;
|
||||
color: rgba(242, 242, 242, .7); background: transparent; border: 0;
|
||||
@@ -93,7 +91,7 @@
|
||||
.guide__dot.is-on { width: .42rem; background: var(--mint-500); box-shadow: 0 0 .08rem rgba(23, 249, 183, .5); }
|
||||
.guide__count { justify-self: end; font-size: .24rem; color: var(--text-md); letter-spacing: .06em; }
|
||||
|
||||
/* text fallback (no local tutorial art) */
|
||||
/* text fallback: the official tips when no tutorial page can be shown (none listed, or none of a page's copies loads) */
|
||||
.guide__stage--text { width: min(92vw, 10rem); max-height: calc(100vh - 2rem); overflow: auto; padding: .1rem .06rem; }
|
||||
.guide__tips ol { margin: .14rem 0 0; padding-left: .34rem; display: flex; flex-direction: column; gap: .08rem; }
|
||||
.guide__tips li { font-size: .18rem; line-height: 1.6; color: var(--text-md); }
|
||||
|
||||
@@ -31,6 +31,7 @@ export const DATA_FILES = Object.freeze({
|
||||
config: 'config.json',
|
||||
assets: 'assets.json',
|
||||
// Optional art extracted from a local game client (DESIGN §13): { groups: { '<subdir>': { name: { path, w, h } } } }.
|
||||
// The emotes and the 玩法说明 pages are in data/assets.json too (downloaded from the mirror): artUrls().
|
||||
local: 'local-assets.json',
|
||||
});
|
||||
|
||||
@@ -229,6 +230,39 @@ export function localAsset(group, name) {
|
||||
return e && typeof e.path === 'string' && e.path ? e.path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate URLs, best first, of art that both the local client and the public mirror have — the 36 battle emotes and
|
||||
* the 19 玩法说明 pages (GitHub issue #42: a server without the client showed default emote icons): the local-client
|
||||
* entry (`localAsset(group, name)`), then the copy tools/fetch-assets.mjs downloads, which data/assets.json lists under
|
||||
* the same group and name as `ui['<group>/<name>']` (tools/assets/plan.mjs UI_EXTRAS). Empty when neither manifest
|
||||
* lists it (callers draw their own fallback); a caller skips a URL that fails to load and tries the next. Load both
|
||||
* files first (`useData('local', 'assets')`).
|
||||
* @param {string} group e.g. 'emoticon/basic', 'guide'
|
||||
* @param {string} name entry name without extension
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function artUrls(group, name) {
|
||||
const out = [];
|
||||
const local = localAsset(group, name);
|
||||
if (local) out.push(local);
|
||||
const ui = data.get('assets')?.ui;
|
||||
const key = `${group}/${name}`;
|
||||
const web = ui && typeof ui === 'object' && Object.hasOwn(ui, key) ? ui[key] : null;
|
||||
if (typeof web === 'string' && web && web !== local) out.push(web);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL to show from an artUrls() list: the first one that has not failed to load (a local file listed by a copied
|
||||
* data/local-assets.json without its files falls through to the mirror copy), or null when none is left.
|
||||
* @param {string[]} urls
|
||||
* @param {Set<string>} [failed] URLs whose image fired an error
|
||||
*/
|
||||
export function nextArtUrl(urls, failed) {
|
||||
for (const u of Array.isArray(urls) ? urls : []) if (typeof u === 'string' && u && !failed?.has(u)) return u;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode record from config.json (`modes[modeId]`), or null.
|
||||
* @param {string} modeId e.g. 'mode_multi_hard'
|
||||
|
||||
+30
-15
@@ -2,8 +2,10 @@
|
||||
// EMOTE_THEMES / EMOTES, generated reference data/emotes.json). Official emotes are pictures only: nothing here ever
|
||||
// renders an emote's text (our labels are aria-labels only).
|
||||
//
|
||||
// EmoteArt the emote picture (local-client art, data/local-assets.json → emoticon/<dir>/<picId>); a neutral glyph
|
||||
// when the art is missing or fails to load, an empty box while the manifest is still loading.
|
||||
// EmoteArt the emote picture: the local-client art (data/local-assets.json → emoticon/<dir>/<picId>) first, else
|
||||
// the copy setup downloads from the public mirror (data/assets.json → ui['emoticon/<dir>/<picId>'];
|
||||
// GitHub issue #42), each tried in turn when one fails to load; a neutral glyph when neither is there,
|
||||
// an empty box while a manifest is still loading.
|
||||
// EmoteBubble the pop bubble beside the sender's avatar in the team panel (official emoji_bubble_bkg: a dark rounded
|
||||
// square with a tail pointing left + the icon only); pop-in, 3 s, fade. The parent keys it by the emote's
|
||||
// seq so a newer emote replaces the old one and pops again, and passes the arrival time (`at`) so a
|
||||
@@ -20,7 +22,7 @@ import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { EMOTE_THEMES, EMOTE_COOLDOWN_MS, EMOTE_BUBBLE_MS, emoteInfo, emoteArtGroup } from '../../../shared/constants.js';
|
||||
import { html } from './components.js';
|
||||
import { GIcon } from './gameComponents.js';
|
||||
import { data, useData, localAsset } from '../data.js';
|
||||
import { data, useData, localAsset, artUrls, nextArtUrl } from '../data.js';
|
||||
import { loadPref, savePref } from '../store.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
@@ -48,15 +50,27 @@ export function ensureEmoteCss(doc = globalThis.document) {
|
||||
}
|
||||
|
||||
/**
|
||||
* URL of an emote's official art, or null when the id is unknown or the local manifest does not list the picture
|
||||
* (a path that is not listed is never requested).
|
||||
* URLs of an emote's official art, best first: the local-client picture, then the mirror copy (data.js artUrls). Empty
|
||||
* when the id is unknown or neither manifest lists the picture (a path that is not listed is never requested).
|
||||
* @param {string} id official emoji id
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function emoteArtUrls(id) {
|
||||
const e = emoteInfo(id);
|
||||
return e ? artUrls(emoteArtGroup(id), e.picId) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* URL of an emote's official art (the first of emoteArtUrls), or null.
|
||||
* @param {string} id official emoji id
|
||||
*/
|
||||
export function emoteArtUrl(id) {
|
||||
const e = emoteInfo(id);
|
||||
return e ? localAsset(emoteArtGroup(id), e.picId) : null;
|
||||
return emoteArtUrls(id)[0] || null;
|
||||
}
|
||||
|
||||
/** True while the local-art manifest or the asset manifest has not settled (EmoteArt shows an empty box meanwhile). */
|
||||
const artManifestsPending = () => ['local', 'assets'].some((n) => { const st = data.status(n); return st === 'loading' || st === 'idle'; });
|
||||
|
||||
/** Official emote UI sprite (ui/battle: emoji_bubble_bkg, emoji_bkg, emoji_cell_bkg, emoji_btn, emoji_btn_disable). */
|
||||
export const emoteUiSprite = (name) => localAsset('ui/battle', name);
|
||||
|
||||
@@ -149,18 +163,19 @@ function EmoteGlyph({ class: cls }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Official emote picture; neutral glyph when unavailable.
|
||||
* Official emote picture (the local-client art, else the mirror copy; the next one when a picture fails to load);
|
||||
* neutral glyph when unavailable.
|
||||
* @param {{ id: string, class?: string }} props
|
||||
*/
|
||||
export function EmoteArt({ id, class: cls }) {
|
||||
useData('local');
|
||||
const [bad, setBad] = useState(null);
|
||||
const src = emoteArtUrl(id);
|
||||
if (src && bad !== src) {
|
||||
return html`<img class=${cx('eart', cls)} src=${src} alt="" draggable=${false} decoding="async" onError=${() => setBad(src)} />`;
|
||||
useData('local', 'assets');
|
||||
const [bad, setBad] = useState(() => new Set()); // URLs that failed to load
|
||||
const src = nextArtUrl(emoteArtUrls(id), bad);
|
||||
if (src) {
|
||||
return html`<img key=${src} class=${cx('eart', cls)} src=${src} alt="" draggable=${false} decoding="async"
|
||||
onError=${() => setBad((s) => new Set(s).add(src))} />`;
|
||||
}
|
||||
const st = data.status('local');
|
||||
if (!src && emoteInfo(id) && (st === 'loading' || st === 'idle')) return html`<span class=${cx('eart', 'eart--pending', cls)} aria-hidden="true"></span>`;
|
||||
if (emoteInfo(id) && artManifestsPending()) return html`<span class=${cx('eart', 'eart--pending', cls)} aria-hidden="true"></span>`;
|
||||
return html`<${EmoteGlyph} class=${cls} />`;
|
||||
}
|
||||
|
||||
|
||||
+43
-21
@@ -1,8 +1,13 @@
|
||||
// 玩法说明 (How to play): viewer for the 19 official tutorial pages of the mode (local-client art, DESIGN §13:
|
||||
// data/local-assets.json → guide/autochess_{home 1–9, shop 1–6, handbook 1–4}). The pages are stored squashed to
|
||||
// 1024² and are displayed stretched back to 16:9. Three chapters (基础规则 / 调度手册 / 进阶图鉴), ‹ › buttons,
|
||||
// ←/→ (A/D) keys, page dots, thumbnails; Esc or the backdrop closes. Adjacent pages are preloaded.
|
||||
// Without the local art the viewer shows the official loading-screen tips (config.tips) instead.
|
||||
// 玩法说明 (How to play): viewer for the 19 official tutorial pages of the mode: guide/autochess_{home 1–9, shop 1–6,
|
||||
// handbook 1–4} from the local-client art (DESIGN §13: data/local-assets.json) when it is there, else the copies setup
|
||||
// downloads from the public mirror (data/assets.json → ui['guide/<key>']; GitHub issue #42) — data.js artUrls; a page
|
||||
// whose picture fails to load tries its next URL. The pages are stored squashed to 1024² (both copies) and are
|
||||
// displayed stretched back to 16:9. Three chapters (基础规则 / 调度手册 / 进阶图鉴), ‹ › buttons, ←/→ (A/D) keys, page
|
||||
// dots, thumbnails; Esc or the backdrop closes. Adjacent pages are preloaded.
|
||||
// With no page listed the viewer shows the official loading-screen tips (config.tips) instead, and so does a page none
|
||||
// of whose copies loads (`guideStage`: data/assets.json lists the downloaded pages but the files are not on disk — a
|
||||
// `git pull` and restart without setup, an older asset folder, a failed download the shrink guard kept); the chapter
|
||||
// tabs, the page dots and the keys still reach the other pages.
|
||||
//
|
||||
// Global & imperative so every screen can open it: `openGuide(page?)`; <GuideHost/> is mounted once by main.js
|
||||
// (and by the dev mock harness); <GuideButton/> is the standard trigger (title, lobby, room, in-match menu).
|
||||
@@ -10,7 +15,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Icon, MicroLabel, Button, Spinner } from './components.js';
|
||||
import { createStore, useStore } from '../store.js';
|
||||
import { data, useData, localAsset } from '../data.js';
|
||||
import { data, useData, artUrls, nextArtUrl } from '../data.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
@@ -32,26 +37,41 @@ export const GUIDE_CHAPTERS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Flat page list with URLs (only pages the local manifest lists).
|
||||
* @returns {Array<{ key: string, title: string, chapter: number, url: string }>}
|
||||
* Flat page list with URLs (only pages the local-art manifest or the asset manifest lists): `urls` best first (the
|
||||
* local file, then the mirror copy), `url` the first of them.
|
||||
* @returns {Array<{ key: string, title: string, chapter: number, url: string, urls: string[] }>}
|
||||
*/
|
||||
export function guidePages() {
|
||||
const out = [];
|
||||
GUIDE_CHAPTERS.forEach((ch, ci) => {
|
||||
for (const [key, title] of ch.pages) {
|
||||
const url = localAsset('guide', key);
|
||||
if (url) out.push({ key, title, chapter: ci, url });
|
||||
const urls = artUrls('guide', key);
|
||||
if (urls.length) out.push({ key, title, chapter: ci, url: urls[0], urls });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the viewer's stage shows for a page: `{ kind: 'image', src }` — the first of its URLs that has not failed to
|
||||
* load (the local file, then the downloaded copy) — or `{ kind: 'tips' }`, the official tips text, when none is left
|
||||
* (no page, or every copy failed to load).
|
||||
* @param {{ urls?: string[] }|null|undefined} page an entry of guidePages()
|
||||
* @param {Set<string>} [failed] URLs whose image fired an error
|
||||
* @returns {{ kind: 'image', src: string } | { kind: 'tips' }}
|
||||
*/
|
||||
export function guideStage(page, failed) {
|
||||
const src = page ? nextArtUrl(page.urls, failed) : null;
|
||||
return src ? { kind: 'image', src } : { kind: 'tips' };
|
||||
}
|
||||
|
||||
/** Open/closed + current page. */
|
||||
export const guideStore = createStore({ open: false, page: 0 });
|
||||
|
||||
/** Open the viewer (optionally at a page index). */
|
||||
export function openGuide(page = 0) {
|
||||
data.load('local');
|
||||
data.load('assets');
|
||||
data.load('config');
|
||||
guideStore.set({ open: true, page: Math.max(0, page | 0) });
|
||||
}
|
||||
@@ -71,7 +91,7 @@ function preload(url) {
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
/** Fallback body when the tutorial art is not installed: the official tips as a numbered list. */
|
||||
/** Fallback body when no tutorial page can be shown: the official tips as a numbered list. */
|
||||
function TipsFallback() {
|
||||
const tips = (Array.isArray(data.get('config')?.tips) ? data.get('config').tips : []).map((t) => t?.tip).filter(Boolean);
|
||||
return html`<div class="guide__tips">
|
||||
@@ -84,7 +104,7 @@ function TipsFallback() {
|
||||
/** The viewer (mounted once near the root). */
|
||||
export function GuideHost() {
|
||||
const { open, page } = useStore((s) => s, Object.is, guideStore);
|
||||
const ready = useData('local', 'config');
|
||||
const ready = useData('local', 'assets', 'config');
|
||||
const pages = useMemo(() => (ready ? guidePages() : []), [ready]);
|
||||
const [loaded, setLoaded] = useState(() => new Set());
|
||||
const [failed, setFailed] = useState(() => new Set());
|
||||
@@ -115,14 +135,16 @@ export function GuideHost() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !n) return;
|
||||
preload(pages[(i + 1) % n]?.url);
|
||||
preload(pages[(i + n - 1) % n]?.url);
|
||||
}, [open, i, n]);
|
||||
preload(nextArtUrl(pages[(i + 1) % n]?.urls, failed));
|
||||
preload(nextArtUrl(pages[(i + n - 1) % n]?.urls, failed));
|
||||
}, [open, i, n, failed]);
|
||||
|
||||
if (!open) return null;
|
||||
const chapter = cur ? GUIDE_CHAPTERS[cur.chapter] : null;
|
||||
const firstOf = (ci) => pages.findIndex((p) => p.chapter === ci);
|
||||
const isLoaded = cur && loaded.has(cur.url);
|
||||
const stage = guideStage(cur, failed); // the next copy when one fails to load; the tips when none is left
|
||||
const src = stage.kind === 'image' ? stage.src : null;
|
||||
const isLoaded = !!src && loaded.has(src);
|
||||
return html`<div class="guide" role="presentation" onMouseDown=${(e) => { if (e.target === e.currentTarget) closeGuide(); }}>
|
||||
<div class="guide__box brackets" role="dialog" aria-modal="true" aria-label="玩法说明" tabindex="-1" ref=${boxRef}>
|
||||
<header class="guide__head">
|
||||
@@ -142,13 +164,13 @@ export function GuideHost() {
|
||||
<button type="button" class="guide__close" aria-label="关闭" title="关闭 (Esc)" onClick=${closeGuide}><${Icon} name="close" /></button>
|
||||
</header>
|
||||
|
||||
${n ? html`<div class="guide__stage">
|
||||
${src ? html`<div class="guide__stage">
|
||||
<button type="button" class="guide__nav guide__prev" aria-label="上一页" onClick=${() => go(i - 1)}><${Icon} name="chevronLeft" /></button>
|
||||
<div class=${cx('guide__page', isLoaded && 'is-loaded')}>
|
||||
${cur && !failed.has(cur.url) ? html`<img key=${cur.url} src=${cur.url} alt=${cur.title} draggable=${false}
|
||||
onLoad=${() => setLoaded((s) => new Set(s).add(cur.url))}
|
||||
onError=${() => setFailed((s) => new Set(s).add(cur.url))} />` : html`<div class="guide__missing"><${Icon} name="info" />该页面暂时无法显示</div>`}
|
||||
${!isLoaded && cur && !failed.has(cur.url) ? html`<span class="guide__loading"><${Spinner} size="md" /></span>` : null}
|
||||
<img key=${src} src=${src} alt=${cur.title} draggable=${false}
|
||||
onLoad=${() => setLoaded((s) => new Set(s).add(src))}
|
||||
onError=${() => setFailed((s) => new Set(s).add(src))} />
|
||||
${!isLoaded ? html`<span class="guide__loading"><${Spinner} size="md" /></span>` : null}
|
||||
</div>
|
||||
<button type="button" class="guide__nav guide__next" aria-label="下一页" onClick=${() => go(i + 1)}><${Icon} name="chevronRight" /></button>
|
||||
</div>` : html`<div class="guide__stage guide__stage--text"><${TipsFallback} /></div>`}
|
||||
|
||||
+1
-1
@@ -693,7 +693,7 @@ async function main() {
|
||||
else console.error('[boot] failed to start', e);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\n 卫戍协议:盟约 · Stronghold Protocol: Covenant v${APP_VERSION}`);
|
||||
console.log(`\n 卫戍协议:盟约 · Stronghold Protocol: Alliance v${APP_VERSION}`);
|
||||
console.log(` Local: ${srv.url}`);
|
||||
if (srv.host === '0.0.0.0' || srv.host === '::') {
|
||||
for (const u of lanUrls(srv.port)) console.log(` LAN: ${u}`);
|
||||
|
||||
+3
-1
@@ -152,7 +152,9 @@ export const ERR_TEXT = {
|
||||
// (tools/build-emotes.mjs); test/ui/emotes.test.js keeps this table identical to it.
|
||||
// Official emotes have no text (desc is null): `label` is ours and only ever an aria-label, never displayed.
|
||||
// Art: extracted from a local client (tools/local-extract) to /assets/local/emoticon/<dir>/<picId>.png and listed in
|
||||
// data/local-assets.json group `emoticon/<dir>`; the UI shows a neutral glyph when it is absent. The picId is not
|
||||
// data/local-assets.json group `emoticon/<dir>`; also downloaded from the public mirror by tools/fetch-assets.mjs
|
||||
// (tools/assets/plan.mjs UI_EXTRAS → data/assets.json ui['emoticon/<dir>/<picId>'], GitHub issue #42). The UI takes the
|
||||
// local picture first, then the mirror copy, and shows a neutral glyph when neither is there. The picId is not
|
||||
// derived from the id (autochess_battle_fooldoctor_03…06 → pic_fooldoctor_04/05/06/08_battle).
|
||||
const emo = (id, sortId, picId, label) => Object.freeze({ id, sortId, picId, label });
|
||||
export const EMOTE_THEMES = Object.freeze([
|
||||
|
||||
+62
-1
@@ -18,9 +18,10 @@ import { pngSize, isCompletePng, isMp3, validate } from '../tools/assets/formats
|
||||
import { encodeWoff2, decodeWoff2Tables, readSfnt, uintBase128 } from '../tools/assets/woff2.mjs';
|
||||
import { assetToPath, pickUnitSfx, indexAudio } from '../tools/assets/audio.mjs';
|
||||
import { mirrorUrl, safeName, encodePath } from '../tools/assets/sources.mjs';
|
||||
import { collectEnemyIds, skillIndicesByChar } from '../tools/assets/plan.mjs';
|
||||
import { collectEnemyIds, skillIndicesByChar, buildPlan, GUIDE_PAGES, UI_EXTRAS } from '../tools/assets/plan.mjs';
|
||||
import { resolveTemplate, collectLeaves } from '../tools/assets/manifest.mjs';
|
||||
import { spineEntry } from '../public/js/assets.js';
|
||||
import { EMOTE_CATALOG, emoteArtGroup } from '../shared/constants.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PUBLIC = join(ROOT, 'public');
|
||||
@@ -468,6 +469,66 @@ describe('downloader (fake network)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub issue #42: a source deploy on a server without the Arknights client had no public/assets/local, so the battle
|
||||
// emotes showed default icons and 玩法说明 showed text tips. The public mirror has both (checked 2026-10-03: all 55
|
||||
// URLs answer 200 on raw.githubusercontent.com and on jsDelivr, same pictures and sizes as the local extraction), so the
|
||||
// plan downloads them like any other UI sprite, keyed by the data/local-assets.json group and name.
|
||||
describe('emotes and 玩法说明 pages from the public mirror (GitHub issue #42)', () => {
|
||||
const AA2 = 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/cn/assets/dyn/';
|
||||
const plan = () => buildPlan({ assets07: {}, ops03: {}, enemies05: {}, maps05: {}, audio: indexAudio({}), modelsData: {} }).template;
|
||||
|
||||
test('the plan fetches the 36 battle emotes and the 19 guide pages from ArknightsAssets2 cn, keyed like the local art', () => {
|
||||
const ui = plan().ui;
|
||||
assert.equal(EMOTE_CATALOG.length, 36);
|
||||
for (const e of EMOTE_CATALOG) {
|
||||
const key = `${emoteArtGroup(e.id)}/${e.picId}`; // the local manifest's group + name (emoticon/<dir>/<picId>)
|
||||
assert.deepEqual(ui[key]?.alts, [{ rel: `ui/${key}.png`, urls: [`${AA2}ui/emoticon/theme/%5Buc%5D${e.themeId}/icon/${e.picId}.png`], kind: 'png' }], key);
|
||||
}
|
||||
assert.ok(ui['emoticon/fooldoctor/pic_fooldoctor_08_battle'], 'keyed by picId, not by the emote id (fooldoctor_06 → pic 08)');
|
||||
assert.notEqual(ui['emoticon/slug/pic_thanks_battle'].alts[0].rel, ui['emoticon/basic/pic_thanks_battle'].alts[0].rel, 'same picId, two themes');
|
||||
assert.deepEqual(GUIDE_PAGES.slice(0, 2), ['autochess_home_1', 'autochess_home_2']);
|
||||
assert.equal(GUIDE_PAGES.length, 19);
|
||||
for (const k of GUIDE_PAGES) {
|
||||
assert.deepEqual(ui[`guide/${k}`]?.alts, [{ rel: `ui/guide/${k}.png`, urls: [`${AA2}arts/guidebookpages/%5Bpack%5Dautochess/${k}.png`], kind: 'png' }], k);
|
||||
}
|
||||
const rels = Object.values(ui).map((l) => l.alts[0].rel);
|
||||
assert.equal(new Set(rels).size, rels.length, 'no two UI entries share a file');
|
||||
assert.ok(rels.every((r) => !r.startsWith('local/')), 'public/assets/local belongs to tools/local-extract');
|
||||
assert.equal(mirrorUrl(ui['emoticon/fooldoctor/pic_fooldoctor_08_battle'].alts[0].urls[0]),
|
||||
'https://cdn.jsdelivr.net/gh/ArknightsAssets/ArknightsAssets2@cn/assets/dyn/ui/emoticon/theme/%5Buc%5Demoticon_foolsday_doctor/icon/pic_fooldoctor_08_battle.png',
|
||||
'the jsDelivr fallback (downloader) serves the same path');
|
||||
assert.ok(Object.isFrozen(UI_EXTRAS) && UI_EXTRAS.every(Object.isFrozen));
|
||||
});
|
||||
|
||||
test('a rebuilt manifest lists the copies that are on disk under /assets/ui/…; a missing one is left out', async () => {
|
||||
const { mkdtemp, mkdir, writeFile } = await import('node:fs/promises');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const root = await mkdtemp(join(tmpdir(), 'sp-mirror-art-'));
|
||||
const ui = plan().ui;
|
||||
const keys = ['emoticon/basic/pic_happy_battle', 'guide/autochess_home_1', 'emoticon/slug/pic_bye_battle'];
|
||||
for (const k of keys.slice(0, 2)) {
|
||||
await mkdir(join(root, dirname(ui[k].alts[0].rel)), { recursive: true });
|
||||
await writeFile(join(root, ui[k].alts[0].rel), 'x');
|
||||
}
|
||||
const r = resolveTemplate({ ui: Object.fromEntries(keys.map((k) => [k, ui[k]])) }, { root, spine: new Map() });
|
||||
assert.deepEqual(r.value.ui, {
|
||||
'emoticon/basic/pic_happy_battle': '/assets/ui/emoticon/basic/pic_happy_battle.png',
|
||||
'guide/autochess_home_1': '/assets/ui/guide/autochess_home_1.png',
|
||||
});
|
||||
assert.deepEqual(r.misses, ['ui.emoticon/slug/pic_bye_battle']);
|
||||
});
|
||||
|
||||
test('the committed data/assets.json lists all 55, so setup on an install made before them downloads them', () => {
|
||||
// tools/setup.mjs runs fetch-assets when data/assets.json lists a file that is not on disk (checkAssets)
|
||||
const m = readJson('data/assets.json');
|
||||
for (const e of EMOTE_CATALOG) assert.equal(m.ui[`emoticon/${e.dir}/${e.picId}`], `/assets/ui/emoticon/${e.dir}/${e.picId}.png`, e.id);
|
||||
for (const k of GUIDE_PAGES) assert.equal(m.ui[`guide/${k}`], `/assets/ui/guide/${k}.png`, k);
|
||||
assert.equal(m.stats.ui, Object.keys(m.ui).length);
|
||||
assert.ok(!JSON.stringify(m).includes('/assets/local/'), 'never a local-client path');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('generated manifest data/assets.json', () => {
|
||||
const haveManifest = existsSync(MANIFEST);
|
||||
|
||||
@@ -71,6 +71,22 @@ test('fetch-assets shrink guard: the committed manifest minus some audio entries
|
||||
assert.equal(statSync(MANIFEST).mtimeMs, before, 'importing the tool runs nothing');
|
||||
});
|
||||
|
||||
test('a server where the mirror emotes / 玩法说明 pages failed to download keeps the committed manifest, so the next setup retries them (#42)', async () => {
|
||||
// setup re-runs fetch-assets only while data/assets.json lists a file that is not on disk: a manifest rewritten without
|
||||
// these entries would stop the retries, and the emotes would stay default icons on that server
|
||||
const { shrinkGuard, parseArgs } = await import('../tools/fetch-assets.mjs');
|
||||
const prev = JSON.parse(readFileSync(MANIFEST, 'utf8'));
|
||||
const next = JSON.parse(JSON.stringify(prev));
|
||||
for (const k of Object.keys(next.ui)) if (/^(emoticon|guide)\//.test(k)) delete next.ui[k];
|
||||
next.stats = { ...next.stats, ui: Object.keys(next.ui).length };
|
||||
const g = shrinkGuard(prev, next, parseArgs([]));
|
||||
assert.equal(g.write, false);
|
||||
assert.equal(g.dropped.length, 36 + 19);
|
||||
for (const k of ['ui.emoticon/basic/pic_happy_battle', 'ui.emoticon/fooldoctor/pic_fooldoctor_08_battle', 'ui.guide/autochess_home_1', 'ui.guide/autochess_handbook_4']) {
|
||||
assert.ok(g.dropped.includes(k), k);
|
||||
}
|
||||
});
|
||||
|
||||
test('fetch-assets still runs as a script: --help lists --allow-shrink', () => {
|
||||
const r = spawnSync(process.execPath, [join(ROOT, 'tools', 'fetch-assets.mjs'), '--help'], { encoding: 'utf8', timeout: 30000 });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// The optional local-client art manifest (data/local-assets.json, DESIGN §13): an install without it must get an
|
||||
// empty manifest (200) instead of a 404, and an install with it must get the real file.
|
||||
// empty manifest (200) instead of a 404, and an install with it must get the real file. The docs and the setup /
|
||||
// doctor messages say what falls back without it (GitHub issue #42, DESIGN §22.5).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -69,3 +70,26 @@ test('present data/local-assets.json is served as-is', async () => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('docs and messages say what falls back without the local art and how a server without the client gets it (GitHub issue #42)', async () => {
|
||||
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
|
||||
const { LOCAL_ART_FALLBACK, LOCAL_ART_COPY_HINT } = await import('../tools/setup.mjs');
|
||||
for (const f of ['README.md', 'docs/DEPLOY.md', 'tools/setup.mjs', 'tools/doctor.mjs']) {
|
||||
assert.ok(!/不影响游戏|其他功能不受影响|游戏不受影响/.test(read(f)), `${f}: never "the local art does not matter"`);
|
||||
}
|
||||
for (const re of [/3D 棋盘/, /界面图标/, /源石虫/]) assert.match(LOCAL_ART_FALLBACK, re);
|
||||
assert.ok(!/表情|玩法说明/.test(LOCAL_ART_FALLBACK), 'the emotes and the 玩法说明 pages are downloaded, not local-only');
|
||||
assert.match(LOCAL_ART_COPY_HINT, /同一版本的整合包/);
|
||||
// setup's row is printed on every start (scripts/launch.mjs): it names the fallbacks and points to DEPLOY §6; doctor adds the hint
|
||||
const noClientRow = read('tools/setup.mjs').split('\n').find((l) => l.includes("'未检测到本机明日方舟客户端'"));
|
||||
assert.ok(noClientRow && noClientRow.includes('LOCAL_ART_FALLBACK') && noClientRow.includes('DEPLOY.md 第 6 节') && !noClientRow.includes('LOCAL_ART_COPY_HINT'), noClientRow);
|
||||
assert.match(read('tools/doctor.mjs'), /未提取:\$\{LOCAL_ART_FALLBACK\}(\$\{LOCAL_ART_COPY_HINT\})/);
|
||||
const deploy = read('docs/DEPLOY.md');
|
||||
const s6 = deploy.slice(deploy.indexOf('## 6. 本地客户端素材'));
|
||||
assert.ok(deploy.includes('## 6. 本地客户端素材') && s6.length > 200, 'DEPLOY §6');
|
||||
for (const re of [/同一版本/, /public\/assets\/local\//, /data\/local-assets\.json/, /3D 棋盘/, /源石虫/, /表情/, /玩法说明/]) assert.match(s6, re);
|
||||
const readme = read('README.md');
|
||||
assert.match(readme, /表情和「玩法说明」的教程图随上面的素材一起从公开镜像下载/);
|
||||
assert.match(readme, /\*\*同一版本\*\*的整合包/);
|
||||
assert.ok(!/需本地提取/.test(read('docs/PLAYING.md')), 'PLAYING: the 玩法说明 pages come with the download');
|
||||
});
|
||||
@@ -248,7 +248,7 @@ describe('public/js/ui/emotes.js helpers', () => {
|
||||
} };
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
test('art URLs come from the manifest only (keyed by picId); unknown / unlisted → null', async () => {
|
||||
test('art URLs come only from what the manifests list (here the local one, keyed by picId; data/assets.json absent); unknown / unlisted → null', async () => {
|
||||
globalThis.fetch = async (url) => (String(url).endsWith('/local-assets.json')
|
||||
? { ok: true, status: 200, json: async () => manifest } : { ok: false, status: 404, json: async () => ({}) });
|
||||
try {
|
||||
|
||||
+104
-6
@@ -1,12 +1,16 @@
|
||||
// Local-client art is optional (DESIGN §13): the emote art, the 玩法说明 pages and the official tier chips resolve
|
||||
// from data/local-assets.json when present and fall back (text / tips / CSS) when the manifest or an entry is missing.
|
||||
// The emotes and the 玩法说明 pages are also on the public mirror (GitHub issue #42): data/assets.json lists the copies
|
||||
// tools/fetch-assets.mjs downloads under the same group and name (ui['<group>/<name>']); the client takes the local
|
||||
// file first, then the mirror copy, then its fallback (data.js artUrls / nextArtUrl).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { EMOTES, emoteArtPath, emoteArtGroup, emoteInfo } from '../../shared/constants.js';
|
||||
import { EMOTES, EMOTE_CATALOG, emoteArtPath, emoteArtGroup, emoteInfo } from '../../shared/constants.js';
|
||||
import { GUIDE_PAGES } from '../../tools/assets/plan.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
@@ -19,13 +23,28 @@ const manifest = {
|
||||
'ui/battle': { img_chess_level_3: { path: '/assets/local/ui/battle/img_chess_level_3.png', w: 45, h: 45 } },
|
||||
},
|
||||
};
|
||||
// what server/index.js serves when data/local-assets.json is absent
|
||||
const EMPTY_LOCAL = { version: 1, source: 'none', count: 0, groups: {} };
|
||||
|
||||
let mode = 'ok';
|
||||
let mode = 'ok'; // data/local-assets.json: 'ok' (the manifest above) | 'empty' (the server's stand-in) | 'missing' (404)
|
||||
let assetsDoc = null; // data/assets.json, or null (404)
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).endsWith('/local-assets.json') && mode === 'ok') return { ok: true, status: 200, json: async () => manifest };
|
||||
const u = String(url);
|
||||
if (u.endsWith('/local-assets.json')) {
|
||||
if (mode === 'ok') return { ok: true, status: 200, json: async () => manifest };
|
||||
if (mode === 'empty') return { ok: true, status: 200, json: async () => EMPTY_LOCAL };
|
||||
} else if (u.endsWith('/assets.json') && assetsDoc) return { ok: true, status: 200, json: async () => assetsDoc };
|
||||
return { ok: false, status: 404, json: async () => ({}) };
|
||||
};
|
||||
|
||||
/** Reload both manifests after changing `mode` / `assetsDoc`. */
|
||||
async function reload(data) {
|
||||
const orig = console.warn; console.warn = () => {};
|
||||
try {
|
||||
for (const n of ['local', 'assets']) { await data.invalidate(n); await data.load(n); }
|
||||
} finally { console.warn = orig; }
|
||||
}
|
||||
|
||||
describe('local-client art fallbacks', () => {
|
||||
test('manifest present: listed entries resolve, unlisted ones stay null', async () => {
|
||||
const { data, localAsset } = await import('../../public/js/data.js');
|
||||
@@ -34,7 +53,7 @@ describe('local-client art fallbacks', () => {
|
||||
const { guidePages, GUIDE_CHAPTERS } = await import('../../public/js/ui/guide.js');
|
||||
const { tierChipUrl } = await import('../../public/js/ui/components.js');
|
||||
assert.equal(emoteArtUrl('autochess_battle_happy'), emoteArtPath('autochess_battle_happy'));
|
||||
assert.equal(emoteArtUrl('autochess_battle_sad'), null, 'unlisted emote art is never requested');
|
||||
assert.equal(emoteArtUrl('autochess_battle_sad'), null, 'listed by neither manifest (no data/assets.json here): no URL, never requested');
|
||||
assert.equal(emoteArtUrl('nope'), null);
|
||||
const pages = guidePages();
|
||||
assert.deepEqual(pages.map((p) => p.key), ['autochess_home_1', 'autochess_home_2', 'autochess_shop_1', 'autochess_handbook_4'], 'reading order, missing pages skipped');
|
||||
@@ -48,9 +67,9 @@ describe('local-client art fallbacks', () => {
|
||||
|
||||
test('manifest missing: everything falls back', async () => {
|
||||
mode = 'missing';
|
||||
assetsDoc = null;
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
const orig = console.warn; console.warn = () => {};
|
||||
try { await data.invalidate('local'); } finally { console.warn = orig; }
|
||||
await reload(data);
|
||||
assert.equal(data.status('local'), 'missing');
|
||||
const { emoteArtUrl } = await import('../../public/js/ui/emotes.js');
|
||||
const { guidePages } = await import('../../public/js/ui/guide.js');
|
||||
@@ -71,6 +90,85 @@ describe('local-client art fallbacks', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('emotes and 玩法说明 pages: local art first, then the mirror copy (GitHub issue #42)', () => {
|
||||
test('lookup order: the local file, then data/assets.json ui[\'<group>/<name>\'], then nothing', async () => {
|
||||
mode = 'ok';
|
||||
assetsDoc = { ui: {
|
||||
'emoticon/basic/pic_happy_battle': '/assets/ui/emoticon/basic/pic_happy_battle.png', // both: the local one wins
|
||||
'emoticon/basic_2/pic_sad_battle': '/assets/ui/emoticon/basic_2/pic_sad_battle.png', // the mirror copy only
|
||||
'guide/autochess_home_1': '/assets/ui/guide/autochess_home_1.png',
|
||||
'guide/autochess_home_9': '/assets/ui/guide/autochess_home_9.png',
|
||||
'ui/battle/img_chess_level_4': '/assets/ui/x.png', // not one of the mirrored groups' names
|
||||
} };
|
||||
const { data, artUrls, nextArtUrl, localAsset } = await import('../../public/js/data.js');
|
||||
await reload(data);
|
||||
const { emoteArtUrl, emoteArtUrls } = await import('../../public/js/ui/emotes.js');
|
||||
const { guidePages } = await import('../../public/js/ui/guide.js');
|
||||
assert.deepEqual(emoteArtUrls('autochess_battle_happy'), ['/assets/local/emoticon/basic/pic_happy_battle.png', '/assets/ui/emoticon/basic/pic_happy_battle.png']);
|
||||
assert.equal(emoteArtUrl('autochess_battle_happy'), '/assets/local/emoticon/basic/pic_happy_battle.png', 'local first');
|
||||
assert.equal(emoteArtUrl('autochess_battle_sad'), '/assets/ui/emoticon/basic_2/pic_sad_battle.png', 'no local file: the mirror copy');
|
||||
assert.deepEqual(emoteArtUrls('autochess_battle_dying'), [], 'neither: the glyph');
|
||||
assert.equal(emoteArtUrl('autochess_battle_dying'), null);
|
||||
assert.deepEqual(emoteArtUrls('__proto__'), []);
|
||||
const pages = guidePages();
|
||||
assert.deepEqual(pages.map((p) => p.key), ['autochess_home_1', 'autochess_home_2', 'autochess_home_9', 'autochess_shop_1', 'autochess_handbook_4'], 'reading order, either source');
|
||||
assert.deepEqual(pages[0].urls, ['/assets/local/guide/autochess_home_1.png', '/assets/ui/guide/autochess_home_1.png']);
|
||||
assert.equal(pages[0].url, '/assets/local/guide/autochess_home_1.png');
|
||||
assert.deepEqual(pages[2].urls, ['/assets/ui/guide/autochess_home_9.png']);
|
||||
assert.deepEqual(artUrls('guide', 'autochess_home_2'), ['/assets/local/guide/autochess_home_2.png']);
|
||||
assert.deepEqual(artUrls('ui/battle', 'img_chess_level_3'), ['/assets/local/ui/battle/img_chess_level_3.png']);
|
||||
assert.deepEqual(artUrls('guide', '__proto__'), []);
|
||||
assert.deepEqual(artUrls('constructor', 'x'), []);
|
||||
assert.equal(localAsset('guide', 'autochess_home_9'), null, 'localAsset stays local-only');
|
||||
// an image that fails to load (a copied local-assets.json without its files) falls through to the next URL
|
||||
const urls = emoteArtUrls('autochess_battle_happy');
|
||||
assert.equal(nextArtUrl(urls, new Set()), urls[0]);
|
||||
assert.equal(nextArtUrl(urls, new Set([urls[0]])), urls[1]);
|
||||
assert.equal(nextArtUrl(urls, new Set(urls)), null, 'both failed: the fallback');
|
||||
assert.equal(nextArtUrl(urls), urls[0]);
|
||||
assert.equal(nextArtUrl(null), null);
|
||||
});
|
||||
|
||||
test('no local client (a source deploy on a server, issue #42): every emote and all 19 pages come from the committed data/assets.json', async () => {
|
||||
mode = 'empty';
|
||||
assetsDoc = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
await reload(data);
|
||||
const { emoteArtUrl } = await import('../../public/js/ui/emotes.js');
|
||||
const { guidePages } = await import('../../public/js/ui/guide.js');
|
||||
for (const e of EMOTE_CATALOG) assert.equal(emoteArtUrl(e.id), `/assets/ui/emoticon/${e.dir}/${e.picId}.png`, e.id);
|
||||
const pages = guidePages();
|
||||
assert.deepEqual(pages.map((p) => p.key), GUIDE_PAGES);
|
||||
assert.deepEqual(pages.map((p) => p.url), GUIDE_PAGES.map((k) => `/assets/ui/guide/${k}.png`));
|
||||
assert.ok(pages.every((p) => p.urls.length === 1));
|
||||
});
|
||||
|
||||
test('the guide stage: the local page, else the downloaded copy, else the official tips text (0.1.1\'s fallback)', async () => {
|
||||
const { guideStage } = await import('../../public/js/ui/guide.js');
|
||||
const local = '/assets/local/guide/autochess_home_1.png';
|
||||
const mirror = '/assets/ui/guide/autochess_home_1.png';
|
||||
const page = { key: 'autochess_home_1', urls: [local, mirror] };
|
||||
assert.deepEqual(guideStage(page, new Set()), { kind: 'image', src: local }, 'local ok → local');
|
||||
assert.deepEqual(guideStage(page), { kind: 'image', src: local }, 'nothing failed yet');
|
||||
assert.deepEqual(guideStage(page, new Set([local])), { kind: 'image', src: mirror }, 'local fails → the downloaded copy');
|
||||
assert.deepEqual(guideStage(page, new Set([local, mirror])), { kind: 'tips' }, 'both fail → tips');
|
||||
// data/assets.json lists the downloaded page but the file is not on disk (a git pull and restart without setup)
|
||||
assert.deepEqual(guideStage({ urls: [mirror] }, new Set([mirror])), { kind: 'tips' }, 'listed, not on disk → tips, not a blank notice');
|
||||
assert.deepEqual(guideStage({ urls: [] }), { kind: 'tips' }, 'no copy');
|
||||
assert.deepEqual(guideStage(null), { kind: 'tips' }, 'no page');
|
||||
// the viewer shows exactly that: the image stage for a URL, the tips stage otherwise (never the old blank notice)
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/ui/guide.js'), 'utf8');
|
||||
assert.match(src, /const stage = guideStage\(cur, failed\)/);
|
||||
assert.match(src, /: html`<div class="guide__stage guide__stage--text"><\$\{TipsFallback\} \/><\/div>`\}/);
|
||||
assert.ok(!src.includes('该页面暂时无法显示'), 'no blank notice left');
|
||||
});
|
||||
|
||||
test('tools/assets/plan.mjs GUIDE_PAGES is the viewer\'s page list (GUIDE_CHAPTERS), in reading order', async () => {
|
||||
const { GUIDE_CHAPTERS } = await import('../../public/js/ui/guide.js');
|
||||
assert.deepEqual([...GUIDE_PAGES], GUIDE_CHAPTERS.flatMap((c) => c.pages.map(([key]) => key)));
|
||||
});
|
||||
});
|
||||
|
||||
function safeRead() {
|
||||
try { return readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8'); } catch { return null; }
|
||||
}
|
||||
+16
-1
@@ -37,10 +37,25 @@ test('the release version is what players see', () => {
|
||||
assert.match(read('public/js/screens/title.js'), /v\$\{APP_VERSION\}/, 'title screen footer');
|
||||
assert.ok(!/PROTOCOL v1/.test(read('public/js/screens/title.js')), 'no protocol number posing as a version');
|
||||
const server = read('server/index.js');
|
||||
assert.match(server, /Stronghold Protocol: Covenant v\$\{APP_VERSION\}/, 'boot banner');
|
||||
assert.match(server, /Stronghold Protocol: Alliance v\$\{APP_VERSION\}/, 'boot banner');
|
||||
assert.match(server, /app: APP_VERSION/, '/healthz');
|
||||
});
|
||||
|
||||
test('the English title is the official one: Stronghold Protocol: Alliance (as in the reply to GitHub issue #38, which stays open)', () => {
|
||||
// EN client data, activity_table basicInfo.act2autochess.name = "Stronghold Protocol: Alliance" (CN 卫戍协议:盟约);
|
||||
// the project used to call it "Covenant". The Chinese title stays 卫戍协议:盟约; the repository keeps its name.
|
||||
const readme = read('README.md');
|
||||
assert.match(readme.split('\n')[0], /^# 卫戍协议:盟约 · Stronghold Protocol: Alliance$/, 'README title');
|
||||
assert.match(readme, /mode \*Stronghold Protocol: Alliance\*/, 'README English summary');
|
||||
assert.match(read('server/index.js'), /卫戍协议:盟约 · Stronghold Protocol: Alliance v/, 'boot banner');
|
||||
assert.equal(pkg.name, 'stronghold-protocol-alliance');
|
||||
assert.equal(lock.name, pkg.name);
|
||||
assert.equal(lock.packages[''].name, pkg.name);
|
||||
for (const f of ['README.md', 'server/index.js', 'package.json', 'package-lock.json', 'NOTICE.md', 'public/index.html', 'docs/DEPLOY.md']) {
|
||||
assert.ok(!/covenant/i.test(read(f)), `${f}: no "Covenant" title left`);
|
||||
}
|
||||
});
|
||||
|
||||
test('GPL-3.0-or-later: LICENSE, package metadata and notices', () => {
|
||||
const license = read('LICENSE');
|
||||
assert.match(license.slice(0, 200), /GNU GENERAL PUBLIC LICENSE\s+Version 3, 29 June 2007/);
|
||||
|
||||
+26
-4
@@ -15,12 +15,14 @@
|
||||
// act2autochess match (07 enemy list ∪ act1autochess wave/boss levels used by
|
||||
// act2 modes ∪ bosses ∪ their summons ∪ enemy units spawned by operator kits),
|
||||
// the 23 bonds, 59 shop items, 40 bands,
|
||||
// default-skill icons, profession icons, autochess UI sprites, BGM and SFX.
|
||||
// default-skill icons, profession icons, autochess UI sprites (the 36 battle
|
||||
// emotes and the 19 玩法说明 pages included: UI_EXTRAS), BGM and SFX.
|
||||
|
||||
import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs';
|
||||
import { kindOf } from './formats.mjs';
|
||||
import { pickUnitSfx, UI_SFX, BATTLE_SFX, resolveSpec } from './audio.mjs';
|
||||
import { literal } from './manifest.mjs';
|
||||
import { EMOTE_CATALOG } from '../../shared/constants.js';
|
||||
|
||||
/**
|
||||
* Enemies whose Spine no community dump carries: the web model is another enemy's (research 07 §5.6). Their official
|
||||
@@ -38,8 +40,26 @@ const LOADING_USED = new Set(['loading_ac_core', 'loading_ac_prototype', 'loadin
|
||||
|
||||
const PROFESSIONS = ['caster', 'medic', 'pioneer', 'sniper', 'special', 'support', 'tank', 'warrior'];
|
||||
|
||||
/** Extra UI sprites (not in 07-assets.json groups): [group, key, path under ArknightsAssets2 cn assets/dyn]. */
|
||||
const UI_EXTRAS = (() => {
|
||||
/**
|
||||
* The 19 official 玩法说明 (tutorial) pages, in the reading order of public/js/ui/guide.js GUIDE_CHAPTERS (a test keeps
|
||||
* the two identical): 基础规则 home 1–9, 调度手册 shop 1–6, 进阶图鉴 handbook 1–4.
|
||||
*/
|
||||
export const GUIDE_PAGES = Object.freeze([
|
||||
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `autochess_home_${i}`),
|
||||
...[1, 2, 3, 4, 5, 6].map((i) => `autochess_shop_${i}`),
|
||||
...[1, 2, 3, 4].map((i) => `autochess_handbook_${i}`),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extra UI sprites (not in 07-assets.json groups): [group, key, path under ArknightsAssets2 cn assets/dyn].
|
||||
* The last two blocks are art that used to come from the local client only (tools/local-extract, DESIGN §13) and that
|
||||
* the mirror carries too (GitHub issue #42: a server without the client showed default emote icons): the 36 battle
|
||||
* emotes (shared/constants.js EMOTE_CATALOG; the bundle ui/emoticon/theme/[uc]<themeId>.ab → its icon/<picId>.png) and
|
||||
* the 19 玩法说明 pages (arts/guidebookpages/[pack]autochess.ab, 1024² like the local copies: displayed at 16:9). Their
|
||||
* manifest keys are the data/local-assets.json group and name — ui['emoticon/<dir>/<picId>'], ui['guide/<key>'] — so
|
||||
* the client looks both up by the same names (public/js/data.js artUrls: the local file first, then this copy).
|
||||
*/
|
||||
export const UI_EXTRAS = (() => {
|
||||
const L = [];
|
||||
const mc = 'ui/autochess/[uc]autochessouter/modechoice/auto_chess_mode_choice_state/';
|
||||
for (const m of ['normal', 'hard', 'abyss', 'funny']) L.push(['modeChoice', `${m}_rhodes_island`, `${mc}${m}_rhodes_island.png`]);
|
||||
@@ -67,7 +87,9 @@ const UI_EXTRAS = (() => {
|
||||
L.push(['prepReady', 'countdown_arrow', `${pr}countdown_arrow.png`]);
|
||||
const si = 'ui/autochess/[uc]autochessouter/stageinfo/auto_chess_stage_info_state/';
|
||||
for (const k of ['img_title_mode_abyss', 'img_title_mode_funny', 'img_title_mode_hard', 'img_title_mode_normal', 'btn_confirm', 'btn_confirmed']) L.push(['stageInfo', k, `${si}${k}.png`]);
|
||||
return L;
|
||||
for (const e of EMOTE_CATALOG) L.push([`emoticon/${e.dir}`, e.picId, `ui/emoticon/theme/[uc]${e.themeId}/icon/${e.picId}.png`]);
|
||||
for (const k of GUIDE_PAGES) L.push(['guide', k, `arts/guidebookpages/[pack]autochess/${k}.png`]);
|
||||
return Object.freeze(L.map((x) => Object.freeze(x)));
|
||||
})();
|
||||
|
||||
/** Renames of research 07 `arts` groups to manifest UI groups. */
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
// themes: [{ themeId, dir, sortId, isBasic, name, emotes: [id…] }], pages, enabled-list order
|
||||
// emotes: [{ id, themeId, sortId, picId, art, label }] } 36, page order then sortId
|
||||
// `art` = /assets/local/emoticon/<dir>/<picId>.png (tools/local-extract; key by picId — fooldoctor_03…06 use pics
|
||||
// 04/05/06/08). Official emotes carry no text (desc is null); `label` is ours and only ever an aria-label.
|
||||
// 04/05/06/08); the mirror copy that tools/fetch-assets.mjs downloads is data/assets.json ui['emoticon/<dir>/<picId>'].
|
||||
// Official emotes carry no text (desc is null); `label` is ours and only ever an aria-label.
|
||||
// shared/constants.js EMOTE_THEMES mirrors this file (test/ui/emotes.test.js keeps them identical).
|
||||
//
|
||||
// Usage: node tools/build-emotes.mjs [--offline] [--check] [--out data/emotes.json] [--cache .cache/gamedata]
|
||||
|
||||
+4
-3
@@ -18,6 +18,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
ROOT, MIN_NODE, IS_WIN, IS_MAC, c, mark, capture, padDisplay, displayWidth,
|
||||
checkNode, checkDeps, checkVendor, checkData, checkAssets, checkLocal, findClient, findPython,
|
||||
LOCAL_ART_FALLBACK, LOCAL_ART_COPY_HINT,
|
||||
} from './setup.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
@@ -25,8 +26,8 @@ import {
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
// 名字里带这些的网卡不对局域网开放:虚拟机 / 容器 / WSL / 代理软件的 TUN 适配器(Mihomo、Clash)。
|
||||
// 这里不再列 `^tun\d`:下面 VPN_IF 的 `tun\d` 先判定,任何能被 `^tun\d` 命中的名字也一定被它命中,
|
||||
// 于是 tun0/tap0 一律归为 vpn(那正是同组好友互连用的地址)。写在两处只会让人以为顺序无关。
|
||||
// tun0 / tap0 不归这一类:classifyAddresses 先判下面的 VPN_IF,它的 `tun\d` / `tap` 是子串匹配,
|
||||
// tun0 / tap0 先被它命中,归为 vpn(那正是同组好友互连用的地址)。
|
||||
const VIRTUAL_IF = /(vethernet|virtualbox|vmware|vmnet|docker|^br-|^veth|wsl|hyper-v|vboxnet|bridge\d|utun|awdl|llw|parallels|loopback|mihomo|clash|sing-?box)/i;
|
||||
// 点对点 VPN:这些地址就是同组好友互相访问用的(Tailscale / ZeroTier / WireGuard / Radmin VPN / Hamachi)。
|
||||
const VPN_IF = /(tailscale|zerotier|^zt|wireguard|^wg\d|tun\d|tap|radmin|hamachi)/i;
|
||||
@@ -192,7 +193,7 @@ async function main() {
|
||||
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' : ''}`
|
||||
: client ? `检测到 ${client.kind} 客户端 → node tools/setup.mjs --local` : '未提取(不影响游戏)');
|
||||
: client ? `检测到 ${client.kind} 客户端 → node tools/setup.mjs --local` : `未提取:${LOCAL_ART_FALLBACK}(${LOCAL_ART_COPY_HINT})`);
|
||||
if (client || local.manifest) {
|
||||
const py = findPython();
|
||||
row(py ? 'ok' : 'skip', 'Python(仅提取用)', py ? `${py.cmd} ${py.version}` : '未找到 Python 3.8+');
|
||||
|
||||
@@ -8,6 +8,8 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
|
||||
- the 6 in-match emoticon themes of 盟约 (activity_table autoChessData.enabledEmoticonThemeIdList; only the
|
||||
*_battle sprites, keyed by display_meta_table picId: emoticon/<dir>/<picId>.png, see data/emotes.json)
|
||||
- the autochess guidebook pages, battle projectile sprites and a few token/skin Spine models missing upstream
|
||||
(the emotes and the guidebook pages are on the public mirror too: tools/fetch-assets.mjs downloads them, and the
|
||||
client uses these local copies first — GitHub issue #42)
|
||||
- the enemy battle Spine models no community dump carries (ENEMY_SPINES: 灼热源石虫 / 炽焰源石虫), from the enemy art
|
||||
bundles (refs/arts/enm_art_*.ab) → spine/enemy/<enemyId>/<stem>.skel|.atlas + page PNGs with the [alpha] texture
|
||||
merged in (premultiplied RGB + A, the Ark-Models format; the atlas gets `size:` / `pma: true` like the fetched
|
||||
|
||||
+16
-5
@@ -8,7 +8,7 @@
|
||||
// 2. Dependencies: `npm ci` (falls back to `npm install`) when node_modules is missing or incomplete.
|
||||
// 3. Client libraries in public/vendor (tools/vendor.mjs) when any is missing.
|
||||
// 4. Game data (data/*.json, committed) present and parseable.
|
||||
// 5. Art/audio (tools/fetch-assets.mjs, ~250 MB into public/assets, resumable, mirror fallback) when public/assets
|
||||
// 5. Art/audio (tools/fetch-assets.mjs, ~270 MB into public/assets, resumable, mirror fallback) when public/assets
|
||||
// is missing or data/assets.json lists files that are not on disk. A failure is a warning: the game still runs
|
||||
// with fallback visuals and the next run resumes.
|
||||
// 6. Optional: official board/UI art from a locally installed Arknights client (Windows native install, CrossOver
|
||||
@@ -189,6 +189,16 @@ export function checkAssets() {
|
||||
return { ok: present && missing.length === 0 && urls.length > 0, present, manifest: true, total: urls.length, missing: missing.length, sample: missing.slice(0, 5), bytes: Number(m.stats?.bytes) || 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* What the game draws instead when the local-client art is absent (DESIGN §13, docs/DEPLOY.md §6). The battle emotes and
|
||||
* the 玩法说明 pages are not in the list: step 5 downloads them from the public mirror with the other assets (GitHub
|
||||
* issue #42). Shown by setup and doctor.
|
||||
*/
|
||||
export const LOCAL_ART_FALLBACK = '3D 棋盘改用 2D,部分官方界面图标和灼热/炽焰源石虫模型用替代样式';
|
||||
/** Where a machine without the client gets the local art (docs/DEPLOY.md §6「本地客户端素材」); shown by doctor (setup's row,
|
||||
* printed on every start by scripts/launch.mjs, only points to that section). */
|
||||
export const LOCAL_ART_COPY_HINT = '没有客户端的服务器可以从同一版本的整合包复制 public/assets/local 和 data/local-assets.json';
|
||||
|
||||
/**
|
||||
* Local-client art (optional): manifest entry count, whether the 3D board atlas is on disk and whether the extraction
|
||||
* has the enemy models only the client has (`spine/enemy/*` groups, extract.py ENEMY_SPINES — added after 0.1.0).
|
||||
@@ -400,7 +410,7 @@ async function main() {
|
||||
let assets = checkAssets();
|
||||
if (!opts.assets) add(assets.ok ? 'ok' : 'skip', '美术/音频 public/assets', assets.ok ? `${assets.total} 个文件` : '已跳过(--no-assets)');
|
||||
else if (!assets.ok && deps.ok && !opts.check) {
|
||||
const what = !assets.present ? `首次下载约 ${assets.bytes ? mb(assets.bytes) : '250 MB'},可随时中断,重新运行会续传`
|
||||
const what = !assets.present ? `首次下载约 ${assets.bytes ? mb(assets.bytes) : '270 MB'},可随时中断,重新运行会续传`
|
||||
: `补全缺失的 ${assets.missing} 个文件`;
|
||||
log(`\n${c.cyan('▶')} 下载美术与音频素材(${what})…`);
|
||||
const r = run(process.execPath, [path.join(ROOT, 'tools', 'fetch-assets.mjs')]);
|
||||
@@ -422,7 +432,8 @@ async function main() {
|
||||
const already = local.manifest && local.dirPresent;
|
||||
if (!client) {
|
||||
if (already && local.board3d && !local.tiles && !opts.check) cropBoardTiles(log);
|
||||
add(already ? 'ok' : 'skip', '本地客户端美术(可选)', already ? `已提取 ${local.count} 项` : (opts.game ? `找不到 ${opts.game}` : '未检测到本机明日方舟客户端(不影响游戏)'));
|
||||
add(already ? 'ok' : 'skip', '本地客户端美术(可选)', already ? `已提取 ${local.count} 项`
|
||||
: `${opts.game ? `找不到 ${opts.game}` : '未检测到本机明日方舟客户端'}:${LOCAL_ART_FALLBACK}(见 docs/DEPLOY.md 第 6 节)`);
|
||||
} else if (!client.autochess) {
|
||||
add(already ? 'ok' : 'warn', '本地客户端美术(可选)', `${client.kind} 客户端缺少卫戍协议资源(请在游戏内下载全部资源):${client.path}`);
|
||||
} else if (already && opts.local !== 'force') {
|
||||
@@ -438,7 +449,7 @@ async function main() {
|
||||
let go = opts.local === 'force' || opts.yes;
|
||||
if (go || !state.localDeclined) {
|
||||
log(`\n${c.cyan('▶')} 检测到本机明日方舟客户端(${client.kind}):\n ${c.dim(client.path)}`);
|
||||
log(' 可以从中提取官方棋盘贴图、UI 图标、表情等(仅本机使用;通常 1–5 分钟,Python 依赖约 40 MB,装在项目内的 .venv-extract)。');
|
||||
log(' 可以从中提取官方 3D 棋盘贴图、界面图标等(仅本机使用;通常 1–5 分钟,Python 依赖约 40 MB,装在项目内的 .venv-extract)。');
|
||||
}
|
||||
let unattended = false;
|
||||
if (!go && !state.localDeclined) {
|
||||
@@ -457,7 +468,7 @@ async function main() {
|
||||
if (r.ok) cropBoardTiles(log);
|
||||
const after = checkLocal();
|
||||
if (r.ok && after.manifest) { add('ok', '本地客户端美术(可选)', `提取 ${after.count} 项${after.board3d ? ',3D 棋盘可用' : ''}`); saveState({ localDeclined: false, localExtractedFrom: client.path }); }
|
||||
else add('warn', '本地客户端美术(可选)', `提取未成功(退出码 ${r.code}),游戏不受影响;可稍后重试 node tools/setup.mjs --local`);
|
||||
else add('warn', '本地客户端美术(可选)', `提取未成功(退出码 ${r.code}):游戏照常运行,${LOCAL_ART_FALLBACK};可稍后重试 node tools/setup.mjs --local`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user