Files
ark-stronghold-protocol/public/js/ui/gameLogic/shortcuts.js
T
sganggs f2bec76176 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).
2026-10-07 01:56:19 +08:00

188 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
}