From 82c54897c7a4ef6262895ce6f048151c20216002 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 16:14:43 +0800 Subject: [PATCH] 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. --- public/css/devices.css | 5 + public/js/render/app.js | 24 ++-- public/js/screens/game.js | 32 ++++- public/js/ui/fieldHost.js | 78 ++++++++--- public/js/ui/gameLogic.js | 31 +++++ test/ui/issue5-fold-camera.test.js | 212 +++++++++++++++++++++++++++++ test/ui/mock.e2e.test.js | 42 +++++- test/ui/playtest5-ui.test.js | 3 +- 8 files changed, 394 insertions(+), 33 deletions(-) create mode 100644 test/ui/issue5-fold-camera.test.js diff --git a/public/css/devices.css b/public/css/devices.css index 4f4c9843..9c0396b4 100644 --- a/public/css/devices.css +++ b/public/css/devices.css @@ -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 diff --git a/public/js/render/app.js b/public/js/render/app.js index c6b6967c..e4431517 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -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 }), }); } diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 19fdcd09..49a023a0 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -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) diff --git a/public/js/ui/fieldHost.js b/public/js/ui/fieldHost.js index b370d334..3fc23810 100644 --- a/public/js/ui/fieldHost.js +++ b/public/js/ui/fieldHost.js @@ -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'); diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 5bcc381e..3dfca161 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -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` diff --git a/test/ui/issue5-fold-camera.test.js b/test/ui/issue5-fold-camera.test.js new file mode 100644 index 00000000..61b01ac1 --- /dev/null +++ b/test/ui/issue5-fold-camera.test.js @@ -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\]\);/); + }); +}); diff --git a/test/ui/mock.e2e.test.js b/test/ui/mock.e2e.test.js index f11ec4b4..78a2caa7 100644 --- a/test/ui/mock.e2e.test.js +++ b/test/ui/mock.e2e.test.js @@ -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 }); diff --git a/test/ui/playtest5-ui.test.js b/test/ui/playtest5-ui.test.js index 2d839d17..74981ae5 100644 --- a/test/ui/playtest5-ui.test.js +++ b/test/ui/playtest5-ui.test.js @@ -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\) \}\)/); });