diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index c32d998..75644f3 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1157,3 +1157,46 @@ smoking-door slip-in (FLOOR2 §2's second noStamp source) are the next nibbles. **Next:** John plays it (economy feel + ear pass, M works live); re-roll any sprite that bugs him; regular step-up lines and the smoking-door slip-in stay the next code nibbles. + +--- +### SESSION — SCENARIOS: THE JOINT & THE RACK (Fable, solo) — 2026-07-21 00:50 +**Branch/commits:** main; deployed live. +**Done:** +- **docs/SCENARIOS.md** — the idea shelf: characters (off-duty cop, celebrity + incognito, twins, sovereign citizen, noise-complaint neighbour, lost hen's + party, ex-doorman tutorial character), interactions (ID passback, 1:30 + LOCKOUT LAW as a late-game phase, vomit protocol, water station, kebab run), + all held to §4.3 discipline. Two items implemented this session: +- **THE JOINT BUST** (smoking yard): 'joint' is real contraband (severity 2 — + it also turns up in rope pat-downs now). E on a committed smoker who smells + herbal opens the new reusable **ChoiceOverlay** (three verbs + doll + + keyboard/pointer, built for every future floor scenario): + - BIN IT: by the book; the yard grumbles (+1 aggro); truth-logged. + - POCKET IT — **the game's first corruption verb**: nothing happens now; + 50% a Dazza sting lands later ("a punter says SECURITY is holding"); + and the incident sits in the log with report-form accounts where the + TRUTHFUL option is a confession (interest weight 9 — it WILL come up). + - DIDN'T SEE IT: the yard exhales (+1 vibe, smoker +0.08 intox), and + "no action taken" is still on the record. +- **THE GLASSIE RUN** (the role ladder's glassie fantasy as a floor task): + kitchen room carved behind the bar's west end (dishwasher prop with green + glow, racks, door), a full rack spawns at the bar's EAST end (~22 min + respawn, amber marker). Carry it the length of the venue: crowd bumps and + hard steering build WOBBLE (pure physics in floor/glassie.ts, 5 tests); + wobble breaks glasses one smash at a time (new Sfx 'glassSmash' voice); + deliver survivors for vibe. Drop the lot: "The crowd applauds. Someone + yells TAXI." While carrying, the ONLY interaction in the world is the + dishwasher — the rack owns your hands, the door owns Kayden. +**Verified in-browser**: full rack run (took rack → stressed → 5/6 delivered, +incident logged), joint bust modal screenshot, POCKET IT → truth-log incident + +sting queued. **Tests: 651. Gate clean. Deployed, live 200.** +**Broke / known-wonky:** +- The joint gates on activity === 'smoking' (a committed yard dwell) — right + for real play; test harnesses must pin dwellMs. +- Rack carry doesn't slow the player (wobble IS the cost); revisit if the run + feels free once John plays it. +- ChoiceOverlay is scroll-fixed full-screen; on-floor camera position is + irrelevant to it (deliberate). +**Next:** SCENARIOS.md 🔜 items (ID passback, regular step-up lines, +smoking-door slip-in); the lockout law wants its own design pass; John's two +human passes still open. diff --git a/docs/SCENARIOS.md b/docs/SCENARIOS.md new file mode 100644 index 0000000..622557a --- /dev/null +++ b/docs/SCENARIOS.md @@ -0,0 +1,79 @@ +# NOT TONIGHT — Character, Scenario & Interaction Backlog + +*The idea shelf. Everything here obeys §4.3: choices cost something real, the +game never grades them, and the humane option is never secretly optimal. +Status: ✅ implemented · 🔜 next nibbles · 💡 backlog.* + +## Interactions & mini-systems + +- ✅ **The joint bust** (smoking yard). E on a smoker who smells herbal → three + verbs, none of them clean: **BIN IT** (by the book — write-up in the truth + log, smoker's night dampened), **POCKET IT** (the game's first corruption + verb — nothing happens now; maybe a Dazza sting later; and it sits in the + incident log waiting for the report form, where your account of it can be a + lie the audit remembers), **DIDN'T SEE IT** (the yard relaxes, vibe drifts + up… and it's still on the record as ignored). 'joint' is real contraband, so + it also turns up in pat-downs at the rope. +- ✅ **The glassie run** (the glassie role's fantasy, previewed as a floor + task). A wire rack of glasses appears at the bar's east end; carry it the + length of the venue to the kitchen dishwasher. Crowd bumps and sharp turns + build WOBBLE; wobble breaks glasses one smash at a time. Deliver what + survives for vibe. Racks keep coming. The door is unattended while you do it + — Kayden is thrilled. +- 🔜 **ID passback detection**: the same card twice in one night — the name on + the guest-list clipboard you already checked; second appearance of an + identical card = passback. Needs a "cards seen tonight" memory at the desk. +- 🔜 **Regular step-up lines** keyed off timesDeniedBefore ("back again. new + moustache. no hard feelings"). +- 🔜 **Smoking-door slip-in** (FLOOR2 §2's second noStamp source). +- 💡 **The 1:30 lockout** (deeply Queensland): after 1:30 AM, admissions are + ILLEGAL. The queue still forms; Dazza still wants bodies; every post-lockout + admit is a guaranteed strike risk vs a rioting line. A whole late-game phase + in one law. +- 💡 **Vomit protocol**: someone redecorates the dance floor → deploy the wet + floor sign (we own the prop) → slips if you dawdle; the glassie mop loop. +- 💡 **Phone confiscation**: someone filming on the dance floor — the + influencer's revenge arc. +- 💡 **Water station duty** (RSA): press a cup on a `loose` patron; done right + it de-escalates the ripen timer. The humane tool that costs time. +- 💡 **First aid**: a fainter. Recovery position via a QTE that is deliberately + NOT funny; the room hushes; the night resumes changed. +- 💡 **Kebab run**: Dazza demands a kebab mid-slam. The shop is right there. + The queue can see you. The whole game in one errand. + +## Characters (door archetypes & scripted people) + +- 💡 **The off-duty cop** — flashes a badge at the rope. Real cop (deny freely, + he respects it; admit and he quietly reports the venue's breaches — a + walking inspector) or a $30 costume badge (the tells: hat, confidence, + wrong lanyard). The ID game's scariest bluff. +- 💡 **The celebrity incognito** — cap + sunnies (two live rule breaches!). + Deny: nothing happens, ever, and Dazza never finds out who. Admit: vibe + rocket OR pap swarm outside (aggro storm). You cannot know which. +- 💡 **The twins** — two people, one face, different names. Not a fake: + genuinely twins. Everything about the photo check is suddenly on trial. +- 💡 **The sovereign citizen** — "I don't recognise your dress code as having + authority over my person." Aggro engine in a polo. His ID is a laminated + business card. +- 💡 **The delivery rider** — insists the DJ ordered food. The DJ did not + order food. OR DID HE. (The booth lamp flickers hungrily.) +- 💡 **The promoter** — arrives with a roll of wristbands and 'his' guest list + that doesn't match yours. Two clipboards enter, one leaves. +- 💡 **The noise-complaint neighbour** — pyjamas, at the rope, at 1 AM, not + trying to get in: trying to get it turned DOWN. The only patron who wants + less club. Handle him badly and the inspector hears about it. +- 💡 **The hen's party, lost** — seven matching sashes, wrong venue, right + energy. Admitting them is a vibe grenade with a random timer. +- 💡 **The ex-doorman** — watches you work a patron, then tells you what you + missed (a live tutorial character; his tips are right ~80% of the time, + which is the joke). + +## Systems worth a whole phase + +- 💡 **Venue 4 / ROOM boss week** (design §6): dress code re-randomises every + 15 in-game minutes; dialogue rendered as lip-read guesses under the loudest + mix arc. +- 💡 **The DJ shift** (role ladder): the booth is built, the engine's mix is + already externally scriptable — requests at the booth, energy management. +- 💡 **Bartender shift**: the bar geometry and RSA scoring already exist; + pour-and-cut-off from the other side of the counter. diff --git a/src/audio/Sfx.ts b/src/audio/Sfx.ts index 3908c7d..2f44497 100644 --- a/src/audio/Sfx.ts +++ b/src/audio/Sfx.ts @@ -16,7 +16,8 @@ export type SfxName = | 'phoneBuzz' | 'ropeUnhook' | 'denyCrowdOoh' - | 'typeTick'; + | 'typeTick' + | 'glassSmash'; /** Seconds of white noise generated once and shared by every noise voice. */ const NOISE_SECONDS = 2; @@ -84,6 +85,8 @@ export class Sfx { return this.denyCrowdOoh(); case 'typeTick': return this.typeTick(); + case 'glassSmash': + return this.glassSmash(); } } @@ -271,6 +274,15 @@ export class Sfx { this.voice(src, [this.band(3400, 2.2), this.env(t, 0.0005, 0.002, 0.012, 0.045)], t, 0.016); } + /** A pint glass meeting a tiled floor: bright burst, short shard ring. */ + glassSmash(): void { + const t = this.ctx.currentTime; + const src = this.noiseSource(); + this.voice(src, [this.band(3100, 1.1), this.env(t, 0.001, 0.02, 0.12, 0.22)], t, 0.24); + const ring = this.noiseSource(); + this.voice(ring, [this.band(5200, 6), this.env(t + 0.03, 0.001, 0.01, 0.08, 0.16)], t + 0.03, 0.18); + } + /** Idempotent — the door scene may call this on every re-entry. */ startRain(): void { if (this.rain) return; diff --git a/src/data/contraband.ts b/src/data/contraband.ts index 0c22f16..f1a5a33 100644 --- a/src/data/contraband.ts +++ b/src/data/contraband.ts @@ -16,6 +16,7 @@ export const CONTRABAND: readonly ContrabandDef[] = [ { id: 'someoneElsesId', name: "Someone else's ID", severity: 2, weight: 2, spriteHint: 'small card' }, { id: 'budgie', name: 'A live budgie', severity: 1, weight: 1, spriteHint: 'green blob (flaps)' }, { id: 'vapes', name: 'Fourteen vapes', severity: 1, weight: 3, spriteHint: 'neon sticks' }, + { id: 'joint', name: 'One (1) joint', severity: 2, weight: 2, spriteHint: 'small white cone' }, { id: 'marker', name: 'Fat permanent marker', severity: 2, weight: 2, spriteHint: 'black stick' }, { id: 'snags', name: 'Raw sausages (why)', severity: 1, weight: 1, spriteHint: 'pink links' }, ]; diff --git a/src/data/strings/floor.ts b/src/data/strings/floor.ts index e9206bf..3492a7c 100644 --- a/src/data/strings/floor.ts +++ b/src/data/strings/floor.ts @@ -126,3 +126,25 @@ export const NO_STAMP_RADIO: readonly string[] = [ export const HELP_TEXT = 'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed'; + + +/** The smoking-yard joint bust (docs/SCENARIOS.md). Three verbs, none clean. */ +export const JOINT_UI = { + title: 'SOMETHING SMELLS HERBAL', + smokerLine: 'its uh. a rollie. bush tobacco. very natural', + bin: 'BIN IT', + binDetail: 'by the book. into the amnesty bin, into the log', + pocket: 'POCKET IT', + pocketDetail: 'for later. for evidence. for whatever', + slide: "DIDN'T SEE IT", + slideDetail: 'you were never here. neither was the smell', +} as const; + +export const JOINT_RESULTS = { + binned: 'It goes in the bin. The yard watches it go.', + pocketed: 'It goes in your pocket. Nobody says anything. Especially you.', + slid: 'You study the fence line. Excellent fence. The yard exhales.', +} as const; + +/** Dazza, later, if word gets around about what is in your pocket. */ +export const JOINT_STING = 'a punter says SECURITY is holding. tell me thats a lie. tell me NOW'; diff --git a/src/rules/incidentReport.ts b/src/rules/incidentReport.ts index 2bd6f45..975db00 100644 --- a/src/rules/incidentReport.ts +++ b/src/rules/incidentReport.ts @@ -68,6 +68,9 @@ export const REPORT_TUNING = { // band, never a fixed running order. interest: { deferred: 10, // a breach the audit already found — the awkward one + jointPocketed: 9, // the one question you really don't want asked + jointBinned: 3, + jointIgnored: 5, ejection: 8, maggotUnhandled: 7, kayden: 6, // his mistake, your signature @@ -137,6 +140,30 @@ const BANKS: Record = { { id: 'maggot.soft2', text: 'Patron was seated and stable. No intervention was assessed as necessary at that point.', truthful: false }, ], }, + jointPocketed: { + prompt: 'Contraband was located in the smoking area. Where is it now?', + options: [ + { id: 'jointP.true', text: 'On me. I took possession and I still have possession. That is the account.', truthful: true }, + { id: 'jointP.soft1', text: 'Item was destroyed on the spot in view of the patron.', truthful: false }, + { id: 'jointP.soft2', text: 'Item was placed in the amnesty bin per procedure. The bin was emptied by close.', truthful: false }, + ], + }, + jointBinned: { + prompt: 'Contraband was located in the smoking area. Your account?', + options: [ + { id: 'jointB.true', text: 'Confiscated and binned in front of them. The yard went quiet for a bit.', truthful: true }, + { id: 'jointB.soft1', text: 'Patron surrendered the item voluntarily and thanked security for the chat.', truthful: false }, + { id: 'jointB.soft2', text: 'Item was assessed as herbal cigarettes. Binned as general litter.', truthful: false }, + ], + }, + jointIgnored: { + prompt: 'A smell was reported near the smoking area. Your account?', + options: [ + { id: 'jointI.true', text: 'I smelled it, I saw it, and I decided the fence line was more interesting.', truthful: true }, + { id: 'jointI.soft1', text: 'Area was patrolled at the time stated. Nothing of note was observed.', truthful: false }, + { id: 'jointI.soft2', text: 'Smell was traced to the kebab shop exhaust across the road.', truthful: false }, + ], + }, kayden: { prompt: 'A ruling was made at the rope while you were inside. Your account?', options: [ diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index ed29940..1e7a0f3 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -20,7 +20,10 @@ 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 { ChoiceOverlay } from './overlays/ChoiceOverlay'; +import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie'; +import { tileToWorld } from './venueMap'; +import { HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO } from '../../data/strings/floor'; import { MeterHud } from '../../ui/MeterHud'; import type { NightContext } from '../door/NightScene'; import type { Patron } from '../../data/types'; @@ -31,13 +34,19 @@ 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]; +/** Where a full rack of empties appears (bar's east end) and where it goes. */ +const RACK_SPOT = tileToWorld(51, 7); +const DISH_SPOT = tileToWorld(17, 3); +/** Crowd contact distance while carrying, and its debounce. */ +const CARRY_BUMP_PX = 14; +const CARRY_BUMP_COOLDOWN_MS = 350; const STAGE_RANK: Readonly> = { sober: 0, tipsy: 1, loose: 2, messy: 3, maggot: 4, }; /** What pressing E on the current target would do. */ -type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit'; +type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit' | 'joint' | 'rack' | 'dishwasher'; interface InteractTarget { kind: InteractKind; @@ -77,6 +86,14 @@ export class FloorDemoScene extends Phaser.Scene { private cutOff!: CutOffOverlay; private stall!: StallOverlay; private patDown!: PatDownOverlay; + private choice!: ChoiceOverlay; + private readonly jointDealt = new Set(); + private rack: RackState | null = null; + private rackReadyAtMin = 6; + private rackMarker: Phaser.GameObjects.Image | null = null; + private carryBumpMs = 0; + private prevVx = 0; + private prevVy = 0; private keys!: Record; private uvMode = false; @@ -134,6 +151,15 @@ export class FloorDemoScene extends Phaser.Scene { this.view = new FloorView(this, VENUE); this.cutOff = new CutOffOverlay(this); this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null); + this.choice = new ChoiceOverlay(this); + this.rackMarker = this.add + .image(RACK_SPOT.x, RACK_SPOT.y, 'floor:glow') + .setDepth(17) + .setBlendMode(Phaser.BlendModes.ADD) + .setTint(0xd8c23a) + .setAlpha(0.5) + .setScale(0.8) + .setVisible(false); this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0'); this.statusText = this.fixedText(4, VIEW_H - 12, '#556'); @@ -185,6 +211,9 @@ export class FloorDemoScene extends Phaser.Scene { this.maggotSeen.clear(); this.spotted.clear(); this.cutOffDealt.clear(); + this.jointDealt.clear(); + this.rack = null; + this.rackReadyAtMin = 6; this.ctxOffs = [ // The floor is fed by the door: every admission walks in a few seconds later. @@ -251,6 +280,9 @@ export class FloorDemoScene extends Phaser.Scene { this.maggotSeen.clear(); this.spotted.clear(); this.cutOffDealt.clear(); + this.jointDealt.clear(); + this.rack = null; + this.rackReadyAtMin = 6; this.clock.start(); this.beat.start(); @@ -264,6 +296,7 @@ export class FloorDemoScene extends Phaser.Scene { 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 (this.choice.isOpen) return this.choice.handleKey(key); if (key === 'TAB') { ev.preventDefault(); @@ -275,7 +308,7 @@ export class FloorDemoScene extends Phaser.Scene { } private get overlayOpen(): boolean { - return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen; + return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen || this.choice.isOpen; } private readInput(): PlayerInput { @@ -373,6 +406,8 @@ export class FloorDemoScene extends Phaser.Scene { this.crowd.update(dt, this.clock.clockMin); if (this.escorting) this.tickEscort(dt); + this.tickCarry(dt); + this.rackMarker?.setVisible(this.rack === null && this.clock.clockMin >= this.rackReadyAtMin); this.trackMaggots(); this.tickCalm(dt); @@ -470,6 +505,22 @@ export class FloorDemoScene extends Phaser.Scene { private findTarget(): InteractTarget | null { const spec = this.coneSpec(); + // Hands full: the only thing in the world is the dishwasher. Everything + // else (including the door home) waits until the rack is down. + if (this.rack) { + const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, DISH_SPOT.x, DISH_SPOT.y); + if (d <= INTERACT_RANGE) return { kind: 'dishwasher', prompt: 'E — rack into the dishwasher' }; + return null; + } + + // A full rack waiting at the bar's east end. + if (this.clock.clockMin >= this.rackReadyAtMin) { + const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, RACK_SPOT.x, RACK_SPOT.y); + if (d <= INTERACT_RANGE) { + return { kind: 'rack', prompt: 'E — take the rack through to the kitchen' }; + } + } + // Night mode: the way back out to the rope is itself an interaction. if (this.nightCtx) { const entry = VENUE.anchors.entry[0]; @@ -513,6 +564,16 @@ export class FloorDemoScene extends Phaser.Scene { return { kind: 'noStamp', agent: best, prompt: 'E — no stamp. Walk them back to the door' }; } + // The yard bust: a smoker whose rollie is not a rollie (docs/SCENARIOS.md). + if ( + best.activity === 'smoking' && + best.patron.flags.contraband?.includes('joint') && + !this.jointDealt.has(best.patron.id) + ) { + this.spot(best.patron.id, 'contraband'); + return { kind: 'joint', agent: best, prompt: 'E — something smells herbal' }; + } + const stage = drunkStage(best.patron.intoxication); if (stage === 'messy' || stage === 'maggot' || stage === 'loose') { // Dealing with someone settles that stage, not the whole night. The Quiet @@ -536,13 +597,120 @@ export class FloorDemoScene extends Phaser.Scene { if (!t || this.escorting) return; if (t.kind === 'exit') return this.nightCtx?.requestLocation('door'); + if (t.kind === 'rack') return this.takeRack(); + if (t.kind === 'dishwasher') return this.deliverRack(); if (t.kind === 'stall' && t.stallId) return this.openStall(t.stallId); if (!t.agent) return; + if (t.kind === 'joint') return this.openJointBust(t.agent); 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); } + // ---- the joint bust (docs/SCENARIOS.md) -------------------------------- + + private openJointBust(agent: Agent): void { + this.choice.open( + { + title: JOINT_UI.title, + line: JOINT_UI.smokerLine, + patron: agent.patron, + choices: [ + { id: 'bin', label: JOINT_UI.bin, detail: JOINT_UI.binDetail }, + { id: 'pocket', label: JOINT_UI.pocket, detail: JOINT_UI.pocketDetail }, + { id: 'slide', label: JOINT_UI.slide, detail: JOINT_UI.slideDetail }, + ], + }, + (id) => { + if (id === null) return; // walked away; the smell keeps + this.jointDealt.add(agent.patron.id); + const left = (agent.patron.flags.contraband ?? []).filter((c) => c !== 'joint'); + if (left.length > 0) agent.patron.flags.contraband = left; + else delete agent.patron.flags.contraband; + + if (id === 'bin') { + // By the book. The yard notices the book. + this.bus.emit('meters:delta', { aggro: 1 }); + this.logIncident('jointBinned', agent.patron.id, 'Confiscated in the yard, into the amnesty bin.'); + this.toast(JOINT_RESULTS.binned); + } else if (id === 'pocket') { + // Nothing happens now. That is the whole point of now. + this.logIncident('jointPocketed', agent.patron.id, 'Confiscated in the yard. Retained by security.'); + this.toast(JOINT_RESULTS.pocketed); + if (this.nightCtx && this.rng.stream('joint').chance(0.5)) { + this.nightCtx.scheduleDeferred([{ + atClockMin: this.clock.clockMin + 20 + this.rng.stream('joint').int(0, 40), + vibeDelta: -2, + dazzaText: JOINT_STING, + reason: 'word got around about what security is holding', + patronId: agent.patron.id, + }]); + } + } else { + // The fence line really is excellent tonight. + agent.patron.intoxication = Math.min(1, agent.patron.intoxication + 0.08); + this.bus.emit('meters:delta', { vibe: 1 }); + this.logIncident('jointIgnored', agent.patron.id, 'A smell near the yard. No action taken.'); + this.toast(JOINT_RESULTS.slid); + } + }, + ); + } + + // ---- the glassie run (docs/SCENARIOS.md) ------------------------------- + + private takeRack(): void { + this.rack = freshRack(); + this.rackMarker?.setVisible(false); + this.prevVx = this.player.vx; + this.prevVy = this.player.vy; + } + + private deliverRack(): void { + const r = this.rack; + if (!r) return; + this.rack = null; + this.rackReadyAtMin = this.clock.clockMin + RACK_RESPAWN_MIN; + const vibe = Math.min(3, r.glasses * 0.5); + if (vibe > 0) this.bus.emit('meters:delta', { vibe }); + this.logIncident('glassieRun', undefined, `${r.glasses}/6 glasses made it to the dishwasher.`); + this.toast(r.glasses === 6 ? 'Full rack, not a chip. The dishwasher accepts your offering.' : `${r.glasses} of 6 survived the crowd.`); + } + + /** Carry physics: bumps and hard steering shake the rack; wobble breaks glass. */ + private tickCarry(dt: number): void { + const r = this.rack; + if (!r) return; + this.carryBumpMs += dt; + let bumped = false; + if (this.carryBumpMs >= CARRY_BUMP_COOLDOWN_MS) { + for (const a of this.crowd.agents) { + if (a.gone) continue; + if (Phaser.Math.Distance.Between(this.player.x, this.player.y, a.x, a.y) < CARRY_BUMP_PX) { + bumped = true; + this.carryBumpMs = 0; + break; + } + } + } + const sharp = turnSharpness(this.prevVx, this.prevVy, this.player.vx, this.player.vy, 116); + this.prevVx = this.player.vx; + this.prevVy = this.player.vy; + const out = stepRack(r, dt, bumped, sharp); + this.rack = out.rack; + if (out.broke > 0) { + this.nightCtx?.sfx?.play('glassSmash'); + this.bus.emit('meters:delta', { vibe: -0.5 }); + if (out.rack.glasses <= 0) { + this.rack = null; + this.rackReadyAtMin = this.clock.clockMin + RACK_RESPAWN_MIN; + this.bus.emit('meters:delta', { vibe: -1.5 }); + this.logIncident('rackDropped', undefined, 'The whole rack. The whole floor heard it.'); + this.toast('The last glass goes. The crowd applauds. Someone yells TAXI.'); + } + } + } + private openStall(stallId: string): void { const occupants = this.crowd.stallOccupancy().get(stallId) ?? []; this.stall.open( @@ -655,12 +823,17 @@ export class FloorDemoScene extends Phaser.Scene { if (this.radioMs <= 0) this.radioText.setText(''); } // A brewing fight outranks everything — it's the one thing on a clock. + const carryLine = this.rack + ? `RACK ${'▮'.repeat(this.rack.glasses)}${'▯'.repeat(6 - this.rack.glasses)} wobble ${'!'.repeat(Math.min(4, Math.ceil(this.rack.wobble * 4)))}` + : null; this.promptText.setText( this.fights.active ? 'GET BETWEEN THEM' : this.escorting ? 'escorting — walk them to the door (no interacting)' - : (this.target?.prompt ?? ''), + : carryLine && !this.target + ? carryLine + : this.target?.prompt ?? '', ); const maggots = this.crowd.agents.filter( (a) => !a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot', @@ -679,6 +852,7 @@ export class FloorDemoScene extends Phaser.Scene { this.cutOff?.destroy(); this.stall?.destroy(); this.patDown?.destroy(); + this.choice?.destroy(); // Night mode: the bus/meters belong to NightScene — touch nothing shared. this.meters?.destroy(); this.hud?.destroy(); diff --git a/src/scenes/floor/FloorView.ts b/src/scenes/floor/FloorView.ts index 801dcf9..d395087 100644 --- a/src/scenes/floor/FloorView.ts +++ b/src/scenes/floor/FloorView.ts @@ -619,6 +619,7 @@ const PROP_SIZES: Record = { poster1: [32, 16], poster2: [32, 16], poster3: [32, 16], cloak: [16, 48], stampPodium: [16, 16], arcade: [32, 32], highTable: [16, 16], plant: [16, 16], wetFloor: [16, 16], poolTable: [48, 32], + dishwasher: [32, 16], }; function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string { @@ -724,6 +725,9 @@ function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string { case 'wetFloor': f(5, 2, 6, 12, '#d8c23a'); f(6, 4, 4, 4, '#22222c'); break; + case 'dishwasher': + f(1, 1, 30, 14, '#3a4450'); f(4, 4, 10, 8, '#22303a'); f(17, 4, 11, 3, '#50c060'); f(17, 9, 11, 3, '#8a9aa2'); + break; case 'poolTable': f(2, 2, 44, 28, '#4a3226'); f(5, 5, 38, 22, '#1e6e3c'); for (const [px, py] of [[6, 6], [42, 6], [6, 25], [42, 25], [24, 5], [24, 26]] as const) { diff --git a/src/scenes/floor/glassie.ts b/src/scenes/floor/glassie.ts new file mode 100644 index 0000000..809f2ed --- /dev/null +++ b/src/scenes/floor/glassie.ts @@ -0,0 +1,63 @@ +// The glassie run, pure half (docs/SCENARIOS.md): carry a wire rack of glasses +// through a nightclub crowd without redecorating the floor. Wobble physics and +// breakage rules live here so the feel is testable; the scene only reports +// bumps and steering. + +export const RACK_GLASSES = 6; +/** Wobble at or past this breaks a glass (and the wobble sheds with it). */ +export const BREAK_AT = 1; +/** A break dumps you back here — shaken, not reset. */ +const AFTER_BREAK = 0.45; +/** Passive settle per second when you walk smoothly. */ +const SETTLE_PER_S = 0.35; +/** One crowd bump is most of the way to trouble on a settled rack. */ +const BUMP_WOBBLE = 0.55; +/** Full-lock steering reversal adds this much per second. */ +const TURN_WOBBLE_PER_S = 1.4; +/** In-game minutes between racks — the bar never stops producing empties. */ +export const RACK_RESPAWN_MIN = 22; + +export interface RackState { + glasses: number; + wobble: number; +} + +export const freshRack = (): RackState => ({ glasses: RACK_GLASSES, wobble: 0 }); + +/** + * Advance the rack one frame. `turnSharpness` is 0..1 (how hard the carry + * velocity swung this frame); `bumped` is a de-bounced crowd contact. + * Returns the new state plus how many glasses broke THIS frame (0 or 1 — a + * frame only ever breaks one; the wobble shed makes chains possible but earned). + */ +export function stepRack( + rack: RackState, + dtMs: number, + bumped: boolean, + turnSharpness: number, +): { rack: RackState; broke: number } { + const dt = Math.max(0, dtMs) / 1000; + let wobble = rack.wobble - SETTLE_PER_S * dt; + wobble += Math.max(0, Math.min(1, turnSharpness)) * TURN_WOBBLE_PER_S * dt; + if (bumped) wobble += BUMP_WOBBLE; + wobble = Math.max(0, wobble); + + if (wobble >= BREAK_AT && rack.glasses > 0) { + return { rack: { glasses: rack.glasses - 1, wobble: AFTER_BREAK }, broke: 1 }; + } + return { rack: { glasses: rack.glasses, wobble }, broke: 0 }; +} + +/** 0..1 steering harshness from consecutive frame velocities. */ +export function turnSharpness( + prevVx: number, + prevVy: number, + vx: number, + vy: number, + maxSpeed: number, +): number { + if (maxSpeed <= 0) return 0; + const dx = vx - prevVx; + const dy = vy - prevVy; + return Math.min(1, Math.hypot(dx, dy) / (maxSpeed * 2)); +} diff --git a/src/scenes/floor/overlays/ChoiceOverlay.ts b/src/scenes/floor/overlays/ChoiceOverlay.ts new file mode 100644 index 0000000..e9ce157 --- /dev/null +++ b/src/scenes/floor/overlays/ChoiceOverlay.ts @@ -0,0 +1,140 @@ +import Phaser from 'phaser'; +import { renderDoll } from '../../../patrons/doll'; +import type { Patron } from '../../../data/types'; + +// A reusable three-verb modal for floor scenarios (docs/SCENARIOS.md): the +// joint bust today, whatever the yard produces next. Same shell discipline as +// the other overlays: the scene routes keys, the overlay owns no listeners of +// its own beyond pointer hits on its rows. + +const W = 640; +const H = 360; +const PANEL_X = 96; +const PANEL_Y = 52; +const PANEL_W = 448; +const PANEL_H = 236; +const ROW_X = PANEL_X + 118; +const ROW_W = PANEL_W - 136; +const ROW_TOP = PANEL_Y + 86; +const ROW_GAP = 34; +const AMBER = 0xd8a020; + +const mono = (size: number, colour: string): Phaser.Types.GameObjects.Text.TextStyle => ({ + fontFamily: 'monospace', + fontSize: `${size}px`, + color: colour, +}); + +export interface ChoiceDef { + id: string; + label: string; + detail: string; +} + +export interface ChoiceArgs { + title: string; + /** What the person at the centre of it says. */ + line: string; + patron: Patron; + choices: readonly ChoiceDef[]; // up to 3 +} + +export class ChoiceOverlay { + private readonly scene: Phaser.Scene; + private readonly root: Phaser.GameObjects.Container; + private readonly titleText: Phaser.GameObjects.Text; + private readonly lineText: Phaser.GameObjects.Text; + private readonly doll: Phaser.GameObjects.Image; + private readonly rows: Array<{ + box: Phaser.GameObjects.Rectangle; + label: Phaser.GameObjects.Text; + detail: Phaser.GameObjects.Text; + }> = []; + private choices: readonly ChoiceDef[] = []; + private done: ((id: string | null) => void) | null = null; + private open_ = false; + + constructor(scene: Phaser.Scene) { + this.scene = scene; + this.root = scene.add.container(0, 0).setDepth(5000).setScrollFactor(0).setVisible(false); + this.root.add(scene.add.rectangle(W / 2, H / 2, W, H, 0x000000, 0.82)); + this.root.add( + scene.add.rectangle(W / 2, PANEL_Y + PANEL_H / 2, PANEL_W, PANEL_H, 0x101018).setStrokeStyle(1, 0x6080a0, 0.7), + ); + + this.titleText = scene.add.text(W / 2, PANEL_Y + 16, '', mono(11, '#9fc0e8')).setOrigin(0.5, 0); + this.lineText = scene.add + .text(PANEL_X + 118, PANEL_Y + 44, '', mono(8, '#cfc6d8')) + .setOrigin(0, 0) + .setWordWrapWidth(ROW_W); + this.doll = scene.add.image(PANEL_X + 58, PANEL_Y + 120, '__DEFAULT').setScale(2); + this.root.add([this.titleText, this.lineText, this.doll]); + + for (let i = 0; i < 3; i++) { + const y = ROW_TOP + i * ROW_GAP; + const box = scene.add + .rectangle(ROW_X + ROW_W / 2, y, ROW_W, 28, 0x14141f) + .setStrokeStyle(1, 0x2a2a3a) + .setInteractive({ useHandCursor: true }); + const label = scene.add.text(ROW_X + 10, y - 6, '', mono(9, '#e8e0d0')).setOrigin(0, 0.5); + const detail = scene.add.text(ROW_X + 10, y + 7, '', mono(7, '#6a7a8a')).setOrigin(0, 0.5); + box.on('pointerover', () => box.setStrokeStyle(1, AMBER)); + box.on('pointerout', () => box.setStrokeStyle(1, 0x2a2a3a)); + box.on('pointerup', () => this.pick(i)); + this.root.add([box, label, detail]); + this.rows.push({ box, label, detail }); + } + + this.root.add( + scene.add + .text(W / 2, PANEL_Y + PANEL_H - 16, '1/2/3 choose · ESC walk away', mono(7, '#5a5a70')) + .setOrigin(0.5, 0), + ); + } + + get isOpen(): boolean { + return this.open_; + } + + open(args: ChoiceArgs, done: (id: string | null) => void): void { + this.done = done; + this.choices = args.choices.slice(0, 3); + this.open_ = true; + this.titleText.setText(args.title); + this.lineText.setText(`"${args.line}"`); + this.doll.setTexture(renderDoll(this.scene, args.patron, 'queue')); + this.rows.forEach((r, i) => { + const c = this.choices[i]; + r.box.setVisible(!!c); + r.label.setVisible(!!c).setText(c ? `${i + 1} ${c.label}` : ''); + r.detail.setVisible(!!c).setText(c ? c.detail : ''); + }); + this.root.setVisible(true); + } + + handleKey(key: string): void { + if (!this.open_) return; + if (key === 'ESCAPE' || key === 'ESC') return this.close(null); + const idx = Number(key) - 1; + if (idx >= 0 && idx < this.choices.length) this.pick(idx); + } + + update(_dtMs: number): void {} + + private pick(i: number): void { + const c = this.choices[i]; + if (c) this.close(c.id); + } + + private close(id: string | null): void { + this.open_ = false; + this.root.setVisible(false); + const done = this.done; + this.done = null; + done?.(id); + } + + destroy(): void { + this.root.destroy(); + } +} diff --git a/src/scenes/floor/venueMap.ts b/src/scenes/floor/venueMap.ts index 6b108aa..51ef7bf 100644 --- a/src/scenes/floor/venueMap.ts +++ b/src/scenes/floor/venueMap.ts @@ -114,6 +114,11 @@ function buildTiles(): TileKind[] { // Dunnies get plumbing: sink row along the south interior wall. rect(66, 15, 71, 15, 'sink'); + // The kitchen: back-of-house behind the bar's west end. Glassie territory — + // the dishwasher lives here and the racks come home to it. + box(14, 1, 21, 8, 'wall'); + set(18, 8, 'floor'); + // Dunnies: walled room in the north-east, one doorway on its south face. box(60, 3, 76, 16, 'wall'); rect(62, 16, 63, 16, 'floor'); @@ -178,7 +183,7 @@ export type PropKind = | 'speaker' | 'discoball' | 'barShelf' | 'taps' | 'till' | 'glassStack' | 'glassRack' | 'boothTable' | 'highTable' | 'sinkRow' - | 'buttBin' | 'crate' | 'plant' | 'wetFloor' | 'poolTable' + | 'buttBin' | 'crate' | 'plant' | 'wetFloor' | 'poolTable' | 'dishwasher' | 'poster1' | 'poster2' | 'poster3' | 'cloak' | 'stampPodium' | 'arcade'; export interface PropEmissive { @@ -229,9 +234,10 @@ export const PROPS: readonly PropDef[] = Object.freeze([ P('glassStack', 'glassStack', 24, 4, 2, 1), P('taps', 'taps', 32, 4, 3, 1), P('till', 'till', 46, 4, 2, 1), - // glassie station (future role home — design §6) + // the kitchen (glassie territory — design §6) P('rackA', 'glassRack', 16, 4, 2, 1), P('rackB', 'glassRack', 16, 6, 2, 1), + P('dish', 'dishwasher', 15, 2, 2, 1, { colour: 0x50c060, radius: 20 }), // booths P('tblS1', 'boothTable', 25, 40, 2, 1, { colour: 0xd8a050, radius: 12 }), P('tblS2', 'boothTable', 32, 40, 2, 1, { colour: 0xd8a050, radius: 12 }), diff --git a/tests/floor/glassie.test.ts b/tests/floor/glassie.test.ts new file mode 100644 index 0000000..e440424 --- /dev/null +++ b/tests/floor/glassie.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { BREAK_AT, RACK_GLASSES, freshRack, stepRack, turnSharpness } from '../../src/scenes/floor/glassie'; + +describe('the glassie run (pure physics)', () => { + it('a smooth walk settles the rack and breaks nothing', () => { + let r = { glasses: RACK_GLASSES, wobble: 0.6 }; + let broke = 0; + for (let i = 0; i < 120; i++) { + const out = stepRack(r, 16, false, 0); + r = out.rack; + broke += out.broke; + } + expect(broke).toBe(0); + expect(r.wobble).toBe(0); + expect(r.glasses).toBe(RACK_GLASSES); + }); + + it('two quick crowd bumps break a glass; the wobble sheds with it', () => { + let r = freshRack(); + r = stepRack(r, 16, true, 0).rack; + expect(r.glasses).toBe(RACK_GLASSES); // one bump is a scare, not a smash + const out = stepRack(r, 16, true, 0); + expect(out.broke).toBe(1); + expect(out.rack.glasses).toBe(RACK_GLASSES - 1); + expect(out.rack.wobble).toBeLessThan(BREAK_AT); + }); + + it('sawing the stick hard eventually costs glass even with no crowd', () => { + let r = freshRack(); + let broke = 0; + for (let i = 0; i < 300; i++) { + const out = stepRack(r, 16, false, 1); + r = out.rack; + broke += out.broke; + } + expect(broke).toBeGreaterThan(0); + }); + + it('a rack cannot go below zero glasses', () => { + const r = { glasses: 1, wobble: 0.99 }; + const out = stepRack(r, 16, true, 1); + expect(out.rack.glasses).toBe(0); + const again = stepRack(out.rack, 16, true, 1); + expect(again.rack.glasses).toBe(0); + expect(again.broke).toBe(0); + }); + + it('turnSharpness is 0 for steady cruise, 1 for a full reversal', () => { + expect(turnSharpness(100, 0, 100, 0, 116)).toBe(0); + expect(turnSharpness(116, 0, -116, 0, 116)).toBe(1); + expect(turnSharpness(0, 0, 60, 0, 0)).toBe(0); // junk maxSpeed is inert + }); +}); diff --git a/tools/props_import.py b/tools/props_import.py index 5427208..1dae2c4 100644 --- a/tools/props_import.py +++ b/tools/props_import.py @@ -26,6 +26,7 @@ TARGETS = { "poster1": (32, 16), "poster2": (32, 16), "poster3": (32, 16), "cloak": (16, 48), "stampPodium": (16, 16), "arcade": (32, 32), "highTable": (16, 16), "plant": (16, 16), "wetFloor": (16, 16), "poolTable": (48, 32), + "dishwasher": (32, 16), # the door-scene backdrop plate is special-cased: full size, no quantise "street:backdrop": (640, 176), }