feedback5: merge feedback4 (beb0296) — PR #73 (operator battle voices) ported to the split layout (the voice setting into ui/gameLogic/settings.js, the gameLogic.js shim unchanged; onResult and DetailPanel voice stay in screens/game.js, which needed its store import back; the sim's 'engage' in server/sim/ai.js performAttack, still the one battle/combat.js and devices.js call; plan.mjs / fetch-assets.mjs merged with --add-only, extraOperators and moduleTypes; the 干员语音 slider through t() with English); golden: roster / bonds / fields / standins / diy differ only in events.engage, matches unchanged

Ported by hand:
- public/js/ui/gameLogic.js is a re-export shim on feedback5: #73's DEFAULT_SETTINGS / sanitizeSettings `voice`
  hunk went into public/js/ui/gameLogic/settings.js.
- public/js/screens/game.js: MatchScreen still lives there, so #73's onResult (battleRunner 'result' → resultSpeaker /
  resultVoiceSlot) and `voice=${combat}` on the DetailPanel auto-merged in place. feedback5's game.js no longer
  imported `store` (the split moved its other users), so onResult's store.get() would have thrown into its
  try/catch and the 结算 line would never have played: `store` is imported again. `localAsset` stays dropped.
- public/js/ui/settings.js (i18n phase 1): the slider is `label=${t('干员语音')}`, public/i18n/en.json
  "干员语音": "Operator Voices" (next to Music / Sound Effects).
- tools/assets/plan.mjs: buildPlan takes #73's charword / voiceLang / voiceSlots and feedback5's extraOperators /
  moduleTypes; the template has `modules` and audio { bgm, bossBgm, voice, sfx }. `chars` now includes the 自选 picks,
  so the voice loop's comment says so (the next `npm run assets` plans their lines too).
- tools/fetch-assets.mjs: parseArgs keeps --add-only beside --voice-lang / --voice-all.
- data/assets.json: feedback5's manifest plus #73's audio.voice (120 operators, after bossBgm, the plan order);
  stats files +1680 and bytes +42 041 118 as measured on feedback4, voiceChars 120; the hash is recomputed with
  the generator's contentHash. No other entry changed.
- docs/ASSETS.md: the stats line lists modules and voiceChars. DESIGN §21.30 stays where feedback4 put it; SIM.md's
  'engage' line and docs-consistency's §21.30 test auto-merged (game.js / detailPanel.js still hold the pinned code).
- Auto-merged and checked: audio.js, battle/runner.js emit('result'), components.js mic icon, detailPanel.js voice
  prop, shared/protocol.js EV.ENGAGE, server/sim/ai.js, tools/assets/audio.mjs / cache.mjs, #73's tests.

