Files
ark-stronghold-protocol/public/js/ui/settings.js
T
2026-10-07 11:47:09 +08:00

183 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<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_('低')]];
/** 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 })} />
<${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>
<//>`;
}