diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ce8d38f..1f83fe16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,8 @@ - 干员详情卡的攻击范围小图实时显示技能期间的范围;整备期棋盘上的范围提示、转向轮盘和详情卡显示同一个范围。 - 网页卡顿、切到后台或中途进场观战时,不会再漏掉敌人的变身,也不会在切回来后补播早已结束的变身动画。 - 灼热 / 炽焰源石虫:整合包附带官方模型;从源码运行且没有提取本机客户端素材时,显示染成橙色 / 红橙色的源石虫。 +- 准备阶段收起商店后,棋盘按官方收起商店时的视角放大,整备区留在收起的商店栏和左下角按钮上方,展开后恢复原视角;拖动干员或选择朝向时会等放下后再切换(GitHub #5)。 +- 浏览器在后台回收页面、切回时重新加载后,干员不再一直显示为方块加水滴的占位图标:素材清单直接沿用已载入的游戏数据,下载失败会重试,模型下载失败或超时也会重试;在后台开始的战斗切回时干员直接显示模型(GitHub #8)。 ### 人机 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a9479404..ded2443d 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.26) 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.26). 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.26). 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.26** (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 | @@ -1404,3 +1405,15 @@ Not done (noted, no primary source or out of scope): whether 华法琳 S2's 流 - **The prep dispatcher's equipped items** (pointed out in PR #2, whose 突变细胞 rule is superseded by §21.1): step 5 iterated `holder.items` live while handlers move and destroy pieces — normal 博士投影 destroys itself at the round start (the splice skipped the next item on that holder), 突变细胞 transforms its holder (the next item ran with a holder that was gone). It now walks a snapshot of the owned chess and of each holder's items and runs an item only while it is still equipped on its still-owned holder; a piece gained or equipped meanwhile waits for the next dispatch (effectsMeta header, META §2.2). No shipped item has a second hook on those events, so play is unchanged; a synthetic spy handler pins the rule (`test/match/feedback1-dispatch-snapshot.test.js`). - **fetch-assets never shrinks the committed manifest by accident** (pointed out in PR #7, whose `data/assets.json`, regenerated on a machine where some downloads failed, was 42 audio entries short — all 42 still resolve upstream): a rebuilt manifest leaves out every entry whose files are missing on that machine. When it would lose an entry of the current `data/assets.json`, the run keeps the current file, prints the entries it would drop (also the report's `droppedEntries` / `manifestWritten`) and exits 1; `--allow-shrink` (or `--prune`) writes it (ASSETS.md; `test/fetch-assets-shrink.test.js`). + +### 21.26 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).