Hide the load behind the roster board

~130 sprites meant several seconds of blank screen after picking a shift.
RosterScene now warms the prop textures while you read the cards — opportunistic
and non-blocking, so clicking through early is no worse than before. Manifest via
plain fetch to avoid the nested load.once(COMPLETE) pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-06 19:59:07 +10:00
parent 8bd219796a
commit 442efa4c3c
2 changed files with 81 additions and 0 deletions

View File

@ -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.

View File

@ -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();
}
}