- 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).
188 lines
9.8 KiB
JavaScript
188 lines
9.8 KiB
JavaScript
// 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 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, 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;
|
||
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.
|
||
* @param {{ kind?: string }|null|undefined} detail
|
||
*/
|
||
// a card opened BY a field press (a piece, a unit, a special terrain tile: issue #184) closes on the next press of
|
||
// the field; the ones opened from the shop / hand / HUD stay until their own close button (or the flow that opened them)
|
||
export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit' || detail?.kind === 'terrain';
|
||
|
||
/**
|
||
* 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, the other
|
||
* shortcuts never act behind it.
|
||
* @param {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null} act shortcutFor
|
||
* @param {{ modal?: boolean, drawer?: boolean }} open
|
||
*/
|
||
export function shortcutBlocked(act, { modal = false, drawer = false } = {}) {
|
||
if (!act) return true;
|
||
if (modal) return true;
|
||
return !!drawer && act !== 'escape';
|
||
}
|