531 lines
20 KiB
TypeScript
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();
|
|
}
|
|
}
|