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.
This commit is contained in:
Chun'an Shi authored and GitHub committed 2026-10-05 14:18:44 +08:00
1 parent a0a5419eb8
commit 3f05c65dec
9 files changed
+82 -12

No files matched your search

+11 -1
View File
@@ -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();
+5 -3
View File
@@ -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 } = {}) {
+1 -1
View File
@@ -73,7 +73,7 @@ export function SettingsModal({ open, onClose }) {
</div>
${touchUi
? html`<p class="set-hint">触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向</p>`
: html`<p class="set-hint">快捷键:<kbd>R</kbd> 刷新 · <kbd>F</kbd> 冻结 · <kbd>D</kbd> 升级 · <kbd>Space</kbd> 准备就绪 · <kbd>Esc</kbd> 关闭弹窗 · 右键查看详情</p>`}
: html`<p class="set-hint">快捷键:<kbd>R</kbd> 刷新 · <kbd>F</kbd> 冻结 · <kbd>D</kbd> 升级 · <kbd>Q</kbd> 撤退选中干员 · <kbd>X</kbd> 出售选中干员 · <kbd>Space</kbd> 准备就绪 · <kbd>Esc</kbd> 关闭弹窗 · 右键查看详情</p>`}
</div>
<//>`;
}
+4 -4
View File
@@ -91,13 +91,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="撤退至整备区" aria-label="撤退">
<${RetreatGlyph} /><span class="uframe__label">撤退</span>
onClick=${(e) => { stop(e); onRetreat?.(); }} title=${actions.sell != null ? '撤退至整备区(Q)' : '撤退至整备区'} aria-label="撤退" aria-keyshortcuts=${actions.sell != null ? 'Q' : undefined}>
<${RetreatGlyph} /><span class="uframe__label">${actions.sell != null ? '撤退[Q]' : '撤退'}</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=${`出售(+${actions.sell} 资金)`} aria-label=${`出售,获得 ${actions.sell} 资金`}>
onClick=${(e) => { stop(e); onSell?.(); }} title=${`出售(+${actions.sell} 资金,X)`} aria-label=${`出售,获得 ${actions.sell} 资金`} aria-keyshortcuts="X">
<${PlateIcon} sprite="icon_sell" glyph="sell" tone="sell" />
<span class="uframe__label">出售</span>
<span class="uframe__label">出售[X]</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}