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>
184 lines
6.5 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|
|
}
|