not-tonight/src/scenes/floor/overlays/StallOverlay.ts
type-two 595371f684 Phase 1 review: merge door/floor/juice, fold CCRs into contracts, integration fixes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:26:35 +10:00

335 lines
11 KiB
TypeScript

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));
}
}