import Phaser from 'phaser'; import type { EventBus } from '../../../core/EventBus'; import { PALETTE } from '../../../data/outfits'; import type { Patron } from '../../../data/types'; import { BANGS_TO_BUST, OK_MS, PERFECT_MS, applyBang, freshStall, judgeBang, scoreStall, type StallOutcome, type StallState, } from '../bangJudge'; import { STALL_BUSTED, STALL_MUFFLED, STALL_OFF_BEAT } from '../../../data/strings/floor'; const W = 640; const H = 360; const PINK = 0xd03470; const GREEN = 0x9fe8a0; const AMBER = 0xd8a020; const PURPLE = 0x7b2fd0; const INK = 0x0a0a12; const DOOR_W = 250; const DOOR_H = 160; const DOOR_CX = 320; const DOOR_CY = 145; const DOOR_HINGE_X = DOOR_CX - DOOR_W / 2; const DOOR_BOTTOM = DOOR_CY + DOOR_H / 2; const BAR_W = 380; const BAR_H = 14; const BAR_CX = 320; const BAR_CY = 292; const BUST_HOLD_MS = 1600; /** How far the door swings before it's "open" — a sliver of edge stays visible. */ const DOOR_SWUNG_SCALE = 0.06; interface TickMark { beatIndex: number; /** The tick's arrival stamped on OUR clock, so bangs and beats compare directly. */ atElapsedMs: number; } function shoeColour(p: Patron): number { const layer = p.outfit.find((l) => l.slot === 'shoes'); if (!layer || layer.type === 'none') return 0x14141c; return PALETTE[layer.colour] ?? 0x14141c; } export class StallOverlay { private readonly root: Phaser.GameObjects.Container; private readonly doorNode: Phaser.GameObjects.Container; private readonly barFlash: Phaser.GameObjects.Rectangle; private readonly marker: Phaser.GameObjects.Rectangle; private readonly titleText: Phaser.GameObjects.Text; private readonly gradeText: Phaser.GameObjects.Text; private readonly flavourText: Phaser.GameObjects.Text; private readonly noteText: Phaser.GameObjects.Text; private readonly pips: Phaser.GameObjects.Rectangle[] = []; private readonly feet: Phaser.GameObjects.Rectangle[] = []; private readonly feetBaseX: number[] = []; private readonly offBeat: () => void; private open_ = false; private resolved = true; private done: ((r: StallOutcome) => void) | null = null; private state: StallState = freshStall(); private elapsedMs = 0; private lastTick: TickMark | null = null; private bustHoldMs = 0; private shakeMs = 0; private shakeMag = 0; private flashMs = 0; constructor( scene: Phaser.Scene, bus: EventBus, private readonly beatIntervalMs: number, ) { const t = (x: number, y: number, size: number, colour: string): Phaser.GameObjects.Text => scene.add.text(x, y, '', { fontFamily: 'monospace', fontSize: `${size}px`, color: colour }); this.root = scene.add.container(0, 0); this.root.add(scene.add.rectangle(W / 2, H / 2, W, H, 0x000000, 0.82)); this.root.add(scene.add.rectangle(W / 2, 180, 470, 310, INK).setStrokeStyle(1, PINK, 0.7)); this.titleText = t(105, 40, 9, '#d03470'); this.root.add(this.titleText); // Stall interior: revealed as the door swings away. this.root.add(scene.add.rectangle(DOOR_CX, DOOR_CY, DOOR_W, DOOR_H, 0x05050a)); this.root.add(scene.add.rectangle(DOOR_CX, DOOR_CY - 30, 40, 60, 0x11111a)); // cistern, in shadow // Door hangs off its hinge so the bust can swing it, not slide it. this.doorNode = scene.add.container(DOOR_HINGE_X, DOOR_CY); const face = scene.add.rectangle(DOOR_W / 2, 0, DOOR_W, DOOR_H, 0x2b2018).setStrokeStyle(1, 0x120c08); this.doorNode.add(face); this.doorNode.add(scene.add.rectangle(DOOR_W / 2, -34, DOOR_W - 40, 54, 0x342519)); this.doorNode.add(scene.add.rectangle(DOOR_W / 2, 36, DOOR_W - 40, 54, 0x342519)); this.doorNode.add(scene.add.rectangle(DOOR_W - 26, 0, 14, 5, 0xc0c0c8)); // latch this.doorNode.add(scene.add.rectangle(DOOR_W - 26, -12, 22, 9, 0x8a1020)); // ENGAGED tab this.doorNode.add( scene.add .text(DOOR_W - 26, -12, 'ENGAGED', { fontFamily: 'monospace', fontSize: '5px', color: '#f0c0c8' }) .setOrigin(0.5), ); this.doorNode.add( scene.add .text(DOOR_W / 2, -46, 'DAZZA WOZ HERE', { fontFamily: 'monospace', fontSize: '7px', color: '#5c4630' }) .setOrigin(0.5) .setAngle(-6), ); this.root.add(this.doorNode); // The infraction itself: two pairs of feet in a one-person cubicle. this.root.add(scene.add.rectangle(DOOR_CX, DOOR_BOTTOM + 7, DOOR_W, 14, 0x1c1c24)); for (let i = 0; i < 4; i++) { const pairX = i < 2 ? DOOR_CX - 64 : DOOR_CX + 52; const x = pairX + (i % 2) * 13; this.feetBaseX.push(x); const foot = scene.add.rectangle(x, DOOR_BOTTOM + 6, 9, 6, 0x14141c); this.feet.push(foot); this.root.add(foot); } this.flavourText = t(120, 244, 8, '#c8c8d4').setWordWrapWidth(400); this.root.add(this.flavourText); this.noteText = t(120, 268, 7, '#6a6a80').setWordWrapWidth(400); this.root.add(this.noteText); // Beat bar: the full width is one beat, dead centre is the kick. const zone = (ms: number, colour: number, alpha: number): Phaser.GameObjects.Rectangle => { const frac = beatIntervalMs > 0 ? Math.min(1, (2 * ms) / beatIntervalMs) : 1; return scene.add.rectangle(BAR_CX, BAR_CY, BAR_W * frac, BAR_H, colour, alpha); }; this.root.add(scene.add.rectangle(BAR_CX, BAR_CY, BAR_W, BAR_H, 0x14141c).setStrokeStyle(1, 0x30304a)); this.root.add(zone(OK_MS, AMBER, 0.22)); this.root.add(zone(PERFECT_MS, GREEN, 0.32)); this.barFlash = scene.add.rectangle(BAR_CX, BAR_CY, BAR_W, BAR_H, GREEN, 0); this.root.add(this.barFlash); this.marker = scene.add.rectangle(BAR_CX, BAR_CY, 3, BAR_H + 8, 0xffffff); this.root.add(this.marker); this.gradeText = t(BAR_CX + BAR_W / 2 + 6, BAR_CY - 5, 8, '#9fe8a0'); this.root.add(this.gradeText); for (let i = 0; i < BANGS_TO_BUST; i++) { const pip = scene.add.rectangle(BAR_CX - (BANGS_TO_BUST - 1) * 8 + i * 16, 314, 11, 7, PURPLE, 0.25); this.pips.push(pip); this.root.add(pip); } this.root.add( scene.add .text(BAR_CX, 328, 'SPACE bang on the door · ESC walk away', { fontFamily: 'monospace', fontSize: '7px', color: '#556', }) .setOrigin(0.5, 0), ); this.root.setScrollFactor(0, 0, true); this.root.setDepth(5200); this.root.setVisible(false); this.offBeat = bus.on('beat:tick', (p) => { if (!this.open_) return; this.lastTick = { beatIndex: p.beatIndex, atElapsedMs: this.elapsedMs }; }); } get isOpen(): boolean { return this.open_; } open(args: { stallId: string; patrons: Patron[] }, done: (r: StallOutcome) => void): void { this.done = done; this.resolved = false; this.open_ = true; this.state = freshStall(); this.elapsedMs = 0; this.lastTick = null; this.bustHoldMs = 0; this.shakeMs = 0; this.shakeMag = 0; this.flashMs = 0; this.titleText.setText(`CUBICLE ${args.stallId.toUpperCase()} — occupied. Twice.`); this.flavourText.setText('Two pairs of shoes. One cubicle. Knock on the beat, mate.'); this.noteText.setText(''); this.gradeText.setText(''); this.feet.forEach((foot, i) => { const patron = args.patrons[i < 2 ? 0 : 1]; foot.setVisible(patron !== undefined); foot.setAlpha(1); foot.x = this.feetBaseX[i] ?? foot.x; if (patron) foot.setFillStyle(shoeColour(patron)); }); this.doorNode.setScale(1, 1); this.doorNode.x = DOOR_HINGE_X; this.doorNode.y = DOOR_CY; this.barFlash.setAlpha(0); this.paintPips(); this.root.setVisible(true); } handleKey(key: string): void { if (!this.open_) return; const k = key.toUpperCase(); if (k === 'ESC' || k === 'ESCAPE') return this.resolve(); // Door's already open; the last beat is just theatre. if (this.bustHoldMs > 0) return; if (k === 'SPACE' || k === ' ') this.bang(); } update(deltaMs: number): void { if (!this.open_) return; this.elapsedMs += deltaMs; if (this.shakeMs > 0) this.shakeMs = Math.max(0, this.shakeMs - deltaMs); if (this.flashMs > 0) { this.flashMs = Math.max(0, this.flashMs - deltaMs); this.barFlash.setAlpha((this.flashMs / 180) * 0.5); } if (this.bustHoldMs > 0) { this.bustHoldMs = Math.max(0, this.bustHoldMs - deltaMs); const progress = 1 - this.bustHoldMs / BUST_HOLD_MS; const swing = Math.min(1, progress * 2.2); // door goes fast, the shame lingers this.doorNode.setScale(1 - swing * (1 - DOOR_SWUNG_SCALE), 1); this.feet.forEach((foot, i) => { const base = this.feetBaseX[i] ?? foot.x; foot.x = base + (i < 2 ? -1 : 1) * swing * 70; foot.setAlpha(1 - swing); }); if (this.bustHoldMs === 0) this.resolve(); return; } this.doorNode.x = DOOR_HINGE_X + this.shakeWobble(); this.doorNode.y = DOOR_CY + this.shakeWobble() * 0.4; this.marker.x = BAR_CX - BAR_W / 2 + this.beatPhase() * BAR_W; this.marker.setAlpha(this.lastTick ? 1 : 0.25); } destroy(): void { this.offBeat(); this.done = null; this.open_ = false; this.root.destroy(true); } private bang(): void { const tick = this.lastTick; // No beat authority yet — swinging at silence shouldn't cost you a combo. if (!tick) return; const grade = judgeBang( this.elapsedMs, { beatIndex: tick.beatIndex, audioTimeMs: tick.atElapsedMs }, this.beatIntervalMs, ); const brokeCombo = grade === 'miss' && this.state.combo > 0; this.state = applyBang(this.state, grade); this.shakeMs = grade === 'miss' ? 90 : 220; this.shakeMag = grade === 'miss' ? 1.5 : 4.5; this.flashMs = 180; this.barFlash.setFillStyle(grade === 'perfect' ? GREEN : grade === 'ok' ? AMBER : PINK); this.gradeText.setText(grade.toUpperCase()); this.gradeText.setColor(grade === 'perfect' ? '#9fe8a0' : grade === 'ok' ? '#d8a020' : '#d03470'); this.paintPips(); if (this.state.busted) { this.flavourText.setText(this.line(STALL_BUSTED)); this.noteText.setText(''); this.bustHoldMs = BUST_HOLD_MS; this.gradeText.setText('BUSTED'); return; } if (grade === 'miss') { this.flavourText.setText(this.line(STALL_MUFFLED)); this.noteText.setText(brokeCombo ? STALL_OFF_BEAT : ''); } else { this.flavourText.setText('The door rattles. Something in there stops talking.'); this.noteText.setText(''); } } /** Deterministic line pick — the beat index is the only entropy we're allowed. */ private line(lines: readonly string[]): string { const i = ((this.lastTick?.beatIndex ?? 0) + this.state.bangs) % lines.length; return lines[i] ?? lines[0] ?? ''; } /** 0..1 across the bar, offset so a kick lands the marker dead centre. */ private beatPhase(): number { if (!this.lastTick || !(this.beatIntervalMs > 0)) return 0.5; const since = (this.elapsedMs - this.lastTick.atElapsedMs) / this.beatIntervalMs; return ((since % 1) + 1.5) % 1; } private shakeWobble(): number { if (this.shakeMs <= 0) return 0; return Math.sin(this.shakeMs * 0.9) * this.shakeMag * (this.shakeMs / 220); } private paintPips(): void { this.pips.forEach((pip, i) => { const lit = i < this.state.combo; pip.setFillStyle(lit ? GREEN : PURPLE, lit ? 0.95 : 0.25); }); } private resolve(): void { if (this.resolved) return; this.resolved = true; this.open_ = false; this.root.setVisible(false); const done = this.done; this.done = null; done?.(scoreStall(this.state)); } }