feedback5 WAC: players rebind the in-match shortcuts in 设置 → 快捷键 (community request 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07)
- The settings (sp.pref.settings) hold a key map, action → KeyboardEvent.code, for refresh / freeze / level-up / retreat / sell / ready (the ready key also pauses a solo battle); the defaults are the 0.1.4 keys R F D Q X Space, so nothing changes for a player who never opens the section. sanitizeSettings keeps each saved key that may be a shortcut and gives any other its default; not a map, or two actions on one key, gives the defaults. - One lookup, actionForKey (the character typed first, then the physical key: the 0.1.4 code-or-key match, identical for the defaults), serves shortcutFor(e, keys) in game.js and the facing wheel; the modifier, text-field, repeat and overlay guards are unchanged. The shop bar, ready / pause and underframe key hints read the map. - 设置 → 快捷键 (the one SettingsModal, in a match and on the title screen): each action with its key; click or Enter waits, the next key binds; Esc cancels (the dialog stays open), Tab leaves; Ctrl / Alt / ⌘ combinations and the interface keys (Esc, Tab, Enter, arrows, F1–F12) are refused; a key in use is swapped and the line under the list names both; 恢复默认. Esc stays fixed. Touch-only devices: a keyboard note and a folded list; one column under 768 px. - Tests: test/ui/feedback5-hotkeys.test.js (map, sanitising, conflicts, lookup, capture, handlers and hints with a rebound key), test/ui/gameLogic.test.js (sanitizeSettings now returns keys), test/ui/hotkeys.e2e.test.js (SP_E2E=1: 撤退 rebound to W with the keyboard only in the mock match; phone width). Golden unchanged (client UI only).
This commit is contained in:
1 parent
30d9a41c51
commit
f2bec76176
14 files changed
+772
-61
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
// ui/gameLogic/settings.js — settings defaults and sanitising. Re-exported from ../gameLogic.js.
|
||||
|
||||
import { clamp, isObj } from './shared.js';
|
||||
import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js';
|
||||
|
||||
|
||||
// ---- settings ------------------------------------------------------------------------------------------------------
|
||||
|
||||
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, muted: false, damageNumbers: true, quality: 'high' });
|
||||
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). */
|
||||
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, muted: false, damageNumbers: true, quality: 'high', keys: DEFAULT_HOTKEYS });
|
||||
const QUALITIES = ['high', 'medium', 'low'];
|
||||
|
||||
/**
|
||||
* Sanitize persisted settings.
|
||||
* @param {any} raw
|
||||
* @returns {{ bgm: number, sfx: number, voice: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low' }}
|
||||
* @returns {{ bgm: number, sfx: number, voice: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
|
||||
* keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
|
||||
*/
|
||||
export function sanitizeSettings(raw) {
|
||||
const r = isObj(raw) ? raw : {};
|
||||
@@ -23,5 +26,6 @@ export function sanitizeSettings(raw) {
|
||||
muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted,
|
||||
damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers,
|
||||
quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality,
|
||||
keys: sanitizeHotkeys(r.keys),
|
||||
};
|
||||
}
|
||||
@@ -1,33 +1,169 @@
|
||||
// ui/gameLogic/shortcuts.js — in-match keyboard shortcuts. Re-exported from ../gameLogic.js.
|
||||
// ui/gameLogic/shortcuts.js — in-match keyboard shortcuts and the player's key map (设置 → 快捷键, 0.2.0: the community
|
||||
// request 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07). Re-exported from ../gameLogic.js.
|
||||
|
||||
import { isObj } from './shared.js';
|
||||
|
||||
|
||||
// ---- the key map ------------------------------------------------------------------------------------------------
|
||||
|
||||
/** The rebindable shortcuts in the settings' order (the ready key also pauses / resumes a solo battle). Esc is fixed. */
|
||||
export const HOTKEY_ACTIONS = Object.freeze(['refresh', 'freeze', 'levelUp', 'retreat', 'sell', 'ready']);
|
||||
|
||||
/** Default key of each action (a KeyboardEvent.code): the keys of 0.1.4, so nothing changes for a player who never rebinds. */
|
||||
export const DEFAULT_HOTKEYS = Object.freeze({ refresh: 'KeyR', freeze: 'KeyF', levelUp: 'KeyD', retreat: 'KeyQ', sell: 'KeyX', ready: 'Space' });
|
||||
|
||||
// The keys a shortcut may use → the `key` value each types on a US layout (lower case): letters, digits, Space, the
|
||||
// punctuation keys, and six named keys whose `key` equals their `code`. Everything else stays with the interface: Esc
|
||||
// (closes; cancels a rebind), Tab / Enter / the arrows (focus, buttons, the facing wheel, lists), F1–F12 (the browser's),
|
||||
// the modifiers.
|
||||
const KEY_OF = new Map([
|
||||
...[...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'].map((c) => [`Key${c}`, c.toLowerCase()]),
|
||||
...[...'0123456789'].map((c) => [`Digit${c}`, c]),
|
||||
['Space', ' '], ['Backquote', '`'], ['Minus', '-'], ['Equal', '='], ['BracketLeft', '['], ['BracketRight', ']'],
|
||||
['Backslash', '\\'], ['Semicolon', ';'], ['Quote', "'"], ['Comma', ','], ['Period', '.'], ['Slash', '/'],
|
||||
...['Delete', 'Insert', 'Home', 'End', 'PageUp', 'PageDown'].map((k) => [k, k]),
|
||||
]);
|
||||
const CODE_OF = new Map([...KEY_OF].map(([code, key]) => [key, code]));
|
||||
|
||||
/** A key press's `key`, lower case when it is one character ('' when absent). */
|
||||
const keyValue = (e) => {
|
||||
const k = e?.key;
|
||||
return typeof k !== 'string' ? '' : k.length === 1 ? k.toLowerCase() : k;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a KeyboardEvent.code may be a shortcut's key.
|
||||
* @param {any} code
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isBindableCode = (code) => typeof code === 'string' && KEY_OF.has(code);
|
||||
|
||||
/**
|
||||
* The label a key is shown with ('KeyQ' → 'Q', 'Digit1' → '1', 'Comma' → ',', 'Space', 'Delete'); '' for any other code.
|
||||
* @param {any} code
|
||||
*/
|
||||
export function hotkeyLabel(code) {
|
||||
if (!isBindableCode(code)) return '';
|
||||
if (code === 'Space') return 'Space';
|
||||
const k = KEY_OF.get(code);
|
||||
return k.length === 1 ? k.toUpperCase() : k;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a persisted key map: each action keeps a saved key that may be a shortcut, else gets its default; a map in
|
||||
* which two actions would share a key is bad data — the defaults instead.
|
||||
* @param {any} raw
|
||||
* @returns {Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string>}
|
||||
*/
|
||||
export function sanitizeHotkeys(raw) {
|
||||
const r = isObj(raw) && !Array.isArray(raw) ? raw : {};
|
||||
const out = {};
|
||||
for (const a of HOTKEY_ACTIONS) out[a] = Object.hasOwn(r, a) && isBindableCode(r[a]) ? r[a] : DEFAULT_HOTKEYS[a];
|
||||
return new Set(Object.values(out)).size === HOTKEY_ACTIONS.length ? out : { ...DEFAULT_HOTKEYS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind `action` to `code`. A key another action holds is swapped, never shared: that action takes the old key of
|
||||
* `action`, so every action keeps exactly one key and no key does two things.
|
||||
* @param {any} keys the current map
|
||||
* @param {string} action
|
||||
* @param {string} code
|
||||
* @returns {{ keys: Record<string, string>, changed: boolean, swapped: string|null }} swapped: the action that took the old key
|
||||
*/
|
||||
export function rebindHotkey(keys, action, code) {
|
||||
const cur = sanitizeHotkeys(keys);
|
||||
if (!HOTKEY_ACTIONS.includes(action) || !isBindableCode(code) || cur[action] === code) return { keys: cur, changed: false, swapped: null };
|
||||
const swapped = HOTKEY_ACTIONS.find((a) => cur[a] === code) ?? null;
|
||||
const next = { ...cur, [action]: code };
|
||||
if (swapped) next[swapped] = cur[action];
|
||||
return { keys: next, changed: true, swapped };
|
||||
}
|
||||
|
||||
/** Whether a map is the default one. @param {any} keys */
|
||||
export const isDefaultHotkeys = (keys) => {
|
||||
const k = sanitizeHotkeys(keys);
|
||||
return HOTKEY_ACTIONS.every((a) => k[a] === DEFAULT_HOTKEYS[a]);
|
||||
};
|
||||
|
||||
/**
|
||||
* The bindable key a key press names: the character typed — the label on the player's keycap (AZERTY, Dvorak) — else
|
||||
* the physical key (a non-Latin layout, an IME, Shift + a digit); null when neither may be a shortcut.
|
||||
* @param {{ key?: string, code?: string }} e
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function hotkeyOf(e) {
|
||||
const typed = CODE_OF.get(keyValue(e));
|
||||
if (typed) return typed;
|
||||
return isBindableCode(e?.code) ? e.code : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one lookup of every shortcut handler: the action a key press triggers under the key map — the action bound to the
|
||||
* character typed, else the one bound to the physical key (as the fixed keys of 0.1.4 matched `code === 'KeyR' ||
|
||||
* key === 'r'`: a Russian layout or an IME still works by position). No modifier or focus checks (shortcutFor).
|
||||
* @param {{ key?: string, code?: string }} e
|
||||
* @param {any} [keys] the player's map (settings `keys`); the defaults when omitted
|
||||
* @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|null}
|
||||
*/
|
||||
export function actionForKey(e, keys = DEFAULT_HOTKEYS) {
|
||||
const map = keys === DEFAULT_HOTKEYS ? keys : sanitizeHotkeys(keys);
|
||||
const find = (code) => (code ? HOTKEY_ACTIONS.find((a) => map[a] === code) ?? null : null);
|
||||
return find(CODE_OF.get(keyValue(e))) ?? find(typeof e?.code === 'string' ? e.code : null);
|
||||
}
|
||||
|
||||
// keys that only modify another (pressed alone while the settings wait for a key: ignored)
|
||||
const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'AltGraph', 'Meta', 'OS', 'Super', 'Hyper', 'Fn', 'FnLock', 'CapsLock',
|
||||
'NumLock', 'ScrollLock', 'Symbol', 'SymbolLock']);
|
||||
|
||||
/**
|
||||
* What a key press does while the settings wait for a new key: Esc cancels (the dialog stays open), Tab leaves (the focus
|
||||
* moves on), a lone modifier or an auto-repeat is ignored, a key held with Ctrl / ⌘ / Alt or one the interface keeps is
|
||||
* refused (the wait goes on), any other key is the new key.
|
||||
* @param {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean }} e
|
||||
* @returns {{ kind: 'cancel'|'leave'|'ignore' } | { kind: 'refuse', reason: 'modifier'|'reserved', name: string } | { kind: 'key', code: string }}
|
||||
*/
|
||||
export function captureHotkey(e) {
|
||||
const key = typeof e?.key === 'string' ? e.key : '';
|
||||
if (key === 'Escape') return { kind: 'cancel' };
|
||||
if (key === 'Tab') return { kind: 'leave' };
|
||||
if (!e || e.repeat || MODIFIER_KEYS.has(key)) return { kind: 'ignore' };
|
||||
const name = key === ' ' ? 'Space' : key.length === 1 ? key.toUpperCase() : key || e.code || '?';
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return { kind: 'refuse', reason: 'modifier', name };
|
||||
const code = hotkeyOf(e);
|
||||
return code ? { kind: 'key', code } : { kind: 'refuse', reason: 'reserved', name };
|
||||
}
|
||||
|
||||
|
||||
// ---- keyboard ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a keydown to a game shortcut (R refresh, F freeze, D level-up, Q retreat, X sell, Space ready, Esc close).
|
||||
* Space means ready even while a HUD button has focus (a mouse click leaves the shop card / 刷新 focused, and
|
||||
* Space must not re-trigger it); the caller prevents the button's own activation. Enter still activates buttons.
|
||||
* Map a keydown to a game shortcut under the player's key map (defaults: R refresh, F freeze, D level-up, Q retreat,
|
||||
* X sell, Space ready) or Esc (close; fixed). A shortcut key means its action even while a HUD button has focus (a
|
||||
* mouse click leaves the shop card / 刷新 focused, and Space must not re-trigger it); the caller prevents the button's
|
||||
* own activation. Enter still activates buttons (it cannot be a shortcut). Nothing while Ctrl / ⌘ / Alt is held or a
|
||||
* text field has the focus.
|
||||
* @param {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean, target?: any }} e
|
||||
* @param {any} [keys] the player's map (settings `keys`)
|
||||
* @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null}
|
||||
*/
|
||||
export function shortcutFor(e) {
|
||||
export function shortcutFor(e, keys = DEFAULT_HOTKEYS) {
|
||||
if (!e || e.ctrlKey || e.metaKey || e.altKey) return null;
|
||||
const t = e.target;
|
||||
const tag = t && typeof t.tagName === 'string' ? t.tagName.toUpperCase() : '';
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) return null;
|
||||
if (e.key === 'Escape') return 'escape';
|
||||
if (e.repeat) return null;
|
||||
const code = e.code || '';
|
||||
const key = typeof e.key === 'string' ? e.key.toLowerCase() : '';
|
||||
if (code === 'KeyR' || key === 'r') return 'refresh';
|
||||
if (code === 'KeyF' || key === 'f') return 'freeze';
|
||||
if (code === 'KeyD' || key === 'd') return 'levelUp';
|
||||
if (code === 'KeyQ' || key === 'q') return 'retreat';
|
||||
if (code === 'KeyX' || key === 'x') return 'sell';
|
||||
if (code === 'Space' || key === ' ') return 'ready';
|
||||
return null;
|
||||
return actionForKey(e, keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the facing wheel swallows a key press: no ready / shop action while a facing is chosen — Space (a focused
|
||||
* button must not activate either) and every key bound to a shortcut.
|
||||
* @param {{ key?: string, code?: string }} e
|
||||
* @param {any} [keys] the player's map
|
||||
*/
|
||||
export const facingSwallows = (e, keys = DEFAULT_HOTKEYS) => e?.key === ' ' || actionForKey(e, keys) != null;
|
||||
|
||||
/**
|
||||
* Whether a press on the field closes the open detail card: a card opened from the field itself (an own piece — tap,
|
||||
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
|
||||
@@ -39,8 +175,8 @@ export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail
|
||||
|
||||
/**
|
||||
* Whether an open overlay swallows a game shortcut: a modal / the guide own the keyboard (Esc included — they close
|
||||
* themselves); the 本局信息 / 敌方情报 drawer is a dialog too — only Esc (it closes the drawer) passes, R / F / D / Space
|
||||
* never act behind it.
|
||||
* themselves); the 本局信息 / 敌方情报 drawer is a dialog too — only Esc (it closes the drawer) passes, the other
|
||||
* shortcuts never act behind it.
|
||||
* @param {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null} act shortcutFor
|
||||
* @param {{ modal?: boolean, drawer?: boolean }} open
|
||||
*/
|
||||
|
||||
+4
-2
@@ -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
@@ -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>
|
||||
<//>`;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user