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

+1
View File
@@ -151,6 +151,7 @@
"完成": "Done",
"背景音乐": "Music",
"干员语音": "Operator Voices",
"语音语言": "Voice Language",
"音效": "Sound Effects",
"静音": "Mute",
"显示伤害数字": "Damage Numbers",
+1
View File
@@ -157,6 +157,7 @@
"完成": "完了",
"背景音乐": "BGM",
"干员语音": "オペレーターボイス",
"语音语言": "ボイス言語",
"音效": "効果音",
"静音": "ミュート",
"显示伤害数字": "ダメージ数値を表示",
+1
View File
@@ -153,6 +153,7 @@
"完成": "완료",
"背景音乐": "배경 음악",
"干员语音": "오퍼레이터 음성",
"语音语言": "음성 언어",
"音效": "효과음",
"静音": "음소거",
"显示伤害数字": "대미지 수치 표시",
+1
View File
@@ -155,6 +155,7 @@
"完成": "完成",
"背景音乐": "背景音樂",
"干员语音": "幹員語音",
"语音语言": "語音語言",
"音效": "音效",
"静音": "靜音",
"显示伤害数字": "顯示傷害數字",
+96 -23
View File
@@ -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 {
+5 -2
View File
@@ -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}
+22 -6
View File
@@ -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;
+1 -1
View File
@@ -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';
+10 -3
View File
@@ -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,
+18 -4
View File
@@ -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 })} />