Files
ark-stronghold-protocol/public/js/ui/gameLogic/shortcuts.js
T
sganggs a1bc37ed90 feedback7 R22-LATE: while a facing is chosen, Enter follows the focus — ✕ cancels, any other button does nothing, the wheel confirms; golden: unchanged
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).
2026-10-08 21:20:27 +08:00

210 lines
11 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; 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';
}