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 1/5] 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`