// 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 // 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 } 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, muted, damageNumbers, quality, keys }. */ export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null))); settingsStore.subscribe((s) => { savePref('settings', sanitizeSettings(s)); audio.setVolumes(s); }); audio.setVolumes(settingsStore.get()); /** @param {Partial>} 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``; } function Toggle({ label, micro, value, onChange }) { return html`
${label}<${MicroLabel}>${micro}
`; } const QUALITY = [['high', N_('高')], ['medium', N_('中')], ['low', N_('低')]]; /** 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, 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`

${status}

`; return html`
${t('快捷键')}<${MicroLabel}>HOTKEYS <${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('恢复默认')}
${touchUi ? html`

${t('快捷键需要实体键盘;触屏设备连接键盘后可用')}

${t('查看 / 更改快捷键')}${list}
` : list}
`; } /** * 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('完成')}`}>
${t('语言')}<${MicroLabel}>LANGUAGE <${LangToggle} class="set-lang" />
${mtNote ? html`

${mtNote}

` : 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 })} /> <${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 })} />
${t('画面质量')}<${MicroLabel}>QUALITY
${QUALITY.map(([id, label]) => html``)}
<${HotkeySection} keys=${s.keys} touchUi=${touchUi} />

${touchUi ? t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向') : t('右键查看详情')}

`; }