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
@@ -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 })} />
|
||||
|
||||
Reference in new issue
Block a user