import Phaser from 'phaser'; import { EventBus } from '../../core/EventBus'; import { GameClock } from '../../core/GameClock'; import { SeededRNG } from '../../core/SeededRNG'; import { StubBeatClock } from '../../core/StubBeatClock'; import { Meters, freshNightState } from '../../core/meters'; import { drunkStage } from '../../rules/drunk'; import { generatePatron, _resetPatronSerial } from '../../patrons/generator'; import type { NightState } from '../../data/types'; import { HudStub } from '../shared/HudStub'; import { VENUE } from './venueMap'; import { NORMAL_CONE, UV_CONE, inCone, type ConeSpec } from './cone'; import { CrowdSim, type Agent } from './crowdSim'; import { freshPlayer, stepPlayer, type PlayerInput, type PlayerState } from './player'; import { FloorView } from './FloorView'; import { maggotOverdue } from './escalation'; import { layoutPockets, type Pocket } from './patDown'; import { CutOffOverlay } from './overlays/CutOffOverlay'; import { StallOverlay } from './overlays/StallOverlay'; import { PatDownOverlay } from './overlays/PatDownOverlay'; import { HELP_TEXT, NO_STAMP_RADIO } from '../../data/strings/floor'; import { MeterHud } from '../../ui/MeterHud'; import type { NightContext } from '../door/NightScene'; import type { Patron } from '../../data/types'; const VIEW_H = 360; const INTERACT_RANGE = 46; const MAX_CROWD = 44; const SPAWN_EVERY_MS = 420; /** Admitted at the rope → appears on the floor after the walk in. */ const WALK_IN_MS: [number, number] = [6000, 14000]; /** What pressing E on the current target would do. */ type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit'; interface InteractTarget { kind: InteractKind; agent?: Agent; stallId?: string; prompt: string; } /** * The venue floor. Two modes, one scene class: * - 'FloorDemo' (standalone): self-populates from the patron generator, owns its * own bus/clock/meters. The Phase-1 demo, unchanged. * - 'Floor' (night mode): launched by NightScene with a NightContext. Shares the * night's bus/clock/state, is populated by ACTUAL door admissions, runs its * sim all night (even while the player works the rope), and hands the player * back to the door via the exit. */ export class FloorDemoScene extends Phaser.Scene { private bus!: EventBus; private rng!: SeededRNG; private clock!: GameClock; private beat: StubBeatClock | null = null; private meters: Meters | null = null; private night!: NightState; private hud!: HudStub | MeterHud; private nightCtx: NightContext | null = null; /** In night mode: is the player physically on the floor right now? */ private present = true; private elapsedMs = 0; private pendingSpawns: Array<{ patron: Patron; atMs: number }> = []; private ctxOffs: Array<() => void> = []; private view!: FloorView; private crowd!: CrowdSim; private player!: PlayerState; private cutOff!: CutOffOverlay; private stall!: StallOverlay; private patDown!: PatDownOverlay; private keys!: Record; private uvMode = false; private seed = 4207; private spawnAccMs = 0; private escorting: Agent | null = null; private target: InteractTarget | null = null; /** patronId -> clock minute we first saw them as a maggot (deferred heat risk). */ private maggotSeen = new Map(); private spotted = new Set(); private promptText!: Phaser.GameObjects.Text; private statusText!: Phaser.GameObjects.Text; private radioText!: Phaser.GameObjects.Text; private radioMs = 0; constructor(key: string = 'FloorDemo') { super(key); } create(data?: { night?: NightContext }): void { this.nightCtx = data?.night ?? null; this.events.once('shutdown', () => this.shutdown()); this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16); this.cameras.main.setBackgroundColor('#04040a'); this.view = new FloorView(this, VENUE); this.cutOff = new CutOffOverlay(this); this.patDown = new PatDownOverlay(this); this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0'); this.statusText = this.fixedText(4, VIEW_H - 12, '#556'); this.radioText = this.fixedText(4, 26, '#d8a020'); this.fixedText(4, 14, '#445').setText(HELP_TEXT); this.keys = this.input.keyboard!.addKeys( 'W,A,S,D,UP,DOWN,LEFT,RIGHT,E,TAB,SPACE,ESC,R', ) as Record; // Overlays own no keyboard listeners of their own — the scene routes keys so // exactly one thing consumes input at a time. this.input.keyboard!.on('keydown', (ev: KeyboardEvent) => this.onKey(ev)); if (this.nightCtx) this.bindNight(this.nightCtx); else this.resetRun(this.seed); } /** Night mode: adopt the night's world instead of building our own. */ private bindNight(ctx: NightContext): void { this.seed = ctx.seed; this.bus = ctx.bus; this.rng = ctx.rng; this.clock = ctx.clock; this.night = ctx.state; this.beat = null; // beat:tick arrives on the shared bus (stub, then TechnoEngine) this.meters = null; // NightScene owns the single writer this.hud = new MeterHud(this, this.bus, { initialHeat: this.night.heatStrikes.length }); this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs); this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') }); const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 }; this.player = freshPlayer(entry.x, entry.y); this.escorting = null; this.target = null; this.uvMode = false; this.present = false; // the night starts at the rope this.elapsedMs = 0; this.pendingSpawns = []; this.maggotSeen.clear(); this.spotted.clear(); this.ctxOffs = [ // The floor is fed by the door: every admission walks in a few seconds later. this.bus.on('door:verdict', ({ patron, verdict }) => { if (verdict !== 'admit') return; const delay = this.rng.stream('floorArrive').int(WALK_IN_MS[0], WALK_IN_MS[1]); this.pendingSpawns.push({ patron, atMs: this.elapsedMs + delay }); }), this.bus.on('night:phaseChange', ({ location }) => { this.present = location === 'floor'; if (this.present) this.target = null; }), this.bus.on('beat:tick', ({ beatIndex }) => this.crowd.onBeat(beatIndex)), ]; } private fixedText(x: number, y: number, colour: string): Phaser.GameObjects.Text { return this.add .text(x, y, '', { fontFamily: 'monospace', fontSize: '8px', color: colour }) .setScrollFactor(0) .setDepth(4000); } private resetRun(seed: number): void { this.seed = seed; _resetPatronSerial(); this.crowd?.destroy(); this.stall?.destroy(); this.bus?.removeAll(); this.meters?.destroy(); this.hud?.destroy(); this.bus = new EventBus(); this.rng = new SeededRNG(seed); this.clock = new GameClock(this.bus); this.beat = new StubBeatClock(this.bus); this.night = freshNightState('voltage', 0, 200); this.night.location = 'floor'; this.meters = new Meters(this.bus, this.night); this.hud = new HudStub(this, this.bus); this.stall = new StallOverlay(this, this.bus, this.beat.beatIntervalMs); this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') }); this.bus.on('beat:tick', ({ beatIndex }) => this.crowd.onBeat(beatIndex)); const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 }; this.player = freshPlayer(entry.x, entry.y); this.escorting = null; this.target = null; this.uvMode = false; this.spawnAccMs = 0; this.maggotSeen.clear(); this.spotted.clear(); this.clock.start(); this.beat.start(); } // ---- input ------------------------------------------------------------- private onKey(ev: KeyboardEvent): void { const key = ev.key === ' ' ? 'SPACE' : ev.key.toUpperCase(); if (this.cutOff.isOpen) return this.cutOff.handleKey(key); if (this.stall.isOpen) return this.stall.handleKey(key); if (this.patDown.isOpen) return this.patDown.handleKey(key); if (key === 'TAB') { ev.preventDefault(); this.uvMode = !this.uvMode; return; } if (key === 'R' && !this.nightCtx) return this.resetRun(this.seed + 1); if (key === 'E') return this.interact(); } private get overlayOpen(): boolean { return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen; } private readInput(): PlayerInput { const k = this.keys; const down = (a?: Phaser.Input.Keyboard.Key, b?: Phaser.Input.Keyboard.Key) => Boolean(a?.isDown || b?.isDown); return { up: down(k['W'], k['UP']), down: down(k['S'], k['DOWN']), left: down(k['A'], k['LEFT']), right: down(k['D'], k['RIGHT']), }; } // ---- the loop ---------------------------------------------------------- override update(_time: number, deltaMs: number): void { const dt = Math.min(deltaMs, 50); // These run REGARDLESS of overlays. The music doesn't stop because you // opened a cubicle door — and the stall minigame judges against beat:tick, // so freezing the beat clock behind a modal makes it literally unplayable. // The night clock keeps running too: time is the one thing you never get back. // (In night mode NightScene drives both; this scene just consumes the bus.) if (!this.nightCtx) { this.clock.update(dt); this.beat?.update(dt); } this.elapsedMs += dt; if (this.nightCtx) { for (let i = this.pendingSpawns.length - 1; i >= 0; i--) { const s = this.pendingSpawns[i]!; if (this.elapsedMs < s.atMs) continue; this.pendingSpawns.splice(i, 1); // Licensed capacity is 80 but 44 agents is the sim's perf budget; past // that, arrivals dissolve into the (conceptual) back room. log()-style // honesty: this is a cap, not a simulation. if (this.crowd.agents.length < MAX_CROWD) this.crowd.spawn(s.patron); } // Player at the door: the floor keeps living (drinks keep being drunk, // stalls keep filling, maggots keep ripening → radio pressure), but // nothing needs the camera, the cone, or input. if (!this.present) { this.crowd.update(dt, this.clock.clockMin); this.trackMaggots(); return; } } if (this.overlayOpen) { this.cutOff.update(dt); this.stall.update(dt); this.patDown.update(dt); // World keeps breathing behind the panel, but you can't move while you're // dealing with someone — that's the point of the mechanic. this.crowd.update(dt, this.clock.clockMin); this.view.syncAgents(this.crowd.agents); return; } // Escorting costs you the walk: no interacting, slower going. const input = this.readInput(); this.player = stepPlayer(this.player, input, dt, VENUE); if (!this.nightCtx) { // Demo mode stands in for the door with a synthetic spawner. this.spawnAccMs += dt; while (this.spawnAccMs > SPAWN_EVERY_MS && this.crowd.agents.length < MAX_CROWD) { this.spawnAccMs -= SPAWN_EVERY_MS; this.spawnOne(); } } if (this.hud instanceof MeterHud) this.hud.update(dt); this.crowd.update(dt, this.clock.clockMin); if (this.escorting) this.tickEscort(dt); this.trackMaggots(); this.target = this.escorting ? null : this.findTarget(); this.cameras.main.centerOn(this.player.x, this.player.y); this.view.updatePlayer(this.player.x, this.player.y, this.player.facing); this.view.updateCone(this.coneSpec(), this.uvMode); this.view.syncAgents(this.crowd.agents); this.view.setInteractTarget(this.target?.agent ?? null); this.drawHud(dt); } private coneSpec(): ConeSpec { const c = this.uvMode ? UV_CONE : NORMAL_CONE; return { x: this.player.x, y: this.player.y, facing: this.player.facing, ...c }; } private spawnOne(): void { const patron = generatePatron( { rng: this.rng, nightDate: this.clock.nightDate }, this.clock.clockMin, ); // Standing in for the door phase: most people got stamped on the way in. // The ones who didn't snuck past Kayden — that's the UV infraction. patron.flags.uvStamped = this.rng.stream('floorStamp').chance(0.86); this.crowd.spawn(patron); } // ---- infraction detection --------------------------------------------- private trackMaggots(): void { const now = this.clock.clockMin; for (const a of this.crowd.agents) { if (a.gone || a.handled) continue; if (drunkStage(a.patron.intoxication) !== 'maggot') continue; const seen = this.maggotSeen.get(a.patron.id); if (seen === undefined) { this.maggotSeen.set(a.patron.id, now); this.spot(a.patron.id, 'drunk'); } else if (maggotOverdue(seen, now)) { this.maggotSeen.set(a.patron.id, now); // re-arm so it logs once per grace period this.logIncident('maggotUnhandled', a.patron.id, 'Left on the floor past the grace period.'); } } } private spot(patronId: string, kind: 'drunk' | 'stall' | 'contraband' | 'noStamp'): void { const key = `${patronId}:${kind}`; if (this.spotted.has(key)) return; this.spotted.add(key); this.bus.emit('floor:infractionSpotted', { patronId, kind }); } private findTarget(): InteractTarget | null { const spec = this.coneSpec(); // Night mode: the way back out to the rope is itself an interaction. if (this.nightCtx) { const entry = VENUE.anchors.entry[0]; if (entry) { const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y); if (d <= INTERACT_RANGE) return { kind: 'exit', prompt: 'E — back out to the door' }; } } // Stall doors first — you interact with the door, not a person. for (const s of VENUE.stalls) { const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.door.x, s.door.y); if (d > INTERACT_RANGE) continue; const occupants = this.crowd.stallOccupancy().get(s.id) ?? []; if (occupants.length < 2) continue; this.spot(s.id, 'stall'); return { kind: 'stall', stallId: s.id, prompt: 'E — two pairs of feet under that door' }; } let best: Agent | null = null; let bestDist = INTERACT_RANGE; for (const a of this.crowd.agents) { if (a.gone || a.handled || a.activity === 'inStall' || a.activity === 'escorted') continue; if (!inCone(spec, a.x, a.y)) continue; const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, a.x, a.y); if (d < bestDist) { bestDist = d; best = a; } } if (!best) return null; // UV mode is a different lens on the same crowd: it only surfaces stamps. if (this.uvMode) { if (best.patron.flags.uvStamped) return null; this.spot(best.patron.id, 'noStamp'); return { kind: 'noStamp', agent: best, prompt: 'E — no stamp. Walk them back to the door' }; } const stage = drunkStage(best.patron.intoxication); if (stage === 'messy' || stage === 'maggot' || stage === 'loose') { return { kind: 'drunk', agent: best, prompt: 'E — have a word' }; } if (best.patron.flags.contraband?.length) { this.spot(best.patron.id, 'contraband'); return { kind: 'contraband', agent: best, prompt: 'E — pat them down' }; } return null; } // ---- interactions ------------------------------------------------------ private interact(): void { const t = this.target; if (!t || this.escorting) return; if (t.kind === 'exit') return this.nightCtx?.requestLocation('door'); if (t.kind === 'stall' && t.stallId) return this.openStall(t.stallId); if (!t.agent) return; if (t.kind === 'noStamp') return this.startEscort(t.agent, 'clean', 'noStamp'); if (t.kind === 'drunk') return this.openCutOff(t.agent); if (t.kind === 'contraband') return this.openPatDown(t.agent); } private openStall(stallId: string): void { const occupants = this.crowd.stallOccupancy().get(stallId) ?? []; this.stall.open( { stallId, patrons: occupants.map((a) => a.patron) }, (r) => { this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta }); if (r.solved) { // Zeroing the dwell makes the sim's own stall-exit path fire next // frame: both get put back at the door and independently re-target, // which reads exactly like scattering in opposite directions. for (const a of occupants) { a.handled = true; a.dwellMs = 0; } this.logIncident('stallBusted', occupants[0]?.patron.id, 'Two out of one cubicle.'); } }, ); } private openCutOff(agent: Agent): void { const stage = drunkStage(agent.patron.intoxication); this.cutOff.open({ patron: agent.patron, stage }, (r) => { if (!r) return; this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta }); if (r.resolved) agent.handled = true; if (r.eject) { this.startEscort(agent, r.outcome === 'clean' ? 'clean' : 'messy', 'drunk'); } else { this.logIncident('cutOff', agent.patron.id, `${stage} — ${r.outcome}`); } }); } private openPatDown(agent: Agent): void { const pockets: Pocket[] = layoutPockets(agent.patron, this.rng.stream('patdown')); this.patDown.open({ patron: agent.patron, pockets }, (r) => { this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta }); agent.handled = true; this.logIncident('patDown', agent.patron.id, r.summary); if (r.eject) this.startEscort(agent, 'clean', 'contraband'); }); } private startEscort(agent: Agent, style: 'clean' | 'messy', kind: InteractKind): void { if (!this.crowd.beginEscort(agent)) return; agent.handled = true; this.escorting = agent; if (kind === 'noStamp') { this.radio(NO_STAMP_RADIO[this.rng.stream('radio').int(0, NO_STAMP_RADIO.length - 1)] ?? ''); } this.bus.emit('floor:ejection', { patronId: agent.patron.id, style }); this.logIncident('ejection', agent.patron.id, `${kind} / ${style}`); } private tickEscort(dt: number): void { const a = this.escorting; if (!a) return; this.crowd.updateEscorts(this.player.x, this.player.y, dt); if (a.gone) { this.escorting = null; this.bus.emit('meters:delta', { vibe: 1 }); } } // ---- chrome ------------------------------------------------------------ private radio(line: string): void { this.radioText.setText(line ? `KAYDEN: ${line}` : ''); this.radioMs = 5000; } private logIncident(kind: string, patronId: string | undefined, detail: string): void { this.bus.emit('incident:log', { clockMin: this.clock.clockMin, kind, patronId, detail }); } private drawHud(dt: number): void { if (this.radioMs > 0) { this.radioMs -= dt; if (this.radioMs <= 0) this.radioText.setText(''); } this.promptText.setText( this.escorting ? 'escorting — walk them to the door (no interacting)' : (this.target?.prompt ?? ''), ); const maggots = this.crowd.agents.filter( (a) => !a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot', ).length; this.statusText.setText( `${this.clock.label} seed ${this.seed} crowd ${this.crowd.agents.length} maggots ${maggots} ${this.uvMode ? 'UV' : 'torch'} incidents ${this.night.incidents.length}`, ); } shutdown(): void { for (const off of this.ctxOffs) off(); this.ctxOffs = []; this.crowd?.destroy(); this.view?.destroy(); this.cutOff?.destroy(); this.stall?.destroy(); this.patDown?.destroy(); // Night mode: the bus/meters belong to NightScene — touch nothing shared. this.meters?.destroy(); this.hud?.destroy(); } }