Files
ark-stronghold-protocol/public/js/ui/underframe.js
T
sganggs f2bec76176 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).
2026-10-07 01:56:19 +08:00

179 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Underframe — the selection diamond of a tapped own piece (research 09 §1.2 "Tap a deployed unit", §5, §6.5;
// official `act2autochess_panel_character_menu`: `util_btn` icon_sell + price "+1", funcId autochessSale; the
// destroy variant `_underFramePanelBtnParamDestroyPos` with icon_destory).
//
// board operator → 撤退 (upper-left: back to the bench) + 出售 +N (upper-right)
// board summon → 撤退
// bench operator → 出售 +N
// item / Art → 销毁
// The diamond frames the piece's tile; only its buttons take pointer events, so the unit under it can still be
// dragged (moving it — or dropping it back on its own tile to re-orient it with the wheel). The game screen shows the
// unit's range tiles (rotated to its facing) and the detail card beside it while it is selected (on the side away
// from the unit, `underframeRect` / gameLogic `panelSide`), and the underframe sits above every HUD panel, so its
// buttons are never covered.
//
// Button look (user playtest #2 item 7): the official sprites icon_sell / icon_destory are WHITE octagon plates with
// the glyph cut out — tinted in the client. Drawn as is they read as a plain white block, so each button is a coloured
// octagon plate: the sprite is used as a CSS mask over the plate colour (出售 amber like every money action, 销毁 /
// 撤退 red) on a dark backing that shows through the glyph cut-out. Without the local sprites the same plate carries
// the built-in glyph.
//
// TempRowNotice (user playtest #3 item 3) — the other board-anchored overlay of the prep: while the temp overflow row
// (临时整备区, row 8, the 5 pads in front of the bench) holds pieces, a dashed red frame around that row and a label at
// its end say that they block 准备就绪 and are destroyed when the prep ends unless moved to the bench, equipped or used.
// Placed through view.tileScreen (useTileScreen, so it follows the camera — the Final Assault prep too); it never takes
// the pointer, so the pieces under it stay draggable.
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';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Runner glyph of 撤退 (a figure leaving through a door; original shape). */
function RetreatGlyph() {
return html`<svg class="uframe__glyph" viewBox="0 0 24 24" aria-hidden="true">
<path d="M13.5 3.2a2 2 0 1 1 0 4 2 2 0 0 1 0-4zM9.6 8.3l3.7-.6 2.2 3.3 2.7 1-.6 1.8-3.4-1.2-.9-1.3-.8 3.2 2.6 2.6V22h-2v-4.7l-2.4-2.3-.9 3.6-4.4-1 .5-1.9 2.4.5 1.6-6.6-1 .3-1.4 2.8-1.8-.9 1.9-3.6z" />
<path d="M2 5h5v2H4v10h3v2H2z" opacity=".7" />
</svg>`;
}
/**
* A tinted octagon plate: the official sprite (white plate, glyph cut out) as a mask over the tone colour, or the
* built-in glyph on the same plate when the local sprite is not installed.
* @param {{ sprite: string, glyph: string, tone: 'sell'|'destroy' }} props
*/
export function PlateIcon({ sprite, glyph, tone }) {
const url = localAsset('ui/battle', sprite);
return html`<span class=${cx('uframe__plate', `uframe__plate--${tone}`, url ? 'has-mask' : 'is-glyph')}
style=${url ? `--uf-mask:url("${url}")` : ''} data-sprite=${url ? sprite : null} aria-hidden="true">
${url ? null : html`<${GIcon} name=${glyph} class="uframe__pglyph" />`}
</span>`;
}
/**
* Client-px rect the underframe of a tile covers: the diamond plus its buttons (plates, labels, the +N price) — the
* geometry of the css below (.uframe__btn--retreat / --sell: .56rem plates at 25 % / 75 % across, 25 % down, shifted
* −80 % / −20 % horizontally and −90 % vertically). Used for the detail panel placement.
* @param {{ x: number, y: number, s: number }|null} g view.tileScreen(row, col)
* @param {number} [rem] root font size (px)
* @returns {{ left: number, right: number, top: number, bottom: number }|null}
*/
export function underframeRect(g, rem = 100) {
if (!g || !Number.isFinite(g.x) || !Number.isFinite(g.y)) return null;
const s = g.s > 0 ? g.s : 64;
const half = s * 1.05;
const P = rem * 0.56; // plate
const H = P + rem * 0.26; // plate + label
const q = half / 2; // 25 % / 75 % of the diamond box, from its centre
const btnTop = g.y - q - 0.9 * H;
const left = Math.min(g.x - half, g.x - q - 0.8 * P);
const right = Math.max(g.x + half, g.x + q - 0.2 * P + P + rem * 0.14);
return { left, right, top: Math.min(g.y - half, btnTop - rem * 0.08), bottom: g.y + half };
}
/**
* @param {{ view: any, uid?: number|null, row: number, col: number, actions: { retreat: boolean, sell: number|null, destroy: boolean },
* name?: string, busy?: boolean, onRetreat?: () => void, onSell?: () => void, onDestroy?: () => void }} props
*/
export function Underframe({ view, uid = null, row, col, actions, name = '', busy = false, onRetreat, onSell, onDestroy }) {
const g = useTileScreen(view, row, col);
if (!g || !actions) return null;
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">
<path class="uframe__outer" d="M0 -100 L100 0 L0 100 L-100 0 Z" />
<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('撤退至整备区({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} 资金,{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('出售[{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}
onClick=${(e) => { stop(e); onDestroy?.(); }} title=${t('销毁道具')} aria-label=${t('销毁')}>
<${PlateIcon} sprite="icon_destory" glyph="trash" tone="destroy" />
<span class="uframe__label">${t('销毁')}</span>
</button>` : null}
</div>`;
}
// ---- the temp overflow row (临时整备区) ---------------------------------------------------------------------------
/**
* Screen frame of the temp row from its two end tiles (view.tileScreen of cols TEMP_C0 and TEMP_C0 + TEMP_SIZE − 1): the
* quad [back-left, back-right, front-right, front-left] (back = the far edge, higher on screen — also when the Final
* Assault prep mirrors the board), its bounds, and the side of the label: left of the row, or right of it when the
* left has no room for `labelW` px. Pure (test/ui/playtest3.test.js).
* @param {{ poly: Array<[number, number]> } | null} a @param {{ poly: Array<[number, number]> } | null} b
* @param {{ labelW?: number, gap?: number, vw?: number }} [opts]
* @returns {{ quad: Array<[number, number]>, left: number, right: number, top: number, bottom: number, y: number, side: 'left'|'right' } | null}
*/
export function tempRowFrame(a, b, { labelW = 0, gap = 8, vw = Infinity } = {}) {
const ok = (t) => t && Array.isArray(t.poly) && t.poly.length >= 4 && t.poly.every((p) => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]));
if (!ok(a) || !ok(b)) return null;
const backs = [a.poly[0], a.poly[1], b.poly[0], b.poly[1]];
const fronts = [a.poly[2], a.poly[3], b.poly[2], b.poly[3]];
const minX = (pts) => pts.reduce((m, p) => (p[0] < m[0] ? p : m));
const maxX = (pts) => pts.reduce((m, p) => (p[0] > m[0] ? p : m));
const quad = [minX(backs), maxX(backs), maxX(fronts), minX(fronts)];
const xs = quad.map((p) => p[0]);
const ys = quad.map((p) => p[1]);
const left = Math.min(...xs), right = Math.max(...xs), top = Math.min(...ys), bottom = Math.max(...ys);
const side = left - gap - labelW >= 0 || right + gap + labelW > vw ? 'left' : 'right';
return { quad, left, right, top, bottom, y: (top + bottom) / 2, side };
}
/**
* What the temp row's label says will happen to its pieces (server/match/player/basics.js tempDue): a piece is resolved
* at the end of the first prep in which the player can act on it. Not ready (or outside PREP): at the end of this / the
* coming prep, and 准备就绪 waits for the row to be cleared. Ready in PREP: Ready is refused while the row holds pieces,
* so whatever lies there arrived after it — kept through the NEXT prep (cancelling Ready makes it due at this one).
* @param {boolean} ready m.private ready (during PREP)
*/
export function tempRowRule(ready) {
return ready
? t('已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)')
: t('放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁');
}
/**
* The temp row's notice: dashed red frame + label (count, what to do, what happens at the end of the prep — tempRowRule).
* `label` false (a piece is being dragged / placed): the frame only, nothing over the pieces the player is moving.
* @param {{ view: any, count: number, items?: number, label?: boolean, ready?: boolean }} props
*/
export function TempRowNotice({ view, count, items = 0, label = true, ready = false }) {
const a = useTileScreen(view, GEO.TEMP_ROW, GEO.TEMP_C0);
const b = useTileScreen(view, GEO.TEMP_ROW, GEO.TEMP_C0 + GEO.TEMP_SIZE - 1);
let rem = 100;
try { rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100; } catch { /* default */ }
const vw = globalThis.innerWidth || 1920;
const f = count > 0 ? tempRowFrame(a, b, { labelW: rem * 2.9, gap: rem * 0.1, vw }) : null;
if (!f) return null;
const pts = f.quad.map((p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
const pad = rem * 0.1;
const style = f.side === 'left' ? `left:${(f.left - pad).toFixed(1)}px;top:${f.y.toFixed(1)}px` : `left:${(f.right + pad).toFixed(1)}px;top:${f.y.toFixed(1)}px`;
return html`<div class="tempnote" aria-hidden="false" data-testid="temp-notice">
<svg class="tempnote__frame" aria-hidden="true"><polygon points=${pts} /></svg>
${label ? html`<div class=${`tempnote__label is-${f.side}`} style=${style} role="status">
<b class="tempnote__title"><${Icon} name="warn" />${items > 0 && items === count
? tParts('临时整备区 {n} 件道具待处理', { n: html`<span class="num">${count}</span>`, count })
: tParts('临时整备区 {n} 个单位待处理', { n: html`<span class="num">${count}</span>`, count })}</b>
<span class="tempnote__rule">${tempRowRule(ready)}</span>
</div>` : null}
</div>`;
}