not-tonight/src/scenes/shared/ParadeScene.ts
type-two 3eee8432d4 LANE-FLOOR: the Floor — patrol, flashlight/UV, cut-off, stalls, pat-down
FloorDemoScene (F from Parade): an 80x45-tile venue you patrol in the dark with
a flashlight cone that reveals detail, plus all three v0.2 infractions and the
UV stamp check. Self-populates from the patron generator; consumes Patron[] only,
so Phase 2 can hand it the real admitted list.

Pure, tested logic (venueMap, cone, player, crowdSim, bangJudge, escalation,
patDown); Phaser layer stays thin (FloorView + three overlays). 120 new tests,
169 total.

Two bugs found by running it rather than by the suite, both fixed:
- bump penalties had a per-agent cooldown but no sim-wide bound, so the leak rate
  scaled with crowd size and an unattended venue pinned vibe to 0 / aggro to 100
  by ~1AM. Sim-wide gate added, with a regression test.
- beat.update() sat below the overlay early-return, freezing the beat clock behind
  modals and making the stall rhythm game unwinnable. Clock and beat now always run.

Also retuned dwell times and the tile/darkness palette for readability, and
dropped Light2D in favour of the cone mask it was fighting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:49:13 +10:00

184 lines
6.5 KiB
TypeScript

