LANE-FLOOR: the Floor — patrol, flashlight/UV, cut-off, stalls, pat-down
FloorDemoScene (F from Parade): an 80x45-tile venue you patrol in the dark with a flashlight cone that reveals detail, plus all three v0.2 infractions and the UV stamp check. Self-populates from the patron generator; consumes Patron[] only, so Phase 2 can hand it the real admitted list. Pure, tested logic (venueMap, cone, player, crowdSim, bangJudge, escalation, patDown); Phaser layer stays thin (FloorView + three overlays). 120 new tests, 169 total. Two bugs found by running it rather than by the suite, both fixed: - bump penalties had a per-agent cooldown but no sim-wide bound, so the leak rate scaled with crowd size and an unattended venue pinned vibe to 0 / aggro to 100 by ~1AM. Sim-wide gate added, with a regression test. - beat.update() sat below the overlay early-return, freezing the beat clock behind modals and making the stall rhythm game unwinnable. Clock and beat now always run. Also retuned dwell times and the tile/darkness palette for readability, and dropped Light2D in favour of the cone mask it was fighting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9805ff8fe5
commit
3eee8432d4
@ -95,3 +95,86 @@ walking under an ID card claiming 18+ — that's the whole game in one image.
|
|||||||
DOOR first, JUICE second, FLOOR third.
|
DOOR first, JUICE second, FLOOR third.
|
||||||
3. Every session ends with the gate + a SESSION block here + a push. Fable
|
3. Every session ends with the gate + a SESSION block here + a push. Fable
|
||||||
reviews on request ("review the lanes").
|
reviews on request ("review the lanes").
|
||||||
|
|
||||||
|
---
|
||||||
|
### SESSION — LANE-FLOOR — 2026-07-19 18:50
|
||||||
|
**Branch/commits:** lane/floor @ 9805ff8..(this commit) — branched from main, worktree `../not-tonight-floor`
|
||||||
|
**Done:**
|
||||||
|
- `FloorDemoScene` (menu key **F** from Parade): 80x45-tile venue (1280x720, 2x screen,
|
||||||
|
camera-follow), 44-patron crowd sim, flashlight cone, UV mode, all three v0.2
|
||||||
|
infractions + the stamp check, playable end-to-end.
|
||||||
|
- Pure logic, all tested: `venueMap` (programmatic layout + anchors + stalls),
|
||||||
|
`cone` (vision maths), `player` (heavy accel + wall slide), `crowdSim`
|
||||||
|
(archetype-weighted anchor wandering, clock-driven intoxication drift, beat-driven
|
||||||
|
dance bob, stall double-occupancy, escort-out), `bangJudge` (nearest-beat-boundary
|
||||||
|
timing), `escalation` (5x3 cut-off matrix), `patDown` (pocket layout + scoring).
|
||||||
|
- Phaser layer: `FloorView` (baked tile texture, silhouette/detail split with a
|
||||||
|
cone GeometryMask, neon glow pools, UV stamp dots) + three overlays
|
||||||
|
(`CutOffOverlay`, `StallOverlay`, `PatDownOverlay`).
|
||||||
|
- All floor dialogue in `src/scenes/floor/strings.ts` (see deviation #1).
|
||||||
|
**How to see it:** `npm run dev` → press **F** at the Parade → WASD patrol, **TAB** UV,
|
||||||
|
**E** interact, **SPACE** bang, **ESC** cancel, **R** reseed. Walk into the dark until
|
||||||
|
the torch finds someone. In Claude's preview pane drive frames per CLAUDE.md.
|
||||||
|
**Tests:** 169 passing / 0 failing (49 pre-existing + 120 new floor tests).
|
||||||
|
**Verified in-browser, not just green:** cut-off (correct rung ejects, one rung too
|
||||||
|
soft on a maggot lets them stagger free), escort walks to the exit and logs an
|
||||||
|
ejection, stall busts on 3 on-beat bangs and *never* busts on 8 off-beat ones,
|
||||||
|
pat-down bins an item (+2 vibe, right flavour line) and leaks it on timeout (-1.5),
|
||||||
|
UV shows the stamp glow and marches an unstamped patron out with a Kayden radio line.
|
||||||
|
**Broke / known-wonky:**
|
||||||
|
- **Two bugs the test suite could not catch, both found by running it and both fixed:**
|
||||||
|
1. *Ambient meter collapse.* The bump penalty had a per-agent cooldown but no
|
||||||
|
sim-wide bound, so leak rate scaled with crowd size — an unattended venue
|
||||||
|
pinned vibe to 0 and aggro to 100 by ~1AM before the player touched anything.
|
||||||
|
Added a sim-wide gate (`BUMP_GATE_MS`); an unattended night now drifts
|
||||||
|
50→27 vibe / 0→34 aggro. Regression test added.
|
||||||
|
2. *Frozen beat behind modals.* `beat.update()` sat below the overlay early-return
|
||||||
|
in the scene loop, so opening the stall minigame stopped the beat clock and the
|
||||||
|
rhythm game could never receive a tick. Clock + beat now run regardless of overlays.
|
||||||
|
- Dwell times raised (2.5-9s → 7-22s): at the old values ~68% of the crowd was
|
||||||
|
permanently in transit and the bar/dance floor read as empty.
|
||||||
|
- Tile palette + darkness alpha retuned; at the original values the venue
|
||||||
|
multiplied down to near-black and you could not tell a dance floor from a wall.
|
||||||
|
- Light2D dropped in `FloorView` (documented in-file) — the pipeline swap fights the
|
||||||
|
cone GeometryMask, and the mask is the actual gameplay. The look is carried by a
|
||||||
|
darkness sheet + additive cone + neon glow pools instead.
|
||||||
|
- `GeometryMask` on a container is a WebGL-first path; fine under `Phaser.AUTO`,
|
||||||
|
would misbehave if anything ever forced the Canvas renderer.
|
||||||
|
- The 16x16 top-down doll reads as a coloured ring from above (dark hair over
|
||||||
|
outer-layer shoulders). Correct per Phase 0's `dollPlan`, just noting it.
|
||||||
|
- Stall interiors are deliberately sealed (`stall` walkable, `stallDoor` not), so
|
||||||
|
patrons are moved in/out via `StallDef.inside`/`.door` rather than pathed.
|
||||||
|
**Contract change requests:**
|
||||||
|
1. **`incident:log` event.** `core` exposes no incident API and `EventMap` is frozen,
|
||||||
|
so `FloorDemoScene` appends to `NightState.incidents` directly (marked
|
||||||
|
`// TODO(contract):`). Three lanes writing that array directly is exactly the
|
||||||
|
shared-mutable-state fight the bus discipline exists to prevent, and Phase 3's
|
||||||
|
inspector/audit needs a single writer. Requesting
|
||||||
|
`'incident:log': IncidentRecord` plus a handler in `core/meters.ts`.
|
||||||
|
2. **Aggro has no decay.** `Meters` only ratchets aggro upward, so it is a one-way
|
||||||
|
trip to 100 over a long night. Ambient pressure is now bounded (above), but a slow
|
||||||
|
downward drift on a quiet floor would make the meter a *state* rather than a
|
||||||
|
countdown. Flagging as a design/contract question, not patching it unilaterally.
|
||||||
|
**Questions for reviewer:**
|
||||||
|
1. **Floor strings placement.** CONTRACTS.md §7 says Aussie dialogue lives in
|
||||||
|
`src/data/strings/`, but `data/` is LANE-0's and lanes commit only in their own
|
||||||
|
dirs. Parked in `src/scenes/floor/strings.ts`. Move it to
|
||||||
|
`src/data/strings/floor.ts` on your word — it is a pure file move.
|
||||||
|
2. **`.gitignore` gap (affects every lane using a worktree).** It lists
|
||||||
|
`node_modules/` with a trailing slash, which does not match a *symlinked*
|
||||||
|
`node_modules` — worktrees that symlink it show `?? node_modules` as untracked.
|
||||||
|
I staged paths explicitly, but a lane running `git add -A` would commit a symlink
|
||||||
|
pointing at an absolute path on John's machine. One-char fix (drop the slash);
|
||||||
|
left alone since `.gitignore` is not in my lane.
|
||||||
|
3. Escalation numbers (clean +4 vibe, scene -3/+5 per rung of overreach) are
|
||||||
|
deliberately small so aggression never feels rewarded. Confirm the balance intent
|
||||||
|
before LANE-DOOR mirrors it at the rope.
|
||||||
|
**Next session should:**
|
||||||
|
- Phase 2 integration: replace the self-populating spawner with the real admitted-patron
|
||||||
|
list from LANE-DOOR (the scene already only ever consumes `Patron[]`).
|
||||||
|
- Swap `StubBeatClock` for LANE-JUICE's `TechnoEngine` — `StallOverlay` takes
|
||||||
|
`beatIntervalMs` in its constructor and reads `beat:tick` off the bus, so it should
|
||||||
|
be a one-line change.
|
||||||
|
- Fight-brewing infraction (design §3.2) is specced but NOT built — the only v0.2
|
||||||
|
floor mechanic still missing.
|
||||||
|
- Replace `HudStub` with LANE-JUICE's real meter HUD.
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { BootScene } from './scenes/shared/BootScene';
|
import { BootScene } from './scenes/shared/BootScene';
|
||||||
import { ParadeScene } from './scenes/shared/ParadeScene';
|
import { ParadeScene } from './scenes/shared/ParadeScene';
|
||||||
|
import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
|
||||||
|
|
||||||
const game = new Phaser.Game({
|
const game = new Phaser.Game({
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
@ -13,7 +14,7 @@ const game = new Phaser.Game({
|
|||||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||||
zoom: Phaser.Scale.MAX_ZOOM,
|
zoom: Phaser.Scale.MAX_ZOOM,
|
||||||
},
|
},
|
||||||
scene: [BootScene, ParadeScene],
|
scene: [BootScene, ParadeScene, FloorDemoScene],
|
||||||
});
|
});
|
||||||
|
|
||||||
// debug handle (harmless in prod; used by dev tooling)
|
// debug handle (harmless in prod; used by dev tooling)
|
||||||
|
|||||||
431
src/scenes/floor/FloorDemoScene.ts
Normal file
431
src/scenes/floor/FloorDemoScene.ts
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
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 './strings';
|
||||||
|
|
||||||
|
const VIEW_H = 360;
|
||||||
|
const INTERACT_RANGE = 46;
|
||||||
|
const MAX_CROWD = 44;
|
||||||
|
const SPAWN_EVERY_MS = 420;
|
||||||
|
|
||||||
|
/** What pressing E on the current target would do. */
|
||||||
|
type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp';
|
||||||
|
|
||||||
|
interface InteractTarget {
|
||||||
|
kind: InteractKind;
|
||||||
|
agent?: Agent;
|
||||||
|
stallId?: string;
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LANE-FLOOR demo: a populated venue you patrol in the dark, with all three v0.2
|
||||||
|
* infractions plus the UV stamp check live. Self-populates from the patron
|
||||||
|
* generator; Phase 2 integration will hand it the real admitted-patron list
|
||||||
|
* instead, which is why everything downstream only ever sees Patron[].
|
||||||
|
*/
|
||||||
|
export class FloorDemoScene extends Phaser.Scene {
|
||||||
|
private bus!: EventBus;
|
||||||
|
private rng!: SeededRNG;
|
||||||
|
private clock!: GameClock;
|
||||||
|
private beat!: StubBeatClock;
|
||||||
|
private meters!: Meters;
|
||||||
|
private night!: NightState;
|
||||||
|
private hud!: HudStub;
|
||||||
|
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() {
|
||||||
|
super('FloorDemo');
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
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));
|
||||||
|
|
||||||
|
this.resetRun(this.seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
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') 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.
|
||||||
|
this.clock.update(dt);
|
||||||
|
this.beat.update(dt);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
this.spawnAccMs += dt;
|
||||||
|
while (this.spawnAccMs > SPAWN_EVERY_MS && this.crowd.agents.length < MAX_CROWD) {
|
||||||
|
this.spawnAccMs -= SPAWN_EVERY_MS;
|
||||||
|
this.spawnOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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 === '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 {
|
||||||
|
// TODO(contract): core has no incident API and EventMap is frozen, so the
|
||||||
|
// floor appends to its own NightState. Requested `incident:log` in LANEHANDOVER.
|
||||||
|
this.night.incidents.push({ 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 {
|
||||||
|
this.crowd?.destroy();
|
||||||
|
this.view?.destroy();
|
||||||
|
this.cutOff?.destroy();
|
||||||
|
this.stall?.destroy();
|
||||||
|
this.patDown?.destroy();
|
||||||
|
this.meters?.destroy();
|
||||||
|
this.hud?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
301
src/scenes/floor/FloorView.ts
Normal file
301
src/scenes/floor/FloorView.ts
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
import { renderDoll } from '../../patrons/doll';
|
||||||
|
import { dollPlan } from '../../patrons/dollPlan';
|
||||||
|
import { conePolygon, type ConeSpec } from './cone';
|
||||||
|
import { TILE, tileAt, tileColour, type VenueMap } from './venueMap';
|
||||||
|
import type { Agent } from './crowdSim';
|
||||||
|
|
||||||
|
// Pure drawing for the floor. Darkness is the mechanic: the venue is painted
|
||||||
|
// once into a flat texture, buried under a black sheet, and the torch cone is
|
||||||
|
// the only thing that puts colour back on a punter's face.
|
||||||
|
|
||||||
|
// Phaser Light2D is deliberately NOT used — the pipeline swap fights the
|
||||||
|
// geometry mask that drives the reveal, and the mask is the gameplay.
|
||||||
|
|
||||||
|
const TILES_TEX = 'floor:tiles';
|
||||||
|
const GLOW_TEX = 'floor:glow';
|
||||||
|
const PLAYER_TEX = 'floor:player';
|
||||||
|
const MARKER_TEX = 'floor:marker';
|
||||||
|
|
||||||
|
const D_TILES = 0;
|
||||||
|
const D_SIL = 10;
|
||||||
|
const D_DARK = 15;
|
||||||
|
const D_CONE = 16;
|
||||||
|
const D_NEON = 17;
|
||||||
|
const D_DETAIL = 20;
|
||||||
|
const D_MARKER = 40;
|
||||||
|
|
||||||
|
// A body in the dark should read as a presence, not vanish — the cone supplies
|
||||||
|
// identity (who, what they're wearing, how drunk), never the fact of them.
|
||||||
|
const SIL_TINT = 0x3d3d55;
|
||||||
|
const CONE_WARM = 0xffe0a8;
|
||||||
|
const CONE_UV = 0x7b2fd0;
|
||||||
|
const UV_STAMP = 0x50ff90;
|
||||||
|
const NEON_TINTS = [0xff3d9a, 0x38e0ff, 0xb45cff] as const;
|
||||||
|
|
||||||
|
const GLOW_SIZE = 64;
|
||||||
|
|
||||||
|
interface AgentGfx {
|
||||||
|
sil: Phaser.GameObjects.Image;
|
||||||
|
detail: Phaser.GameObjects.Image;
|
||||||
|
uvDot: Phaser.GameObjects.Image;
|
||||||
|
swayPx: number;
|
||||||
|
intox: number;
|
||||||
|
phase: number; // sway offset from dollSeed, so two neighbours don't wobble in lockstep
|
||||||
|
stamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FloorView {
|
||||||
|
private readonly scene: Phaser.Scene;
|
||||||
|
private readonly map: VenueMap;
|
||||||
|
|
||||||
|
private readonly tileImg: Phaser.GameObjects.Image;
|
||||||
|
private readonly neon: Phaser.GameObjects.Image[] = [];
|
||||||
|
private readonly dark: Phaser.GameObjects.Rectangle;
|
||||||
|
private readonly coneG: Phaser.GameObjects.Graphics;
|
||||||
|
private readonly maskG: Phaser.GameObjects.Graphics;
|
||||||
|
private readonly mask: Phaser.Display.Masks.GeometryMask;
|
||||||
|
private readonly detailC: Phaser.GameObjects.Container;
|
||||||
|
private readonly playerLight: Phaser.GameObjects.Image;
|
||||||
|
private readonly playerImg: Phaser.GameObjects.Image;
|
||||||
|
private readonly marker: Phaser.GameObjects.Image;
|
||||||
|
|
||||||
|
private readonly agents = new Map<string, AgentGfx>();
|
||||||
|
private frame = 0;
|
||||||
|
private uv = false;
|
||||||
|
|
||||||
|
constructor(scene: Phaser.Scene, map: VenueMap) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.map = map;
|
||||||
|
const worldW = map.width * TILE;
|
||||||
|
const worldH = map.height * TILE;
|
||||||
|
|
||||||
|
this.bakeGlow();
|
||||||
|
this.bakePlayer();
|
||||||
|
this.bakeMarker();
|
||||||
|
this.bakeTiles(worldW, worldH);
|
||||||
|
|
||||||
|
this.tileImg = scene.add.image(0, 0, TILES_TEX).setOrigin(0, 0).setDepth(D_TILES);
|
||||||
|
|
||||||
|
this.dark = scene.add
|
||||||
|
.rectangle(0, 0, worldW, worldH, 0x000000, 0.5)
|
||||||
|
.setOrigin(0, 0)
|
||||||
|
.setDepth(D_DARK);
|
||||||
|
|
||||||
|
this.coneG = scene.add.graphics().setDepth(D_CONE);
|
||||||
|
this.coneG.setBlendMode(Phaser.BlendModes.ADD);
|
||||||
|
|
||||||
|
this.playerLight = scene.add
|
||||||
|
.image(0, 0, GLOW_TEX)
|
||||||
|
.setDepth(D_CONE)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
|
.setTint(0xffd9a0)
|
||||||
|
.setAlpha(0.35)
|
||||||
|
.setScale(1.1);
|
||||||
|
|
||||||
|
// Neon lives above the black sheet so the room has colour pools you can
|
||||||
|
// navigate by with the torch off.
|
||||||
|
for (let ty = 0; ty < map.height; ty++) {
|
||||||
|
for (let tx = 0; tx < map.width; tx++) {
|
||||||
|
if (tileAt(map, tx, ty) !== 'neon') continue;
|
||||||
|
const tint = NEON_TINTS[(tx * 7 + ty * 3) % NEON_TINTS.length] ?? NEON_TINTS[0];
|
||||||
|
this.neon.push(
|
||||||
|
scene.add
|
||||||
|
.image(tx * TILE + TILE / 2, ty * TILE + TILE / 2, GLOW_TEX)
|
||||||
|
.setDepth(D_NEON)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
|
.setTint(tint)
|
||||||
|
.setAlpha(0.72)
|
||||||
|
.setScale(3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.maskG = scene.make.graphics({}, false);
|
||||||
|
this.mask = this.maskG.createGeometryMask();
|
||||||
|
this.detailC = scene.add.container(0, 0).setDepth(D_DETAIL);
|
||||||
|
this.detailC.setMask(this.mask);
|
||||||
|
|
||||||
|
this.playerImg = scene.add.image(0, 0, PLAYER_TEX).setDepth(D_MARKER);
|
||||||
|
this.marker = scene.add.image(0, 0, MARKER_TEX).setDepth(D_MARKER).setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAgents(agents: readonly Agent[]): void {
|
||||||
|
this.frame++;
|
||||||
|
const t = this.scene.time.now;
|
||||||
|
|
||||||
|
for (const a of agents) {
|
||||||
|
if (a.gone) {
|
||||||
|
this.drop(a.patron.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let g = this.agents.get(a.patron.id);
|
||||||
|
if (!g) {
|
||||||
|
g = this.create(a);
|
||||||
|
this.agents.set(a.patron.id, g);
|
||||||
|
}
|
||||||
|
g.stamp = this.frame;
|
||||||
|
|
||||||
|
// Drunk tells are baked into the doll texture, so a punter who has kept
|
||||||
|
// drinking needs a fresh one — but only when they actually have.
|
||||||
|
if (Math.abs(g.intox - a.patron.intoxication) > 1e-3) {
|
||||||
|
g.intox = a.patron.intoxication;
|
||||||
|
const key = renderDoll(this.scene, a.patron, 'floorTopDown');
|
||||||
|
g.swayPx = dollPlan(a.patron, 'floorTopDown').swayPx;
|
||||||
|
g.sil.setTexture(key);
|
||||||
|
g.detail.setTexture(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bob = a.activity === 'dancing' ? 2.5 : 0.8;
|
||||||
|
const y = a.y - Math.abs(Math.sin(a.bobPhase * Math.PI * 2)) * bob;
|
||||||
|
const x = g.swayPx > 0 ? a.x + Math.sin(t / 260 + g.phase) * g.swayPx : a.x;
|
||||||
|
|
||||||
|
g.sil.setPosition(x, y);
|
||||||
|
g.detail.setPosition(x, y);
|
||||||
|
g.uvDot.setPosition(x + 4, y + 2);
|
||||||
|
g.detail.setVisible(!this.uv);
|
||||||
|
// No stamp, no dot — the absence is the tell.
|
||||||
|
g.uvDot.setVisible(this.uv && a.patron.flags.uvStamped === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, g] of this.agents) {
|
||||||
|
if (g.stamp !== this.frame) {
|
||||||
|
this.destroyGfx(g);
|
||||||
|
this.agents.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCone(spec: ConeSpec, uv: boolean): void {
|
||||||
|
this.uv = uv;
|
||||||
|
const pts = conePolygon(spec);
|
||||||
|
|
||||||
|
this.maskG.clear();
|
||||||
|
this.maskG.fillStyle(0xffffff, 1);
|
||||||
|
this.maskG.fillPoints(pts, true);
|
||||||
|
|
||||||
|
// Wide wash plus a hotter core, so the beam reads as a beam and not a wedge
|
||||||
|
// of flat colour.
|
||||||
|
const tint = uv ? CONE_UV : CONE_WARM;
|
||||||
|
this.coneG.clear();
|
||||||
|
this.coneG.fillStyle(tint, uv ? 0.16 : 0.2);
|
||||||
|
this.coneG.fillPoints(pts, true);
|
||||||
|
this.coneG.fillStyle(tint, uv ? 0.12 : 0.18);
|
||||||
|
this.coneG.fillPoints(conePolygon({ ...spec, range: spec.range * 0.55 }, 16), true);
|
||||||
|
|
||||||
|
this.playerLight.setTint(uv ? CONE_UV : 0xffd9a0);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlayer(x: number, y: number, facing: number): void {
|
||||||
|
this.playerImg.setPosition(x, y).setRotation(facing);
|
||||||
|
this.playerLight.setPosition(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInteractTarget(agent: Agent | null): void {
|
||||||
|
if (!agent) {
|
||||||
|
this.marker.setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.marker.setVisible(true).setPosition(agent.x, agent.y - 12);
|
||||||
|
this.marker.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this.scene.time.now / 260)));
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
for (const g of this.agents.values()) this.destroyGfx(g);
|
||||||
|
this.agents.clear();
|
||||||
|
this.detailC.clearMask(true);
|
||||||
|
this.detailC.destroy();
|
||||||
|
this.maskG.destroy();
|
||||||
|
this.coneG.destroy();
|
||||||
|
this.dark.destroy();
|
||||||
|
this.tileImg.destroy();
|
||||||
|
for (const n of this.neon) n.destroy();
|
||||||
|
this.neon.length = 0;
|
||||||
|
this.playerLight.destroy();
|
||||||
|
this.playerImg.destroy();
|
||||||
|
this.marker.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private create(a: Agent): AgentGfx {
|
||||||
|
const key = renderDoll(this.scene, a.patron, 'floorTopDown');
|
||||||
|
const sil = this.scene.add.image(a.x, a.y, key).setDepth(D_SIL).setTint(SIL_TINT);
|
||||||
|
const detail = this.scene.add.image(a.x, a.y, key);
|
||||||
|
const uvDot = this.scene.add
|
||||||
|
.image(a.x, a.y, GLOW_TEX)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
|
.setTint(UV_STAMP)
|
||||||
|
.setScale(0.16)
|
||||||
|
.setVisible(false);
|
||||||
|
this.detailC.add(detail);
|
||||||
|
this.detailC.add(uvDot);
|
||||||
|
return {
|
||||||
|
sil,
|
||||||
|
detail,
|
||||||
|
uvDot,
|
||||||
|
swayPx: dollPlan(a.patron, 'floorTopDown').swayPx,
|
||||||
|
intox: a.patron.intoxication,
|
||||||
|
phase: (a.patron.dollSeed % 628) / 100,
|
||||||
|
stamp: this.frame,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private drop(id: string): void {
|
||||||
|
const g = this.agents.get(id);
|
||||||
|
if (!g) return;
|
||||||
|
this.destroyGfx(g);
|
||||||
|
this.agents.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroyGfx(g: AgentGfx): void {
|
||||||
|
g.sil.destroy();
|
||||||
|
g.detail.destroy();
|
||||||
|
g.uvDot.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bakeTiles(worldW: number, worldH: number): void {
|
||||||
|
if (this.scene.textures.exists(TILES_TEX)) return;
|
||||||
|
const g = this.scene.make.graphics({}, false);
|
||||||
|
for (let ty = 0; ty < this.map.height; ty++) {
|
||||||
|
for (let tx = 0; tx < this.map.width; tx++) {
|
||||||
|
g.fillStyle(tileColour(tileAt(this.map, tx, ty)), 1);
|
||||||
|
g.fillRect(tx * TILE, ty * TILE, TILE, TILE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.generateTexture(TILES_TEX, worldW, worldH);
|
||||||
|
g.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bakeGlow(): void {
|
||||||
|
if (this.scene.textures.exists(GLOW_TEX)) return;
|
||||||
|
const g = this.scene.make.graphics({}, false);
|
||||||
|
const c = GLOW_SIZE / 2;
|
||||||
|
// Stacked translucent discs — cheap radial falloff without a canvas gradient.
|
||||||
|
for (let i = 16; i >= 1; i--) {
|
||||||
|
g.fillStyle(0xffffff, 0.055);
|
||||||
|
g.fillCircle(c, c, (i / 16) * c);
|
||||||
|
}
|
||||||
|
g.generateTexture(GLOW_TEX, GLOW_SIZE, GLOW_SIZE);
|
||||||
|
g.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bakePlayer(): void {
|
||||||
|
if (this.scene.textures.exists(PLAYER_TEX)) return;
|
||||||
|
const g = this.scene.make.graphics({}, false);
|
||||||
|
g.fillStyle(0x0a0a10, 1);
|
||||||
|
g.fillCircle(7, 7, 5);
|
||||||
|
g.fillStyle(0xdfe6ff, 1);
|
||||||
|
g.fillCircle(7, 7, 4);
|
||||||
|
g.fillRect(7, 6, 7, 3); // torch nose, points +x so rotation == facing
|
||||||
|
g.generateTexture(PLAYER_TEX, 14, 14);
|
||||||
|
g.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bakeMarker(): void {
|
||||||
|
if (this.scene.textures.exists(MARKER_TEX)) return;
|
||||||
|
const g = this.scene.make.graphics({}, false);
|
||||||
|
g.fillStyle(0xffd23f, 1);
|
||||||
|
g.fillTriangle(0, 0, 8, 0, 4, 6);
|
||||||
|
g.generateTexture(MARKER_TEX, 8, 6);
|
||||||
|
g.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/scenes/floor/bangJudge.ts
Normal file
76
src/scenes/floor/bangJudge.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
export interface BeatTick {
|
||||||
|
beatIndex: number;
|
||||||
|
audioTimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BangGrade = 'perfect' | 'ok' | 'miss';
|
||||||
|
|
||||||
|
export const PERFECT_MS = 60;
|
||||||
|
export const OK_MS = 130;
|
||||||
|
export const BANGS_TO_BUST = 3;
|
||||||
|
|
||||||
|
const AGGRO_PER_MISS = 0.8;
|
||||||
|
const AGGRO_CAP = 6;
|
||||||
|
const VIBE_BUSTED = 9;
|
||||||
|
const VIBE_FIZZLED = -1.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far (ms, absolute) a bang landed from the NEAREST beat boundary implied by
|
||||||
|
* lastTick. Boundaries are lastTick.audioTimeMs + k*beatIntervalMs for integer k
|
||||||
|
* (k may be negative — a bang can land just BEFORE the tick it is judged against).
|
||||||
|
*/
|
||||||
|
export function beatError(bangTimeMs: number, lastTick: BeatTick, beatIntervalMs: number): number {
|
||||||
|
const delta = bangTimeMs - lastTick.audioTimeMs;
|
||||||
|
// Degenerate clock: every point is its own boundary, so the tick is all we have.
|
||||||
|
if (!(beatIntervalMs > 0)) return Math.abs(delta);
|
||||||
|
const nearest = Math.round(delta / beatIntervalMs) * beatIntervalMs;
|
||||||
|
return Math.abs(delta - nearest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function judgeBang(bangTimeMs: number, lastTick: BeatTick, beatIntervalMs: number): BangGrade {
|
||||||
|
const err = beatError(bangTimeMs, lastTick, beatIntervalMs);
|
||||||
|
if (err <= PERFECT_MS) return 'perfect';
|
||||||
|
if (err <= OK_MS) return 'ok';
|
||||||
|
return 'miss';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StallState {
|
||||||
|
combo: number;
|
||||||
|
bangs: number;
|
||||||
|
busted: boolean;
|
||||||
|
aggro: number;
|
||||||
|
lastGrade: BangGrade | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function freshStall(): StallState {
|
||||||
|
return { combo: 0, bangs: 0, busted: false, aggro: 0, lastGrade: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure: returns the NEXT state, never mutates the input. */
|
||||||
|
export function applyBang(s: StallState, grade: BangGrade): StallState {
|
||||||
|
const hit = grade !== 'miss';
|
||||||
|
const combo = hit ? s.combo + 1 : 0;
|
||||||
|
return {
|
||||||
|
combo,
|
||||||
|
bangs: s.bangs + 1,
|
||||||
|
// Once the door gives, it stays given.
|
||||||
|
busted: s.busted || combo >= BANGS_TO_BUST,
|
||||||
|
aggro: hit ? s.aggro : s.aggro + AGGRO_PER_MISS,
|
||||||
|
lastGrade: grade,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StallOutcome {
|
||||||
|
vibeDelta: number;
|
||||||
|
aggroDelta: number;
|
||||||
|
solved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Final scoring when the stall minigame ends (busted, or the player walked away). */
|
||||||
|
export function scoreStall(s: StallState): StallOutcome {
|
||||||
|
return {
|
||||||
|
vibeDelta: s.busted ? VIBE_BUSTED : VIBE_FIZZLED,
|
||||||
|
aggroDelta: Math.min(s.aggro, AGGRO_CAP),
|
||||||
|
solved: s.busted,
|
||||||
|
};
|
||||||
|
}
|
||||||
65
src/scenes/floor/cone.ts
Normal file
65
src/scenes/floor/cone.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// Flashlight maths. Pure — the scene turns these numbers into a mask, the AI
|
||||||
|
// uses them to know whether it's being watched.
|
||||||
|
|
||||||
|
import type { WorldPoint } from './venueMap';
|
||||||
|
|
||||||
|
export interface ConeSpec {
|
||||||
|
x: number; y: number; // world px, cone apex (the player)
|
||||||
|
facing: number; // radians
|
||||||
|
halfAngle: number; // radians
|
||||||
|
range: number; // world px
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NORMAL_CONE = { halfAngle: 0.55, range: 190 } as const; // ~63 deg wide
|
||||||
|
export const UV_CONE = { halfAngle: 0.42, range: 140 } as const; // tighter, shorter
|
||||||
|
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
|
||||||
|
/** Shortest signed angular difference a-b, wrapped to (-PI, PI]. */
|
||||||
|
export function angleDelta(a: number, b: number): number {
|
||||||
|
let d = (a - b) % TAU;
|
||||||
|
if (d <= -Math.PI) d += TAU;
|
||||||
|
else if (d > Math.PI) d -= TAU;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inCone(spec: ConeSpec, x: number, y: number): boolean {
|
||||||
|
const dx = x - spec.x;
|
||||||
|
const dy = y - spec.y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
if (dist > spec.range) return false;
|
||||||
|
if (dist === 0) return true; // standing on the torch
|
||||||
|
return Math.abs(angleDelta(Math.atan2(dy, dx), spec.facing)) <= spec.halfAngle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 0 = invisible, 1 = fully lit. Falls off toward the cone edge AND with distance.
|
||||||
|
* Must return 0 outside the cone and >0 strictly inside it.
|
||||||
|
*/
|
||||||
|
export function coneVisibility(spec: ConeSpec, x: number, y: number): number {
|
||||||
|
const dx = x - spec.x;
|
||||||
|
const dy = y - spec.y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
if (dist > spec.range) return 0;
|
||||||
|
if (dist === 0) return 1;
|
||||||
|
const off = Math.abs(angleDelta(Math.atan2(dy, dx), spec.facing));
|
||||||
|
if (off > spec.halfAngle) return 0;
|
||||||
|
const t = spec.halfAngle === 0 ? 0 : off / spec.halfAngle;
|
||||||
|
const d = spec.range === 0 ? 0 : dist / spec.range;
|
||||||
|
return (1 - t * t) * (1 - d * d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polygon points (world px) approximating the cone, for a Phaser mask/graphics fill. */
|
||||||
|
export function conePolygon(spec: ConeSpec, segments = 24): WorldPoint[] {
|
||||||
|
const steps = Math.max(1, Math.floor(segments));
|
||||||
|
const apex: WorldPoint = { x: spec.x, y: spec.y };
|
||||||
|
const pts: WorldPoint[] = [apex];
|
||||||
|
const start = spec.facing - spec.halfAngle;
|
||||||
|
const span = spec.halfAngle * 2;
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const a = start + (span * i) / steps;
|
||||||
|
pts.push({ x: spec.x + Math.cos(a) * spec.range, y: spec.y + Math.sin(a) * spec.range });
|
||||||
|
}
|
||||||
|
pts.push({ ...apex }); // explicit close so callers can stroke it without fixing up
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
574
src/scenes/floor/crowdSim.ts
Normal file
574
src/scenes/floor/crowdSim.ts
Normal file
@ -0,0 +1,574 @@
|
|||||||
|
// The living crowd. Renderer-agnostic on purpose: this owns where everyone is
|
||||||
|
// and what they're doing, the Phaser layer just draws whatever it finds here.
|
||||||
|
|
||||||
|
import type { EventBus } from '../../core/EventBus';
|
||||||
|
import type { RngStream } from '../../core/SeededRNG';
|
||||||
|
import type { Archetype, DrunkStage, Patron } from '../../data/types';
|
||||||
|
import { drunkStage } from '../../rules/drunk';
|
||||||
|
import { isWalkable, isWalkableWorld, tileToWorld, worldToTile } from './venueMap';
|
||||||
|
import type { StallDef, VenueMap, WorldPoint } from './venueMap';
|
||||||
|
|
||||||
|
export type Activity =
|
||||||
|
| 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving';
|
||||||
|
|
||||||
|
export interface Agent {
|
||||||
|
patron: Patron;
|
||||||
|
x: number; y: number;
|
||||||
|
vx: number; vy: number;
|
||||||
|
activity: Activity;
|
||||||
|
target: WorldPoint;
|
||||||
|
dwellMs: number; // ms left at the current anchor
|
||||||
|
bobPhase: number; // 0..1, advanced on beat:tick, drives the dance bob
|
||||||
|
stallId?: string; // set while activity === 'inStall'
|
||||||
|
handled: boolean; // player has already dealt with this one
|
||||||
|
gone: boolean; // reached the exit / left the sim; scene should drop the sprite
|
||||||
|
lastBumpMs: number; // cooldown so bumping can't spam meters
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrowdSimOpts {
|
||||||
|
map: VenueMap;
|
||||||
|
bus: EventBus;
|
||||||
|
rng: RngStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Haunt = 'bar' | 'dance' | 'booth' | 'toilet' | 'smoke';
|
||||||
|
|
||||||
|
const HAUNT_KINDS: readonly Haunt[] = ['bar', 'dance', 'booth', 'toilet', 'smoke'];
|
||||||
|
|
||||||
|
// Where each sort of punter gravitates. Weights, not probabilities — every
|
||||||
|
// archetype needs an entry or the crowd stops moving.
|
||||||
|
const HAUNTS: Readonly<Record<Archetype, Readonly<Record<Haunt, number>>>> = {
|
||||||
|
fresh18: { bar: 3, dance: 6, booth: 2, toilet: 2, smoke: 1 },
|
||||||
|
almost18: { bar: 2, dance: 5, booth: 3, toilet: 2, smoke: 2 },
|
||||||
|
financeBro: { bar: 8, dance: 2, booth: 3, toilet: 2, smoke: 1 },
|
||||||
|
influencer: { bar: 2, dance: 8, booth: 2, toilet: 2, smoke: 1 },
|
||||||
|
regular: { bar: 6, dance: 2, booth: 3, toilet: 1, smoke: 4 },
|
||||||
|
ownersMate: { bar: 7, dance: 2, booth: 5, toilet: 1, smoke: 2 },
|
||||||
|
quietMessy: { bar: 3, dance: 1, booth: 7, toilet: 3, smoke: 2 },
|
||||||
|
inspector: { bar: 2, dance: 1, booth: 4, toilet: 2, smoke: 1 },
|
||||||
|
punter: { bar: 4, dance: 4, booth: 3, toilet: 2, smoke: 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVITY_FOR: Readonly<Record<Haunt, Activity>> = {
|
||||||
|
bar: 'atBar',
|
||||||
|
dance: 'dancing',
|
||||||
|
booth: 'inBooth',
|
||||||
|
toilet: 'inStall',
|
||||||
|
smoke: 'smoking',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPEED_MIN = 28;
|
||||||
|
const SPEED_MAX = 44;
|
||||||
|
// Transits across a 1280x720 venue take ~30s at these speeds, so short dwells
|
||||||
|
// left ~68% of the crowd permanently walking and the bar/dance floor reading as
|
||||||
|
// empty. The player has to be able to FIND infractions where they'd expect them.
|
||||||
|
const DWELL_MIN_MS = 7000;
|
||||||
|
const DWELL_MAX_MS = 22_000;
|
||||||
|
const ARRIVE_PX = 2.5;
|
||||||
|
const WAYPOINT_PX = 9; // half a tile — loose enough that a drunk sway can't orbit one
|
||||||
|
const MAX_STEP_MS = 50;
|
||||||
|
const STUCK_MS = 700;
|
||||||
|
|
||||||
|
// Intoxication per in-game minute for a patron with zero tolerance. At the bar
|
||||||
|
// you're actively buying; everywhere else you're nursing what you've got.
|
||||||
|
const BAR_DRIFT_PER_MIN = 0.011;
|
||||||
|
const AMBIENT_DRIFT_PER_MIN = 0.0012;
|
||||||
|
|
||||||
|
const SWAY_BY_STAGE: Readonly<Record<DrunkStage, number>> = {
|
||||||
|
sober: 0, tipsy: 4, loose: 10, messy: 18, maggot: 26,
|
||||||
|
};
|
||||||
|
const SWAY_RATE = 0.006; // radians per ms — roughly a one-second wobble
|
||||||
|
const STUMBLE_CHANCE_PER_SEC = 0.5;
|
||||||
|
const STUMBLE_MIN_MS = 180;
|
||||||
|
const STUMBLE_MAX_MS = 520;
|
||||||
|
const STUMBLE_ARC = 0.9; // worst heading error, radians
|
||||||
|
|
||||||
|
const BUMP_DIST_PX = 10;
|
||||||
|
const BUMP_COOLDOWN_MS = 12_000;
|
||||||
|
// Per-agent cooldown alone doesn't bound the LEAK — the emission rate scales
|
||||||
|
// with how many rowdy patrons are on the floor, so a full late-night venue
|
||||||
|
// pinned vibe to 0 and aggro to 100 by ~1AM with the player doing nothing.
|
||||||
|
// This gate makes ambient chaos cost a roughly fixed amount per night
|
||||||
|
// regardless of crowd size; the player's own mistakes stay legible against it.
|
||||||
|
const BUMP_GATE_MS = 8000;
|
||||||
|
const BUMP_VIBE = -0.4;
|
||||||
|
const BUMP_AGGRO = 0.6;
|
||||||
|
|
||||||
|
// Chance a dunny-bound patron shoulders into a cubicle that's already busy.
|
||||||
|
const STALL_SHARE_CHANCE = 0.3;
|
||||||
|
const STALL_CAP = 2;
|
||||||
|
|
||||||
|
const ESCORT_SPEED = 22; // the walk of a bloke who knows it's over
|
||||||
|
const ESCORT_AHEAD_PX = 18; // they only shuffle on while you're this close behind
|
||||||
|
const BOB_STEP = 0.25; // four beats to a full bob, i.e. one bar
|
||||||
|
|
||||||
|
const NEIGHBOURS = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const;
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
haunt: Haunt;
|
||||||
|
stallId?: string; // reserved while walking, so the cap survives the trip over
|
||||||
|
path: WorldPoint[]; // detour waypoints; empty means steer straight at the target
|
||||||
|
bestDist: number; // closest approach to the current steer point so far
|
||||||
|
stuckMs: number;
|
||||||
|
stumbleMs: number;
|
||||||
|
stumbleAngle: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowdy(a: Agent): boolean {
|
||||||
|
const s = drunkStage(a.patron.intoxication);
|
||||||
|
return s === 'messy' || s === 'maggot';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic per-patron pace — same doll, same dawdle, every run. */
|
||||||
|
function paceOf(patron: Patron): number {
|
||||||
|
const span = SPEED_MAX - SPEED_MIN + 1;
|
||||||
|
return SPEED_MIN + (Math.abs(patron.dollSeed) % span);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CrowdSim {
|
||||||
|
readonly agents: Agent[] = [];
|
||||||
|
|
||||||
|
private readonly map: VenueMap;
|
||||||
|
private readonly bus: EventBus;
|
||||||
|
private readonly rng: RngStream;
|
||||||
|
private readonly plans = new WeakMap<Agent, Plan>();
|
||||||
|
private elapsedMs = 0;
|
||||||
|
private prevClockMin = Number.NaN;
|
||||||
|
private bumpGateMs = BUMP_GATE_MS;
|
||||||
|
|
||||||
|
constructor(opts: CrowdSimOpts) {
|
||||||
|
this.map = opts.map;
|
||||||
|
this.bus = opts.bus;
|
||||||
|
this.rng = opts.rng;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn(patron: Patron): Agent {
|
||||||
|
const entries = this.map.anchors.entry;
|
||||||
|
const at = entries.length > 0 ? this.rng.pick(entries) : { x: 0, y: 0 };
|
||||||
|
const agent: Agent = {
|
||||||
|
patron,
|
||||||
|
x: at.x,
|
||||||
|
y: at.y,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
activity: 'walking',
|
||||||
|
target: { x: at.x, y: at.y },
|
||||||
|
dwellMs: 0,
|
||||||
|
bobPhase: (Math.abs(patron.dollSeed) % 100) / 100,
|
||||||
|
handled: false,
|
||||||
|
gone: false,
|
||||||
|
lastBumpMs: BUMP_COOLDOWN_MS,
|
||||||
|
};
|
||||||
|
this.plans.set(agent, {
|
||||||
|
haunt: 'bar', path: [], bestDist: Infinity, stuckMs: 0, stumbleMs: 0, stumbleAngle: 0,
|
||||||
|
});
|
||||||
|
this.agents.push(agent);
|
||||||
|
this.chooseTarget(agent);
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** deltaMs of real time; clockMin is the in-game minute (drives intoxication drift). */
|
||||||
|
update(deltaMs: number, clockMin: number): void {
|
||||||
|
const ms = Math.max(0, Math.min(deltaMs, MAX_STEP_MS));
|
||||||
|
this.elapsedMs += ms;
|
||||||
|
this.bumpGateMs += ms;
|
||||||
|
|
||||||
|
this.drift(clockMin);
|
||||||
|
|
||||||
|
for (const a of this.agents) {
|
||||||
|
if (a.gone) continue;
|
||||||
|
a.lastBumpMs += ms;
|
||||||
|
if (a.activity === 'escorted') continue;
|
||||||
|
if (a.activity === 'walking') this.walk(a, ms);
|
||||||
|
else this.dwell(a, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bumps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call from a bus.on('beat:tick') handler. Advances dance bob only. */
|
||||||
|
onBeat(beatIndex: number): void {
|
||||||
|
// The downbeat lands harder than the rest of the bar.
|
||||||
|
const step = beatIndex % 4 === 0 ? BOB_STEP * 1.5 : BOB_STEP;
|
||||||
|
for (const a of this.agents) {
|
||||||
|
if (a.gone || a.activity !== 'dancing') continue;
|
||||||
|
a.bobPhase = (a.bobPhase + step) % 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agents currently inside each stall, keyed by stall id. */
|
||||||
|
stallOccupancy(): Map<string, Agent[]> {
|
||||||
|
const out = new Map<string, Agent[]>();
|
||||||
|
for (const s of this.map.stalls) out.set(s.id, []);
|
||||||
|
for (const a of this.agents) {
|
||||||
|
if (a.gone || a.activity !== 'inStall' || a.stallId === undefined) continue;
|
||||||
|
const list = out.get(a.stallId);
|
||||||
|
if (list) list.push(a);
|
||||||
|
else out.set(a.stallId, [a]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach to the player for a walk-to-the-exit escort. Returns false if already escorted. */
|
||||||
|
beginEscort(agent: Agent): boolean {
|
||||||
|
if (agent.gone || agent.activity === 'escorted' || agent.activity === 'leaving') return false;
|
||||||
|
const exit = this.nearestExit(agent.x, agent.y);
|
||||||
|
if (!exit) return false;
|
||||||
|
|
||||||
|
if (agent.activity === 'inStall') {
|
||||||
|
const stall = this.stallById(agent.stallId);
|
||||||
|
if (stall) {
|
||||||
|
agent.x = stall.door.x;
|
||||||
|
agent.y = stall.door.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agent.stallId = undefined;
|
||||||
|
agent.activity = 'escorted';
|
||||||
|
agent.handled = true;
|
||||||
|
agent.dwellMs = 0;
|
||||||
|
agent.target = exit;
|
||||||
|
|
||||||
|
const plan = this.plans.get(agent);
|
||||||
|
if (plan) {
|
||||||
|
plan.stallId = undefined;
|
||||||
|
plan.stumbleMs = 0;
|
||||||
|
plan.bestDist = Infinity;
|
||||||
|
plan.stuckMs = 0;
|
||||||
|
// Always route the perp walk — the door is a long way from the dunnies and
|
||||||
|
// a straight line would wedge them in a corner with you shouting at them.
|
||||||
|
plan.path = this.findPath(agent.x, agent.y, exit) ?? [];
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called each frame with the player position so escorted agents walk ahead of you. */
|
||||||
|
updateEscorts(playerX: number, playerY: number, deltaMs: number): void {
|
||||||
|
const dt = Math.max(0, Math.min(deltaMs, MAX_STEP_MS)) / 1000;
|
||||||
|
const step = ESCORT_SPEED * dt;
|
||||||
|
|
||||||
|
for (const a of this.agents) {
|
||||||
|
if (a.gone || a.activity !== 'escorted') continue;
|
||||||
|
const plan = this.plans.get(a);
|
||||||
|
if (!plan) continue;
|
||||||
|
|
||||||
|
const point = this.steerPoint(a, plan);
|
||||||
|
const dx = point.x - a.x;
|
||||||
|
const dy = point.y - a.y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
|
||||||
|
if (plan.path.length === 0 && dist <= Math.max(ARRIVE_PX, step)) {
|
||||||
|
a.x = point.x;
|
||||||
|
a.y = point.y;
|
||||||
|
a.vx = 0;
|
||||||
|
a.vy = 0;
|
||||||
|
a.activity = 'leaving';
|
||||||
|
a.gone = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// They shuffle on only while you're breathing down their neck; stop pushing
|
||||||
|
// and they stop walking.
|
||||||
|
if (Math.hypot(playerX - a.x, playerY - a.y) > ESCORT_AHEAD_PX) {
|
||||||
|
a.vx = 0;
|
||||||
|
a.vy = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ux = dx / (dist || 1);
|
||||||
|
const uy = dy / (dist || 1);
|
||||||
|
a.vx = ux * ESCORT_SPEED;
|
||||||
|
a.vy = uy * ESCORT_SPEED;
|
||||||
|
this.tryMove(a, ux * step, uy * step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
find(patronId: string): Agent | undefined {
|
||||||
|
return this.agents.find((a) => a.patron.id === patronId);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
// Nothing to unsubscribe — beat ticks are pushed in by the owning scene.
|
||||||
|
this.agents.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- internals ----
|
||||||
|
|
||||||
|
private drift(clockMin: number): void {
|
||||||
|
const prev = this.prevClockMin;
|
||||||
|
this.prevClockMin = clockMin;
|
||||||
|
if (Number.isNaN(prev)) return;
|
||||||
|
const mins = clockMin - prev;
|
||||||
|
if (mins <= 0) return;
|
||||||
|
|
||||||
|
for (const a of this.agents) {
|
||||||
|
if (a.gone) continue;
|
||||||
|
const base = a.activity === 'atBar' ? BAR_DRIFT_PER_MIN : AMBIENT_DRIFT_PER_MIN;
|
||||||
|
a.patron.intoxication = Math.min(1, a.patron.intoxication + base * (1 - a.patron.tolerance) * mins);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dwell(a: Agent, ms: number): void {
|
||||||
|
a.vx = 0;
|
||||||
|
a.vy = 0;
|
||||||
|
a.dwellMs -= ms;
|
||||||
|
if (a.dwellMs > 0) return;
|
||||||
|
|
||||||
|
if (a.activity === 'inStall') {
|
||||||
|
const stall = this.stallById(a.stallId);
|
||||||
|
if (stall) {
|
||||||
|
a.x = stall.door.x;
|
||||||
|
a.y = stall.door.y;
|
||||||
|
}
|
||||||
|
a.stallId = undefined;
|
||||||
|
}
|
||||||
|
a.dwellMs = 0;
|
||||||
|
a.activity = 'walking';
|
||||||
|
this.chooseTarget(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
private walk(a: Agent, ms: number): void {
|
||||||
|
const plan = this.plans.get(a);
|
||||||
|
if (!plan) return;
|
||||||
|
|
||||||
|
const dt = ms / 1000;
|
||||||
|
const speed = paceOf(a.patron);
|
||||||
|
const step = speed * dt;
|
||||||
|
const point = this.steerPoint(a, plan);
|
||||||
|
const dx = point.x - a.x;
|
||||||
|
const dy = point.y - a.y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
|
||||||
|
if (plan.path.length === 0 && dist <= Math.max(ARRIVE_PX, step)) {
|
||||||
|
this.arrive(a, plan);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walked into geometry the straight line can't solve (the dunny room is one
|
||||||
|
// long concave corner). Fall back to a real route rather than pushing at a wall.
|
||||||
|
if (dist < plan.bestDist - 0.5) {
|
||||||
|
plan.bestDist = dist;
|
||||||
|
plan.stuckMs = 0;
|
||||||
|
} else {
|
||||||
|
plan.stuckMs += ms;
|
||||||
|
if (plan.stuckMs >= STUCK_MS) this.unstick(a, plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
let heading = Math.atan2(dy, dx);
|
||||||
|
const stage = drunkStage(a.patron.intoxication);
|
||||||
|
|
||||||
|
if (plan.stumbleMs > 0) {
|
||||||
|
plan.stumbleMs -= ms;
|
||||||
|
heading += plan.stumbleAngle;
|
||||||
|
} else if ((stage === 'messy' || stage === 'maggot') && this.rng.chance(STUMBLE_CHANCE_PER_SEC * dt)) {
|
||||||
|
plan.stumbleMs = this.rng.int(STUMBLE_MIN_MS, STUMBLE_MAX_MS);
|
||||||
|
plan.stumbleAngle = (this.rng.next() * 2 - 1) * STUMBLE_ARC;
|
||||||
|
heading += plan.stumbleAngle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ux = Math.cos(heading);
|
||||||
|
const uy = Math.sin(heading);
|
||||||
|
// Sway rides perpendicular to travel — the wider the wobble, the drunker the walk.
|
||||||
|
const sway = Math.sin(this.elapsedMs * SWAY_RATE + a.patron.dollSeed) * SWAY_BY_STAGE[stage];
|
||||||
|
a.vx = ux * speed - uy * sway;
|
||||||
|
a.vy = uy * speed + ux * sway;
|
||||||
|
this.tryMove(a, a.vx * dt, a.vy * dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current thing to walk at: the next detour waypoint, else the target itself. */
|
||||||
|
private steerPoint(a: Agent, plan: Plan): WorldPoint {
|
||||||
|
while (plan.path.length > 0) {
|
||||||
|
const wp = plan.path[0];
|
||||||
|
if (!wp) {
|
||||||
|
plan.path.shift();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Math.hypot(wp.x - a.x, wp.y - a.y) > WAYPOINT_PX) return wp;
|
||||||
|
plan.path.shift();
|
||||||
|
plan.bestDist = Infinity;
|
||||||
|
plan.stuckMs = 0;
|
||||||
|
}
|
||||||
|
return a.target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unstick(a: Agent, plan: Plan): void {
|
||||||
|
plan.stuckMs = 0;
|
||||||
|
plan.bestDist = Infinity;
|
||||||
|
const path = this.findPath(a.x, a.y, a.target);
|
||||||
|
if (path && path.length > 0) plan.path = path;
|
||||||
|
else this.chooseTarget(a); // nowhere to go from here — find somewhere else to be
|
||||||
|
}
|
||||||
|
|
||||||
|
private arrive(a: Agent, plan: Plan): void {
|
||||||
|
a.vx = 0;
|
||||||
|
a.vy = 0;
|
||||||
|
a.dwellMs = this.rng.int(DWELL_MIN_MS, DWELL_MAX_MS);
|
||||||
|
|
||||||
|
if (plan.haunt === 'toilet' && plan.stallId !== undefined) {
|
||||||
|
const stall = this.stallById(plan.stallId);
|
||||||
|
if (stall) {
|
||||||
|
a.activity = 'inStall';
|
||||||
|
a.stallId = stall.id;
|
||||||
|
a.x = stall.inside.x;
|
||||||
|
a.y = stall.inside.y;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.x = a.target.x;
|
||||||
|
a.y = a.target.y;
|
||||||
|
a.activity = ACTIVITY_FOR[plan.haunt];
|
||||||
|
}
|
||||||
|
|
||||||
|
private chooseTarget(a: Agent): void {
|
||||||
|
const plan = this.plans.get(a);
|
||||||
|
if (!plan) return;
|
||||||
|
plan.stallId = undefined;
|
||||||
|
plan.path = [];
|
||||||
|
plan.bestDist = Infinity;
|
||||||
|
plan.stuckMs = 0;
|
||||||
|
|
||||||
|
const weights = HAUNTS[a.patron.archetype];
|
||||||
|
let haunt = this.rng.weighted(HAUNT_KINDS.map((k) => [k, weights[k]] as const));
|
||||||
|
|
||||||
|
if (haunt === 'toilet') {
|
||||||
|
const stall = this.claimStall();
|
||||||
|
if (stall) {
|
||||||
|
plan.haunt = 'toilet';
|
||||||
|
plan.stallId = stall.id;
|
||||||
|
a.target = stall.door;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
haunt = 'bar'; // every dunny's full — go queue for another drink instead
|
||||||
|
}
|
||||||
|
|
||||||
|
const spots = this.map.anchors[haunt];
|
||||||
|
plan.haunt = haunt;
|
||||||
|
if (spots.length > 0) a.target = this.rng.pick(spots);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reserve a cubicle, occasionally squeezing into one that's already busy. */
|
||||||
|
private claimStall(): StallDef | undefined {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const s of this.map.stalls) counts.set(s.id, 0);
|
||||||
|
const bump = (id: string | undefined): void => {
|
||||||
|
if (id === undefined) return;
|
||||||
|
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||||
|
};
|
||||||
|
for (const other of this.agents) {
|
||||||
|
if (other.gone) continue;
|
||||||
|
bump(other.stallId);
|
||||||
|
const p = this.plans.get(other);
|
||||||
|
if (other.activity === 'walking' && p) bump(p.stallId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const free = this.map.stalls.filter((s) => (counts.get(s.id) ?? 0) === 0);
|
||||||
|
const busy = this.map.stalls.filter((s) => {
|
||||||
|
const n = counts.get(s.id) ?? 0;
|
||||||
|
return n > 0 && n < STALL_CAP;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (busy.length > 0 && (free.length === 0 || this.rng.chance(STALL_SHARE_CHANCE))) {
|
||||||
|
return this.rng.pick(busy);
|
||||||
|
}
|
||||||
|
if (free.length > 0) return this.rng.pick(free);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bumps(): void {
|
||||||
|
for (let i = 0; i < this.agents.length; i++) {
|
||||||
|
const a = this.agents[i];
|
||||||
|
if (!a || a.gone) continue;
|
||||||
|
for (let j = i + 1; j < this.agents.length; j++) {
|
||||||
|
const b = this.agents[j];
|
||||||
|
if (!b || b.gone) continue;
|
||||||
|
if (Math.hypot(a.x - b.x, a.y - b.y) >= BUMP_DIST_PX) continue;
|
||||||
|
for (const who of [a, b]) {
|
||||||
|
if (!rowdy(who) || who.lastBumpMs < BUMP_COOLDOWN_MS) continue;
|
||||||
|
who.lastBumpMs = 0;
|
||||||
|
// Still track the individual cooldown above (so one pair grinding
|
||||||
|
// together can't chain), but only the gate opens the till.
|
||||||
|
if (this.bumpGateMs < BUMP_GATE_MS) continue;
|
||||||
|
this.bumpGateMs = 0;
|
||||||
|
this.bus.emit('meters:delta', { vibe: BUMP_VIBE, aggro: BUMP_AGGRO });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryMove(a: Agent, dx: number, dy: number): void {
|
||||||
|
if (this.open(a.x + dx, a.y + dy)) {
|
||||||
|
a.x += dx;
|
||||||
|
a.y += dy;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.open(a.x + dx, a.y)) a.x += dx; // slide along the wall
|
||||||
|
else if (this.open(a.x, a.y + dy)) a.y += dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private open(x: number, y: number): boolean {
|
||||||
|
return isWalkableWorld(this.map, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4-connected BFS over walkable tiles. Waypoints are tile centres. */
|
||||||
|
private findPath(fromX: number, fromY: number, to: WorldPoint): WorldPoint[] | undefined {
|
||||||
|
const w = this.map.width;
|
||||||
|
const h = this.map.height;
|
||||||
|
const start = worldToTile(fromX, fromY);
|
||||||
|
const goal = worldToTile(to.x, to.y);
|
||||||
|
const startK = start.ty * w + start.tx;
|
||||||
|
const goalK = goal.ty * w + goal.tx;
|
||||||
|
if (startK === goalK) return [];
|
||||||
|
if (!isWalkable(this.map, goal.tx, goal.ty)) return undefined;
|
||||||
|
|
||||||
|
const prev = new Int32Array(w * h).fill(-1);
|
||||||
|
const seen = new Uint8Array(w * h);
|
||||||
|
const queue = [startK];
|
||||||
|
seen[startK] = 1;
|
||||||
|
for (let head = 0; head < queue.length; head++) {
|
||||||
|
const cur = queue[head] ?? -1;
|
||||||
|
if (cur === goalK) break;
|
||||||
|
const cx = cur % w;
|
||||||
|
const cy = (cur - cx) / w;
|
||||||
|
for (const [dx, dy] of NEIGHBOURS) {
|
||||||
|
const nx = cx + dx;
|
||||||
|
const ny = cy + dy;
|
||||||
|
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||||||
|
const k = ny * w + nx;
|
||||||
|
if (seen[k] === 1 || !isWalkable(this.map, nx, ny)) continue;
|
||||||
|
seen[k] = 1;
|
||||||
|
prev[k] = cur;
|
||||||
|
queue.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seen[goalK] !== 1) return undefined;
|
||||||
|
|
||||||
|
const out: WorldPoint[] = [];
|
||||||
|
let cur = goalK;
|
||||||
|
while (cur !== startK) {
|
||||||
|
const cx = cur % w;
|
||||||
|
out.push(tileToWorld(cx, (cur - cx) / w));
|
||||||
|
const p = prev[cur] ?? -1;
|
||||||
|
if (p < 0) break;
|
||||||
|
cur = p;
|
||||||
|
}
|
||||||
|
out.reverse();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stallById(id: string | undefined): StallDef | undefined {
|
||||||
|
if (id === undefined) return undefined;
|
||||||
|
return this.map.stalls.find((s) => s.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private nearestExit(x: number, y: number): WorldPoint | undefined {
|
||||||
|
let best: WorldPoint | undefined;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (const e of this.map.anchors.exit) {
|
||||||
|
const d = Math.hypot(e.x - x, e.y - y);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/scenes/floor/escalation.ts
Normal file
98
src/scenes/floor/escalation.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// The Cut-Off judgement matrix. Pure — no Phaser, no bus. The scene decides
|
||||||
|
// what to do with the result; this only decides what the room thinks of you.
|
||||||
|
|
||||||
|
import type { DrunkStage } from '../../data/types';
|
||||||
|
import {
|
||||||
|
CUTOFF_REPLY_CLEAN,
|
||||||
|
CUTOFF_REPLY_SCENE,
|
||||||
|
CUTOFF_REPLY_SOFT,
|
||||||
|
ESCALATION_LINES,
|
||||||
|
type EscalationLine,
|
||||||
|
} from './strings';
|
||||||
|
|
||||||
|
export type EscalationOutcome = 'clean' | 'scene' | 'staggerFree';
|
||||||
|
|
||||||
|
export interface EscalationResult {
|
||||||
|
outcome: EscalationOutcome;
|
||||||
|
vibeDelta: number;
|
||||||
|
aggroDelta: number;
|
||||||
|
/** What you said (from ESCALATION_LINES). */
|
||||||
|
line: string;
|
||||||
|
/** What they said back (from CUTOFF_REPLY_*). */
|
||||||
|
reply: string;
|
||||||
|
/** Eject: attach them to the escort-out walk. */
|
||||||
|
eject: boolean;
|
||||||
|
/** Resolved: stop treating them as an open infraction. */
|
||||||
|
resolved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Softest first — comparing rungs is the whole judgement. */
|
||||||
|
const RUNG: Readonly<Record<EscalationLine, number>> = {
|
||||||
|
water: 0,
|
||||||
|
done: 1,
|
||||||
|
escort: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The correct rung for each drunk stage. */
|
||||||
|
export const CORRECT_LINE: Readonly<Record<DrunkStage, EscalationLine>> = {
|
||||||
|
sober: 'water',
|
||||||
|
tipsy: 'water',
|
||||||
|
loose: 'water',
|
||||||
|
messy: 'done',
|
||||||
|
maggot: 'escort',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deliberately small. Being right about a drunk shouldn't feel like a jackpot,
|
||||||
|
// and overreach should sting more than restraint pays (design doc §4.3).
|
||||||
|
const CLEAN_VIBE = 4;
|
||||||
|
const SCENE_VIBE_PER_RUNG = -3;
|
||||||
|
const SCENE_AGGRO_PER_RUNG = 5;
|
||||||
|
const SOFT_VIBE = -1;
|
||||||
|
|
||||||
|
export function judgeEscalation(stage: DrunkStage, line: EscalationLine): EscalationResult {
|
||||||
|
const said = ESCALATION_LINES[line];
|
||||||
|
const overreach = RUNG[line] - RUNG[CORRECT_LINE[stage]];
|
||||||
|
|
||||||
|
if (overreach === 0) {
|
||||||
|
return {
|
||||||
|
outcome: 'clean',
|
||||||
|
vibeDelta: CLEAN_VIBE,
|
||||||
|
aggroDelta: 0,
|
||||||
|
line: said,
|
||||||
|
reply: CUTOFF_REPLY_CLEAN[stage],
|
||||||
|
// Water is a kindness, not an exit — only the top two stages walk.
|
||||||
|
eject: stage === 'messy' || stage === 'maggot',
|
||||||
|
resolved: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overreach > 0) {
|
||||||
|
return {
|
||||||
|
outcome: 'scene',
|
||||||
|
vibeDelta: SCENE_VIBE_PER_RUNG * overreach,
|
||||||
|
aggroDelta: SCENE_AGGRO_PER_RUNG * overreach,
|
||||||
|
line: said,
|
||||||
|
reply: CUTOFF_REPLY_SCENE[stage],
|
||||||
|
// They're not going anywhere with you now, but the moment is spent.
|
||||||
|
eject: false,
|
||||||
|
resolved: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
outcome: 'staggerFree',
|
||||||
|
vibeDelta: SOFT_VIBE,
|
||||||
|
aggroDelta: 0,
|
||||||
|
line: said,
|
||||||
|
reply: CUTOFF_REPLY_SOFT[stage],
|
||||||
|
eject: false,
|
||||||
|
resolved: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A maggot left alone too long becomes a deferred heat risk. */
|
||||||
|
export const MAGGOT_GRACE_MIN = 25;
|
||||||
|
|
||||||
|
export function maggotOverdue(spottedAtClockMin: number, nowClockMin: number): boolean {
|
||||||
|
return nowClockMin - spottedAtClockMin > MAGGOT_GRACE_MIN;
|
||||||
|
}
|
||||||
216
src/scenes/floor/overlays/CutOffOverlay.ts
Normal file
216
src/scenes/floor/overlays/CutOffOverlay.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
import { renderDoll } from '../../../patrons/doll';
|
||||||
|
import type { DrunkStage, Patron } from '../../../data/types';
|
||||||
|
import { judgeEscalation, type EscalationResult } from '../escalation';
|
||||||
|
import { ESCALATION_LINES, type EscalationLine } from '../strings';
|
||||||
|
|
||||||
|
const W = 640;
|
||||||
|
const H = 360;
|
||||||
|
|
||||||
|
const PANEL_X = 60;
|
||||||
|
const PANEL_Y = 56;
|
||||||
|
const PANEL_W = 520;
|
||||||
|
const PANEL_H = 248;
|
||||||
|
|
||||||
|
const ROW_X = 214;
|
||||||
|
const ROW_W = 348;
|
||||||
|
const ROW_H = 34;
|
||||||
|
const ROW_TOP = 126;
|
||||||
|
const ROW_GAP = 42;
|
||||||
|
|
||||||
|
const PINK = 0xd03470;
|
||||||
|
const AMBER = 0xd8a020;
|
||||||
|
const PURPLE = 0x7b2fd0;
|
||||||
|
|
||||||
|
const OUTCOME_COLOUR: Readonly<Record<EscalationResult['outcome'], string>> = {
|
||||||
|
clean: '#9fe8a0',
|
||||||
|
scene: '#d03470',
|
||||||
|
staggerFree: '#d8a020',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Softest first — index maps straight to the 1/2/3 keys. */
|
||||||
|
const ORDER: readonly EscalationLine[] = ['water', 'done', 'escort'];
|
||||||
|
|
||||||
|
// What the player can actually SEE across a dark room. The raw intoxication
|
||||||
|
// float never reaches the screen — you read the person, not a stat.
|
||||||
|
const LOOK: Readonly<Record<DrunkStage, string>> = {
|
||||||
|
sober: 'steady as a rock',
|
||||||
|
tipsy: 'talking with her hands',
|
||||||
|
loose: 'leaning on things that move',
|
||||||
|
messy: 'wearing some of the drink',
|
||||||
|
maggot: 'gone. absolutely gone.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOLD_MS = 1700;
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
box: Phaser.GameObjects.Rectangle;
|
||||||
|
text: Phaser.GameObjects.Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mono = (size: number, colour: string): Phaser.Types.GameObjects.Text.TextStyle => ({
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: `${size}px`,
|
||||||
|
color: colour,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Modal cut-off dialogue tree. A thin shell over judgeEscalation(). */
|
||||||
|
export class CutOffOverlay {
|
||||||
|
private readonly root: Phaser.GameObjects.Container;
|
||||||
|
private readonly doll: Phaser.GameObjects.Image;
|
||||||
|
private readonly whoText: Phaser.GameObjects.Text;
|
||||||
|
private readonly lookText: Phaser.GameObjects.Text;
|
||||||
|
private readonly hintText: Phaser.GameObjects.Text;
|
||||||
|
private readonly rows: Row[] = [];
|
||||||
|
private readonly saidText: Phaser.GameObjects.Text;
|
||||||
|
private readonly replyText: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
private open_ = false;
|
||||||
|
private stage: DrunkStage = 'sober';
|
||||||
|
private holdMs = 0;
|
||||||
|
private pending: EscalationResult | null = null;
|
||||||
|
private done: ((r: EscalationResult | null) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly scene: Phaser.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, 0x05050a, 0.82));
|
||||||
|
this.root.add(
|
||||||
|
scene.add
|
||||||
|
.rectangle(PANEL_X + PANEL_W / 2, PANEL_Y + PANEL_H / 2, PANEL_W, PANEL_H, 0x0b0b12, 0.98)
|
||||||
|
.setStrokeStyle(1, PURPLE),
|
||||||
|
);
|
||||||
|
this.root.add(scene.add.rectangle(PANEL_X + PANEL_W / 2, PANEL_Y + 1, PANEL_W, 2, PINK, 0.8));
|
||||||
|
this.root.add(scene.add.text(PANEL_X + 12, PANEL_Y + 10, 'CUT-OFF', mono(9, '#d03470')));
|
||||||
|
|
||||||
|
// '__DEFAULT' is Phaser's always-present blank texture — a placeholder so
|
||||||
|
// every object exists before the first open().
|
||||||
|
this.doll = scene.add.image(134, 252, '__DEFAULT').setOrigin(0.5, 1).setScale(2);
|
||||||
|
this.root.add(this.doll);
|
||||||
|
|
||||||
|
this.whoText = scene.add.text(134, 258, '', mono(8, '#9fe8a0')).setOrigin(0.5, 0);
|
||||||
|
this.lookText = scene.add
|
||||||
|
.text(134, 270, '', mono(7, '#8a7a96'))
|
||||||
|
.setOrigin(0.5, 0)
|
||||||
|
.setAlign('center')
|
||||||
|
.setWordWrapWidth(120);
|
||||||
|
this.root.add(this.whoText);
|
||||||
|
this.root.add(this.lookText);
|
||||||
|
|
||||||
|
ORDER.forEach((line, i) => {
|
||||||
|
const y = ROW_TOP + i * ROW_GAP;
|
||||||
|
const box = scene.add
|
||||||
|
.rectangle(ROW_X + ROW_W / 2, y, ROW_W, ROW_H, 0x14141f)
|
||||||
|
.setStrokeStyle(1, 0x2a2a3a)
|
||||||
|
.setInteractive({ useHandCursor: true });
|
||||||
|
const text = scene.add
|
||||||
|
.text(ROW_X + 10, y, `${i + 1} ${ESCALATION_LINES[line]}`, mono(8, '#cfc6d8'))
|
||||||
|
.setOrigin(0, 0.5);
|
||||||
|
|
||||||
|
box.on('pointerover', () => box.setStrokeStyle(1, AMBER));
|
||||||
|
box.on('pointerout', () => box.setStrokeStyle(1, 0x2a2a3a));
|
||||||
|
box.on('pointerup', () => this.pick(line));
|
||||||
|
|
||||||
|
this.root.add(box);
|
||||||
|
this.root.add(text);
|
||||||
|
this.rows.push({ box, text });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.saidText = scene.add
|
||||||
|
.text(ROW_X, 136, '', mono(9, '#cfc6d8'))
|
||||||
|
.setOrigin(0, 0)
|
||||||
|
.setWordWrapWidth(ROW_W);
|
||||||
|
this.replyText = scene.add
|
||||||
|
.text(ROW_X, 176, '', mono(8, '#9fe8a0'))
|
||||||
|
.setOrigin(0, 0)
|
||||||
|
.setLineSpacing(3)
|
||||||
|
.setWordWrapWidth(ROW_W);
|
||||||
|
this.root.add(this.saidText);
|
||||||
|
this.root.add(this.replyText);
|
||||||
|
|
||||||
|
this.hintText = scene.add
|
||||||
|
.text(W / 2, PANEL_Y + PANEL_H - 16, '1/2/3 choose · ESC back off', mono(7, '#5a5a70'))
|
||||||
|
.setOrigin(0.5, 0);
|
||||||
|
this.root.add(this.hintText);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isOpen(): boolean {
|
||||||
|
return this.open_;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(args: { patron: Patron; stage: DrunkStage }, done: (r: EscalationResult | null) => void): void {
|
||||||
|
this.done = done;
|
||||||
|
this.stage = args.stage;
|
||||||
|
this.pending = null;
|
||||||
|
this.holdMs = 0;
|
||||||
|
this.open_ = true;
|
||||||
|
|
||||||
|
this.doll.setTexture(renderDoll(this.scene, args.patron, 'queue'));
|
||||||
|
this.whoText.setText(args.patron.archetype);
|
||||||
|
this.lookText.setText(LOOK[args.stage]);
|
||||||
|
this.showChoices(true);
|
||||||
|
this.root.setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaMs: number): void {
|
||||||
|
if (!this.open_ || this.holdMs <= 0) return;
|
||||||
|
this.holdMs -= deltaMs;
|
||||||
|
if (this.holdMs > 0) return;
|
||||||
|
const result = this.pending;
|
||||||
|
this.hide();
|
||||||
|
this.resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKey(key: string): void {
|
||||||
|
if (!this.open_ || this.pending) return;
|
||||||
|
const k = key.toUpperCase();
|
||||||
|
if (k === 'ESC' || k === 'ESCAPE') {
|
||||||
|
this.hide();
|
||||||
|
this.resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const line = ORDER[Number(k) - 1];
|
||||||
|
if (line) this.pick(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.open_ = false;
|
||||||
|
this.done = null;
|
||||||
|
this.root.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private pick(line: EscalationLine): void {
|
||||||
|
if (!this.open_ || this.pending) return;
|
||||||
|
const result = judgeEscalation(this.stage, line);
|
||||||
|
this.pending = result;
|
||||||
|
this.holdMs = HOLD_MS;
|
||||||
|
|
||||||
|
this.showChoices(false);
|
||||||
|
this.saidText.setText(`"${result.line}"`);
|
||||||
|
this.replyText.setText(result.reply).setColor(OUTCOME_COLOUR[result.outcome]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private showChoices(on: boolean): void {
|
||||||
|
for (const r of this.rows) {
|
||||||
|
r.box.setVisible(on).setStrokeStyle(1, 0x2a2a3a);
|
||||||
|
r.text.setVisible(on);
|
||||||
|
}
|
||||||
|
this.hintText.setVisible(on);
|
||||||
|
this.saidText.setVisible(!on);
|
||||||
|
this.replyText.setVisible(!on);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hide(): void {
|
||||||
|
this.open_ = false;
|
||||||
|
this.pending = null;
|
||||||
|
this.holdMs = 0;
|
||||||
|
this.root.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** done() fires exactly once per open(), always after hide(). */
|
||||||
|
private resolve(result: EscalationResult | null): void {
|
||||||
|
const cb = this.done;
|
||||||
|
this.done = null;
|
||||||
|
cb?.(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
469
src/scenes/floor/overlays/PatDownOverlay.ts
Normal file
469
src/scenes/floor/overlays/PatDownOverlay.ts
Normal file
@ -0,0 +1,469 @@
|
|||||||
|
import Phaser from 'phaser';
|
||||||
|
import { CONTRABAND } from '../../../data/contraband';
|
||||||
|
import type { Patron } from '../../../data/types';
|
||||||
|
import { PATDOWN_INTRO } from '../strings';
|
||||||
|
import { PATDOWN_MS, POCKET_COUNT, foundLine, scorePatDown } from '../patDown';
|
||||||
|
import type { PatDownResult, Pocket } from '../patDown';
|
||||||
|
|
||||||
|
const VIEW_W = 640;
|
||||||
|
const VIEW_H = 360;
|
||||||
|
const DEPTH = 5000;
|
||||||
|
|
||||||
|
/** patDown.ts lays pockets out on a 64x96 outline; blow it up so 12px zones are clickable. */
|
||||||
|
const SCALE = 2.5;
|
||||||
|
const OUTLINE_X = 46;
|
||||||
|
const OUTLINE_Y = 62;
|
||||||
|
const ZONE = 24;
|
||||||
|
|
||||||
|
const BIN_X = 430;
|
||||||
|
const BIN_Y = 190;
|
||||||
|
const BIN_W = 160;
|
||||||
|
const BIN_H = 170;
|
||||||
|
|
||||||
|
const PINK = 0xd03470;
|
||||||
|
const GREEN = 0x9fe8a0;
|
||||||
|
const AMBER = 0xd8a020;
|
||||||
|
const PURPLE = 0x7b2fd0;
|
||||||
|
|
||||||
|
const MONO = 'monospace';
|
||||||
|
|
||||||
|
interface TokenArt {
|
||||||
|
readonly w: number;
|
||||||
|
readonly h: number;
|
||||||
|
readonly colour: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Placeholder art, one entry per CONTRABAND spriteHint. Real sprites land in the v0.4 pass. */
|
||||||
|
const TOKEN_ART: Readonly<Record<string, TokenArt>> = {
|
||||||
|
baggie: { w: 14, h: 10, colour: 0xf2f2f0 },
|
||||||
|
flask: { w: 12, h: 22, colour: 0xb8c0c8 },
|
||||||
|
goonSack: { w: 24, h: 16, colour: 0xc8ccd4 },
|
||||||
|
tinnies: { w: 18, h: 20, colour: AMBER },
|
||||||
|
kebab: { w: 12, h: 26, colour: 0xd8cea4 },
|
||||||
|
someoneElsesId: { w: 22, h: 14, colour: 0xdad4c4 },
|
||||||
|
budgie: { w: 18, h: 14, colour: 0x6fd06f },
|
||||||
|
vapes: { w: 22, h: 12, colour: PURPLE },
|
||||||
|
marker: { w: 8, h: 24, colour: 0x24242c },
|
||||||
|
snags: { w: 24, h: 12, colour: 0xe08c94 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_ART: TokenArt = { w: 16, h: 14, colour: 0x9aa0b0 };
|
||||||
|
const NAME_BY_ID = new Map(CONTRABAND.map((c) => [c.id, c.name] as const));
|
||||||
|
|
||||||
|
interface ZoneView {
|
||||||
|
readonly g: Phaser.GameObjects.Graphics;
|
||||||
|
readonly label: Phaser.GameObjects.Text;
|
||||||
|
readonly hit: Phaser.GameObjects.Zone;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenView {
|
||||||
|
readonly root: Phaser.GameObjects.Container;
|
||||||
|
readonly body: Phaser.GameObjects.Rectangle;
|
||||||
|
readonly label: Phaser.GameObjects.Text;
|
||||||
|
pocketIndex: number;
|
||||||
|
itemId: string;
|
||||||
|
homeX: number;
|
||||||
|
homeY: number;
|
||||||
|
grabOffX: number;
|
||||||
|
grabOffY: number;
|
||||||
|
/** Owns a pocket's item this round. One token per pocket, so slots are never recycled mid-round. */
|
||||||
|
live: boolean;
|
||||||
|
/** In or past the bin tween: no longer draggable, no longer flapping. */
|
||||||
|
gone: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashes along each edge, corners left open — reads as a rounded dashed hint box
|
||||||
|
// without paying for an actual rounded path per zone.
|
||||||
|
function dashRect(g: Phaser.GameObjects.Graphics, x: number, y: number, w: number, h: number): void {
|
||||||
|
const step = 6;
|
||||||
|
const dash = 3;
|
||||||
|
const r = 3;
|
||||||
|
for (let px = x + r; px < x + w - r; px += step) {
|
||||||
|
const len = Math.min(dash, x + w - r - px);
|
||||||
|
g.lineBetween(px, y, px + len, y);
|
||||||
|
g.lineBetween(px, y + h, px + len, y + h);
|
||||||
|
}
|
||||||
|
for (let py = y + r; py < y + h - r; py += step) {
|
||||||
|
const len = Math.min(dash, y + h - r - py);
|
||||||
|
g.lineBetween(x, py, x, py + len);
|
||||||
|
g.lineBetween(x + w, py, x + w, py + len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timed pat-down. Thin shell: all scoring lives in patDown.ts — this only
|
||||||
|
* flips `revealed` / `binned` on the caller's pockets, which is the agreed channel.
|
||||||
|
*/
|
||||||
|
export class PatDownOverlay {
|
||||||
|
private readonly scene: Phaser.Scene;
|
||||||
|
private readonly root: Phaser.GameObjects.Container;
|
||||||
|
private readonly timerBar: Phaser.GameObjects.Rectangle;
|
||||||
|
private readonly timerText: Phaser.GameObjects.Text;
|
||||||
|
private readonly lineText: Phaser.GameObjects.Text;
|
||||||
|
private readonly logText: Phaser.GameObjects.Text;
|
||||||
|
private readonly binGeom = new Phaser.Geom.Rectangle(BIN_X - BIN_W / 2, BIN_Y - BIN_H / 2, BIN_W, BIN_H);
|
||||||
|
private readonly zones: ZoneView[] = [];
|
||||||
|
private readonly tokens: TokenView[] = [];
|
||||||
|
|
||||||
|
private pockets: Pocket[] = [];
|
||||||
|
private patron: Patron | null = null;
|
||||||
|
private doneFn: ((r: PatDownResult) => void) | null = null;
|
||||||
|
private remainingMs = 0;
|
||||||
|
private flapMs = 0;
|
||||||
|
private open_ = false;
|
||||||
|
private found: string[] = [];
|
||||||
|
|
||||||
|
constructor(scene: Phaser.Scene) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.root = scene.add.container(0, 0);
|
||||||
|
|
||||||
|
this.root.add(scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x05050a, 0.82));
|
||||||
|
this.root.add(
|
||||||
|
scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, 600, 328, 0x0d0d16, 0.96).setStrokeStyle(1, PINK, 0.6),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.root.add(scene.add.rectangle(32, 30, 576, 8, 0x1c1c2a).setOrigin(0, 0));
|
||||||
|
this.timerBar = scene.add.rectangle(32, 30, 576, 8, GREEN).setOrigin(0, 0);
|
||||||
|
this.root.add(this.timerBar);
|
||||||
|
|
||||||
|
this.root.add(scene.add.text(32, 44, PATDOWN_INTRO, { fontFamily: MONO, fontSize: '9px', color: '#d03470' }));
|
||||||
|
this.timerText = scene.add
|
||||||
|
.text(608, 44, '', { fontFamily: MONO, fontSize: '9px', color: '#9fe8a0' })
|
||||||
|
.setOrigin(1, 0);
|
||||||
|
this.root.add(this.timerText);
|
||||||
|
|
||||||
|
this.buildOutline();
|
||||||
|
this.buildBin();
|
||||||
|
|
||||||
|
for (let i = 0; i < POCKET_COUNT; i++) this.zones.push(this.buildZone(i));
|
||||||
|
for (let i = 0; i < POCKET_COUNT; i++) this.tokens.push(this.buildToken());
|
||||||
|
|
||||||
|
this.logText = scene.add.text(524, 104, '', {
|
||||||
|
fontFamily: MONO,
|
||||||
|
fontSize: '7px',
|
||||||
|
color: '#d8a020',
|
||||||
|
wordWrap: { width: 88 },
|
||||||
|
lineSpacing: 2,
|
||||||
|
});
|
||||||
|
this.root.add(this.logText);
|
||||||
|
|
||||||
|
this.lineText = scene.add.text(46, 316, '', {
|
||||||
|
fontFamily: MONO,
|
||||||
|
fontSize: '8px',
|
||||||
|
color: '#e8e4d8',
|
||||||
|
wordWrap: { width: 390 },
|
||||||
|
});
|
||||||
|
this.root.add(this.lineText);
|
||||||
|
|
||||||
|
this.root.add(
|
||||||
|
scene.add
|
||||||
|
.text(608, 318, 'CLICK pockets · DRAG to bin · ESC done', {
|
||||||
|
fontFamily: MONO,
|
||||||
|
fontSize: '7px',
|
||||||
|
color: '#556',
|
||||||
|
})
|
||||||
|
.setOrigin(1, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.root.setDepth(DEPTH).setScrollFactor(0, 0, true).setVisible(false);
|
||||||
|
scene.input.on('dragstart', this.onDragStart);
|
||||||
|
scene.input.on('drag', this.onDrag);
|
||||||
|
scene.input.on('dragend', this.onDragEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isOpen(): boolean {
|
||||||
|
return this.open_;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(args: { patron: Patron; pockets: Pocket[] }, done: (r: PatDownResult) => void): void {
|
||||||
|
if (this.open_) return;
|
||||||
|
this.patron = args.patron;
|
||||||
|
this.pockets = args.pockets;
|
||||||
|
this.doneFn = done;
|
||||||
|
this.remainingMs = PATDOWN_MS;
|
||||||
|
this.flapMs = 0;
|
||||||
|
this.found = [];
|
||||||
|
this.open_ = true;
|
||||||
|
|
||||||
|
this.lineText.setText('');
|
||||||
|
this.logText.setText('');
|
||||||
|
|
||||||
|
for (let i = 0; i < this.zones.length; i++) {
|
||||||
|
const z = this.zones[i];
|
||||||
|
const p = this.pockets[i];
|
||||||
|
if (z === undefined) continue;
|
||||||
|
const on = p !== undefined;
|
||||||
|
z.g.setVisible(on);
|
||||||
|
z.label.setVisible(on);
|
||||||
|
z.hit.setVisible(on);
|
||||||
|
if (z.hit.input) z.hit.input.enabled = on;
|
||||||
|
if (on) this.paintZone(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of this.tokens) this.hideToken(t);
|
||||||
|
|
||||||
|
this.root.setVisible(true);
|
||||||
|
this.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaMs: number): void {
|
||||||
|
if (!this.open_) return;
|
||||||
|
this.flapMs += deltaMs;
|
||||||
|
this.remainingMs -= deltaMs;
|
||||||
|
|
||||||
|
for (const t of this.tokens) {
|
||||||
|
// The budgie is alive and it would like everyone to know.
|
||||||
|
if (t.live && !t.gone && t.itemId === 'budgie') t.root.setRotation(Math.sin(this.flapMs / 70) * 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.remainingMs <= 0) {
|
||||||
|
this.remainingMs = 0;
|
||||||
|
this.tick();
|
||||||
|
this.resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKey(key: string): void {
|
||||||
|
if (!this.open_) return;
|
||||||
|
if (key === 'ESC' || key === 'Escape') this.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.scene.input.off('dragstart', this.onDragStart);
|
||||||
|
this.scene.input.off('drag', this.onDrag);
|
||||||
|
this.scene.input.off('dragend', this.onDragEnd);
|
||||||
|
this.root.destroy(true);
|
||||||
|
this.doneFn = null;
|
||||||
|
this.open_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- build (constructor only) ----
|
||||||
|
|
||||||
|
private buildOutline(): void {
|
||||||
|
const g = this.scene.add.graphics();
|
||||||
|
const px = (v: number): number => OUTLINE_X + v * SCALE;
|
||||||
|
const py = (v: number): number => OUTLINE_Y + v * SCALE;
|
||||||
|
const box = (x: number, y: number, w: number, h: number): void => {
|
||||||
|
g.fillRect(px(x), py(y), w * SCALE, h * SCALE);
|
||||||
|
g.strokeRect(px(x), py(y), w * SCALE, h * SCALE);
|
||||||
|
};
|
||||||
|
g.fillStyle(0x14141f, 1);
|
||||||
|
g.lineStyle(1, 0x3c3c5a, 1);
|
||||||
|
g.fillCircle(px(32), py(10), 8 * SCALE);
|
||||||
|
g.strokeCircle(px(32), py(10), 8 * SCALE);
|
||||||
|
box(30, 17, 4, 4); // neck
|
||||||
|
box(20, 21, 24, 29); // torso
|
||||||
|
box(2, 24, 18, 5); // arms out, mate
|
||||||
|
box(44, 24, 18, 5);
|
||||||
|
box(20, 50, 24, 10); // hips
|
||||||
|
box(21, 60, 9, 34); // legs
|
||||||
|
box(34, 60, 9, 34);
|
||||||
|
this.root.add(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildBin(): void {
|
||||||
|
const s = this.scene;
|
||||||
|
this.root.add(
|
||||||
|
s.add.rectangle(BIN_X, BIN_Y, BIN_W, BIN_H, 0x120c1c, 0.9).setStrokeStyle(1, GREEN, 0.75),
|
||||||
|
);
|
||||||
|
this.root.add(s.add.rectangle(BIN_X, BIN_Y - BIN_H / 2, BIN_W + 12, 12, GREEN, 0.22));
|
||||||
|
this.root.add(s.add.rectangle(BIN_X, BIN_Y - BIN_H / 2 + 2, 90, 4, 0x05050a)); // slot
|
||||||
|
this.root.add(
|
||||||
|
s.add
|
||||||
|
.text(BIN_X, BIN_Y - BIN_H / 2 + 18, 'AMNESTY BIN', {
|
||||||
|
fontFamily: MONO,
|
||||||
|
fontSize: '9px',
|
||||||
|
color: '#9fe8a0',
|
||||||
|
})
|
||||||
|
.setOrigin(0.5, 0),
|
||||||
|
);
|
||||||
|
this.root.add(
|
||||||
|
s.add
|
||||||
|
.text(BIN_X, BIN_Y + BIN_H / 2 - 16, 'no questions asked', {
|
||||||
|
fontFamily: MONO,
|
||||||
|
fontSize: '7px',
|
||||||
|
color: '#4a6a50',
|
||||||
|
})
|
||||||
|
.setOrigin(0.5, 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildZone(index: number): ZoneView {
|
||||||
|
const s = this.scene;
|
||||||
|
const g = s.add.graphics();
|
||||||
|
const label = s.add.text(0, 0, '', { fontFamily: MONO, fontSize: '6px', color: '#6f7aa8' }).setOrigin(0.5, 0);
|
||||||
|
const hit = s.add.zone(0, 0, ZONE + 8, ZONE + 8).setInteractive({ useHandCursor: true });
|
||||||
|
hit.on('pointerdown', () => this.revealPocket(index));
|
||||||
|
this.root.add([g, label, hit]);
|
||||||
|
return { g, label, hit };
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildToken(): TokenView {
|
||||||
|
const s = this.scene;
|
||||||
|
const root = s.add.container(0, 0);
|
||||||
|
const body = s.add.rectangle(0, 0, 16, 14, 0xffffff).setStrokeStyle(1, 0x05050a, 0.8);
|
||||||
|
const label = s.add
|
||||||
|
.text(0, 12, '', { fontFamily: MONO, fontSize: '6px', color: '#e8e4d8', align: 'center' })
|
||||||
|
.setOrigin(0.5, 0);
|
||||||
|
root.add([body, label]);
|
||||||
|
root.setSize(40, 40).setInteractive(new Phaser.Geom.Rectangle(-20, -20, 40, 40), Phaser.Geom.Rectangle.Contains);
|
||||||
|
s.input.setDraggable(root);
|
||||||
|
this.root.add(root);
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
body,
|
||||||
|
label,
|
||||||
|
pocketIndex: -1,
|
||||||
|
itemId: '',
|
||||||
|
homeX: 0,
|
||||||
|
homeY: 0,
|
||||||
|
grabOffX: 0,
|
||||||
|
grabOffY: 0,
|
||||||
|
live: false,
|
||||||
|
gone: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- runtime ----
|
||||||
|
|
||||||
|
private tick(): void {
|
||||||
|
const frac = Phaser.Math.Clamp(this.remainingMs / PATDOWN_MS, 0, 1);
|
||||||
|
this.timerBar.setSize(Math.max(576 * frac, 0.001), 8);
|
||||||
|
this.timerBar.setFillStyle(frac > 0.5 ? GREEN : frac > 0.2 ? AMBER : PINK, 1);
|
||||||
|
this.timerText.setText(`${(this.remainingMs / 1000).toFixed(1)}s`);
|
||||||
|
this.timerText.setColor(frac > 0.5 ? '#9fe8a0' : frac > 0.2 ? '#d8a020' : '#d03470');
|
||||||
|
}
|
||||||
|
|
||||||
|
private paintZone(index: number): void {
|
||||||
|
const z = this.zones[index];
|
||||||
|
const p = this.pockets[index];
|
||||||
|
if (z === undefined || p === undefined) return;
|
||||||
|
const cx = OUTLINE_X + (p.x + 6) * SCALE;
|
||||||
|
const cy = OUTLINE_Y + (p.y + 6) * SCALE;
|
||||||
|
const half = ZONE / 2;
|
||||||
|
|
||||||
|
z.hit.setPosition(cx, cy);
|
||||||
|
z.label.setPosition(cx, cy + half + 2).setText(p.label);
|
||||||
|
|
||||||
|
z.g.clear();
|
||||||
|
if (!p.revealed) {
|
||||||
|
z.g.lineStyle(1, 0x7b8cd0, 0.9);
|
||||||
|
dashRect(z.g, cx - half, cy - half, ZONE, ZONE);
|
||||||
|
z.label.setColor('#6f7aa8');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hot = p.itemId !== undefined && !p.binned;
|
||||||
|
const c = hot ? AMBER : GREEN;
|
||||||
|
z.g.fillStyle(c, 0.12);
|
||||||
|
z.g.fillRect(cx - half, cy - half, ZONE, ZONE);
|
||||||
|
z.g.lineStyle(1, c, 0.9);
|
||||||
|
z.g.strokeRect(cx - half, cy - half, ZONE, ZONE);
|
||||||
|
z.label.setColor(hot ? '#d8a020' : '#9fe8a0');
|
||||||
|
}
|
||||||
|
|
||||||
|
private revealPocket(index: number): void {
|
||||||
|
if (!this.open_) return;
|
||||||
|
const p = this.pockets[index];
|
||||||
|
if (p === undefined || p.revealed) return;
|
||||||
|
p.revealed = true;
|
||||||
|
this.paintZone(index);
|
||||||
|
if (p.itemId !== undefined) this.spawnToken(index, p.itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnToken(index: number, itemId: string): void {
|
||||||
|
const p = this.pockets[index];
|
||||||
|
const t = this.tokens.find((x) => !x.live);
|
||||||
|
if (p === undefined || t === undefined) return;
|
||||||
|
|
||||||
|
const art = TOKEN_ART[itemId] ?? DEFAULT_ART;
|
||||||
|
t.itemId = itemId;
|
||||||
|
t.pocketIndex = index;
|
||||||
|
t.live = true;
|
||||||
|
t.gone = false;
|
||||||
|
t.homeX = OUTLINE_X + (p.x + 6) * SCALE;
|
||||||
|
t.homeY = OUTLINE_Y + (p.y + 6) * SCALE;
|
||||||
|
|
||||||
|
t.body.setSize(art.w, art.h).setFillStyle(art.colour, 1);
|
||||||
|
t.label.setText(NAME_BY_ID.get(itemId) ?? itemId).setPosition(0, art.h / 2 + 2);
|
||||||
|
t.root.setPosition(t.homeX, t.homeY).setScale(1).setAlpha(1).setRotation(0).setVisible(true);
|
||||||
|
if (t.root.input) t.root.input.enabled = true;
|
||||||
|
this.root.bringToTop(t.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideToken(t: TokenView): void {
|
||||||
|
this.scene.tweens.killTweensOf(t.root);
|
||||||
|
t.live = false;
|
||||||
|
t.gone = false;
|
||||||
|
t.itemId = '';
|
||||||
|
t.pocketIndex = -1;
|
||||||
|
t.root.setVisible(false).setRotation(0).setScale(1).setAlpha(1);
|
||||||
|
if (t.root.input) t.root.input.enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tokenFor(obj: Phaser.GameObjects.GameObject): TokenView | undefined {
|
||||||
|
return this.tokens.find((t) => t.live && !t.gone && t.root === obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly onDragStart = (pointer: Phaser.Input.Pointer, obj: Phaser.GameObjects.GameObject): void => {
|
||||||
|
if (!this.open_) return;
|
||||||
|
const t = this.tokenFor(obj);
|
||||||
|
if (t === undefined) return;
|
||||||
|
// Overlay is scrollFactor 0, so track screen-space pointer, not world drag coords.
|
||||||
|
t.grabOffX = t.root.x - pointer.x;
|
||||||
|
t.grabOffY = t.root.y - pointer.y;
|
||||||
|
this.root.bringToTop(t.root);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onDrag = (pointer: Phaser.Input.Pointer, obj: Phaser.GameObjects.GameObject): void => {
|
||||||
|
if (!this.open_) return;
|
||||||
|
const t = this.tokenFor(obj);
|
||||||
|
if (t === undefined) return;
|
||||||
|
t.root.setPosition(pointer.x + t.grabOffX, pointer.y + t.grabOffY);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onDragEnd = (_pointer: Phaser.Input.Pointer, obj: Phaser.GameObjects.GameObject): void => {
|
||||||
|
if (!this.open_) return;
|
||||||
|
const t = this.tokenFor(obj);
|
||||||
|
if (t === undefined) return;
|
||||||
|
if (this.binGeom.contains(t.root.x, t.root.y)) this.binToken(t);
|
||||||
|
else this.scene.tweens.add({ targets: t.root, x: t.homeX, y: t.homeY, duration: 160, ease: 'Back.easeOut' });
|
||||||
|
};
|
||||||
|
|
||||||
|
private binToken(t: TokenView): void {
|
||||||
|
const p = this.pockets[t.pocketIndex];
|
||||||
|
if (p === undefined) return;
|
||||||
|
p.binned = true;
|
||||||
|
const itemId = t.itemId;
|
||||||
|
|
||||||
|
if (t.root.input) t.root.input.enabled = false;
|
||||||
|
t.gone = true; // stops the flap and the drag, but keeps the slot so nothing reuses it mid-tween
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: t.root,
|
||||||
|
x: BIN_X,
|
||||||
|
y: BIN_Y,
|
||||||
|
scale: 0.2,
|
||||||
|
alpha: 0,
|
||||||
|
rotation: 0,
|
||||||
|
duration: 240,
|
||||||
|
ease: 'Quad.easeIn',
|
||||||
|
onComplete: () => t.root.setVisible(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.lineText.setText(foundLine(itemId));
|
||||||
|
this.found.push(NAME_BY_ID.get(itemId) ?? itemId);
|
||||||
|
this.logText.setText(this.found.join('\n'));
|
||||||
|
this.paintZone(t.pocketIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolve(): void {
|
||||||
|
const done = this.doneFn;
|
||||||
|
const patron = this.patron;
|
||||||
|
if (!this.open_ || done === null || patron === null) return;
|
||||||
|
this.open_ = false;
|
||||||
|
this.doneFn = null;
|
||||||
|
this.root.setVisible(false);
|
||||||
|
done(scorePatDown(patron, this.pockets));
|
||||||
|
}
|
||||||
|
}
|
||||||
334
src/scenes/floor/overlays/StallOverlay.ts
Normal file
334
src/scenes/floor/overlays/StallOverlay.ts
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
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 '../strings';
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/scenes/floor/patDown.ts
Normal file
120
src/scenes/floor/patDown.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
// The pat-down minigame's pure half: where the pockets are, what's in them,
|
||||||
|
// and what it costs you. Phaser-free so the scoring stays testable.
|
||||||
|
|
||||||
|
import type { RngStream } from '../../core/SeededRNG';
|
||||||
|
import { CONTRABAND } from '../../data/contraband';
|
||||||
|
import type { Patron } from '../../data/types';
|
||||||
|
import { PATDOWN_CLEAR, PATDOWN_FOUND, PATDOWN_MISSED } from './strings';
|
||||||
|
|
||||||
|
export const PATDOWN_MS = 10_000;
|
||||||
|
export const POCKET_COUNT = 6;
|
||||||
|
|
||||||
|
export interface Pocket {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
/** Position on a 64x96 patron-outline overlay coordinate space, top-left origin. */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
itemId?: string;
|
||||||
|
revealed: boolean;
|
||||||
|
binned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed anchors down the outline — chest, jacket, hips, back, sock. Spaced so no
|
||||||
|
* two hit areas touch at a ~12px footprint; the search is about time pressure,
|
||||||
|
* not pixel-hunting.
|
||||||
|
*/
|
||||||
|
const POCKET_SPOTS: ReadonlyArray<{ readonly label: string; readonly x: number; readonly y: number }> = [
|
||||||
|
{ label: 'inside pocket', x: 26, y: 22 },
|
||||||
|
{ label: 'left jacket', x: 10, y: 30 },
|
||||||
|
{ label: 'right jacket', x: 40, y: 30 },
|
||||||
|
{ label: 'front hip', x: 12, y: 52 },
|
||||||
|
{ label: 'back pocket', x: 42, y: 54 },
|
||||||
|
{ label: 'sock', x: 20, y: 80 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEVERITY_BY_ID = new Map(CONTRABAND.map((c) => [c.id, c.severity] as const));
|
||||||
|
const NAME_BY_ID = new Map(CONTRABAND.map((c) => [c.id, c.name] as const));
|
||||||
|
|
||||||
|
/** Deterministic: same patron + same rng stream state => same layout. */
|
||||||
|
export function layoutPockets(patron: Patron, rng: RngStream): Pocket[] {
|
||||||
|
const pockets: Pocket[] = POCKET_SPOTS.map((spot, i) => ({
|
||||||
|
id: `pk${i}`,
|
||||||
|
label: spot.label,
|
||||||
|
x: spot.x,
|
||||||
|
y: spot.y,
|
||||||
|
revealed: false,
|
||||||
|
binned: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Fisher-Yates over slot indices, then fill from the front: distinct pockets
|
||||||
|
// for free, and anything past POCKET_COUNT simply doesn't fit on them.
|
||||||
|
const order = pockets.map((_, i) => i);
|
||||||
|
for (let i = order.length - 1; i > 0; i--) {
|
||||||
|
const j = rng.int(0, i);
|
||||||
|
const a = order[i]!;
|
||||||
|
order[i] = order[j]!;
|
||||||
|
order[j] = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = patron.flags.contraband ?? [];
|
||||||
|
const placeable = Math.min(items.length, POCKET_COUNT);
|
||||||
|
for (let i = 0; i < placeable; i++) {
|
||||||
|
pockets[order[i]!]!.itemId = items[i]!;
|
||||||
|
}
|
||||||
|
return pockets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatDownResult {
|
||||||
|
foundIds: string[];
|
||||||
|
missedIds: string[];
|
||||||
|
vibeDelta: number;
|
||||||
|
aggroDelta: number;
|
||||||
|
/** Severity-3 contraband found => they are out. */
|
||||||
|
eject: boolean;
|
||||||
|
/** Player-facing summary line, built from strings.ts. */
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scores the pockets as laid out, not the patron's flags — what you didn't open,
|
||||||
|
// you didn't find, even if the generator put it there.
|
||||||
|
export function scorePatDown(_patron: Patron, pockets: readonly Pocket[]): PatDownResult {
|
||||||
|
const foundIds: string[] = [];
|
||||||
|
const missedIds: string[] = [];
|
||||||
|
for (const p of pockets) {
|
||||||
|
if (p.itemId === undefined) continue;
|
||||||
|
(p.binned ? foundIds : missedIds).push(p.itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const carried = foundIds.length + missedIds.length;
|
||||||
|
// An unnecessary pat-down is a small indignity — that's the moral cost.
|
||||||
|
const aggroDelta = carried === 0 ? 1.5 : 0;
|
||||||
|
const eject = foundIds.some((id) => SEVERITY_BY_ID.get(id) === 3);
|
||||||
|
|
||||||
|
let summary: string;
|
||||||
|
if (carried === 0) {
|
||||||
|
summary = PATDOWN_CLEAR;
|
||||||
|
} else {
|
||||||
|
const parts = foundIds.map(foundLine);
|
||||||
|
if (missedIds.length > 0) parts.push(PATDOWN_MISSED);
|
||||||
|
summary = parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
foundIds,
|
||||||
|
missedIds,
|
||||||
|
vibeDelta: foundIds.length * 2 - missedIds.length * 1.5,
|
||||||
|
aggroDelta,
|
||||||
|
eject,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flavour line for a found item; falls back gracefully on an unknown id. */
|
||||||
|
export function foundLine(itemId: string): string {
|
||||||
|
const line = PATDOWN_FOUND[itemId];
|
||||||
|
if (line !== undefined) return line;
|
||||||
|
const name = NAME_BY_ID.get(itemId);
|
||||||
|
return name !== undefined ? `${name}. Into the bin, mate.` : 'Into the bin, mate.';
|
||||||
|
}
|
||||||
76
src/scenes/floor/player.ts
Normal file
76
src/scenes/floor/player.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
// Player movement. Pure maths — the scene owns the sprite, this owns the weight.
|
||||||
|
// You are a big unit: slow to start, slower to stop, and you slide off walls
|
||||||
|
// rather than sticking to them.
|
||||||
|
|
||||||
|
import { isWalkableWorld } from './venueMap';
|
||||||
|
import type { VenueMap } from './venueMap';
|
||||||
|
|
||||||
|
export interface PlayerInput { up: boolean; down: boolean; left: boolean; right: boolean }
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
x: number; y: number; // world px
|
||||||
|
vx: number; vy: number; // px/sec
|
||||||
|
facing: number; // radians, persists when stationary
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLAYER_ACCEL = 620; // px/s^2
|
||||||
|
export const PLAYER_MAX_SPEED = 116;
|
||||||
|
export const PLAYER_FRICTION = 7; // per second, exponential damping
|
||||||
|
export const PLAYER_RADIUS = 5;
|
||||||
|
|
||||||
|
// A stalled tab hands back one enormous delta; cap it so nobody blinks through a
|
||||||
|
// wall. At the cap a full-speed step is ~6px, well under a tile.
|
||||||
|
const MAX_STEP_MS = 50;
|
||||||
|
|
||||||
|
// Below this the heading is noise, so the torch keeps pointing where it was.
|
||||||
|
const FACING_EPS = 4;
|
||||||
|
|
||||||
|
export function freshPlayer(x: number, y: number): PlayerState {
|
||||||
|
return { x, y, vx: 0, vy: 0, facing: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 4 cardinal edge points of the body circle must all be on walkable floor. */
|
||||||
|
function canStand(map: VenueMap, x: number, y: number): boolean {
|
||||||
|
return isWalkableWorld(map, x + PLAYER_RADIUS, y)
|
||||||
|
&& isWalkableWorld(map, x - PLAYER_RADIUS, y)
|
||||||
|
&& isWalkableWorld(map, x, y + PLAYER_RADIUS)
|
||||||
|
&& isWalkableWorld(map, x, y - PLAYER_RADIUS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutates nothing; returns the next state. Slides along walls (axis-separated collision). */
|
||||||
|
export function stepPlayer(s: PlayerState, input: PlayerInput, deltaMs: number, map: VenueMap): PlayerState {
|
||||||
|
const dt = Math.max(0, Math.min(deltaMs, MAX_STEP_MS)) / 1000;
|
||||||
|
|
||||||
|
let ix = (input.right ? 1 : 0) - (input.left ? 1 : 0);
|
||||||
|
let iy = (input.down ? 1 : 0) - (input.up ? 1 : 0);
|
||||||
|
const mag = Math.hypot(ix, iy);
|
||||||
|
if (mag > 0) { ix /= mag; iy /= mag; }
|
||||||
|
|
||||||
|
let vx = s.vx + ix * PLAYER_ACCEL * dt;
|
||||||
|
let vy = s.vy + iy * PLAYER_ACCEL * dt;
|
||||||
|
|
||||||
|
// Friction only bites on an axis you aren't driving, so held input still tops
|
||||||
|
// out at PLAYER_MAX_SPEED instead of settling at accel/friction.
|
||||||
|
const damp = Math.exp(-PLAYER_FRICTION * dt);
|
||||||
|
if (ix === 0) vx *= damp;
|
||||||
|
if (iy === 0) vy *= damp;
|
||||||
|
|
||||||
|
const speed = Math.hypot(vx, vy);
|
||||||
|
if (speed > PLAYER_MAX_SPEED) {
|
||||||
|
const k = PLAYER_MAX_SPEED / speed;
|
||||||
|
vx *= k;
|
||||||
|
vy *= k;
|
||||||
|
}
|
||||||
|
|
||||||
|
let x = s.x;
|
||||||
|
let y = s.y;
|
||||||
|
const nx = x + vx * dt;
|
||||||
|
if (canStand(map, nx, y)) x = nx; else vx = 0;
|
||||||
|
const ny = y + vy * dt;
|
||||||
|
if (canStand(map, x, ny)) y = ny; else vy = 0;
|
||||||
|
|
||||||
|
const moved = Math.hypot(vx, vy);
|
||||||
|
const facing = moved > FACING_EPS ? Math.atan2(vy, vx) : s.facing;
|
||||||
|
|
||||||
|
return { x, y, vx, vy, facing };
|
||||||
|
}
|
||||||
91
src/scenes/floor/strings.ts
Normal file
91
src/scenes/floor/strings.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// Floor dialogue & flavour. Typed const maps, never inline in scene code
|
||||||
|
// (CONTRACTS.md §7). Tone: Aussie hospitality satire — affectionate, absurd,
|
||||||
|
// with a moral undertow. Nobody here is a villain; they're just having a night.
|
||||||
|
//
|
||||||
|
// NOTE(convention): CONTRACTS.md §7 puts dialogue in `src/data/strings/`, but
|
||||||
|
// `data/` is LANE-0 territory and lanes commit only inside their owned dirs.
|
||||||
|
// Parked here; see LANEHANDOVER.md — happy to move it on the reviewer's word.
|
||||||
|
|
||||||
|
import type { DrunkStage } from '../../data/types';
|
||||||
|
|
||||||
|
/** The three rungs of the cut-off ladder, softest first. */
|
||||||
|
export type EscalationLine = 'water' | 'done' | 'escort';
|
||||||
|
|
||||||
|
export const ESCALATION_LINES: Record<EscalationLine, string> = {
|
||||||
|
water: 'Have some water, mate. On the house.',
|
||||||
|
done: "Righto — you're done for the night.",
|
||||||
|
escort: "Come on. Walk with me. Out you go.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What you get back when you pitch it right for their state. */
|
||||||
|
export const CUTOFF_REPLY_CLEAN: Record<DrunkStage, string> = {
|
||||||
|
sober: '"...yeah, nah, cheers?" He drinks the water. Confused, but hydrated.',
|
||||||
|
tipsy: '"Aw, legend." She takes the water and means it.',
|
||||||
|
loose: '"Yeah fair, fair. Good on ya." He sips it like medicine.',
|
||||||
|
messy: '"Nah I get it. I GET it." He shakes your hand for slightly too long.',
|
||||||
|
maggot: 'No words. Just a slow, dignified nod, and the walk of the defeated.',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Too much boot for their state — the whole room watches you overdo it. */
|
||||||
|
export const CUTOFF_REPLY_SCENE: Record<DrunkStage, string> = {
|
||||||
|
sober: '"I have had ONE lemon squash." The booth behind you starts filming.',
|
||||||
|
tipsy: '"Mate. MATE. I\'m on my second." Her friends boo you in unison.',
|
||||||
|
loose: '"Are you serious? I\'m FINE." He is fine. That\'s the problem.',
|
||||||
|
messy: '"Don\'t TOUCH me—" Arms up. Drink goes sideways. Room turns.',
|
||||||
|
maggot: 'He goes limp on purpose. All ninety kilos of him. Crowd cheers.',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Too gentle for their state — they take the water and keep going. */
|
||||||
|
export const CUTOFF_REPLY_SOFT: Record<DrunkStage, string> = {
|
||||||
|
sober: '"Ta." Nothing happened. Nothing was going to happen.',
|
||||||
|
tipsy: '"Sweet as." She wanders off, entirely unbothered.',
|
||||||
|
loose: '"Yeah yeah, water, love it." Straight back to the bar.',
|
||||||
|
messy: '"You\'re a good bloke." He pours the water into a pot plant.',
|
||||||
|
maggot: 'He accepts the water, salutes you, and staggers into the dance floor.',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Muffled through a stall door. They are not coming out politely. */
|
||||||
|
export const STALL_MUFFLED: readonly string[] = [
|
||||||
|
'"Piss off mate, I\'m BUSY."',
|
||||||
|
'"Occupied! ...by one person!"',
|
||||||
|
'"We are having a private conversation."',
|
||||||
|
'"Nobody in here but me and my reflection."',
|
||||||
|
'"Two seconds! ...Two more seconds!"',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STALL_BUSTED: readonly string[] = [
|
||||||
|
'The door swings. Two people leave in two directions at four times the speed of sound.',
|
||||||
|
'Busted. One goes left, one goes through the smoking door, neither makes eye contact.',
|
||||||
|
'They scatter like startled ibises. Someone drops a vape. Nobody comes back for it.',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STALL_OFF_BEAT = 'Your knuckles land in the gap between kicks. Even the door is embarrassed.';
|
||||||
|
|
||||||
|
/** Pat-down patter. Confiscation is a bit, not a punishment. */
|
||||||
|
export const PATDOWN_INTRO = 'Arms out, mate. You know the drill.';
|
||||||
|
|
||||||
|
export const PATDOWN_FOUND: Record<string, string> = {
|
||||||
|
baggie: 'Well. That\'s not a mint.',
|
||||||
|
flask: 'A hip flask. In this economy.',
|
||||||
|
goonSack: 'Sir, this is a licensed venue and that is a pillow of wine.',
|
||||||
|
tinnies: 'Two warm tinnies. Warm. That\'s the real crime.',
|
||||||
|
kebab: 'An entire kebab. Still wrapped. Honestly? Respect.',
|
||||||
|
someoneElsesId: 'This is Kylie. You are not Kylie.',
|
||||||
|
budgie: 'That is a live budgie. It is looking at me. Into the bin, mate.',
|
||||||
|
vapes: 'Fourteen vapes. Fourteen. Are you a shop?',
|
||||||
|
marker: 'Fat marker. We just repainted that cubicle.',
|
||||||
|
snags: 'Raw sausages. In your jacket. Why. WHY.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATDOWN_CLEAR = 'Nothing on him. "Told ya," he says, delighted.';
|
||||||
|
export const PATDOWN_MISSED = 'Time. Whatever\'s left in there walks out the door with him.';
|
||||||
|
|
||||||
|
/** Kayden, on the radio, when a stampless patron turns up inside. */
|
||||||
|
export const NO_STAMP_RADIO: readonly string[] = [
|
||||||
|
'"Yeah sorry, that one might\'ve been me. Sorry. Sorry."',
|
||||||
|
'"He said he was getting his jacket. Forty minutes ago."',
|
||||||
|
'"I stamped SOMEONE. Pretty sure I stamped someone."',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const HELP_TEXT =
|
||||||
|
'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed';
|
||||||
175
src/scenes/floor/venueMap.ts
Normal file
175
src/scenes/floor/venueMap.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
// Hand-authored top-down venue. Pure data + maths, no Phaser — the floor scene
|
||||||
|
// paints it, the AI walks it, the tests prove it hangs together.
|
||||||
|
|
||||||
|
export const TILE = 16;
|
||||||
|
export const MAP_W = 80; // tiles
|
||||||
|
export const MAP_H = 45; // tiles
|
||||||
|
|
||||||
|
export type TileKind =
|
||||||
|
| 'void' | 'floor' | 'wall' | 'bar' | 'stool' | 'dance'
|
||||||
|
| 'booth' | 'stall' | 'stallDoor' | 'exit' | 'smokeDoor' | 'neon';
|
||||||
|
|
||||||
|
export type AnchorKind = 'bar' | 'dance' | 'booth' | 'toilet' | 'smoke' | 'entry' | 'exit';
|
||||||
|
|
||||||
|
export interface TilePoint { tx: number; ty: number }
|
||||||
|
export interface WorldPoint { x: number; y: number }
|
||||||
|
|
||||||
|
export interface StallDef {
|
||||||
|
id: string; // 's1'..'s4'
|
||||||
|
door: WorldPoint; // world px, the tile just OUTSIDE the door (where you stand)
|
||||||
|
inside: WorldPoint; // world px, centre of the cubicle
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VenueMap {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
tiles: readonly TileKind[]; // row-major, length MAP_W*MAP_H
|
||||||
|
anchors: Readonly<Record<AnchorKind, readonly WorldPoint[]>>; // world px
|
||||||
|
stalls: readonly StallDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'stall' is walkable, but each cubicle interior is sealed behind a non-walkable
|
||||||
|
// 'stallDoor' — no path leads in. Movers must not pick stall tiles as pathing
|
||||||
|
// targets; send a patron inside via StallDef.inside instead. Everything else
|
||||||
|
// walkable is mutually reachable (see tests/floor/venueMap.test.ts).
|
||||||
|
const WALKABLE: ReadonlySet<TileKind> = new Set<TileKind>([
|
||||||
|
'floor', 'dance', 'exit', 'smokeDoor', 'stall',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Tuned UNDER the renderer's darkness sheet, not in isolation — at the original
|
||||||
|
// values the venue multiplied down to near-black and you couldn't tell the dance
|
||||||
|
// floor from a wall. Dark enough that you need the torch, light enough to navigate.
|
||||||
|
const COLOURS: Record<TileKind, number> = {
|
||||||
|
void: 0x05050a,
|
||||||
|
floor: 0x221e2b,
|
||||||
|
wall: 0x3a3448,
|
||||||
|
bar: 0x5c3f28,
|
||||||
|
stool: 0x6d4726,
|
||||||
|
dance: 0x33284a,
|
||||||
|
booth: 0x452c3c,
|
||||||
|
stall: 0x2e3838,
|
||||||
|
stallDoor: 0x4c6058,
|
||||||
|
exit: 0x2c5636,
|
||||||
|
smokeDoor: 0x3d4d5c,
|
||||||
|
neon: 0xd8409a,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cubicle west edges. Each cubicle is a 4x4 walled box; the last one shares the
|
||||||
|
// dunny room's east wall, which is why they stop at 73. CUBICLE_Y sits flush
|
||||||
|
// under the room's north wall — a gap there would strand a dead strip of floor.
|
||||||
|
const CUBICLE_X = [61, 65, 69, 73] as const;
|
||||||
|
const CUBICLE_Y = 4;
|
||||||
|
|
||||||
|
function buildTiles(): TileKind[] {
|
||||||
|
const tiles = new Array<TileKind>(MAP_W * MAP_H).fill('floor');
|
||||||
|
const set = (tx: number, ty: number, kind: TileKind): void => {
|
||||||
|
if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) return;
|
||||||
|
tiles[ty * MAP_W + tx] = kind;
|
||||||
|
};
|
||||||
|
const rect = (x0: number, y0: number, x1: number, y1: number, kind: TileKind): void => {
|
||||||
|
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) set(tx, ty, kind);
|
||||||
|
};
|
||||||
|
const box = (x0: number, y0: number, x1: number, y1: number, kind: TileKind): void => {
|
||||||
|
rect(x0, y0, x1, y0, kind);
|
||||||
|
rect(x0, y1, x1, y1, kind);
|
||||||
|
rect(x0, y0, x0, y1, kind);
|
||||||
|
rect(x1, y0, x1, y1, kind);
|
||||||
|
};
|
||||||
|
|
||||||
|
box(0, 0, MAP_W - 1, MAP_H - 1, 'wall');
|
||||||
|
|
||||||
|
// Dead space west of the main room, carved back out for the entry corridor.
|
||||||
|
rect(1, 1, 13, MAP_H - 2, 'wall');
|
||||||
|
rect(1, 21, 14, 23, 'floor');
|
||||||
|
rect(1, 21, 1, 23, 'exit');
|
||||||
|
|
||||||
|
// Bar run along the north wall, stools hugging its south face.
|
||||||
|
rect(22, 4, 52, 5, 'bar');
|
||||||
|
rect(22, 6, 52, 6, 'stool');
|
||||||
|
|
||||||
|
rect(26, 16, 50, 30, 'dance');
|
||||||
|
|
||||||
|
// Booths: south wall then east wall.
|
||||||
|
for (const tx of [22, 30, 38, 46]) rect(tx, 41, tx + 1, 42, 'booth');
|
||||||
|
for (const ty of [22, 27, 32]) rect(76, ty, 77, ty + 1, 'booth');
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
for (const x of CUBICLE_X) {
|
||||||
|
box(x, CUBICLE_Y, x + 3, CUBICLE_Y + 3, 'wall');
|
||||||
|
rect(x + 1, CUBICLE_Y + 1, x + 2, CUBICLE_Y + 2, 'stall');
|
||||||
|
set(x + 1, CUBICLE_Y + 3, 'stallDoor');
|
||||||
|
}
|
||||||
|
|
||||||
|
set(18, MAP_H - 1, 'smokeDoor');
|
||||||
|
|
||||||
|
// Accent strips the renderer hangs point lights off.
|
||||||
|
for (const tx of [20, 30, 40, 50, 60]) set(tx, 0, 'neon');
|
||||||
|
for (const ty of [10, 20, 30]) set(MAP_W - 1, ty, 'neon');
|
||||||
|
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function worldToTile(x: number, y: number): TilePoint {
|
||||||
|
return { tx: Math.floor(x / TILE), ty: Math.floor(y / TILE) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Centre of the tile, not its corner — patrons stand on centres. */
|
||||||
|
export function tileToWorld(tx: number, ty: number): WorldPoint {
|
||||||
|
return { x: tx * TILE + TILE / 2, y: ty * TILE + TILE / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStalls(): StallDef[] {
|
||||||
|
return CUBICLE_X.map((x, i) => ({
|
||||||
|
id: `s${i + 1}`,
|
||||||
|
door: tileToWorld(x + 1, CUBICLE_Y + 4),
|
||||||
|
// Centre of the 2x2 interior, which lands on a tile corner rather than a centre.
|
||||||
|
inside: { x: (x + 2) * TILE, y: (CUBICLE_Y + 2) * TILE },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnchors(): Record<AnchorKind, readonly WorldPoint[]> {
|
||||||
|
const dance: WorldPoint[] = [];
|
||||||
|
for (const ty of [20, 26]) for (const tx of [30, 36, 42, 48]) dance.push(tileToWorld(tx, ty));
|
||||||
|
return {
|
||||||
|
bar: [24, 29, 34, 39, 44, 49].map((tx) => tileToWorld(tx, 7)),
|
||||||
|
dance,
|
||||||
|
booth: [
|
||||||
|
...[22, 30, 38, 46].map((tx) => tileToWorld(tx, 40)),
|
||||||
|
tileToWorld(75, 22),
|
||||||
|
tileToWorld(75, 27),
|
||||||
|
],
|
||||||
|
toilet: CUBICLE_X.map((x) => tileToWorld(x + 1, CUBICLE_Y + 4)),
|
||||||
|
smoke: [tileToWorld(18, MAP_H - 1)],
|
||||||
|
entry: [tileToWorld(4, 22), tileToWorld(9, 22)],
|
||||||
|
exit: [tileToWorld(1, 22)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VENUE: VenueMap = Object.freeze({
|
||||||
|
width: MAP_W,
|
||||||
|
height: MAP_H,
|
||||||
|
tiles: Object.freeze(buildTiles()),
|
||||||
|
anchors: Object.freeze(buildAnchors()),
|
||||||
|
stalls: Object.freeze(buildStalls()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function tileAt(map: VenueMap, tx: number, ty: number): TileKind {
|
||||||
|
if (tx < 0 || tx >= map.width || ty < 0 || ty >= map.height) return 'void';
|
||||||
|
return map.tiles[ty * map.width + tx] ?? 'void';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWalkable(map: VenueMap, tx: number, ty: number): boolean {
|
||||||
|
return WALKABLE.has(tileAt(map, tx, ty));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWalkableWorld(map: VenueMap, x: number, y: number): boolean {
|
||||||
|
const { tx, ty } = worldToTile(x, y);
|
||||||
|
return isWalkable(map, tx, ty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colour for painting a tile in the dark venue. */
|
||||||
|
export function tileColour(kind: TileKind): number {
|
||||||
|
return COLOURS[kind];
|
||||||
|
}
|
||||||
@ -45,6 +45,7 @@ export class ParadeScene extends Phaser.Scene {
|
|||||||
this.resetRun(this.seed);
|
this.resetRun(this.seed);
|
||||||
|
|
||||||
this.input.keyboard?.on('keydown-SPACE', () => this.resetRun(this.seed + 1));
|
this.input.keyboard?.on('keydown-SPACE', () => this.resetRun(this.seed + 1));
|
||||||
|
this.input.keyboard?.on('keydown-F', () => this.scene.start('FloorDemo')); // LANE-FLOOR demo
|
||||||
this.input.keyboard?.on('keydown-D', () => {
|
this.input.keyboard?.on('keydown-D', () => {
|
||||||
this.showIds = !this.showIds;
|
this.showIds = !this.showIds;
|
||||||
for (const w of this.walkers) {
|
for (const w of this.walkers) {
|
||||||
@ -89,7 +90,7 @@ export class ParadeScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.seedText = this.add.text(4, H - 12, '', { fontFamily: 'monospace', fontSize: '8px', color: '#667' });
|
this.seedText = this.add.text(4, H - 12, '', { fontFamily: 'monospace', fontSize: '8px', color: '#667' });
|
||||||
this.clockText = this.add.text(W - 60, 4, '', { fontFamily: 'monospace', fontSize: '8px', color: '#9fe8a0' });
|
this.clockText = this.add.text(W - 60, 4, '', { fontFamily: 'monospace', fontSize: '8px', color: '#9fe8a0' });
|
||||||
this.add.text(4, 14, 'SPACE reseed · D toggle IDs', { fontFamily: 'monospace', fontSize: '8px', color: '#556' });
|
this.add.text(4, 14, 'SPACE reseed · D toggle IDs · F floor demo', { fontFamily: 'monospace', fontSize: '8px', color: '#556' });
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetRun(seed: number): void {
|
private resetRun(seed: number): void {
|
||||||
|
|||||||
149
tests/floor/bangJudge.test.ts
Normal file
149
tests/floor/bangJudge.test.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
applyBang,
|
||||||
|
BANGS_TO_BUST,
|
||||||
|
beatError,
|
||||||
|
freshStall,
|
||||||
|
judgeBang,
|
||||||
|
OK_MS,
|
||||||
|
PERFECT_MS,
|
||||||
|
scoreStall,
|
||||||
|
type StallState,
|
||||||
|
} from '../../src/scenes/floor/bangJudge';
|
||||||
|
|
||||||
|
const INTERVAL = 60_000 / 128;
|
||||||
|
const TICK = { beatIndex: 4, audioTimeMs: 10_000 };
|
||||||
|
|
||||||
|
describe('beatError', () => {
|
||||||
|
it('is zero exactly on the tick', () => {
|
||||||
|
expect(beatError(TICK.audioTimeMs, TICK, INTERVAL)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures to the nearest boundary, not to the tick itself', () => {
|
||||||
|
expect(beatError(TICK.audioTimeMs + INTERVAL, TICK, INTERVAL)).toBeCloseTo(0, 6);
|
||||||
|
expect(beatError(TICK.audioTimeMs + 3 * INTERVAL + 25, TICK, INTERVAL)).toBeCloseTo(25, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('judges a bang landing before the tick against the previous boundary', () => {
|
||||||
|
expect(beatError(TICK.audioTimeMs - INTERVAL + 30, TICK, INTERVAL)).toBeCloseTo(30, 6);
|
||||||
|
expect(beatError(TICK.audioTimeMs - 20, TICK, INTERVAL)).toBeCloseTo(20, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('peaks halfway between beats', () => {
|
||||||
|
expect(beatError(TICK.audioTimeMs + INTERVAL / 2, TICK, INTERVAL)).toBeCloseTo(INTERVAL / 2, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('judgeBang', () => {
|
||||||
|
it('grades an exact hit as perfect', () => {
|
||||||
|
expect(judgeBang(TICK.audioTimeMs, TICK, INTERVAL)).toBe('perfect');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still grades perfect a full beat interval later', () => {
|
||||||
|
expect(judgeBang(TICK.audioTimeMs + INTERVAL, TICK, INTERVAL)).toBe('perfect');
|
||||||
|
expect(judgeBang(TICK.audioTimeMs + 8 * INTERVAL, TICK, INTERVAL)).toBe('perfect');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grades halfway between beats as a miss', () => {
|
||||||
|
expect(judgeBang(TICK.audioTimeMs + INTERVAL / 2, TICK, INTERVAL)).toBe('miss');
|
||||||
|
expect(judgeBang(TICK.audioTimeMs + 2.5 * INTERVAL, TICK, INTERVAL)).toBe('miss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats PERFECT_MS as inclusive and just past it as ok', () => {
|
||||||
|
expect(judgeBang(TICK.audioTimeMs + PERFECT_MS, TICK, INTERVAL)).toBe('perfect');
|
||||||
|
expect(judgeBang(TICK.audioTimeMs - PERFECT_MS, TICK, INTERVAL)).toBe('perfect');
|
||||||
|
expect(judgeBang(TICK.audioTimeMs + PERFECT_MS + 0.5, TICK, INTERVAL)).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats OK_MS as inclusive and just past it as a miss', () => {
|
||||||
|
expect(judgeBang(TICK.audioTimeMs + OK_MS, TICK, INTERVAL)).toBe('ok');
|
||||||
|
expect(judgeBang(TICK.audioTimeMs - OK_MS, TICK, INTERVAL)).toBe('ok');
|
||||||
|
expect(judgeBang(TICK.audioTimeMs + OK_MS + 0.5, TICK, INTERVAL)).toBe('miss');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grades late bangs relative to whichever boundary is closest', () => {
|
||||||
|
// 100ms past the fourth boundary — ok, even though it is ~1875ms past the tick.
|
||||||
|
expect(judgeBang(TICK.audioTimeMs + 4 * INTERVAL + 100, TICK, INTERVAL)).toBe('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyBang', () => {
|
||||||
|
it('does not mutate the input state', () => {
|
||||||
|
const s = freshStall();
|
||||||
|
const snapshot: StallState = { ...s };
|
||||||
|
applyBang(s, 'perfect');
|
||||||
|
expect(s).toEqual(snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advances the combo on perfect and ok alike', () => {
|
||||||
|
const a = applyBang(freshStall(), 'perfect');
|
||||||
|
const b = applyBang(a, 'ok');
|
||||||
|
expect(b.combo).toBe(2);
|
||||||
|
expect(b.bangs).toBe(2);
|
||||||
|
expect(b.aggro).toBe(0);
|
||||||
|
expect(b.lastGrade).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the combo and adds aggro on a miss', () => {
|
||||||
|
const a = applyBang(applyBang(freshStall(), 'ok'), 'miss');
|
||||||
|
expect(a.combo).toBe(0);
|
||||||
|
expect(a.bangs).toBe(2);
|
||||||
|
expect(a.aggro).toBeCloseTo(0.8, 6);
|
||||||
|
expect(a.busted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('busts after three consecutive ok bangs', () => {
|
||||||
|
let s = freshStall();
|
||||||
|
for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'ok');
|
||||||
|
expect(s.busted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays busted once busted, even after a later miss', () => {
|
||||||
|
let s = freshStall();
|
||||||
|
for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'perfect');
|
||||||
|
s = applyBang(s, 'miss');
|
||||||
|
expect(s.busted).toBe(true);
|
||||||
|
expect(s.combo).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not busted when a miss interrupts the run', () => {
|
||||||
|
let s = freshStall();
|
||||||
|
for (const g of ['ok', 'miss', 'ok', 'ok'] as const) s = applyBang(s, g);
|
||||||
|
expect(s.busted).toBe(false);
|
||||||
|
expect(s.combo).toBe(2);
|
||||||
|
expect(s.bangs).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scoreStall', () => {
|
||||||
|
it('pays out big when the stall is busted', () => {
|
||||||
|
let s = freshStall();
|
||||||
|
for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'perfect');
|
||||||
|
const out = scoreStall(s);
|
||||||
|
expect(out.solved).toBe(true);
|
||||||
|
expect(out.vibeDelta).toBe(9);
|
||||||
|
expect(out.aggroDelta).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('costs vibe when the player made a scene and achieved nothing', () => {
|
||||||
|
const out = scoreStall(applyBang(freshStall(), 'miss'));
|
||||||
|
expect(out.solved).toBe(false);
|
||||||
|
expect(out.vibeDelta).toBe(-1.5);
|
||||||
|
expect(out.aggroDelta).toBeCloseTo(0.8, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps accumulated aggro at 6', () => {
|
||||||
|
let s = freshStall();
|
||||||
|
for (let i = 0; i < 12; i++) s = applyBang(s, 'miss');
|
||||||
|
expect(s.aggro).toBeGreaterThan(6);
|
||||||
|
expect(scoreStall(s).aggroDelta).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries aggro from misses through a bust', () => {
|
||||||
|
let s = freshStall();
|
||||||
|
for (const g of ['miss', 'miss', 'ok', 'ok', 'perfect'] as const) s = applyBang(s, g);
|
||||||
|
const out = scoreStall(s);
|
||||||
|
expect(out.solved).toBe(true);
|
||||||
|
expect(out.aggroDelta).toBeCloseTo(1.6, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
167
tests/floor/cone.test.ts
Normal file
167
tests/floor/cone.test.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
NORMAL_CONE, UV_CONE, angleDelta, coneVisibility, conePolygon, inCone,
|
||||||
|
} from '../../src/scenes/floor/cone';
|
||||||
|
import type { ConeSpec } from '../../src/scenes/floor/cone';
|
||||||
|
|
||||||
|
const EAST: ConeSpec = { x: 100, y: 100, facing: 0, ...NORMAL_CONE };
|
||||||
|
const at = (spec: ConeSpec, dist: number, off: number) => ({
|
||||||
|
x: spec.x + Math.cos(spec.facing + off) * dist,
|
||||||
|
y: spec.y + Math.sin(spec.facing + off) * dist,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('angleDelta', () => {
|
||||||
|
it('is zero for identical angles and full turns apart', () => {
|
||||||
|
expect(angleDelta(0, 0)).toBeCloseTo(0, 12);
|
||||||
|
expect(angleDelta(Math.PI * 2, 0)).toBeCloseTo(0, 12);
|
||||||
|
expect(angleDelta(-Math.PI * 4, 0)).toBeCloseTo(0, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the short way around the wrap point', () => {
|
||||||
|
expect(angleDelta(0.1, -0.1)).toBeCloseTo(0.2, 12);
|
||||||
|
// 175 deg and -175 deg are 10 deg apart, not 350.
|
||||||
|
const a = Math.PI - 0.087;
|
||||||
|
const b = -Math.PI + 0.087;
|
||||||
|
expect(Math.abs(angleDelta(a, b))).toBeCloseTo(0.174, 6);
|
||||||
|
expect(Math.abs(angleDelta(b, a))).toBeCloseTo(0.174, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays in (-PI, PI] for a sweep of inputs', () => {
|
||||||
|
for (let i = -40; i <= 40; i++) {
|
||||||
|
const d = angleDelta(i * 0.37, i * -0.61);
|
||||||
|
expect(d).toBeGreaterThan(-Math.PI);
|
||||||
|
expect(d).toBeLessThanOrEqual(Math.PI + 1e-12);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps an exact half turn to +PI, not -PI', () => {
|
||||||
|
expect(angleDelta(Math.PI, 0)).toBeCloseTo(Math.PI, 12);
|
||||||
|
expect(angleDelta(0, Math.PI)).toBeCloseTo(Math.PI, 12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inCone', () => {
|
||||||
|
it('sees straight ahead', () => {
|
||||||
|
expect(inCone(EAST, 200, 100)).toBe(true);
|
||||||
|
expect(inCone(EAST, EAST.x, EAST.y)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is blind behind and to the sides', () => {
|
||||||
|
expect(inCone(EAST, 0, 100)).toBe(false);
|
||||||
|
expect(inCone(EAST, 100, 20)).toBe(false);
|
||||||
|
expect(inCone(EAST, ...([at(EAST, 50, 1.2).x, at(EAST, 50, 1.2).y] as const))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is blind beyond range', () => {
|
||||||
|
const far = at(EAST, EAST.range + 1, 0);
|
||||||
|
expect(inCone(EAST, far.x, far.y)).toBe(false);
|
||||||
|
const near = at(EAST, EAST.range - 1, 0);
|
||||||
|
expect(inCone(EAST, near.x, near.y)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects facing — rotating the cone rotates what it sees', () => {
|
||||||
|
const north: ConeSpec = { ...EAST, facing: -Math.PI / 2 };
|
||||||
|
expect(inCone(north, 100, 20)).toBe(true);
|
||||||
|
expect(inCone(north, 200, 100)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works across the PI wrap', () => {
|
||||||
|
const west: ConeSpec = { ...EAST, facing: Math.PI };
|
||||||
|
const p = at(west, 60, 0.2);
|
||||||
|
expect(inCone(west, p.x, p.y)).toBe(true);
|
||||||
|
expect(inCone(west, 200, 100)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UV torch is tighter and shorter than the normal one', () => {
|
||||||
|
expect(UV_CONE.halfAngle).toBeLessThan(NORMAL_CONE.halfAngle);
|
||||||
|
expect(UV_CONE.range).toBeLessThan(NORMAL_CONE.range);
|
||||||
|
const uv: ConeSpec = { x: 100, y: 100, facing: 0, ...UV_CONE };
|
||||||
|
const p = at(EAST, 160, 0.5);
|
||||||
|
expect(inCone(EAST, p.x, p.y)).toBe(true);
|
||||||
|
expect(inCone(uv, p.x, p.y)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('coneVisibility', () => {
|
||||||
|
it('is 0 wherever inCone is false', () => {
|
||||||
|
for (let off = -2; off <= 2; off += 0.1) {
|
||||||
|
for (const dist of [10, 90, 189, 191, 400]) {
|
||||||
|
const p = at(EAST, dist, off);
|
||||||
|
if (!inCone(EAST, p.x, p.y)) expect(coneVisibility(EAST, p.x, p.y)).toBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is strictly positive inside and never above 1', () => {
|
||||||
|
for (const off of [0, 0.2, -0.2, 0.5, -0.5]) {
|
||||||
|
for (const dist of [1, 40, 120, 180]) {
|
||||||
|
const p = at(EAST, dist, off);
|
||||||
|
const v = coneVisibility(EAST, p.x, p.y);
|
||||||
|
expect(v).toBeGreaterThan(0);
|
||||||
|
expect(v).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(coneVisibility(EAST, EAST.x, EAST.y)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is brighter on-axis than off-axis at the same distance', () => {
|
||||||
|
const onAxis = at(EAST, 100, 0);
|
||||||
|
const offAxis = at(EAST, 100, 0.4);
|
||||||
|
expect(coneVisibility(EAST, onAxis.x, onAxis.y))
|
||||||
|
.toBeGreaterThan(coneVisibility(EAST, offAxis.x, offAxis.y));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decreases monotonically with distance along the axis', () => {
|
||||||
|
let prev = Infinity;
|
||||||
|
for (let d = 1; d < EAST.range; d += 10) {
|
||||||
|
const p = at(EAST, d, 0);
|
||||||
|
const v = coneVisibility(EAST, p.x, p.y);
|
||||||
|
expect(v).toBeLessThan(prev);
|
||||||
|
prev = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fades to nothing at the cone edge', () => {
|
||||||
|
const edge = at(EAST, 100, EAST.halfAngle);
|
||||||
|
expect(coneVisibility(EAST, edge.x, edge.y)).toBeCloseTo(0, 9);
|
||||||
|
const rim = at(EAST, EAST.range, 0);
|
||||||
|
expect(coneVisibility(EAST, rim.x, rim.y)).toBeCloseTo(0, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('conePolygon', () => {
|
||||||
|
it('starts at the apex and closes back on it', () => {
|
||||||
|
const pts = conePolygon(EAST, 12);
|
||||||
|
expect(pts[0]).toEqual({ x: EAST.x, y: EAST.y });
|
||||||
|
expect(pts[pts.length - 1]).toEqual(pts[0]);
|
||||||
|
expect(pts).toHaveLength(12 + 3); // apex + (segments+1) arc points + closing apex
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts every arc point at exactly range, inside the cone', () => {
|
||||||
|
const pts = conePolygon(EAST, 16).slice(1, -1);
|
||||||
|
for (const p of pts) {
|
||||||
|
expect(Math.hypot(p.x - EAST.x, p.y - EAST.y)).toBeCloseTo(EAST.range, 9);
|
||||||
|
expect(Math.abs(angleDelta(Math.atan2(p.y - EAST.y, p.x - EAST.x), EAST.facing)))
|
||||||
|
.toBeLessThanOrEqual(EAST.halfAngle + 1e-9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spans the full cone width, edge to edge', () => {
|
||||||
|
const pts = conePolygon(EAST, 8);
|
||||||
|
const firstArc = pts[1]!;
|
||||||
|
const lastArc = pts[pts.length - 2]!;
|
||||||
|
expect(angleDelta(Math.atan2(firstArc.y - EAST.y, firstArc.x - EAST.x), EAST.facing))
|
||||||
|
.toBeCloseTo(-EAST.halfAngle, 9);
|
||||||
|
expect(angleDelta(Math.atan2(lastArc.y - EAST.y, lastArc.x - EAST.x), EAST.facing))
|
||||||
|
.toBeCloseTo(EAST.halfAngle, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a degenerate segment count', () => {
|
||||||
|
expect(conePolygon(EAST, 0)).toHaveLength(4);
|
||||||
|
expect(conePolygon(EAST, 1)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to a smooth arc', () => {
|
||||||
|
expect(conePolygon(EAST).length).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
362
tests/floor/crowdSim.test.ts
Normal file
362
tests/floor/crowdSim.test.ts
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { EventBus } from '../../src/core/EventBus';
|
||||||
|
import { SeededRNG } from '../../src/core/SeededRNG';
|
||||||
|
import type { Patron } from '../../src/data/types';
|
||||||
|
import { _resetPatronSerial, generatePatron } from '../../src/patrons/generator';
|
||||||
|
import { CrowdSim } from '../../src/scenes/floor/crowdSim';
|
||||||
|
import type { Agent } from '../../src/scenes/floor/crowdSim';
|
||||||
|
import { VENUE, isWalkableWorld } from '../../src/scenes/floor/venueMap';
|
||||||
|
|
||||||
|
const NIGHT = new Date('2026-07-19T21:00:00');
|
||||||
|
|
||||||
|
function makeSim(seed = 1234): CrowdSim {
|
||||||
|
return new CrowdSim({ map: VENUE, bus: new EventBus(), rng: new SeededRNG(seed).stream('crowd') });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same seed in, same crowd out — the sims under test must not share patrons. */
|
||||||
|
function makeCrowd(count: number, seed = 99): Patron[] {
|
||||||
|
_resetPatronSerial();
|
||||||
|
const rng = new SeededRNG(seed);
|
||||||
|
const out: Patron[] = [];
|
||||||
|
for (let i = 0; i < count; i++) out.push(generatePatron({ rng, nightDate: NIGHT }, 60));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEntry = (a: Agent): boolean =>
|
||||||
|
VENUE.anchors.entry.some((e) => e.x === a.x && e.y === a.y);
|
||||||
|
|
||||||
|
/** Park an agent in a fixed activity so the test can drive one thing at a time. */
|
||||||
|
function pin(a: Agent, activity: Agent['activity'], x?: number, y?: number): Agent {
|
||||||
|
a.activity = activity;
|
||||||
|
a.dwellMs = 10 * 60 * 1000;
|
||||||
|
if (x !== undefined) a.x = x;
|
||||||
|
if (y !== undefined) a.y = y;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetPatronSerial();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spawn', () => {
|
||||||
|
it('puts the agent on an entry anchor, unhandled and present', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
for (const p of makeCrowd(6)) {
|
||||||
|
const a = sim.spawn(p);
|
||||||
|
expect(isEntry(a)).toBe(true);
|
||||||
|
expect(a.handled).toBe(false);
|
||||||
|
expect(a.gone).toBe(false);
|
||||||
|
expect(a.activity).toBe('walking');
|
||||||
|
}
|
||||||
|
expect(sim.agents).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds agents by patron id', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
const crowd = makeCrowd(3);
|
||||||
|
for (const p of crowd) sim.spawn(p);
|
||||||
|
expect(sim.find(crowd[1]!.id)?.patron).toBe(crowd[1]);
|
||||||
|
expect(sim.find('nobody')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('movement', () => {
|
||||||
|
it('keeps every agent on walkable ground for a whole night', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
for (const p of makeCrowd(14)) sim.spawn(p);
|
||||||
|
|
||||||
|
for (let step = 0; step < 6000; step++) {
|
||||||
|
sim.update(16, step / 60);
|
||||||
|
for (const a of sim.agents) {
|
||||||
|
expect(
|
||||||
|
isWalkableWorld(VENUE, a.x, a.y),
|
||||||
|
`${a.patron.id} walked into a wall at ${a.x.toFixed(1)},${a.y.toFixed(1)}`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('actually gets people to their anchors — nobody walks all night', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
for (const p of makeCrowd(10)) sim.spawn(p);
|
||||||
|
const settled = new Set<string>();
|
||||||
|
for (let step = 0; step < 4000; step++) {
|
||||||
|
sim.update(16, step / 60);
|
||||||
|
for (const a of sim.agents) if (a.activity !== 'walking') settled.add(a.patron.id);
|
||||||
|
}
|
||||||
|
expect(settled.size).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('intoxication drift', () => {
|
||||||
|
it('rises with the clock, and faster at the bar', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
const [barP, danceP] = makeCrowd(2);
|
||||||
|
const bar = pin(sim.spawn(barP!), 'atBar');
|
||||||
|
const dance = pin(sim.spawn(danceP!), 'dancing');
|
||||||
|
for (const a of [bar, dance]) {
|
||||||
|
a.patron.tolerance = 0.5;
|
||||||
|
a.patron.intoxication = 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
sim.update(16, 0);
|
||||||
|
sim.update(16, 90);
|
||||||
|
|
||||||
|
expect(bar.patron.intoxication).toBeGreaterThan(0.3);
|
||||||
|
expect(dance.patron.intoxication).toBeGreaterThan(0.3);
|
||||||
|
expect(bar.patron.intoxication).toBeGreaterThan(dance.patron.intoxication);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is framerate independent — only clock minutes count', () => {
|
||||||
|
const coarse = makeSim();
|
||||||
|
const fine = makeSim();
|
||||||
|
const [a1] = makeCrowd(1);
|
||||||
|
const [a2] = makeCrowd(1);
|
||||||
|
const slow = pin(coarse.spawn(a1!), 'atBar');
|
||||||
|
const fast = pin(fine.spawn(a2!), 'atBar');
|
||||||
|
for (const a of [slow, fast]) {
|
||||||
|
a.patron.tolerance = 0.4;
|
||||||
|
a.patron.intoxication = 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
coarse.update(16, 0);
|
||||||
|
coarse.update(16, 60);
|
||||||
|
fine.update(16, 0);
|
||||||
|
for (let i = 1; i <= 60; i++) fine.update(16, i);
|
||||||
|
|
||||||
|
expect(fast.patron.intoxication).toBeCloseTo(slow.patron.intoxication, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never tips past legless', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
const a = pin(sim.spawn(makeCrowd(1)[0]!), 'atBar');
|
||||||
|
a.patron.tolerance = 0;
|
||||||
|
a.patron.intoxication = 0.9;
|
||||||
|
sim.update(16, 0);
|
||||||
|
sim.update(16, 10000);
|
||||||
|
expect(a.patron.intoxication).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('beat bob', () => {
|
||||||
|
it('advances dancers only', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
const [p1, p2] = makeCrowd(2);
|
||||||
|
const dancer = pin(sim.spawn(p1!), 'dancing');
|
||||||
|
const drinker = pin(sim.spawn(p2!), 'atBar');
|
||||||
|
const before = { dancer: dancer.bobPhase, drinker: drinker.bobPhase };
|
||||||
|
|
||||||
|
sim.onBeat(1);
|
||||||
|
expect(dancer.bobPhase).not.toBe(before.dancer);
|
||||||
|
expect(drinker.bobPhase).toBe(before.drinker);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the phase inside 0..1 across a long set', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
const dancer = pin(sim.spawn(makeCrowd(1)[0]!), 'dancing');
|
||||||
|
for (let beat = 0; beat < 500; beat++) {
|
||||||
|
sim.onBeat(beat);
|
||||||
|
expect(dancer.bobPhase).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(dancer.bobPhase).toBeLessThan(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stalls', () => {
|
||||||
|
it('reports who is in which cubicle', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
const [p1, p2, p3] = makeCrowd(3);
|
||||||
|
const a = pin(sim.spawn(p1!), 'inStall');
|
||||||
|
const b = pin(sim.spawn(p2!), 'inStall');
|
||||||
|
const c = pin(sim.spawn(p3!), 'inStall');
|
||||||
|
a.stallId = 's1';
|
||||||
|
b.stallId = 's1';
|
||||||
|
c.stallId = 's3';
|
||||||
|
|
||||||
|
const occ = sim.stallOccupancy();
|
||||||
|
expect(occ.get('s1')).toHaveLength(2);
|
||||||
|
expect(occ.get('s2')).toHaveLength(0);
|
||||||
|
expect(occ.get('s3')).toEqual([c]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets two punters end up in one cubicle on its own', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
for (const p of makeCrowd(14)) sim.spawn(p);
|
||||||
|
let doubled = false;
|
||||||
|
let maxOccupancy = 0;
|
||||||
|
for (let step = 0; step < 9000; step++) {
|
||||||
|
sim.update(16, step / 60);
|
||||||
|
for (const [, list] of sim.stallOccupancy()) {
|
||||||
|
maxOccupancy = Math.max(maxOccupancy, list.length);
|
||||||
|
if (list.length >= 2) doubled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(doubled).toBe(true);
|
||||||
|
expect(maxOccupancy).toBeLessThanOrEqual(2); // the cap holds
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bumping', () => {
|
||||||
|
function bumpRig(): { sim: CrowdSim; hits: number[]; drunk: Agent } {
|
||||||
|
const bus = new EventBus();
|
||||||
|
const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(5).stream('crowd') });
|
||||||
|
const hits: number[] = [];
|
||||||
|
bus.on('meters:delta', (d) => hits.push(d.aggro ?? 0));
|
||||||
|
|
||||||
|
const [p1, p2] = makeCrowd(2);
|
||||||
|
const drunk = pin(sim.spawn(p1!), 'dancing');
|
||||||
|
const mate = pin(sim.spawn(p2!), 'dancing');
|
||||||
|
drunk.patron.intoxication = 0.95;
|
||||||
|
drunk.patron.tolerance = 1; // no drift, so the stage under test stays put
|
||||||
|
mate.patron.intoxication = 0.05;
|
||||||
|
mate.patron.tolerance = 1;
|
||||||
|
mate.x = drunk.x;
|
||||||
|
mate.y = drunk.y;
|
||||||
|
return { sim, hits, drunk };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fires once, then not again until the cooldown lapses', () => {
|
||||||
|
const { sim, hits } = bumpRig();
|
||||||
|
for (let i = 0; i < 100; i++) sim.update(16, 0); // 1.6s of grinding together
|
||||||
|
expect(hits).toHaveLength(1);
|
||||||
|
expect(hits[0]).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Second leak needs BOTH windows open: the 12s per-agent cooldown and the
|
||||||
|
// 8s sim-wide gate. 800 more frames puts total elapsed at ~14.4s.
|
||||||
|
for (let i = 0; i < 800; i++) sim.update(16, 0);
|
||||||
|
expect(hits).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: the per-agent cooldown bounds one PAIR, but the leak rate used
|
||||||
|
// to scale with crowd size — a full late-night floor pinned vibe to 0 and
|
||||||
|
// aggro to 100 on its own, before the player had touched anything. The
|
||||||
|
// sim-wide gate is what keeps ambient chaos a background cost.
|
||||||
|
it('bounds the total leak no matter how big the rowdy crowd is', () => {
|
||||||
|
const bus = new EventBus();
|
||||||
|
const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(9).stream('crowd') });
|
||||||
|
let vibe = 0;
|
||||||
|
bus.on('meters:delta', (d) => {
|
||||||
|
vibe += d.vibe ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 30 maggots stacked on one spot — the worst case the venue can produce.
|
||||||
|
const agents = makeCrowd(30).map((p) => pin(sim.spawn(p), 'dancing'));
|
||||||
|
const first = agents[0]!;
|
||||||
|
for (const a of agents) {
|
||||||
|
a.patron.intoxication = 0.95;
|
||||||
|
a.patron.tolerance = 1;
|
||||||
|
a.x = first.x;
|
||||||
|
a.y = first.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 3750; i++) sim.update(16, 0); // 60s of maximum chaos
|
||||||
|
// One leak per 8s, so a minute of it costs ~8 bumps, not hundreds.
|
||||||
|
expect(vibe).toBeGreaterThan(-5);
|
||||||
|
expect(vibe).toBeLessThan(0); // but it does still cost you something
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays quiet when the sober one is the only neighbour', () => {
|
||||||
|
const { sim, hits, drunk } = bumpRig();
|
||||||
|
drunk.patron.intoxication = 0.1; // nobody here is messy
|
||||||
|
for (let i = 0; i < 400; i++) sim.update(16, 0);
|
||||||
|
expect(hits).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the contracted vibe/aggro payload', () => {
|
||||||
|
const bus = new EventBus();
|
||||||
|
const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(5).stream('crowd') });
|
||||||
|
const seen: Array<{ vibe?: number; aggro?: number }> = [];
|
||||||
|
bus.on('meters:delta', (d) => seen.push(d));
|
||||||
|
const [p1, p2] = makeCrowd(2);
|
||||||
|
const drunk = pin(sim.spawn(p1!), 'dancing');
|
||||||
|
const mate = pin(sim.spawn(p2!), 'dancing');
|
||||||
|
drunk.patron.intoxication = 0.95;
|
||||||
|
drunk.patron.tolerance = 1;
|
||||||
|
mate.patron.tolerance = 1;
|
||||||
|
mate.x = drunk.x;
|
||||||
|
mate.y = drunk.y;
|
||||||
|
sim.update(16, 0);
|
||||||
|
expect(seen).toEqual([{ vibe: -0.4, aggro: 0.6 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('determinism', () => {
|
||||||
|
it('same seed and same calls give identical positions', () => {
|
||||||
|
const snap = (sim: CrowdSim): string => sim.agents
|
||||||
|
.map((a) => `${a.patron.id}:${a.x.toFixed(6)},${a.y.toFixed(6)}:${a.activity}:${a.patron.intoxication.toFixed(6)}`)
|
||||||
|
.join('|');
|
||||||
|
|
||||||
|
const runOne = (): string => {
|
||||||
|
const sim = makeSim(4242);
|
||||||
|
for (const p of makeCrowd(9)) sim.spawn(p);
|
||||||
|
for (let step = 0; step < 3000; step++) sim.update(16, step / 60);
|
||||||
|
return snap(sim);
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(runOne()).toBe(runOne());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('escort', () => {
|
||||||
|
it('walks them to the door when you follow, and only then', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
const a = sim.spawn(makeCrowd(1)[0]!);
|
||||||
|
// Start them deep in the dunnies — the nastiest route out of the venue.
|
||||||
|
a.x = VENUE.stalls[0]!.door.x;
|
||||||
|
a.y = VENUE.stalls[0]!.door.y;
|
||||||
|
|
||||||
|
expect(sim.beginEscort(a)).toBe(true);
|
||||||
|
expect(a.activity).toBe('escorted');
|
||||||
|
expect(a.handled).toBe(true);
|
||||||
|
expect(sim.beginEscort(a)).toBe(false);
|
||||||
|
|
||||||
|
// Stand off and they refuse to budge.
|
||||||
|
const parked = { x: a.x, y: a.y };
|
||||||
|
for (let i = 0; i < 100; i++) sim.updateEscorts(parked.x + 400, parked.y + 200, 16);
|
||||||
|
expect(a.x).toBe(parked.x);
|
||||||
|
expect(a.y).toBe(parked.y);
|
||||||
|
expect(a.gone).toBe(false);
|
||||||
|
|
||||||
|
// Stay on their heels and they go.
|
||||||
|
for (let i = 0; i < 12000 && !a.gone; i++) {
|
||||||
|
sim.updateEscorts(a.x, a.y, 16);
|
||||||
|
expect(isWalkableWorld(VENUE, a.x, a.y)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(a.gone).toBe(true);
|
||||||
|
expect(a.activity).toBe('leaving');
|
||||||
|
expect(a.x).toBe(VENUE.anchors.exit[0]!.x);
|
||||||
|
expect(a.y).toBe(VENUE.anchors.exit[0]!.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to escort someone already gone', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
const a = sim.spawn(makeCrowd(1)[0]!);
|
||||||
|
sim.beginEscort(a);
|
||||||
|
for (let i = 0; i < 12000 && !a.gone; i++) sim.updateEscorts(a.x, a.y, 16);
|
||||||
|
expect(a.gone).toBe(true);
|
||||||
|
expect(sim.beginEscort(a)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pulls them out of the cubicle they were hiding in', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
const a = pin(sim.spawn(makeCrowd(1)[0]!), 'inStall');
|
||||||
|
a.stallId = 's2';
|
||||||
|
a.x = VENUE.stalls[1]!.inside.x;
|
||||||
|
a.y = VENUE.stalls[1]!.inside.y;
|
||||||
|
|
||||||
|
expect(sim.beginEscort(a)).toBe(true);
|
||||||
|
expect(a.stallId).toBeUndefined();
|
||||||
|
expect(a.x).toBe(VENUE.stalls[1]!.door.x);
|
||||||
|
expect(sim.stallOccupancy().get('s2')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destroy', () => {
|
||||||
|
it('drops the crowd', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
for (const p of makeCrowd(4)) sim.spawn(p);
|
||||||
|
sim.destroy();
|
||||||
|
expect(sim.agents).toHaveLength(0);
|
||||||
|
expect(sim.find('p0')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
139
tests/floor/escalation.test.ts
Normal file
139
tests/floor/escalation.test.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
CORRECT_LINE,
|
||||||
|
MAGGOT_GRACE_MIN,
|
||||||
|
judgeEscalation,
|
||||||
|
maggotOverdue,
|
||||||
|
} from '../../src/scenes/floor/escalation';
|
||||||
|
import type { DrunkStage } from '../../src/data/types';
|
||||||
|
import {
|
||||||
|
CUTOFF_REPLY_CLEAN,
|
||||||
|
CUTOFF_REPLY_SCENE,
|
||||||
|
CUTOFF_REPLY_SOFT,
|
||||||
|
ESCALATION_LINES,
|
||||||
|
type EscalationLine,
|
||||||
|
} from '../../src/scenes/floor/strings';
|
||||||
|
|
||||||
|
const STAGES: readonly DrunkStage[] = ['sober', 'tipsy', 'loose', 'messy', 'maggot'];
|
||||||
|
const LINES: readonly EscalationLine[] = ['water', 'done', 'escort'];
|
||||||
|
|
||||||
|
describe('judgeEscalation matrix', () => {
|
||||||
|
it('returns a coherent result for every (stage, line) pair', () => {
|
||||||
|
for (const stage of STAGES) {
|
||||||
|
for (const line of LINES) {
|
||||||
|
const r = judgeEscalation(stage, line);
|
||||||
|
expect(['clean', 'scene', 'staggerFree']).toContain(r.outcome);
|
||||||
|
expect(r.line).toBe(ESCALATION_LINES[line]);
|
||||||
|
expect(r.line.length).toBeGreaterThan(0);
|
||||||
|
expect(r.reply.length).toBeGreaterThan(0);
|
||||||
|
expect(Object.values(CUTOFF_REPLY_CLEAN)
|
||||||
|
.concat(Object.values(CUTOFF_REPLY_SCENE), Object.values(CUTOFF_REPLY_SOFT)))
|
||||||
|
.toContain(r.reply);
|
||||||
|
expect(r.aggroDelta).toBeGreaterThanOrEqual(0);
|
||||||
|
if (r.eject) expect(r.outcome).toBe('clean');
|
||||||
|
if (!r.resolved) expect(r.outcome).toBe('staggerFree');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pulls the reply from the bank matching its outcome', () => {
|
||||||
|
for (const stage of STAGES) {
|
||||||
|
for (const line of LINES) {
|
||||||
|
const r = judgeEscalation(stage, line);
|
||||||
|
const bank =
|
||||||
|
r.outcome === 'clean' ? CUTOFF_REPLY_CLEAN
|
||||||
|
: r.outcome === 'scene' ? CUTOFF_REPLY_SCENE
|
||||||
|
: CUTOFF_REPLY_SOFT;
|
||||||
|
expect(r.reply).toBe(bank[stage]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is clean whenever the line matches the correct rung', () => {
|
||||||
|
for (const stage of STAGES) {
|
||||||
|
const r = judgeEscalation(stage, CORRECT_LINE[stage]);
|
||||||
|
expect(r.outcome).toBe('clean');
|
||||||
|
expect(r.vibeDelta).toBeGreaterThan(0);
|
||||||
|
expect(r.aggroDelta).toBe(0);
|
||||||
|
expect(r.resolved).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the clean reward modest — never a jackpot for being right', () => {
|
||||||
|
for (const stage of STAGES) {
|
||||||
|
expect(judgeEscalation(stage, CORRECT_LINE[stage]).vibeDelta).toBeLessThanOrEqual(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('over-escalating', () => {
|
||||||
|
it('makes a scene with positive aggro on a loose patron', () => {
|
||||||
|
const r = judgeEscalation('loose', 'done');
|
||||||
|
expect(r.outcome).toBe('scene');
|
||||||
|
expect(r.aggroDelta).toBeGreaterThan(0);
|
||||||
|
expect(r.vibeDelta).toBeLessThan(0);
|
||||||
|
expect(r.eject).toBe(false);
|
||||||
|
expect(r.resolved).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales aggro and vibe loss with the distance overreached', () => {
|
||||||
|
const oneRung = judgeEscalation('loose', 'done');
|
||||||
|
const twoRungs = judgeEscalation('loose', 'escort');
|
||||||
|
expect(twoRungs.aggroDelta).toBe(oneRung.aggroDelta * 2);
|
||||||
|
expect(twoRungs.vibeDelta).toBe(oneRung.vibeDelta * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('costs more than restraint does, so aggression never pays', () => {
|
||||||
|
const scene = judgeEscalation('sober', 'escort');
|
||||||
|
const soft = judgeEscalation('maggot', 'water');
|
||||||
|
expect(scene.vibeDelta).toBeLessThan(soft.vibeDelta);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('under-escalating', () => {
|
||||||
|
it('lets a maggot stagger free and leaves the infraction open', () => {
|
||||||
|
const r = judgeEscalation('maggot', 'water');
|
||||||
|
expect(r.outcome).toBe('staggerFree');
|
||||||
|
expect(r.resolved).toBe(false);
|
||||||
|
expect(r.eject).toBe(false);
|
||||||
|
expect(r.aggroDelta).toBe(0);
|
||||||
|
expect(r.vibeDelta).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not eject or resolve when you water a messy patron', () => {
|
||||||
|
const r = judgeEscalation('messy', 'water');
|
||||||
|
expect(r.outcome).toBe('staggerFree');
|
||||||
|
expect(r.eject).toBe(false);
|
||||||
|
expect(r.resolved).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ejection', () => {
|
||||||
|
it('escorts a maggot out', () => {
|
||||||
|
const r = judgeEscalation('maggot', 'escort');
|
||||||
|
expect(r.outcome).toBe('clean');
|
||||||
|
expect(r.eject).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks a messy patron when you call them done', () => {
|
||||||
|
expect(judgeEscalation('messy', 'done').eject).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never ejects someone who only needed water', () => {
|
||||||
|
for (const stage of ['sober', 'tipsy', 'loose'] as const) {
|
||||||
|
expect(judgeEscalation(stage, 'water').eject).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('maggotOverdue', () => {
|
||||||
|
it('is false at the grace boundary and true one minute past', () => {
|
||||||
|
expect(maggotOverdue(100, 100 + MAGGOT_GRACE_MIN)).toBe(false);
|
||||||
|
expect(maggotOverdue(100, 100 + MAGGOT_GRACE_MIN + 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false before the grace window elapses', () => {
|
||||||
|
expect(maggotOverdue(100, 100)).toBe(false);
|
||||||
|
expect(maggotOverdue(100, 110)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
163
tests/floor/patDown.test.ts
Normal file
163
tests/floor/patDown.test.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SeededRNG } from '../../src/core/SeededRNG';
|
||||||
|
import { PATDOWN_CLEAR, PATDOWN_MISSED } from '../../src/scenes/floor/strings';
|
||||||
|
import {
|
||||||
|
foundLine,
|
||||||
|
layoutPockets,
|
||||||
|
scorePatDown,
|
||||||
|
POCKET_COUNT,
|
||||||
|
type Pocket,
|
||||||
|
} from '../../src/scenes/floor/patDown';
|
||||||
|
import type { Patron } from '../../src/data/types';
|
||||||
|
|
||||||
|
const patronWith = (contraband: string[]): Patron => ({
|
||||||
|
id: 'p1',
|
||||||
|
dollSeed: 42,
|
||||||
|
archetype: 'punter',
|
||||||
|
age: 24,
|
||||||
|
idCard: { name: 'Shazza Nguyen', dob: '2002-03-04', expiry: '2029-03-04', photoSeed: 42 },
|
||||||
|
outfit: [],
|
||||||
|
intoxication: 0.3,
|
||||||
|
tolerance: 0.5,
|
||||||
|
flags: contraband.length > 0 ? { contraband } : {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = (seed: number) => new SeededRNG(seed).stream('patdown');
|
||||||
|
const binAll = (pockets: Pocket[]): Pocket[] => pockets.map((p) => ({ ...p, binned: p.itemId !== undefined }));
|
||||||
|
|
||||||
|
describe('layoutPockets', () => {
|
||||||
|
it('returns POCKET_COUNT pockets with unique ids at distinct positions', () => {
|
||||||
|
const pockets = layoutPockets(patronWith(['flask']), stream(1));
|
||||||
|
expect(pockets).toHaveLength(POCKET_COUNT);
|
||||||
|
expect(new Set(pockets.map((p) => p.id)).size).toBe(POCKET_COUNT);
|
||||||
|
expect(new Set(pockets.map((p) => `${p.x},${p.y}`)).size).toBe(POCKET_COUNT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps pockets inside the 64x96 outline and clear of each other', () => {
|
||||||
|
const pockets = layoutPockets(patronWith([]), stream(2));
|
||||||
|
for (const p of pockets) {
|
||||||
|
expect(p.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(p.y).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(p.x).toBeLessThanOrEqual(64);
|
||||||
|
expect(p.y).toBeLessThanOrEqual(96);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < pockets.length; i++) {
|
||||||
|
for (let j = i + 1; j < pockets.length; j++) {
|
||||||
|
const a = pockets[i]!;
|
||||||
|
const b = pockets[j]!;
|
||||||
|
const clear = Math.abs(a.x - b.x) >= 12 || Math.abs(a.y - b.y) >= 12;
|
||||||
|
expect(clear, `${a.label} overlaps ${b.label}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places every contraband id in a distinct pocket', () => {
|
||||||
|
const items = ['flask', 'kebab', 'budgie'];
|
||||||
|
const pockets = layoutPockets(patronWith(items), stream(3));
|
||||||
|
const filled = pockets.filter((p) => p.itemId !== undefined);
|
||||||
|
expect(filled).toHaveLength(items.length);
|
||||||
|
expect(new Set(filled.map((p) => p.id)).size).toBe(items.length);
|
||||||
|
expect(filled.map((p) => p.itemId).sort()).toEqual([...items].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an empty-handed patron with no items and nothing revealed or binned', () => {
|
||||||
|
const pockets = layoutPockets(patronWith([]), stream(4));
|
||||||
|
expect(pockets.every((p) => p.itemId === undefined)).toBe(true);
|
||||||
|
expect(pockets.every((p) => !p.revealed && !p.binned)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places only what fits when there is more contraband than pockets', () => {
|
||||||
|
const items = ['flask', 'kebab', 'budgie', 'vapes', 'marker', 'snags', 'tinnies', 'goonSack'];
|
||||||
|
const pockets = layoutPockets(patronWith(items), stream(5));
|
||||||
|
expect(pockets.filter((p) => p.itemId !== undefined)).toHaveLength(POCKET_COUNT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic for the same seed and divergent across seeds', () => {
|
||||||
|
const items = ['flask', 'kebab', 'budgie'];
|
||||||
|
const a = layoutPockets(patronWith(items), stream(7)).map((p) => p.itemId);
|
||||||
|
const b = layoutPockets(patronWith(items), stream(7)).map((p) => p.itemId);
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
|
||||||
|
const seeds = [11, 12, 13, 14, 15, 16].map((s) =>
|
||||||
|
layoutPockets(patronWith(items), stream(s))
|
||||||
|
.map((p) => p.itemId ?? '-')
|
||||||
|
.join(),
|
||||||
|
);
|
||||||
|
expect(new Set(seeds).size).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scorePatDown', () => {
|
||||||
|
it('rewards a full sweep: every item found, positive vibe, no aggro', () => {
|
||||||
|
const patron = patronWith(['flask', 'kebab']);
|
||||||
|
const result = scorePatDown(patron, binAll(layoutPockets(patron, stream(21))));
|
||||||
|
expect(result.foundIds.sort()).toEqual(['flask', 'kebab']);
|
||||||
|
expect(result.missedIds).toEqual([]);
|
||||||
|
expect(result.vibeDelta).toBe(4);
|
||||||
|
expect(result.aggroDelta).toBe(0);
|
||||||
|
expect(result.summary).not.toContain(PATDOWN_MISSED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaks vibe for items left in the pockets when time runs out', () => {
|
||||||
|
const patron = patronWith(['flask', 'kebab']);
|
||||||
|
const result = scorePatDown(patron, layoutPockets(patron, stream(22)));
|
||||||
|
expect(result.foundIds).toEqual([]);
|
||||||
|
expect(result.missedIds.sort()).toEqual(['flask', 'kebab']);
|
||||||
|
expect(result.vibeDelta).toBe(-3);
|
||||||
|
expect(result.summary).toBe(PATDOWN_MISSED);
|
||||||
|
expect(result.eject).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixes found and missed, and still flags the miss', () => {
|
||||||
|
const patron = patronWith(['flask', 'kebab']);
|
||||||
|
const pockets = layoutPockets(patron, stream(23)).map((p) => ({
|
||||||
|
...p,
|
||||||
|
binned: p.itemId === 'flask',
|
||||||
|
}));
|
||||||
|
const result = scorePatDown(patron, pockets);
|
||||||
|
expect(result.foundIds).toEqual(['flask']);
|
||||||
|
expect(result.missedIds).toEqual(['kebab']);
|
||||||
|
expect(result.vibeDelta).toBe(0.5);
|
||||||
|
expect(result.summary).toContain(PATDOWN_MISSED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ejects on a binned severity-3 item but not a severity-1 one', () => {
|
||||||
|
const baggie = patronWith(['baggie']);
|
||||||
|
expect(scorePatDown(baggie, binAll(layoutPockets(baggie, stream(31)))).eject).toBe(true);
|
||||||
|
|
||||||
|
const kebab = patronWith(['kebab']);
|
||||||
|
const kebabResult = scorePatDown(kebab, binAll(layoutPockets(kebab, stream(32))));
|
||||||
|
expect(kebabResult.eject).toBe(false);
|
||||||
|
expect(kebabResult.foundIds).toEqual(['kebab']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not eject for a severity-3 item that was never binned', () => {
|
||||||
|
const patron = patronWith(['baggie']);
|
||||||
|
const result = scorePatDown(patron, layoutPockets(patron, stream(33)));
|
||||||
|
expect(result.eject).toBe(false);
|
||||||
|
expect(result.missedIds).toEqual(['baggie']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charges aggro and reads PATDOWN_CLEAR when they had nothing on them', () => {
|
||||||
|
const patron = patronWith([]);
|
||||||
|
const result = scorePatDown(patron, layoutPockets(patron, stream(41)));
|
||||||
|
expect(result.foundIds).toEqual([]);
|
||||||
|
expect(result.missedIds).toEqual([]);
|
||||||
|
expect(result.aggroDelta).toBe(1.5);
|
||||||
|
expect(result.vibeDelta).toBe(0);
|
||||||
|
expect(result.eject).toBe(false);
|
||||||
|
expect(result.summary).toBe(PATDOWN_CLEAR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('foundLine', () => {
|
||||||
|
it('uses the scripted line for a known item', () => {
|
||||||
|
expect(foundLine('budgie')).toContain('budgie');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back gracefully for an unknown id', () => {
|
||||||
|
const line = foundLine('a-second-budgie');
|
||||||
|
expect(typeof line).toBe('string');
|
||||||
|
expect(line.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
134
tests/floor/player.test.ts
Normal file
134
tests/floor/player.test.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
PLAYER_ACCEL, PLAYER_FRICTION, PLAYER_MAX_SPEED, PLAYER_RADIUS,
|
||||||
|
freshPlayer, stepPlayer,
|
||||||
|
} from '../../src/scenes/floor/player';
|
||||||
|
import type { PlayerInput, PlayerState } from '../../src/scenes/floor/player';
|
||||||
|
import { TILE, VENUE, isWalkableWorld, tileToWorld } from '../../src/scenes/floor/venueMap';
|
||||||
|
|
||||||
|
const NONE: PlayerInput = { up: false, down: false, left: false, right: false };
|
||||||
|
const input = (over: Partial<PlayerInput>): PlayerInput => ({ ...NONE, ...over });
|
||||||
|
|
||||||
|
// Open dance floor, and a spot one tile north of the south border wall.
|
||||||
|
const OPEN = tileToWorld(38, 23);
|
||||||
|
const NEAR_SOUTH_WALL = tileToWorld(40, 43);
|
||||||
|
const SOUTH_WALL_Y = 44 * TILE;
|
||||||
|
|
||||||
|
function run(p: PlayerState, i: PlayerInput, steps: number, ms = 16): PlayerState {
|
||||||
|
let s = p;
|
||||||
|
for (let n = 0; n < steps; n++) s = stepPlayer(s, i, ms, VENUE);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const speedOf = (s: PlayerState): number => Math.hypot(s.vx, s.vy);
|
||||||
|
|
||||||
|
/** The collision test the mover itself uses. */
|
||||||
|
function standing(s: PlayerState): boolean {
|
||||||
|
return isWalkableWorld(VENUE, s.x + PLAYER_RADIUS, s.y)
|
||||||
|
&& isWalkableWorld(VENUE, s.x - PLAYER_RADIUS, s.y)
|
||||||
|
&& isWalkableWorld(VENUE, s.x, s.y + PLAYER_RADIUS)
|
||||||
|
&& isWalkableWorld(VENUE, s.x, s.y - PLAYER_RADIUS);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('freshPlayer', () => {
|
||||||
|
it('starts at rest where it was put', () => {
|
||||||
|
const p = freshPlayer(100, 200);
|
||||||
|
expect(p).toEqual({ x: 100, y: 200, vx: 0, vy: 0, facing: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a heavy tuning — accel far outstrips top speed', () => {
|
||||||
|
expect(PLAYER_ACCEL).toBeGreaterThan(PLAYER_MAX_SPEED);
|
||||||
|
expect(PLAYER_FRICTION).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('acceleration', () => {
|
||||||
|
it('accelerates from rest', () => {
|
||||||
|
const p = stepPlayer(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 16, VENUE);
|
||||||
|
expect(p.vx).toBeGreaterThan(0);
|
||||||
|
expect(p.x).toBeGreaterThan(OPEN.x);
|
||||||
|
expect(p.vy).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps to PLAYER_MAX_SPEED however long you hold it', () => {
|
||||||
|
const p = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 40);
|
||||||
|
expect(speedOf(p)).toBeCloseTo(PLAYER_MAX_SPEED, 6);
|
||||||
|
expect(speedOf(p)).toBeLessThanOrEqual(PLAYER_MAX_SPEED + 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps diagonals at the same top speed, not 1.41x', () => {
|
||||||
|
const p = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true, down: true }), 40);
|
||||||
|
expect(speedOf(p)).toBeLessThanOrEqual(PLAYER_MAX_SPEED + 1e-9);
|
||||||
|
expect(p.vx).toBeCloseTo(p.vy, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('friction', () => {
|
||||||
|
it('decelerates with no input, without reversing', () => {
|
||||||
|
const moving = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 40);
|
||||||
|
const a = run(moving, NONE, 5);
|
||||||
|
const b = run(a, NONE, 5);
|
||||||
|
expect(speedOf(a)).toBeLessThan(speedOf(moving));
|
||||||
|
expect(speedOf(b)).toBeLessThan(speedOf(a));
|
||||||
|
expect(b.vx).toBeGreaterThan(0);
|
||||||
|
expect(run(b, NONE, 400).vx).toBeCloseTo(0, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('facing', () => {
|
||||||
|
it('follows the direction of travel', () => {
|
||||||
|
const right = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 20);
|
||||||
|
expect(right.facing).toBeCloseTo(0, 4);
|
||||||
|
const up = run(freshPlayer(OPEN.x, OPEN.y), input({ up: true }), 20);
|
||||||
|
expect(up.facing).toBeCloseTo(-Math.PI / 2, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists when input stops and the body coasts to a halt', () => {
|
||||||
|
const up = run(freshPlayer(OPEN.x, OPEN.y), input({ up: true }), 20);
|
||||||
|
const stopped = run(up, NONE, 600);
|
||||||
|
expect(speedOf(stopped)).toBeLessThan(1);
|
||||||
|
expect(stopped.facing).toBeCloseTo(up.facing, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('collision', () => {
|
||||||
|
it('cannot walk through a wall', () => {
|
||||||
|
const p = run(freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y), input({ down: true }), 200);
|
||||||
|
expect(standing(p)).toBe(true);
|
||||||
|
expect(p.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y);
|
||||||
|
expect(p.vy).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('slides along a wall when moving diagonally into it', () => {
|
||||||
|
const start = freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y);
|
||||||
|
const p = run(start, input({ down: true, right: true }), 120);
|
||||||
|
expect(standing(p)).toBe(true);
|
||||||
|
expect(p.x).toBeGreaterThan(start.x + 50);
|
||||||
|
expect(p.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not tunnel on a monstrous delta', () => {
|
||||||
|
const one = stepPlayer(freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y), input({ down: true }), 60000, VENUE);
|
||||||
|
expect(standing(one)).toBe(true);
|
||||||
|
expect(one.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y);
|
||||||
|
|
||||||
|
// ...and repeated giant deltas are still just clamped steps.
|
||||||
|
const many = run(one, input({ down: true }), 30, 9999);
|
||||||
|
expect(standing(many)).toBe(true);
|
||||||
|
expect(many.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a full patrol of the open floor on walkable ground', () => {
|
||||||
|
let p = freshPlayer(OPEN.x, OPEN.y);
|
||||||
|
const legs: PlayerInput[] = [
|
||||||
|
input({ right: true }), input({ down: true }), input({ left: true }),
|
||||||
|
input({ up: true }), input({ up: true, left: true }), input({ down: true, right: true }),
|
||||||
|
];
|
||||||
|
for (const leg of legs) {
|
||||||
|
for (let n = 0; n < 300; n++) {
|
||||||
|
p = stepPlayer(p, leg, 16, VENUE);
|
||||||
|
expect(standing(p)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
228
tests/floor/venueMap.test.ts
Normal file
228
tests/floor/venueMap.test.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
MAP_H, MAP_W, TILE, VENUE,
|
||||||
|
isWalkable, isWalkableWorld, tileAt, tileColour, tileToWorld, worldToTile,
|
||||||
|
} from '../../src/scenes/floor/venueMap';
|
||||||
|
import type { AnchorKind, TileKind, WorldPoint } from '../../src/scenes/floor/venueMap';
|
||||||
|
|
||||||
|
const ANCHOR_KINDS: readonly AnchorKind[] = ['bar', 'dance', 'booth', 'toilet', 'smoke', 'entry', 'exit'];
|
||||||
|
const allAnchors = (): WorldPoint[] => ANCHOR_KINDS.flatMap((k) => [...VENUE.anchors[k]]);
|
||||||
|
|
||||||
|
const key = (tx: number, ty: number): number => ty * MAP_W + tx;
|
||||||
|
|
||||||
|
/** Every walkable tile 4-connected to the seed. */
|
||||||
|
function flood(seed: WorldPoint): Set<number> {
|
||||||
|
const start = worldToTile(seed.x, seed.y);
|
||||||
|
const seen = new Set<number>();
|
||||||
|
if (!isWalkable(VENUE, start.tx, start.ty)) return seen;
|
||||||
|
const stack = [start];
|
||||||
|
seen.add(key(start.tx, start.ty));
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const cur = stack.pop()!;
|
||||||
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
|
||||||
|
const tx = cur.tx + dx;
|
||||||
|
const ty = cur.ty + dy;
|
||||||
|
const k = key(tx, ty);
|
||||||
|
if (seen.has(k) || !isWalkable(VENUE, tx, ty)) continue;
|
||||||
|
seen.add(k);
|
||||||
|
stack.push({ tx, ty });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('venueMap dimensions', () => {
|
||||||
|
it('matches the shared geometry constants', () => {
|
||||||
|
expect(TILE).toBe(16);
|
||||||
|
expect(VENUE.width).toBe(MAP_W);
|
||||||
|
expect(VENUE.height).toBe(MAP_H);
|
||||||
|
expect(MAP_W * TILE).toBe(1280);
|
||||||
|
expect(MAP_H * TILE).toBe(720);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has one tile per cell, all of known kinds', () => {
|
||||||
|
expect(VENUE.tiles).toHaveLength(MAP_W * MAP_H);
|
||||||
|
const known = new Set<TileKind>([
|
||||||
|
'void', 'floor', 'wall', 'bar', 'stool', 'dance',
|
||||||
|
'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon',
|
||||||
|
]);
|
||||||
|
for (const t of VENUE.tiles) expect(known.has(t)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is ringed by non-walkable edges apart from doors', () => {
|
||||||
|
for (let tx = 0; tx < MAP_W; tx++) {
|
||||||
|
for (const ty of [0, MAP_H - 1]) {
|
||||||
|
const kind = tileAt(VENUE, tx, ty);
|
||||||
|
if (kind === 'smokeDoor') continue;
|
||||||
|
expect(isWalkable(VENUE, tx, ty)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains every authored tile kind', () => {
|
||||||
|
const seen = new Set(VENUE.tiles);
|
||||||
|
for (const k of ['floor', 'wall', 'bar', 'stool', 'dance', 'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon'] as const) {
|
||||||
|
expect(seen.has(k)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tileAt / walkability', () => {
|
||||||
|
it('reports void out of bounds', () => {
|
||||||
|
expect(tileAt(VENUE, -1, 0)).toBe('void');
|
||||||
|
expect(tileAt(VENUE, 0, -1)).toBe('void');
|
||||||
|
expect(tileAt(VENUE, MAP_W, 0)).toBe('void');
|
||||||
|
expect(tileAt(VENUE, 0, MAP_H)).toBe('void');
|
||||||
|
expect(isWalkable(VENUE, -5, -5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks floor/dance/exit/smokeDoor/stall and nothing else', () => {
|
||||||
|
const walkable = new Set<TileKind>(['floor', 'dance', 'exit', 'smokeDoor', 'stall']);
|
||||||
|
for (let ty = 0; ty < MAP_H; ty++) {
|
||||||
|
for (let tx = 0; tx < MAP_W; tx++) {
|
||||||
|
expect(isWalkable(VENUE, tx, ty)).toBe(walkable.has(tileAt(VENUE, tx, ty)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agrees between tile and world walkability queries', () => {
|
||||||
|
for (const a of allAnchors()) expect(isWalkableWorld(VENUE, a.x, a.y)).toBe(true);
|
||||||
|
expect(isWalkableWorld(VENUE, -8, -8)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('coordinate conversion', () => {
|
||||||
|
it('round-trips tile -> world -> tile', () => {
|
||||||
|
for (let ty = 0; ty < MAP_H; ty += 3) {
|
||||||
|
for (let tx = 0; tx < MAP_W; tx += 3) {
|
||||||
|
const w = tileToWorld(tx, ty);
|
||||||
|
expect(worldToTile(w.x, w.y)).toEqual({ tx, ty });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns tile centres, and floors world coords', () => {
|
||||||
|
expect(tileToWorld(0, 0)).toEqual({ x: 8, y: 8 });
|
||||||
|
expect(tileToWorld(3, 5)).toEqual({ x: 56, y: 88 });
|
||||||
|
expect(worldToTile(0, 0)).toEqual({ tx: 0, ty: 0 });
|
||||||
|
expect(worldToTile(15.9, 31.9)).toEqual({ tx: 0, ty: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('anchors', () => {
|
||||||
|
it('meets the minimum counts per kind', () => {
|
||||||
|
const min: Record<AnchorKind, number> = {
|
||||||
|
bar: 6, dance: 8, booth: 4, toilet: 4, smoke: 1, entry: 1, exit: 1,
|
||||||
|
};
|
||||||
|
for (const k of ANCHOR_KINDS) {
|
||||||
|
expect(VENUE.anchors[k].length).toBeGreaterThanOrEqual(min[k]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places every anchor on a walkable tile centre', () => {
|
||||||
|
for (const k of ANCHOR_KINDS) {
|
||||||
|
for (const a of VENUE.anchors[k]) {
|
||||||
|
expect(a.x % TILE).toBe(TILE / 2);
|
||||||
|
expect(a.y % TILE).toBe(TILE / 2);
|
||||||
|
expect(isWalkableWorld(VENUE, a.x, a.y)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CRITICAL: every anchor is 4-connected to every other anchor', () => {
|
||||||
|
const anchors = allAnchors();
|
||||||
|
const first = anchors[0]!;
|
||||||
|
const reachable = flood(first);
|
||||||
|
for (const a of anchors) {
|
||||||
|
const t = worldToTile(a.x, a.y);
|
||||||
|
expect(
|
||||||
|
reachable.has(key(t.tx, t.ty)),
|
||||||
|
`anchor at tile ${t.tx},${t.ty} is cut off from ${JSON.stringify(worldToTile(first.x, first.y))}`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves no orphaned open floor — every non-stall walkable tile is reachable', () => {
|
||||||
|
const reachable = flood(VENUE.anchors.entry[0]!);
|
||||||
|
const orphans: string[] = [];
|
||||||
|
for (let ty = 0; ty < MAP_H; ty++) {
|
||||||
|
for (let tx = 0; tx < MAP_W; tx++) {
|
||||||
|
if (!isWalkable(VENUE, tx, ty) || reachable.has(key(tx, ty))) continue;
|
||||||
|
// Cubicle interiors are sealed behind their doors by design.
|
||||||
|
if (tileAt(VENUE, tx, ty) === 'stall') continue;
|
||||||
|
orphans.push(`${tx},${ty}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(orphans, `unreachable open tiles: ${orphans.join(' ')}`).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchors of different kinds sit on distinct tiles within their kind', () => {
|
||||||
|
for (const k of ANCHOR_KINDS) {
|
||||||
|
const keys = VENUE.anchors[k].map((a) => {
|
||||||
|
const t = worldToTile(a.x, a.y);
|
||||||
|
return key(t.tx, t.ty);
|
||||||
|
});
|
||||||
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stalls', () => {
|
||||||
|
it('has four cubicles with unique ids', () => {
|
||||||
|
expect(VENUE.stalls).toHaveLength(4);
|
||||||
|
expect(new Set(VENUE.stalls.map((s) => s.id)).size).toBe(4);
|
||||||
|
for (const s of VENUE.stalls) expect(s.id).toMatch(/^s[1-4]$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('door tiles are walkable and sit directly south of a stallDoor tile', () => {
|
||||||
|
for (const s of VENUE.stalls) {
|
||||||
|
const d = worldToTile(s.door.x, s.door.y);
|
||||||
|
expect(isWalkable(VENUE, d.tx, d.ty)).toBe(true);
|
||||||
|
expect(tileAt(VENUE, d.tx, d.ty - 1)).toBe('stallDoor');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inside points land on stall tiles', () => {
|
||||||
|
for (const s of VENUE.stalls) {
|
||||||
|
// The 2x2 interior centre is a tile corner; probe just inside it.
|
||||||
|
expect(isWalkableWorld(VENUE, s.inside.x - 1, s.inside.y - 1)).toBe(true);
|
||||||
|
const t = worldToTile(s.inside.x - 1, s.inside.y - 1);
|
||||||
|
expect(tileAt(VENUE, t.tx, t.ty)).toBe('stall');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seals each cubicle behind its door — the only way in is StallDef.inside', () => {
|
||||||
|
for (const s of VENUE.stalls) {
|
||||||
|
const d = worldToTile(s.door.x, s.door.y);
|
||||||
|
const reachable = flood(s.door);
|
||||||
|
const interior = worldToTile(s.inside.x - 1, s.inside.y - 1);
|
||||||
|
expect(reachable.has(key(interior.tx, interior.ty))).toBe(false);
|
||||||
|
// ...and the door tile still connects back to the rest of the venue.
|
||||||
|
expect(reachable.has(key(worldToTile(VENUE.anchors.entry[0]!.x, VENUE.anchors.entry[0]!.y).tx,
|
||||||
|
worldToTile(VENUE.anchors.entry[0]!.x, VENUE.anchors.entry[0]!.y).ty))).toBe(true);
|
||||||
|
expect(isWalkable(VENUE, d.tx, d.ty)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes one toilet anchor per stall door', () => {
|
||||||
|
const doors = VENUE.stalls.map((s) => `${s.door.x},${s.door.y}`).sort();
|
||||||
|
const anchors = VENUE.anchors.toilet.map((a) => `${a.x},${a.y}`).sort();
|
||||||
|
expect(anchors).toEqual(doors);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tileColour', () => {
|
||||||
|
it('gives every kind a distinct 24-bit colour', () => {
|
||||||
|
const kinds: readonly TileKind[] = [
|
||||||
|
'void', 'floor', 'wall', 'bar', 'stool', 'dance',
|
||||||
|
'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon',
|
||||||
|
];
|
||||||
|
const colours = kinds.map(tileColour);
|
||||||
|
for (const c of colours) {
|
||||||
|
expect(Number.isInteger(c)).toBe(true);
|
||||||
|
expect(c).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(c).toBeLessThanOrEqual(0xffffff);
|
||||||
|
}
|
||||||
|
expect(new Set(colours).size).toBe(kinds.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user