diff --git a/CHANGELOG.md b/CHANGELOG.md index fb8bb973..2fd48167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,8 @@ - 变形同构体的详情卡列出天赋栏里的对应关系:每个盟约由哪些装备搭配,本局禁用的盟约会标出;装在干员身上时高亮正在生效的那一组,各盟约装备的详情也写明与变形同构体一同装备时携带者视为哪个盟约的成员。 - 调和让核心盟约人数 +1 时,盟约详情写明「含调和 +1」,成员列表上方列出带来这 1 人的调和干员(如缪尔赛思);查看队友的盟约时也一样。 - 选择策略时,围绕本局禁用盟约的策略(标准模拟下的潘格尼尼、克莱门莎、玛恩纳)标出「本局禁用」并在详情里说明,仍可选择。 +- 准备阶段收起商店后,棋盘按官方收起商店时的视角放大,整备区留在收起的商店栏和左下角按钮上方,展开后恢复原视角;拖动干员或选择朝向时会等放下后再切换(GitHub #5)。 +- 浏览器在后台回收页面、切回时重新加载后,干员不再一直显示为方块加水滴的占位图标:素材清单直接沿用已载入的游戏数据,下载失败会重试,模型下载失败或超时也会重试;在后台开始的战斗切回时干员直接显示模型(GitHub #8)。 ### 人机 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 753b1541..a403fc69 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -458,7 +458,8 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ { ```js const view = await createFieldView(canvasHost /* HTMLElement */, { data, assets, audio }) view.setStage(stage) // draw tiles (procedural), devices -view.setCamera(kind /* 'prep'|'normal'|'unite'|'boss' */, { rect, side }) // animates camera +view.setCamera(kind /* 'prep'|'normal'|'unite'|'boss' */, { rect, side, shop }) // animates camera; shop: false = the + // folded shop's prep camera (official shop-collapsed framing, §21.27) view.setPrep(privateState, { editable }) // render hand/temp/board pieces as chibis (spine or fallback); editable enables drag; // a merge's new elite on the board plays fx.promote instead of fx.deploy (render/promote.js, §20.11), // also after a battle (a merge between two preps: the last prep state is kept, §20.14) @@ -477,7 +478,7 @@ view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile w view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir) view.resize() ; view.destroy() ``` -Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored, shadow sprite), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens. 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). +Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored, shadow sprite), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). --- @@ -604,7 +605,7 @@ Nine reports from a real co-op session. Where each is handled: #1 lost operator ### 17.1 Lost operator models (#1) — the Spine store (`public/js/assets.js RefLru`) -Cause: `PIXI.Assets.unload` is asynchronous (the loader cache entry is dropped and the textures destroyed a microtask later), so a skeleton evicted and re-acquired in the same task came back as the doomed object and turned invisible for good (a tier chip without a body; the dead data stayed cached, so every later copy of that operator was invisible too). It happened at every battle → prep switch: `enterPrepMode → clearViews` made the reference count 0 for an instant, the LRU applied its "no scene on screen" budget (0 bytes) at that instant and dropped the bench models idle since the battle started, then `setPrep` took them back at once. Rules now: an `unload` may return a promise and a load of a key whose unload is still in flight waits for it (`unloadSpineData` returns one; `stats().spine.unloading`); the spine store runs eviction in one pass `SPINE_EVICT_DELAY_MS` (1 s) after a release (a scene switch takes its models back first); the quiet budget applies only after `SPINE_QUIET_DELAY_MS` (3 s) with nothing referenced (lobby / room). The count-cap path (60 skeletons) waits the same way. Tests: `test/render/assets.test.js` (fake `PIXI.Assets` with the same async unload), `test/render/models.browser.test.js` (models alive after long battles, drag / wheel stress). +Cause: `PIXI.Assets.unload` is asynchronous (the loader cache entry is dropped and the textures destroyed a microtask later), so a skeleton evicted and re-acquired in the same task came back as the doomed object and turned invisible for good (a tier chip without a body; the dead data stayed cached, so every later copy of that operator was invisible too). It happened at every battle → prep switch: `enterPrepMode → clearViews` made the reference count 0 for an instant, the LRU applied its "no scene on screen" budget (0 bytes) at that instant and dropped the bench models idle since the battle started, then `setPrep` took them back at once. Rules now: an `unload` may return a promise and a load of a key whose unload is still in flight waits for it (`unloadSpineData` returns one; `stats().spine.unloading`); the spine store runs eviction in one pass `SPINE_EVICT_DELAY_MS` (1 s) after a release (a scene switch takes its models back first); the quiet budget applies only after `SPINE_QUIET_DELAY_MS` (3 s) with nothing referenced (lobby / room) and no scene holding the cache (a field view in battle mode holds it, §21.27). The count-cap path (60 skeletons) waits the same way. Tests: `test/render/assets.test.js` (fake `PIXI.Assets` with the same async unload), `test/render/models.browser.test.js` (models alive after long battles, drag / wheel stress). ### 17.2 Picking and the drag target (#7) — superseded by §18.1 @@ -802,7 +803,7 @@ Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in - **#8**: the ⚙ was a hand-written SVG path with uneven teeth on a rim that was not round (the extracted autochess UI has no gear sprite; 交流 is the official `emoji_btn`, 📖 / ⛶ were clean). `gearPath()` now generates a regular 8-tooth gear (tip r 10, root r 7.4, hole r 3.3); on phones the corner glyphs are `max(.28rem, 17px)`, half their 34 px buttons (were 11 px). - **#9, the user's viewport**: the screenshot is 2772×1272 px with the page right of a 141 px cutout band; the page's own markers give DPR ≈ 3.48, so **756×366 CSS px (≈ 2.07:1)** — the mock rendered there puts the exit and 准备就绪 buttons on the screenshot's pixels within ±1 px. - **Cause**: the official shop camera (`left_shop_camera_param`, boss `*_boss_shop_*`) fits the prep area exactly at 16:9 (bench near edge 811 px vs the shop bar at 813 px at 1920×1080), while the remake's HUD is in rem with a 40 px floor (css/theme.css), so below 432 CSS px of height it is relatively taller: v2.3 left the worst bench pad 54 % free at 756×366 (Final Assault prep 49 %), 71 % at 844×390, 49 % at 800×360. -- **Now**: the view receives the HUD bands, `hudBands(kind, size)` (top = the HUD layer's safe-area top + 2.16rem, the bond strip; bottom = 2.64rem + 3 px, the shop bar; the rem values copy the CSS, which a test checks), and `presetCamera('prep' | 'bossPrep', …, { hud })` runs `clearHud` on the band from the bench's near edge to the back row: the official camera when it fits (0.5 px tolerance), else the smallest pan, else a zoom-out about the centre — a 2D pan / zoom of the image, so picking, the three.js camera and flights stay consistent. Every desktop prep camera is unchanged; the bar stays on the bottom edge (§18.1); the collapsed shop keeps the camera (nothing jumps) — the official client moves to its closer `left_prepare_camera_param` there, so on a phone the remake's collapsed prep board stays at the clearing zoom (×0.844 at 756×366; integration QA, an optional follow-up: `shop: !collapsed` for the own prep camera with a 0 bottom band). Accepted: an armed shop card rises 4 px above the bar on a phone and covers ≈ 3 px of the pads' near corners (≈ 11 px under the official camera at 1920×1080). [ASSUMED] the bond strip's bottom (2.16rem, measured 2.14–2.15rem); the zoom anchor. +- **Now**: the view receives the HUD bands, `hudBands(kind, size)` (top = the HUD layer's safe-area top + 2.16rem, the bond strip; bottom = 2.64rem + 3 px, the shop bar; the rem values copy the CSS, which a test checks), and `presetCamera('prep' | 'bossPrep', …, { hud })` runs `clearHud` on the band from the bench's near edge to the back row: the official camera when it fits (0.5 px tolerance), else the smallest pan, else a zoom-out about the centre — a 2D pan / zoom of the image, so picking, the three.js camera and flights stay consistent. Every desktop prep camera is unchanged; the bar stays on the bottom edge (§18.1); the collapsed shop kept the camera — **superseded by §21.27** (public issue #5): folding the shop moves the own prep board to the official shop-collapsed camera (`left_prepare_camera_param`, `presetCamera` `shop: false`) clear of the folded shop's band (`hudBands(kind, size, { shop: false })`: the tab and the corner buttons' hit areas), and unfolding back to the shop camera. Accepted: an armed shop card rises 4 px above the bar on a phone and covers ≈ 3 px of the pads' near corners (≈ 11 px under the official camera at 1920×1080). [ASSUMED] the bond strip's bottom (2.16rem, measured 2.14–2.15rem); the zoom anchor. - Tests: `test/render/projection.test.js` (HUD clearance suite), `test/ui/playtest5-ui.test.js`; browser (opt-in, `SP_E2E=1`) `test/ui/playtest5-ui.e2e.test.js` (per-tile coverage at 16 viewports, the armed card, the gear). | Viewport (CSS px) | Worst bench pad free, v2.3 → now (prep / Final Assault prep) | Camera now | Tile px | @@ -1420,3 +1421,15 @@ Where: `ui/gameLogic.js morphPairings / HARMONY_BOND / harmonyMembers`, `ui/deta - **Found on the way — the client-result layer bound** (`match/fields.js layerAllowanceOf / validateClientResult`): with the bots' new picks the full-match suite's coop/FUNNY/107 (4 humans on AI 托管) reached a 6-谢拉格 freeze board at R12 (初雪 + 银灰: garrison_28, 50 % +1 per freeze in range, no per-battle cap in the data; 凛御银灰 hands out a 60 % +2) whose honest +112 谢拉格 layers passed the flat 60 + 4·12 = 108, so the server rejected the client and re-simulated (SP_VERIFY=all agrees with the client on all 57 battles of that match). Uncapped traits are common (菲莱 / 百炼嘉维尔 per skill, 斯卡蒂 per 2 kills, 莫斯提马 per 6 bullets …) and capped ones reach 100–200 (史尔特尔, 荒芜拉普兰德), so the flat bound now adds, per bond, what the player's IN_BATTLE layer 特质 can add to it — each trait of its units and each one their ADD_BOND traits hand out (once per operator), on the bonds it names, up to the cap the sim applies (`max_add_count_per_battle`, the handed-out 华法琳 trait's `GRANTED_CAP_OVERRIDE`); a trait without a cap — or a 魔王, whose +extra counts toward none — leaves its bonds bounded only by the 999 room. A forged gain on any other bond meets the flat bound as before. - Normative lines rewritten with it: §6.3 (`harmony` in the views), §6.6 (the band pick), §8.2 / §8.3 (`bonds[]` entries `harmony?`), §14 / §20.12 (the layer bound), §21.11 (its 调和 note). - Tests: `test/ui/feedback1-gaps.test.js`, `test/match/feedback1-gaps.test.js`; opt-in `test/ui/feedback1-gaps.e2e.test.js` (`SP_E2E=1`; 1920×1080, 844×390, 756×366; `SP_E2E_OUT` moves the screenshots). + +### 21.27 Client fixes for GitHub issues #5 and #8 (v0.1.1) — `ui/gameLogic.js prepCameraFor / foldCamera`, `screens/game.js`, `ui/fieldHost.js hudBands / cornerBand / seedAssets`, `render/app.js`, `css/devices.css`, `assets.js`, `render/units.js` + +Two public GitHub issues after 0.1.0, both client-only. Normative lines rewritten with them: §9 (`setCamera` `shop`, the model fallback), §17.1 (the quiet budget), §19.8 (the collapsed shop). + +- **#5 seen**: "准备阶段,收起商店界面时,界面并不会进行缩放(观战及作战时无此现象)". **Real**: folding the shop (收起) was UI state only — the own prep camera was requested once on entering prep, never with `shop`, so render/projection.js's official shop-collapsed camera (`left_prepare_camera_param` / `*_boss_prepare_*`, `presetCamera` `shop: false`) was never used, and `hudBands` always reserved the bar (measured: 110 px per tile at 1920×1080, 36 at 844×390, 31 at 756×366, the same folded). +- **Now**: while the own prep board is on screen and the bar is shown folded, the game requests `prepCameraFor(pub, myId, folded)` — the prep camera with `shop: !folded` — whenever the fold changes, with the normal 0.75 s flight (`foldCamera`): never over the pen (it folds the bar itself and returns to the camera it left), on a teammate's board, in battle or for an eliminated player (no bar); 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) and applied when that ends; unfolding returns to exactly the shop camera; the fold still resets at combat start. The view hands the request's flag to `hud(kind, size, { shop })` (also on resize). The folded band is the higher of the tab (`.shopbar-tab`: .8rem + 3 px, above the bottom safe-area inset) and the corner buttons' hit areas (交流 / ⚙ / 📖 / ⛶, measured by `cornerBand`; on touch screens a 34 px button takes taps 5 px above itself, `--tap-min` 44 px): with the tab alone the corner covered bench pad 0 at 756×366 and the near edge of pads 0–2 at 844×390. Below 768 CSS px the corner wraps into two rows only because the open bar would cover it; with the bar folded it stays on one row (css/devices.css). Picking reads the camera of every frame. [ASSUMED] the folded band (the official collapsed HUD is not in the extracted bundles). +- Measured (headless Chrome; px per tile at row 10; every bench pad fully pressable open and folded, also the Final Assault prep halves): 1920×1080 109.8 → 130.7 (the official camera at its own scale, panned up 46 px to clear the tab); 844×390 35.9 → 44.3; 756×366 (touch) 31.4 → 40.1. A real drag deploy and a tap on a piece work folded; a drag or the wheel defer the flight; scouting (the 'normal' camera), the battle camera and the next prep are unchanged. +- **#8 item 5 seen**: "游览器切换页面后所有干员会变成方块+水滴的图标" — the image-less placeholder (render/textures.js) on every operator. **A plain tab switch does not reproduce it; a reload does** (phone browsers and Chrome's Memory Saver discard background tabs and reload them on return; the game resumes into the match): the asset store downloaded /data/assets.json a second time beside the game data's copy, `createFieldView` waited ≤ 4 s for it, every view looked its model and avatar up once in its constructor, and a failed download kept `{}` for the page — placeholders for good. Related without a reload: a battle that began in a hidden tab built no views (they are built in animation frames), nothing referenced a skeleton, and the quiet budget (§17.1) unloaded all of them ≈ 18 s later, so back in the tab every unit was an avatar diamond until its model downloaded again; a model that failed or timed out (20 s) was never retried. +- **Now**: the field host seeds the asset store with the game data's copies (`seedAssets` → `assets.seed` / `seedLocal`; the match screen already waits for them, so no second download). Without one, `ready()` retries a transient failure after 600 ms and 2 s (like data.js), then resolves `{}` without keeping it: the next `ready()` and a bounded background backoff (5 / 15 / 30 / 60 s) fetch again, and `onChange` tells the field view when a manifest arrives late — every view re-resolves what it could not draw (`UnitView.retryAssets`; prep item icons through `setPrep` / `ItemView.setIcon`). A view whose model failed or timed out loads it again after 2 / 6 / 15 / 30 s (`SPINE_RETRY_MS`; frames only, so never while hidden), `acquire(entry, { retry: true })` dropping the remembered failure; showing the tab again retries at once (also a missing manifest). Battle mode holds the Spine cache (`spine.hold()`, from `enterBattle` to the next `setPrep` or destroy): no quiet budget meanwhile, the 48 MB idle budget still applies, and the quiet budget keeps freeing everything in the lobby, room and result screen (the policy is in the assets.js header). +- Verified (headless Chrome, no console errors): a reload with every assets.json request delayed 6 s, with only a second download delayed (the 0.1.0 race: none is made now) and with the first 4 requests failing — operators drawn as Spine models at once; the first 6 failing — placeholders until the store's first background try 5 s later, then Spine models; a battle started in a hidden tab, back after 25–30 s (still running, ended meanwhile, or paused with Spine downloads slowed to 4 s) — every unit a model within 0.3 s of the return, no diamonds (before: the cache was empty from ≈ 18 s); six solo rounds with two battles started hidden — the cache follows the roster (4 → 24 skeletons, 6.9 → 25.2 MB estimated, idle ≤ 11 MB, under the 48 MB budget), never a diamond or a placeholder. +- Tests: `test/ui/issue5-fold-camera.test.js`, `test/render/issue8-placeholders.test.js`, `test/ui/playtest5-ui.test.js` (wiring), `test/render/assets.test.js`; opt-in `SP_E2E=1 node --test test/ui/mock.e2e.test.js` (fold / unfold at 1920×1080 and 844×390). 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/assets.js b/public/js/assets.js index 11e399ce..2112d42e 100644 --- a/public/js/assets.js +++ b/public/js/assets.js @@ -2,10 +2,30 @@ // Spine loader (LRU + refcount + memory budget + timeout + concurrency cap) used by the battlefield renderer. A // skeleton is never handed out while its unload is in flight (RefLru; user playtest #3 item 1: invisible models). // +// Manifest (public issue #8 item 5: after a page reload every operator was drawn as the image-less placeholder): seeded +// from the game data's copy when there is one (`seed`, ui/fieldHost.js), else fetched by `ready()` with retries; a +// failure is never kept as an empty manifest (the next `ready()` and a bounded background backoff fetch again), and +// `onChange` listeners hear when it arrives late (render/app.js re-resolves its views' models then). +// +// Spine memory policy (phones have little memory; a parsed skeleton holds ~0.1–12 MB, `spineDataWeight`): +// * a skeleton a view references is never evicted; +// * idle (unreferenced) skeletons are kept up to SPINE_IDLE_BYTES while a scene is on screen, each safe for +// SPINE_IDLE_GRACE_MS after its release (a prep ⇄ battle switch takes its models back); beyond the budget the least +// recently used go, in one pass SPINE_EVICT_DELAY_MS after the release; +// * quiet — no scene on screen (lobby, room, result: nothing referenced and no scene holding the cache for +// SPINE_QUIET_DELAY_MS) — every idle skeleton goes after its grace: the memory goes back to the phone; +// * a match view in battle mode holds the cache (`spine.hold()`, render/app.js): its battle views are built in +// animation frames, which a hidden tab does not run, so a battle that began in a background tab referenced nothing +// and the quiet budget emptied the cache ≈ 18 s later — back in the tab every unit was an avatar diamond until its +// model downloaded again (public issue #8 item 5). The idle budget still applies while it holds. +// A model that fails or times out is retried by its view (bounded, render/units.js), `acquire(entry, { retry: true })`. +// // Pure at import time: no PIXI, no DOM access until a loader actually runs (Node tests import this file). // -// import { assets } from './assets.js'; // browser singleton (manifest fetched on first use) -// await assets.ready(); // manifest loaded (or failed → every helper returns null) +// import { assets } from './assets.js'; // browser singleton (manifest fetched on first use, or seeded) +// await assets.ready(); // manifest loaded (or failed for now → every helper returns null) +// assets.seed(data.get('assets')); // adopt an already loaded copy of /data/assets.json +// assets.onChange((what) => …); // 'manifest' | 'local' arrived (late) // assets.avatar('char_002_amiya', { e2: true }) // URL or null // const data = await assets.spine.acquire(entry); // spineData (PIXI.spine) — throws on failure/timeout // assets.spine.release(entry); @@ -215,12 +235,17 @@ export function unitSfxUrl(m, id, kind, skillIndex) { * LRU cache of refcounted async resources. `load(key, arg)` → Promise; `unload(key, value, record)` frees it * (`record` is the cache record the value belonged to) and may return a promise that settles once it is freed. * Entries with refs > 0 are never evicted; the cache may exceed `max` while everything is in use. - * Failures are remembered for `failTtl` ms (so a missing model is not refetched every frame) and rethrown. + * Failures are remembered for `failTtl` ms (so a missing model is not refetched every frame) and rethrown — except for + * `acquire(key, arg, { retry: true })`, which drops a remembered failure nobody references and loads again (a view + * retrying a failed or timed-out model: render/units.js). * Memory budget (optional): `weigh(key, value, arg)` → cost of a ready value; idle (refs 0) ready entries are evicted, * least recently used first, while their total weight exceeds `maxIdleWeight` — or `quietWeight` once nothing at all - * has been referenced for `quietDelay` ms (no scene on screen; the instant zero in the middle of a scene switch is not - * quiet) — once they have been idle for `idleGrace` ms (a scene switch releases and re-acquires its models within that - * window, so it never reloads them); a timer sweeps what the grace held back. + * has been referenced and no scene holds the cache (`hold()`) for `quietDelay` ms (no scene on screen; the instant zero + * in the middle of a scene switch is not quiet) — once they have been idle for `idleGrace` ms (a scene switch releases + * and re-acquires its models within that window, so it never reloads them); a timer sweeps what the grace held back. + * `hold()` → release function: a scene that will reference its values again although it references none right now + * (render/app.js in battle mode: the battle's views are built in animation frames, which a hidden tab does not run) + * keeps the quiet budget off while it holds; the `maxIdleWeight` budget still applies. * `evictDelay` > 0: a release (or a finished load) schedules the eviction pass that much later instead of running it * at once, so a scene rebuilt in one go (every view destroyed, then the next scene's views built) never drops a value * it takes again. @@ -255,7 +280,8 @@ export class RefLru { /** @type {Map} */ this.map = new Map(); this._refs = 0; // Σ refs of every entry - this._quietAt = this.now(); // since when nothing has been referenced (null while something is) + this._holds = 0; // scenes holding the cache (hold()): never quiet meanwhile + this._quietAt = this.now(); // since when nothing has been referenced nor held (null while something is) /** @type {Map>} key → unload still in flight (settles, never rejects) */ this._unloading = new Map(); this._active = 0; @@ -272,10 +298,14 @@ export class RefLru { has(key) { return this.map.has(key); } get size() { return this.map.size; } - /** Acquire a reference; resolves to the value (or rejects). Always pair with release(key). */ - acquire(key, arg) { + /** + * Acquire a reference; resolves to the value (or rejects). Always pair with release(key). `opts.retry`: a remembered + * failure that nobody references any more is dropped and the value loaded again (a failure still referenced — a + * caller that has not released it yet — is shared: dropping it would make that release hit the new entry). + */ + acquire(key, arg, opts) { let e = this.map.get(key); - if (e && e.state === 'failed' && this.now() - e.failedAt > this.failTtl) { this.map.delete(key); e = null; } + if (e && e.state === 'failed' && (this.now() - e.failedAt > this.failTtl || (opts && opts.retry && e.refs === 0))) { this.map.delete(key); e = null; } if (!e) { e = { key, promise: null, value: null, state: 'loading', refs: 0, used: ++this._tick, weight: 0, idleSince: null }; this.map.set(key, e); @@ -313,12 +343,29 @@ export class RefLru { if (!e) return; if (e.refs > 0) { if (--e.refs === 0) e.idleSince = this.now(); - if (--this._refs <= 0) { this._refs = 0; this._quietAt = this.now(); } + if (--this._refs <= 0) { this._refs = 0; if (this._holds === 0) this._quietAt = this.now(); } } e.used = ++this._tick; this._requestEvict(); } + /** + * Hold the cache for a scene that will reference its values again (see the header): no quiet budget until the + * returned function is called (once; later calls do nothing). The quiet delay then counts from the release. + * @returns {() => void} + */ + hold() { + this._holds++; + this._quietAt = null; + let held = true; + return () => { + if (!held) return; + held = false; + if (--this._holds <= 0) { this._holds = 0; if (this._refs === 0) this._quietAt = this.now(); } + this._requestEvict(); + }; + } + /** Is an unload of `key` still in flight (a new load of it waits for it)? */ unloading(key) { return this._unloading.has(key); } @@ -350,7 +397,7 @@ export class RefLru { const now = this.now(); let budget = this.maxIdleWeight; let wait = Infinity; - if (this._refs === 0) { + if (this._refs === 0 && this._holds === 0) { const quietFor = now - (this._quietAt ?? now); if (quietFor >= this.quietDelay) budget = this.quietWeight; else if (idleWeight > this.quietWeight) wait = this.quietDelay - quietFor; // re-check once the quiet has lasted @@ -393,13 +440,13 @@ export class RefLru { this._sweep = { timer, at }; } - /** Unload everything (refs ignored). */ + /** Unload everything (refs ignored; a scene's hold stays). */ clear() { if (this._sweep) { this._timers.clear(this._sweep.timer); this._sweep = null; } const all = [...this.map.values()]; this.map.clear(); this._refs = 0; - this._quietAt = this.now(); + this._quietAt = this._holds > 0 ? null : this.now(); for (const e of all) if (e.state === 'ready') this._unloadNow(e.key, e.value, e); } @@ -411,7 +458,7 @@ export class RefLru { weight += e.weight || 0; if (e.refs === 0) idleWeight += e.weight || 0; } - return { size: this.map.size, ready, loading, failed, refs, weight, idleWeight, active: this._active, queued: this._queue.length, unloading: this._unloading.size }; + return { size: this.map.size, ready, loading, failed, refs, holds: this._holds, weight, idleWeight, active: this._active, queued: this._queue.length, unloading: this._unloading.size }; } _schedule(fn) { @@ -561,16 +608,38 @@ export const SPINE_IDLE_GRACE_MS = 15000; * before anything is dropped (user playtest #3 item 1: the bench models were unloaded and re-loaded in one go). */ export const SPINE_EVICT_DELAY_MS = 1000; -/** Nothing referenced for this long = no scene on screen (lobby / room): the idle skeletons may all go. */ +/** Nothing referenced (and no scene holding the cache) for this long = no scene on screen (lobby / room): the idle skeletons may all go. */ export const SPINE_QUIET_DELAY_MS = 3000; +/** Waits (ms) before the manifest is fetched again within one `ready()` after a transient failure (like data.js). */ +export const MANIFEST_RETRY_MS = Object.freeze([600, 2000]); +/** Waits (ms) before the store tries again by itself after a `ready()` whose fetches all failed transiently. */ +export const MANIFEST_BACKOFF_MS = Object.freeze([5000, 15000, 30000, 60000]); + +/** A fetch failure worth retrying (data.js): network error, HTTP 5xx / 408 / 429 — not a 404 or a body that is no JSON. */ +const transientFetch = (err) => { + if (!err || err.badJson) return false; + const s = err.status; + return !(Number.isInteger(s) && s >= 400 && s < 500 && s !== 408 && s !== 429); +}; /** * Create an asset store. - * @param {{ url?: string, fetch?: typeof fetch, manifest?: object, loadImage?: (url) => Promise, + * + * Manifest (public issue #8 item 5: after a page reload — a phone browser discarding a background tab, Chrome's Memory + * Saver — every operator was drawn as the image-less placeholder): the game data already holds /data/assets.json (the + * match screen waits for it, ui/gameComponents.js GAME_FILES), so `seed(manifest)` adopts that copy (ui/fieldHost.js) and + * the store fetches nothing. Fetched by `ready()` (no seed yet): a transient failure is retried after MANIFEST_RETRY_MS; + * when every try fails `ready()` resolves with an empty object — every helper returns null meanwhile, the views draw + * their fallbacks — but the failure is never kept: the next `ready()` fetches again and the store tries again by itself + * after MANIFEST_BACKOFF_MS (bounded; a 404 or a body that is no JSON only on the next `ready()`). `onChange(fn)` is told + * when the manifest (`'manifest'`) or the optional local-client manifest (`'local'`) arrives, so views built without it + * resolve their models then (render/app.js → UnitView.retryAssets). + * @param {{ url?: string, fetch?: typeof fetch, manifest?: object, localManifest?: object, loadImage?: (url) => Promise, * loadSpine?: (entry) => Promise, unloadSpine?: (entry, value, keepPages:Set) => (Promise|void), * spineMax?: number, spineTimeout?: number, spineWeigh?: (key, spineData) => number, spineIdleBytes?: number, * spineQuietBytes?: number, spineIdleGrace?: number, spineEvictDelay?: number, spineQuietDelay?: number, - * spineTimers?: { set, clear }, spineNow?: () => number }} [opts] + * spineTimers?: { set, clear }, spineNow?: () => number, retryDelays?: number[], backoff?: number[], + * wait?: (ms: number) => Promise, timers?: { set, clear } }} [opts] */ export function createAssets(options) { const opts = options && typeof options === 'object' ? options : {}; @@ -583,20 +652,68 @@ export function createAssets(options) { let readyPromise = manifest ? Promise.resolve(manifest) : null; const loadImage = opts.loadImage || loadImageElement; const images = new Map(); // url → { promise, value } + const retryDelays = Array.isArray(opts.retryDelays) ? opts.retryDelays : MANIFEST_RETRY_MS; + const backoff = Array.isArray(opts.backoff) ? opts.backoff : MANIFEST_BACKOFF_MS; + const wait = opts.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const timers = opts.timers && typeof opts.timers.set === 'function' ? opts.timers : { + set: (fn, ms) => { const t = setTimeout(fn, ms); t?.unref?.(); return t; }, + clear: (t) => clearTimeout(t), + }; + const listeners = new Set(); + let warned = false; + let backoffN = 0; // background tries used since the last success + let backoffTimer = null; + + const notify = (what) => { + for (const fn of [...listeners]) { try { fn(what); } catch (err) { console.warn('[assets] change listener failed', err); } } + }; + + /** Adopt a manifest (fetched or seeded): resolves `ready()`, stops the background tries, tells the listeners. */ + function adopt(m) { + if (manifest || !isObj(m)) return false; + manifest = m; + readyPromise = Promise.resolve(m); + backoffN = 0; + if (backoffTimer != null) { timers.clear(backoffTimer); backoffTimer = null; } + notify('manifest'); + return true; + } + + /** One fetch of the manifest → the JSON object, or throws (err.status / err.badJson as data.js). */ + async function fetchOnce() { + const res = await doFetch(url, { cache: 'no-cache' }); + if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null }); + let json; + try { json = await res.json(); } catch (err) { throw Object.assign(err instanceof Error ? err : new Error(String(err)), { badJson: true }); } + if (!isObj(json)) throw Object.assign(new Error('not a manifest'), { badJson: true }); + return json; + } + + function scheduleBackoff() { + if (manifest || backoffTimer != null || backoffN >= backoff.length) return; + const ms = backoff[backoffN++]; + backoffTimer = timers.set(() => { backoffTimer = null; if (!manifest) ready(); }, ms); + } function ready() { + if (manifest) return Promise.resolve(manifest); if (!readyPromise) { - readyPromise = (async () => { - try { - const res = await doFetch(url, { cache: 'no-cache' }); - if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`); - const json = await res.json(); - manifest = isObj(json) ? json : {}; - } catch (err) { - console.warn(`[assets] ${url} unavailable (${err?.message || err}); using fallbacks`); - manifest = {}; + const p = readyPromise = (async () => { + for (let attempt = 0; ; attempt++) { + try { + const json = await fetchOnce(); + adopt(json); + return manifest; + } catch (err) { + if (manifest) return manifest; // seeded meanwhile + if (transientFetch(err) && attempt < retryDelays.length) { await wait(retryDelays[attempt]); if (manifest) return manifest; continue; } + if (!warned) { warned = true; console.warn(`[assets] ${url} unavailable (${err?.message || err}); using fallbacks until it loads`); } + // not kept: the next ready() fetches again; the store tries again by itself after a transient failure + if (readyPromise === p) readyPromise = null; + if (transientFetch(err)) scheduleBackoff(); + return {}; + } } - return manifest; })(); } return readyPromise; @@ -621,9 +738,9 @@ export function createAssets(options) { timeout: opts.spineTimeout ?? 20000, concurrency: opts.spineConcurrency ?? 6, // memory budget (a parsed skeleton holds ~0.1–12 MB): idle skeletons beyond SPINE_IDLE_BYTES go, and all of - // them once no scene has referenced any for SPINE_QUIET_DELAY_MS (lobby / room), each after a grace that covers a - // prep ⇄ battle switch; evictions run SPINE_EVICT_DELAY_MS after the release that allows them (a scene switch - // takes its models back first) + // them once no scene has referenced or held any for SPINE_QUIET_DELAY_MS (lobby / room / result; a field view in + // battle mode holds the cache — see the header), each after a grace that covers a prep ⇄ battle switch; evictions + // run SPINE_EVICT_DELAY_MS after the release that allows them (a scene switch takes its models back first) weigh: opts.spineWeigh || ((key, value) => spineDataWeight(value)), maxIdleWeight: opts.spineIdleBytes ?? SPINE_IDLE_BYTES, quietWeight: opts.spineQuietBytes ?? 0, @@ -650,16 +767,16 @@ export function createAssets(options) { return e.promise; } - /** Optional local-client art manifest (null when absent: every consumer falls back). Fetched once. */ + /** Optional local-client art manifest (null when absent: every consumer falls back). Fetched once (or seeded). */ function local() { if (!localPromise) { localPromise = (async () => { try { const res = await doFetch(localUrl, { cache: 'no-cache' }); - if (!res || !res.ok) return null; + if (!res || !res.ok) return localManifest; const json = await res.json(); - localManifest = isObj(json) && isObj(json.groups) ? json : null; - } catch { localManifest = null; } + if (!localManifest && isObj(json) && isObj(json.groups)) { localManifest = json; notify('local'); } + } catch { /* optional art: absent */ } return localManifest; })(); } @@ -670,6 +787,25 @@ export function createAssets(options) { return { ready, local, + /** + * Adopt the game data's copy of /data/assets.json (data.js 'assets', already loaded by the match screen) unless a + * manifest is loaded: no second download, nothing to wait for. Returns whether it was adopted. + */ + seed: (mf) => adopt(mf), + /** The same for the optional local-client manifest (data.js 'local'; `{ groups }`), unless one is known. */ + seedLocal(lm) { + if (localManifest || !isObj(lm) || !isObj(lm.groups)) return false; + localManifest = lm; + localPromise = Promise.resolve(lm); + notify('local'); + return true; + }, + /** `fn('manifest' | 'local')` when a manifest arrives (seeded or fetched, also late); returns the unsubscribe. */ + onChange(fn) { + if (typeof fn !== 'function') return () => {}; + listeners.add(fn); + return () => listeners.delete(fn); + }, localUrl: (group, name) => localAssetUrl(localManifest, group, name), get manifest() { return manifest; }, get loaded() { return !!manifest; }, @@ -709,11 +845,15 @@ export function createAssets(options) { }))); return { ok, failed: list.length - ok, total: list.length }; }, - /** Spine data LRU: acquire(entry) → Promise; release(entry); peek(entry). */ + /** + * Spine data LRU: acquire(entry, { retry }) → Promise (`retry`: load again after a remembered failure); + * release(entry); peek(entry); hold() → release function (a scene that will need its skeletons again: RefLru). + */ spine: { - acquire: (entry) => (validSpine(entry) ? spine.acquire(entry.skel, entry) : Promise.reject(new Error('no spine entry'))), + acquire: (entry, o) => (validSpine(entry) ? spine.acquire(entry.skel, entry, o) : Promise.reject(new Error('no spine entry'))), release: (entry) => { if (entry && entry.skel) spine.release(entry.skel); }, peek: (entry) => (entry && entry.skel ? spine.peek(entry.skel) : null), + hold: () => spine.hold(), stats: () => spine.stats(), clear: () => spine.clear(), cache: spine, diff --git a/public/js/render/app.js b/public/js/render/app.js index c6b6967c..76893c83 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 @@ -80,9 +83,12 @@ // context fall back to the 2D atlas board. `opts.board`: 'auto' (default) | '3d' | '2d'; `?board=2d|3d` in the URL // overrides (dev; '3d' also accepts a slow / software GPU). stats().board3d → { on, calls, triangles, cpuMs }. // three.js is only downloaded when the local-art manifest lists the board atlas. That manifest is awaited with the asset -// manifest (≤ 4 s), so an enemy whose model only the local client has draws it (assets.js spineEntry). The built 3D -// area, the drawn 2D rows and the lit rect follow `viewKind` (a 'prep' camera on the boss rows = the Final Assault -// prep = the boss field). +// manifest (≤ 4 s; the game seeds the store with its own copies, ui/fieldHost.js seedAssets), so an enemy whose model +// only the local client has draws it (assets.js spineEntry). A manifest that arrives later (assets.js onChange) makes +// every view re-resolve its picture and model (UnitView.retryAssets), and so does showing the tab again; battle mode +// holds the Spine cache so a battle begun in a hidden tab keeps its models (holdScene; public issue #8 item 5). The +// built 3D area, the drawn 2D rows and the lit rect follow `viewKind` (a 'prep' camera on the boss rows = the Final +// Assault prep = the boss field). // Battle device boxes (`ctx.createBox`) follow the board layer (switchableBox), so a 3D ⇄ 2D switch keeps crates. // Crowds and clipped Spine skeletons render through the shared impostor atlas (render/impostor.js), flushed once per // frame before the main pass. @@ -374,7 +380,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'); @@ -443,13 +449,18 @@ export async function createFieldView(host, options = {}) { bgMountains2.alpha = 1; const bgVignette = new P.Sprite(bg.vignette); backdrop.addChild(bgGrad, bgMountains, bgMountains2, bgGrid, bgVignette); - const mountainUrl = assets.ui ? assets.ui('entry/bg_mountains_tiled') : null; - if (mountainUrl && assets.image) { + let mountainsAsked = false; + /** The backdrop's mountain silhouette (optional art; asked again when the manifest arrives late). */ + function loadMountains() { + const mountainUrl = !mountainsAsked && assets.ui ? assets.ui('entry/bg_mountains_tiled') : null; + if (!mountainUrl || !assets.image) return; + mountainsAsked = true; assets.image(mountainUrl).then((img) => { if (!img || destroyed) return; try { const mt = silhouetteTexture(img); bgMountains.texture = mt; bgMountains2.texture = mt; } catch { /* optional art */ } }, () => {}); } + loadMountains(); // ---- state ---------------------------------------------------------------------------------------------- const listeners = new Map(); @@ -472,6 +483,7 @@ export async function createFieldView(host, options = {}) { let promoBase = []; const promotions = []; // the last merges cued by setPrep (fx.promote): { uid, id, area, row, col, idx, copies } — dev / tests let battleMeta = null; + let sceneHold = null; // release function of the Spine cache hold of battle mode (holdScene) const infos = new Map(); // battle unit id → UnitInfo // battle ids whose view finished its death / leak fade: a snapshot may still list them for a moment (the sim keeps // dead units for DIE_ANIM_TIME), which must not bring the view back; a spawn / deploy / live sample clears it @@ -625,15 +637,25 @@ export async function createFieldView(host, options = {}) { const ready = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false); await withTimeout(ready, 6000); } - // the official soft shadow sprite replaces the procedural one once loaded (may already be cached) - const shadowUrl = assets.ui ? assets.ui('battle/sprite_shadow') : null; - if (shadowUrl) { + // the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the + // manifest arrives late) + let shadowAsked = false; + function loadShadow() { + const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null; + if (!shadowUrl) return; + shadowAsked = true; try { const t = P.Texture.from(shadowUrl); - const use = () => { if (destroyed) return; ctx.shadowTex = t; for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; }; + const use = () => { + if (destroyed) return; + ctx.shadowTex = t; + for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; + for (const v of penViews.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; + }; if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use); } catch { /* optional */ } } + loadShadow(); ensureDamageFonts(); // web fonts may land after the first chips were drawn if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) refreshTierChips(); }).catch(() => {}); @@ -650,10 +672,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 +696,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 }), }); } @@ -944,6 +969,7 @@ export async function createFieldView(host, options = {}) { } v._home = w; v.dimmed = false; + if (info.kind === 'item' && v.setIcon) v.setIcon(info.icon); // an icon the manifest named late (onAssets) if (v.setCount) v.setCount(e.piece.kind === 'token' ? e.piece.count : 0); if (v.setItems) v.setItems(Array.isArray(e.piece.items) ? e.piece.items.map((it) => { const r = data.item(it?.id); return assets.itemIcon ? assets.itemIcon(r ? { trapId: r.trapId, iconId: r.iconId } : it?.id) : null; }) : []); v._showFacing = e.area === 'board'; @@ -953,6 +979,7 @@ export async function createFieldView(host, options = {}) { for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k); prepPieces = list.filter((e) => e.key && views.has(e.key)); if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); } + holdScene(false); // the prep pieces reference their models now (a battle's hold ends here) return true; } @@ -1285,8 +1312,23 @@ export async function createFieldView(host, options = {}) { // ---- battle --------------------------------------------------------------------------------------------- + /** + * Battle mode holds the Spine cache (assets.js `spine.hold()`, its memory policy): the battle's views are built in + * animation frames, which a hidden tab does not run — a battle that began in a background tab referenced no skeleton, and + * the quiet budget emptied the cache ≈ 18 s later, so back in the tab every unit was an avatar diamond until its model + * downloaded again (public issue #8 item 5). Released once the prep pieces reference their models (setPrep) and on + * destroy; the idle budget still applies meanwhile. + */ + function holdScene(on) { + if (on) { if (!sceneHold && typeof assets.spine?.hold === 'function') sceneHold = assets.spine.hold(); return; } + const release = sceneHold; + sceneHold = null; + if (release) { try { release(); } catch { /* ignore */ } } + } + function enterBattle(meta) { if (destroyed || !meta || typeof meta !== 'object') return false; + holdScene(true); // before the prep views go: the cache is never "quiet" across the switch drag.reset(); endDragVisual(false); clearViews(); @@ -1695,6 +1737,28 @@ export async function createFieldView(host, options = {}) { let lastDpr = globalThis.devicePixelRatio || 1; layoutBackdrop(); + // ---- late assets, hidden tabs (public issue #8 item 5: operators drawn as image-less placeholders) ---------------------- + // The asset manifest (or the local-client one) arrived after views were built — a reload whose manifest fetch was slow or + // failed (assets.js onChange): every view re-resolves what it could not draw (UnitView.retryAssets) and the prep pieces + // re-read their item icons. The tab shown again: a manifest still missing is asked for again and views without a model + // load it again at once (their bounded retries never run while hidden: no frames). + function onAssets() { + if (destroyed) return; + loadShadow(); + loadMountains(); + if (mode === 'prep' && lastPrep) setPrep(lastPrep.ps, lastPrep.o); + for (const v of views.values()) v.retryAssets?.(); + for (const v of penViews.values()) v.retryAssets?.(); + } + const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null; + const onVisible = () => { + if (destroyed || globalThis.document?.visibilityState !== 'visible') return; + if (assets.loaded === false && typeof assets.ready === 'function') assets.ready(); + for (const v of views.values()) v.retryAssets?.(); + for (const v of penViews.values()) v.retryAssets?.(); + }; + globalThis.document?.addEventListener?.('visibilitychange', onVisible); + // ---- public API --------------------------------------------------------------------------------------------- const view = { @@ -1810,6 +1874,8 @@ export async function createFieldView(host, options = {}) { if (destroyed) return; destroyed = true; try { ro?.disconnect(); } catch { /* ignore */ } + try { offAssets?.(); } catch { /* ignore */ } + globalThis.document?.removeEventListener?.('visibilitychange', onVisible); canvas.removeEventListener('pointerdown', onPointerDown); canvas.removeEventListener('pointermove', onPointerMove); canvas.removeEventListener('pointerup', onPointerUp); @@ -1824,6 +1890,7 @@ export async function createFieldView(host, options = {}) { drag.reset(); for (const k of [...views.keys()]) dropView(k); clearPen(); + holdScene(false); // no scene any more: the quiet budget may free the skeletons (lobby / room / result) try { fx.destroy(); } catch { /* ignore */ } try { tiles.destroy(); } catch { /* ignore */ } try { impostors.destroy(); } catch { /* ignore */ } diff --git a/public/js/render/units.js b/public/js/render/units.js index f3ffb97f..e88c92b9 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -3,11 +3,13 @@ // UnitView = shadow sprite (shadow layer) + body container (depth-sorted unit layer: elite aura, Spine actor or // the avatar-in-rarity-diamond fallback) + HUD container (bar layer: HP bar with delayed "ghost" damage, SP bar // with ready glow / draining skill bar, tier chip, status icons, blocked marker). The fallback shows at once and -// cross-fades to the Spine model when it has loaded; missing or failed models keep the fallback forever (the -// game never blocks on Spine) — except an optional local-client model (assets.js spineEntry `fallback`, DESIGN §13: -// 灼热源石虫 / 炽焰源石虫), which falls back to the web model first; that web alias (the plain 源石虫) is drawn tinted -// toward the slug's own colours (ALIAS_TINT). Every bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw -// calls. +// cross-fades to the Spine model when it has loaded (the game never blocks on Spine); an optional local-client model +// (assets.js spineEntry `fallback`, DESIGN §13: 灼热源石虫 / 炽焰源石虫) falls back to the web model first; that web alias +// (the plain 源石虫) is drawn tinted toward the slug's own colours (ALIAS_TINT). A model that failed or timed out is +// loaded again after SPINE_RETRY_MS (bounded), and `retryAssets()` re-resolves a view's picture and model when the +// asset manifest arrives after the view was built or the tab is shown again (render/app.js; public issue #8 item 5: +// after a reload whose manifest was slow or failed, every operator stayed the image-less placeholder for good). Every +// bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw calls. // // Placement: feet anchored at world (x, y, z); scale = camera px-per-tile at the feet × UNIT.modelScale, so // chibis shrink with distance like the original. An enemy's model is also scaled by its official prefab factor @@ -84,6 +86,12 @@ export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1] const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now()); /** How long a view waits for its avatar before showing the image-less placeholder diamond. */ const PIC_WAIT_MS = 400; +/** + * Waits (ms, real time) before a view loads its Spine model again after the load failed or timed out (assets.js, 20 s) + * — bounded: after the last one the view keeps its diamond until `retryAssets` (a manifest that arrived late, the tab + * shown again). Counted from the failure; a hidden tab runs no frames, so nothing is retried while hidden. + */ +export const SPINE_RETRY_MS = Object.freeze([2000, 6000, 15000, 30000]); /** Heights above this count as standing on a raised top (bench pads are the lowest raised tiles, 0.16). */ const RAISED_Z = 0.12; @@ -317,6 +325,9 @@ export class UnitView { this.body.addChild(this.fallback); this.actor = null; this.spineReady = false; + this._spineBusy = false; // a Spine load of this view is in flight + this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS) + this._retryAt = 0; // when the next retry is due (ms, performance clock; 0 = none) this.baseTint = 0xffffff; // the drawn model's own tint (ALIAS_TINT), under the status tints this.hud = new P.Container(); @@ -365,7 +376,8 @@ export class UnitView { pic.shown = want; } - _loadSpine() { + /** @param {boolean} [retry] load again even after a remembered failure (assets.js acquire `{ retry }`) */ + _loadSpine(retry = false) { const a = this.ctx.assets; if (!a || !a.spineEntry || !a.spine) return; const id = this.info.spine || this.info.defId; @@ -374,21 +386,38 @@ export class UnitView { const entry = id ? a.spineEntry(id, { back }) : null; if (!entry || this.ctx.settings?.quality === 'low' && this.isEnemy && !this.isBoss && this.ctx.crowded?.()) return; this.entryBack = back; - this._acquireSpine(entry, id); + this._acquireSpine(entry, id, retry); + } + + /** + * Re-resolve what this view could not draw yet (public issue #8 item 5): the picture when it has none (no avatar URL — + * the asset manifest arrived after the view was built — or the image failed) and, at once, the Spine model when none + * is shown and none is loading (no manifest entry then, or a load that failed / timed out). render/app.js calls it for + * every view when a manifest arrives (assets.js onChange) and when the tab is shown again. Nothing to do otherwise. + */ + retryAssets() { + if (this.destroyed) return; + if (!this._pic || this._pic.state === 'none') this._loadPicture(); + if (!this.actor && !this._spineBusy) { this._retryAt = 0; this._loadSpine(true); } } /** * Load `entry` and show it. Every acquire is paired with exactly one release: a superseded / failed / post-destroy * load releases its own entry; the displayed model's entry (`_actorEntry`) is released when that model is replaced or - * destroyed. A model that fails to load keeps the fallback diamond — unless the entry names a `fallback` (an optional - * local-client model, assets.js spineEntry: DESIGN §13), which is loaded in its place. + * destroyed. A model that fails to load falls back to the entry's `fallback` (an optional local-client model, + * assets.js spineEntry: DESIGN §13) when it names one; otherwise the view keeps its fallback diamond and — while no + * model is shown — loads again after SPINE_RETRY_MS (a timed-out or failed download used to stay a diamond for good). */ - _acquireSpine(entry, id) { + _acquireSpine(entry, id, retry = false) { const a = this.ctx.assets; this.entry = entry; const req = this._spineReq = (this._spineReq || 0) + 1; - a.spine.acquire(entry).then((data) => { + this._spineBusy = true; + a.spine.acquire(entry, retry ? { retry: true } : undefined).then((data) => { + if (req === this._spineReq) this._spineBusy = false; if (this.destroyed || req !== this._spineReq) { this._releaseEntry(entry); return; } + this._spineTries = 0; + this._retryAt = 0; let actor = null; try { actor = new SpineActor(data, entry); @@ -421,9 +450,12 @@ export class UnitView { this.actor.setBase(this._baseFromAnim()); } }, () => { + if (req === this._spineReq) this._spineBusy = false; this._releaseEntry(entry); - if (entry.fallback && !this.destroyed && req === this._spineReq) this._acquireSpine(entry.fallback, id); - // else keep the fallback diamond + if (this.destroyed || req !== this._spineReq) return; + if (entry.fallback) { this._acquireSpine(entry.fallback, id, retry); return; } + // keep the fallback diamond (or the model already shown: a failed Front ⇄ Back swap) — and try again later + if (!this.actor && this._spineTries < SPINE_RETRY_MS.length) this._retryAt = nowMs() + SPINE_RETRY_MS[this._spineTries++]; }); } @@ -729,6 +761,8 @@ export class UnitView { update(dt, cam, t) { if (this.destroyed) return; const P = this.P; + // a failed / timed-out model load is tried again once its wait is over (SPINE_RETRY_MS; frames only: never hidden) + if (this._retryAt && nowMs() >= this._retryAt) { this._retryAt = 0; if (!this.actor && !this._spineBusy) this._loadSpine(true); } if (this.zTarget != null && this.z !== this.zTarget) { const d = this.zTarget - this.z; this.z = Math.abs(d) < 1e-3 ? this.zTarget : this.z + d * Math.min(1, dt * 12); @@ -1297,10 +1331,17 @@ export class ItemView { this.plate = new P.Sprite(itemTexture(String(info.defId || 'item'), null, info.color || 0x9aa5a0)); this.plate.anchor.set(0.5, 1); this.root.addChild(this.plate); - const url = info.icon; - if (url && ctx.assets?.image) ctx.assets.image(url).then((img) => { if (!this.destroyed && img) this.plate.texture = itemTexture(String(info.defId), img, info.color || 0x9aa5a0); }, () => {}); + this.info.icon = null; + this.setIcon(info.icon); this.hud = null; } + /** Show the item's icon (an URL; the plain plate until it loads) — again when the asset manifest named it late. */ + setIcon(url) { + if (this.destroyed || !url || url === this.info.icon) return; + this.info.icon = url; + const a = this.ctx.assets; + if (a?.image) a.image(url).then((img) => { if (!this.destroyed && img && this.info.icon === url) this.plate.texture = itemTexture(String(this.info.defId), img, this.info.color || 0x9aa5a0); }, () => {}); + } setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; } update(dt, cam, t) { const lifted = this.lift > 0; 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..eeec6551 100644 --- a/public/js/ui/fieldHost.js +++ b/public/js/ui/fieldHost.js @@ -1,6 +1,8 @@ // Field view loader: mounts the Pixi render engine (public/js/render/app.js → createFieldView, DESIGN §9) // into a host element, falling back to the DOM view (fallbackField.js) when the engine is missing, times // out or throws. Every call into the view goes through a guard so a render bug can never crash the HUD. +// The shared asset store gets the game data's copy of the asset manifest (`seedAssets`, public issue #8 item 5), and the +// prep cameras the HUD bands they keep clear (`hudBands`; the folded shop's band: public issue #5). // // `?render=fallback` (or globalThis.__SP_RENDER__ = 'fallback') skips the engine (dev / mock harness); // `?render=engine` never falls back silently (errors are logged and the fallback still mounts). @@ -43,41 +45,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'); @@ -116,6 +162,27 @@ export function guardView(view, kind) { return safe; } +/** + * Hand the game data's copies of /data/assets.json and /data/local-assets.json (data.js 'assets' / 'local': the match + * screen waits for them, gameComponents GAME_FILES) to the asset store, now or when they land: it then never downloads + * the manifest a second time — after a page reload (a phone browser discarding a background tab, Chrome's Memory Saver) + * that second download was the one createFieldView waited ≤ 4 s for, and a slow or failed one left every operator the + * image-less placeholder (public issue #8 item 5). Without a copy the store fetches and retries by itself. + * @param {{ seed?: (m: any) => boolean, seedLocal?: (m: any) => boolean }} store public/js/assets.js store + */ +export function seedAssets(store) { + if (!store) return; + const give = (name, fn) => { + if (typeof fn !== 'function') return; + const now = data.get(name); + if (now) { fn(now); return; } + // still loading (or failed): adopt it if it lands; a later success of the store's own fetch makes this a no-op + Promise.resolve(data.load(name)).then((m) => { if (m) fn(m); }, () => {}); + }; + give('assets', store.seed?.bind(store)); + give('local', store.seedLocal?.bind(store)); +} + /** * Create a field view in `host`: the render engine when available, else the DOM fallback. * @param {HTMLElement} host @@ -128,7 +195,10 @@ export async function mountFieldView(host) { try { // the shared asset store (public/js/assets.js) keeps its Spine cache across remounts (next match, reconnect) const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import').catch(() => null); - if (am?.assets && typeof am.assets.ready === 'function') opts.assets = am.assets; + if (am?.assets && typeof am.assets.ready === 'function') { + opts.assets = am.assets; + seedAssets(am.assets); + } const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import'); if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing'); const view = await withTimeout(Promise.resolve(mod.createFieldView(host, opts)), LOAD_TIMEOUT_MS, 'createFieldView'); diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 9ca6c3e3..caa7949e 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/render/assets.test.js b/test/render/assets.test.js index f4a9a6f4..5712952e 100644 --- a/test/render/assets.test.js +++ b/test/render/assets.test.js @@ -485,10 +485,13 @@ describe('createAssets store', () => { assert.equal(a.audio.sfx('battle', 'deploy'), '/sfx/dep.mp3'); const warn = console.warn; console.warn = () => {}; try { + // a failure resolves ready() (fallbacks meanwhile) but is not kept as the manifest (public issue #8 item 5; + // test/render/issue8-placeholders.test.js covers the retries) const b = createAssets({ fetch: async () => ({ ok: false, status: 404 }) }); - await b.ready(); + assert.deepEqual(await b.ready(), {}); assert.equal(b.avatar('char_002_amiya'), null); - assert.deepEqual(b.manifest, {}); + assert.equal(b.manifest, null); + assert.equal(b.loaded, false); } finally { console.warn = warn; } }); diff --git a/test/render/issue8-placeholders.test.js b/test/render/issue8-placeholders.test.js new file mode 100644 index 00000000..c2de81ec --- /dev/null +++ b/test/render/issue8-placeholders.test.js @@ -0,0 +1,341 @@ +// Public issue #8 item 5 (v0.1.1): "游览器切换页面后所有干员会变成方块+水滴的图标" — after the tab came back every operator was +// the image-less placeholder (diamond + droplet glyph). A plain tab switch did not do it; a reload did (phone browsers +// and Chrome's Memory Saver discard background tabs and reload them on return; the game resumes into the match): the +// asset store fetched /data/assets.json a second time, createFieldView waited ≤ 4 s for it, every view looked its model +// and avatar up once, and a failed fetch kept an empty manifest for the page. Without a reload, a battle that began +// in a hidden tab referenced no skeleton, so the quiet budget emptied the Spine cache ≈ 18 s later. +// public/js/assets.js: seed / ready retries + backoff / onChange / spine acquire { retry } / spine hold; +// public/js/render/units.js: UnitView.retryAssets, bounded Spine retries (SPINE_RETRY_MS), ItemView.setIcon; +// public/js/ui/fieldHost.js seedAssets; public/js/render/app.js (wiring: onChange, visibilitychange, battle hold). + +import { test, describe, before, after } 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 { installFakePixi, fakeViewCtx } from './fakepixi.js'; +import { presetCamera } from '../../public/js/render/projection.js'; +import { + createAssets, RefLru, MANIFEST_RETRY_MS, MANIFEST_BACKOFF_MS, SPINE_IDLE_GRACE_MS, SPINE_QUIET_DELAY_MS, SPINE_EVICT_DELAY_MS, +} from '../../public/js/assets.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (p) => readFileSync(path.join(ROOT, p), 'utf8'); +const tick = () => new Promise((r) => setImmediate(r)); +const quiet = async (fn) => { const w = console.warn; console.warn = () => {}; try { return await fn(); } finally { console.warn = w; } }; + +/** Fake clock + timers (RefLru `now` / `timers`, the store's backoff `timers`). */ +const clock = () => { + const c = { t: 0, pending: [] }; + c.now = () => c.t; + c.timers = { set: (fn, ms) => { const h = { fn, at: c.t + ms, ms }; c.pending.push(h); return h; }, clear: (h) => { c.pending = c.pending.filter((x) => x !== h); } }; + c.advance = (ms) => { c.t += ms; for (const h of c.pending.filter((x) => x.at <= c.t)) { c.pending = c.pending.filter((x) => x !== h); h.fn(); } }; + return c; +}; + +const SP = (id) => ({ skel: `/assets/spine/${id}.skel`, atlas: `/assets/spine/${id}.atlas`, textures: [`/assets/spine/${id}.png`], anims: { idle: 'Idle' }, animations: { Idle: 1 } }); +const M = { + chars: { char_002_amiya: { avatar: '/a/amiya.png', spine: { front: SP('amiya_f') } }, char_010_chen: { avatar: '/a/chen.png', spine: { front: SP('chen_f') } } }, + enemies: { enemy_1305_mhslim: { icon: '/e/mh.png', spineLocal: { group: 'spine/enemy/mh', skel: 'mh.skel', atlas: 'mh.atlas', textures: ['mh.png'], anims: { idle: 'Idle' } } } }, + items: { trap_1: '/i/1.png' }, + ui: {}, +}; +const LOCAL = { groups: { 'spine/enemy/mh': { 'mh.skel': { path: '/l/mh.skel' }, 'mh.atlas': { path: '/l/mh.atlas' }, 'mh.png': { path: '/l/mh.png' } } } }; +const ok = (json) => ({ ok: true, status: 200, json: async () => json }); + +describe('the manifest: seeded, retried, never kept empty (assets.js)', () => { + test('seed: the game data\'s copy is adopted — no download, ready() resolves it, listeners hear it', async () => { + let fetches = 0; + const a = createAssets({ fetch: async () => { fetches++; return ok(M); } }); + const heard = []; + a.onChange((w) => heard.push(w)); + assert.equal(a.seed(M), true); + assert.equal(await a.ready(), M); + assert.equal(fetches, 0, 'nothing downloaded'); + assert.equal(a.avatar('char_002_amiya'), '/a/amiya.png'); + assert.deepEqual(heard, ['manifest']); + assert.equal(a.seed({ chars: {} }), false, 'a loaded manifest is kept'); + assert.equal(a.seed(null), false); + }); + + test('a transient failure is fetched again within the same ready() (MANIFEST_RETRY_MS, like data.js)', async () => { + assert.deepEqual([...MANIFEST_RETRY_MS], [600, 2000]); + const waits = []; + let n = 0; + const a = createAssets({ wait: async (ms) => { waits.push(ms); }, fetch: async () => { n++; if (n < 3) throw new TypeError('Failed to fetch'); return ok(M); } }); + assert.equal(await a.ready(), M); + assert.equal(n, 3); + assert.deepEqual(waits, [600, 2000]); + }); + + test('every try failing: ready() resolves {} (fallbacks), the failure is not kept — the next ready() fetches again', async () => { + let n = 0, up = false; + const c = clock(); + const a = createAssets({ wait: async () => {}, timers: c.timers, fetch: async () => { n++; if (!up) return { ok: false, status: 503 }; return ok(M); } }); + const heard = []; + a.onChange((w) => heard.push(w)); + assert.deepEqual(await quiet(() => a.ready()), {}); + assert.equal(n, 3, 'the first try + 2 retries'); + assert.equal(a.manifest, null); + assert.equal(a.loaded, false); + assert.equal(a.avatar('char_002_amiya'), null, 'helpers fall back meanwhile'); + up = true; + assert.equal(await a.ready(), M, 'asked again: fetched again (no empty manifest kept for the page)'); + assert.equal(n, 4); + assert.deepEqual(heard, ['manifest']); + assert.equal(c.pending.length, 0, 'the background try was cancelled by the success'); + }); + + test('after a failed ready() the store tries again by itself, with a bounded backoff', async () => { + assert.ok(MANIFEST_BACKOFF_MS.length >= 3 && MANIFEST_BACKOFF_MS.every((ms, i) => i === 0 || ms >= MANIFEST_BACKOFF_MS[i - 1])); + const c = clock(); + let n = 0, up = false; + const a = createAssets({ wait: async () => {}, timers: c.timers, backoff: [100, 200], retryDelays: [], fetch: async () => { n++; if (!up) throw new TypeError('offline'); return ok(M); } }); + const heard = []; + a.onChange((w) => heard.push(w)); + await quiet(() => a.ready()); + assert.equal(n, 1); + assert.deepEqual(c.pending.map((h) => h.ms), [100]); + await quiet(async () => { c.advance(100); await tick(); await tick(); }); + assert.equal(n, 2, 'tried again after 100 ms'); + assert.deepEqual(c.pending.map((h) => h.ms), [200]); + up = true; + c.advance(200); await tick(); await tick(); + assert.equal(n, 3); + assert.equal(a.manifest, M, 'arrived late'); + assert.deepEqual(heard, ['manifest'], 'listeners (render/app.js) re-resolve their views now'); + // bounded: a store that never gets it stops after the backoff list + const d = clock(); + let m = 0; + const b = createAssets({ wait: async () => {}, timers: d.timers, backoff: [10, 20], retryDelays: [], fetch: async () => { m++; throw new TypeError('offline'); } }); + await quiet(async () => { + await b.ready(); + for (let i = 0; i < 6; i++) { d.advance(50); await tick(); await tick(); } + }); + assert.equal(m, 3, 'the first try + 2 background tries'); + assert.equal(d.pending.length, 0); + }); + + test('a 404 or a body that is no JSON: no retry loop, but still not kept (the next ready() asks again)', async () => { + const c = clock(); + let n = 0; + const a = createAssets({ wait: async () => {}, timers: c.timers, fetch: async () => { n++; return n === 1 ? { ok: false, status: 404 } : { ok: true, status: 200, json: async () => { throw new SyntaxError('x'); } }; } }); + await quiet(() => a.ready()); + assert.equal(n, 1); + assert.equal(c.pending.length, 0, 'no background tries for a definite failure'); + await quiet(() => a.ready()); + assert.equal(n, 2, 'asked again'); + assert.equal(a.manifest, null); + }); + + test('a seed while a fetch is still retrying wins; the late fetch changes nothing', async () => { + let release; + const gate = new Promise((r) => { release = r; }); + const a = createAssets({ wait: () => gate, fetch: async () => { throw new TypeError('slow network'); } }); + const p = a.ready(); + a.seed(M); + release(); + assert.equal(await p, M); + assert.equal(await a.ready(), M); + }); + + test('seedLocal: the local-client manifest from the game data (an enemy\'s local model resolves with it)', async () => { + let fetches = 0; + const a = createAssets({ manifest: M, fetch: async () => { fetches++; return ok(LOCAL); } }); + const heard = []; + a.onChange((w) => heard.push(w)); + assert.equal(a.spineEntry('enemy_1305_mhslim'), null, 'no web model, no local manifest yet'); + assert.equal(a.seedLocal(LOCAL), true); + assert.equal(await a.local(), LOCAL); + assert.equal(fetches, 0); + assert.equal(a.spineEntry('enemy_1305_mhslim').skel, '/l/mh.skel'); + assert.deepEqual(heard, ['local']); + assert.equal(a.seedLocal({ groups: {} }), false, 'a known one is kept'); + assert.equal(createAssets({ manifest: M }).seedLocal({ nope: 1 }), false, 'not a local manifest'); + }); +}); + +describe('Spine cache: retries and the scene hold (RefLru)', () => { + test('a remembered failure is shared until failTtl — acquire { retry } loads again once nobody references it', async () => { + let calls = 0, fail = true; + const lru = new RefLru({ failTtl: 60000, now: () => 0, load: async (k) => { calls++; if (fail) throw new Error('timeout'); return k; } }); + await assert.rejects(lru.acquire('a')); + lru.release('a'); + await assert.rejects(lru.acquire('a'), 'remembered: no second download for every new view'); + lru.release('a'); + assert.equal(calls, 1); + fail = false; + assert.equal(await lru.acquire('a', null, { retry: true }), 'a', 'a retry loads again'); + assert.equal(calls, 2); + lru.release('a'); + // a failure some caller still references is shared (dropping it would make that caller's release hit the new entry) + const b = new RefLru({ load: async () => { throw new Error('x'); } }); + const p1 = b.acquire('k'); + await assert.rejects(p1); + await assert.rejects(b.acquire('k', null, { retry: true })); + assert.equal(b.map.get('k').refs, 2); + }); + + test('hold(): no quiet budget while a scene holds the cache; the idle budget still applies; released → quiet again', async () => { + const c = clock(), unloads = []; + const lru = new RefLru({ load: async (k) => k, unload: (k) => unloads.push(k), weigh: (k) => (k === 'big' ? 900 : 100), + maxIdleWeight: 1000, quietWeight: 0, idleGrace: SPINE_IDLE_GRACE_MS, quietDelay: SPINE_QUIET_DELAY_MS, evictDelay: SPINE_EVICT_DELAY_MS, + now: c.now, timers: c.timers }); + for (const k of ['op1', 'op2', 'big']) await lru.acquire(k); + // battle mode: the prep views go before the battle's views are built — in a hidden tab, not for a long while + const release = lru.hold(); + for (const k of ['op1', 'op2', 'big']) lru.release(k); + c.advance(SPINE_IDLE_GRACE_MS + SPINE_QUIET_DELAY_MS + 10000); + assert.deepEqual(unloads, ['op1'], 'held: only the idle budget (1100 > 1000: the least recently used goes), not the quiet one'); + assert.equal(lru.stats().holds, 1); + // the battle's views come back (the tab is shown): their models are still cached + assert.equal(lru.peek('op2'), 'op2'); + release(); + release(); // twice: no effect + assert.equal(lru.stats().holds, 0); + c.advance(SPINE_QUIET_DELAY_MS - 100); + assert.deepEqual(unloads, ['op1'], 'the quiet delay counts from the release'); + c.advance(200 + SPINE_EVICT_DELAY_MS); + assert.deepEqual(unloads.sort(), ['big', 'op1', 'op2'], 'no scene: every idle skeleton goes (the phone gets its memory back)'); + }); + + test('without a hold the old behaviour stays: nothing referenced for the quiet delay → the cache empties', async () => { + const c = clock(), unloads = []; + const lru = new RefLru({ load: async (k) => k, unload: (k) => unloads.push(k), weigh: () => 100, maxIdleWeight: 1e9, quietWeight: 0, + idleGrace: SPINE_IDLE_GRACE_MS, quietDelay: SPINE_QUIET_DELAY_MS, evictDelay: SPINE_EVICT_DELAY_MS, now: c.now, timers: c.timers }); + await lru.acquire('op'); + lru.release('op'); + c.advance(SPINE_IDLE_GRACE_MS + SPINE_QUIET_DELAY_MS + 10); + assert.deepEqual(unloads, ['op']); + }); + + test('the store exposes them: spine.acquire(entry, { retry }) and spine.hold()', async () => { + let calls = 0, fail = true; + const a = createAssets({ manifest: M, loadSpine: async (e) => { calls++; if (fail) throw new Error('x'); return { animations: [], from: e.skel }; }, unloadSpine: () => {} }); + const e = a.spineEntry('char_002_amiya'); + await assert.rejects(a.spine.acquire(e)); a.spine.release(e); + fail = false; + await assert.rejects(a.spine.acquire(e)); a.spine.release(e); + assert.equal((await a.spine.acquire(e, { retry: true })).from, e.skel); + assert.equal(calls, 2); + const r = a.spine.hold(); + assert.equal(a.spine.stats().holds, 1); + r(); + assert.equal(a.spine.stats().holds, 0); + }); +}); + +describe('views re-resolve and retry (render/units.js)', () => { + let fake, UnitView, ItemView, SPINE_RETRY_MS; + before(async () => { + fake = installFakePixi(); + ({ UnitView, ItemView, SPINE_RETRY_MS } = await import('../../public/js/render/units.js')); + }); + after(() => fake.restore()); + const cam = () => presetCamera('prep', { width: 1280, height: 720 }); + const IMG = { width: 180, height: 180 }; + const unit = (assets, info = {}) => new UnitView(fakeViewCtx(fake.P, { assets, cam }), { id: 1, side: 'ally', kind: 'chess', defId: 'char_002_amiya', spine: 'char_002_amiya', avatar: 'char_002_amiya', tier: 3, x: 5, y: 12, maxHp: 1000, ...info }, { prep: true }); + + test('a view built before the manifest (a slow reload) is a placeholder; when it arrives retryAssets loads the model and avatar', async () => { + const a = createAssets({ fetch: async () => ({ ok: false, status: 503 }), wait: async () => {}, retryDelays: [], backoff: [], loadImage: async () => IMG, + loadSpine: async () => ({ animations: [{ name: 'Idle' }] }), unloadSpine: () => {} }); + await quiet(() => a.ready()); + const v = unit(a); + await tick(); await tick(); + for (let i = 0; i < 3; i++) v.update(1 / 60, cam(), i / 60); + assert.equal(v.actor, null); + assert.equal(v._pic.state, 'none', 'no avatar URL without the manifest'); + assert.equal(v._pic.shown, 'placeholder', 'the image-less diamond of the report'); + // the manifest arrives (render/app.js calls retryAssets on onChange) + let heard = 0; + a.onChange(() => { heard++; v.retryAssets(); }); + a.seed(M); + await tick(); await tick(); await tick(); + assert.equal(heard, 1); + assert.ok(v.actor && v.spineReady, 'the Spine model is shown'); + assert.equal(v._pic.state, 'img', 'and its avatar is known for the diamond'); + }); + + test('a failed / timed-out model is loaded again after SPINE_RETRY_MS (bounded); a success ends the retries', async () => { + assert.deepEqual([...SPINE_RETRY_MS], [2000, 6000, 15000, 30000]); + let calls = 0, fail = true; + const a = createAssets({ manifest: M, loadImage: async () => IMG, unloadSpine: () => {}, + loadSpine: async () => { calls++; if (fail) throw new Error('load timeout'); return { animations: [{ name: 'Idle' }] }; } }); + const v = unit(a); + await tick(); await tick(); + assert.equal(calls, 1); + assert.equal(v.actor, null); + assert.ok(v._retryAt > 0, 'a retry is due later'); + v.update(1 / 60, cam(), 0); + await tick(); + assert.equal(calls, 1, 'not before its wait'); + // every retry fails until the list runs out + for (let i = 0; i < SPINE_RETRY_MS.length; i++) { + v._retryAt = 1; // due + v.update(1 / 60, cam(), 0); + await tick(); await tick(); + } + assert.equal(calls, 1 + SPINE_RETRY_MS.length, 'bypassing the remembered failure (acquire { retry })'); + assert.equal(v._retryAt, 0, 'bounded: no more retries'); + for (let i = 0; i < 5; i++) v.update(1 / 60, cam(), 0); + await tick(); + assert.equal(calls, 1 + SPINE_RETRY_MS.length); + // the tab shown again (render/app.js visibilitychange → retryAssets): loads at once + fail = false; + v.retryAssets(); + await tick(); await tick(); + assert.ok(v.actor && v.spineReady); + assert.equal(v._spineTries, 0); + assert.equal(a.spine.stats().refs, 1, 'every acquire paired with one release'); + // nothing to do for a view that shows its model + v.retryAssets(); + await tick(); + assert.equal(calls, 2 + SPINE_RETRY_MS.length); + v.destroy(); + assert.equal(a.spine.stats().refs, 0); + }); + + test('retryAssets never doubles a load in flight; a destroyed view retries nothing', async () => { + let resolve, calls = 0; + const a = createAssets({ manifest: M, loadImage: async () => IMG, unloadSpine: () => {}, loadSpine: () => { calls++; return new Promise((r) => { resolve = r; }); } }); + const v = unit(a); + v.retryAssets(); + v.retryAssets(); + assert.equal(calls, 1); + resolve({ animations: [{ name: 'Idle' }] }); + await tick(); await tick(); + assert.ok(v.actor); + const w = unit(createAssets({ manifest: {}, loadImage: async () => IMG })); + w.destroy(); + assert.doesNotThrow(() => w.retryAssets()); + }); + + test('ItemView.setIcon: an icon the manifest named late replaces the plain plate', async () => { + const a = createAssets({ manifest: M, loadImage: async () => IMG }); + const it = new ItemView(fakeViewCtx(fake.P, { assets: a, cam }), { id: 'p:9', uid: 9, kind: 'item', defId: 'trap_1', icon: null, color: 0xffffff }); + const plain = it.plate.texture; + it.setIcon('/i/1.png'); + await tick(); await tick(); + assert.notEqual(it.plate.texture, plain); + assert.equal(it.info.icon, '/i/1.png'); + }); +}); + +describe('wiring (render/app.js, ui/fieldHost.js)', () => { + test('the field view re-resolves its views on a late manifest and on a shown tab, and holds the cache in battle mode', () => { + const app = read('public/js/render/app.js'); + assert.match(app, /const offAssets = typeof assets\.onChange === 'function' \? assets\.onChange\(onAssets\) : null;/); + assert.match(app, /for \(const v of views\.values\(\)\) v\.retryAssets\?\.\(\);/); + assert.match(app, /globalThis\.document\?\.addEventListener\?\.\('visibilitychange', onVisible\);/); + assert.match(app, /globalThis\.document\?\.removeEventListener\?\.\('visibilitychange', onVisible\);/); + assert.match(app, /function enterBattle\(meta\) \{\n {4}if \(destroyed \|\| !meta \|\| typeof meta !== 'object'\) return false;\n {4}holdScene\(true\);/); + assert.match(app, /holdScene\(false\); \/\/ the prep pieces reference their models now/); + assert.match(app, /holdScene\(false\); \/\/ no scene any more/); + }); + + test('the field host seeds the asset store with the game data\'s copies', () => { + const fh = read('public/js/ui/fieldHost.js'); + assert.match(fh, /opts\.assets = am\.assets;\n {8}seedAssets\(am\.assets\);/); + assert.match(fh, /give\('assets', store\.seed\?\.bind\(store\)\);\n {2}give\('local', store\.seedLocal\?\.bind\(store\)\);/); + }); +}); 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\) \}\)/); });