feedback7 R22-VOICES: the Japanese voice dub as a second tree beside the Chinese one — audio.voiceJp (the same slots and file names from ArknightsAssets2 voice/: 2674 files, 89.4 MB, for the 191 voiced operators), 设置 → 语音语言 中文 / 日本語 (default 中文, not tied to the interface language), a JP line the manifest or the host lacks plays the Chinese one; the full zip ships both dubs (one switch, tools/package.mjs FULL_ZIP_JP_VOICE), the lite zip stays art-free (the owner's request of 2026-10-08 「全套的日配语音」); golden: unchanged
- tools/assets/plan.mjs: one voiceTree() per dub — audio.voice (--voice-lang, cn by default) and audio.voiceJp (VOICE_JP_LANG, always the JP dub, audio/voice/jp/<charId>/cn_nnn.mp3); fetch-assets plans and downloads both (stats.voiceJpChars), so setup (checkAssets walks every listed URL) fetches both too. - data/assets.json regenerated with the project's tool (node tools/fetch-assets.mjs --add-only, raw.githubusercontent.com, 107 s): the only change is audio.voiceJp + stats (+2674 files, +89,388,041 bytes, voiceJpChars 191) + hash — the rest is byte-identical; the JP tree is the Chinese one with the folder swapped (191 × 14 files, every file a valid MP3). The audio files are not committed (public/assets/ is git-ignored). - public/js/audio.js voiceLine(): 'jp' draws from voiceJp and falls back to the Chinese line per slot (a JP entry the manifest lacks) and per line at play time (the Chinese file of the same name when the host lacks the JP one — same token, same gate); operators without voice (stand-ins, 盟约·辅助干员, summons) stay silent. setVoiceLang(). - settings: voiceLang 'cn' | 'jp' (VOICE_LANGS, sanitizeSettings), the 语音语言 row under 干员语音 (each dub named in its own language, like the language switch), 语音语言 in the en / ja / ko / zh-TW packs. - tools/package.mjs FULL_ZIP_JP_VOICE (true): false holds the JP files out of the full zip (and its update zip) — not missing, not orphans, never in an update's `removed`; setup then downloads them. - Sizes measured: art download 548 MiB (was 463), full zip ≈ 505 MB (0.2.1: 428 MiB + 76 MiB of zipped JP voice), unzipped 711 MB (package --dry-run); lite zip unchanged. README, DEPLOY, ASSETS, setup's fallback size updated; PLAYING voice paragraph; §21.30 "Changed in 0.2.2" note. Tests: test/feedback7-voices.test.js (new: plan, manifest, settings, package switch), test/ui/audio.test.js (voiceLine, the manager's dub choice and fallback), test/ui/gameLogic.test.js, test/docs-consistency.test.js — all fail on the parent.
This commit is contained in:
1 parent
cb8865d9d8
commit
ddf8c39ed0
22 files changed
+414
-76
No files matched your search
@@ -69,8 +69,8 @@ English summary: [below](#english).
|
||||
|
||||
[Releases](../../releases/latest) 里有两种整合包,代码和运行依赖完全相同,二选一;已经装好 0.2.x 的,升级时只下载更新包即可:
|
||||
|
||||
- **完整包** `Stronghold-Protocol-v<版本>.zip`(约 430 MB,解压后约 625 MB):附带全部美术 / 音频(含官方 3D 棋盘贴图),解压就能玩,不需要再下载任何东西。**推荐。**
|
||||
- **精简包** `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB):不带素材,第一次启动时自动从公开镜像下载美术、Spine 模型、音频、字体、表情和「玩法说明」教程图(约 460 MB,可中断,再次启动会续传);官方 3D 棋盘等本地客户端素材不在其中(见下面的「本地客户端素材」)。适合下载大文件不方便的情况。
|
||||
- **完整包** `Stronghold-Protocol-v<版本>.zip`(约 505 MB,解压后约 710 MB):附带全部美术 / 音频(含中文、日文两套干员语音和官方 3D 棋盘贴图),解压就能玩,不需要再下载任何东西。**推荐。**
|
||||
- **精简包** `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB):不带素材,第一次启动时自动从公开镜像下载美术、Spine 模型、音频、字体、表情和「玩法说明」教程图(约 550 MB,可中断,再次启动会续传);官方 3D 棋盘等本地客户端素材不在其中(见下面的「本地客户端素材」)。适合下载大文件不方便的情况。
|
||||
- **更新包** `Stronghold-Protocol-v<版本>-update.zip`(0.2.1 起提供,大小看改动多少,通常只有几 MB):只含比之前的 0.2.x 版本改动过的文件,用来把已经装好的 0.2.x(完整包或精简包装的都行)升级到新版本,不用重新下载整个包。用法:先停止服务器(关掉窗口;装了开机自启的运行 `scripts\install-service-windows.ps1 -Stop`),把 zip 里 `Stronghold-Protocol` 文件夹的全部内容合并到安装文件夹、覆盖同名文件(Windows 资源管理器里复制粘贴即可;macOS 不要用访达拖放,它会整个替换文件夹,请用 `unzip -o`,见 [docs/DEPLOY.md](docs/DEPLOY.md) 第 1.5 节),再照常启动。启动时会先核对全部程序文件、删除新版本不再用的旧文件,然后正常运行;如果这个文件夹不是更新包对应的版本(例如 0.1.x,或者程序文件被改过),会提示下载完整包,服务器不启动。全新安装请用完整包或精简包。
|
||||
|
||||
两种包都只含运行和部署需要的文件(服务器、客户端、数据、启动脚本、setup / doctor / 素材下载工具、许可证与说明、[docs/PLAYING.md](docs/PLAYING.md) 和 [docs/DEPLOY.md](docs/DEPLOY.md));测试、开发工具和设计文档只在源码仓库里。
|
||||
@@ -91,7 +91,7 @@ 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 # 检查环境,并从公开镜像下载约 460 MB 美术 / 音频(可中断,再次运行会续传)
|
||||
npm run setup # 检查环境,并从公开镜像下载约 550 MB 美术 / 音频(可中断,再次运行会续传)
|
||||
npm start # 启动服务器:http://localhost:3000
|
||||
```
|
||||
|
||||
@@ -105,7 +105,7 @@ npm start # 启动服务器:http://localhost:3000
|
||||
|
||||
| 项目 | 要求 |
|
||||
|---|---|
|
||||
| 开服的电脑 | Windows / macOS / Linux,Node.js 22 或 24(LTS);磁盘约 600–750 MB(素材、依赖与本地提取贴图:完整包解压后约 625 MB);内存空闲约 100 MB,每局再加几 MB |
|
||||
| 开服的电脑 | Windows / macOS / Linux,Node.js 22 或 24(LTS);磁盘约 700–850 MB(素材、依赖与本地提取贴图:完整包解压后约 710 MB);内存空闲约 100 MB,每局再加几 MB |
|
||||
| 玩家 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑、手机或平板(横屏) |
|
||||
| 网络 | 首次进入游戏时,每位玩家要从开服的电脑下载几十 MB 素材(之后走浏览器缓存);对局中流量很小 |
|
||||
|
||||
@@ -251,7 +251,7 @@ GOLDEN_FULL=1 node --test test/golden.test.js # 黄金结果:固定
|
||||
|
||||
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 full bundle `Stronghold-Protocol-v<version>.zip` (~430 MB, all the art inside) from [Releases](../../releases/latest) — or the lite one, `…-lite.zip` (~22 MB), which downloads the art (~460 MB) on its first start; from 0.2.1 on, `…-update.zip` holds only the files changed since the earlier 0.2.x releases: stop the server, extract it over an existing 0.2.x folder and start again (the first start deletes the files the new version dropped and verifies the install) — 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 ~460 MB of art from public mirrors, the emotes and the how-to-play pages included; the official 3D board, some official HUD icons, two enemy models and 39 summon models are extracted from a local Arknights client — without one the game uses the 2D board and look-alike stand-ins (the summons show their avatars), and a server can copy `public/assets/local/` and `data/local-assets.json` from the full bundle of the same version).
|
||||
- **Run:** download the full bundle `Stronghold-Protocol-v<version>.zip` (~505 MB, all the art inside, the Chinese and Japanese operator voices included) from [Releases](../../releases/latest) — or the lite one, `…-lite.zip` (~22 MB), which downloads the art (~550 MB) on its first start; from 0.2.1 on, `…-update.zip` holds only the files changed since the earlier 0.2.x releases: stop the server, extract it over an existing 0.2.x folder and start again (the first start deletes the files the new version dropped and verifies the install) — 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 ~550 MB of art from public mirrors, the emotes and the how-to-play pages included; the official 3D board, some official HUD icons, two enemy models and 39 summon models are extracted from a local Arknights client — without one the game uses the 2D board and look-alike stand-ins (the summons show their avatars), and a server can copy `public/assets/local/` and `data/local-assets.json` from the full bundle of the same version).
|
||||
- **Languages:** Chinese (the default), English, 日本語, 한국어 and 繁體中文 — switch on the title screen or in Settings. Game texts come from the official clients; the Japanese, Korean and Traditional Chinese interface strings are machine translations (corrections welcome: [docs/I18N.md](docs/I18N.md)).
|
||||
- **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.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":1,"hash":"7ae1d03466cb","generator":"tools/fetch-assets.mjs","stats":{"files":7969,"bytes":485061859,"chars":209,"charsWithBack":206,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":671,"bonds":23,"items":59,"bands":40,"skills":522,"modules":131,"ui":602,"sfxUnits":483,"voiceChars":191},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/front/char_017_huang.skelLine truncated
|
||||
{"version":1,"hash":"bfdfd5e7efd9","generator":"tools/fetch-assets.mjs","stats":{"files":10643,"bytes":574449900,"chars":209,"charsWithBack":206,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":671,"bonds":23,"items":59,"bands":40,"skills":522,"modules":131,"ui":602,"sfxUnits":483,"voiceChars":191,"voiceJpChars":191},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/frontLine truncated
|
||||
+10
-4
@@ -20,7 +20,7 @@ npm run assets # = node tools/vendor.mjs && node tools/fetch-assets.mjs
|
||||
| `--offline` | No network. Re-runs post-processing (atlas fixes, skeleton parsing, WOFF2) on what is already on disk, then rebuilds `data/assets.json`. |
|
||||
| `--dry-run` | Print the plan (file and model counts, alias notes) and exit. |
|
||||
| `--refresh-index` | Re-download the upstream indexes: `audio_data.json`, `charword_table.json` (the 干员战斗语音 slots) and `models_data.json`. |
|
||||
| `--voice-lang=cn` | 干员战斗语音 language: `cn` (default) | `jp` | `en` | `kr` — the same file names under `voice_cn/`, `voice/`, `voice_en/`, `voice_kr/`. |
|
||||
| `--voice-lang=cn` | The dub of `audio.voice` (the 中文 voice setting): `cn` (default) | `jp` | `en` | `kr` — the same file names under `voice_cn/`, `voice/`, `voice_en/`, `voice_kr/`. `audio.voiceJp` (the 日本語 setting) is always the JP dub: every run plans both trees. |
|
||||
| `--voice-all` | Plan every official voice slot, including the prep-only lines no battle plays (干员报到 / 编入队伍 / 任命队长 — 360 files, one per operator and slot). Off by default: nothing requests them, so planning them only makes every run download more. |
|
||||
| `--prune` | Delete files under `public/assets/` that the manifest no longer references, for example after a mapping change. Without this flag they are only listed in the report. `public/assets/local/` (written by `tools/local-extract`) is never pruned. Implies `--allow-shrink`. |
|
||||
| `--allow-shrink` | Write `data/assets.json` even when it loses entries the current one has (see "The manifest never shrinks by accident" below). |
|
||||
@@ -53,7 +53,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 **309 MiB in about 5,690 files** (it took 134 s on a ~3 MB/s link before the 55 emote and 玩法说明 files, 21.3 MiB, and the 1,680 干员战斗语音 files, 40.1 MiB, were added). A re-run takes about 1 s. The voice count is the twelve slots a battle plays; the three prep-only slots the official client uses elsewhere (干员报到 / 编入队伍 / 任命队长, 360 more files, 18.4 MiB) are left out unless `--voice-all` is passed.
|
||||
The first run downloads about **548 MiB in 10,643 files** (`stats` of `data/assets.json` in 0.2.2: 574,449,900 bytes). The two 干员战斗语音 dubs are 2,674 files each, the twelve slots a battle plays for 191 operators: the Chinese one 65.6 MiB, the Japanese one 85.2 MiB (89,388,041 bytes; adding it took 107 s at about 1 MB/s from raw.githubusercontent.com). A re-run takes about 1 s. The three prep-only slots the official client uses elsewhere (干员报到 / 编入队伍 / 任命队长, 360 more files per dub, 18.4 MiB of them Chinese) are left out unless `--voice-all` is passed.
|
||||
|
||||
Outputs:
|
||||
- `data/assets.json`: the manifest (committed).
|
||||
@@ -93,7 +93,7 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee
|
||||
| Token Spine that no dump carries (39 summons: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼) | the local client only (`tools/local-extract/extract.py TOKEN_SPINES`, optional); never downloaded and never required: an overlay (`tokens[id].spineLocal`; without it the avatar diamond) | `local/spine/token/{tokenId}/{stem}.*` (listed in `data/local-assets.json`) |
|
||||
| BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` (大厅/休整期 `act1autochess`, 开战 `act13side/m_bat_kazimierz2_{1,2}` — 骑士之日 / 无畏者; the 开战 track follows the round: `_2` 无畏者 rounds 1–7, `_1` 骑士之日 from round 8) | `audio/bgm/{file}.mp3` |
|
||||
| SFX (UI, battle, per unit) | AA2 `voice` `audio/sound_beta_2/**`, mapped from `audio_data.json` banks | `audio/sfx/{same sub-path}.mp3` |
|
||||
| 干员战斗语音 | AA2 `voice` `audio/sound_beta_2/voice_cn/{charId}/cn_nn.mp3` — the lines `charword_table.json` lists (`placeType` = when the game plays one, `voiceAsset` = the path); `--voice-lang=jp|en|kr` takes the same file names from `voice/`, `voice_en/`, `voice_kr/` | `audio/voice/{lang}/{charId}/{cn_nn}.mp3` |
|
||||
| 干员战斗语音 | AA2 `voice` `audio/sound_beta_2/voice_cn/{charId}/cn_nn.mp3` (Chinese, `audio.voice`) and `voice/{charId}/cn_nn.mp3` (Japanese, `audio.voiceJp`: the same file names) — the lines `charword_table.json` lists (`placeType` = when the game plays one, `voiceAsset` = the path); `--voice-lang=jp|en|kr` fills `audio.voice` from `voice/`, `voice_en/`, `voice_kr/` instead | `audio/voice/cn/{charId}/{cn_nn}.mp3`, `audio/voice/jp/{charId}/{cn_nn}.mp3` (`audio/voice/{lang}/…` for another `--voice-lang`) |
|
||||
| Fonts: Bender Regular and Light, Novecento Wide | TimWangZi/The-font-of-Arknights | `public/fonts/*.{otf,ttf,woff2}`, `public/fonts/fonts.css` |
|
||||
|
||||
The `stem` of a Spine model is the upstream file name. Two examples: `char_107_liskam` has the stem `char_107_liskarm`, and `enemy_9032_aclionk` uses `enemy_1559_vtlionk`. The skel and atlas of a model always share one stem. pixi-spine locates the atlas by swapping the extension, so this matters.
|
||||
@@ -148,7 +148,7 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
|
||||
hash: 'a1b2c3d4e5f6', // content hash (cache busting)
|
||||
generator: 'tools/fetch-assets.mjs',
|
||||
stats: { files, bytes, chars, charsWithBack, enemies, enemiesWithSpine, tokens, tokensWithSpine,
|
||||
spineModels, bonds, items, bands, skills, modules, ui, sfxUnits, voiceChars },
|
||||
spineModels, bonds, items, bands, skills, modules, ui, sfxUnits, voiceChars, voiceJpChars },
|
||||
chars: { [charId]: { avatar, avatarE2?, portrait, portraitE2?, spine: { front: Spine, back?: Spine } } },
|
||||
enemies: { [enemyId]: { icon, spine?: Spine, spineAliasOf?: enemyId,
|
||||
spineLocal?: { group, skel, atlas, textures, …Spine } } },
|
||||
@@ -186,6 +186,12 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
|
||||
// prep-only slots 干员报到 / 编入队伍 / 任命队长 are left out of the plan by default —
|
||||
// nothing requests them and they cost 360 files (19.3 MB) per run — and `--voice-all` adds
|
||||
// them (audio.mjs VOICE_PREP_SLOTS) for the complete official set
|
||||
voiceJp: { [charId]: { …the slots of `voice` } },
|
||||
// the Japanese dub (0.2.2, the owner's request 「全套的日配语音」): the same slots, lines and file
|
||||
// names as `voice`, under /assets/audio/voice/jp/ (AA2 `voice/`). The client plays it when the
|
||||
// player picks 日本語 (settings 语音语言, not tied to the interface language) and falls back to
|
||||
// `voice` for a slot it lacks or a file the host does not have (public/js/audio.js voiceLine);
|
||||
// the full zip ships it unless tools/package.mjs FULL_ZIP_JP_VOICE is off
|
||||
sfx: {
|
||||
ui: { click, back, confirm, tab, pick, drop, error, buy, sell, income, refresh, freeze, levelup,
|
||||
merge, equip, itemMerge, bondUp, artPlace, ready, timer, draft, yourTurn, yourTurnCircle,
|
||||
|
||||
+6
-6
@@ -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 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。 |
|
||||
| 磁盘 | 素材约 460 MB(`public/assets`)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 625 MB。 |
|
||||
| 磁盘 | 素材约 550 MB(`public/assets`,含中文约 65 MB、日文约 85 MB 两套干员语音)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 710 MB。 |
|
||||
| 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 |
|
||||
|
||||
服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。
|
||||
@@ -26,13 +26,13 @@
|
||||
```
|
||||
装完**关闭并重新打开**终端,`node -v` 应显示 v22 或更高(winget 的 LTS 目前是 v24.x,同样可用)。没有 winget 时从 <https://nodejs.org/zh-cn/download> 和 <https://git-scm.com/download/win> 下载安装。
|
||||
2. 下载,三选一。建议放在一个固定、短、**不在 OneDrive 同步范围内**的目录,例如 `C:\Stronghold-Protocol`:
|
||||
- **完整包(推荐)**:在仓库的 [Releases](https://github.com/sganggs/Stronghold-Protocol/releases) 页面下载最新版本的 `Stronghold-Protocol-v<版本>.zip`(约 430 MB,解压后约 625 MB;已含运行依赖、前端库和全部素材,包括官方 3D 棋盘等本地客户端素材),解压后把里面的 `Stronghold-Protocol` 文件夹放到上述位置。不需要 Git,首次启动也不用再下载素材。素材版权归上海鹰角网络 / Yostar,仅限非商业使用,见 [NOTICE.md](../NOTICE.md)。
|
||||
- **精简包**:同一页面的 `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB)。代码、运行依赖和前端库与完整包相同,但不带素材:美术、Spine 模型、音频、字体、表情和「玩法说明」教程图在首次启动时由 setup 从公开镜像下载(约 460 MB,显示进度,可中断续传;镜像设置见下面的「国内镜像下载」)。官方 3D 棋盘等本地客户端素材需要用本机客户端提取,或从同一版本的完整包复制(第 6 节)。适合下载大文件不方便、或想先下一个小包的情况;放置方式同完整包。
|
||||
- **完整包(推荐)**:在仓库的 [Releases](https://github.com/sganggs/Stronghold-Protocol/releases) 页面下载最新版本的 `Stronghold-Protocol-v<版本>.zip`(约 505 MB,解压后约 710 MB;已含运行依赖、前端库和全部素材,包括中文、日文两套干员语音和官方 3D 棋盘等本地客户端素材),解压后把里面的 `Stronghold-Protocol` 文件夹放到上述位置。不需要 Git,首次启动也不用再下载素材。素材版权归上海鹰角网络 / Yostar,仅限非商业使用,见 [NOTICE.md](../NOTICE.md)。
|
||||
- **精简包**:同一页面的 `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB)。代码、运行依赖和前端库与完整包相同,但不带素材:美术、Spine 模型、音频(含两套干员语音)、字体、表情和「玩法说明」教程图在首次启动时由 setup 从公开镜像下载(约 550 MB,显示进度,可中断续传;镜像设置见下面的「国内镜像下载」)。官方 3D 棋盘等本地客户端素材需要用本机客户端提取,或从同一版本的完整包复制(第 6 节)。适合下载大文件不方便、或想先下一个小包的情况;放置方式同完整包。
|
||||
- **源码**:
|
||||
```powershell
|
||||
git clone https://github.com/sganggs/Stronghold-Protocol.git C:\Stronghold-Protocol
|
||||
```
|
||||
3. 双击 `C:\Stronghold-Protocol\scripts\start-windows.bat`。首次会:安装依赖(`npm ci`;整合包已含,跳过)→ 复制前端库(整合包已含,跳过)→ 下载约 460 MB 素材(完整包已含,跳过;精简包和源码在这一步下载,显示进度,中断后再次启动会续传)→ 若检测到本机的明日方舟客户端,询问是否提取官方贴图(可跳过)→ 启动服务器并打开浏览器。
|
||||
3. 双击 `C:\Stronghold-Protocol\scripts\start-windows.bat`。首次会:安装依赖(`npm ci`;整合包已含,跳过)→ 复制前端库(整合包已含,跳过)→ 下载约 550 MB 素材(完整包已含,跳过;精简包和源码在这一步下载,显示进度,中断后再次启动会续传)→ 若检测到本机的明日方舟客户端,询问是否提取官方贴图(可跳过)→ 启动服务器并打开浏览器。
|
||||
4. 窗口里会打印朋友可用的地址,例如 `http://192.168.1.23:3000`。用另一台设备打开它确认能进入。关闭窗口即停止服务器。
|
||||
|
||||
等价的手动命令:`npm ci`、`node tools/setup.mjs`、`npm start`。
|
||||
@@ -226,7 +226,7 @@ https / wss 说明:页面通过 https 打开时客户端自动连接 `wss://
|
||||
## 3. Docker
|
||||
|
||||
```bash
|
||||
# A) 构建时下载素材(需要联网,约 460 MB)
|
||||
# A) 构建时下载素材(需要联网,约 550 MB)
|
||||
docker build -t stronghold-protocol --build-arg FETCH_ASSETS=1 .
|
||||
docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol
|
||||
|
||||
@@ -323,7 +323,7 @@ npm run package:lite -- --out <目录> # 精简包 Stronghold-Protocol-v<版
|
||||
npm run package -- --update --from <旧版本的完整包>[,<…>] --out <目录> # 更新包 Stronghold-Protocol-v<版本>-update.zip
|
||||
```
|
||||
|
||||
- **打进去的**:`git ls-files` 里的 `server/`、`shared/`、`data/`、`public/`(不含 `public/dev/`)、`packs/`(随仓库提交的内容包;只在本机安装、没提交的不打进去)、启动脚本、玩家会运行的工具(setup、vendor、fetch-assets 与 `tools/assets/`、doctor,以及 setup 调用的 `tools/local-extract/` 和 `crop-board-atlas.mjs`)、服务器和 fetch-assets 读取的 4 张研究数据表(`docs/research/` 的 `03-operators`、`05-enemies`、`05-maps`、`07-assets` 四个 JSON)、`package.json` / `package-lock.json`、许可证与说明(`LICENSE`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`README.md`、`CHANGELOG.md`)、`docs/PLAYING.md` 和本文;然后在临时目录里生成 `packs/index.json`(打进去的语言包和内容包的列表,供纯静态托管使用;服务器自己会实时列出,见 [PACKS.md](PACKS.md)),再 `npm ci --omit=dev` 装上运行依赖和 `public/vendor`。完整包再加上 `data/assets.json` 列出的素材、`public/fonts`,以及本地提取的 `public/assets/local/` 和 `data/local-assets.json`。磁盘上有、清单却没列出的文件不打进去(例如 0.2.0 移出自选的焰狐龙梓兰的旧素材)。
|
||||
- **打进去的**:`git ls-files` 里的 `server/`、`shared/`、`data/`、`public/`(不含 `public/dev/`)、`packs/`(随仓库提交的内容包;只在本机安装、没提交的不打进去)、启动脚本、玩家会运行的工具(setup、vendor、fetch-assets 与 `tools/assets/`、doctor,以及 setup 调用的 `tools/local-extract/` 和 `crop-board-atlas.mjs`)、服务器和 fetch-assets 读取的 4 张研究数据表(`docs/research/` 的 `03-operators`、`05-enemies`、`05-maps`、`07-assets` 四个 JSON)、`package.json` / `package-lock.json`、许可证与说明(`LICENSE`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`README.md`、`CHANGELOG.md`)、`docs/PLAYING.md` 和本文;然后在临时目录里生成 `packs/index.json`(打进去的语言包和内容包的列表,供纯静态托管使用;服务器自己会实时列出,见 [PACKS.md](PACKS.md)),再 `npm ci --omit=dev` 装上运行依赖和 `public/vendor`。完整包再加上 `data/assets.json` 列出的素材、`public/fonts`,以及本地提取的 `public/assets/local/` 和 `data/local-assets.json`。磁盘上有、清单却没列出的文件不打进去(例如 0.2.0 移出自选的焰狐龙梓兰的旧素材)。日文干员语音(`audio.voiceJp`,`public/assets/audio/voice/jp/` 的 2674 个文件,约 85 MB,zip 后约 76 MB)默认也打进完整包;`tools/package.mjs` 里的开关 `FULL_ZIP_JP_VOICE` 改成 `false` 时,完整包(以及由它比较出的更新包)不带这些文件:玩家首次启动时 setup 会像精简包那样下载它们,下载完成前选「日本語」会播中文语音,更新包也不会删除玩家已有的日文语音。
|
||||
- **不打进去的**:`test/`、维护用的工具(数据构建、golden、botbench、i18n、导入检查、本工具等)、`scripts/make-windows-bundle.mjs`(Windows 便携包,见 [WINDOWS.md](WINDOWS.md))、其他文档、研究笔记和 `docs/img/`、`handoff/`、`.github/`、`types/`、lint / 编辑器 / Docker 配置。和 0.1.x 的整树打包(全部跟踪文件加上 `public/assets` 的全部内容)相比,0.2.0 的完整包少了约 640 个文件、解压后小约 26 MB,zip 小约 8 MB。
|
||||
- **打包前的检查**(`--dry-run` 也全部做一遍):拒绝名单(`pv`、`review`、`.cache`、`.claude`、`.git`、`logs`、`.env`、`scripts/service.env.cmd`、`handoff`、`test` 等);每个打进去的模块的相对导入、玩家用的 npm 脚本(start / setup / doctor / launch / postinstall / vendor / assets)都指向包里的文件;完整包里 `data/assets.json` 和 `data/local-assets.json` 列出的文件都在(缺了先运行 `node tools/fetch-assets.mjs`);没有只差大小写的两个路径;包里的文件(二进制素材也查)不含个人目录路径(`/Users/…`、`C:\Users\…`、`/home/…`)或本机的账户名(运行时从系统读取;`SP_PACKAGE_SCAN_NAMES=a,b` 可以再加名字);打进去的已跟踪文件没有未提交的改动(重新生成的 `data/assets.json` 要先提交)。有任何问题都会列出原因、以非零状态结束,不写 zip;正式打包时还会核对临时目录里的文件和计划完全一致。
|
||||
- **MANIFEST.json**:三种包的根目录都有(`npm ci` 之后写入):除素材(`public/assets/`、`public/fonts/`、`data/assets.json`、`data/local-assets.json`,归 setup 管)以外每个文件的大小和 sha256,同一版本的三种包内容相同。`npm run doctor` 和更新包的启动检查(`server/update.js`)用它核对安装。
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
界面语言:默认中文;标题画面右上角或「设置」里可切换 **中文 / English / 日本語 / 한국어 / 繁體中文**(会记住选择,不用刷新)。日语、韩语、繁体中文的游戏文本取自官方日服 / 韩服 / 台服数据,界面文字暂未翻译——日语和韩语显示英文界面,繁体中文显示简体中文界面,欢迎社区补全(docs/I18N.md「Adding a language」)。英文的干员、技能、盟约、装备、敌人等游戏文本取自官方英文服数据,界面文字全部有英文;切到英文后仍显示中文的只有玩家名字(人机的名字也是)、多轮悬赏改写过的「接下来两场作战」说明,以及官方英文服还没有的游戏文本(部分自选干员和较新的模组);「玩法说明」的教程图是官方的中文图片,标题有英文(docs/I18N.md)。
|
||||
|
||||
干员语音(官方台词,「设置」里的「干员语音」调音量):点选干员时——作战区或整备区里的干员、商店和晋升奖励里的干员卡片——会说一句官方的「选中干员」语音,休整期也一样;连续点选时新的一句接替正在说的那句,但不会打断正在播放的部署、技能等作战语音。部署(每场第一个部署的干员说「行动出发」)、技能、接敌和结算语音只在作战中播放。替补干员和原型干员(预备干员、Sharp、Mechanist 等)、盟约·辅助干员和召唤物没有官方语音,点选时不出声。
|
||||
干员语音(官方台词):「设置」里的「干员语音」调音量,「语音语言」选 **中文 / 日本語**(默认中文,和界面语言无关,会记住选择;日语缺了哪一句就播那一句的中文)。点选干员时——作战区或整备区里的干员、商店和晋升奖励里的干员卡片——会说一句官方的「选中干员」语音,休整期也一样;连续点选时新的一句接替正在说的那句,但不会打断正在播放的部署、技能等作战语音。部署(每场第一个部署的干员说「行动出发」)、技能、接敌和结算语音只在作战中播放。替补干员和原型干员(预备干员、Sharp、Mechanist 等)、盟约·辅助干员和召唤物没有官方语音,点选时不出声。
|
||||
|
||||
## 1. 一局的流程
|
||||
|
||||
|
||||
@@ -350,7 +350,7 @@ Two public GitHub issues after 0.1.0, both client-only. Normative lines rewritte
|
||||
|
||||
User: 「给每个卫戍干员都加语音」, then, after the first build, 「整备阶段不需要干员语音,战斗开始后才需要语音,也就是只需要作战中语音」.
|
||||
|
||||
**Changed in 0.2.2** (§27, the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」): 选中干员 is no longer battle-only — the detail panel opening on an operator the player taps says it in every phase, the prep, the shop and the hand included (`DetailPanel voice` is true on the game screen), with the official FOCUS_CHAR timing: no cooldown and no global gap on an idle channel, a newer tap replaces the 选中 line on air, a higher-priority line is never interrupted (`VOICE_TAP_SLOTS`). Every other line stays battle-only; the paragraphs below describe 0.1.1.
|
||||
**Changed in 0.2.2** (§27, the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」): 选中干员 is no longer battle-only — the detail panel opening on an operator the player taps says it in every phase, the prep, the shop and the hand included (`DetailPanel voice` is true on the game screen), with the official FOCUS_CHAR timing: no cooldown and no global gap on an idle channel, a newer tap replaces the 选中 line on air, a higher-priority line is never interrupted (`VOICE_TAP_SLOTS`). Every other line stays battle-only. 0.2.2 also adds the Japanese dub (「全套的日配语音」) as a second tree beside the Chinese one, `audio.voiceJp` — the same slots and file names from the dump's `voice/` folder — which the client plays when the player picks 日本語 under 设置 → 语音语言 (default 中文, not tied to the interface language), falling back to the Chinese line it lacks. The paragraphs below describe 0.1.1.
|
||||
|
||||
**Where the lines come from.** Every operator the mode can field already has official battle lines in the same voice dump the BGM and SFX come from — `voice_cn/{charId}/cn_nn.mp3` (JP/EN/KR: `voice/`, `voice_en/`, `voice_kr/`, the same file names, `tools/fetch-assets.mjs --voice-lang=jp|en|kr`). `charword_table.json`'s `placeType` says when each line is used, and `indexVoice()` turns it into the manifest's `audio.voice[charId]`: 行动出发 `start` (BATTLE_START), 行动开始 `faceEnemy` (BATTLE_FACE_ENEMY), 选中干员 `select`, 部署 `place`, 作战中1-4 `skill1..skill4` (BATTLE_SKILL_N), 编入队伍 `squad`, 任命队长 `squadFirst`, 结算 `resultFour/Three/Two/Lose` (完成高难行动 / 3星结束行动 / 非3星结束行动 / 行动失败) and 干员报到 `gacha`. A slot with several lines stays an array and the client draws one. Only the base word key is planned (`wordKey === charId`): the dump has no folder for a skin variant's word key (`char_x_ita`, `char_x_epoque#28`), so those lines 404. **Only the slots a battle can play are planned** (`audio.mjs VOICE_BATTLE_SLOTS`): 干员报到 / 编入队伍 / 任命队长 are lines of the official client's own 养成 / 编队 UI, nothing in this mode ever requests them (`test/docs-consistency.test.js` checks the client's `voice(...)` call sites), and downloading them cost 360 files (19.3 MB) on every run — `--voice-all` adds them back for the complete official set. Measured: 120 of the 121 in-mode charIds have a full set — the 17 预备干员 and the mode's own 盟约·辅助干员 have no battle voice at all, so they simply get no entry — 1680 lines of the 2040 upstream, in a manifest of 5694 files / 324.2 MB (files and `stats.voiceChars` in `data/assets.json`).
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
"完成": "Done",
|
||||
"背景音乐": "Music",
|
||||
"干员语音": "Operator Voices",
|
||||
"语音语言": "Voice Language",
|
||||
"音效": "Sound Effects",
|
||||
"静音": "Mute",
|
||||
"显示伤害数字": "Damage Numbers",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"完成": "完了",
|
||||
"背景音乐": "BGM",
|
||||
"干员语音": "オペレーターボイス",
|
||||
"语音语言": "ボイス言語",
|
||||
"音效": "効果音",
|
||||
"静音": "ミュート",
|
||||
"显示伤害数字": "ダメージ数値を表示",
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
"完成": "완료",
|
||||
"背景音乐": "배경 음악",
|
||||
"干员语音": "오퍼레이터 음성",
|
||||
"语音语言": "음성 언어",
|
||||
"音效": "효과음",
|
||||
"静音": "음소거",
|
||||
"显示伤害数字": "대미지 수치 표시",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"完成": "完成",
|
||||
"背景音乐": "背景音樂",
|
||||
"干员语音": "幹員語音",
|
||||
"语音语言": "語音語言",
|
||||
"音效": "音效",
|
||||
"静音": "靜音",
|
||||
"显示伤害数字": "顯示傷害數字",
|
||||
|
||||
+63
-12
@@ -4,6 +4,7 @@
|
||||
// bgm { lobby, prep, combat, combatAlts?: [ {intro?, loop}, … ], boss: { intro?, loop } },
|
||||
// bossBgm { [bossId]: { intro?, loop } },
|
||||
// voice { [charId]: { start, faceEnemy, select, place, skill1…skill4, squad, squadFirst, result*, gacha } },
|
||||
// voiceJp { [charId]: { …the same slots } } (the Japanese dub, settings 语音语言 日本語; see voiceLine),
|
||||
// sfx.ui { click, buy, sell, refresh, freeze, levelup, merge, equip, ready, timer, yourTurn, … },
|
||||
// sfx.battle { deploy, tokenDeploy, charDie, tokenDie?, enemyDie, enemyHit, heal, killCoin, … },
|
||||
// sfx.units { [charId|tokenId|enemyId]: { attack?, hit?, skill?, die?, born?, mix?: { [role]: { p?, vol? } } } }.
|
||||
@@ -25,7 +26,9 @@
|
||||
// operators, then fires dozens of skills, so they run on their own channel (own gain, settings 干员语音) through
|
||||
// VoiceGate: one line at a time, a global gap, a per-unit per-slot cooldown, and a higher-priority line taking the
|
||||
// channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions` (a tap's 选中 line skips
|
||||
// the gap: VOICE_TAP_SLOTS).
|
||||
// the gap: VOICE_TAP_SLOTS). Two dubs: 中文 `audio.voice` (default) and 日本語 `audio.voiceJp` (settings 语音语言, not
|
||||
// tied to the interface language; the owner's request of 2026-10-08 「全套的日配语音」) — a JP line the manifest or the
|
||||
// host lacks falls back to the Chinese one (voiceLine).
|
||||
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most
|
||||
// MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a
|
||||
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
|
||||
@@ -281,6 +284,34 @@ export const VOICE_COOLDOWN_MS = Object.freeze({
|
||||
*/
|
||||
export const VOICE_TAP_SLOTS = Object.freeze(['select']);
|
||||
|
||||
/**
|
||||
* The line an operator says for a slot, in the chosen dub (settings 语音语言, ui/gameLogic/settings.js VOICE_LANGS):
|
||||
* { url, fallback }, or null for an operator no dub voices (the 预备干员 / 原型干员 stand-ins, 盟约·辅助干员, summons)
|
||||
* or a slot it lacks. 'cn' draws from `audio.voice`; 'jp' from
|
||||
* `audio.voiceJp`, the same slots and file names in the Japanese dub, and falls back to the Chinese line twice over: per
|
||||
* slot when the JP tree lacks it (a file the fetch could not get is left out of the manifest), and per line at play time
|
||||
* — `fallback` is the Chinese file of the same name, played when the host does not have the JP one (a full zip built
|
||||
* without the JP dub, before setup downloaded it). A slot with several lines draws one (`random` ∈ [0, 1)).
|
||||
* @param {any} audio the manifest's `audio`
|
||||
* @param {string} charId
|
||||
* @param {string} slot
|
||||
* @param {'cn'|'jp'|string} [lang]
|
||||
* @param {() => number} [random]
|
||||
* @returns {{ url: string, fallback: string|null } | null}
|
||||
*/
|
||||
export function voiceLine(audio, charId, slot, lang = 'cn', random = Math.random) {
|
||||
const lines = (line) => (Array.isArray(line) ? line : [line]).filter((u) => typeof u === 'string' && u);
|
||||
const draw = (list) => (list.length ? list[Math.min(list.length - 1, Math.floor(random() * list.length))] : null);
|
||||
const cn = lines(audio?.voice?.[charId]?.[slot]);
|
||||
const jp = lang === 'jp' ? draw(lines(audio?.voiceJp?.[charId]?.[slot])) : null;
|
||||
if (jp) {
|
||||
const file = (u) => u.slice(u.lastIndexOf('/') + 1);
|
||||
return { url: jp, fallback: cn.find((u) => file(u) === file(jp)) ?? draw(cn) };
|
||||
}
|
||||
const url = draw(cn);
|
||||
return url ? { url, fallback: null } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settlement slot of a finished 作战: 完美作战 ⇒ 3星结束行动 (绝境 / 终极 ⇒ 完成高难行动 instead), a leaked enemy
|
||||
* ⇒ 非3星结束行动, nothing killed at all ⇒ 行动失败.
|
||||
@@ -475,6 +506,7 @@ export class AudioManager {
|
||||
this.voiceGate = new VoiceGate();
|
||||
this.voiceNode = null; // { src, gain, url, token } of the line on air
|
||||
this.voiceToken = 0;
|
||||
this.voiceLang = 'cn'; // settings 语音语言: 'cn' (audio.voice) | 'jp' (audio.voiceJp, falling back to audio.voice)
|
||||
this.startVoiceDone = false; // 行动出发 of this field (the first operator deployed says it)
|
||||
this.uiVoices = 0;
|
||||
this.wantBgm = null; // desired key (kept while locked)
|
||||
@@ -614,6 +646,15 @@ export class AudioManager {
|
||||
this._applyVolumes();
|
||||
}
|
||||
|
||||
/**
|
||||
* The voice dub (settings 语音语言): 'jp' plays `audio.voiceJp`, anything else `audio.voice` (中文, the default). The
|
||||
* line on air finishes in its own dub; the next one follows the setting.
|
||||
* @param {string} lang
|
||||
*/
|
||||
setVoiceLang(lang) {
|
||||
this.voiceLang = lang === 'jp' ? 'jp' : 'cn';
|
||||
}
|
||||
|
||||
_applyVolumes() {
|
||||
if (!this.ctx) return;
|
||||
try {
|
||||
@@ -841,7 +882,8 @@ export class AudioManager {
|
||||
// ---- operator battle voice ----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Play an operator's battle line (`audio.voice[charId][slot]`; a slot with several lines draws one at random).
|
||||
* Play an operator's battle line (`audio.voice[charId][slot]`, or `audio.voiceJp` when the 语音语言 setting is 日本語 —
|
||||
* voiceLine; a slot with several lines draws one at random).
|
||||
* Every caller is a running battle's own event stream or its settlement, except 选中干员: the detail panel opening on
|
||||
* an operator the player tapped, in every phase (ui/detailPanel.js `voice`; the owner's request of 2026-10-08). The
|
||||
* line must pass VoiceGate: one at a time, a global gap, a per-unit cooldown, higher priority wins.
|
||||
@@ -855,31 +897,40 @@ export class AudioManager {
|
||||
try {
|
||||
if (!this.ctx || !this.voiceGain || this.volumes.muted || this.volumes.voice <= 0) return false;
|
||||
if (typeof charId !== 'string' || typeof slot !== 'string') return false;
|
||||
const line = this.getManifest()?.audio?.voice?.[charId]?.[slot];
|
||||
const url = Array.isArray(line) ? line[Math.floor(Math.random() * line.length)] : line;
|
||||
if (typeof url !== 'string' || !url) return false;
|
||||
// the chosen dub's line (settings 语音语言), with the Chinese file of the same name as its fallback (voiceLine)
|
||||
const line = voiceLine(this.getManifest()?.audio, charId, slot, this.voiceLang);
|
||||
if (!line) return false;
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const verdict = this.voiceGate.request(slot, o.unitKey ?? null, now);
|
||||
if (verdict === 'drop') return false;
|
||||
if (verdict === 'preempt') this._stopVoice();
|
||||
this.voiceGate.start(slot, o.unitKey ?? null, now);
|
||||
const token = ++this.voiceToken;
|
||||
this._playVoice(url, token, o.volume);
|
||||
this._playVoice(line.url, token, o.volume, line.fallback);
|
||||
return true;
|
||||
} catch (err) { this._warn('voice', err); return false; }
|
||||
}
|
||||
|
||||
/** Fetch/decode and start one voice line through the voice channel. */
|
||||
_playVoice(url, token, volume) {
|
||||
/**
|
||||
* Fetch/decode and start one voice line through the voice channel. `fallback`: the line to play instead when this one
|
||||
* cannot be loaded (a JP file the host lacks → the Chinese file of the same name; voiceLine) — same token, same gate.
|
||||
*/
|
||||
_playVoice(url, token, volume, fallback = null) {
|
||||
// `token` is the line's own `voiceToken`. Every deferred step below — the decode, a failed fetch, `onended` and the
|
||||
// safety timer — can land AFTER this line was taken over or stopped: `voiceToken` has moved on and the channel then
|
||||
// belongs to the line that replaced it. So each step re-checks its token and, when it is stale, touches NOTHING:
|
||||
// `_stopVoice` (takeover / stop) and `setFieldUnits` released the gate themselves. An unconditional release here let
|
||||
// a stale callback free the channel the NEW line had just taken, and the next line walked in on top of it (review
|
||||
// on #73).
|
||||
const failed = () => {
|
||||
if (token !== this.voiceToken) return; // taken over / stopped meanwhile: not ours to release
|
||||
if (fallback && fallback !== url && this.ctx && this.voiceGain) this._playVoice(fallback, token, volume);
|
||||
else this.voiceGate.release();
|
||||
};
|
||||
this._buffer(url).then((buf) => {
|
||||
if (token !== this.voiceToken) return; // taken over / stopped while it decoded: not ours to release
|
||||
if (!buf || !this.ctx || !this.voiceGain) { this.voiceGate.release(); return; }
|
||||
if (!buf) { failed(); return; }
|
||||
if (!this.ctx || !this.voiceGain) { this.voiceGate.release(); return; }
|
||||
try {
|
||||
const src = this.ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
@@ -907,7 +958,7 @@ export class AudioManager {
|
||||
this._warn('voice-play', err);
|
||||
if (token === this.voiceToken) this.voiceGate.release();
|
||||
}
|
||||
}, () => { if (token === this.voiceToken) this.voiceGate.release(); });
|
||||
}, failed);
|
||||
}
|
||||
|
||||
/** Fade the line on air out (a higher priority line is taking the channel over). */
|
||||
@@ -1058,13 +1109,13 @@ export const audio = new AudioManager({ getManifest: () => manifestGetter() });
|
||||
/**
|
||||
* Wire the singleton to the app (called once by main.js): manifest source, settings and store-driven BGM.
|
||||
* @param {{ getManifest: () => any, subscribe: (fn: (s:any, prev:any) => void) => () => void, getState: () => any,
|
||||
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, voice:number, muted:boolean } }} deps
|
||||
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, voice:number, muted:boolean, voiceLang?:string } }} deps
|
||||
*/
|
||||
export function installAudio(deps) {
|
||||
try {
|
||||
manifestGetter = typeof deps?.getManifest === 'function' ? deps.getManifest : manifestGetter;
|
||||
audio.install();
|
||||
if (deps?.settings) audio.setVolumes(deps.settings);
|
||||
if (deps?.settings) { audio.setVolumes(deps.settings); audio.setVoiceLang(deps.settings.voiceLang); }
|
||||
if (typeof deps?.subscribe === 'function' && typeof deps?.getState === 'function') {
|
||||
const sync = (s) => {
|
||||
try {
|
||||
|
||||
@@ -40,7 +40,7 @@ export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
|
||||
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
|
||||
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
|
||||
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows } from './gameLogic/shortcuts.js';
|
||||
export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js';
|
||||
export { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from './gameLogic/settings.js';
|
||||
export { normalizeResult } from './gameLogic/result.js';
|
||||
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
|
||||
export { standInIds, fieldsStandIn, standInOf, ownStandIn, cardStandIn, memberStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel, standInForText, standInTip } from './gameLogic/standIn.js';
|
||||
|
||||
@@ -6,14 +6,20 @@ import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js';
|
||||
|
||||
// ---- settings ------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). */
|
||||
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, muted: false, damageNumbers: true, quality: 'high', keys: DEFAULT_HOTKEYS });
|
||||
/**
|
||||
* The operator voice dubs (settings 语音语言): 'cn' 中文 (`audio.voice`, the default) and 'jp' 日本語 (`audio.voiceJp`,
|
||||
* falling back to the Chinese line it lacks — public/js/audio.js voiceLine). Not tied to the interface language.
|
||||
*/
|
||||
export const VOICE_LANGS = Object.freeze(['cn', 'jp']);
|
||||
|
||||
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS. */
|
||||
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', muted: false, damageNumbers: true, quality: 'high', keys: DEFAULT_HOTKEYS });
|
||||
const QUALITIES = ['high', 'medium', 'low'];
|
||||
|
||||
/**
|
||||
* Sanitize persisted settings.
|
||||
* @param {any} raw
|
||||
* @returns {{ bgm: number, sfx: number, voice: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
|
||||
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
|
||||
* keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
|
||||
*/
|
||||
export function sanitizeSettings(raw) {
|
||||
@@ -23,6 +29,7 @@ export function sanitizeSettings(raw) {
|
||||
bgm: vol(r.bgm, DEFAULT_SETTINGS.bgm),
|
||||
sfx: vol(r.sfx, DEFAULT_SETTINGS.sfx),
|
||||
voice: vol(r.voice, DEFAULT_SETTINGS.voice),
|
||||
voiceLang: VOICE_LANGS.includes(r.voiceLang) ? r.voiceLang : DEFAULT_SETTINGS.voiceLang,
|
||||
muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted,
|
||||
damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers,
|
||||
quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Player settings (BGM/SFX/voice volume, mute, damage numbers, render quality, the shortcut keys): a tiny observable
|
||||
// store persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
|
||||
// Player settings (BGM/SFX/voice volume, the voice dub 语音语言, mute, damage numbers, render quality, the shortcut keys): a
|
||||
// tiny observable store persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
|
||||
// the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`; under it a note
|
||||
// while the current language's pack is a machine translation, `_meta.machineTranslated`) and the 快捷键
|
||||
// section that rebinds the in-match shortcuts (the key map: ui/gameLogic/shortcuts.js; the community request
|
||||
@@ -8,7 +8,7 @@
|
||||
import { useLayoutEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
|
||||
import { createStore, useStore, loadPref, savePref } from '../store.js';
|
||||
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey } from './gameLogic.js';
|
||||
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey, VOICE_LANGS } from './gameLogic.js';
|
||||
import { audio } from '../audio.js';
|
||||
import { openGuide } from './guide.js';
|
||||
import { detectFeatures } from './device.js';
|
||||
@@ -17,14 +17,16 @@ import { t, tc, N_ } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
/** Settings store: { bgm, sfx, voice, muted, damageNumbers, quality, keys }. */
|
||||
/** Settings store: { bgm, sfx, voice, voiceLang, muted, damageNumbers, quality, keys }. */
|
||||
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
|
||||
|
||||
settingsStore.subscribe((s) => {
|
||||
savePref('settings', sanitizeSettings(s));
|
||||
audio.setVolumes(s);
|
||||
audio.setVoiceLang(s.voiceLang);
|
||||
});
|
||||
audio.setVolumes(settingsStore.get());
|
||||
audio.setVoiceLang(settingsStore.get().voiceLang);
|
||||
|
||||
/** @param {Partial<ReturnType<typeof sanitizeSettings>>} patch */
|
||||
export function updateSettings(patch) {
|
||||
@@ -60,6 +62,11 @@ function Toggle({ label, micro, value, onChange }) {
|
||||
}
|
||||
|
||||
const QUALITY = [['high', N_('高')], ['medium', N_('中')], ['low', N_('低')]];
|
||||
/**
|
||||
* 语音语言: each dub named in its own language, like the interface language switch (ui/lang.js) — the owner's
|
||||
* 「中文 / 日本語」 (2026-10-08); VOICE_LANGS order.
|
||||
*/
|
||||
const VOICE_LANG_NAMES = { cn: '中文', jp: '日本語' }; // i18n-ignore
|
||||
/** The rebindable shortcuts' names (msgids), by action. */
|
||||
const HOTKEY_NAMES = { refresh: N_('刷新商店'), freeze: N_('冻结 / 解冻商店'), levelUp: N_('升级调度中心'), retreat: N_('撤退选中干员'),
|
||||
sell: N_('出售选中干员'), ready: N_('准备就绪 / 暂停(独立模拟)') };
|
||||
@@ -164,6 +171,13 @@ export function SettingsModal({ open, onClose }) {
|
||||
${mtNote ? html`<p class="set-hint set-lang-note" data-testid="lang-mt-note">${mtNote}</p>` : null}
|
||||
<${Slider} label=${t('背景音乐')} micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
|
||||
<${Slider} label=${t('干员语音')} micro="VOICE" icon="mic" value=${s.voice} onInput=${(v) => updateSettings({ voice: v })} />
|
||||
<div class="set-row">
|
||||
<span class="set-row__label">${t('语音语言')}<${MicroLabel}>VOICE LANGUAGE<//></span>
|
||||
<div class="set-seg" role="radiogroup" aria-label=${t('语音语言')} data-testid="voice-lang">
|
||||
${VOICE_LANGS.map((id) => html`<button key=${id} type="button" role="radio" aria-checked=${s.voiceLang === id ? 'true' : 'false'}
|
||||
lang=${id === 'jp' ? 'ja' : 'zh'} class=${s.voiceLang === id ? 'is-on' : ''} onClick=${() => updateSettings({ voiceLang: id })}>${VOICE_LANG_NAMES[id]}</button>`)}
|
||||
</div>
|
||||
</div>
|
||||
<${Slider} label=${t('音效')} micro="SFX" icon="signal" value=${s.sfx}
|
||||
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
|
||||
<${Toggle} label=${t('静音')} micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
|
||||
|
||||
@@ -909,6 +909,13 @@ test('干员战斗语音 (DESIGN §21.30): the manifest data, the official prior
|
||||
assert.match(DESIGN, /\*\*Where each line plays — battle only\.\*\*/);
|
||||
assert.match(doc('docs/ASSETS.md'), /\| 干员战斗语音 \|/);
|
||||
assert.match(doc('docs/ASSETS.md'), /voiceChars/);
|
||||
// 0.2.2: the Japanese dub beside the Chinese one (audio.voiceJp, settings 语音语言, the full zip's switch)
|
||||
assert.equal(Object.keys(manifest.audio?.voiceJp ?? {}).length, charIds.length, 'every voiced operator has its JP tree');
|
||||
assert.equal(manifest.stats.voiceJpChars, charIds.length);
|
||||
assert.match(doc('docs/ASSETS.md'), /voiceJp: \{ \[charId\]: \{ …the slots of `voice` \} \}/);
|
||||
assert.match(doc('docs/DEPLOY.md'), /`FULL_ZIP_JP_VOICE` 改成 `false` 时/);
|
||||
assert.match(PLAYING, /「语音语言」选 \*\*中文 \/ 日本語\*\*(默认中文,和界面语言无关/);
|
||||
assert.match(PLAYING, /会说一句官方的「选中干员」语音,休整期也一样/);
|
||||
assert.match(SIM, /\['engage', id\]/);
|
||||
// the code: every slot the client asks for comes from a running battle's own stream — the three prep-only lines
|
||||
// (干员报到 / 编入队伍 / 任命队长) are never requested — except 选中干员, the detail panel's line on every tap of an
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// test/feedback7-voices.test.js — the Japanese voice dub beside the Chinese one (0.2.2; the owner's request of 2026-10-08
|
||||
// 「全套的日配语音」): tools/assets/plan.mjs plans `audio.voiceJp` — the same slots and file names from the
|
||||
// ArknightsAssets2 `voice/` folder (the JP dub), under audio/voice/jp/ — the committed data/assets.json lists it for every
|
||||
// operator the Chinese tree has (checked against public/assets when present), the settings carry 语音语言 (中文 by
|
||||
// default, the four language packs translate the row), and tools/package.mjs FULL_ZIP_JP_VOICE decides whether the full
|
||||
// zip ships the JP files (default: yes) — held back, they are neither missing nor deleted by an update.
|
||||
// The client's choice of line and its fallback: test/ui/audio.test.js (voiceLine).
|
||||
// Run: node --test test/feedback7-voices.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildPlan, VOICE_JP_LANG } from '../tools/assets/plan.mjs';
|
||||
import { indexAudio, VOICE_DIRS } from '../tools/assets/audio.mjs';
|
||||
import { RAW } from '../tools/assets/sources.mjs';
|
||||
import { artPlan, FULL_ZIP_JP_VOICE, JP_VOICE_DIR } from '../tools/package.mjs';
|
||||
import { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from '../public/js/ui/gameLogic.js';
|
||||
|
||||
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const readJson = (rel) => JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'));
|
||||
|
||||
/** A charword_table.json with two operators: one the game fields (planned), one it does not (a 预备干员: no voice). */
|
||||
const CHARWORD = {
|
||||
charWords: {
|
||||
a: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'BATTLE_SELECT', voiceId: 'CN_021', voiceIndex: 21, voiceAsset: 'char_263_skadi/CN_021' },
|
||||
b: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'BATTLE_SELECT', voiceId: 'CN_022', voiceIndex: 22, voiceAsset: 'char_263_skadi/CN_022' },
|
||||
c: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'BATTLE_START', voiceId: 'CN_019', voiceIndex: 19, voiceAsset: 'char_263_skadi/CN_019' },
|
||||
d: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'GACHA', voiceId: 'CN_011', voiceIndex: 11, voiceAsset: 'char_263_skadi/CN_011' },
|
||||
e: { charId: 'char_602_cdfend', wordKey: 'char_602_cdfend', placeType: 'BATTLE_SELECT', voiceId: 'CN_021', voiceIndex: 21, voiceAsset: 'char_602_cdfend/CN_021' },
|
||||
},
|
||||
};
|
||||
const OPS = { char_263_skadi: { name: '斯卡蒂', skills: [] } };
|
||||
const plan = (extra = {}) => buildPlan({ assets07: {}, ops03: {}, enemies05: {}, maps05: {}, audio: indexAudio({}), modelsData: {},
|
||||
charword: CHARWORD, extraOperators: OPS, ...extra }).template;
|
||||
|
||||
test('plan: audio.voiceJp is the JP dub (ArknightsAssets2 voice/) of the very slots and file names of audio.voice, under audio/voice/jp/', () => {
|
||||
assert.equal(VOICE_JP_LANG, 'jp');
|
||||
assert.equal(VOICE_DIRS.jp, 'voice', 'the dump folder of the JP dub');
|
||||
const t = plan();
|
||||
const cn = t.audio.voice.char_263_skadi;
|
||||
const jp = t.audio.voiceJp.char_263_skadi;
|
||||
assert.deepEqual(Object.keys(jp).sort(), ['select', 'start'], 'the battle slots only, like the Chinese tree (干员报到 not planned)');
|
||||
assert.deepEqual(Object.keys(jp).sort(), Object.keys(cn).sort());
|
||||
assert.equal(jp.select.length, 2, 'one leaf per line: 选中干员1 / 2');
|
||||
assert.deepEqual(jp.select.map((l) => l.alts[0].rel), ['audio/voice/jp/char_263_skadi/cn_021.mp3', 'audio/voice/jp/char_263_skadi/cn_022.mp3']);
|
||||
assert.deepEqual(jp.select[0].alts[0].urls, [`${RAW.aa2voice}voice/char_263_skadi/cn_021.mp3`]);
|
||||
assert.deepEqual(cn.select.map((l) => l.alts[0].rel), ['audio/voice/cn/char_263_skadi/cn_021.mp3', 'audio/voice/cn/char_263_skadi/cn_022.mp3']);
|
||||
assert.deepEqual(cn.select[0].alts[0].urls, [`${RAW.aa2voice}voice_cn/char_263_skadi/cn_021.mp3`]);
|
||||
assert.equal(jp.start.alts[0].rel, 'audio/voice/jp/char_263_skadi/cn_019.mp3');
|
||||
assert.equal(t.audio.voiceJp.char_602_cdfend, undefined, 'an operator the game does not field (a stand-in) gets neither tree');
|
||||
assert.equal(t.audio.voice.char_602_cdfend, undefined);
|
||||
assert.deepEqual(Object.keys(t.audio), ['bgm', 'bossBgm', 'voice', 'voiceJp', 'sfx'], 'voiceJp right after voice');
|
||||
// --voice-lang puts another dub in audio.voice; voiceJp stays the JP one; voiceJp: false plans no JP tree
|
||||
const en = plan({ voiceLang: 'en' });
|
||||
assert.equal(en.audio.voice.char_263_skadi.start.alts[0].rel, 'audio/voice/en/char_263_skadi/cn_019.mp3');
|
||||
assert.equal(en.audio.voiceJp.char_263_skadi.start.alts[0].rel, 'audio/voice/jp/char_263_skadi/cn_019.mp3');
|
||||
assert.equal(plan({ voiceJp: false }).audio.voiceJp, undefined);
|
||||
// --voice-all widens both trees alike
|
||||
assert.ok(plan({ voiceSlots: null }).audio.voiceJp.char_263_skadi.gacha);
|
||||
});
|
||||
|
||||
test('data/assets.json: voiceJp gives every voiced operator the JP twin of each Chinese line (191 operators, 2674 files); stand-ins, 盟约·辅助干员 and summons have neither', () => {
|
||||
const m = readJson('data/assets.json');
|
||||
const cn = m.audio.voice;
|
||||
const jp = m.audio.voiceJp;
|
||||
assert.ok(jp && typeof jp === 'object', 'the manifest carries the JP tree');
|
||||
assert.deepEqual(Object.keys(jp), Object.keys(cn), 'the same operators');
|
||||
assert.equal(m.stats.voiceJpChars, Object.keys(jp).length);
|
||||
assert.equal(m.stats.voiceChars, Object.keys(cn).length);
|
||||
assert.equal(Object.keys(jp).length, 191);
|
||||
// the JP tree is the Chinese one with the folder swapped: same slots, same lines in the same order, same file names
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(cn).replaceAll('/assets/audio/voice/cn/', '/assets/audio/voice/jp/')), jp);
|
||||
const lines = [];
|
||||
const walk = (x) => { if (typeof x === 'string') lines.push(x); else if (Array.isArray(x)) x.forEach(walk); else if (x && typeof x === 'object') Object.values(x).forEach(walk); };
|
||||
walk(jp);
|
||||
assert.equal(lines.length, 2674);
|
||||
for (const u of lines) assert.match(u, /^\/assets\/audio\/voice\/jp\/char_[^/]+\/cn_\d+\.mp3$/);
|
||||
// no voice: the 17 stand-ins of data/backups.json (预备干员 / 原型干员), the mode's 盟约·辅助干员, every summon
|
||||
const backups = readJson('data/backups.json');
|
||||
const standIns = Object.entries(backups.units).filter(([, u]) => Array.isArray(u.standsIn) ? u.standsIn.length : u.standsIn).map(([id]) => id);
|
||||
assert.equal(standIns.length, 17);
|
||||
for (const id of [...standIns, 'char_616_pithst', ...Object.keys(readJson('data/tokens.json'))]) {
|
||||
assert.equal(jp[id], undefined, `${id}: no JP line`);
|
||||
assert.equal(cn[id], undefined, `${id}: no line`);
|
||||
}
|
||||
// the files themselves, where this checkout has downloaded the art
|
||||
const dir = path.join(ROOT, 'public', 'assets', 'audio', 'voice', 'jp');
|
||||
if (fs.existsSync(dir)) for (const u of lines) assert.ok(fs.statSync(path.join(ROOT, 'public', u)).size > 0, u);
|
||||
});
|
||||
|
||||
test('settings 语音语言: 中文 by default, 日本語 kept, nothing else; the row is translated in every language pack', () => {
|
||||
assert.deepEqual([...VOICE_LANGS], ['cn', 'jp']);
|
||||
assert.equal(DEFAULT_SETTINGS.voiceLang, 'cn', 'not tied to the interface language: 中文 until the player picks 日本語');
|
||||
assert.equal(sanitizeSettings({}).voiceLang, 'cn');
|
||||
assert.equal(sanitizeSettings({ voiceLang: 'jp' }).voiceLang, 'jp');
|
||||
assert.equal(sanitizeSettings({ voiceLang: 'en' }).voiceLang, 'cn', 'no English / Korean dub');
|
||||
const ui = fs.readFileSync(path.join(ROOT, 'public/js/ui/settings.js'), 'utf8');
|
||||
assert.match(ui, /updateSettings\(\{ voiceLang: id \}\)/);
|
||||
assert.match(ui, /const VOICE_LANG_NAMES = \{ cn: '中文', jp: '日本語' \};/, 'each dub named in its own language');
|
||||
for (const code of ['en', 'ja', 'ko', 'zh-TW']) {
|
||||
const pack = readJson(`public/i18n/${code}.json`);
|
||||
assert.ok(typeof pack['语音语言'] === 'string' && pack['语音语言'] && pack['语音语言'] !== '语音语言', `${code}: 语音语言`);
|
||||
}
|
||||
});
|
||||
|
||||
test('package: the full zip ships the JP dub by default; FULL_ZIP_JP_VOICE off holds it back (not missing, not an orphan, never removed by an update); the lite zip has no art', () => {
|
||||
assert.equal(FULL_ZIP_JP_VOICE, true, '0.2.2 ships both dubs in the full zip');
|
||||
assert.equal(JP_VOICE_DIR, 'public/assets/audio/voice/jp/');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-voicejp-'));
|
||||
try {
|
||||
const cnRel = 'public/assets/audio/voice/cn/char_263_skadi/cn_021.mp3';
|
||||
const jpRel = 'public/assets/audio/voice/jp/char_263_skadi/cn_021.mp3';
|
||||
const jpRel2 = 'public/assets/audio/voice/jp/char_263_skadi/cn_022.mp3';
|
||||
for (const rel of [cnRel, jpRel]) { fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); fs.writeFileSync(path.join(dir, rel), 'ID3'); }
|
||||
fs.mkdirSync(path.join(dir, 'data'));
|
||||
const url = (rel) => `/${rel.slice('public/'.length)}`;
|
||||
fs.writeFileSync(path.join(dir, 'data', 'assets.json'), JSON.stringify({ audio: {
|
||||
voice: { char_263_skadi: { select: url(cnRel) } }, voiceJp: { char_263_skadi: { select: [url(jpRel), url(jpRel2)] } } } }));
|
||||
const on = artPlan(dir);
|
||||
assert.ok(on.files.includes(cnRel) && on.files.includes(jpRel), 'both dubs ship');
|
||||
assert.deepEqual(on.missing, [jpRel2], 'a listed JP file not on disk is missing, like any art');
|
||||
assert.deepEqual(on.held, []);
|
||||
const off = artPlan(dir, { jpVoice: false });
|
||||
assert.ok(off.files.includes(cnRel), 'the Chinese dub ships');
|
||||
assert.ok(!off.files.includes(jpRel), 'the JP files stay out of the full zip');
|
||||
assert.deepEqual(off.held, [jpRel, jpRel2], 'held back on purpose (setup downloads them; an update never deletes them)');
|
||||
assert.deepEqual(off.missing, [], 'not missing');
|
||||
assert.deepEqual(off.orphans, [], 'not orphans either');
|
||||
assert.deepEqual(artPlan(dir, { lite: true }).files, [], 'the lite zip carries no art');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
// the update zip keeps a held file out of `removed` (tools/package.mjs buildUpdate)
|
||||
const src = fs.readFileSync(path.join(ROOT, 'tools/package.mjs'), 'utf8');
|
||||
assert.match(src, /removable: \(rel\) => !removalProblem\(rel\) && !heldBack\.has\(rel\)/);
|
||||
});
|
||||
+64
-1
@@ -6,7 +6,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager, normalAttackSfx, installAudio, audio, combatTrackFor, COMBAT_TRACK_SWITCH_ROUND, VoiceGate, resultSpeaker, resultVoiceSlot, VOICE_PRIORITY, VOICE_COOLDOWN_MS } from '../../public/js/audio.js';
|
||||
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager, normalAttackSfx, installAudio, audio, combatTrackFor, COMBAT_TRACK_SWITCH_ROUND, VoiceGate, resultSpeaker, resultVoiceSlot, VOICE_PRIORITY, VOICE_COOLDOWN_MS, voiceLine } from '../../public/js/audio.js';
|
||||
import { mediaUrl } from '../../public/js/media.js';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
|
||||
@@ -300,6 +300,69 @@ describe('operator battle voice', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('语音语言 (0.2.2): 日本語 plays audio.voiceJp — the same slots and file names — and falls back to the Chinese line per slot and per line', async () => {
|
||||
const audioM = {
|
||||
voice: { char_a: { select: ['/a/voice/cn/char_a/cn_021.mp3', '/a/voice/cn/char_a/cn_022.mp3'], place: '/a/voice/cn/char_a/cn_023.mp3', start: '/a/voice/cn/char_a/cn_019.mp3' } },
|
||||
voiceJp: { char_a: { select: ['/a/voice/jp/char_a/cn_021.mp3', '/a/voice/jp/char_a/cn_022.mp3'], place: '/a/voice/jp/char_a/cn_023.mp3' } },
|
||||
};
|
||||
assert.deepEqual(voiceLine(audioM, 'char_a', 'place'), { url: '/a/voice/cn/char_a/cn_023.mp3', fallback: null }, '中文 by default');
|
||||
assert.deepEqual(voiceLine(audioM, 'char_a', 'place', 'cn'), { url: '/a/voice/cn/char_a/cn_023.mp3', fallback: null });
|
||||
assert.deepEqual(voiceLine(audioM, 'char_a', 'place', 'jp'), { url: '/a/voice/jp/char_a/cn_023.mp3', fallback: '/a/voice/cn/char_a/cn_023.mp3' });
|
||||
// a drawn line keeps its Chinese twin as the fallback (选中干员2 ⇒ 选中干员2)
|
||||
assert.deepEqual(voiceLine(audioM, 'char_a', 'select', 'jp', () => 0.99), { url: '/a/voice/jp/char_a/cn_022.mp3', fallback: '/a/voice/cn/char_a/cn_022.mp3' });
|
||||
assert.deepEqual(voiceLine(audioM, 'char_a', 'select', 'jp', () => 0), { url: '/a/voice/jp/char_a/cn_021.mp3', fallback: '/a/voice/cn/char_a/cn_021.mp3' });
|
||||
assert.deepEqual(voiceLine(audioM, 'char_a', 'start', 'jp'), { url: '/a/voice/cn/char_a/cn_019.mp3', fallback: null }, 'a slot the JP tree lacks: the Chinese line');
|
||||
assert.equal(voiceLine(audioM, 'char_zz', 'select', 'jp'), null, 'an operator no dub voices (stand-ins, 盟约·辅助干员, summons) stays silent');
|
||||
assert.equal(voiceLine({ voice: {} }, 'char_a', 'select', 'jp'), null);
|
||||
assert.equal(voiceLine(null, 'char_a', 'select', 'jp'), null);
|
||||
assert.deepEqual(voiceLine({ voice: audioM.voice }, 'char_a', 'place', 'jp'), { url: '/a/voice/cn/char_a/cn_023.mp3', fallback: null }, 'a manifest without voiceJp');
|
||||
// the real manifest: every operator with a Chinese line has its Japanese twin
|
||||
const real = voiceLine(manifest.audio, 'char_263_skadi', 'select', 'jp', () => 0);
|
||||
assert.match(real.url, /^\/assets\/audio\/voice\/jp\/char_263_skadi\/cn_021\.mp3$/);
|
||||
assert.equal(real.fallback, '/assets/audio/voice/cn/char_263_skadi/cn_021.mp3');
|
||||
|
||||
// the manager: the setting picks the tree; a JP file the host lacks (404) plays the Chinese one, holding the channel
|
||||
const fw = fakeWindow();
|
||||
const origFetch = globalThis.fetch;
|
||||
const urls = [];
|
||||
const missing = new Set(['/a/voice/jp/char_a/cn_023.mp3', mediaUrl('/a/voice/jp/char_a/cn_023.mp3')]);
|
||||
globalThis.fetch = async (u) => { urls.push(u); return missing.has(u) ? { ok: false, status: 404 } : { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
const tick = () => new Promise((r) => setTimeout(r, 10));
|
||||
try {
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => ({ audio: { sfx: { ui: {}, battle: {}, units: {} }, ...audioM } }) });
|
||||
a.voiceGate = new VoiceGate({ gapMs: 0 }); // the gap itself is covered above
|
||||
a.install();
|
||||
fw.fire('pointerdown');
|
||||
await tick();
|
||||
assert.equal(a.voiceLang, 'cn');
|
||||
a.setVoiceLang('jp');
|
||||
assert.equal(a.voiceLang, 'jp');
|
||||
assert.equal(a.voice('char_a', 'select'), true);
|
||||
await tick();
|
||||
assert.match(a.voiceNode?.url ?? '', /^\/a\/voice\/jp\/char_a\/cn_02[12]\.mp3$/, '日本語: the JP line');
|
||||
a._stopVoice();
|
||||
assert.equal(a.voice('char_a', 'place', { unitKey: 1 }), true);
|
||||
await tick(); await tick();
|
||||
assert.ok(asked(urls, '/a/voice/jp/char_a/cn_023.mp3'), 'the JP file was asked for first');
|
||||
assert.equal(a.voiceNode?.url, '/a/voice/cn/char_a/cn_023.mp3', 'the host lacks it: the Chinese line of the same name plays');
|
||||
assert.equal(a.voice('char_a', 'place', { unitKey: 2 }), false, 'the fallback holds the channel like any line');
|
||||
a._stopVoice();
|
||||
a.setVoiceLang('cn');
|
||||
assert.equal(a.voice('char_a', 'select'), true);
|
||||
await tick();
|
||||
assert.match(a.voiceNode?.url ?? '', /^\/a\/voice\/cn\/char_a\/cn_02[12]\.mp3$/, '中文 again');
|
||||
a._stopVoice();
|
||||
a.setVoiceLang('kr');
|
||||
assert.equal(a.voiceLang, 'cn', 'no other dub: anything but jp is 中文');
|
||||
// the settings store hands the choice over (installAudio and ui/settings.js)
|
||||
const settings = readFileSync(path.join(ROOT, 'public/js/ui/settings.js'), 'utf8');
|
||||
assert.match(settings, /audio\.setVoiceLang\(s\.voiceLang\)/);
|
||||
assert.match(settings, /t\('语音语言'\)/);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('AudioManager.voice: manifest slots (a drawn array), the gate, and the battle events that drive them', async () => {
|
||||
const fw = fakeWindow();
|
||||
const origFetch = globalThis.fetch;
|
||||
|
||||
@@ -642,9 +642,14 @@ describe('keyboard & settings', () => {
|
||||
test('sanitizeSettings', () => {
|
||||
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
|
||||
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
|
||||
{ bgm: 1, sfx: 0, voice: 1, muted: false, damageNumbers: false, quality: 'high', keys: { ...DEFAULT_SETTINGS.keys } },
|
||||
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', muted: false, damageNumbers: false, quality: 'high', keys: { ...DEFAULT_SETTINGS.keys } },
|
||||
'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)');
|
||||
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
|
||||
// 语音语言 (0.2.2): 中文 by default — a profile saved before it, or any other value, plays the Chinese dub
|
||||
assert.equal(DEFAULT_SETTINGS.voiceLang, 'cn');
|
||||
assert.equal(sanitizeSettings({ bgm: 0.5 }).voiceLang, 'cn');
|
||||
assert.equal(sanitizeSettings({ voiceLang: 'jp' }).voiceLang, 'jp');
|
||||
for (const bad of ['en', 'kr', 'JP', 'ja', 1, null]) assert.equal(sanitizeSettings({ voiceLang: bad }).voiceLang, 'cn', String(bad));
|
||||
assert.equal(sanitizeSettings({ bgm: 0.333 }).bgm, 0.33);
|
||||
assert.equal(sanitizeSettings({ quality: 'low' }).quality, 'low');
|
||||
});
|
||||
|
||||
+37
-18
@@ -29,10 +29,19 @@ import { EMOTE_CATALOG } from '../../shared/constants.js';
|
||||
|
||||
/**
|
||||
* The voice slots are read from the zh_CN charword table, whose voiceIds are always `CN_*`: the other dubs
|
||||
* (`--voice-lang=jp|en|kr`) share the very same slot numbering and file names, only the dump folder differs.
|
||||
* (`--voice-lang=jp|en|kr`, the JP tree `audio.voiceJp`) share the very same slot numbering and file names, only the
|
||||
* dump folder differs.
|
||||
*/
|
||||
const VOICE_ID_LANG = 'CN';
|
||||
|
||||
/**
|
||||
* The second voice tree, `audio.voiceJp`: the Japanese dub (ArknightsAssets2 `voice/`, audio.mjs VOICE_DIRS.jp) of the
|
||||
* same slots and lines as `audio.voice`, under `audio/voice/jp/`. The client plays it when the player picks 日本語
|
||||
* (settings 语音语言) and falls back to `audio.voice` for a line it lacks (public/js/audio.js voiceLine). The owner's
|
||||
* request of 2026-10-08 「全套的日配语音」: the JP dub of every line the game plays, beside the Chinese one.
|
||||
*/
|
||||
export const VOICE_JP_LANG = 'jp';
|
||||
|
||||
/**
|
||||
* Enemies whose Spine no community dump carries: the web model is another enemy's (research 07 §5.6). Their official
|
||||
* models come from the local client only (tools/local-extract ENEMY_SPINES): `localEnemySpines` adds them as the
|
||||
@@ -294,8 +303,10 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
|
||||
* @param {any} p.maps05 docs/research/05-maps.json
|
||||
* @param {ReturnType<import('./audio.mjs').indexAudio>} p.audio indexed audio_data.json
|
||||
* @param {any} p.modelsData Ark-Models models_data.json
|
||||
* @param {any} [p.charword] parsed excel/charword_table.json — the operators' official voice slots (voice)
|
||||
* @param {string} [p.voiceLang] voice dump to plan: cn (default) | jp | en | kr
|
||||
* @param {any} [p.charword] parsed excel/charword_table.json — the operators' official voice slots (voice, voiceJp)
|
||||
* @param {string} [p.voiceLang] voice dump of `audio.voice` (the 中文 setting): cn (default) | jp | en | kr — `audio.voiceJp`
|
||||
* is the JP dub (`voice/`) whatever it is
|
||||
* @param {boolean} [p.voiceJp] plan the JP tree `audio.voiceJp` (default true)
|
||||
* @param {Iterable<string>|null} [p.voiceSlots] which voice slots to plan: VOICE_BATTLE_SLOTS (default) plans only the
|
||||
* lines a battle can play, null plans every slot of audio.mjs VOICE_SLOTS (`--voice-all`). The prep-only slots
|
||||
* (干员报到 / 编入队伍 / 任命队长) are never requested by the client and cost 360 files / 19.3 MB of downloads.
|
||||
@@ -316,7 +327,7 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
|
||||
* @returns {{ template: any, models: Map<string, any>, notes: string[] }}
|
||||
*/
|
||||
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, charword = null, voiceLang = 'cn',
|
||||
voiceSlots = VOICE_BATTLE_SLOTS,
|
||||
voiceJp = true, voiceSlots = VOICE_BATTLE_SLOTS,
|
||||
extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {}, localTokenSpines = {}, extraOperators = {},
|
||||
moduleTypes = [] }) {
|
||||
const notes = [];
|
||||
@@ -612,8 +623,8 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
|
||||
modules[t] = leaf(alt(`module/${safeName(t).toLowerCase()}.png`, [...new Set([t, t.toLowerCase()])].map((n) => joinUrl(RAW.aa2, `arts/ui/uniequiptype/${n}.png`))));
|
||||
}
|
||||
|
||||
// --- 干员战斗语音 (excel/charword_table.json → audio.voice) ---------------------------------------------
|
||||
// The official lines of every operator the mode can field, for the slots a battle can actually play: 行动出发 start /
|
||||
// --- 干员战斗语音 (excel/charword_table.json → audio.voice, audio.voiceJp) ---------------------------------
|
||||
// The official lines of every operator the mode can field, for the slots the client can actually play: 行动出发 start /
|
||||
// 行动开始 faceEnemy / 选中干员 select / 部署 place / 作战中1-4 skillN / 结算 result* (charword `placeType`,
|
||||
// audio.mjs VOICE_BATTLE_SLOTS). A slot with several lines stays an array — the client draws one at random
|
||||
// (public/js/audio.js voice). Operators without official battle voice keep no entry at all: the 17 预备干员
|
||||
@@ -621,19 +632,26 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
|
||||
// The three prep-only slots (干员报到 gacha / 编入队伍 squad / 任命队长 squadFirst) are NOT planned by default: the
|
||||
// client never requests them, and downloading them adds 360 files / 19.3 MB to every `npm run assets` — pass --voice-all for
|
||||
// the complete official set (`voiceSlots: null`, reviewer note on the voice PR).
|
||||
const voice = {};
|
||||
for (const [charId, slots] of indexVoice(charword, VOICE_ID_LANG, voiceSlots)) {
|
||||
if (!chars[charId]) continue; // only the operators this game can field (`chars`: the 138 pool charIds and the 自选 picks)
|
||||
const v = {};
|
||||
for (const [slot, assets] of Object.entries(slots)) {
|
||||
// one leaf per line (部署1 / 部署2 …): an array stays an array so the client can draw one — chaining them as
|
||||
// alternatives of a single leaf would keep only the first line that landed on disk.
|
||||
const lines = assets.map((a) => leaf(voiceAlt(a, voiceLang))).filter(Boolean);
|
||||
if (!lines.length) continue;
|
||||
v[slot] = lines.length === 1 ? lines[0] : lines;
|
||||
// `audio.voiceJp` (VOICE_JP_LANG): the same slots and lines in the Japanese dub, a second tree beside the Chinese one
|
||||
// (the owner's request of 2026-10-08) — same file names, `audio/voice/jp/`.
|
||||
const voiceSlotsByChar = indexVoice(charword, VOICE_ID_LANG, voiceSlots);
|
||||
const voiceTree = (lang) => {
|
||||
const tree = {};
|
||||
for (const [charId, slots] of voiceSlotsByChar) {
|
||||
if (!chars[charId]) continue; // only the operators this game can field (`chars`: the 138 pool charIds and the 自选 picks)
|
||||
const v = {};
|
||||
for (const [slot, assets] of Object.entries(slots)) {
|
||||
// one leaf per line (部署1 / 部署2 …): an array stays an array so the client can draw one — chaining them as
|
||||
// alternatives of a single leaf would keep only the first line that landed on disk.
|
||||
const lines = assets.map((a) => leaf(voiceAlt(a, lang))).filter(Boolean);
|
||||
if (!lines.length) continue;
|
||||
v[slot] = lines.length === 1 ? lines[0] : lines;
|
||||
}
|
||||
if (Object.keys(v).length) tree[charId] = v;
|
||||
}
|
||||
if (Object.keys(v).length) voice[charId] = v;
|
||||
}
|
||||
return tree;
|
||||
};
|
||||
const voice = voiceTree(voiceLang);
|
||||
if (!Object.keys(voice).length) notes.push('battle voice: charword_table.json has no slots (index missing?)');
|
||||
|
||||
const template = {
|
||||
@@ -642,6 +660,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
|
||||
bgm,
|
||||
bossBgm: Object.fromEntries(Object.entries(bossBgm).sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true }))),
|
||||
voice,
|
||||
...(voiceJp ? { voiceJp: voiceTree(VOICE_JP_LANG) } : {}),
|
||||
sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -92,7 +92,8 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
|
||||
--offline no network: post-process what is on disk and rebuild data/assets.json
|
||||
--dry-run print the plan and exit
|
||||
--refresh-index re-download the audio_data.json / charword_table.json / models_data.json indexes
|
||||
--voice-lang=cn operator battle voice language: cn (default) | jp | en | kr
|
||||
--voice-lang=cn the dub of audio.voice (the 中文 voice setting): cn (default) | jp | en | kr
|
||||
(audio.voiceJp, the 日本語 setting, is always the JP dub: both trees are planned)
|
||||
--voice-all plan every official voice slot, including the prep-only lines no battle plays
|
||||
(干员报到 / 编入队伍 / 任命队长; 360 files / 19.3 MB more per run — off by default)
|
||||
--prune delete files under public/assets that the manifest no longer references
|
||||
@@ -253,6 +254,7 @@ function countStats(m, bytes, files) {
|
||||
ui: Object.keys(m.ui || {}).length,
|
||||
sfxUnits: Object.keys(m.audio?.sfx?.units || {}).length,
|
||||
voiceChars: Object.keys(m.audio?.voice || {}).length,
|
||||
voiceJpChars: Object.keys(m.audio?.voiceJp || {}).length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -344,7 +346,8 @@ async function main() {
|
||||
`(${Object.keys(plan.template.chars).length} chars, ${Object.keys(plan.template.enemies).length} enemies, ` +
|
||||
`${Object.keys(plan.template.tokens).length} tokens, ${Object.keys(plan.template.ui).length} UI sprites, ` +
|
||||
`${Object.keys(plan.template.audio.sfx.units).length} units with SFX, ` +
|
||||
`${Object.keys(plan.template.audio.voice).length} operators with ${opts.voiceLang.toUpperCase()} voice)`);
|
||||
`${Object.keys(plan.template.audio.voice).length} operators with ${opts.voiceLang.toUpperCase()} voice, ` +
|
||||
`${Object.keys(plan.template.audio.voiceJp || {}).length} with JP voice)`);
|
||||
if (opts.dryRun) {
|
||||
for (const n of plan.notes) log(` note: ${n}`);
|
||||
return 0;
|
||||
@@ -434,7 +437,7 @@ async function main() {
|
||||
log(`bonds ${s.bonds} · items ${s.items} · bands ${s.bands} · skill icons ${s.skills} · UI ${s.ui} · units with SFX ${s.sfxUnits}`);
|
||||
const overlays = (o) => Object.values(o || {}).filter((e) => e?.spineLocal).length;
|
||||
log(`local-client models (spineLocal, drawn when extracted): enemies ${overlays(manifest.enemies)} · tokens ${overlays(manifest.tokens)}`);
|
||||
log(`operator battle voice: ${s.voiceChars} charIds (--voice-lang=${opts.voiceLang})`);
|
||||
log(`operator battle voice: ${s.voiceChars} charIds (--voice-lang=${opts.voiceLang}) · JP dub (audio.voiceJp): ${s.voiceJpChars} charIds`);
|
||||
log(`fonts: ${Object.values(fonts.files).map((f) => f.woff2 || f.original).join(', ') || 'none'}`);
|
||||
if (resolved.fallbacks.length) { log(`fallbacks used (${resolved.fallbacks.length}):`); for (const f of resolved.fallbacks.slice(0, 20)) log(` ${f}`); }
|
||||
if (downloadErrors.length) log(`download errors (${downloadErrors.length}, re-run to retry): ${downloadErrors.slice(0, 10).join(', ')}`);
|
||||
|
||||
+31
-11
@@ -21,7 +21,8 @@
|
||||
// stage adds the production node_modules and (postinstall) public/vendor. The full zip adds the git-ignored art: the
|
||||
// files data/assets.json lists, public/fonts, and the local-client extraction (public/assets/local/,
|
||||
// data/local-assets.json) when present — nothing else on disk, so art the data no longer lists (焰狐龙梓兰, left out of
|
||||
// 自选 in 0.2.0, or files of an old mapping) never ships even when this machine still has it.
|
||||
// 自选 in 0.2.0, or files of an old mapping) never ships even when this machine still has it. FULL_ZIP_JP_VOICE (below)
|
||||
// decides whether the full zip carries the Japanese voice dub too (default: yes).
|
||||
// Left out: test/, the maintainer tools (build-data, golden, botbench, i18n, check-imports, this file …),
|
||||
// scripts/make-windows-bundle.mjs, the other docs (DESIGN, SIM, the research notes, docs/img …), public/dev/, handoff/,
|
||||
// .github/, types/, lint / editor / Docker files (Docker builds from a git clone).
|
||||
@@ -93,6 +94,18 @@ const NOT_TRACKED_SHIP = ['public/dev/', 'public/assets/', 'public/fonts/', 'pub
|
||||
/** The npm scripts a player runs: each `node <file>` of them must ship. */
|
||||
export const PLAYER_NPM_SCRIPTS = ['start', 'setup', 'doctor', 'launch', 'postinstall', 'vendor', 'assets'];
|
||||
|
||||
/**
|
||||
* THE SWITCH for the Japanese voice dub in the full zip (`audio.voiceJp` of data/assets.json: 2674 files under
|
||||
* public/assets/audio/voice/jp/, about 93 MB that zip compression barely shrinks). true (0.2.2): the full zip ships both
|
||||
* dubs, so it still runs with nothing to download. false: the full zip (and an update zip built from it) leaves the JP
|
||||
* files out — setup downloads them on the first start like the lite zip's art, the 日本語 setting plays the Chinese line
|
||||
* until then (public/js/audio.js voiceLine), and an update never deletes a player's copy (`held`). The lite zip carries
|
||||
* no art either way.
|
||||
*/
|
||||
export const FULL_ZIP_JP_VOICE = true;
|
||||
/** Where the JP dub lies (plan.mjs voiceAlt: audio/voice/<lang>/<charId>/<file>). */
|
||||
export const JP_VOICE_DIR = 'public/assets/audio/voice/jp/';
|
||||
|
||||
/**
|
||||
* Refused in a plan and in a stage: 0.1.x's list (owner-only notes, the promo project, caches, per-machine config) and
|
||||
* what 0.2.0 leaves out on purpose. A path is refused when it is one of these or lies under one.
|
||||
@@ -205,17 +218,20 @@ export const manifestFiles = listedArtFiles;
|
||||
/**
|
||||
* The art of a full package: every file data/assets.json lists, public/fonts, public/assets/local/ and
|
||||
* data/local-assets.json (when present). `missing`: listed files not on disk; `orphans`: files under public/assets
|
||||
* that nothing lists (left out).
|
||||
* that nothing lists (left out); `held`: listed files the zip holds back on purpose — the JP voice dub when `jpVoice`
|
||||
* (FULL_ZIP_JP_VOICE) is off: neither shipped nor missing, and never deleted by an update.
|
||||
*/
|
||||
export function artPlan(root, { lite = false } = {}) {
|
||||
if (lite) return { files: [], missing: [], orphans: [], local: false };
|
||||
export function artPlan(root, { lite = false, jpVoice = FULL_ZIP_JP_VOICE } = {}) {
|
||||
if (lite) return { files: [], missing: [], orphans: [], held: [], local: false };
|
||||
const listed = manifestFiles(readJson(path.join(root, 'data', 'assets.json')) ?? {});
|
||||
const local = isFile(path.join(root, 'data', 'local-assets.json'));
|
||||
if (local) for (const f of manifestFiles(readJson(path.join(root, 'data', 'local-assets.json')) ?? {})) listed.add(f);
|
||||
const files = new Set();
|
||||
const missing = [];
|
||||
const held = [];
|
||||
for (const f of listed) {
|
||||
if (isFile(path.join(root, f))) files.add(f);
|
||||
if (!jpVoice && f.startsWith(JP_VOICE_DIR)) held.push(f);
|
||||
else if (isFile(path.join(root, f))) files.add(f);
|
||||
else missing.push(f);
|
||||
}
|
||||
for (const f of listTree(root, 'public/fonts')) files.add(f);
|
||||
@@ -224,9 +240,9 @@ export function artPlan(root, { lite = false } = {}) {
|
||||
files.add('data/local-assets.json');
|
||||
}
|
||||
// without case: on Windows / macOS a file whose name differs only in case is the listed one (fetch-assets orphanFiles)
|
||||
const lower = new Set([...files].map((f) => f.toLowerCase()));
|
||||
const lower = new Set([...files, ...held].map((f) => f.toLowerCase()));
|
||||
const orphans = listTree(root, 'public/assets').filter((f) => !lower.has(f.toLowerCase()));
|
||||
return { files: [...files].sort(), missing: missing.sort(), orphans, local };
|
||||
return { files: [...files].sort(), missing: missing.sort(), orphans, held: held.sort(), local };
|
||||
}
|
||||
|
||||
const URL_MOUNTS = [['/data/', 'data/'], ['/shared/', 'shared/'], ['/sim/', 'server/sim/']];
|
||||
@@ -335,13 +351,14 @@ function groupOf(rel) {
|
||||
* Everything a package would hold, with every check: no copying, no install. `scan: false` skips the personal-info
|
||||
* scan and `measure: false` the node_modules / public/vendor sizes (tests that only want the selection).
|
||||
* @param {string} root
|
||||
* @param {{ lite?: boolean, paths?: string[], allowDirty?: boolean, env?: object, scan?: boolean, measure?: boolean }} [opts]
|
||||
* @param {{ lite?: boolean, paths?: string[], allowDirty?: boolean, env?: object, scan?: boolean, measure?: boolean,
|
||||
* jpVoice?: boolean }} [opts] jpVoice: the full zip's JP dub (default FULL_ZIP_JP_VOICE)
|
||||
*/
|
||||
export function plan(root, opts = {}) {
|
||||
const lite = !!opts.lite;
|
||||
const all = (opts.paths || trackedFiles(root)).map(posixRel);
|
||||
const { keep, drop } = selectTracked(all);
|
||||
const art = artPlan(root, { lite });
|
||||
const art = artPlan(root, { lite, jpVoice: opts.jpVoice ?? FULL_ZIP_JP_VOICE });
|
||||
const pkg = readJson(path.join(root, 'package.json')) || {};
|
||||
const lock = readJson(path.join(root, 'package-lock.json')) || {};
|
||||
const generated = shipsPacks(keep) ? [GENERATED_PACK_INDEX] : [];
|
||||
@@ -383,7 +400,7 @@ export function plan(root, opts = {}) {
|
||||
const dropBytes = Object.values(dropped).reduce((n, g) => n + g.bytes, 0);
|
||||
return {
|
||||
version: pkg.version || '0.0.0', lite, tracked: keep, art: art.files, generated, files, dropped, orphans: art.orphans,
|
||||
localArt: art.local, problems, names: names.length, scanned,
|
||||
held: art.held, localArt: art.local, problems, names: names.length, scanned,
|
||||
bytes: {
|
||||
tracked: trackedBytes, art: artBytes, modules: modulesBytes, vendor: vendorBytes,
|
||||
total: trackedBytes + artBytes + modulesBytes + vendorBytes,
|
||||
@@ -409,6 +426,7 @@ export function formatSummary(p) {
|
||||
const groups = Object.entries(p.dropped).sort((a, b) => b[1].bytes - a[1].bytes);
|
||||
if (groups.length) lines.push(`left out: ${groups.map(([g, v]) => `${g} ${v.files} (${MB(v.bytes)})`).join(', ')}`);
|
||||
if (!p.lite && p.orphans.length) lines.push(`left out art: ${p.orphans.length} files under public/assets that nothing lists (${MB(p.bytes.orphans)})`);
|
||||
if (!p.lite && p.held?.length) lines.push(`held back: ${p.held.length} files of the JP voice dub (FULL_ZIP_JP_VOICE off: setup downloads them on the first start)`);
|
||||
lines.push(`personal-info scan: ${p.scanned} files, ${p.names ? `home paths + ${p.names} account name(s)` : 'home paths only (no account name to refuse)'}`);
|
||||
lines.push(p.problems.length ? `PROBLEMS (${p.problems.length}):\n${p.problems.map((x) => ` ${x}`).join('\n')}` : 'checks: ok');
|
||||
return lines.join('\n') + '\n';
|
||||
@@ -598,7 +616,9 @@ export function buildUpdate(root, p, bases, { out, install = true, force = false
|
||||
}
|
||||
}
|
||||
// a path no update deletes (a per-machine name such as node_modules/x/.env.example) stays behind, named in the summary
|
||||
const diff = diffBases(next, bases, { removable: (rel) => !removalProblem(rel) });
|
||||
// …nor a file this zip holds back on purpose (the JP dub with FULL_ZIP_JP_VOICE off): the manifest still lists it
|
||||
const heldBack = new Set(p.held || []);
|
||||
const diff = diffBases(next, bases, { removable: (rel) => !removalProblem(rel) && !heldBack.has(rel) });
|
||||
for (const rel of diff.left) {
|
||||
const bad = pathProblem(rel);
|
||||
if (bad) throw new Error(`a base ships ${rel}, which is ${bad}`);
|
||||
|
||||
+4
-4
@@ -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, ~460 MB into public/assets, resumable, mirror fallback) when public/assets
|
||||
// 5. Art/audio (tools/fetch-assets.mjs, ~550 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
|
||||
@@ -76,9 +76,9 @@ export const nodeMajor = () => Number(process.versions.node.split('.')[0]);
|
||||
const exists = (p) => { try { fs.accessSync(p); return true; } catch { return false; } };
|
||||
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
|
||||
const mb = (n) => `${(n / 1048576).toFixed(0)} MB`;
|
||||
/** The art download when data/assets.json gives no size (missing, unreadable or without stats.bytes): the 0.2.0
|
||||
* manifest's stats.bytes, 463 MB, as README and docs/DEPLOY.md quote it. */
|
||||
const ART_DOWNLOAD_FALLBACK = '460 MB';
|
||||
/** The art download when data/assets.json gives no size (missing, unreadable or without stats.bytes): the 0.2.2
|
||||
* manifest's stats.bytes, 548 MB (both voice dubs; 463 MB before the JP one), as README and docs/DEPLOY.md quote it. */
|
||||
const ART_DOWNLOAD_FALLBACK = '550 MB';
|
||||
/** Terminal display width (CJK / full-width characters take two columns). */
|
||||
export const displayWidth = (s) => [...String(s)].reduce((n, ch) => n + (ch.codePointAt(0) >= 0x2e80 ? 2 : 1), 0);
|
||||
export const padDisplay = (s, w) => s + ' '.repeat(Math.max(0, w - displayWidth(s)));
|
||||
|
||||
Reference in new issue
Block a user