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`