public/i18n/en.json: Chinese msgid -> English, seeded from PR #70 (@YuriRestia, credited) for every Chinese literal of the current code (≈830 entries; 966 of the 1 036 literals left for phase 2 already translated), completed by hand in the official EN terms. public/js/ui/lang.js: boot language (?lang=en|zh, then localStorage sp.pref.lang, else Chinese; the browser language is not consulted), switchLang, useLang, LangToggle; main.js renders after initLang and re-renders in place on a switch, and translates server texts (m.toast / m.ticker frames, room.closed reasons, error codes via ui/toasts.js describeError). Converted: title screen (English title, wordmark hidden), lobby, room, settings (语言 row, On/Off, quality, hotkeys), the shell (crash screen, toasts) and DifficultyTag. tools/i18n.mjs: extract (msgids + untranslated literals per file), check (msgids missing from en.json), codemod (html`` text / attributes / strings / template literals -> t(), nested strings, N_ for module level, i18n-ignore lines), seed (fill en.json from a msgid map).
89 lines
4.9 KiB
JavaScript
89 lines
4.9 KiB
JavaScript
// Player settings (BGM/SFX volume, mute, damage numbers, render quality): 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`).
|
|
|
|
import { 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 } from './gameLogic.js';
|
|
import { audio } from '../audio.js';
|
|
import { openGuide } from './guide.js';
|
|
import { detectFeatures } from './device.js';
|
|
import { LangToggle } from './lang.js';
|
|
import { t, tc, N_ } from '../../../shared/i18n.js';
|
|
|
|
/** Settings store: { bgm, sfx, muted, damageNumbers, quality }. */
|
|
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);
|
|
|
|
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_('低')]];
|
|
/** Desktop shortcuts of the hint line: key → action (msgids). */
|
|
const SHORTCUTS = [['R', N_('刷新')], ['F', N_('冻结')], ['D', N_('升级')], ['Q', N_('撤退选中干员')], ['X', N_('出售选中干员')],
|
|
['Space', N_('准备就绪')], ['Esc', N_('关闭弹窗')]];
|
|
|
|
/**
|
|
* 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);
|
|
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>
|
|
<${Slider} label=${t('背景音乐')} micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: 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>
|
|
${touchUi
|
|
? html`<p class="set-hint">${t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向')}</p>`
|
|
: html`<p class="set-hint">${t('快捷键:')}${SHORTCUTS.map(([key, label]) => html`<kbd>${key}</kbd> ${t(label)} · `)}${t('右键查看详情')}</p>`}
|
|
</div>
|
|
<//>`;
|
|
}
|