GitHub #394 / PR #395 by @Xinglan233. The wheel's capture listener committed the previewed direction on Enter whatever had the focus, so Tab to 准备 / 设置 / the exit button / ✕ and Enter deployed (the PR let only a focused ✕ keep its native Enter). Now gameLogic facingEnter decides: Enter on ✕ cancels; on any other control (button, link, form field, ARIA control) it is swallowed — the wheel already holds Space and the shortcuts; elsewhere it commits the previewed direction, and does nothing while none is previewed. The wheel (role="dialog") takes the focus when it opens (tabIndex -1, no focus ring over the screen), so a button focused before the drop cannot swallow the confirming Enter and the first Tab reaches ✕; nothing restores the old focus when it closes (a restored 准备 would take the next key). Docs: PLAYING.md's key paragraph. Tests: test/ui/feedback5-hotkeys.test.js (facingEnter and its wiring, 2 new); test/ui/facing.e2e.test.js (SP_E2E, the PR's drag / Tab flow, 4 cases: run here in headless Chrome, 4/4 pass, 4/4 fail on the parent — Enter on the focused exit button deployed). Golden unchanged (client only).
210 lines
11 KiB
JavaScript
210 lines
11 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; behind the facing wheel only its ✕:
|
||
* facingEnter). 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;
|
||
|
||
// what a focused element is when Enter would activate it (a button, a link, a form field, an ARIA control)
|
||
const CONTROL = 'button, a[href], input, select, textarea, summary, [contenteditable=""], [contenteditable="true"], '
|
||
+ '[role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="menuitem"], [role="option"]';
|
||
|
||
/**
|
||
* What Enter does while the facing wheel is open (GitHub #394, PR #395): on the wheel's own ✕ button it cancels; on
|
||
* any other control (准备, 刷新, 设置, 玩法说明, a shop card … — Tab reaches them behind the wheel) nothing happens — the
|
||
* wheel holds the keyboard as it holds Space and the shortcuts; with the focus anywhere else (the wheel itself, the
|
||
* page) it confirms the previewed direction, and does nothing while none is previewed. null: not Enter.
|
||
* @param {{ key?: string, target?: any }} e @param {boolean} chosen a direction is previewed
|
||
* @returns {'cancel'|'commit'|'swallow'|null}
|
||
*/
|
||
export function facingEnter(e, chosen) {
|
||
if (e?.key !== 'Enter') return null;
|
||
const t = e.target;
|
||
const closest = (sel) => (t && typeof t.closest === 'function' ? t.closest(sel) : null);
|
||
if (closest('.fwheel__cancel')) return 'cancel';
|
||
if (closest(CONTROL)) return 'swallow';
|
||
return chosen ? 'commit' : 'swallow';
|
||
}
|
||
|
||
/**
|
||
* 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, a stage device: #228) 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' || detail?.kind === 'device';
|
||
|
||
/**
|
||
* 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';
|
||
}
|