import Phaser from 'phaser';
import { EventBus } from '../../core/EventBus';
import { GameClock } from '../../core/GameClock';
import { SeededRNG } from '../../core/SeededRNG';
import { Meters, freshNightState } from '../../core/meters';
import { drunkStage } from '../../rules/drunk';
import { generatePatron, idAge, _resetPatronSerial } from '../../patrons/generator';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import type { Patron } from '../../data/types';
import { HudStub } from './HudStub';
const W = 640;
const H = 360;
interface Walker {
patron: Patron;
root: Phaser.GameObjects.Container;
doll: Phaser.GameObjects.Image;
speed: number;
}
// Phase-0 proof scene: generator + doll renderer + RNG determinism + clock all
// visibly working. Patrons stream across a rainy street; their ID card renders
// from the same data record. SPACE reseeds, D toggles ID cards.
export class ParadeScene extends Phaser.Scene {
private bus!: EventBus;
private clock!: GameClock;
private rng!: SeededRNG;
private meters!: Meters;
private hud!: HudStub;
private walkers: Walker[] = [];
private seed = 4207;
private spawnAccMs = 0;
private showIds = true;
private seedText!: Phaser.GameObjects.Text;
private clockText!: Phaser.GameObjects.Text;
constructor() {
super('Parade');
}
create(): void {
this.buildWorld();
this.resetRun(this.seed);
this.input.keyboard?.on('keydown-SPACE', () => this.resetRun(this.seed + 1));
this.input.keyboard?.on('keydown-F', () => this.scene.start('FloorDemo')); // LANE-FLOOR demo
this.input.keyboard?.on('keydown-D', () => {
this.showIds = !this.showIds;
for (const w of this.walkers) {
(w.root.getByName('card') as Phaser.GameObjects.Container | null)?.setVisible(this.showIds);
}
});
}
private buildWorld(): void {
// street
this.add.rectangle(W / 2, H - 40, W, 80, 0x1a1a24); // footpath
this.add.rectangle(W / 2, H / 2 - 40, W, H - 160, 0x0d0d16); // wall of night
// kebab shop glow, stage left — the moral centre of the game
this.add.rectangle(70, 120, 120, 90, 0x2a1c08);
this.add.rectangle(70, 96, 100, 18, 0xd8a020, 0.9);
this.add.text(34, 90, 'KEBABS', { fontFamily: 'monospace', fontSize: '12px', color: '#3a2404' });
this.add.rectangle(70, 150, 100, 40, 0xe8c060, 0.12);
// venue door, stage right
this.add.rectangle(W - 60, 130, 90, 110, 0x181828);
this.add.rectangle(W - 60, 90, 70, 14, 0xd03470, 0.9);
this.add.text(W - 88, 84, 'NOT TONIGHT', { fontFamily: 'monospace', fontSize: '9px', color: '#2a0818' });
this.add.rectangle(W - 60, 150, 26, 54, 0x05050a);
// neon reflections on the footpath
this.add.rectangle(70, H - 60, 90, 6, 0xd8a020, 0.15);
this.add.rectangle(W - 60, H - 60, 60, 6, 0xd03470, 0.15);
// rain
const rainG = this.add.graphics();
rainG.fillStyle(0x8899bb, 1);
rainG.fillRect(0, 0, 1, 4);
rainG.generateTexture('rainDrop', 1, 4);
rainG.destroy();
this.add.particles(0, 0, 'rainDrop', {
x: { min: 0, max: W },
y: -6,
lifespan: 900,
speedY: { min: 220, max: 300 },
speedX: { min: -30, max: -10 },
quantity: 2,
alpha: { start: 0.5, end: 0.15 },
});
this.seedText = this.add.text(4, H - 12, '', { fontFamily: 'monospace', fontSize: '8px', color: '#667' });
this.clockText = this.add.text(W - 60, 4, '', { fontFamily: 'monospace', fontSize: '8px', color: '#9fe8a0' });
this.add.text(4, 14, 'SPACE reseed · D toggle IDs · F floor demo', { fontFamily: 'monospace', fontSize: '8px', color: '#556' });
}
private resetRun(seed: number): void {
this.seed = seed;
for (const w of this.walkers) w.root.destroy();
this.walkers = [];
_resetPatronSerial();
this.bus?.removeAll();
this.bus = new EventBus();
this.rng = new SeededRNG(seed);
this.clock = new GameClock(this.bus);
this.meters?.destroy();
this.meters = new Meters(this.bus, freshNightState('theRoyal', 0, 80));
this.hud?.destroy();
this.hud = new HudStub(this, this.bus);
this.clock.start();
this.bus.on('clock:tick', () => {
// idle proof that the meter pipeline works: hype breathes with the clock
this.bus.emit('meters:delta', { vibe: 0 });
});
this.seedText.setText(`seed ${seed}`);
}
private spawnWalker(): void {
const patron = generatePatron({ rng: this.rng, nightDate: this.clock.nightDate }, this.clock.clockMin);
const plan = dollPlan(patron, 'queue');
const key = renderDoll(this, patron, 'queue');
const y = H - 78 + (this.walkers.length % 5) * 6;
const root = this.add.container(-40, y);
const doll = this.add.image(0, 0, key).setOrigin(0.5, 1);
root.add(doll);
const stage = drunkStage(patron.intoxication);
const label = this.add
.text(0, 4, `${patron.archetype} ${patron.age} ${stage !== 'sober' ? stage : ''}`.trim(), {
fontFamily: 'monospace',
fontSize: '7px',
color: stage === 'maggot' || stage === 'messy' ? '#e06060' : '#556',
})
.setOrigin(0.5, 0);
root.add(label);
const card = this.add.container(0, -plan.height - 18);
card.setName('card');
const idKey = renderDoll(this, patron, 'idPhoto');
card.add(this.add.rectangle(0, 0, 58, 30, 0xdad4c4).setStrokeStyle(1, 0x555555));
card.add(this.add.image(-22, 0, idKey).setScale(1));
const fakeMark = patron.idCard.fake ? ' ?' : '';
card.add(
this.add
.text(-8, -11, `${patron.idCard.name}\nID ${idAge(patron, this.clock.nightDate)}${fakeMark}`, {
fontFamily: 'monospace',
fontSize: '7px',
color: '#222',
})
.setOrigin(0, 0),
);
card.setVisible(this.showIds);
root.add(card);
root.setDepth(y);
this.walkers.push({ patron, root, doll, speed: 26 + (patron.dollSeed % 20) - plan.swayPx * 5 });
}
override update(_time: number, deltaMs: number): void {
this.clock.update(deltaMs);
this.clockText.setText(this.clock.label);
this.spawnAccMs += deltaMs;
const spawnEvery = 700;
while (this.spawnAccMs > spawnEvery && this.walkers.length < 24) {
this.spawnAccMs -= spawnEvery;
this.spawnWalker();
}
for (const w of [...this.walkers]) {
w.root.x += (w.speed * deltaMs) / 1000;
const sway = dollPlan(w.patron, 'queue').swayPx;
if (sway > 0) w.doll.x = Math.sin(w.root.x / (14 - sway * 3)) * sway;
if (w.root.x > W + 40) {
w.root.destroy();
this.walkers.splice(this.walkers.indexOf(w), 1);
}
}
}
}