feedback1 ui: folding the shop in prep moves the own board to the official shop-collapsed camera (GitHub #5)

While the own prep board is shown and the shop bar is folded, the game asks for the prep camera with shop: false
(left_prepare_camera_param / the boss prepare params) and the view keeps the bench above the folded shop's HUD band:
the higher of the folded tab and the corner buttons' hit areas (hudBands(kind, size, { shop: false }), cornerBand).
Unfolding returns to the shop camera. Never over the pen, a teammate's board or a battle; deferred while a piece is
dragged or its direction is chosen (gameLogic prepCameraFor / foldCamera). Below 768 px of width the corner stays on
one row while the bar is folded. Tile px at row 10: 1920x1080 109.8 -> 130.7, 844x390 35.9 -> 44.3,
756x366 31.4 -> 40.1, every bench pad fully pressable.
This commit is contained in:
sganggs committed 2026-10-03 16:14:43 +08:00
1 parent 842e1e13c4
commit 82c54897c7
8 files changed
+394 -33

No files matched your search

+5
View File
@@ -129,6 +129,11 @@ html:not(.sp-in-match) .toast-host { margin-top: var(--sa-t); }
.gm__corner { width: max(96px, calc(3 * max(.56rem, 34px) + .2rem)); }
.dpanel:not(.dpanel--right) { bottom: calc(.44rem + max(1.51rem, 96px) * 55 / 151 + max(.56rem, 34px)); }
}
/* With the shop folded (收起) no bar covers the corner: one row again, so the folded shop's prep camera, which keeps the
bench above the corner's hit areas (ui/fieldHost.js hudBands), grows the board on narrow phones too (public issue #5). */
@media (max-width: 767px) {
.gm.is-collapsed .gm__corner { flex-wrap: nowrap; width: auto; }
}
/* Short landscape phones (≤ 460 CSS px tall, 1rem = 40 px): the lobby's two columns are compressed so the
创建同盟 button and the join panel stay on screen without scrolling. The detail card's header is shorter (no
+15 -9
View File
@@ -3,11 +3,14 @@
//
// const view = await createFieldView(host, { data, assets, audio, settings, padding, hud })
// view.setStage(stage) procedural tiles + devices (data/stages.json entry)
// view.setCamera(kind, { rect, side, padding, instant }) 'prep'|'normal'|'unite'|'boss'('hidden'); animated
// view.setCamera(kind, { rect, side, padding, instant, shop }) 'prep'|'normal'|'unite'|'boss'('hidden'); animated
// — the prep cameras (own board / Final Assault half) keep the bench
// and the field clear of `hud(kind, size)` = { top, bottom } px of
// DOM HUD along the top / bottom edge (projection.js clearHud; user
// playtest #5 item 9: the shop bar covered the bench on phones)
// and the field clear of `hud(kind, size, { shop })` = { top, bottom } px
// of DOM HUD along the top / bottom edge (projection.js clearHud; user
// playtest #5 item 9: the shop bar covered the bench on phones); `shop:
// false` = the folded shop: the official shop-collapsed prep camera
// (left_prepare / *_boss_prepare) clear of the folded shop's band (public
// issue #5: folding the shop did not grow the board)
// view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop; a
// new board piece flashes (fx.deploy); a merge's elite — on the tile of
// the deployed copy it replaced, or on its bench slot — gets the
@@ -374,7 +377,7 @@ export function releaseGl(renderer) {
* @param {HTMLElement} host
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string },
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}) => {top:number,bottom:number}|null) }} [opts]
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts]
*/
export async function createFieldView(host, options = {}) {
if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required');
@@ -650,10 +653,11 @@ export async function createFieldView(host, options = {}) {
}
// the HUD bands the prep cameras keep the bench / field clear of (projection.js clearHud; user playtest #5 item 9):
// `opts.hud` = (kind, size) => { top, bottom } | null, or a fixed object; none → the plain official framing
function hudBands(kind, sz) {
// `opts.hud` = (kind, size, { shop }) => { top, bottom } | null, or a fixed object; none → the plain official framing.
// `shop: false` asks for the folded shop's band (the camera request's `shop: false`, public issue #5)
function hudBands(kind, sz, o) {
if (kind !== 'prep' && kind !== 'bossPrep') return null;
if (typeof opts.hud === 'function') { try { return opts.hud(kind, sz) || null; } catch { return null; } }
if (typeof opts.hud === 'function') { try { return opts.hud(kind, sz, o) || null; } catch { return null; } }
return opts.hud && typeof opts.hud === 'object' ? opts.hud : null;
}
@@ -673,10 +677,12 @@ export async function createFieldView(host, options = {}) {
if (k === 'prep') rect = rect ? { ...rect, r0: Math.min(rect.r0, GEO.HAND_ROW) } : null;
// official configBlackBoard framing (render/projection.js presetCamera); the padding only matters for the
// fitted fallback (custom rects, portrait viewports); a prep camera keeps the bench and the field clear of the HUD
// — `shop: false` (the folded shop, public issue #5) = the official shop-collapsed camera, clear of the folded
// shop's HUD band (re-evaluated on resize: camOpts keep the flag)
const vk = viewKind(kind, o); // (a 'prep' camera on the boss rows = the Final Assault prep)
return presetCamera(k, { width: sz.width, height: sz.height, padding: o.padding || defaultPadding(k, sz) }, {
rect, side: o.side, half: !!o.half, shop: o.shop, fit: !!o.fit, config: stageRec?.config || null,
hud: hudBands(vk, sz),
hud: hudBands(vk, sz, { shop: o.shop !== false }),
});
}
+27 -5
View File
@@ -29,7 +29,10 @@
// comes back as it was. Tapping an enemy in the pen (view pieceClick with `enemyKey` / a `preview` enemy unit) opens its
// detail card; the old enemy list lives on as the 敌方情报 tab of the 本局信息 dialog (left 🔍).
// Every camera request of this screen goes through setCam (it remembers the camera the pen returns to);
// `.gm[data-camera]` mirrors the current camera kind (E2E / styling).
// `.gm[data-camera]` mirrors the current camera kind (E2E / styling). Folding the shop bar (收起) on the own prep board
// flies the camera to the official shop-collapsed framing and unfolding back to the shop camera (public issue #5:
// gameLogic prepCameraFor / foldCamera — never over the pen, a teammate's board or a battle; deferred while a piece is
// dragged or its direction is chosen).
// Equipment dropped on an operator whose two slots are used opens the equip-replace dialog (ui/equipReplace.js): the
// player picks the equipped item to destroy → g.equip {itemUid, targetUid, replaceUid}; 取消 sends nothing. 销毁 is only
// offered for loose items (equipped ones are locked, ui/facing.js itemDestroyable).
@@ -88,7 +91,8 @@ import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout, mergeTarget, modeOffBonds,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
mergeTarget, modeOffBonds,
} from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js';
import { BriefingScreen } from './briefing.js';
@@ -225,6 +229,7 @@ function MatchScreen() {
const home = homeFieldId(pub, myId);
const watchingOther = !!watching && watching !== home && watching !== ownFieldId(myId);
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther;
const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther;
const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase);
const sp = phase === PHASE.SP_DRAFT ? normalizeSp(pub?.sp, players) : null;
const total = phaseTotalSeconds(pub, gd.config, myId);
@@ -274,6 +279,9 @@ function MatchScreen() {
const prepCamSeen = useRef(prepCamKey); // the prep camera last requested
const camRef = useRef({ kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L' } });
const penRef = useRef({ on: false, collapsed: false });
// the shop bar is shown folded (收起; the pen folds it for itself: the player's own state is the one it returns to) —
// the own prep board then takes the official shop-collapsed camera (public issue #5, gameLogic prepCameraFor)
const shopFolded = showShop && (pen ? penRef.current.collapsed : collapsed);
const cancelFacingRef = useRef(() => {});
const setCam = useCallback((kind, opts) => {
camRef.current = { kind, opts: opts || {} };
@@ -360,7 +368,8 @@ function MatchScreen() {
// the battle we just left (or whatever was stored before mount) must not be re-entered next combat;
// an m.field that arrives during prep (the upcoming battle) is a new object and will be entered
staleFieldRef.current = field;
setCam(prepCam.kind, prepCam.opts);
const pc = prepCameraFor(pub, myId, shopFolded);
setCam(pc.kind, pc.opts);
prepCamSeen.current = prepCamKey;
viewModeRef.current = 'prep';
lastFieldRef.current = null;
@@ -483,9 +492,23 @@ function MatchScreen() {
if (!view || viewModeRef.current !== 'prep' || !showPrep) return;
if (live.current.facing) cancelFacingRef.current();
setSel(null);
setCam(prepCam.kind, prepCam.opts);
const pc = prepCameraFor(pub, myId, shopFolded);
setCam(pc.kind, pc.opts);
}, [view, prepCamKey, showPrep]);
// the shop bar folded (收起) or unfolded on the own prep board (public issue #5: the board did not grow, while the
// battle and scouting views had no bar to make room for): the official shop-collapsed camera or the shop camera
// again, flown like any camera change. Never over the pen or a teammate's board or in battle; deferred while a piece
// is dragged or its direction is chosen (the drop target and the wheel sit on tiles of the camera in use) — this
// effect runs again when that ends (gameLogic foldCamera). Picking reads the camera of every frame (render/pick.js).
useEffect(() => {
const next = foldCamera({
pub, myId, folded: shopFolded, ownPrep: !!view && viewModeRef.current === 'prep' && showPrep,
pen, busy: !!drag || !!facing, current: camRef.current,
});
if (next) setCam(next.kind, next.opts);
}, [view, shopFolded, showPrep, pen, !!drag, !!facing, prepCamKey]);
// 联防 / 最终攻势: the ‹ › pill moves the camera between the field's halves and 全景 (research 09 §3.1)
useEffect(() => {
if (!view || !field || !field.local || !isCombatPhase(phase)) return;
@@ -1050,7 +1073,6 @@ function MatchScreen() {
// ---- render ---------------------------------------------------------------------------------------------------
const readyCount = players.filter((p) => p.ready || p.status === 'ready').length;
const aliveCount = players.filter((p) => p.alive !== false && p.status !== 'left').length;
const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther;
// the own battle is over: the server says so (status done) or — client-side combat — the local simulation just ended
const localDone = cc && !!battleState && battleState.own && !battleState.watch && battleState.done && battleState.fieldId === ownFieldId(myId);
// (client-side combat: only in 各自行动 — 联防 observers just watch the 联防 field, research 09 §3.1)
+61 -17
View File
@@ -43,41 +43,85 @@ export function hudPadding(kind, size) {
* 2.15rem measured) and the shop bar's top edge above the viewport's bottom (css/screens/game-shop.css .shopbar
* bottom .2rem + .shopbar__row padding .1rem ×2 + card height 2.24rem, plus its 2 px + 1 px borders). The shop bar
* sits on the viewport's bottom edge even on a notched phone (css/devices.css, DESIGN §18.1).
* The folded shop (public issue #5): the tab's top edge (.shopbar-tab bottom .2rem + padding .08rem ×2 + its .44rem
* button, plus its 2 px + 1 px borders; it sits inside the HUD layer, above the bottom safe-area inset) and the corner
* buttons' top edge (css/screens/game.css .gm__corner bottom .24rem + a .56rem row — the fallback when the corner
* cannot be measured: on phones its buttons grow to 34 px and below 768 CSS px of width they wrap into two rows,
* css/devices.css).
*/
export const HUD_REM = Object.freeze({ bondStripBottom: 2.16, shopBarTop: 2.64, shopBarBorderPx: 3 });
export const HUD_REM = Object.freeze({
bondStripBottom: 2.16, shopBarTop: 2.64, shopBarBorderPx: 3, shopTabTop: 0.8, shopTabBorderPx: 3, cornerTop: 0.8,
});
/**
* CSS px of HUD along the top edge (top bar + bond strip) and the bottom edge (the shop bar) of the viewport during
* prep — the own board ('prep') or the Final Assault half ('bossPrep'); null for every other camera. The prep camera
* keeps the bench / temp rows and the field's back row clear of them (render/projection.js clearHud; user playtest
* #5 item 9: the rem floor of 40 px makes the HUD relatively taller on phones in landscape and the shop bar covered
* the bench). The prep camera is the shop camera whether or not the bar is collapsed, so the band assumes the bar —
* also for an eliminated player's own board (no shop bar: the band only costs size there, while a camera following
* the bar's presence would have to re-frame whenever it appears, e.g. when the private state arrives after the prep
* camera was set). Scouting a teammate's board uses the 'normal' camera: no band. An armed shop card (two-tap buy,
* css/screens/game-shop.css .scard.is-armed) rises 4 px above the bar's top on a phone and covers the bench pads'
* near corners by ≈ 3 px while it stays armed — less than under the unchanged official camera at 1920×1080 (13 px
* above the bar, ≈ 11 px over the pads).
* CSS px of HUD along the top edge (top bar + bond strip) and the bottom edge of the viewport during prep — the own
* board ('prep') or the Final Assault half ('bossPrep'); null for every other camera. The prep camera keeps the bench /
* temp rows and the field's back row clear of them (render/projection.js clearHud; user playtest #5 item 9: the rem
* floor of 40 px makes the HUD relatively taller on phones in landscape and the shop bar covered the bench).
* The bottom band is the shop bar's — also for an eliminated player's own board (no shop bar: the band only costs size
* there, while a camera following the bar's presence would have to re-frame whenever it appears, e.g. when the private
* state arrives after the prep camera was set) — unless `opts.shop === false`: the player folded the shop (收起) and the
* own prep board moved to the official shop-collapsed camera (public issue #5: the board did not grow; screens/game.js
* asks for it only while a folded bar is shown). Its band is the higher of the folded tab (bottom right) and the corner
* buttons' hit areas (交流 / ⚙ / 📖 / ⛶, bottom left, measured by `cornerBand`: on a narrow phone two rows of 34 px buttons
* with 44 px touch areas) — under the official collapsed camera the bench's left pads reach under the corner, and with
* the tab alone it would cover bench pad 0 at the user's 756×366 Android and the near edge of pads 0–2 at 844×390; both
* are 1-D bands like the bar's, so every bench pad stays fully pressable. Scouting a
* teammate's board uses the 'normal' camera: no band. An armed shop card (two-tap buy, css/screens/game-shop.css
* .scard.is-armed) rises 4 px above the bar's top on a phone and covers the bench pads' near corners by ≈ 3 px while it
* stays armed — less than under the unchanged official camera at 1920×1080 (13 px above the bar, ≈ 11 px over the pads).
* @param {string} kind
* @param {{ width: number, height: number }} size
* @param {{ shop?: boolean }} [opts] `shop: false` = the folded shop's band
* @returns {{ top: number, bottom: number }|null}
*/
export function hudBands(kind, size) {
export function hudBands(kind, size, opts) {
if (kind !== 'prep' && kind !== 'bossPrep') return null;
const folded = !!opts && opts.shop === false;
const h = size?.height || 1080;
let rem = 100;
let safeTop = 0;
let safeBottom = 0;
let corner = 0;
try {
rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100;
// the HUD layer starts below the top safe-area inset (css/devices.css .gm__hud)
safeTop = Math.max(0, document.querySelector('.gm__hud')?.getBoundingClientRect().top || 0);
// the HUD layer starts below the top safe-area inset and ends above the bottom one (css/devices.css .gm__hud)
const hud = document.querySelector('.gm__hud')?.getBoundingClientRect();
safeTop = Math.max(0, hud?.top || 0);
if (folded) {
if (hud && hud.bottom > 0) safeBottom = Math.max(0, h - hud.bottom);
corner = cornerBand(h);
}
} catch { /* ignore */ }
const h = size?.height || 1080;
const bottom = folded
? Math.max(safeBottom + rem * HUD_REM.shopTabTop + HUD_REM.shopTabBorderPx, corner || safeBottom + rem * HUD_REM.cornerTop)
: rem * HUD_REM.shopBarTop + HUD_REM.shopBarBorderPx;
return {
top: Math.min(h * 0.4, safeTop + rem * HUD_REM.bondStripBottom),
bottom: Math.min(h * 0.4, rem * HUD_REM.shopBarTop + HUD_REM.shopBarBorderPx),
bottom: Math.min(h * 0.4, bottom),
};
}
/**
* CSS px from the viewport's bottom edge (`h` high) up to the top of the corner buttons' hit areas (交流 / ⚙ / 📖 / ⛶,
* .gm__corner), 0 when they are not on the page. On a touch screen every one of them takes taps from an invisible area
* of at least --tap-min (44 px) centred on it (css/devices.css touch targets), 5 px above a 34 px phone button — a bench
* pad edge there is not pressable.
* @param {number} h
*/
function cornerBand(h) {
const btns = document.querySelectorAll('.gm__corner .gm__gear, .gm__corner .ewheel__btn');
if (!btns || !btns.length) return 0;
const root = document.documentElement;
const tap = root?.classList?.contains('sp-coarse') ? parseFloat(getComputedStyle(root).getPropertyValue('--tap-min')) || 0 : 0;
let top = Infinity;
for (const b of btns) {
const r = b.getBoundingClientRect();
if (r && r.height > 0) top = Math.min(top, r.top + r.height / 2 - Math.max(r.height, tap) / 2);
}
return top < Infinity ? Math.max(0, h - top) : 0;
}
function renderPref() {
try {
const q = new URLSearchParams(globalThis.location?.search || '').get('render');
+31
View File
@@ -91,6 +91,37 @@ export function prepCamera(pub, myId) {
return { kind: 'bossPrep', opts: { side: idx % 2 === 1 ? 'R' : 'L' } };
}
/**
* The own prep board's camera request with the shop bar's state (public issue #5 "准备阶段,收起商店界面时,界面并不会进行
* 缩放"): `prepCamera` plus `shop: !folded` — a folded shop (收起) takes the official shop-collapsed camera
* (configBlackBoard left_prepare_camera_param / *_boss_prepare_*, render/projection.js presetCamera `shop: false`) and
* the folded shop's HUD band (ui/fieldHost.js hudBands), an open one the shop camera. `folded` = the shop bar is shown
* and folded (an eliminated player's board, which shows no bar, keeps the shop camera).
* @returns {{ kind: 'prep'|'bossPrep', opts: { rect?: object, side: 'L'|'R', shop: boolean } }}
*/
export function prepCameraFor(pub, myId, folded = false) {
const c = prepCamera(pub, myId);
return { kind: c.kind, opts: { ...c.opts, shop: !folded } };
}
/**
* The camera request a fold / unfold of the shop bar needs right now (public issue #5), or null: only while the own prep
* board is on screen (`ownPrep`: prep, not a teammate's scouted board, not a battle), never over the enemy pen (`pen`: it
* folds the shop itself and returns to the camera it left), deferred while a piece is dragged or its direction is chosen
* (`busy`: the drop target and the wheel sit on tiles of the camera in use — the caller asks again once that ends), and
* nothing when the current request (`current` { kind, opts }) already has that shop state.
* @param {{ pub: any, myId: string|null, ownPrep: boolean, folded: boolean, pen?: boolean, busy?: boolean,
* current?: { kind: string, opts?: { shop?: boolean } }|null }} s
* @returns {{ kind: 'prep'|'bossPrep', opts: object }|null}
*/
export function foldCamera(s) {
if (!s || !s.ownPrep || s.pen || s.busy) return null;
const want = prepCameraFor(s.pub, s.myId, !!s.folded);
const cur = s.current;
if (cur && cur.kind === want.kind && (cur.opts?.shop !== false) === want.opts.shop) return null;
return want;
}
/**
* The field the own pieces are deployed on (server/match/Match.js deployFieldOf): 'bossL' / 'bossR' in the prep of a
* boss round (the same pairing as `prepCamera`), else 'normal'. Placement legality (`placementContext` → `deployMap`
+212
View File
@@ -0,0 +1,212 @@
// Public issue #5 (v0.1.1): "准备阶段,收起商店界面时,界面并不会进行缩放(观战及作战时无此现象)" — folding the shop (收起) in prep
// left the own board at the shop camera. Now the own prep board takes the official shop-collapsed camera
// (configBlackBoard left_prepare_camera_param / *_boss_prepare_*) while the shop bar is shown folded, keeping the bench
// above the folded shop's HUD band (the tab and the corner buttons' hit areas), and returns to the shop camera when it is
// unfolded; never over the pen, a teammate's board or a battle; deferred during a drag or the direction step.
// gameLogic prepCameraFor / foldCamera (the choice), ui/fieldHost.js hudBands(kind, size, { shop: false }) (the band),
// render/app.js (the band follows the request's `shop`), screens/game.js (the effect), css/devices.css (one-row corner).
// Browser measurement: the headless-Chrome checks of DESIGN §21 (fold / unfold at 1920×1080, 844×390, 756×366).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { GEO } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const { prepCameraFor, foldCamera, prepCamera } = await import('../../public/js/ui/gameLogic.js');
const { hudBands, HUD_REM } = await import('../../public/js/ui/fieldHost.js');
const { presetCamera, tileQuad } = await import('../../public/js/render/projection.js');
const pl = (id, seat, alive = true) => ({ playerId: id, seat, alive });
const pubAt = (round, players = [pl('a', 0), pl('b', 1)]) => ({ round, bossRound: 14, hiddenRound: 15, players });
describe('the camera choice (gameLogic prepCameraFor / foldCamera)', () => {
test('prepCameraFor: the own prep camera with the shop state — open = shop camera, folded = shop-collapsed', () => {
assert.deepEqual(prepCameraFor(pubAt(6), 'a'), { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L', shop: true } });
assert.deepEqual(prepCameraFor(pubAt(6), 'a', true), { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L', shop: false } });
// the Final Assault prep: the player's half of the boss field, with the same flag
assert.deepEqual(prepCameraFor(pubAt(14), 'b', true), { kind: 'bossPrep', opts: { side: 'R', shop: false } });
assert.deepEqual(prepCameraFor(pubAt(14), 'a'), { kind: 'bossPrep', opts: { side: 'L', shop: true } });
// everything but the flag is prepCamera's
for (const [pub, me] of [[pubAt(3), 'a'], [pubAt(15), 'b'], [null, 'a']]) {
const { shop, ...rest } = prepCameraFor(pub, me, true).opts;
assert.equal(shop, false);
assert.deepEqual(rest, prepCamera(pub, me).opts);
}
});
const base = { pub: pubAt(6), myId: 'a', ownPrep: true, pen: false, busy: false };
const open = { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L', shop: true } };
const folded = { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L', shop: false } };
test('foldCamera: a fold asks for the shop-collapsed camera, an unfold for the shop camera again, else nothing', () => {
assert.deepEqual(foldCamera({ ...base, folded: true, current: open }), folded);
assert.deepEqual(foldCamera({ ...base, folded: false, current: folded }), open);
assert.equal(foldCamera({ ...base, folded: true, current: folded }), null, 'already folded');
assert.equal(foldCamera({ ...base, folded: false, current: open }), null, 'already open');
// a request made before this rule (no shop flag) counts as the shop camera
assert.equal(foldCamera({ ...base, folded: false, current: { kind: 'prep', opts: { side: 'L' } } }), null);
assert.deepEqual(foldCamera({ ...base, folded: true, current: { kind: 'prep', opts: { side: 'L' } } }), folded);
});
test('foldCamera: own prep board only — not a teammate\'s board, a battle, the pen', () => {
assert.equal(foldCamera({ ...base, ownPrep: false, folded: true, current: open }), null, 'scouting / battle / observer');
assert.equal(foldCamera({ ...base, pen: true, folded: true, current: open }), null, 'the pen folds the shop itself');
assert.equal(foldCamera(null), null);
});
test('foldCamera: deferred while a piece is dragged or its direction is chosen, applied once that ends', () => {
const s = { ...base, folded: true, current: open };
assert.equal(foldCamera({ ...s, busy: true }), null, 'drag / wheel in progress: the camera stays');
assert.deepEqual(foldCamera({ ...s, busy: false }), folded, 'the effect runs again when busy clears');
});
test('foldCamera: the Final Assault prep half follows the fold too', () => {
const want = foldCamera({ ...base, pub: pubAt(14), myId: 'b', folded: true, current: { kind: 'bossPrep', opts: { side: 'R', shop: true } } });
assert.deepEqual(want, { kind: 'bossPrep', opts: { side: 'R', shop: false } });
});
});
// ---- the folded shop's HUD band (ui/fieldHost.js hudBands) ----------------------------------------------------------
/** Root font size of css/theme.css: clamp(40px, min(100vw / 19.2, 100vh / 10.8), 240px). */
const remAt = (w, h) => Math.max(40, Math.min(w / 19.2, h / 10.8, 240));
/**
* Run `fn` with a stubbed DOM: root font size, the HUD layer's rect (safe-area insets), the corner buttons (client-px
* rects, or none) and whether the pointer is coarse (`sp-coarse`, --tap-min 44 px).
*/
function withDom({ rem, hudTop = 0, hudBottom = null, corner = null, coarse = false }, fn) {
const g = globalThis;
const saved = { document: g.document, getComputedStyle: g.getComputedStyle };
const root = { classList: { contains: (c) => c === 'sp-coarse' && coarse } };
g.getComputedStyle = (el) => ({ fontSize: `${rem}px`, getPropertyValue: (p) => (el === root && p === '--tap-min' ? ' 44px' : '') });
g.document = {
documentElement: root,
querySelector: (s) => (s === '.gm__hud' ? { getBoundingClientRect: () => ({ top: hudTop, bottom: hudBottom ?? 0 }) } : null),
querySelectorAll: (s) => (s.includes('.gm__corner') && corner ? corner.map((r) => ({ getBoundingClientRect: () => r })) : []),
};
try { return fn(); } finally { g.document = saved.document; g.getComputedStyle = saved.getComputedStyle; }
}
/** A one-row corner of `n` buttons `size` px high at bottom .24rem (css/screens/game.css .gm__corner). */
const cornerRow = (h, rem, size, n = 4) => Array.from({ length: n }, () => ({ top: h - 0.24 * rem - size, height: size }));
describe('hudBands: the folded shop\'s band', () => {
test('HUD_REM mirrors the CSS of the folded tab and the corner', () => {
const shop = read('public/css/screens/game-shop.css');
const game = read('public/css/screens/game.css');
// tab: bottom .2rem + padding .08rem × 2 + the .44rem button + 1 px + 2 px borders
assert.match(shop, /\.shopbar-tab \{ position: absolute; right: \.26rem; bottom: \.2rem;[^}]*padding: \.08rem;[^}]*border: 1px solid var\(--line-2\); border-top: 2px solid var\(--mint-700\); \}/);
assert.match(shop, /\.shopbar-tab__btn \{[^}]*height: \.44rem;/);
assert.equal(HUD_REM.shopTabTop, 0.2 + 0.08 * 2 + 0.44);
assert.equal(HUD_REM.shopTabBorderPx, 3);
// corner (fallback when it cannot be measured): bottom .24rem + a .56rem desktop row
assert.match(game, /\.gm__corner \{ position: absolute; left: \.24rem; bottom: \.24rem;/);
assert.match(game, /\.gm__gear \{\n {2}width: \.56rem; height: \.56rem;/);
assert.ok(Math.abs(HUD_REM.cornerTop - (0.24 + 0.56)) < 1e-9);
// folded, the narrow phones' corner is one row again (the bar that made it wrap is gone)
assert.match(read('public/css/devices.css'), /@media \(max-width: 767px\) \{\n {2}\.gm\.is-collapsed \.gm__corner \{ flex-wrap: nowrap; width: auto; \}\n\}/);
});
test('prep / Final Assault prep only; open = the bar (unchanged), folded = the higher of the tab and the corner', () => {
withDom({ rem: 40 }, () => {
for (const k of ['normal', 'unite', 'boss', 'pen']) assert.equal(hudBands(k, { width: 844, height: 390 }, { shop: false }), null, k);
for (const k of ['prep', 'bossPrep']) {
const open = hudBands(k, { width: 844, height: 390 });
assert.deepEqual(hudBands(k, { width: 844, height: 390 }, { shop: true }), open);
assert.ok(Math.abs(open.bottom - 108.6) < 1e-9, 'the bar: 2.64rem + 3 px');
const f = hudBands(k, { width: 844, height: 390 }, { shop: false });
assert.equal(f.top, open.top, 'the top band does not change');
assert.ok(Math.abs(f.bottom - 35) < 1e-9, `no corner on the page: the tab .8rem + 3 px (${f.bottom})`);
}
});
withDom({ rem: 100 }, () => assert.deepEqual(hudBands('prep', { width: 1920, height: 1080 }, { shop: false }), { top: 216, bottom: 83 }));
});
test('the corner buttons are measured, touch hit areas included; the tab sits above the bottom safe-area inset', () => {
const h = 390, rem = 40;
// desktop-like pointer: the buttons' own top
withDom({ rem, corner: cornerRow(h, rem, 34) }, () => assert.ok(Math.abs(hudBands('prep', { width: 844, height: h }, { shop: false }).bottom - (9.6 + 34)) < 1e-9));
// touch: a 44 px hit area centred on a 34 px button reaches 5 px higher
withDom({ rem, corner: cornerRow(h, rem, 34), coarse: true }, () => assert.ok(Math.abs(hudBands('prep', { width: 844, height: h }, { shop: false }).bottom - (9.6 + 34 + 5)) < 1e-9));
// two rows (a narrow phone with the bar open would wrap them): the higher row decides
const two = [...cornerRow(366, rem, 34, 1), { top: 366 - 9.6 - 34 - 4 - 34, height: 34 }];
withDom({ rem, corner: two, coarse: true }, () => assert.ok(Math.abs(hudBands('prep', { width: 756, height: 366 }, { shop: false }).bottom - (9.6 + 72 + 5)) < 1e-9));
// a low corner: the tab wins; a notched phone's bottom inset lifts the tab (it lives inside the HUD layer)
withDom({ rem, corner: cornerRow(h, rem, 10) }, () => assert.ok(Math.abs(hudBands('prep', { width: 844, height: h }, { shop: false }).bottom - 35) < 1e-9));
withDom({ rem, hudBottom: h - 21, corner: cornerRow(h - 21, rem, 10) }, () => assert.ok(Math.abs(hudBands('prep', { width: 844, height: h }, { shop: false }).bottom - (21 + 35)) < 1e-9));
// clamped like the bar's band
withDom({ rem, corner: [{ top: 10, height: 34 }] }, () => assert.equal(hudBands('prep', { width: 640, height: 200 }, { shop: false }).bottom, 80));
});
});
// ---- the camera itself (render/projection.js presetCamera with the folded band) ---------------------------------------
describe('the folded board grows and every bench / temp tile stays clear of the folded HUD', () => {
const stage = JSON.parse(read('data/stages.json')).act2autochess_m01;
const H = { h: 0.42, '#': 0.3, X: 0.55, a: 0.16, A: 0.16 }; // render/style.js TILE_H by glyph
const heightAt = (r, c) => H[stage.rows[r]?.[c]] ?? 0;
const tileAt = (cam, row) => cam.project(5.5, row - 0.5).x - cam.project(4.5, row - 0.5).x;
/** Screen y extent of the bench (row 7 / boss row 0), temp (8 / 1) and back row (12 / 5) tile tops. */
function extents(cam, rows) {
const ys = (r, c0, c1) => { const out = []; for (let c = c0; c <= c1; c++) for (const p of tileQuad(cam, r, c, heightAt(r, c))) out.push(p.y); return out; };
return { benchBottom: Math.max(...ys(rows.bench, 0, 9)), tempBottom: Math.max(...ys(rows.temp, 4, 8)), backTop: Math.min(...ys(rows.back, 4, 10)) };
}
// [w, h, coarse pointer, corner rows when folded (css/devices.css: one row again)]
const VIEWPORTS = [[1920, 1080, false], [1280, 720, false], [1680, 1050, false], [844, 390, true], [756, 366, true], [800, 360, true], [915, 412, true], [932, 430, true]];
test('fold → shop-collapsed camera (bigger tiles), bench above the band, back row under the bond strip; unfold → the shop camera', () => {
for (const [w, h, coarse] of VIEWPORTS) {
const rem = remAt(w, h);
const btn = coarse ? Math.max(0.56 * rem, 34) : 0.56 * rem;
for (const [kind, rows, opts] of [['prep', { bench: 7, temp: 8, back: 12 }, { rect: { ...GEO.NORMAL_RECT }, side: 'L' }],
['bossPrep', { bench: 0, temp: 1, back: 5 }, { side: 'L' }], ['bossPrep', { bench: 0, temp: 1, back: 5 }, { side: 'R' }]]) {
const dom = { rem, corner: cornerRow(h, rem, btn), coarse };
const openHud = withDom(dom, () => hudBands(kind, { width: w, height: h }, { shop: true }));
const foldHud = withDom(dom, () => hudBands(kind, { width: w, height: h }, { shop: false }));
const openCam = presetCamera(kind, { width: w, height: h }, { ...opts, shop: true, hud: openHud });
const foldCam = presetCamera(kind, { width: w, height: h }, { ...opts, shop: false, hud: foldHud });
const tag = `${kind}${opts.side} ${w}×${h}`;
const midRow = rows.back - 2;
assert.ok(tileAt(foldCam, midRow) > tileAt(openCam, midRow) * 1.15, `${tag}: grows (${tileAt(openCam, midRow).toFixed(1)} → ${tileAt(foldCam, midRow).toFixed(1)})`);
const e = extents(foldCam, rows);
// (projection.js clearHud keeps an official camera that overlaps a band by ≤ 0.5 px: float noise)
assert.ok(e.benchBottom <= h - foldHud.bottom + 0.5 + 1e-6, `${tag}: bench ${e.benchBottom} above the folded band ${h - foldHud.bottom}`);
assert.ok(e.tempBottom < e.benchBottom, `${tag}: temp row above the bench`);
assert.ok(e.backTop >= foldHud.top - 1e-6, `${tag}: back row ${e.backTop} under the bond strip ${foldHud.top}`);
// unfolding asks for exactly the shop camera again
assert.deepEqual(presetCamera(kind, { width: w, height: h }, { ...opts, shop: true, hud: openHud }).params(), openCam.params());
}
}
});
test('1920×1080: the official shop-collapsed camera at its own scale, panned up just enough to clear the folded tab', () => {
const off = presetCamera('prep', { width: 1920, height: 1080 }, { rect: { ...GEO.NORMAL_RECT }, side: 'L', shop: false });
const fit = presetCamera('prep', { width: 1920, height: 1080 }, { rect: { ...GEO.NORMAL_RECT }, side: 'L', shop: false, hud: { top: 216, bottom: 83 } });
const shop = presetCamera('prep', { width: 1920, height: 1080 }, { rect: { ...GEO.NORMAL_RECT }, side: 'L', hud: { top: 216, bottom: 267 } });
const { cy: cyFit, ...restFit } = fit.params();
const { cy: cyOff, ...restOff } = off.params();
assert.deepEqual(restFit, restOff, 'same optics and scale (a 2D pan of the image)');
assert.ok(cyFit < cyOff && cyOff - cyFit < 60, `panned up ${cyOff - cyFit} px`);
assert.ok(Math.abs(tileAt(fit, 10) - 130.7) < 0.2, `≈ 131 px per tile at row 10 (${tileAt(fit, 10)})`);
assert.ok(Math.abs(tileAt(shop, 10) - 109.8) < 0.2, `the shop camera: ≈ 110 px (${tileAt(shop, 10)})`);
});
});
describe('wiring', () => {
test('the view passes the request\'s shop state to the HUD bands; the game asks for the camera of the fold state', () => {
const app = read('public/js/render/app.js');
assert.match(app, /hud: hudBands\(vk, sz, \{ shop: o\.shop !== false \}\),/);
assert.match(app, /return opts\.hud\(kind, sz, o\) \|\| null;/);
const game = read('public/js/screens/game.js');
assert.match(game, /const shopFolded = showShop && \(pen \? penRef\.current\.collapsed : collapsed\);/);
// entering prep and the boss-prep re-frame take the fold state; a fold / unfold re-frames through foldCamera
assert.equal((game.match(/const pc = prepCameraFor\(pub, myId, shopFolded\);/g) || []).length, 2);
assert.match(game, /const next = foldCamera\(\{\n\s+pub, myId, folded: shopFolded, ownPrep: !!view && viewModeRef\.current === 'prep' && showPrep,\n\s+pen, busy: !!drag \|\| !!facing, current: camRef\.current,\n\s+\}\);\n\s+if \(next\) setCam\(next\.kind, next\.opts\);\n\s+\}, \[view, shopFolded, showPrep, pen, !!drag, !!facing, prepCamKey\]\);/);
});
});
+41 -1
View File
@@ -11,7 +11,7 @@
// item onto a unit (equip), 机变 pick, band pick, briefing ready, emote, exit → 暂离 → 返回.
// 3. Regressions: tooltip after a click, toasts / reconnect banner placement, refused watch targets, battle unit
// panel closing at the next prep, no Hidden Core medal before R15, equipment dropped on an operator's upper body
// (engine), illegal drops toasting their reason.
// (engine), illegal drops toasting their reason, the folded shop's prep camera (public issue #5).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
@@ -518,6 +518,46 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
await page.close();
});
for (const [w, h] of [[1920, 1080], [844, 390]]) {
test(`fold (public issue #5): the own prep board grows with the shop folded, every bench pad stays free, unfolding returns (${w}×${h})`, async (t) => {
if (RENDER !== 'engine') { t.skip('SP_RENDER=fallback'); return; }
const { page, problems } = await open('phase=PREP', { w, h });
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.raw?.debug?.cam, { timeout: 15000 });
await sleep(900);
const look = () => page.evaluate(() => {
const V = globalThis.__SP_VIEW__; const raw = V.raw; const cam = raw.debug.cam;
const tile = cam.project(5.5, 9.5).x - cam.project(4.5, 9.5).x;
let worst = 1;
for (let c = 0; c <= 9; c++) {
const q = V.tileScreen(7, c);
if (!q) continue;
const cx = q.poly.reduce((s, p) => s + p[0], 0) / 4, cy = q.poly.reduce((s, p) => s + p[1], 0) / 4;
const pts = [[cx, cy], ...q.poly.map(([x, y]) => [x + (cx - x) * 0.12, y + (cy - y) * 0.12])];
const free = pts.filter(([x, y]) => document.elementFromPoint(x, y) === raw.debug.app.view).length / pts.length;
worst = Math.min(worst, free);
}
return { tile, worst, kind: raw.debug.camKind, params: JSON.stringify(cam.params()) };
});
const open1 = await look();
await page.click('.funds__collapse');
await page.waitForSelector('.shopbar-tab', { timeout: 3000 });
await sleep(1200); // the 0.75 s camera flight
const folded = await look();
assert.equal(folded.kind, 'prep');
assert.ok(folded.tile > open1.tile * 1.15, `tile ${open1.tile.toFixed(1)} → ${folded.tile.toFixed(1)} px`);
assert.equal(folded.worst, 1, 'every bench pad fully pressable under the folded camera');
await page.screenshot({ path: path.join(OUT, `ui-prep-folded-${w}.png`) });
await page.click('.shopbar-tab__btn');
await page.waitForSelector('.shopbar', { timeout: 3000 });
await sleep(1200);
const back = await look();
assert.equal(back.params, open1.params, 'unfolded: exactly the shop camera again');
assert.equal(back.worst, 1);
assert.deepEqual(problems, []);
await page.close();
});
}
for (const [w, h] of [[1920, 1080], [1366, 768]]) {
test(`toasts clear the top bar capsule; the reconnect banner clears the view switcher (${w}×${h})`, async () => {
let { page, problems } = await open('phase=PREP', { w, h });
+2 -1
View File
@@ -148,7 +148,8 @@ describe('9: the prep camera keeps the bench clear of the shop bar on phones in
test('wiring: the game hands hudBands to the view, the view to the prep cameras', () => {
assert.match(read('public/js/ui/fieldHost.js'), /padding: hudPadding, hud: hudBands \}/);
const app = read('public/js/render/app.js');
assert.match(app, /hud: hudBands\(vk, sz\),/);
// (the folded shop's band since public issue #5: test/ui/issue5-fold-camera.test.js)
assert.match(app, /hud: hudBands\(vk, sz, \{ shop: o\.shop !== false \}\),/);
assert.match(app, /presetCamera\('prep', \{ width: s0\.width, height: s0\.height, padding: defaultPadding\('prep', s0\) \}, \{ hud: hudBands\('prep', s0\) \}\)/);
});