From 3f05c65dec7acfad9bb9a437a8c6ec034badf284 Mon Sep 17 00:00:00 2001 From: Chun'an Shi <44977219+SpiritedAwayCN@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:18:44 +0800 Subject: [PATCH] Q / X hotkeys retreat and sell the selected operator in prep (#114) Q retreats and X sells the selected operator during prep, through the same buttons (never an item, never while typing in a field); the buttons show [Q] / [X]. Contributed by @SpiritedAwayCN. --- public/css/screens/game-panels.css | 2 +- public/js/screens/game.js | 12 ++++++++- public/js/ui/gameLogic.js | 8 +++--- public/js/ui/settings.js | 2 +- public/js/ui/underframe.js | 8 +++--- test/ui/gameLogic.test.js | 16 +++++++++++- test/ui/mock.e2e.test.js | 42 ++++++++++++++++++++++++++++++ test/ui/playtest2.test.js | 2 ++ test/ui/uiRepair3.test.js | 2 +- 9 files changed, 82 insertions(+), 12 deletions(-) diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css index f1ce6bc2..a3199e84 100644 --- a/public/css/screens/game-panels.css +++ b/public/css/screens/game-panels.css @@ -587,7 +587,7 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; } .uframe__btn:hover:not(:disabled) .uframe__glyph { background: #ff4a2b; } .uframe__btn:hover:not(:disabled) .uframe__plate { filter: brightness(1.12) drop-shadow(0 0 .08rem rgba(255, 198, 90, .7)); } .uframe__btn--destroy:hover:not(:disabled) .uframe__plate { filter: brightness(1.12) drop-shadow(0 0 .08rem rgba(255, 90, 60, .7)); } -.uframe__label { font-size: .14rem; font-weight: 700; letter-spacing: .12em; padding: 0 .06rem; background: rgba(8, 11, 10, .82); text-shadow: 0 1px 2px #000; } +.uframe__label { font-size: .14rem; font-weight: 700; letter-spacing: .12em; white-space: nowrap; padding: 0 .06rem; background: rgba(8, 11, 10, .82); text-shadow: 0 1px 2px #000; } .uframe__btn--sell .uframe__label { color: #ffd98a; } .uframe__price { position: absolute; right: -.12rem; top: -.06rem; --hex-h: .24rem; } diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 27078e57..90c7fee4 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -273,7 +273,7 @@ function MatchScreen() { priv, stage: gd.stage(pub?.stageId), editable, field: deployField, getChess: gd.chess, getToken: gd.token, getItem: gd.item, getEffect: gd.effect, }), [priv, pub?.stageId, editable, gd.ready, deployField]); - live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused }; + live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused }; // ---- camera: every request goes through setCam, which remembers it for the pen's way back ----------------------- // the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field @@ -972,6 +972,7 @@ function MatchScreen() { }, [editable, placeCtx, facing]); // the selected piece: gone / not editable → deselect; on the board its range tiles show (rotated to its facing) const selEntry = sel ? placeCtx.pieces.get(sel.uid) || null : null; + live.current.showPrep = showPrep; useEffect(() => { if (sel && (!selEntry || !editable || !showPrep)) setSel(null); }, [sel, selEntry, editable, showPrep]); const selRangeKey = selEntry && selEntry.area === 'board' ? `${selEntry.piece.uid}:${selEntry.row},${selEntry.col}:${pieceDir(selEntry.piece)}` : ''; useEffect(() => { @@ -1095,6 +1096,15 @@ function MatchScreen() { return; } if (!L.editable) return; + if (act === 'retreat' || act === 'sell') { + if (!L.showPrep || L.drag || L.facing || L.selBusy || !L.sel) return; + const selected = L.placeCtx.pieces.get(L.sel.uid); + if (selected?.piece.kind !== 'chess') return; + const available = underframeActions(L.placeCtx, L.sel.uid); + if (act === 'retreat' && available?.retreat) await retreatSel(); + else if (act === 'sell' && available?.sell != null) await sellSel(); + return; + } const reason = shopBlockReason(act, { priv: L.priv, editable: L.editable }); if (reason) { audio.sfx('error', { volume: 0.5 }); return; } if (act === 'refresh') actions.refresh(); diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 7bccdd8b..18a877c1 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -1528,11 +1528,11 @@ export function rangeGridBox(grid, mirror = false) { // ---- keyboard --------------------------------------------------------------------------------------------------- /** - * Map a keydown to a game shortcut (R refresh, F freeze, D level-up, Space ready, Esc close). + * 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. * @param {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean, target?: any }} e - * @returns {'refresh'|'freeze'|'levelUp'|'ready'|'escape'|null} + * @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null} */ export function shortcutFor(e) { if (!e || e.ctrlKey || e.metaKey || e.altKey) return null; @@ -1546,6 +1546,8 @@ export function shortcutFor(e) { 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; } @@ -1561,7 +1563,7 @@ 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. - * @param {'refresh'|'freeze'|'levelUp'|'ready'|'escape'|null} act shortcutFor + * @param {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null} act shortcutFor * @param {{ modal?: boolean, drawer?: boolean }} open */ export function shortcutBlocked(act, { modal = false, drawer = false } = {}) { diff --git a/public/js/ui/settings.js b/public/js/ui/settings.js index ccf9aa8f..1b8de0fb 100644 --- a/public/js/ui/settings.js +++ b/public/js/ui/settings.js @@ -73,7 +73,7 @@ export function SettingsModal({ open, onClose }) { ${touchUi ? html`

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

` - : html`

快捷键:R 刷新 · F 冻结 · D 升级 · Space 准备就绪 · Esc 关闭弹窗 · 右键查看详情

`} + : html`

快捷键:R 刷新 · F 冻结 · D 升级 · Q 撤退选中干员 · X 出售选中干员 · Space 准备就绪 · Esc 关闭弹窗 · 右键查看详情

`} `; } diff --git a/public/js/ui/underframe.js b/public/js/ui/underframe.js index 57ed698c..95258c0b 100644 --- a/public/js/ui/underframe.js +++ b/public/js/ui/underframe.js @@ -91,13 +91,13 @@ export function Underframe({ view, uid = null, row, col, actions, name = '', bus ${actions.retreat ? html`` : null} ${actions.sell != null ? html`` : null} ${actions.destroy ? html`