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

+6 -2
View File
@@ -1,17 +1,20 @@
// ui/gameLogic/settings.js — settings defaults and sanitising. Re-exported from ../gameLogic.js.
import { clamp, isObj } from './shared.js';
import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js';
// ---- settings ------------------------------------------------------------------------------------------------------
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, muted: false, damageNumbers: true, quality: 'high' });
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). */
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, muted: false, damageNumbers: true, quality: 'high', keys: DEFAULT_HOTKEYS });
const QUALITIES = ['high', 'medium', 'low'];
/**
* Sanitize persisted settings.
* @param {any} raw
* @returns {{ bgm: number, sfx: number, voice: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low' }}
* @returns {{ bgm: number, sfx: number, voice: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
* keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
*/
export function sanitizeSettings(raw) {
const r = isObj(raw) ? raw : {};
@@ -23,5 +26,6 @@ export function sanitizeSettings(raw) {
muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted,
damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers,
quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality,
keys: sanitizeHotkeys(r.keys),
};
}
+152 -16
View File
@@ -1,33 +1,169 @@
// ui/gameLogic/shortcuts.js — in-match keyboard shortcuts. Re-exported from ../gameLogic.js.
// ui/gameLogic/shortcuts.js — in-match keyboard shortcuts and the player's key map (设置 → 快捷键, 0.2.0: the community
// request 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07). Re-exported from ../gameLogic.js.
import { isObj } from './shared.js';
// ---- the key map ------------------------------------------------------------------------------------------------
/** The rebindable shortcuts in the settings' order (the ready key also pauses / resumes a solo battle). Esc is fixed. */
export const HOTKEY_ACTIONS = Object.freeze(['refresh', 'freeze', 'levelUp', 'retreat', 'sell', 'ready']);
/** Default key of each action (a KeyboardEvent.code): the keys of 0.1.4, so nothing changes for a player who never rebinds. */
export const DEFAULT_HOTKEYS = Object.freeze({ refresh: 'KeyR', freeze: 'KeyF', levelUp: 'KeyD', retreat: 'KeyQ', sell: 'KeyX', ready: 'Space' });
// The keys a shortcut may use → the `key` value each types on a US layout (lower case): letters, digits, Space, the
// punctuation keys, and six named keys whose `key` equals their `code`. Everything else stays with the interface: Esc
// (closes; cancels a rebind), Tab / Enter / the arrows (focus, buttons, the facing wheel, lists), F1–F12 (the browser's),
// the modifiers.
const KEY_OF = new Map([
...[...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'].map((c) => [`Key${c}`, c.toLowerCase()]),
...[...'0123456789'].map((c) => [`Digit${c}`, c]),
['Space', ' '], ['Backquote', '`'], ['Minus', '-'], ['Equal', '='], ['BracketLeft', '['], ['BracketRight', ']'],
['Backslash', '\\'], ['Semicolon', ';'], ['Quote', "'"], ['Comma', ','], ['Period', '.'], ['Slash', '/'],
...['Delete', 'Insert', 'Home', 'End', 'PageUp', 'PageDown'].map((k) => [k, k]),
]);
const CODE_OF = new Map([...KEY_OF].map(([code, key]) => [key, code]));
/** A key press's `key`, lower case when it is one character ('' when absent). */
const keyValue = (e) => {
const k = e?.key;
return typeof k !== 'string' ? '' : k.length === 1 ? k.toLowerCase() : k;
};
/**
* Whether a KeyboardEvent.code may be a shortcut's key.
* @param {any} code
* @returns {boolean}
*/
export const isBindableCode = (code) => typeof code === 'string' && KEY_OF.has(code);
/**
* The label a key is shown with ('KeyQ' → 'Q', 'Digit1' → '1', 'Comma' → ',', 'Space', 'Delete'); '' for any other code.
* @param {any} code
*/
export function hotkeyLabel(code) {
if (!isBindableCode(code)) return '';
if (code === 'Space') return 'Space';
const k = KEY_OF.get(code);
return k.length === 1 ? k.toUpperCase() : k;
}
/**
* Sanitize a persisted key map: each action keeps a saved key that may be a shortcut, else gets its default; a map in
* which two actions would share a key is bad data — the defaults instead.
* @param {any} raw
* @returns {Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string>}
*/
export function sanitizeHotkeys(raw) {
const r = isObj(raw) && !Array.isArray(raw) ? raw : {};
const out = {};
for (const a of HOTKEY_ACTIONS) out[a] = Object.hasOwn(r, a) && isBindableCode(r[a]) ? r[a] : DEFAULT_HOTKEYS[a];
return new Set(Object.values(out)).size === HOTKEY_ACTIONS.length ? out : { ...DEFAULT_HOTKEYS };
}
/**
* Bind `action` to `code`. A key another action holds is swapped, never shared: that action takes the old key of
* `action`, so every action keeps exactly one key and no key does two things.
* @param {any} keys the current map
* @param {string} action
* @param {string} code
* @returns {{ keys: Record<string, string>, changed: boolean, swapped: string|null }} swapped: the action that took the old key
*/
export function rebindHotkey(keys, action, code) {
const cur = sanitizeHotkeys(keys);
if (!HOTKEY_ACTIONS.includes(action) || !isBindableCode(code) || cur[action] === code) return { keys: cur, changed: false, swapped: null };
const swapped = HOTKEY_ACTIONS.find((a) => cur[a] === code) ?? null;
const next = { ...cur, [action]: code };
if (swapped) next[swapped] = cur[action];
return { keys: next, changed: true, swapped };
}
/** Whether a map is the default one. @param {any} keys */
export const isDefaultHotkeys = (keys) => {
const k = sanitizeHotkeys(keys);
return HOTKEY_ACTIONS.every((a) => k[a] === DEFAULT_HOTKEYS[a]);
};
/**
* The bindable key a key press names: the character typed — the label on the player's keycap (AZERTY, Dvorak) — else
* the physical key (a non-Latin layout, an IME, Shift + a digit); null when neither may be a shortcut.
* @param {{ key?: string, code?: string }} e
* @returns {string|null}
*/
export function hotkeyOf(e) {
const typed = CODE_OF.get(keyValue(e));
if (typed) return typed;
return isBindableCode(e?.code) ? e.code : null;
}
/**
* The one lookup of every shortcut handler: the action a key press triggers under the key map — the action bound to the
* character typed, else the one bound to the physical key (as the fixed keys of 0.1.4 matched `code === 'KeyR' ||
* key === 'r'`: a Russian layout or an IME still works by position). No modifier or focus checks (shortcutFor).
* @param {{ key?: string, code?: string }} e
* @param {any} [keys] the player's map (settings `keys`); the defaults when omitted
* @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|null}
*/
export function actionForKey(e, keys = DEFAULT_HOTKEYS) {
const map = keys === DEFAULT_HOTKEYS ? keys : sanitizeHotkeys(keys);
const find = (code) => (code ? HOTKEY_ACTIONS.find((a) => map[a] === code) ?? null : null);
return find(CODE_OF.get(keyValue(e))) ?? find(typeof e?.code === 'string' ? e.code : null);
}
// keys that only modify another (pressed alone while the settings wait for a key: ignored)
const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'AltGraph', 'Meta', 'OS', 'Super', 'Hyper', 'Fn', 'FnLock', 'CapsLock',
'NumLock', 'ScrollLock', 'Symbol', 'SymbolLock']);
/**
* What a key press does while the settings wait for a new key: Esc cancels (the dialog stays open), Tab leaves (the focus
* moves on), a lone modifier or an auto-repeat is ignored, a key held with Ctrl / ⌘ / Alt or one the interface keeps is
* refused (the wait goes on), any other key is the new key.
* @param {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean }} e
* @returns {{ kind: 'cancel'|'leave'|'ignore' } | { kind: 'refuse', reason: 'modifier'|'reserved', name: string } | { kind: 'key', code: string }}
*/
export function captureHotkey(e) {
const key = typeof e?.key === 'string' ? e.key : '';
if (key === 'Escape') return { kind: 'cancel' };
if (key === 'Tab') return { kind: 'leave' };
if (!e || e.repeat || MODIFIER_KEYS.has(key)) return { kind: 'ignore' };
const name = key === ' ' ? 'Space' : key.length === 1 ? key.toUpperCase() : key || e.code || '?';
if (e.ctrlKey || e.metaKey || e.altKey) return { kind: 'refuse', reason: 'modifier', name };
const code = hotkeyOf(e);
return code ? { kind: 'key', code } : { kind: 'refuse', reason: 'reserved', name };
}
// ---- keyboard ---------------------------------------------------------------------------------------------------
/**
* Map a keydown to a game shortcut (R refresh, F freeze, D level-up, Q retreat, X sell, Space ready, Esc close).
* Space means ready even while a HUD button has focus (a mouse click leaves the shop card / 刷新 focused, and
* Space must not re-trigger it); the caller prevents the button's own activation. Enter still activates buttons.
* Map a keydown to a game shortcut under the player's key map (defaults: R refresh, F freeze, D level-up, Q retreat,
* X sell, Space ready) or Esc (close; fixed). A shortcut key means its action even while a HUD button has focus (a
* mouse click leaves the shop card / 刷新 focused, and Space must not re-trigger it); the caller prevents the button's
* own activation. Enter still activates buttons (it cannot be a shortcut). Nothing while Ctrl / ⌘ / Alt is held or a
* text field has the focus.
* @param {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean, target?: any }} e
* @param {any} [keys] the player's map (settings `keys`)
* @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null}
*/
export function shortcutFor(e) {
export function shortcutFor(e, keys = DEFAULT_HOTKEYS) {
if (!e || e.ctrlKey || e.metaKey || e.altKey) return null;
const t = e.target;
const tag = t && typeof t.tagName === 'string' ? t.tagName.toUpperCase() : '';
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) return null;
if (e.key === 'Escape') return 'escape';
if (e.repeat) return null;
const code = e.code || '';
const key = typeof e.key === 'string' ? e.key.toLowerCase() : '';
if (code === 'KeyR' || key === 'r') return 'refresh';
if (code === 'KeyF' || key === 'f') return 'freeze';
if (code === 'KeyD' || key === 'd') return 'levelUp';
if (code === 'KeyQ' || key === 'q') return 'retreat';
if (code === 'KeyX' || key === 'x') return 'sell';
if (code === 'Space' || key === ' ') return 'ready';
return null;
return actionForKey(e, keys);
}
/**
* Whether the facing wheel swallows a key press: no ready / shop action while a facing is chosen — Space (a focused
* button must not activate either) and every key bound to a shortcut.
* @param {{ key?: string, code?: string }} e
* @param {any} [keys] the player's map
*/
export const facingSwallows = (e, keys = DEFAULT_HOTKEYS) => e?.key === ' ' || actionForKey(e, keys) != null;
/**
* Whether a press on the field closes the open detail card: a card opened from the field itself (an own piece — tap,
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
@@ -39,8 +175,8 @@ export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail
/**
* Whether an open overlay swallows a game shortcut: a modal / the guide own the keyboard (Esc included — they close
* themselves); the 本局信息 / 敌方情报 drawer is a dialog too — only Esc (it closes the drawer) passes, R / F / D / Space
* never act behind it.
* themselves); the 本局信息 / 敌方情报 drawer is a dialog too — only Esc (it closes the drawer) passes, the other
* shortcuts never act behind it.
* @param {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null} act shortcutFor
* @param {{ modal?: boolean, drawer?: boolean }} open
*/