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:
commit
c5e8d13636
29 files changed
+1059
-278
No files matched your search
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user