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:
1 parent
a0a5419eb8
commit
3f05c65dec
9 files changed
+82
-12
No files matched your search
@@ -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; }
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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>
|
||||
<//>`;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -494,6 +494,20 @@ describe('keyboard & settings', () => {
|
||||
assert.equal(shortcutFor({ key: 'r', code: 'KeyR' }), 'refresh');
|
||||
assert.equal(shortcutFor({ key: 'F', code: 'KeyF' }), 'freeze');
|
||||
assert.equal(shortcutFor({ key: 'd' }), 'levelUp');
|
||||
assert.equal(shortcutFor({ key: 'q', code: 'KeyQ' }), 'retreat');
|
||||
assert.equal(shortcutFor({ key: 'Q' }), 'retreat');
|
||||
assert.equal(shortcutFor({ code: 'KeyX' }), 'sell');
|
||||
assert.equal(shortcutFor({ key: 'X' }), 'sell');
|
||||
for (const key of ['q', 'x']) {
|
||||
assert.equal(shortcutFor({ key, repeat: true }), null);
|
||||
assert.equal(shortcutFor({ key, ctrlKey: true }), null);
|
||||
assert.equal(shortcutFor({ key, metaKey: true }), null);
|
||||
assert.equal(shortcutFor({ key, altKey: true }), null);
|
||||
for (const tagName of ['INPUT', 'TEXTAREA', 'SELECT']) {
|
||||
assert.equal(shortcutFor({ key, target: { tagName } }), null);
|
||||
}
|
||||
assert.equal(shortcutFor({ key, target: { isContentEditable: true } }), null);
|
||||
}
|
||||
assert.equal(shortcutFor({ key: ' ', code: 'Space' }), 'ready');
|
||||
assert.equal(shortcutFor({ key: 'Escape' }), 'escape');
|
||||
assert.equal(shortcutFor({ key: 'r', ctrlKey: true }), null);
|
||||
@@ -503,7 +517,7 @@ describe('keyboard & settings', () => {
|
||||
assert.equal(shortcutFor({ key: ' ', code: 'Space', target: { tagName: 'BUTTON' } }), 'ready', 'space readies even with a HUD button focused');
|
||||
assert.equal(shortcutFor({ key: ' ', code: 'Space', target: { tagName: 'TEXTAREA' } }), null);
|
||||
assert.equal(shortcutFor({ key: 'd', target: { tagName: 'DIV', isContentEditable: true } }), null);
|
||||
assert.equal(shortcutFor({ key: 'x' }), null);
|
||||
assert.equal(shortcutFor({ key: 'z' }), null);
|
||||
assert.equal(shortcutFor(null), null);
|
||||
});
|
||||
test('sanitizeSettings', () => {
|
||||
|
||||
@@ -149,6 +149,48 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('prep: Q retreats and X sells only the selected operator', async () => {
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
const initial = await mockState(page);
|
||||
await page.keyboard.press('KeyQ');
|
||||
await page.keyboard.press('KeyX');
|
||||
assert.deepEqual(await mockState(page), initial, 'no selection means no action');
|
||||
const unit = initial.board.find((piece) => piece.kind === 'chess' && !piece.golden);
|
||||
await page.click(`.ff-piece[data-uid="${unit.uid}"]`);
|
||||
await page.waitForSelector('.uframe__btn--retreat');
|
||||
await page.evaluate(() => {
|
||||
const input = document.createElement('input');
|
||||
input.id = 'shortcut-test-input';
|
||||
document.body.append(input);
|
||||
input.focus();
|
||||
});
|
||||
await page.keyboard.press('KeyQ');
|
||||
await page.keyboard.press('KeyX');
|
||||
assert.deepEqual(await mockState(page), initial, 'typing must not retreat or sell');
|
||||
await page.evaluate(() => document.getElementById('shortcut-test-input').remove());
|
||||
await page.keyboard.press('KeyQ');
|
||||
await page.waitForFunction((uid) => globalThis.__MOCK__.S().priv.hand.some((piece) => piece?.uid === uid), {}, unit.uid);
|
||||
const retreated = await mockState(page);
|
||||
assert.ok(!retreated.board.some((piece) => piece.uid === unit.uid));
|
||||
assert.equal(retreated.funds, initial.funds, 'retreat does not sell');
|
||||
await page.click(`.ff-piece[data-uid="${unit.uid}"]`);
|
||||
await page.waitForSelector('.uframe__btn--sell');
|
||||
assert.equal(await page.$('.uframe__btn--retreat'), null, 'bench operator cannot retreat');
|
||||
await page.keyboard.press('KeyQ');
|
||||
assert.deepEqual(await mockState(page), retreated, 'Q on the bench does nothing');
|
||||
await page.keyboard.press('KeyX');
|
||||
await page.waitForFunction((uid) => !globalThis.__MOCK__.S().priv.hand.some((piece) => piece?.uid === uid), {}, unit.uid);
|
||||
assert.ok((await mockState(page)).funds > retreated.funds, 'X sells the selected operator');
|
||||
const item = (await mockState(page)).hand.find((piece) => piece?.kind === 'item');
|
||||
await page.click(`.ff-piece[data-uid="${item.uid}"]`);
|
||||
await page.waitForSelector('.uframe__btn--destroy');
|
||||
await page.keyboard.press('KeyX');
|
||||
assert.ok((await mockState(page)).hand.some((piece) => piece?.uid === item.uid), 'X must not destroy an item');
|
||||
assert.equal(await page.$('.modal'), null, 'no destroy confirmation');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('prep: drag & drop — tap-sell (no sell zone), hand → board + wheel, item → unit (equip)', async () => {
|
||||
// DOM drag targets only exist in the fallback view (the Pixi engine's own drag is tested by the render module)
|
||||
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
|
||||
|
||||
@@ -115,6 +115,8 @@ describe('8: the detail card never covers the selected unit\'s underframe', () =
|
||||
assert.doesNotMatch(css, /\.gm__bonds > \* \{ pointer-events: auto; \}/);
|
||||
const dev = readFileSync(new URL('../../public/css/devices.css', import.meta.url), 'utf8');
|
||||
assert.match(dev, /\.gm__hud > \.uframe \{ margin-left: calc\(-1 \* var\(--sa-l\)\); margin-top: calc\(-1 \* var\(--sa-t\)\); \}/);
|
||||
const panels = readFileSync(new URL('../../public/css/screens/game-panels.css', import.meta.url), 'utf8');
|
||||
assert.match(panels, /\.uframe__label\s*\{[^}]*white-space:\s*nowrap\s*;/, 'shortcut labels stay on one line');
|
||||
});
|
||||
|
||||
test('underframeRect covers the diamond and its buttons', () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('暂离 (AI 托管) is scoped to one match', () => {
|
||||
|
||||
describe('game shortcuts behind overlays', () => {
|
||||
test('the 本局信息 / 敌方情报 drawer swallows R / F / D / Space, Esc still closes it', () => {
|
||||
for (const act of ['refresh', 'freeze', 'levelUp', 'ready']) {
|
||||
for (const act of ['refresh', 'freeze', 'levelUp', 'retreat', 'sell', 'ready']) {
|
||||
assert.equal(shortcutBlocked(act, { drawer: true }), true, act);
|
||||
assert.equal(shortcutBlocked(act, {}), false, act);
|
||||
assert.equal(shortcutBlocked(act, { modal: true }), true, act);
|
||||
|
||||
Reference in new issue
Block a user