feedback1 final: merge the client fixes for GitHub issues #5 (prep camera follows the folded shop) and #8 item 5 (operators no longer stay placeholders)

Conflicts resolved keeping both sides:
- CHANGELOG 0.1.1 界面: the three gaps lines, then the issue #5 and #8 lines.
- DESIGN: the branch's "§21.26 Client fixes for GitHub issues #5 and #8" follows the gaps' §21.26 as §21.27; its
  references in §9 (setCamera `shop`, the model fallback), §17.1 (the quiet budget) and §19.8 (the collapsed shop)
  now point at §21.27.
This commit is contained in:
sganggs committed 2026-10-03 18:49:39 +08:00
commit c61167e3aa
14 files changed
+1089 -101

No files matched your search

+17 -4
View File
@@ -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).