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:
sganggs committed 2026-10-08 20:35:26 +08:00
commit fbcbfdffbc
26 files changed
+584 -104

No files matched your search

+5 -5
View File
@@ -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
View File
@@ -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 },
},
};