Where #73 met feedback5 work:
- 补位: a chess fielded as its stand-in shows the stand-in in the detail card, and audio.js keys battle lines by
  UnitInfo spine (the stand-in's model). The 选中干员 line now takes detail.standIn.charId first, so the stand-in
  speaks, not the operator it replaces (test/ui/standin-ui.test.js pins it). A 自选 record is already the pick's.
- The sim's 'engage': stand-in and 自选 units attack through the same performAttack, so it fires for them as well.

Golden regenerated: every moved scenario differs only in events.engage (the new event's count; no gameplay change),
proven by a field-path diff against feedback5:
- roster: all 49; bonds: all 46; fields: all 22; standins: all 10 (the stand-ins engage).
- diy: 101 of 109. Unmoved: diy-010/011/012/092/093 (闪灵, 夜莺, 凯尔希 squads: they only heal, and a heal never
  engages) and diy-077 (薇薇安娜), diy-080/081 (赫德雷), whose picks make no attack in those battles (already so on
  feedback5).
- matches: unchanged.
This commit is contained in:
sganggs committed 2026-10-06 04:31:38 +08:00
commit c5e8d13636
29 files changed
+1059 -278

No files matched your search

+88
View File
@@ -268,3 +268,91 @@ export function resolveSpec(spec, bank) {
if (spec?.bank) return bank(spec.bank);
return [];
}
// ---- operator battle voice (excel/charword_table.json) -------------------------------------------------
//
// Every playable operator has official battle lines (行动出发 / 行动开始 / 选中干员 / 部署 / 作战中 / 编入队伍 /
// 任命队长 / 结算 / 干员报到) in `voice_cn/<charId>/cn_<n>.mp3` — JP: `voice/`, EN: `voice_en/`, KR: `voice_kr/`,
// the same file names in every dump, only the folder differs. charword_table.json's `placeType` says when the game
// plays each line; its `voiceAsset` is the path under the dump.
/** Voice dump folder per language (under sound_beta_2). */
export const VOICE_DIRS = Object.freeze({ cn: 'voice_cn', jp: 'voice', en: 'voice_en', kr: 'voice_kr' });
/** Official `placeType` → the manifest's voice slot (public/js/audio.js VOICE_PRIORITY / VOICE_COOLDOWN_MS). */
export const VOICE_SLOTS = Object.freeze({
BATTLE_START: 'start', // 行动出发: 开战
BATTLE_FACE_ENEMY: 'faceEnemy', // 行动开始: 首次接敌
BATTLE_SELECT: 'select', // 选中干员1/2
BATTLE_PLACE: 'place', // 部署1/2
BATTLE_SKILL_1: 'skill1', // 作战中1-4: the equipped skill's own slot
BATTLE_SKILL_2: 'skill2',
BATTLE_SKILL_3: 'skill3',
BATTLE_SKILL_4: 'skill4',
SQUAD: 'squad', // 编入队伍
SQUAD_FIRST: 'squadFirst', // 任命队长
FOUR_STAR: 'resultFour', // 完成高难行动
THREE_STAR: 'resultThree', // 3星结束行动 (完美作战)
TWO_STAR: 'resultTwo', // 非3星结束行动
LOSE: 'resultLose', // 行动失败
GACHA: 'gacha', // 干员报到
});
/**
* 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).
* `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',
'skill1', 'skill2', 'skill3', 'skill4', 'resultFour', 'resultThree', 'resultTwo', 'resultLose']);
/**
* Slots no battle plays: the lines the official client uses in its own 养成 / 编队 UI (干员报到 gacha, 编入队伍 squad,
* 任命队长 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).
*/
export const VOICE_PREP_SLOTS = Object.freeze(['gacha', 'squad', 'squadFirst']);
/**
* Index charword_table.json into per-character voice slots, in voiceIndex order (one slot may have several lines).
* Only the base word key (`wordKey === charId`) is used: the dump has no folder for a skin variant's word key
* (`char_x_ita`, `char_x_epoque#28`, …) — those files simply do not exist upstream.
* @param {any} charword parsed excel/charword_table.json
* @param {string} [lang] voiceId prefix — the zh_CN table carries the `CN_*` lines; the other dubs share the numbering
* @param {Iterable<string>|null} [only] slot names to keep; null/omitted keeps every slot (VOICE_BATTLE_SLOTS is what
* the plan uses by default, so a battle's own lines are planned and the prep-only ones are not)
* @returns {Map<string, Record<string, string[]>>} charId → slot → voiceAsset ('char_263_skadi/CN_023')
*/
export function indexVoice(charword, lang = 'CN', only = null) {
const keep = only ? new Set(only) : null;
const out = new Map();
const words = charword?.charWords;
if (!words || typeof words !== 'object') return out;
/** @type {Map<string, Map<string, Map<string, {index:number, asset:string}>>>} */
const seen = new Map();
for (const e of Object.values(words)) {
if (!e || typeof e !== 'object') continue;
const charId = e.charId, slot = VOICE_SLOTS[e.placeType], vid = e.voiceId;
if (!charId || !slot || (keep && !keep.has(slot)) || typeof vid !== 'string' || !vid.startsWith(`${lang}_`)) continue;
if (e.wordKey !== charId) continue;
if (typeof e.voiceAsset !== 'string' || !e.voiceAsset) continue;
if (!seen.has(charId)) seen.set(charId, new Map());
const slots = seen.get(charId);
if (!slots.has(slot)) slots.set(slot, new Map());
// the same line can be listed twice (an operator's 升变 / alt records): keep its lowest voiceIndex, once
const m = slots.get(slot);
const index = Number.isFinite(e.voiceIndex) ? e.voiceIndex : 0;
const prev = m.get(vid);
if (!prev || index < prev.index) m.set(vid, { index, asset: e.voiceAsset });
}
for (const [charId, slots] of seen) {
const rec = {};
for (const [slot, m] of slots) rec[slot] = [...m.values()].sort((a, b) => a.index - b.index).map((x) => x.asset);
out.set(charId, rec);
}
return out;
}
+15 -5
View File
@@ -1,6 +1,7 @@
// Cached upstream JSON indexes needed by the asset pipeline:
// .cache/gamedata/excel/audio_data.json (Kengxxiao/ArknightsGameData, zh_CN)
// .cache/ark-models/models_data.json (isHarryh/Ark-Models enemy Spine index)
// .cache/gamedata/excel/audio_data.json (Kengxxiao/ArknightsGameData, zh_CN)
// .cache/gamedata/excel/charword_table.json (same repo: the operators' voice slots and voice assets)
// .cache/ark-models/models_data.json (isHarryh/Ark-Models enemy Spine index)
// Downloaded once when missing (or with --refresh-index), then reused.
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
@@ -46,10 +47,10 @@ export async function cachedJson({ cacheFile, url, refresh = false, offline = fa
}
/**
* Load audio_data.json (official) and Ark-Models models_data.json.
* Load audio_data.json (official), charword_table.json (official voice slots) and Ark-Models models_data.json.
* @param {string} root project root
* @param {{refresh?:boolean, offline?:boolean, log?:(m:string)=>void}} [opts]
* @returns {Promise<{ audioData: any, modelsData: any }>}
* @returns {Promise<{ audioData: any, modelsData: any, charword: any }>}
*/
export async function loadIndexes(root, opts = {}) {
const audioData = await cachedJson({
@@ -59,6 +60,15 @@ export async function loadIndexes(root, opts = {}) {
offline: opts.offline,
log: opts.log,
});
// 11 MB, and raw.githubusercontent stalls on it often: the jsDelivr mirror cachedJson() falls back to is the
// reliable path (the operator battle voice needs it, plan.mjs indexVoice).
const charword = await cachedJson({
cacheFile: join(root, '.cache', 'gamedata', 'excel', 'charword_table.json'),
url: RAW.gamedata + 'excel/charword_table.json',
refresh: opts.refresh,
offline: opts.offline,
log: opts.log,
});
const modelsData = await cachedJson({
cacheFile: join(root, '.cache', 'ark-models', 'models_data.json'),
url: RAW.arkModels + 'models_data.json',
@@ -66,5 +76,5 @@ export async function loadIndexes(root, opts = {}) {
offline: opts.offline,
log: opts.log,
});
return { audioData, modelsData };
return { audioData, modelsData, charword };
}
+53 -3
View File
@@ -23,10 +23,16 @@
import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs';
import { kindOf } from './formats.mjs';
import { pickUnitSfx, UI_SFX, BATTLE_SFX, resolveSpec } from './audio.mjs';
import { pickUnitSfx, UI_SFX, BATTLE_SFX, resolveSpec, indexVoice, VOICE_DIRS, VOICE_BATTLE_SLOTS } from './audio.mjs';
import { literal } from './manifest.mjs';
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.
*/
const VOICE_ID_LANG = 'CN';
/**
* 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
@@ -130,6 +136,14 @@ function leaf(...alts) {
return list.length ? { alts: list } : null;
}
/** One operator voice line: charword voiceAsset ('char_263_skadi/CN_023') → `audio/voice/<lang>/<charId>/cn_023.mp3`. */
function voiceAlt(asset, lang) {
const [charId, voiceId] = String(asset).split('/');
if (!charId || !voiceId || !/^[a-z0-9_]+$/i.test(charId) || !/^[a-z]{2}_\d+$/i.test(voiceId)) return null;
const file = `${charId}/${voiceId.toLowerCase()}.mp3`;
return alt(`audio/voice/${lang}/${file}`, joinUrl(RAW.aa2voice, `${VOICE_DIRS[lang]}/${file}`));
}
/** Sound path under sound_beta_2 → alternative under public/assets/audio/<sub>. */
function soundAlt(path, sub = 'sfx') {
const rel = `audio/${sub}/` + path.split('/').map(safeName).join('/');
@@ -280,6 +294,11 @@ 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 {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.
* @param {string[]} [p.extraEnemyIds] more enemy ids that can spawn (e.g. keys of data/enemies.json)
* @param {string[]} [p.extraTokenIds] more token ids (e.g. token_* keys of data/tokens.json)
* @param {Record<string,string>} [p.extraHandbook] enemyId → handbook/model id (e.g. from data/bosses.json)
@@ -293,7 +312,9 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
* official type icon (arts/ui/uniequiptype) the 干员调配 / 自选 module tiles draw when the local-client art lacks it
* @returns {{ template: any, models: Map<string, any>, notes: string[] }}
*/
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {}, extraOperators = {}, moduleTypes = [] }) {
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, charword = null, voiceLang = 'cn',
voiceSlots = VOICE_BATTLE_SLOTS,
extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {}, extraOperators = {}, moduleTypes = [] }) {
const notes = [];
/** @type {Map<string, any>} */
const models = new Map();
@@ -578,9 +599,38 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
modules[t] = leaf(alt(`module/${safeName(t)}.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 /
// 行动开始 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 预备干员
// (char_60x_c*, char_617_sharp2) and the mode's own 盟约·辅助干员 (char_616_pithst).
// 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;
}
if (Object.keys(v).length) voice[charId] = v;
}
if (!Object.keys(voice).length) notes.push('battle voice: charword_table.json has no slots (index missing?)');
const template = {
chars, enemies, tokens, bonds, items, bands, skills, skillsById, modules, ui, prof,
audio: { bgm, bossBgm: Object.fromEntries(Object.entries(bossBgm).sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true }))), sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx } },
audio: {
bgm,
bossBgm: Object.fromEntries(Object.entries(bossBgm).sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true }))),
voice,
sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx },
},
};
return { template, models, notes };
}