feedback5 WAC: players rebind the in-match shortcuts in 设置 → 快捷键 (community request 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07)

- The settings (sp.pref.settings) hold a key map, action → KeyboardEvent.code, for refresh / freeze / level-up /
  retreat / sell / ready (the ready key also pauses a solo battle); the defaults are the 0.1.4 keys R F D Q X Space,
  so nothing changes for a player who never opens the section. sanitizeSettings keeps each saved key that may be a
  shortcut and gives any other its default; not a map, or two actions on one key, gives the defaults.
- One lookup, actionForKey (the character typed first, then the physical key: the 0.1.4 code-or-key match, identical
  for the defaults), serves shortcutFor(e, keys) in game.js and the facing wheel; the modifier, text-field, repeat and
  overlay guards are unchanged. The shop bar, ready / pause and underframe key hints read the map.
- 设置 → 快捷键 (the one SettingsModal, in a match and on the title screen): each action with its key; click or Enter
  waits, the next key binds; Esc cancels (the dialog stays open), Tab leaves; Ctrl / Alt / ⌘ combinations and the
  interface keys (Esc, Tab, Enter, arrows, F1–F12) are refused; a key in use is swapped and the line under the list
  names both; 恢复默认. Esc stays fixed. Touch-only devices: a keyboard note and a folded list; one column under 768 px.
- Tests: test/ui/feedback5-hotkeys.test.js (map, sanitising, conflicts, lookup, capture, handlers and hints with a
  rebound key), test/ui/gameLogic.test.js (sanitizeSettings now returns keys), test/ui/hotkeys.e2e.test.js (SP_E2E=1:
  撤退 rebound to W with the keyboard only in the mock match; phone width).

Golden unchanged (client UI only).
This commit is contained in:
sganggs committed 2026-10-07 01:56:19 +08:00
1 parent 30d9a41c51
commit f2bec76176
14 files changed
+772 -61

No files matched your search

+102 -12
View File
@@ -1,18 +1,22 @@
// Player settings (BGM/SFX/voice 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`).
// 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`) 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 { useState } from '../../vendor/hooks.module.js';
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 } from './gameLogic.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 } from './lang.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
/** Settings store: { bgm, sfx, voice, muted, damageNumbers, quality }. */
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) => {
@@ -29,6 +33,13 @@ export function updateSettings(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">
@@ -48,9 +59,89 @@ function Toggle({ label, micro, value, onChange }) {
}
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_('关闭弹窗')]];
/** 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.
@@ -81,9 +172,8 @@ export function SettingsModal({ open, onClose }) {
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>`}
<${HotkeySection} keys=${s.keys} touchUi=${touchUi} />
<p class="set-hint">${touchUi ? t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向') : t('右键查看详情')}</p>
</div>
<//>`;
}