not-tonight/src/scenes/floor/FloorDemoScene.ts

531 lines
20 KiB
TypeScript

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<string, Phaser.Input.Keyboard.Key>;
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<string, number>();
private spotted = new Set<string>();
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<string, Phaser.Input.Keyboard.Key>;
// 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();
}
}