feedback7: merge fb7-voices — the 选中干员 line on every tap (prep / shop / hand, official FOCUS_CHAR: priority 10, cooldown 0, no gap restart), the Japanese voice tree (audio.voiceJp, 2674 files, setting 语音语言 中文 / 日本語, CN fallback), the full zip ships both
- Conflict: public/js/ui/settings.js imports (store from #321 + VOICE_LANGS) — both kept. test/ui/playtest6-elite.test.js's source pin of the onDetail line allows the new tap counter (bcee737f). - Grok review: MERGE AFTER FIXES — the three findings fixed on the branch (bcee737f). Golden unchanged; broad node --test 5334 (the one failure was that source pin, fixed), i18n strict clean, lint 0 errors, typecheck.
This commit is contained in:
commit
fbcbfdffbc
26 files changed
+584
-104
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 素材(之后走浏览器缓存);对局中流量很小 |
|
||||
|
||||
@@ -253,7 +253,7 @@ node tools/perfbench.mjs --cpu 1,4,6 --profile # 真实战斗的帧
|
||||
|
||||
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`)用它核对安装。
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
界面语言:默认中文;标题画面右上角或「设置」里可切换 **中文 / English / 日本語 / 한국어 / 繁體中文**(会记住选择,不用刷新)。日语、韩语、繁体中文的游戏文本取自官方日服 / 韩服 / 台服数据,界面文字暂未翻译——日语和韩语显示英文界面,繁体中文显示简体中文界面,欢迎社区补全(docs/I18N.md「Adding a language」)。英文的干员、技能、盟约、装备、敌人等游戏文本取自官方英文服数据,界面文字全部有英文;切到英文后仍显示中文的只有玩家名字(人机的名字也是)、多轮悬赏改写过的「接下来两场作战」说明,以及官方英文服还没有的游戏文本(部分自选干员和较新的模组);「玩法说明」的教程图是官方的中文图片,标题有英文(docs/I18N.md)。
|
||||
|
||||
干员语音(官方台词):「设置」里的「干员语音」调音量,「语音语言」选 **中文 / 日本語**(默认中文,和界面语言无关,会记住选择;日语缺了哪一句就播那一句的中文)。点选干员时——作战区或整备区里的干员、商店和晋升奖励里的干员卡片——会说一句官方的「选中干员」语音,休整期也一样;连续点选时新的一句接替正在说的那句,但不会打断正在播放的部署、技能等作战语音。部署(每场第一个部署的干员说「行动出发」)、技能、接敌和结算语音只在作战中播放。替补干员和原型干员(预备干员、Sharp、Mechanist 等)、盟约·辅助干员和召唤物没有官方语音,点选时不出声。
|
||||
|
||||
## 1. 一局的流程
|
||||
|
||||
```
|
||||
|
||||
@@ -350,6 +350,8 @@ 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, and a tap starts no gap of its own for the battle lines (`VOICE_TAP_SLOTS`); every shop / reward card opened counts as a tap, so two cards of one operator each speak (`selectVoiceKey`). 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`).
|
||||
|
||||
**Where each line plays — battle only.** Every line comes from a running battle's own event stream or from its settlement, so no hook needs a phase check and the 休整期 stays silent: the shop does not say 干员报到 (a merge-completing buy neither says 任命队长), a retreat does not say 编入队伍 and dragging a piece onto the board in prep says nothing. In battle: 开战 — the first operator deployed says 行动出发 and the others 部署 (the sim's `deploy` events; `setFieldUnits` resets the flag per field — in this mode every piece enters at once, which is why the gate below exists, and a knocked-out operator's redeploy says 部署 again); 接敌 — the sim's new `['engage', id]` (a unit's first attack that hits an enemy, official ENCOUNTER_ENEMY); 技能 — `skillN` from the unit's `skillIndex` (作战中1-4); 选中 — the detail panel opening on an operator **while a battle runs** (`DetailPanel voice` = the game screen's `combat`); 结算 — `resultVoiceSlot()`: 完美作战 ⇒ 3星结束行动 (绝境 / 终极 ⇒ 完成高难行动), 有漏怪 ⇒ 非3星结束行动, 一个没杀 ⇒ 行动失败, spoken by a drawn operator **of that battle's own field** (`resultSpeaker`, from the result's `perPlayer.unitsEnd`: the operators of that field, survivors first, a summon piece never), driven by the runner's new `emit('result', …)` — never the field on screen, so watching a teammate cannot put their operator's voice on the viewer's settlement line (review on #73). `unitsEnd` names each unit by its chess id (`chess_char_*`), so `onResult` passes the chess record's `charId` as `resultSpeaker`'s `charOf`; as merged, the speaker looked for `char_*` ids only and no battle ever said its settlement line (found while porting #73 to 0.2.0; `test/ui/audio.test.js` runs a real battle result through it).
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
"完成": "Done",
|
||||
"背景音乐": "Music",
|
||||
"干员语音": "Operator Voices",
|
||||
"语音语言": "Voice Language",
|
||||
"音效": "Sound Effects",
|
||||
"静音": "Mute",
|
||||
"显示伤害数字": "Damage Numbers",
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
"完成": "完了",
|
||||
"背景音乐": "BGM",
|
||||
"干员语音": "オペレーターボイス",
|
||||
"语音语言": "ボイス言語",
|
||||
"音效": "効果音",
|
||||
"静音": "ミュート",
|
||||
"显示伤害数字": "ダメージ数値を表示",
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"完成": "완료",
|
||||
"背景音乐": "배경 음악",
|
||||
"干员语音": "오퍼레이터 음성",
|
||||
"语音语言": "음성 언어",
|
||||
"音效": "효과음",
|
||||
"静音": "음소거",
|
||||
"显示伤害数字": "대미지 수치 표시",
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
"完成": "完成",
|
||||
"背景音乐": "背景音樂",
|
||||
"干员语音": "幹員語音",
|
||||
"语音语言": "語音語言",
|
||||
"音效": "音效",
|
||||
"静音": "靜音",
|
||||
"显示伤害数字": "顯示傷害數字",
|
||||
|
||||
+96
-23
@@ -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? } } } }.
|
||||
@@ -18,11 +19,16 @@
|
||||
// schedule), so every client of a match hears the same one, a fight never switches track halfway through and the
|
||||
// 联防 that follows a 作战 keeps its round's track.
|
||||
// - 干员战斗语音 (`audio.voice`, user request): an operator says the official line of the moment it is in —
|
||||
// 行动出发 start, 行动开始 faceEnemy, 选中干员 select, 部署 place, 作战中1-4 skillN and the settlement's
|
||||
// 结算 result* (all battle-only; the 休整期 is silent). Voices are seconds long and a battle deploys eight
|
||||
// 行动出发 start, 行动开始 faceEnemy, 部署 place, 作战中1-4 skillN and the settlement's 结算 result* (battle-only),
|
||||
// and 选中干员 select whenever the player taps an operator — in the prep, the shop and the hand too (the owner's
|
||||
// request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」, which lifts 2026-10-03's 「整备阶段不需要干员语音」 for
|
||||
// this line only). Voices are seconds long and a battle deploys eight
|
||||
// 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`.
|
||||
// channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions` (a tap's 选中 line skips
|
||||
// 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. A `['skill', id, 1]` that arrives before its
|
||||
@@ -253,9 +259,9 @@ export function unitSoundPlays(mix, roll) {
|
||||
/**
|
||||
* Voice priorities — the official battle voice types (`audio_data.json battleVoice.voiceTypeOptions`) mapped onto the
|
||||
* manifest's slots: BATTLE_START 100, BATTLE_FACE_ENEMY 90, SKILL_ACTIVE 70, PASSIVE_IMP 60, PASSIVE_NOR 50,
|
||||
* PLACE_CHAR 20, FOCUS_CHAR 10. The settlement lines are no battle voice of the official scheduler: they sit at 85,
|
||||
* above 作战中 (70) but below 接敌 (90), so a battle's last word is never cut off by an ordinary line. The four prep
|
||||
* slots (部署 / 编入队伍 / 任命队长 / 干员报到) keep their levels although the 休整期 is silent (see the header).
|
||||
* PLACE_CHAR 20, FOCUS_CHAR 10 (选中干员). The settlement lines are no battle voice of the official scheduler: they sit at
|
||||
* 85, above 作战中 (70) but below 接敌 (90), so a battle's last word is never cut off by an ordinary line. The three
|
||||
* prep-only slots (编入队伍 / 任命队长 / 干员报到) keep their levels although nothing plays them (see the header).
|
||||
*/
|
||||
export const VOICE_PRIORITY = Object.freeze({
|
||||
start: 100, faceEnemy: 90,
|
||||
@@ -264,14 +270,55 @@ export const VOICE_PRIORITY = Object.freeze({
|
||||
gacha: 60, squadFirst: 45, squad: 30, place: 20, select: 10,
|
||||
});
|
||||
|
||||
/** Per-unit per-slot cooldowns (ms): the official 10 s of the 作战中 (passive skill) lines, 3 s between 接敌 lines. */
|
||||
/**
|
||||
* Per-unit per-slot cooldowns (ms): the official 10 s of the 作战中 (passive skill) lines, 3 s between 接敌 lines. 选中干员
|
||||
* has none — official FOCUS_CHAR `cooldown: 0` (it was 1.5 s until 0.2.2, when a tap in the prep made it audible).
|
||||
*/
|
||||
export const VOICE_COOLDOWN_MS = Object.freeze({
|
||||
start: 0, faceEnemy: 3000,
|
||||
skill1: 10000, skill2: 10000, skill3: 10000, skill4: 10000,
|
||||
resultFour: 0, resultThree: 0, resultTwo: 0, resultLose: 0,
|
||||
gacha: 0, squadFirst: 0, squad: 0, place: 0, select: 1500,
|
||||
gacha: 0, squadFirst: 0, squad: 0, place: 0, select: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* The slot a player's tap asks for: 选中干员 (official FOCUS_CHAR — priority 10, cooldown 0, `overlapIfSamePriority:
|
||||
* true`). The battle lines are timed by the gate's global gap; a tap is the player's own request and answers at once:
|
||||
* on an idle channel it always plays (no gap, no cooldown), a newer tap replaces the 选中 line still on air (the same
|
||||
* priority, overlapIfSamePriority), and it still never interrupts a higher-priority line (部署, 作战中, 开战 …). Nor does
|
||||
* it start a gap of its own: the battle lines keep the gap of the last battle line (a tap restarting it dropped a 部署 /
|
||||
* 技能 / 接敌, which the battle asks for once, after a 选中 line shorter than the gap had already ended — many are).
|
||||
*/
|
||||
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 ⇒ 行动失败.
|
||||
@@ -370,7 +417,9 @@ export class SfxLimiter {
|
||||
|
||||
/**
|
||||
* Voice gate: one line at a time, a global gap between two lines, a per-unit per-slot cooldown, and takeover by a
|
||||
* clearly more important line (the caller fades the playing one out first). Pure — the clock is passed in.
|
||||
* clearly more important line (the caller fades the playing one out first). A tap's 选中干员 skips the gap and the
|
||||
* cooldown on an idle channel, replaces a 选中 line on air and starts no gap itself (VOICE_TAP_SLOTS). Pure — the clock
|
||||
* is passed in.
|
||||
*/
|
||||
export class VoiceGate {
|
||||
/** @param {{ gapMs?: number, preemptMargin?: number, maxUnits?: number }} [o] */
|
||||
@@ -401,10 +450,13 @@ export class VoiceGate {
|
||||
*/
|
||||
request(slot, unitKey, now) {
|
||||
const pri = VOICE_PRIORITY[slot] ?? 0;
|
||||
const tap = VOICE_TAP_SLOTS.includes(slot);
|
||||
if (this.playing) {
|
||||
if (tap && this.playing.slot === slot) return 'preempt'; // a newer tap replaces the 选中 line on air
|
||||
if (pri < this.playing.pri + this.preemptMargin) return 'drop';
|
||||
return 'preempt'; // a clearly more important line takes the channel
|
||||
}
|
||||
if (tap) return 'play'; // a tap on an idle channel always answers (FOCUS_CHAR cooldown 0, no gap)
|
||||
if (now - this.lastAt < this.gapMs) return 'drop';
|
||||
if (unitKey != null && now < (this.unitUntil.get(`${unitKey}:${slot}`) || 0)) return 'drop';
|
||||
return 'play';
|
||||
@@ -413,7 +465,7 @@ export class VoiceGate {
|
||||
/** Record a line that started (call right after request() answered play / preempt). */
|
||||
start(slot, unitKey, now) {
|
||||
this.playing = { slot, pri: VOICE_PRIORITY[slot] ?? 0 };
|
||||
this.lastAt = now;
|
||||
if (!VOICE_TAP_SLOTS.includes(slot)) this.lastAt = now; // the gap is between battle lines: a tap starts none
|
||||
const cd = VOICE_COOLDOWN_MS[slot] ?? 0;
|
||||
if (unitKey != null && cd > 0) {
|
||||
if (this.unitUntil.size > this.maxUnits) this.unitUntil.clear();
|
||||
@@ -462,6 +514,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)
|
||||
@@ -602,6 +655,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 {
|
||||
@@ -829,9 +891,11 @@ 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).
|
||||
* Only in battle: every caller is a running battle's own event stream or its settlement (user request — the 休整期
|
||||
* is silent). The line must pass VoiceGate: one at a time, a global gap, a per-unit cooldown, higher priority wins.
|
||||
* 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.
|
||||
* @param {string} charId e.g. 'char_263_skadi'
|
||||
* @param {'start'|'faceEnemy'|'select'|'place'|'skill1'|'skill2'|'skill3'|'skill4'|'squad'|'squadFirst'
|
||||
* |'resultFour'|'resultThree'|'resultTwo'|'resultLose'|'gacha'} slot
|
||||
@@ -842,31 +906,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;
|
||||
@@ -894,7 +967,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). */
|
||||
@@ -1061,13 +1134,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 {
|
||||
|
||||
@@ -169,6 +169,9 @@ function MatchScreen() {
|
||||
const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows
|
||||
const [bondsCollapsed, setBondsCollapsed] = useState(false);
|
||||
const [detail, setDetail] = useState(null); // detail target
|
||||
// every shop / reward card opened by a tap is a new tap — `tap` in the target: two cards of one operator (the pool
|
||||
// deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey)
|
||||
const cardTap = useRef(0);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [rewardMin, setRewardMin] = useState(false);
|
||||
const [emoteOpen, setEmoteOpen] = useState(false);
|
||||
@@ -1355,7 +1358,7 @@ function MatchScreen() {
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
|
||||
barRef=${barRef} offBonds=${offBonds}
|
||||
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
|
||||
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null })}
|
||||
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })}
|
||||
onDetailClose=${() => setDetail((d) => (d?.kind === 'chess' || d?.kind === 'item' ? null : d))}
|
||||
onRefuse=${(reason) => { toast(reason, 'warn'); audio.sfx('error', { volume: 0.5 }); }}
|
||||
reward=${phase === PHASE.PREP && !rewardMin ? priv?.shop?.rewardOffer || null : null}
|
||||
@@ -1391,7 +1394,7 @@ function MatchScreen() {
|
||||
onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor, diy) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null, diy: diy || null })} />` : null}
|
||||
|
||||
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
|
||||
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${combat}
|
||||
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${true}
|
||||
onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null}
|
||||
|
||||
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
|
||||
|
||||
@@ -755,9 +755,11 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
|
||||
// (a teammate's 自选 row hands its unit's pick on, `target.diy`: their operator, not the empty 甄选干员 slot — 0.2.1)
|
||||
const mate = foreign && c && target.diy && typeof target.diy === 'object' ? diyRecordFor(c, target.diy, dd) : null;
|
||||
const d = mate ? { chess: mate, diy: target.diy } : foreign ? null : ownDiy(c);
|
||||
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}) };
|
||||
// `tap`: which card tap opened it (game.js numbers every shop / reward card it opens) — selectVoiceKey
|
||||
const tap = target.tap != null ? { tap: target.tap } : null;
|
||||
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}), ...tap };
|
||||
const si = !c ? null : foreign ? (typeof target.standInFor === 'string' && target.standInFor ? standInOf(c, backups) : null) : ownSi(c);
|
||||
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}) } : null;
|
||||
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}), ...tap } : null;
|
||||
}
|
||||
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
|
||||
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
|
||||
@@ -788,6 +790,18 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 选中干员 key of a resolved detail (null: nothing to say). The panel says the line once per opened operator and stays
|
||||
* mounted while its target changes, so the key carries what identifies the opening: the chess record, the piece / battle
|
||||
* unit, and the card tap (`tap`, game.js) — two shop / reward cards of one operator (the pool deals duplicates) carry the
|
||||
* same chess id and no piece, so without it the second card's tap said nothing, nor replaced the first one's line.
|
||||
* @param {any} detail resolveDetail's result
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function selectVoiceKey(detail) {
|
||||
return detail?.type === 'chess' ? `${detail.chess?.chessId || ''}:${detail.unitId ?? detail.piece?.uid ?? ''}:${detail.tap ?? ''}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel.
|
||||
* @param {{ detail:any, editable:boolean, snapHp?:{hp:number,max:number}|null, onClose:Function, onSell:(piece:any)=>void, onDestroy:(piece:any)=>void,
|
||||
@@ -798,15 +812,17 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
|
||||
* = the defaults
|
||||
* live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4×
|
||||
* a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers
|
||||
* voice: whether the panel may speak — 选中干员 (audio.voice 'select') plays only while a battle runs (user request:
|
||||
* 整备期不播干员语音), so the game screen passes its combat flag
|
||||
* voice: whether the panel may speak — 选中干员 (audio.voice 'select') when it opens on an operator the player tapped:
|
||||
* a piece on the field or in the hand, a shop / reward card, a bond member. The game screen passes true in every phase
|
||||
* (the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」 lifted 2026-10-03's 「整备阶段不需要干员语音」
|
||||
* for this line only; [ASSUMED] the official prep tap says 选中干员 like the battle's FOCUS_CHAR)
|
||||
*/
|
||||
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, onBond = null, side = 'left', shopOpen = false, live = null, voice = false }) {
|
||||
const getter = typeof live === 'function' ? live : null;
|
||||
useTicker(detail && getter ? 250 : 0);
|
||||
// 选中干员 voice (audio.voice 'select'): once per opened operator — the panel stays mounted while the target changes,
|
||||
// so the key carries what identifies it (its chess record and its piece / battle unit id)
|
||||
const selectKey = voice && detail?.type === 'chess' ? `${detail.chess?.chessId || ''}:${detail.unitId ?? detail.piece?.uid ?? ''}` : null;
|
||||
// so the key carries what identifies it (its chess record, its piece / battle unit id, the card tap: selectVoiceKey)
|
||||
const selectKey = voice && detail?.type === 'chess' ? selectVoiceKey(detail) : null;
|
||||
// 0.2.0 补位: a chess fielded as its stand-in is spoken for by the stand-in (the operator on the field, whose model, name
|
||||
// and battle lines the card and audio.js show), never by the operator it replaces; a 自选 record is already the pick's
|
||||
const selectChar = voice && detail?.type === 'chess' ? detail.standIn?.charId || detail.chess?.charId || null : null;
|
||||
|
||||
@@ -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
|
||||
@@ -9,7 +9,7 @@
|
||||
import { useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
|
||||
import { createStore, useStore, loadPref, savePref, store } 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';
|
||||
@@ -20,14 +20,16 @@ import { copyText } from './clipboard.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) {
|
||||
@@ -63,6 +65,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_('准备就绪 / 暂停(独立模拟)') };
|
||||
@@ -216,6 +223,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 })} />
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
// the 突变细胞 bench rule (§21.1: the carrier destroyed, its new operator gained into the 整备区 — official footage, PR #2),
|
||||
// the closing additions §21.26–§21.28 (GitHub issues #1 / #5 / #8), the owner's deliberate trigger deviation for six
|
||||
// 重装 skills (§21.29, GitHub issue #4 / PR #12) and the operator battle voice the user asked for the same day (§21.30,
|
||||
// battle only — the 休整期 is silent).
|
||||
// battle only — since 0.2.2 a tap on an operator says 选中干员 in every phase).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
@@ -868,7 +868,7 @@ test('the deliberate trigger deviation (DESIGN §21.29): six 重装 skills DEFAU
|
||||
assert.match(doc('CHANGELOG.md'), /深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能改为攻击范围内有敌人时就释放/);
|
||||
});
|
||||
|
||||
test('干员战斗语音 (DESIGN §21.30): the manifest data, the official priorities, and the 休整期 stays silent', async () => {
|
||||
test('干员战斗语音 (DESIGN §21.30): the manifest data, the official priorities, and only a tap\'s 选中干员 speaks outside battle', async () => {
|
||||
const { VOICE_PRIORITY, VOICE_COOLDOWN_MS, resultVoiceSlot } = await import('../public/js/audio.js');
|
||||
const manifest = JSON.parse(readFileSync(join(ROOT, 'data/assets.json'), 'utf8'));
|
||||
const voice = manifest.audio?.voice ?? {};
|
||||
@@ -902,22 +902,30 @@ test('干员战斗语音 (DESIGN §21.30): the manifest data, the official prior
|
||||
assert.equal(VOICE_PRIORITY.select, 10);
|
||||
assert.equal(VOICE_COOLDOWN_MS.skill1, 10000);
|
||||
assert.equal(VOICE_COOLDOWN_MS.faceEnemy, 3000);
|
||||
assert.equal(VOICE_COOLDOWN_MS.select, 1500);
|
||||
assert.equal(VOICE_COOLDOWN_MS.select, 0, 'official FOCUS_CHAR cooldown 0 (1.5 s until 0.2.2)');
|
||||
assert.equal(resultVoiceSlot({ perfect: true }), 'resultThree');
|
||||
// the docs
|
||||
assert.match(DESIGN, /### 21\.30 /);
|
||||
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, and 选中干员 sits behind the panel's combat flag
|
||||
// (干员报到 / 编入队伍 / 任命队长) are never requested — except 选中干员, the detail panel's line on every tap of an
|
||||
// operator, in every phase (0.2.2: the owner's request of 2026-10-08)
|
||||
const panel = doc('public/js/ui/detailPanel.js');
|
||||
const game = doc('public/js/screens/game.js');
|
||||
for (const [name, src] of [['audio.js', doc('public/js/audio.js')], ['game.js', game], ['detailPanel.js', panel]]) {
|
||||
assert.ok(!/voice\([^)]*'(gacha|squad|squadFirst)'/.test(src), `${name}: no prep slot is played`);
|
||||
}
|
||||
assert.match(panel, /const selectKey = voice && detail\?\.type === 'chess'/);
|
||||
assert.match(game, /voice=\$\{combat\}/);
|
||||
assert.match(game, /voice=\$\{true\}/);
|
||||
assert.match(game, /audio\.voice\(charId, resultVoiceSlot\(/);
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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\)/);
|
||||
});
|
||||
|
||||
test('选中干员 on every card tap (review of fb7-voices): two shop / reward cards of one operator are two taps, a re-render is none', async () => {
|
||||
// shop / reward cards hand only the chess id to the detail target (no piece, no unit): game.js numbers every card it opens
|
||||
// (`tap`), resolveDetail keeps it, and the panel's 选中 key (selectVoiceKey) includes it — the second 斯卡蒂 card spoke
|
||||
// nothing before, nor replaced the first card's line (the pool deals duplicates)
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
const name = String(url).split('/').pop();
|
||||
let body;
|
||||
try { body = readJson(`data/${name}`); } catch { return { ok: false, status: 404, json: async () => ({}) }; }
|
||||
return { ok: true, status: 200, json: async () => body };
|
||||
};
|
||||
try {
|
||||
const { resolveDetail, selectVoiceKey } = await import('../public/js/ui/detailPanel.js');
|
||||
const { data } = await import('../public/js/data.js');
|
||||
await data.loadAll('chess', 'backups');
|
||||
const id = Object.values(readJson('data/chess.json')).find((c) => c.charId === 'char_263_skadi' && !c.isGolden).chessId;
|
||||
const card = (tap) => resolveDetail({ kind: 'chess', id, hint: null, tap }, new Map(), { priv: null });
|
||||
const a = card(1), b = card(2);
|
||||
assert.equal(a.chess.charId, 'char_263_skadi');
|
||||
assert.deepEqual([a.tap, b.tap], [1, 2], 'the tap rides along');
|
||||
assert.notEqual(selectVoiceKey(a), selectVoiceKey(b), 'the second card of the same operator is a new opening');
|
||||
assert.equal(selectVoiceKey(card(1)), selectVoiceKey(a), 'the same tap resolved again (a re-render) says nothing more');
|
||||
assert.equal(resolveDetail({ kind: 'chess', id }, new Map(), { priv: null }).tap, undefined, 'no tap, no field');
|
||||
// pieces and battle units keep their own identity; a non-operator card has no key
|
||||
assert.equal(selectVoiceKey({ type: 'chess', chess: { chessId: 'x' }, piece: { uid: 4 } }), 'x:4:');
|
||||
assert.equal(selectVoiceKey({ type: 'chess', chess: { chessId: 'x' }, unitId: 9 }), 'x:9:');
|
||||
assert.equal(selectVoiceKey({ type: 'item' }), null);
|
||||
assert.equal(selectVoiceKey(null), null);
|
||||
// the panel keys its 选中 effect on it, and the game screen numbers every operator card it opens
|
||||
assert.match(fs.readFileSync(path.join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8'), /const selectKey = voice && detail\?\.type === 'chess' \? selectVoiceKey\(detail\) : null;/);
|
||||
const game = fs.readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
|
||||
assert.match(game, /onDetail=\$\{\(id, kind, hint\) => setDetail\(\{ kind: kind === 'item' \? 'item' : 'chess', id, hint: hint \|\| null, tap: \+\+cardTap\.current \}\)\}/);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
+123
-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';
|
||||
@@ -252,6 +252,128 @@ describe('operator battle voice', () => {
|
||||
assert.equal(g2.request('skill1', 'u1', 10001), 'play', 'a new battle inherits no cooldown');
|
||||
});
|
||||
|
||||
test('选中干员 on every tap (0.2.2; official FOCUS_CHAR: priority 10, cooldown 0): the prep speaks too, an idle channel always answers, a newer tap replaces it, a higher line is never interrupted', async () => {
|
||||
// the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」: the game screen lets the detail panel speak
|
||||
// in every phase — a tap on a piece in the field / hand, a shop card — not only while a battle runs
|
||||
const game = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
|
||||
assert.match(game, /<\$\{DetailPanel\}[^`]*?voice=\$\{true\}/, 'the detail panel speaks outside battle too');
|
||||
assert.doesNotMatch(game, /voice=\$\{combat\}/);
|
||||
assert.equal(VOICE_PRIORITY.select, 10, 'official FOCUS_CHAR priority');
|
||||
assert.equal(VOICE_COOLDOWN_MS.select, 0, 'official FOCUS_CHAR cooldown 0');
|
||||
const g = new VoiceGate(); // the real 1.2 s global gap
|
||||
assert.equal(g.request('select', null, 0), 'play');
|
||||
g.start('select', null, 0); g.release(); // tap A, its line ended
|
||||
assert.equal(g.request('select', null, 300), 'play', 'an idle channel: the global gap never drops a tap');
|
||||
g.start('select', 'u1', 300); g.release();
|
||||
assert.equal(g.request('select', 'u1', 400), 'play', 'no cooldown either, keyed or not');
|
||||
g.start('select', null, 400);
|
||||
assert.equal(g.request('select', null, 600), 'preempt', 'a newer tap replaces the 选中 line on air (overlapIfSamePriority)');
|
||||
g.start('select', null, 600);
|
||||
assert.equal(g.request('place', 'u2', 700), 'preempt', '部署 (20) still takes the channel from 选中 (10)');
|
||||
g.start('place', 'u2', 700);
|
||||
assert.equal(g.request('select', null, 800), 'drop', 'a tap never interrupts a higher-priority line');
|
||||
g.reset();
|
||||
g.start('skill1', 'u3', 0);
|
||||
assert.equal(g.request('select', null, 100), 'drop', '… nor a 作战中 line');
|
||||
g.reset();
|
||||
// a tap starts no gap of its own (review of fb7-voices): a 部署 at 0 holds the battle lines until 1200; a tap at 500
|
||||
// answers, its short line ends at 944, and a 作战中 the battle asks for once at 1300 still plays (it was dropped: the
|
||||
// tap had moved the gap's start to 500)
|
||||
g.start('place', 'u4', 0); g.release();
|
||||
assert.equal(g.request('select', null, 500), 'play');
|
||||
g.start('select', null, 500); g.release();
|
||||
assert.equal(g.request('skill1', 'u5', 1100), 'drop', 'inside the 部署 gap a battle line still waits');
|
||||
assert.equal(g.request('skill1', 'u5', 1300), 'play', 'the gap ends where the 部署 put it: the tap did not restart it');
|
||||
g.reset();
|
||||
g.start('select', null, 0); g.release();
|
||||
assert.equal(g.request('place', 'u6', 100), 'play', 'a battle line right after a tap: no gap behind a 选中 line');
|
||||
g.reset();
|
||||
// the manager: two taps 0.3 s apart in the prep (no battle, the real gate) both speak, the second replacing the first
|
||||
const fw = fakeWindow();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) });
|
||||
try {
|
||||
const vm = { audio: { sfx: { ui: {}, battle: {}, units: {} }, voice: { char_a: { select: '/v/a_sel.mp3' }, char_b: { select: '/v/b_sel.mp3' } } } };
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => vm });
|
||||
a.install();
|
||||
fw.fire('pointerdown');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(a.voice('char_a', 'select'), true, 'tap A');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(a.voiceNode?.url, '/v/a_sel.mp3');
|
||||
assert.equal(a.voice('char_b', 'select'), true, 'tap B while A still speaks');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(a.voiceNode?.url, '/v/b_sel.mp3', 'B replaced A');
|
||||
a._stopVoice(); // B ended
|
||||
assert.equal(a.voice('char_a', 'select'), true, 'tap A again right after: no gap');
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -694,9 +694,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');
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('shop bar: the merge tag and the armed card', () => {
|
||||
assert.equal(mergeHint(board, golden(A)), null, 'an elite card never merges');
|
||||
// the first tap: ShopBar tapCard → onDetail(id, 'chess', mergeHint) → game.js setDetail({ …, hint }) → resolveDetail
|
||||
assert.match(read('public/js/ui/shopBar.js'), /setArmed\(key\); onDetail\(slot\.id, detailKind, detailKind === 'chess' \? mergeHint\(priv, slot\.id\) : null\)/);
|
||||
assert.match(read('public/js/screens/game.js'), /onDetail=\$\{\(id, kind, hint\) => setDetail\(\{ kind: kind === 'item' \? 'item' : 'chess', id, hint: hint \|\| null \}\)\}/);
|
||||
assert.match(read('public/js/screens/game.js'), /onDetail=\$\{\(id, kind, hint\) => setDetail\(\{ kind: kind === 'item' \? 'item' : 'chess', id, hint: hint \|\| null(?:, tap: \+\+cardTap\.current)? \}\)\}/);
|
||||
const r = resolveDetail({ kind: 'chess', id: A, hint: mergeHint(board, A) }, new Map());
|
||||
assert.equal(r.hint, '精锐干员将出现在作战区原位置');
|
||||
const blocks = ChessDetail({ chess: r.chess, piece: null, editable: false, bonds: [], loadout: null, hint: r.hint });
|
||||
|
||||
@@ -319,10 +319,10 @@ export const VOICE_SLOTS = Object.freeze({
|
||||
});
|
||||
|
||||
/**
|
||||
* The slots a running battle can actually request — the only ones `public/js/audio.js` ever asks for (the 休整期 is
|
||||
* silent, so nothing else is played): 行动出发 start, 首次接敌 faceEnemy, 作战中1-4 skillN, 部署 place (the deploy
|
||||
* events) and 选中干员 select (the detail panel, behind its combat flag), plus the settlement lines
|
||||
* resultFour / resultThree / resultTwo / resultLose (public/js/screens/game.js onResult).
|
||||
* The slots the client can actually request — the only ones `public/js/audio.js` ever asks for: 行动出发 start, 首次接敌
|
||||
* faceEnemy, 作战中1-4 skillN, 部署 place (a running battle's deploy events), the settlement lines resultFour /
|
||||
* resultThree / resultTwo / resultLose (public/js/screens/game.js onResult) and 选中干员 select (the detail panel opening
|
||||
* on an operator the player tapped, in every phase since 0.2.2).
|
||||
* `buildPlan` plans these by default; `--voice-all` widens it to every slot of VOICE_SLOTS.
|
||||
*/
|
||||
export const VOICE_BATTLE_SLOTS = Object.freeze(['start', 'faceEnemy', 'select', 'place',
|
||||
@@ -333,7 +333,7 @@ export const VOICE_BATTLE_SLOTS = Object.freeze(['start', 'faceEnemy', 'select',
|
||||
* 任命队长 squadFirst). `test/docs-consistency.test.js` proves the client never asks for one, so planning them only
|
||||
* makes every `npm run assets` download 360 files (19.3 MB, CN dub) that no player will ever hear — they are left out
|
||||
* unless `--voice-all` is passed. 部署 `place` and 选中干员 `select` deliberately stay in: the official client groups
|
||||
* them with the prep lines, but a battle does play them (the deploy events; the detail panel behind its combat flag).
|
||||
* them with the prep lines, but the client does play them (the deploy events; a tap on an operator).
|
||||
*/
|
||||
export const VOICE_PREP_SLOTS = Object.freeze(['gacha', 'squad', 'squadFirst']);
|
||||
|
||||
|
||||
+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 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -95,7 +95,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
|
||||
@@ -256,6 +257,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -347,7 +349,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;
|
||||
@@ -437,7 +440,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/, 89,388,041 bytes = 85.2 MiB on disk, about 76 MB zipped). 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