diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index ba3fe70..17cfeae 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -2432,3 +2432,36 @@ paperwork. Tweens here decorate; `update()` owns the state machine. The desk handed off to NightSummary while **staying active underneath it** — nobody stopped it. Same class as the dev-route bug that left a live Night driving a dead Door. `go()` now stops itself before calling `next()`. + +## SESSION — FABLE-SOLO-27 · 2026-07-22 + +**Branch:** main (solo, Fable — sole operator from here) · **Gate:** lint ✓ build ✓ test ✓ (831 tests, 57 files) + +### The blank screen after picking a shift +The art batch is ~130 sprites now and NightScene loads every one of them before +it launches Door and Floor. Cold, that is several seconds of BLANK SCREEN after +you pick a card, with nothing to say the game had not died. (It also fooled me +mid-verification into calling a regression on main that was never there — the +loader was simply still working, progress climbing 0.25 → 0.5 → 0.76 → 1.) + +Fix: **RosterScene prewarms the prop textures while you read the board.** You +are looking at three shift cards anyway; by the time you pick one the room is +already loaded and Door/Floor come up at once. A quiet line under the board +reads `unlocking the doors · 64%` → `the room is ready`. + +- Opportunistic, never blocking: the cards are live immediately, and clicking + through early just leaves NightScene to load the rest exactly as before. It + is never worse than it was, only usually better. +- The manifest is fetched with plain `fetch()`, not the Phaser loader, so there + is no `load.once(COMPLETE)` registered from inside another COMPLETE handler — + that nested pattern is what NightScene already has to live with, and it is + the kind of thing that works until an asset count changes. +- Textures are game-global, so anything warmed here counts for the night. +- No manifest at all stays a supported state (placeholders, silence). + +### Git hygiene check (asked for, and worth recording) +Working tree clean · HEAD == origin/main exactly (0 ahead, 0 behind) · no +stashes · two dangling commits are pre-rebase copies of my own work, and every +file in them verified byte-identical on HEAD or differing only where the other +session also edited · all 14 feature markers from this session's work confirmed +present · deployed bundle hash matches the local build. diff --git a/src/scenes/shared/RosterScene.ts b/src/scenes/shared/RosterScene.ts index 3f0eecc..937ae91 100644 --- a/src/scenes/shared/RosterScene.ts +++ b/src/scenes/shared/RosterScene.ts @@ -14,6 +14,10 @@ const H = 360; const MONO = 'monospace'; const NIGHT_NAMES = ['thursday', 'friday', 'saturday'] as const; +/** Quiet, because it is not the player's problem — just proof of life. */ +const PREWARM = 'unlocking the doors · {pct}%'; +const PREWARM_DONE = 'the room is ready'; + const CARD_W = 168; const CARD_H = 150; @@ -25,6 +29,7 @@ interface PassThrough { export class RosterScene extends Phaser.Scene { private carried: PassThrough = {}; + private prewarm!: Phaser.GameObjects.Text; constructor() { super('Roster'); @@ -34,6 +39,44 @@ export class RosterScene extends Phaser.Scene { this.carried = data ?? {}; } + /** + * Warm the prop textures while the player reads the board. + * + * The art batch is ~130 sprites now, and NightScene loads them all before it + * launches Door and Floor — which meant several seconds of BLANK SCREEN after + * you picked a shift, with nothing to say the game hadn't died. The roster is + * the natural place to hide that: you are reading three cards anyway. + * + * Opportunistic, never blocking. The cards are live immediately; click + * through early and NightScene loads whatever is left exactly as before. + * Textures are game-global, so anything that lands here counts there. + */ + private prewarmProps(): void { + // Plain fetch rather than the Phaser loader for the manifest: chaining a + // second `load.once(COMPLETE)` from inside the first COMPLETE handler is + // the fragile pattern NightScene already has to live with. One queue, one + // start, no nesting. + void fetch('props/manifest.json') + .then((r) => (r.ok ? r.json() : null)) + .then((m: { props?: string[] } | null) => { + if (!m || !this.scene.isActive()) return; + const missing = (m.props ?? []).filter((k) => !this.textures.exists(`gen:prop:${k}`)); + if (missing.length === 0) return; + for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`); + this.load.on(Phaser.Loader.Events.PROGRESS, (v: number) => { + if (this.prewarm.active) this.prewarm.setText(PREWARM.replace('{pct}', String(Math.round(v * 100)))); + }); + this.load.once(Phaser.Loader.Events.COMPLETE, () => { + if (this.prewarm.active) this.prewarm.setText(PREWARM_DONE); + }); + this.load.start(); + }) + .catch(() => { + // No manifest at all is a supported state — the game renders + // placeholders and says nothing about it. Same here. + }); + } + create(): void { // Display only — NightScene remains the authority on run state (it will // clearSave on freshRun itself; we just show the board that run implies). @@ -95,5 +138,10 @@ export class RosterScene extends Phaser.Scene { fontFamily: MONO, fontSize: '7px', color: '#6a5a44', }) .setOrigin(0.5); + + this.prewarm = this.add + .text(W / 2, H - 32, '', { fontFamily: MONO, fontSize: '7px', color: '#5a4c38' }) + .setOrigin(0.5); + this.prewarmProps(); } }