diff --git a/public/css/screens/game.css b/public/css/screens/game.css index 8f58f30b..642dc7be 100644 --- a/public/css/screens/game.css +++ b/public/css/screens/game.css @@ -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; } diff --git a/public/i18n/en.json b/public/i18n/en.json index bdf656b7..01b2db31 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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", diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 43563c64..3fa3bb21 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -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; diff --git a/public/js/ui/facingWheel.js b/public/js/ui/facingWheel.js index 73a6292f..a4987ccf 100644 --- a/public/js/ui/facingWheel.js +++ b/public/js/ui/facingWheel.js @@ -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); diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 86688c57..d95b47a4 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -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'; diff --git a/public/js/ui/gameLogic/settings.js b/public/js/ui/gameLogic/settings.js index 56f282b9..e698bc34 100644 --- a/public/js/ui/gameLogic/settings.js +++ b/public/js/ui/gameLogic/settings.js @@ -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), }; } diff --git a/public/js/ui/gameLogic/shortcuts.js b/public/js/ui/gameLogic/shortcuts.js index d19ff944..3e9d0e24 100644 --- a/public/js/ui/gameLogic/shortcuts.js +++ b/public/js/ui/gameLogic/shortcuts.js @@ -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, 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 */ diff --git a/public/js/ui/hud.js b/public/js/ui/hud.js index f9807502..5e9586b6 100644 --- a/public/js/ui/hud.js +++ b/public/js/ui/hud.js @@ -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)}> ${ready ? html`<${Icon} name="check" />` : null} ${ready ? t('取消准备') : t('准备就绪')} - Space + ${hotkeyLabelOf('ready')} `; return html`
${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"> + `; + })} +
  • + ${t('取消 / 关闭')} + Esc${t('不可更改')} +
  • + +

    ${status}

    `; + return html`
    +
    + ${t('快捷键')}<${MicroLabel}>HOTKEYS + <${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('恢复默认')} +
    + ${touchUi ? html`

    ${t('快捷键需要实体键盘;触屏设备连接键盘后可用')}

    +
    ${t('查看 / 更改快捷键')}${list}
    ` : list} +
    `; +} /** * Settings modal. @@ -81,9 +172,8 @@ export function SettingsModal({ open, onClose }) { class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${t(label)}`)}
    - ${touchUi - ? html`

    ${t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向')}

    ` - : html`

    ${t('快捷键:')}${SHORTCUTS.map(([key, label]) => html`${key} ${t(label)} · `)}${t('右键查看详情')}

    `} + <${HotkeySection} keys=${s.keys} touchUi=${touchUi} /> +

    ${touchUi ? t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向') : t('右键查看详情')}

    `; } diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js index 5ac14906..e8a275c2 100644 --- a/public/js/ui/shopBar.js +++ b/public/js/ui/shopBar.js @@ -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``; } @@ -323,19 +324,20 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, `; } + const hk = { refresh: hotkeyLabelOf('refresh'), freeze: hotkeyLabelOf('freeze') }; // the player's keys (设置 → 快捷键) return html`
    ${t('剩余可放置角色:')}${remaining} -
    diff --git a/public/js/ui/underframe.js b/public/js/ui/underframe.js index c7012ebe..b27db5b9 100644 --- a/public/js/ui/underframe.js +++ b/public/js/ui/underframe.js @@ -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`
    ${actions.retreat ? html`` : null} ${actions.sell != null ? html`` : null} ${actions.destroy ? html`