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:
1 parent
30d9a41c51
commit
f2bec76176
14 files changed
+772
-61
No files matched your search
+102
-12
@@ -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>
|
||||
<//>`;
|
||||
}
|
||||
Reference in new issue
Block a user