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:
sganggs committed 2026-10-07 01:56:19 +08:00
1 parent 30d9a41c51
commit f2bec76176
14 files changed
+772 -61

No files matched your search

+23
View File
@@ -514,6 +514,29 @@
.set-seg button.is-on { background: var(--mint-500); color: var(--text-on-mint); font-weight: 700; }
.set-hint { margin-top: .06rem; font-size: .14rem; color: var(--text-dim); line-height: 1.8; }
.set-hint kbd, .lvcard__key, .toolbtn kbd { font-family: var(--font-num); font-size: .11rem; border: 1px solid var(--line-2); padding: 0 .04rem; color: var(--text-lo); background: rgba(0, 0, 0, .3); }
/* 快捷键 (ui/settings.js HotkeySection): each shortcut with its key — click the key, press the new one; two columns,
one under 768 px (phones in landscape) */
.set-keys { display: flex; flex-direction: column; gap: .08rem; padding-top: .1rem; border-top: 1px solid var(--line); }
.set-keys__head { display: flex; align-items: center; justify-content: space-between; gap: .12rem; min-height: .44rem; }
.set-keys__list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .06rem .14rem; margin: 0; padding: 0; list-style: none; }
.set-keys__row { display: flex; align-items: center; justify-content: space-between; gap: .1rem; min-height: .42rem; padding: .03rem .03rem .03rem .12rem; background: rgba(0, 0, 0, .22); border: 1px solid var(--line); }
.set-keys__name { min-width: 0; font-size: .15rem; line-height: 1.35; color: var(--text-md); overflow-wrap: anywhere; }
.set-key { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: .06rem; min-width: .9rem; height: .36rem; padding: 0 .1rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--mint-400); font-family: var(--font-num); font-size: .15rem; cursor: pointer; }
.set-key kbd { font: inherit; }
.set-key:hover, .set-key:focus-visible { border-color: var(--mint-700); }
.set-key.is-waiting { border-color: var(--amber); color: var(--amber); font-family: inherit; font-size: .13rem; animation: set-key-wait 1s ease-in-out infinite alternate; }
.set-key.is-fixed { cursor: default; color: var(--text-lo); border-style: dashed; }
.set-key.is-fixed small { font-family: inherit; font-size: .12rem; color: var(--text-dim); }
.set-keys__note { min-height: 1.5em; font-size: .14rem; line-height: 1.5; color: var(--mint-400); }
.set-keys__note.is-warn { color: var(--amber); }
.set-keys__more > summary { padding: .06rem 0; font-size: .15rem; color: var(--mint-400); cursor: pointer; }
.set-keys__more[open] > summary { margin-bottom: .08rem; }
@keyframes set-key-wait { to { box-shadow: 0 0 .12rem rgba(246, 163, 41, .45); } }
@media (max-width: 767px) { .set-keys__list { grid-template-columns: minmax(0, 1fr); } }
@media (max-height: 600px) and (pointer: coarse), (max-height: 600px) and (any-pointer: coarse) {
.set-key { height: max(.38rem, 30px); }
.set-keys__more > summary { padding: max(.06rem, 8px) 0; }
}
/* ---- exit / away ------------------------------------------------------------------------------------------------------ */
.exitm { display: flex; flex-direction: column; gap: .1rem; font-size: .17rem; line-height: 1.7; }
+28 -12
View File
@@ -138,7 +138,7 @@
"显示伤害数字": "Damage Numbers",
"画面质量": "Graphics Quality",
"触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "Touch controls: tap a unit to select it (Retreat / Sell) · long-press a unit or card for details · after dragging to deploy, swipe to choose a direction",
"快捷键:": "Hotkeys: ",
"快捷键": "Hotkeys",
"发生未知错误": "An unknown error occurred",
"标准模拟": "Standard Simulation",
"险境模拟": "Perilous Simulation",
@@ -610,8 +610,8 @@
"休整期可以查看即将迎击的敌方单位": "Upcoming enemies can be viewed during the Rest Phase",
"已流失": "Lost",
"暂停": "Pause",
"继续作战(Space)": "Resume Battle (Space)",
"暂停作战(Space)": "Pause Battle (Space)",
"继续作战({key})": "Resume Battle ({key})",
"暂停作战({key})": "Pause Battle ({key})",
"离开 / 暂离": "Leave / Step Away",
"部署费用(再部署消耗)": "DP Cost (spent on redeploy)",
"攻击力": "ATK",
@@ -660,7 +660,7 @@
"已购买": "Purchased",
"已招募": "Recruited",
"再次点击确认升级({price} 资金)": "Tap again to confirm upgrade ({price} Funds)",
"升级调度中心({price} 资金) · D": "Upgrade Dispatch Center ({price} Funds) · D",
"升级调度中心({price} 资金) · {key}": "Upgrade Dispatch Center ({price} Funds) · {key}",
"已满级": "Max Level",
"确认升级": "Confirm Upgrade",
"稍后选择(回合结束后消失)": "Pick later (expires at the end of the round)",
@@ -670,10 +670,10 @@
"剩余可放置角色:": "Deployable units left:",
"目前资金": "Current Funds",
"收起": "Collapse",
"解冻商店 · F": "Unfreeze Shop · F",
"冻结商店(下回合保留) · F": "Freeze Shop (kept next round) · F",
"解冻商店 · {key}": "Unfreeze Shop · {key}",
"冻结商店(下回合保留) · {key}": "Freeze Shop (kept next round) · {key}",
"解冻": "Unfreeze",
"刷新商店 · R": "Refresh Shop · R",
"刷新商店 · {key}": "Refresh Shop · {key}",
"免费 ×{free}": "Free ×{free}",
"查看 {name} 的战场": "View {name}'s Battlefield",
"查看自己的阵地": "View your Field",
@@ -765,9 +765,25 @@
"toggle::关闭": "Off",
"撤退选中干员": "Retreat selected Operator",
"出售选中干员": "Sell selected Operator",
"关闭弹窗": "Close window",
"取消 / 关闭": "Cancel / Close",
"语言": "Language",
"右键查看详情": "Right-click for details",
"刷新商店": "Refresh Shop",
"冻结 / 解冻商店": "Freeze / Unfreeze Shop",
"升级调度中心": "Upgrade Dispatch Center",
"准备就绪 / 暂停(独立模拟)": "Ready / Pause (Solo)",
"不可更改": "Fixed",
"按下新按键…": "Press a key…",
"更改「{action}」的快捷键(当前:{key})": "Change the hotkey for {action} (now {key})",
"请按下「{action}」的新按键(Esc 取消)": "Press the new key for {action} (Esc to cancel)",
"「{action}」已改为 {key}": "{action} is now {key}",
"「{action}」已改为 {key};「{other}」原来用 {key},已换成 {old}": "{action} is now {key}; {other} had {key} and now uses {old}",
"{key} 不能设为快捷键:Esc、Tab、Enter、方向键和功能键留给界面使用": "{key} cannot be a hotkey: Esc, Tab, Enter, the arrow keys and the function keys belong to the interface",
"快捷键只能是单个按键,不能搭配 Ctrl、Alt 或 ⌘": "A hotkey is a single key, without Ctrl, Alt or ⌘",
"已取消,「{action}」仍是 {key}": "Cancelled: {action} stays {key}",
"已恢复默认快捷键": "Default hotkeys restored",
"快捷键需要实体键盘;触屏设备连接键盘后可用": "Hotkeys need a physical keyboard; connect one to use them on a touch device",
"查看 / 更改快捷键": "Show / change hotkeys",
"观战中无法进行该操作": "Spectators cannot do that",
"标准": "Standard",
"险境": "Perilous",
@@ -1001,10 +1017,10 @@
"目标生命值 {lp},联防中,结算时扣除至多 {pending} 点": "LP {lp}, Unite in progress: settlement costs up to {pending} LP",
"目标生命值 {lp},结算时扣除 {pending} 点": "LP {lp}: settlement costs {pending} LP",
"{name} 操作": "{name} actions",
"撤退至整备区(Q)": "Retreat to Bench (Q)",
"撤退[Q]": "Retreat [Q]",
"出售(+{sell} 资金,X)": "Sell (+{sell} Funds, X)",
"出售[X]": "Sell [X]",
"撤退至整备区({key})": "Retreat to Bench ({key})",
"撤退[{key}]": "Retreat [{key}]",
"出售(+{sell} 资金,{key})": "Sell (+{sell} Funds, {key})",
"出售[{key}]": "Sell [{key}]",
"临时整备区 {n} 件道具待处理": "Temporary Bench: {n} {count|item|items} pending",
"{name}:部分盟约所含干员阵容不完整({bannedN} 名干员无法出现)": "{name}: some of the Alliance's Operators cannot appear ({bannedN} {bannedN|Operator|Operators} banned)",
"{name}:部分盟约所含干员阵容不完整": "{name}: some of the Alliance's Operators cannot appear",
+4 -4
View File
@@ -80,7 +80,7 @@ import { Ticker } from '../ui/ticker.js';
import { EmoteWheel } from '../ui/emotes.js';
import { EffectsList } from '../ui/effectsList.js';
import { CombatHud } from '../ui/combatHud.js';
import { SettingsModal } from '../ui/settings.js';
import { SettingsModal, settingsStore } from '../ui/settings.js';
import { ExitModal, AwayOverlay, awayStore } from '../ui/matchChrome.js';
import { openGuide } from '../ui/guide.js';
import { actions } from '../ui/gameActions.js';
@@ -1094,7 +1094,7 @@ function MatchScreen() {
// ---- keyboard ---------------------------------------------------------------------------------------------
useEffect(() => {
const onKey = async (e) => {
const act = shortcutFor(e);
const act = shortcutFor(e, settingsStore.get().keys); // the player's key map (设置 → 快捷键)
const L = live.current;
// dialogs / the guide own the keyboard; behind the 本局信息 / 敌方情报 drawer only Esc (closing it) acts
if (shortcutBlocked(act, { modal: !!document.querySelector('.modal, .guide'), drawer: !!L.drawer })) return;
@@ -1108,7 +1108,7 @@ function MatchScreen() {
e.preventDefault();
return;
}
// Space pauses / resumes a solo battle (the official battle key)
// the ready key (Space unless rebound) pauses / resumes a solo battle (Space: the official battle key)
if (act === 'ready' && (L.canPause || L.paused)) {
e.preventDefault();
if (e.target instanceof HTMLElement && e.target.closest('button, [role="button"]')) e.target.blur();
@@ -1116,7 +1116,7 @@ function MatchScreen() {
return;
}
if (L.pub?.phase !== PHASE.PREP || !L.priv) return;
e.preventDefault(); // a focused HUD button must not also activate (Space) — see shortcutFor
e.preventDefault(); // a focused HUD button must not also activate (Space, or any key bound) — see shortcutFor
if (act === 'ready' && e.target instanceof HTMLElement && e.target.closest('button, [role="button"]')) e.target.blur();
if (act === 'ready') {
const refused = !L.priv.ready ? shopBlockReason('ready', { priv: L.priv, editable: true }) : null;
+4 -1
View File
@@ -25,6 +25,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from '../../vendor/hooks
import { html } from './components.js';
import { LocalSprite } from './gameComponents.js';
import { DIRS, DIR_LABEL, DEAD_ZONE_TILES, dirFromDelta, dirFromKey, rangeTiles, normDir, boardDir, viewMirrored } from './facing.js';
import { facingSwallows } from './gameLogic.js';
import { settingsStore } from './settings.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -242,7 +244,8 @@ export function FacingWheel({ view, row, col, grid, name = '', onPreview, onComm
const k = dirFromKey(e.key);
if (k) { e.preventDefault(); e.stopImmediatePropagation(); setDir(k); return; }
if (e.key === 'Enter' && L.dir) { e.preventDefault(); e.stopImmediatePropagation(); L.onCommit(L.bdir); return; }
if (e.key === ' ' || /^Key[RFD]$/.test(e.code || '')) { e.preventDefault(); e.stopImmediatePropagation(); } // no ready / shop while choosing
// no ready / shop while choosing: Space and every key of the player's map (设置 → 快捷键)
if (facingSwallows(e, settingsStore.get().keys)) { e.preventDefault(); e.stopImmediatePropagation(); }
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
+1 -1
View File
@@ -38,7 +38,7 @@ export { terrainInfo } from './gameLogic/terrain.js';
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
export { shortcutFor, closesOnFieldPress, shortcutBlocked } from './gameLogic/shortcuts.js';
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows } from './gameLogic/shortcuts.js';
export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js';
export { normalizeResult } from './gameLogic/result.js';
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
+6 -2
View File
@@ -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),
};
}
+152 -16
View File
@@ -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
*/
+4 -2
View File
@@ -31,6 +31,7 @@ import { localAsset } from '../data.js';
import { serverNow } from '../store.js';
import { isCombatPhase, isBossPhase, prepCapsuleLabel, bossFrac, bossPctText, fmtNum, shopBlockReason } from './gameLogic.js';
import { overtimeState, overtimeDrainPerSec, remainAt } from './matchStatus.js';
import { hotkeyLabelOf } from './settings.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -218,7 +219,7 @@ export function ReadyToggle({ priv, onToggle, busy, readyCount, total }) {
aria-pressed=${ready ? 'true' : 'false'} aria-describedby=${!ready && temp.count ? 'readywrap-why' : undefined} onClick=${() => onToggle(!ready)}>
<span class="readybtn__box">${ready ? html`<${Icon} name="check" />` : null}</span>
<span class="readybtn__label">${ready ? t('取消准备') : t('准备就绪')}</span>
<kbd class="readybtn__key">Space</kbd>
<kbd class="readybtn__key">${hotkeyLabelOf('ready')}</kbd>
</button>`;
return html`<div class="readywrap">
${reason ? html`<${Tooltip} text=${reason} placement="bottom">${btn}<//>` : btn}
@@ -290,7 +291,8 @@ function PauseGlyph() {
*/
export function PauseButton({ paused, busy = false, onToggle }) {
const label = paused ? t('继续作战') : t('暂停');
return html`<${Tooltip} text=${paused ? t('继续作战(Space)') : t('暂停作战(Space)')} placement="bottom">
const key = hotkeyLabelOf('ready'); // the ready key pauses a solo battle (Space unless rebound: 设置 → 快捷键)
return html`<${Tooltip} text=${paused ? t('继续作战({key})', { key }) : t('暂停作战({key})', { key })} placement="bottom">
<button type="button" class=${cx('pausebtn', 'tapx', paused && 'is-on', busy && 'is-busy')} aria-pressed=${paused ? 'true' : 'false'}
aria-label=${label} disabled=${busy} data-testid="pause" onClick=${() => onToggle?.()}>
${paused ? html`<${Icon} name="play" class="pausebtn__glyph" />` : html`<${PauseGlyph} />`}
+102 -12
View File
@@ -1,18 +1,22 @@
// Player settings (BGM/SFX/voice volume, mute, damage numbers, render quality): a tiny observable store
// persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
// the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`).
// Player settings (BGM/SFX/voice volume, mute, damage numbers, render quality, the shortcut keys): a tiny observable
// store persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
// the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`) and the 快捷键
// section that rebinds the in-match shortcuts (the key map: ui/gameLogic/shortcuts.js; the community request
// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07).
import { useState } from '../../vendor/hooks.module.js';
import { useLayoutEffect, useState } from '../../vendor/hooks.module.js';
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
import { createStore, useStore, loadPref, savePref } from '../store.js';
import { sanitizeSettings } from './gameLogic.js';
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey } from './gameLogic.js';
import { audio } from '../audio.js';
import { openGuide } from './guide.js';
import { detectFeatures } from './device.js';
import { LangToggle } from './lang.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
/** Settings store: { bgm, sfx, voice, muted, damageNumbers, quality }. */
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Settings store: { bgm, sfx, voice, muted, damageNumbers, quality, keys }. */
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
settingsStore.subscribe((s) => {
@@ -29,6 +33,13 @@ export function updateSettings(patch) {
/** Preact hook: current settings. */
export const useSettings = () => useStore((s) => s, Object.is, settingsStore);
/**
* The label of the key a rebindable shortcut has right now ('R', 'Space' …) — the HUD's key hints (shop bar, ready /
* pause, underframe). Read at render: the game screen re-renders when the settings dialog closes.
* @param {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'} action
*/
export const hotkeyLabelOf = (action) => hotkeyLabel(settingsStore.get().keys?.[action]);
function Slider({ label, micro, value, onInput, icon }) {
const pct = Math.round(value * 100);
return html`<label class="set-row">
@@ -48,9 +59,89 @@ function Toggle({ label, micro, value, onChange }) {
}
const QUALITY = [['high', N_('高')], ['medium', N_('中')], ['low', N_('低')]];
/** Desktop shortcuts of the hint line: key → action (msgids). */
const SHORTCUTS = [['R', N_('刷新')], ['F', N_('冻结')], ['D', N_('升级')], ['Q', N_('撤退选中干员')], ['X', N_('出售选中干员')],
['Space', N_('准备就绪')], ['Esc', N_('关闭弹窗')]];
/** The rebindable shortcuts' names (msgids), by action. */
const HOTKEY_NAMES = { refresh: N_('刷新商店'), freeze: N_('冻结 / 解冻商店'), levelUp: N_('升级调度中心'), retreat: N_('撤退选中干员'),
sell: N_('出售选中干员'), ready: N_('准备就绪 / 暂停(独立模拟)') };
/**
* 快捷键: each shortcut with its key. Click its key (or Enter / Space on it) and press the new one: Esc cancels (the
* dialog stays open), Tab leaves, a key held with Ctrl / Alt / ⌘ or one the interface keeps is refused, a key another
* shortcut has is swapped — the line under the list says what happened; 恢复默认 restores the keys of 0.1.4. Esc
* (cancel / close) is fixed. On a touch-only device the section says the keys need a keyboard and folds the list (an
* attached keyboard still uses it).
* @param {{ keys: Record<string, string>, touchUi: boolean }} props
*/
function HotkeySection({ keys, touchUi }) {
const [waiting, setWaiting] = useState(null); // the action waiting for its new key
const [note, setNote] = useState(null); // { text, warn }: the result line
// a layout effect: the key listener is on as soon as the key shows that it waits (an Esc right after the click must
// end the wait, never reach the dialog's own Esc)
useLayoutEffect(() => {
if (!waiting) return undefined;
const name = t(HOTKEY_NAMES[waiting]);
const onKey = (e) => {
const r = captureHotkey(e);
if (r.kind === 'leave') { setWaiting(null); return; } // Tab: the focus moves on
// ahead of the dialog's own Esc (it stays open), a focused button's Enter / Space and every other key handler
e.preventDefault();
e.stopImmediatePropagation();
if (r.kind === 'ignore') return;
const cur = settingsStore.get().keys;
if (r.kind === 'cancel') {
setWaiting(null);
setNote({ text: t('已取消,「{action}」仍是 {key}', { action: name, key: hotkeyLabel(cur[waiting]) }) });
return;
}
if (r.kind === 'refuse') {
setNote({ warn: true, text: r.reason === 'modifier' ? t('快捷键只能是单个按键,不能搭配 Ctrl、Alt 或 ⌘')
: t('{key} 不能设为快捷键:Esc、Tab、Enter、方向键和功能键留给界面使用', { key: r.name }) });
return;
}
const res = rebindHotkey(cur, waiting, r.code);
updateSettings({ keys: res.keys });
setWaiting(null);
const key = hotkeyLabel(r.code);
setNote({ text: res.swapped
? t('「{action}」已改为 {key};「{other}」原来用 {key},已换成 {old}', { action: name, key, other: t(HOTKEY_NAMES[res.swapped]), old: hotkeyLabel(res.keys[res.swapped]) })
: t('「{action}」已改为 {key}', { action: name, key }) });
};
// a press anywhere but the waiting key ends the wait (another key starts its own)
const onDown = (e) => { if (!(e.target instanceof Element) || !e.target.closest('.set-key.is-waiting')) setWaiting(null); };
window.addEventListener('keydown', onKey, true);
window.addEventListener('pointerdown', onDown, true);
return () => { window.removeEventListener('keydown', onKey, true); window.removeEventListener('pointerdown', onDown, true); };
}, [waiting]);
const status = note ? note.text : waiting ? t('请按下「{action}」的新按键(Esc 取消)', { action: t(HOTKEY_NAMES[waiting]) }) : '';
const list = html`<ul class="set-keys__list">
${HOTKEY_ACTIONS.map((a) => {
const on = waiting === a;
const label = hotkeyLabel(keys?.[a]);
return html`<li key=${a} class="set-keys__row">
<span class="set-keys__name">${t(HOTKEY_NAMES[a])}</span>
<button type="button" class=${cx('set-key', on && 'is-waiting')} data-action=${a} aria-pressed=${on ? 'true' : 'false'}
aria-label=${t('更改「{action}」的快捷键(当前:{key})', { action: t(HOTKEY_NAMES[a]), key: label })}
onClick=${() => { setNote(null); setWaiting((w) => (w === a ? null : a)); }}>
${on ? html`<span class="set-key__wait">${t('按下新按键…')}</span>` : html`<kbd>${label}</kbd>`}
</button>
</li>`;
})}
<li class="set-keys__row is-fixed">
<span class="set-keys__name">${t('取消 / 关闭')}</span>
<span class="set-key is-fixed"><kbd>Esc</kbd><small>${t('不可更改')}</small></span>
</li>
</ul>
<p class=${cx('set-keys__note', note?.warn && 'is-warn')} role="status" aria-live="polite">${status}</p>`;
return html`<section class="set-keys" aria-labelledby="set-keys-title">
<div class="set-keys__head">
<span class="set-row__label" id="set-keys-title">${t('快捷键')}<${MicroLabel}>HOTKEYS<//></span>
<${Button} variant="ghost" size="sm" icon="refresh" class="set-keys__reset" disabled=${isDefaultHotkeys(keys)}
onClick=${() => { setWaiting(null); updateSettings({ keys: DEFAULT_HOTKEYS }); setNote({ text: t('已恢复默认快捷键') }); }}>${t('恢复默认')}<//>
</div>
${touchUi ? html`<p class="set-hint">${t('快捷键需要实体键盘;触屏设备连接键盘后可用')}</p>
<details class="set-keys__more"><summary>${t('查看 / 更改快捷键')}</summary>${list}</details>` : list}
</section>`;
}
/**
* Settings modal.
@@ -81,9 +172,8 @@ export function SettingsModal({ open, onClose }) {
class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${t(label)}</button>`)}
</div>
</div>
${touchUi
? html`<p class="set-hint">${t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向')}</p>`
: html`<p class="set-hint">${t('快捷键:')}${SHORTCUTS.map(([key, label]) => html`<kbd>${key}</kbd> ${t(label)} · `)}${t('右键查看详情')}</p>`}
<${HotkeySection} keys=${s.keys} touchUi=${touchUi} />
<p class="set-hint">${touchUi ? t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向') : t('右键查看详情')}</p>
</div>
<//>`;
}
+8 -6
View File
@@ -30,6 +30,7 @@ import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js'
import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, ownStandIn, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, diyGetter } from './gameLogic.js';
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { hotkeyLabelOf } from './settings.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -179,7 +180,7 @@ function LevelCard({ shop, reason, armed = false, onTap }) {
const max = lv >= (shop?.maxLevel ?? 6);
const price = shop?.upgradePrice ?? 0;
return html`<button type="button" class=${cx('lvcard', max && 'is-max', reason && 'is-disabled', armed && 'is-armed')} onClick=${() => !reason && onTap()}
title=${reason || (armed ? t('再次点击确认升级({price} 资金)', { price }) : t('升级调度中心({price} 资金) · D', { price }))} aria-disabled=${reason ? 'true' : 'false'}
title=${reason || (armed ? t('再次点击确认升级({price} 资金)', { price }) : t('升级调度中心({price} 资金) · {key}', { price, key: hotkeyLabelOf('levelUp') }))} aria-disabled=${reason ? 'true' : 'false'}
aria-pressed=${String(!!armed)}>
${!max ? html`<${HexBadge} value=${price} tone=${reason && reason !== t('调度中心已达最高等级') ? 'dark' : 'gold'} size="md" class="lvcard__price" />` : null}
<span class="lvcard__frame">
@@ -187,7 +188,7 @@ function LevelCard({ shop, reason, armed = false, onTap }) {
<b class="lvcard__num num">${lv}</b>
</span>
<span class="lvcard__label">${max ? t('已满级') : armed ? t('确认升级') : t('升级')}</span>
<kbd class="lvcard__key">D</kbd>
<kbd class="lvcard__key">${hotkeyLabelOf('levelUp')}</kbd>
</button>`;
}
@@ -323,19 +324,20 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
</div>`;
}
const hk = { refresh: hotkeyLabelOf('refresh'), freeze: hotkeyLabelOf('freeze') }; // the player's keys (设置 → 快捷键)
return html`<section class=${cx('shopbar', frozen && 'is-frozen', !editable && 'is-locked', showReward && 'has-reward', armed && 'has-armed')} ref=${barRef} aria-label=${t('调度中心')}>
<div class="shopbar__tools">
<span class="shopbar__remain">${t('剩余可放置角色:')}<b class=${cx('num', remaining === 0 && 't-orange')}>${remaining}</b></span>
<button type="button" class=${cx('toolbtn', 'toolbtn--ice', frozen && 'is-on')} disabled=${!!frzReason} onClick=${onFreeze}
title=${frzReason || (frozen ? t('解冻商店 · F') : t('冻结商店(下回合保留) · F'))}>
title=${frzReason || (frozen ? t('解冻商店 · {key}', { key: hk.freeze }) : t('冻结商店(下回合保留) · {key}', { key: hk.freeze }))}>
<${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} />
<span>${frozen ? t('解冻') : t('冻结')}</span><kbd>F</kbd>
<span>${frozen ? t('解冻') : t('冻结')}</span><kbd>${hk.freeze}</kbd>
</button>
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || t('刷新商店 · R')}>
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || t('刷新商店 · {key}', { key: hk.refresh })}>
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
<span>${t('刷新')}</span>
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
<kbd>R</kbd>
<kbd>${hk.refresh}</kbd>
</button>
</div>
<div class="shopbar__row">
+6 -4
View File
@@ -27,6 +27,7 @@
import { html, HexBadge, Icon } from './components.js';
import { GIcon } from './gameComponents.js';
import { useTileScreen } from './facingWheel.js';
import { hotkeyLabelOf } from './settings.js';
import { localAsset } from '../data.js';
import { GEO } from '../../../shared/constants.js';
import { t, tParts } from '../../../shared/i18n.js';
@@ -85,6 +86,7 @@ export function Underframe({ view, uid = null, row, col, actions, name = '', bus
const s = g.s > 0 ? g.s : 64;
const half = s * 1.05;
const stop = (e) => e.stopPropagation();
const keys = { retreat: hotkeyLabelOf('retreat'), sell: hotkeyLabelOf('sell') }; // the player's keys (设置 → 快捷键)
return html`<div class="uframe" data-uid=${uid} style=${`left:${g.x}px;top:${g.y}px;width:${half * 2}px;height:${half * 2}px`} role="group"
aria-label=${t('{name} 操作', { name: name || t('单位') })}>
<svg class="uframe__dia" viewBox="-110 -110 220 220" aria-hidden="true">
@@ -92,13 +94,13 @@ export function Underframe({ view, uid = null, row, col, actions, name = '', bus
<path class="uframe__corner" d="M-100 0 L-86 -14 M-100 0 L-86 14 M100 0 L86 -14 M100 0 L86 14 M0 -100 L-14 -86 M0 -100 L14 -86 M0 100 L-14 86 M0 100 L14 86" />
</svg>
${actions.retreat ? html`<button type="button" class="uframe__btn uframe__btn--retreat" disabled=${busy} onPointerDown=${stop}
onClick=${(e) => { stop(e); onRetreat?.(); }} title=${actions.sell != null ? t('撤退至整备区(Q)') : t('撤退至整备区')} aria-label=${t('撤退')} aria-keyshortcuts=${actions.sell != null ? 'Q' : undefined}>
<${RetreatGlyph} /><span class="uframe__label">${actions.sell != null ? t('撤退[Q]') : t('撤退')}</span>
onClick=${(e) => { stop(e); onRetreat?.(); }} title=${actions.sell != null ? t('撤退至整备区({key})', { key: keys.retreat }) : t('撤退至整备区')} aria-label=${t('撤退')} aria-keyshortcuts=${actions.sell != null ? keys.retreat : undefined}>
<${RetreatGlyph} /><span class="uframe__label">${actions.sell != null ? t('撤退[{key}]', { key: keys.retreat }) : t('撤退')}</span>
</button>` : null}
${actions.sell != null ? html`<button type="button" class="uframe__btn uframe__btn--sell" disabled=${busy} onPointerDown=${stop}
onClick=${(e) => { stop(e); onSell?.(); }} title=${t('出售(+{sell} 资金,X)', { sell: actions.sell })} aria-label=${t('出售,获得 {sell} 资金', { sell: actions.sell })} aria-keyshortcuts="X">
onClick=${(e) => { stop(e); onSell?.(); }} title=${t('出售(+{sell} 资金,{key})', { sell: actions.sell, key: keys.sell })} aria-label=${t('出售,获得 {sell} 资金', { sell: actions.sell })} aria-keyshortcuts=${keys.sell}>
<${PlateIcon} sprite="icon_sell" glyph="sell" tone="sell" />
<span class="uframe__label">${t('出售[X]')}</span>
<span class="uframe__label">${t('出售[{key}]', { key: keys.sell })}</span>
<${HexBadge} value=${`+${actions.sell}`} tone="gold" size="sm" class="uframe__price" />
</button>` : null}
${actions.destroy ? html`<button type="button" class=${cx('uframe__btn', 'uframe__btn--destroy')} disabled=${busy} onPointerDown=${stop}
+291
View File
@@ -0,0 +1,291 @@
// 0.2.0 rebindable shortcuts (设置 → 快捷键; the community request 「快捷键可不可以自己设置」, the owner's decision of
// 2026-10-07): the key map in the settings (ui/gameLogic/shortcuts.js, ui/gameLogic/settings.js sanitizeSettings) —
// defaults = the keys of 0.1.4, sanitising and its fallback to the defaults, conflicts swapped, the one lookup every
// handler uses, the capture of a new key — the handlers with a rebound key, and the HUD's key hints read from the map.
// Browser counterpart: test/ui/hotkeys.e2e.test.js (SP_E2E=1).
import { test, describe, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf,
actionForKey, captureHotkey, facingSwallows, shortcutFor, shortcutBlocked, sanitizeSettings, DEFAULT_SETTINGS,
} from '../../public/js/ui/gameLogic.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
// the bindable codes, as the settings offer them
const LETTERS = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'].map((c) => `Key${c}`);
const DIGITS = [...'0123456789'].map((c) => `Digit${c}`);
const PUNCT = ['Backquote', 'Minus', 'Equal', 'BracketLeft', 'BracketRight', 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash'];
const NAMED = ['Space', 'Delete', 'Insert', 'Home', 'End', 'PageUp', 'PageDown'];
const BINDABLE = [...LETTERS, ...DIGITS, ...PUNCT, ...NAMED];
const RESERVED = ['Escape', 'Tab', 'Enter', 'NumpadEnter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'F1', 'F5', 'F12',
'ShiftLeft', 'ControlLeft', 'AltLeft', 'MetaLeft', 'CapsLock', 'Backspace', 'ContextMenu', 'IntlBackslash', '', 'keyq', 'constructor', '__proto__'];
const rebound = { ...DEFAULT_HOTKEYS, retreat: 'KeyW' };
describe('the key map: defaults and labels', () => {
test('the defaults are the keys of 0.1.4 (R / F / D / Q / X / Space), in the settings order', () => {
assert.deepEqual([...HOTKEY_ACTIONS], ['refresh', 'freeze', 'levelUp', 'retreat', 'sell', 'ready']);
assert.deepEqual({ ...DEFAULT_HOTKEYS }, { refresh: 'KeyR', freeze: 'KeyF', levelUp: 'KeyD', retreat: 'KeyQ', sell: 'KeyX', ready: 'Space' });
assert.deepEqual(HOTKEY_ACTIONS.map((a) => hotkeyLabel(DEFAULT_HOTKEYS[a])), ['R', 'F', 'D', 'Q', 'X', 'Space']);
assert.ok(Object.isFrozen(DEFAULT_HOTKEYS) && Object.isFrozen(HOTKEY_ACTIONS));
assert.equal(DEFAULT_SETTINGS.keys, DEFAULT_HOTKEYS, 'the settings default is the same map');
assert.deepEqual(sanitizeSettings(null).keys, { ...DEFAULT_HOTKEYS });
assert.deepEqual(sanitizeSettings({ bgm: 0.4 }).keys, { ...DEFAULT_HOTKEYS }, 'a profile saved before 0.2.0 (no keys) keeps the old keys');
assert.equal(isDefaultHotkeys(DEFAULT_HOTKEYS), true);
assert.equal(isDefaultHotkeys(rebound), false);
});
test('which keys may be a shortcut, and how each is shown', () => {
for (const c of BINDABLE) assert.equal(isBindableCode(c), true, c);
for (const c of RESERVED) assert.equal(isBindableCode(c), false, c);
for (const v of [null, undefined, 3, {}, ['KeyQ']]) assert.equal(isBindableCode(v), false);
assert.equal(hotkeyLabel('KeyW'), 'W');
assert.equal(hotkeyLabel('Digit7'), '7');
assert.equal(hotkeyLabel('Comma'), ',');
assert.equal(hotkeyLabel('Backslash'), '\\');
assert.equal(hotkeyLabel('Space'), 'Space');
assert.equal(hotkeyLabel('Delete'), 'Delete');
assert.equal(hotkeyLabel('PageUp'), 'PageUp');
assert.equal(hotkeyLabel('Escape'), '');
assert.equal(hotkeyLabel(undefined), '');
});
});
describe('the key map: sanitising (the settings store, localStorage sp.pref.settings)', () => {
test('a valid map is kept as saved; it survives the JSON round trip of the store', () => {
const mine = { refresh: 'KeyG', freeze: 'Digit2', levelUp: 'Comma', retreat: 'KeyW', sell: 'Delete', ready: 'KeyE' };
assert.deepEqual(sanitizeHotkeys(mine), mine);
const saved = JSON.parse(JSON.stringify(sanitizeSettings({ bgm: 0.3, keys: mine })));
assert.deepEqual(sanitizeSettings(saved).keys, mine);
assert.equal(sanitizeSettings(saved).bgm, 0.3);
});
test('bad entries fall back to their default; unknown actions are dropped', () => {
assert.deepEqual(sanitizeHotkeys({ retreat: 'KeyW', sell: 'Escape', ready: 42 }), { ...DEFAULT_HOTKEYS, retreat: 'KeyW' });
assert.deepEqual(sanitizeHotkeys({ ...DEFAULT_HOTKEYS, pen: 'KeyP', toString: 'KeyT' }), { ...DEFAULT_HOTKEYS });
for (const c of RESERVED) assert.deepEqual(sanitizeHotkeys({ refresh: c }), { ...DEFAULT_HOTKEYS }, c);
// keys of the prototype are not saved keys
assert.deepEqual(sanitizeHotkeys(Object.create({ refresh: 'KeyG' })), { ...DEFAULT_HOTKEYS });
assert.deepEqual(sanitizeHotkeys(JSON.parse('{"__proto__": {"refresh": "KeyG"}, "sell": "KeyV"}')), { ...DEFAULT_HOTKEYS, sell: 'KeyV' });
});
test('bad data — not a map, or two actions on one key — gives the defaults', () => {
for (const raw of [null, undefined, 'KeyQ', 7, true, [], ['KeyW'], () => 'KeyQ']) assert.deepEqual(sanitizeHotkeys(raw), { ...DEFAULT_HOTKEYS }, String(raw));
assert.deepEqual(sanitizeHotkeys({ ...DEFAULT_HOTKEYS, sell: 'KeyQ' }), { ...DEFAULT_HOTKEYS }, 'retreat and sell on Q');
// an invalid entry whose default another action now holds is a conflict too
assert.deepEqual(sanitizeHotkeys({ refresh: 'KeyF', freeze: 'nonsense' }), { ...DEFAULT_HOTKEYS });
assert.deepEqual(sanitizeSettings({ quality: 'low', keys: { ready: 'KeyR' } }).keys, { ...DEFAULT_HOTKEYS });
assert.equal(sanitizeSettings({ quality: 'low', keys: { ready: 'KeyR' } }).quality, 'low', 'the other settings are kept');
});
});
describe('the key map: rebinding and conflicts', () => {
test('a free key just moves the action', () => {
const r = rebindHotkey(DEFAULT_HOTKEYS, 'retreat', 'KeyW');
assert.deepEqual(r, { keys: rebound, changed: true, swapped: null });
assert.deepEqual({ ...DEFAULT_HOTKEYS }, { refresh: 'KeyR', freeze: 'KeyF', levelUp: 'KeyD', retreat: 'KeyQ', sell: 'KeyX', ready: 'Space' }, 'the defaults are untouched');
});
test('a key another action holds is swapped: that action takes the old key', () => {
const r = rebindHotkey(DEFAULT_HOTKEYS, 'retreat', 'KeyX');
assert.equal(r.changed, true);
assert.equal(r.swapped, 'sell');
assert.deepEqual(r.keys, { ...DEFAULT_HOTKEYS, retreat: 'KeyX', sell: 'KeyQ' });
const back = rebindHotkey(r.keys, 'ready', 'KeyQ');
assert.deepEqual(back.keys, { ...DEFAULT_HOTKEYS, retreat: 'KeyX', sell: 'Space', ready: 'KeyQ' });
assert.equal(back.swapped, 'sell');
});
test('the same key, an unknown action or a reserved key change nothing', () => {
assert.deepEqual(rebindHotkey(DEFAULT_HOTKEYS, 'retreat', 'KeyQ'), { keys: { ...DEFAULT_HOTKEYS }, changed: false, swapped: null });
for (const [action, code] of [['pen', 'KeyP'], ['escape', 'KeyE'], ['retreat', 'Escape'], ['retreat', 'Enter'], ['retreat', 'ArrowUp'], ['retreat', 'F5']]) {
assert.deepEqual(rebindHotkey(DEFAULT_HOTKEYS, action, code), { keys: { ...DEFAULT_HOTKEYS }, changed: false, swapped: null }, `${action} ${code}`);
}
});
test('whatever is rebound, every action keeps exactly one key and no key does two things', () => {
let keys = { ...DEFAULT_HOTKEYS };
let seed = 7;
const rnd = (n) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; };
for (let i = 0; i < 2000; i++) {
keys = rebindHotkey(keys, HOTKEY_ACTIONS[rnd(HOTKEY_ACTIONS.length)], BINDABLE[rnd(BINDABLE.length)]).keys;
assert.equal(new Set(Object.values(keys)).size, HOTKEY_ACTIONS.length);
assert.deepEqual(sanitizeHotkeys(keys), keys, 'a rebound map is a valid saved map');
}
});
});
// the matcher of 0.1.4 (ui/gameLogic/shortcuts.js before 0.2.0): `code === 'KeyR' || key === 'r'`, in this order
function oldShortcut(e) {
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;
}
describe('the one lookup (actionForKey) and shortcutFor', () => {
test('with the defaults nothing changes: the 0.1.4 matcher on QWERTY, AZERTY, Dvorak and Russian presses', () => {
const qwerty = [...LETTERS.map((c) => ({ code: c, key: c.slice(3).toLowerCase() })), ...DIGITS.map((c) => ({ code: c, key: c.slice(5) })), { code: 'Space', key: ' ' }];
// AZERTY: A↔Q, Z↔W, M on the ; key; Dvorak: the US positions typing Dvorak letters; Russian: Cyrillic letters by position
const azerty = { KeyQ: 'a', KeyA: 'q', KeyW: 'z', KeyZ: 'w', Semicolon: 'm', KeyM: ',' };
const dvorak = { KeyQ: "'", KeyW: ',', KeyE: '.', KeyR: 'p', KeyT: 'y', KeyY: 'f', KeyU: 'g', KeyI: 'c', KeyO: 'r', KeyP: 'l', KeyS: 'o', KeyD: 'e', KeyF: 'u', KeyG: 'i', KeyH: 'd', KeyJ: 'h', KeyK: 't', KeyL: 'n', KeyZ: ';', KeyX: 'q', KeyC: 'j', KeyV: 'k', KeyB: 'x', KeyN: 'b', KeyM: 'm' };
const russian = { KeyQ: 'й', KeyW: 'ц', KeyE: 'у', KeyR: 'к', KeyF: 'а', KeyD: 'в', KeyX: 'ч', KeyA: 'ф' };
const layout = (m) => Object.entries(m).map(([code, key]) => ({ code, key }));
const presses = [...qwerty, ...layout(azerty), ...layout(dvorak), ...layout(russian), { code: 'KeyR', key: 'Process' }, { code: 'KeyQ', key: 'Unidentified' },
{ key: 'r' }, { key: 'X' }, { code: 'KeyD' }, { key: ' ' }, { code: 'Digit1', key: '!' }, { code: 'ArrowUp', key: 'ArrowUp' }, { code: 'Enter', key: 'Enter' }, {}];
for (const e of [...presses, ...presses.map((p) => ({ ...p, key: typeof p.key === 'string' ? p.key.toUpperCase() : p.key, shiftKey: true }))]) {
assert.equal(actionForKey(e), oldShortcut(e), JSON.stringify(e));
assert.equal(actionForKey(e, DEFAULT_HOTKEYS), oldShortcut(e));
assert.equal(actionForKey(e, { ...DEFAULT_HOTKEYS }), oldShortcut(e), 'a saved copy of the defaults');
}
});
test('a rebound key: the new key acts, the old one is free; the character typed wins over the position', () => {
assert.equal(actionForKey({ key: 'w', code: 'KeyW' }, rebound), 'retreat');
assert.equal(actionForKey({ key: 'q', code: 'KeyQ' }, rebound), null);
assert.equal(actionForKey({ key: 'ц', code: 'KeyW' }, rebound), 'retreat', 'a Russian layout: by position');
assert.equal(actionForKey({ key: 'Process', code: 'KeyW' }, rebound), 'retreat', 'an IME: by position');
// AZERTY: the player bound retreat to their A key (code KeyQ) and sell to their Q key (code KeyA)
const azerty = { ...DEFAULT_HOTKEYS, retreat: 'KeyA', sell: 'KeyQ' };
assert.equal(actionForKey({ key: 'a', code: 'KeyQ' }, azerty), 'retreat');
assert.equal(actionForKey({ key: 'q', code: 'KeyA' }, azerty), 'sell');
const swapped = rebindHotkey(DEFAULT_HOTKEYS, 'refresh', 'KeyF').keys;
assert.equal(actionForKey({ key: 'f', code: 'KeyF' }, swapped), 'refresh');
assert.equal(actionForKey({ key: 'r', code: 'KeyR' }, swapped), 'freeze');
const named = { ...DEFAULT_HOTKEYS, sell: 'Delete', ready: 'KeyE', levelUp: 'Digit3', freeze: 'Comma' };
assert.equal(actionForKey({ key: 'Delete', code: 'Delete' }, named), 'sell');
assert.equal(actionForKey({ key: 'e', code: 'KeyE' }, named), 'ready');
assert.equal(actionForKey({ key: ' ', code: 'Space' }, named), null, 'Space is free once ready moved');
assert.equal(actionForKey({ key: '#', code: 'Digit3', shiftKey: true }, named), 'levelUp', 'Shift + 3: by position');
assert.equal(actionForKey({ key: ',', code: 'KeyM' }, named), 'freeze', 'AZERTY comma: by the character');
assert.equal(actionForKey({ key: 'w', code: 'KeyW' }, { retreat: 'KeyW', sell: 'KeyW' }), null, 'a corrupt map acts as the defaults');
});
test('shortcutFor with a rebound map keeps every guard: modifiers, text fields, repeats; Esc stays fixed', () => {
const w = { key: 'w', code: 'KeyW' };
assert.equal(shortcutFor(w, rebound), 'retreat');
assert.equal(shortcutFor({ key: 'q', code: 'KeyQ' }, rebound), null);
assert.equal(shortcutFor(w), null, 'W does nothing with the defaults');
for (const mod of ['ctrlKey', 'metaKey', 'altKey']) assert.equal(shortcutFor({ ...w, [mod]: true }, rebound), null, mod);
for (const tagName of ['INPUT', 'TEXTAREA', 'SELECT', 'input']) assert.equal(shortcutFor({ ...w, target: { tagName } }, rebound), null, tagName);
assert.equal(shortcutFor({ ...w, target: { tagName: 'DIV', isContentEditable: true } }, rebound), null);
assert.equal(shortcutFor({ ...w, repeat: true }, rebound), null);
assert.equal(shortcutFor({ ...w, target: { tagName: 'BUTTON' } }, rebound), 'retreat', 'a focused HUD button does not stop it');
assert.equal(shortcutFor({ key: 'Escape' }, rebound), 'escape');
assert.equal(shortcutFor({ key: 'Escape', repeat: true }, { ...DEFAULT_HOTKEYS, ready: 'KeyE' }), 'escape');
assert.equal(shortcutFor({ key: 'e', code: 'KeyE' }, { ...DEFAULT_HOTKEYS, ready: 'KeyE' }), 'ready', 'the ready key also pauses a solo battle (game.js)');
// behind overlays the rebound actions are blocked like the defaults
assert.equal(shortcutBlocked(shortcutFor(w, rebound), { drawer: true }), true);
assert.equal(shortcutBlocked(shortcutFor(w, rebound), { modal: true }), true);
assert.equal(shortcutBlocked(shortcutFor(w, rebound), {}), false);
});
test('the facing wheel swallows Space and every key of the map — the rebound one, not the old one', () => {
for (const [key, code] of [[' ', 'Space'], ['r', 'KeyR'], ['f', 'KeyF'], ['d', 'KeyD'], ['q', 'KeyQ'], ['x', 'KeyX']]) {
assert.equal(facingSwallows({ key, code }), true, code);
}
const g = { ...DEFAULT_HOTKEYS, refresh: 'KeyG' };
assert.equal(facingSwallows({ key: 'g', code: 'KeyG' }, g), true);
assert.equal(facingSwallows({ key: 'r', code: 'KeyR' }, g), false, 'R is free once refresh moved');
assert.equal(facingSwallows({ key: ' ', code: 'Space' }, { ...DEFAULT_HOTKEYS, ready: 'KeyE' }), true, 'Space always: a focused button must not activate');
assert.equal(facingSwallows({ key: 'z', code: 'KeyZ' }), false);
});
});
describe('capturing a new key (the settings wait for a key)', () => {
test('Esc cancels, Tab leaves, a lone modifier or a repeat is ignored', () => {
assert.deepEqual(captureHotkey({ key: 'Escape', code: 'Escape' }), { kind: 'cancel' });
assert.deepEqual(captureHotkey({ key: 'Tab', code: 'Tab', shiftKey: true }), { kind: 'leave' });
for (const key of ['Shift', 'Control', 'Alt', 'Meta', 'CapsLock', 'AltGraph']) assert.deepEqual(captureHotkey({ key, code: `${key}Left` }), { kind: 'ignore' }, key);
assert.deepEqual(captureHotkey({ key: 'w', code: 'KeyW', repeat: true }), { kind: 'ignore' });
assert.deepEqual(captureHotkey(null), { kind: 'ignore' });
});
test('a key held with Ctrl / Alt / ⌘ and the keys the interface keeps are refused, with the key named', () => {
assert.deepEqual(captureHotkey({ key: 'k', code: 'KeyK', ctrlKey: true }), { kind: 'refuse', reason: 'modifier', name: 'K' });
assert.deepEqual(captureHotkey({ key: '@', code: 'KeyQ', ctrlKey: true, altKey: true }), { kind: 'refuse', reason: 'modifier', name: '@' });
assert.deepEqual(captureHotkey({ key: 'w', code: 'KeyW', metaKey: true }), { kind: 'refuse', reason: 'modifier', name: 'W' });
for (const [key, code] of [['Enter', 'Enter'], ['ArrowUp', 'ArrowUp'], ['F5', 'F5'], ['Backspace', 'Backspace'], ['ContextMenu', 'ContextMenu']]) {
assert.deepEqual(captureHotkey({ key, code }), { kind: 'refuse', reason: 'reserved', name: key }, key);
}
});
test('the new key: the character typed (the keycap), else the physical key', () => {
assert.deepEqual(captureHotkey({ key: 'w', code: 'KeyW' }), { kind: 'key', code: 'KeyW' });
assert.deepEqual(captureHotkey({ key: 'W', code: 'KeyW', shiftKey: true }), { kind: 'key', code: 'KeyW' });
assert.deepEqual(captureHotkey({ key: ' ', code: 'Space' }), { kind: 'key', code: 'Space' });
assert.deepEqual(captureHotkey({ key: 'Delete', code: 'Delete' }), { kind: 'key', code: 'Delete' });
assert.deepEqual(captureHotkey({ key: '1', code: 'Numpad1' }), { kind: 'key', code: 'Digit1' }, 'the numpad 1 types 1');
assert.equal(hotkeyOf({ key: 'a', code: 'KeyQ' }), 'KeyA', 'AZERTY: the A keycap');
assert.equal(hotkeyOf({ key: ',', code: 'KeyM' }), 'Comma', 'AZERTY: the comma keycap');
assert.equal(hotkeyOf({ key: 'й', code: 'KeyQ' }), 'KeyQ', 'Russian: by position');
assert.equal(hotkeyOf({ key: 'Process', code: 'KeyR' }), 'KeyR', 'an IME: by position');
assert.equal(hotkeyOf({ key: '!', code: 'Digit1', shiftKey: true }), 'Digit1', 'Shift + 1: by position');
assert.equal(hotkeyOf({ key: 'Enter', code: 'Enter' }), null);
assert.equal(hotkeyOf({}), null);
});
});
describe('every handler and key hint reads the map', () => {
const files = [];
const visit = (rel) => {
for (const e of readdirSync(path.join(ROOT, rel), { withFileTypes: true })) {
const r = `${rel}/${e.name}`;
if (e.isDirectory()) visit(r);
else if (r.endsWith('.js')) files.push(r);
}
};
visit('public/js');
test('game.js and the facing wheel pass the player\'s map to the one lookup; no other file names a rebindable key', () => {
assert.match(read('public/js/screens/game.js'), /shortcutFor\(e, settingsStore\.get\(\)\.keys\)/);
assert.match(read('public/js/ui/facingWheel.js'), /facingSwallows\(e, settingsStore\.get\(\)\.keys\)/);
for (const f of files) {
if (f === 'public/js/ui/gameLogic/shortcuts.js') continue;
const src = read(f);
assert.doesNotMatch(src, /['"`]Key[RFDQX]['"`]|\^Key\[|code === 'Space'/, `${f}: a rebindable key hard-coded`);
assert.doesNotMatch(src, /<kbd[^>]*>(R|F|D|Q|X|Space)<\/kbd>/, `${f}: a fixed key hint`);
assert.doesNotMatch(src, /[((]Space[))]|[((][QX][))]|\[[QX]\]|· [RFD]'/, `${f}: a fixed key in a hint text`);
}
});
test('the HUD hints follow the settings: hotkeyLabelOf and the ready button after a rebind', async () => {
const { settingsStore, updateSettings, hotkeyLabelOf } = await import('../../public/js/ui/settings.js');
const { ReadyToggle } = await import('../../public/js/ui/hud.js');
after(() => updateSettings({ keys: DEFAULT_HOTKEYS }));
const kbdOf = (v) => {
const stack = [v];
while (stack.length) {
const n = stack.pop();
if (Array.isArray(n)) { stack.push(...n); continue; }
if (!n || typeof n !== 'object') continue;
if (n.type === 'kbd') return [n.props.children].flat().join('');
stack.push(n.props?.children);
}
return null;
};
const priv = { ready: false, alive: true, temp: [], funds: 0 };
assert.deepEqual(HOTKEY_ACTIONS.map(hotkeyLabelOf), ['R', 'F', 'D', 'Q', 'X', 'Space']);
assert.equal(kbdOf(ReadyToggle({ priv, onToggle() {} })), 'Space');
updateSettings({ keys: rebindHotkey(settingsStore.get().keys, 'ready', 'KeyE').keys });
updateSettings({ keys: rebindHotkey(settingsStore.get().keys, 'retreat', 'KeyW').keys });
assert.deepEqual(settingsStore.get().keys, { ...DEFAULT_HOTKEYS, ready: 'KeyE', retreat: 'KeyW' });
assert.equal(hotkeyLabelOf('retreat'), 'W');
assert.equal(kbdOf(ReadyToggle({ priv, onToggle() {} })), 'E');
updateSettings({ keys: { retreat: 'Escape' } });
assert.equal(hotkeyLabelOf('retreat'), 'Q', 'a bad patch falls back to the defaults');
updateSettings({ keys: DEFAULT_HOTKEYS });
assert.equal(kbdOf(ReadyToggle({ priv, onToggle() {} })), 'Space');
});
});
+2 -1
View File
@@ -516,7 +516,8 @@ describe('keyboard & settings', () => {
test('sanitizeSettings', () => {
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
{ bgm: 1, sfx: 0, voice: 1, muted: false, damageNumbers: false, quality: 'high' });
{ bgm: 1, sfx: 0, voice: 1, muted: false, damageNumbers: false, quality: 'high', keys: { ...DEFAULT_SETTINGS.keys } },
'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)');
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
assert.equal(sanitizeSettings({ bgm: 0.333 }).bgm, 0.33);
assert.equal(sanitizeSettings({ quality: 'low' }).quality, 'low');
+141
View File
@@ -0,0 +1,141 @@
// 0.2.0 rebindable shortcuts (设置 → 快捷键) in the mock match, headless Chrome, the DOM field (no art / WebGL needed):
// 撤退 rebound from Q to W with the keyboard only — Enter on the key waits, Esc ends the wait (the dialog stays), a refused
// key keeps it — a swap and 恢复默认 with their messages, Q then does nothing and W retreats, the underframe shows W, the
// choice survives a reload; at phone width (640×360, touch) the section fits and says the keys need a keyboard.
// Unit side: test/ui/feedback5-hotkeys.test.js.
// SP_E2E=1 CHROME_PATH=/path/to/chrome node --test test/ui/hotkeys.e2e.test.js
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const OUT = fileURLToPath(new URL('../e2e/out/', import.meta.url));
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
describe('rebindable shortcuts (设置 → 快捷键)', { skip: !ENABLED && 'set SP_E2E=1 and CHROME_PATH to run' }, () => {
let srv, browser;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--no-proxy-server'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => { await browser?.close(); await srv?.close(); });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const mockState = (page) => page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv)));
const keyOf = (page, action) => page.$eval(`.set-key[data-action="${action}"]`, (el) => el.textContent.trim());
const keyIs = (page, action, label) => page.waitForFunction((a, l) => document.querySelector(`.set-key[data-action="${a}"]`)?.textContent.trim() === l, {}, action, label);
const note = (page) => page.$eval('.set-keys__note', (el) => el.textContent.trim());
async function open(page, { width = 1280, height = 720, touch = false } = {}) {
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
await page.setViewport({ width, height, isMobile: touch, hasTouch: touch });
await page.goto(`${srv.url}/dev/game-mock.html?shot=1&render=fallback&phase=PREP`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.ff-piece');
return problems;
}
test('撤退 rebound to W in a mock match: keyboard only, swap, Esc, refusal, the HUD hint, a reload', async () => {
const page = await browser.newPage();
try {
const problems = await open(page);
await page.click('.gm__gear');
await page.waitForSelector('.modal .set-keys');
assert.deepEqual(await page.$$eval('.set-key[data-action]', (els) => els.map((el) => el.textContent.trim())), ['R', 'F', 'D', 'Q', 'X', 'Space'],
'the defaults are the keys of 0.1.4');
// a swap: 撤退 onto X (出售's key) — 出售 takes the old Q, and the line under the list says so
await page.click('.set-key[data-action="retreat"]');
await page.waitForSelector('.set-key.is-waiting[data-action="retreat"]');
assert.match(await note(page), /请按下「撤退选中干员」的新按键(Esc 取消)/);
await page.keyboard.press('KeyX');
await keyIs(page, 'retreat', 'X');
assert.equal(await keyOf(page, 'sell'), 'Q');
assert.match(await note(page), /「撤退选中干员」已改为 X;「出售选中干员」原来用 X,已换成 Q/);
await page.click('.set-keys__reset');
await keyIs(page, 'retreat', 'Q');
assert.equal(await keyOf(page, 'sell'), 'X');
assert.match(await note(page), /已恢复默认快捷键/);
assert.equal(await page.$eval('.set-keys__reset', (el) => el.disabled), true, '恢复默认 is off at the defaults');
// keyboard only: Enter on the focused key waits; Esc ends the wait and the dialog stays open
await page.focus('.set-key[data-action="retreat"]');
await page.keyboard.press('Enter');
await page.waitForSelector('.set-key.is-waiting[data-action="retreat"]');
await page.keyboard.press('Escape');
await page.waitForSelector('.set-key.is-waiting', { hidden: true });
assert.ok(await page.$('.modal .set-keys'), 'Esc ends the wait, not the dialog');
assert.match(await note(page), /已取消,「撤退选中干员」仍是 Q/);
// the focus stayed on the key: Enter waits again; Enter itself is refused and the wait goes on; W is taken
await page.keyboard.press('Enter');
await page.waitForSelector('.set-key.is-waiting[data-action="retreat"]');
await page.keyboard.press('Enter');
await sleep(100);
assert.match(await note(page), /Enter 不能设为快捷键/);
assert.ok(await page.$('.set-key.is-waiting[data-action="retreat"]'), 'a refused key keeps the wait');
await page.keyboard.press('KeyW');
await keyIs(page, 'retreat', 'W');
assert.match(await note(page), /「撤退选中干员」已改为 W/);
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.settings')).keys.retreat), 'KeyW', 'saved with the settings');
await page.keyboard.press('Escape'); // no wait: Esc closes the dialog as before
await page.waitForSelector('.modal', { hidden: true });
// the match: the underframe shows W, Q no longer retreats, W does
const initial = await mockState(page);
const unit = initial.board.find((p) => p.kind === 'chess' && !p.golden);
await page.click(`.ff-piece[data-uid="${unit.uid}"]`);
await page.waitForSelector('.uframe__btn--retreat');
assert.match(await page.$eval('.uframe__btn--retreat', (el) => el.textContent), /\[W\]/);
assert.equal(await page.$eval('.uframe__btn--retreat', (el) => el.getAttribute('aria-keyshortcuts')), 'W');
await page.keyboard.press('KeyQ');
await sleep(300);
assert.deepEqual(await mockState(page), initial, 'Q no longer retreats');
await page.keyboard.press('KeyW');
await page.waitForFunction((uid) => globalThis.__MOCK__.S().priv.hand.some((p) => p?.uid === uid), {}, unit.uid);
assert.equal((await mockState(page)).funds, initial.funds, 'W retreats (it does not sell)');
// the choice survives a reload; 恢复默认 brings Q back
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.ff-piece');
await page.click('.gm__gear');
await page.waitForSelector('.modal .set-keys');
assert.equal(await keyOf(page, 'retreat'), 'W');
await page.click('.set-keys__reset');
await keyIs(page, 'retreat', 'Q');
assert.deepEqual(problems, []);
} finally { await page.close(); }
});
test('phone width (640×360, touch): the keys need a keyboard, the folded list opens and fits, no horizontal scroll', async () => {
const page = await browser.newPage();
try {
const problems = await open(page, { width: 640, height: 360, touch: true });
await page.tap('.gm__gear');
await page.waitForSelector('.modal .set-keys');
assert.match(await page.$eval('.set-keys .set-hint', (el) => el.textContent), /快捷键需要实体键盘/);
assert.equal(await page.$eval('.set-keys__more', (el) => el.open), false, 'the list is folded on a touch-only device');
// (tap once the dialog's entrance animation is over: a tap during it lands where the summary is about to be)
await page.waitForFunction(() => !document.querySelector('.modal__box')?.getAnimations().some((a) => a.playState === 'running'));
await page.tap('.set-keys__more > summary');
await page.waitForFunction(() => document.querySelector('.set-keys__more')?.open === true);
const box = await page.$eval('.modal__body', (body) => {
const b = body.getBoundingClientRect();
const rows = [...body.querySelectorAll('.set-keys__row')].map((r) => r.getBoundingClientRect());
return { scroll: body.scrollWidth, client: body.clientWidth, left: b.left, right: b.right, rows: rows.map((r) => [r.left, r.right, r.width]) };
});
assert.ok(box.scroll <= box.client, `no horizontal scroll in the dialog (${box.scroll} > ${box.client})`);
assert.equal(box.rows.length, 7, 'six shortcuts and the fixed Esc');
for (const [l, r, w] of box.rows) assert.ok(l >= box.left - 0.5 && r <= box.right + 0.5 && w > 0, `a row inside the dialog: ${l}–${r}`);
assert.equal(new Set(box.rows.map(([l]) => Math.round(l))).size, 1, 'one column at phone width');
await page.$eval('.set-keys__more', (el) => el.scrollIntoView({ block: 'start' }));
assert.equal(await page.$eval('.set-keys__more', (el) => el.open), true);
await page.screenshot({ path: `${OUT}/hotkeys-phone.png` });
assert.deepEqual(problems, []);
} finally { await page.close(); }
});
});