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:
1 parent
30d9a41c51
commit
f2bec76176
14 files changed
+772
-61
No files matched your search
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user