Playable start to finish: 9 PM to 3 AM, patrons arrive on a curve, you work the rope, inspect them, and rule. Dazza texts escalate the dress code through the night, deferred consequences land when you can no longer argue, and the night ends in a summary or one of two fail states. Rules engine (src/rules/, pure, no Phaser): - dressCode: 8 rules whose text/activeFrom are read out of data/strings/dazza.ts by ruleId, with a load-time throw if a rule and its announcing text disagree - idCheck: all ID date maths, built on core/GameClock's ageOn - judge: the single scoring function, every magnitude named in JUDGE_TUNING - sobriety: challenge content and the drunk-performance model Scene layer (src/scenes/door/): NightScene shell, DoorScene, pure QueueManager + arrivalCurve, patron-up inspection view, ID card, phone, dress-code card, sobriety modal, stamp, summary. Tests 49 -> 229. Ten bugs found by playing it rather than by testing it, including one patron being ruled eleven times (state was mutating inside a tween callback; it now settles synchronously), a correctly-played night still rioting at 1:37 AM, and 28% of patrons being deniable for a logo the renderer never drew. Contract change requests CCR-1..3 are in LANEHANDOVER.md. src/data/types.ts, src/core/ and docs/ are byte-identical to main; the two additive extensions are declare-module augmentations. src/main.ts is the one file touched outside the lane (8 lines, to boot into the night) and is flagged for the reviewer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
202 lines
5.8 KiB
TypeScript
202 lines
5.8 KiB
TypeScript
import Phaser from 'phaser';
|
|
import { renderDoll } from '../../patrons/doll';
|
|
import { dollPlan } from '../../patrons/dollPlan';
|
|
import { drunkStage } from '../../rules/drunk';
|
|
import type { Patron } from '../../data/types';
|
|
import { CLAIM_LINES, DRUNK_GREETINGS, PATRON_GREETINGS } from '../../data/strings/door';
|
|
import { MONO } from './ui';
|
|
import { ZONE_BANDS, ZONE_ORDER, describeZone, greetingIndex, type InspectZone } from './inspect';
|
|
|
|
// The patron at the rope, three times life size so the outfit actually reads.
|
|
// Hover zones map 1:1 onto the doll's y bands — you look at the shoes by
|
|
// looking at the shoes.
|
|
|
|
const SCALE = 3;
|
|
const DOLL_W = 32;
|
|
const DOLL_H = 48;
|
|
|
|
export class PatronUpView {
|
|
private root: Phaser.GameObjects.Container | null = null;
|
|
private doll: Phaser.GameObjects.Image | null = null;
|
|
private tooltip: Phaser.GameObjects.Container | null = null;
|
|
private patron: Patron | null = null;
|
|
private swayT = 0;
|
|
private swayPx = 0;
|
|
|
|
constructor(
|
|
private readonly scene: Phaser.Scene,
|
|
private readonly x: number,
|
|
/** y of the patron's FEET */
|
|
private readonly footY: number,
|
|
) {}
|
|
|
|
get current(): Patron | null {
|
|
return this.patron;
|
|
}
|
|
|
|
show(p: Patron): void {
|
|
this.clear();
|
|
this.patron = p;
|
|
const { scene } = this;
|
|
|
|
const root = scene.add.container(this.x, this.footY).setDepth(300);
|
|
this.root = root;
|
|
|
|
// a puddle-ish shadow so they are standing ON something
|
|
root.add(scene.add.ellipse(0, 2, DOLL_W * SCALE * 0.6, 10, 0x000000, 0.45));
|
|
|
|
const key = renderDoll(scene, p, 'queue');
|
|
const doll = scene.add.image(0, 0, key).setOrigin(0.5, 1).setScale(SCALE);
|
|
this.doll = doll;
|
|
this.swayPx = dollPlan(p, 'queue').swayPx;
|
|
root.add(doll);
|
|
|
|
// hover zones, in doll-local coordinates scaled up
|
|
for (const zone of ZONE_ORDER) {
|
|
const band = ZONE_BANDS[zone];
|
|
const h = (band.y1 - band.y0) * SCALE;
|
|
const cy = -DOLL_H * SCALE + (band.y0 + (band.y1 - band.y0) / 2) * SCALE;
|
|
const hit = scene.add
|
|
.rectangle(0, cy, DOLL_W * SCALE * 0.8, h, 0xffffff, 0)
|
|
.setInteractive({ useHandCursor: true });
|
|
hit.on('pointerover', () => this.showTooltip(zone, cy));
|
|
hit.on('pointerout', () => this.hideTooltip());
|
|
root.add(hit);
|
|
}
|
|
|
|
this.speak(p);
|
|
|
|
// step-up: they walk in from the queue side and settle
|
|
root.x = this.x - 90;
|
|
root.setAlpha(0);
|
|
scene.tweens.add({ targets: root, x: this.x, alpha: 1, duration: 380, ease: 'Quad.easeOut' });
|
|
}
|
|
|
|
private speak(p: Patron): void {
|
|
if (!this.root) return;
|
|
const { scene } = this;
|
|
const drunk = drunkStage(p.intoxication);
|
|
const pool =
|
|
p.flags.claimsGuestList || p.flags.knowsOwner
|
|
? CLAIM_LINES
|
|
: drunk === 'messy' || drunk === 'maggot' || drunk === 'loose'
|
|
? DRUNK_GREETINGS
|
|
: PATRON_GREETINGS;
|
|
const line = pool[greetingIndex(p, pool.length)] ?? PATRON_GREETINGS[0]!;
|
|
|
|
const bubbleY = -DOLL_H * SCALE - 16;
|
|
const label = scene.add
|
|
.text(0, bubbleY, `"${line}"`, {
|
|
fontFamily: MONO,
|
|
fontSize: '8px',
|
|
color: '#d8d0c0',
|
|
align: 'center',
|
|
wordWrap: { width: 150 },
|
|
})
|
|
.setOrigin(0.5, 1);
|
|
const bg = scene.add
|
|
.rectangle(0, bubbleY - label.height / 2 + 1, label.width + 10, label.height + 6, 0x14121a, 0.85)
|
|
.setStrokeStyle(1, 0x3a3448);
|
|
this.root.add([bg, label]);
|
|
|
|
scene.tweens.add({ targets: [bg, label], alpha: 0, delay: 3600, duration: 700 });
|
|
}
|
|
|
|
private showTooltip(zone: InspectZone, localY: number): void {
|
|
this.hideTooltip();
|
|
if (!this.root || !this.patron) return;
|
|
const { scene } = this;
|
|
|
|
const lines = describeZone(this.patron, zone);
|
|
const c = scene.add.container(56, localY).setDepth(310);
|
|
const label = scene.add
|
|
.text(6, 0, lines.join('\n'), {
|
|
fontFamily: MONO,
|
|
fontSize: '7px',
|
|
color: '#f0e4d0',
|
|
wordWrap: { width: 120 },
|
|
})
|
|
.setOrigin(0, 0.5);
|
|
const bg = scene.add
|
|
.rectangle(0, 0, label.width + 14, label.height + 8, 0x0d0b12, 0.94)
|
|
.setOrigin(0, 0.5)
|
|
.setStrokeStyle(1, 0x5a4a68);
|
|
c.add([bg, label]);
|
|
this.root.add(c);
|
|
this.tooltip = c;
|
|
}
|
|
|
|
private hideTooltip(): void {
|
|
this.tooltip?.destroy();
|
|
this.tooltip = null;
|
|
}
|
|
|
|
/** Called every frame — the sway is the drunk tell you watch for. */
|
|
update(deltaMs: number): void {
|
|
if (!this.doll || this.swayPx <= 0) return;
|
|
this.swayT += deltaMs / 1000;
|
|
const speed = 2.2 - this.swayPx * 0.35;
|
|
this.doll.x = Math.sin(this.swayT * speed) * this.swayPx * SCALE * 0.5;
|
|
this.doll.setAngle(Math.sin(this.swayT * speed * 0.7) * this.swayPx * 0.9);
|
|
}
|
|
|
|
/** They walk off left after a denial: the defeated slouch. */
|
|
walkOffDenied(onDone?: () => void): void {
|
|
const root = this.root;
|
|
this.patron = null;
|
|
this.hideTooltip();
|
|
if (!root) {
|
|
onDone?.();
|
|
return;
|
|
}
|
|
this.root = null;
|
|
this.doll?.setAngle(9); // the slouch
|
|
this.scene.tweens.add({
|
|
targets: root,
|
|
x: root.x - 170,
|
|
y: root.y + 6,
|
|
alpha: 0,
|
|
duration: 900,
|
|
ease: 'Quad.easeIn',
|
|
onComplete: () => {
|
|
root.destroy();
|
|
onDone?.();
|
|
},
|
|
});
|
|
}
|
|
|
|
/** They walk right, into the venue. */
|
|
walkInAdmitted(doorX: number, onDone?: () => void): void {
|
|
const root = this.root;
|
|
this.patron = null;
|
|
this.hideTooltip();
|
|
if (!root) {
|
|
onDone?.();
|
|
return;
|
|
}
|
|
this.root = null;
|
|
this.scene.tweens.add({
|
|
targets: root,
|
|
x: doorX,
|
|
y: root.y - 26,
|
|
scale: 0.55,
|
|
alpha: 0,
|
|
duration: 820,
|
|
ease: 'Quad.easeIn',
|
|
onComplete: () => {
|
|
root.destroy();
|
|
onDone?.();
|
|
},
|
|
});
|
|
}
|
|
|
|
clear(): void {
|
|
this.hideTooltip();
|
|
this.root?.destroy();
|
|
this.root = null;
|
|
this.doll = null;
|
|
this.patron = null;
|
|
this.swayT = 0;
|
|
}
|
|
}
|