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 { DrunkStage, 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 { FightDirector } from './fightDirector'; 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]; const STAGE_RANK: Readonly> = { sober: 0, tipsy: 1, loose: 2, messy: 3, maggot: 4, }; /** 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 fights!: FightDirector; 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 liveFight: { id: string; cause: string } | null = null; /** Worst drunk stage we've already had words about, per patron. */ private cutOffDealt = new Map(); private promptText!: Phaser.GameObjects.Text; private statusText!: Phaser.GameObjects.Text; private radioText!: Phaser.GameObjects.Text; private radioMs = 0; private calmAccMs = 0; constructor(key: string = 'FloorDemo') { super(key); } create(data?: { night?: NightContext }): void { this.nightCtx = data?.night ?? null; // Wired explicitly: Phaser does not invoke a `shutdown` METHOD on a Scene // subclass; without this, listeners and sprites leak on every scene stop. this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.teardown()); 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') }); this.fights?.destroy(); this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd }); const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 }; this.player = freshPlayer(entry.x, entry.y); this.view.reset(); this.escorting = null; this.target = null; this.liveFight = 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.cutOffDealt.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.fights?.destroy(); this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.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.view.reset(); this.escorting = null; this.target = null; this.liveFight = null; this.uvMode = false; this.spawnAccMs = 0; this.maggotSeen.clear(); this.spotted.clear(); this.cutOffDealt.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. Counts LIVE bodies (ejections // must not permanently shrink the venue). if (this.liveCrowd < 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); // Fights don't wait for you. An off-map player position means the // interpose check can never accidentally succeed — unattended fights // swing, and the incident radios the door. this.fights.update(-9999, -9999, dt); this.tickFight(); this.trackMaggots(); this.tickCalm(dt); return; } } // Fights burn their fuse through overlays too. You can't be in a cubicle and // between two blokes at once — choosing which one to abandon IS the floor. // ESC is always the way out of a modal, so it's a decision, not a trap. this.fights.update(this.player.x, this.player.y, dt); this.tickFight(); 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. // CrowdSim deliberately keeps departed agents around so find() still works, // so the cap has to count LIVE bodies — otherwise every ejection permanently // shrinks the venue and the floor drains to empty over the night. this.spawnAccMs += dt; while (this.spawnAccMs > SPAWN_EVERY_MS && this.liveCrowd < 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.tickCalm(dt); 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); } /** * Aggro physiology (Phase-1 floor request, approved): a calm floor slowly * cools the night — whether or not the player is watching it. "Calm" means no * fight brewing and no unhandled maggot; the drift is deliberately slower * than any single mistake so it reads as breathing, not forgiveness. * ~0.3 aggro per clock-minute (1 clock-min ≈ 2.17s real at DEFAULT_CLOCK). */ private tickCalm(dt: number): void { if (this.fights.active) { this.calmAccMs = 0; return; } for (const a of this.crowd.agents) { if (!a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot') { this.calmAccMs = 0; return; } } this.calmAccMs += dt; if (this.calmAccMs >= 1000) { this.calmAccMs -= 1000; this.bus.emit('meters:delta', { aggro: -0.14 }); } } /** Have we already dealt with this patron at their current level of drunk? */ private settledAt(agent: Agent, stage: DrunkStage): boolean { const dealt = this.cutOffDealt.get(agent.patron.id); return dealt !== undefined && STAGE_RANK[stage] <= STAGE_RANK[dealt]; } private get liveCrowd(): number { let n = 0; for (const a of this.crowd.agents) if (!a.gone) n++; return n; } 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; if (this.settledAt(a, '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; // Report a real patron, not the cubicle: `patronId` is a frozen contract // field and a stall id resolves to nobody. Keying on the occupant also // means a fresh pair in the same cubicle reports again later in the night. const first = occupants[0]; if (first) this.spot(first.patron.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') { // Dealing with someone settles that stage, not the whole night. The Quiet // Messy One you sensibly watered at 'loose' has to come back around when // they hit 'maggot' — otherwise a correct early call blinds you to them. if (!this.settledAt(best, stage)) { 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) { // Re-query: the crowd keeps simulating behind the modal, so whoever was // in there when you started banging may have wandered off. Scattering // the captured list would retire innocent patrons standing elsewhere. const inThere = this.crowd.stallOccupancy().get(stallId) ?? []; // 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 inThere) { a.handled = true; a.dwellMs = 0; } this.logIncident('stallBusted', inThere[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 }); // Only an ejection retires someone for good. A resolved-but-staying patron // is settled at THIS stage and becomes your problem again if they get worse. if (r.resolved && !r.eject) this.cutOffDealt.set(agent.patron.id, stage); 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}`); } /** Draw the brewing-fight tell and narrate the resolution when one lands. */ private tickFight(): void { const f = this.fights.active; const pair = this.fights.pair(); this.view.setFight(pair, f?.shoves ?? 0, f?.msLeft ?? 0); // The director releases both agents the instant it resolves, so pair() and // cause are already gone by the time we're told about it — remember them // while the fight is still live. if (pair) this.liveFight = { id: pair[0].patron.id, cause: this.fights.cause }; const res = this.fights.takeResolution(); if (!res) return; const was = this.liveFight; this.liveFight = null; this.toast(res.line); this.logIncident('fight', was?.id, `${res.phase === 'broken' ? 'broken up' : 'swung'} — ${was?.cause ?? ''}`); } 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.toast(line ? `KAYDEN: ${line}` : ''); } /** Transient line in the corner — radio chatter, fight outcomes, anything shouted. */ private toast(line: string): void { this.radioText.setText(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(''); } // A brewing fight outranks everything — it's the one thing on a clock. this.promptText.setText( this.fights.active ? 'GET BETWEEN THEM' : 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.liveCrowd} maggots ${maggots} ${this.uvMode ? 'UV' : 'torch'} incidents ${this.night.incidents.length}`, ); } private teardown(): void { for (const off of this.ctxOffs) off(); this.ctxOffs = []; this.fights?.destroy(); 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(); } }