- tools/assets/plan.mjs: one voiceTree() per dub — audio.voice (--voice-lang, cn by default) and audio.voiceJp (VOICE_JP_LANG, always the JP dub, audio/voice/jp/<charId>/cn_nnn.mp3); fetch-assets plans and downloads both (stats.voiceJpChars), so setup (checkAssets walks every listed URL) fetches both too. - data/assets.json regenerated with the project's tool (node tools/fetch-assets.mjs --add-only, raw.githubusercontent.com, 107 s): the only change is audio.voiceJp + stats (+2674 files, +89,388,041 bytes, voiceJpChars 191) + hash — the rest is byte-identical; the JP tree is the Chinese one with the folder swapped (191 × 14 files, every file a valid MP3). The audio files are not committed (public/assets/ is git-ignored). - public/js/audio.js voiceLine(): 'jp' draws from voiceJp and falls back to the Chinese line per slot (a JP entry the manifest lacks) and per line at play time (the Chinese file of the same name when the host lacks the JP one — same token, same gate); operators without voice (stand-ins, 盟约·辅助干员, summons) stay silent. setVoiceLang(). - settings: voiceLang 'cn' | 'jp' (VOICE_LANGS, sanitizeSettings), the 语音语言 row under 干员语音 (each dub named in its own language, like the language switch), 语音语言 in the en / ja / ko / zh-TW packs. - tools/package.mjs FULL_ZIP_JP_VOICE (true): false holds the JP files out of the full zip (and its update zip) — not missing, not orphans, never in an update's `removed`; setup then downloads them. - Sizes measured: art download 548 MiB (was 463), full zip ≈ 505 MB (0.2.1: 428 MiB + 76 MiB of zipped JP voice), unzipped 711 MB (package --dry-run); lite zip unchanged. README, DEPLOY, ASSETS, setup's fallback size updated; PLAYING voice paragraph; §21.30 "Changed in 0.2.2" note. Tests: test/feedback7-voices.test.js (new: plan, manifest, settings, package switch), test/ui/audio.test.js (voiceLine, the manager's dub choice and fallback), test/ui/gameLogic.test.js, test/docs-consistency.test.js — all fail on the parent.
197 lines
12 KiB
JavaScript
197 lines
12 KiB
JavaScript
// 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
|
||
// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07).
|
||
|
||
import { useLayoutEffect, useState } from '../../vendor/hooks.module.js';
|
||
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
|
||
import { createStore, useStore, loadPref, savePref } from '../store.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';
|
||
import { LangToggle, machineTranslationNote } from './lang.js';
|
||
import { t, tc, N_ } from '../../../shared/i18n.js';
|
||
|
||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||
|
||
/** 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) {
|
||
settingsStore.set(sanitizeSettings({ ...settingsStore.get(), ...patch }));
|
||
}
|
||
|
||
/** Preact hook: current settings. */
|
||
export const useSettings = () => useStore((s) => s, Object.is, settingsStore);
|
||
|
||
/**
|
||
* The label of the key a rebindable shortcut has right now ('R', 'Space' …) — the HUD's key hints (shop bar, ready /
|
||
* pause, underframe). Read at render: the game screen re-renders when the settings dialog closes.
|
||
* @param {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'} action
|
||
*/
|
||
export const hotkeyLabelOf = (action) => hotkeyLabel(settingsStore.get().keys?.[action]);
|
||
|
||
function Slider({ label, micro, value, onInput, icon }) {
|
||
const pct = Math.round(value * 100);
|
||
return html`<label class="set-row">
|
||
<span class="set-row__label"><${Icon} name=${icon} />${label}<${MicroLabel}>${micro}<//></span>
|
||
<input class="set-range" type="range" min="0" max="100" step="5" value=${pct} style=${`--pct:${pct}%`}
|
||
onInput=${(e) => onInput(Number(e.currentTarget.value) / 100)} />
|
||
<span class="set-row__val num">${pct}</span>
|
||
</label>`;
|
||
}
|
||
|
||
function Toggle({ label, micro, value, onChange }) {
|
||
return html`<div class="set-row">
|
||
<span class="set-row__label">${label}<${MicroLabel}>${micro}<//></span>
|
||
<button type="button" class=${`set-toggle${value ? ' is-on' : ''}`} role="switch" aria-checked=${value ? 'true' : 'false'}
|
||
onClick=${() => onChange(!value)}><i></i><span>${value ? tc('toggle', '开启') : tc('toggle', '关闭')}</span></button>
|
||
</div>`;
|
||
}
|
||
|
||
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_('准备就绪 / 暂停(独立模拟)') };
|
||
|
||
/**
|
||
* 快捷键: each shortcut with its key. Click its key (or Enter / Space on it) and press the new one: Esc cancels (the
|
||
* dialog stays open), Tab leaves, a key held with Ctrl / Alt / ⌘ or one the interface keeps is refused, a key another
|
||
* shortcut has is swapped — the line under the list says what happened; 恢复默认 restores the keys of 0.1.4. Esc
|
||
* (cancel / close) is fixed. On a touch-only device the section says the keys need a keyboard and folds the list (an
|
||
* attached keyboard still uses it).
|
||
* @param {{ keys: Record<string, string>, touchUi: boolean }} props
|
||
*/
|
||
function HotkeySection({ keys, touchUi }) {
|
||
const [waiting, setWaiting] = useState(null); // the action waiting for its new key
|
||
const [note, setNote] = useState(null); // { text, warn }: the result line
|
||
// a layout effect: the key listener is on as soon as the key shows that it waits (an Esc right after the click must
|
||
// end the wait, never reach the dialog's own Esc)
|
||
useLayoutEffect(() => {
|
||
if (!waiting) return undefined;
|
||
const name = t(HOTKEY_NAMES[waiting]);
|
||
const onKey = (e) => {
|
||
const r = captureHotkey(e);
|
||
if (r.kind === 'leave') { setWaiting(null); return; } // Tab: the focus moves on
|
||
// ahead of the dialog's own Esc (it stays open), a focused button's Enter / Space and every other key handler
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
if (r.kind === 'ignore') return;
|
||
const cur = settingsStore.get().keys;
|
||
if (r.kind === 'cancel') {
|
||
setWaiting(null);
|
||
setNote({ text: t('已取消,「{action}」仍是 {key}', { action: name, key: hotkeyLabel(cur[waiting]) }) });
|
||
return;
|
||
}
|
||
if (r.kind === 'refuse') {
|
||
setNote({ warn: true, text: r.reason === 'modifier' ? t('快捷键只能是单个按键,不能搭配 Ctrl、Alt 或 ⌘')
|
||
: t('{key} 不能设为快捷键:Esc、Tab、Enter、方向键和功能键留给界面使用', { key: r.name }) });
|
||
return;
|
||
}
|
||
const res = rebindHotkey(cur, waiting, r.code);
|
||
updateSettings({ keys: res.keys });
|
||
setWaiting(null);
|
||
const key = hotkeyLabel(r.code);
|
||
setNote({ text: res.swapped
|
||
? t('「{action}」已改为 {key};「{other}」原来用 {key},已换成 {old}', { action: name, key, other: t(HOTKEY_NAMES[res.swapped]), old: hotkeyLabel(res.keys[res.swapped]) })
|
||
: t('「{action}」已改为 {key}', { action: name, key }) });
|
||
};
|
||
// a press anywhere but the waiting key ends the wait (another key starts its own)
|
||
const onDown = (e) => { if (!(e.target instanceof Element) || !e.target.closest('.set-key.is-waiting')) setWaiting(null); };
|
||
window.addEventListener('keydown', onKey, true);
|
||
window.addEventListener('pointerdown', onDown, true);
|
||
return () => { window.removeEventListener('keydown', onKey, true); window.removeEventListener('pointerdown', onDown, true); };
|
||
}, [waiting]);
|
||
|
||
const status = note ? note.text : waiting ? t('请按下「{action}」的新按键(Esc 取消)', { action: t(HOTKEY_NAMES[waiting]) }) : '';
|
||
const list = html`<ul class="set-keys__list">
|
||
${HOTKEY_ACTIONS.map((a) => {
|
||
const on = waiting === a;
|
||
const label = hotkeyLabel(keys?.[a]);
|
||
return html`<li key=${a} class="set-keys__row">
|
||
<span class="set-keys__name">${t(HOTKEY_NAMES[a])}</span>
|
||
<button type="button" class=${cx('set-key', on && 'is-waiting')} data-action=${a} aria-pressed=${on ? 'true' : 'false'}
|
||
aria-label=${t('更改「{action}」的快捷键(当前:{key})', { action: t(HOTKEY_NAMES[a]), key: label })}
|
||
onClick=${() => { setNote(null); setWaiting((w) => (w === a ? null : a)); }}>
|
||
${on ? html`<span class="set-key__wait">${t('按下新按键…')}</span>` : html`<kbd>${label}</kbd>`}
|
||
</button>
|
||
</li>`;
|
||
})}
|
||
<li class="set-keys__row is-fixed">
|
||
<span class="set-keys__name">${t('取消 / 关闭')}</span>
|
||
<span class="set-key is-fixed"><kbd>Esc</kbd><small>${t('不可更改')}</small></span>
|
||
</li>
|
||
</ul>
|
||
<p class=${cx('set-keys__note', note?.warn && 'is-warn')} role="status" aria-live="polite">${status}</p>`;
|
||
return html`<section class="set-keys" aria-labelledby="set-keys-title">
|
||
<div class="set-keys__head">
|
||
<span class="set-row__label" id="set-keys-title">${t('快捷键')}<${MicroLabel}>HOTKEYS<//></span>
|
||
<${Button} variant="ghost" size="sm" icon="refresh" class="set-keys__reset" disabled=${isDefaultHotkeys(keys)}
|
||
onClick=${() => { setWaiting(null); updateSettings({ keys: DEFAULT_HOTKEYS }); setNote({ text: t('已恢复默认快捷键') }); }}>${t('恢复默认')}<//>
|
||
</div>
|
||
${touchUi ? html`<p class="set-hint">${t('快捷键需要实体键盘;触屏设备连接键盘后可用')}</p>
|
||
<details class="set-keys__more"><summary>${t('查看 / 更改快捷键')}</summary>${list}</details>` : list}
|
||
</section>`;
|
||
}
|
||
|
||
/**
|
||
* Settings modal.
|
||
* @param {{ open: boolean, onClose: Function }} props
|
||
*/
|
||
export function SettingsModal({ open, onClose }) {
|
||
const s = useSettings();
|
||
const [tested, setTested] = useState(false);
|
||
const [touchUi] = useState(() => detectFeatures().coarse && !detectFeatures().fine);
|
||
const mtNote = machineTranslationNote(); // a pack marked as machine translation says so under the switch
|
||
return html`<${Modal} open=${open} onClose=${onClose} title=${t('设置')} micro="SETTINGS" width="7.4rem"
|
||
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>${t('玩法说明')}<//>
|
||
<${Button} variant="primary" icon="check" onClick=${onClose}>${t('完成')}<//>`}>
|
||
<div class="set-list">
|
||
<div class="set-row">
|
||
<span class="set-row__label">${t('语言')}<${MicroLabel}>LANGUAGE<//></span>
|
||
<${LangToggle} class="set-lang" />
|
||
</div>
|
||
${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 })} />
|
||
<${Toggle} label=${t('显示伤害数字')} micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
|
||
<div class="set-row">
|
||
<span class="set-row__label">${t('画面质量')}<${MicroLabel}>QUALITY<//></span>
|
||
<div class="set-seg" role="radiogroup">
|
||
${QUALITY.map(([id, label]) => html`<button key=${id} type="button" role="radio" aria-checked=${s.quality === id ? 'true' : 'false'}
|
||
class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${t(label)}</button>`)}
|
||
</div>
|
||
</div>
|
||
<${HotkeySection} keys=${s.keys} touchUi=${touchUi} />
|
||
<p class="set-hint">${touchUi ? t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向') : t('右键查看详情')}</p>
|
||
</div>
|
||
<//>`;
|
||
}
|