Phase 2: night-flow integration — door/floor interleave, Kayden, TechnoEngine in the night, run progression; Phase 3 lane instructions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
595371f684
commit
0f7b948439
12
CLAUDE.md
12
CLAUDE.md
@ -29,7 +29,17 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
|
|||||||
branch. An unpushed session didn't happen.
|
branch. An unpushed session didn't happen.
|
||||||
- Remote is Gitea: `ssh://git@100.71.119.27:222/monster/not-tonight.git`.
|
- Remote is Gitea: `ssh://git@100.71.119.27:222/monster/not-tonight.git`.
|
||||||
|
|
||||||
## Dev notes (Phase 0 landed 2026-07-19)
|
## Dev notes (Phase 2 landed 2026-07-19)
|
||||||
|
- The game boots into the NIGHT (Thu→Fri→Sat run, saved). Controls: click the
|
||||||
|
rope/verdicts at the door; click the venue doorway to step inside; on the
|
||||||
|
floor WASD + E (E at the entry exits back to the door). While you're inside,
|
||||||
|
Kayden works the door — badly, on purpose.
|
||||||
|
- Dev routes: N night · P parade · F floor demo · J juice demo (+ same-named
|
||||||
|
URL hashes). `localStorage.removeItem('not-tonight-save')` for a clean run.
|
||||||
|
- Audio unlocks on first click (TechnoEngine + location filter). Before that a
|
||||||
|
StubBeatClock keeps `beat:tick` honest.
|
||||||
|
|
||||||
|
## Dev notes (Phase 0, still true)
|
||||||
- Gate: `npm run lint && npm run build && npm test` — all three must pass before
|
- Gate: `npm run lint && npm run build && npm test` — all three must pass before
|
||||||
any push. Dev server: `npm run dev` (port 5199, config in `.claude/launch.json`).
|
any push. Dev server: `npm run dev` (port 5199, config in `.claude/launch.json`).
|
||||||
- Key APIs (read the source, it's short): `core/EventBus` (`on` returns an
|
- Key APIs (read the source, it's short): `core/EventBus` (`on` returns an
|
||||||
|
|||||||
@ -539,3 +539,61 @@ floor strings moved to `data/strings/floor.ts`, vite port now respects `$PORT`
|
|||||||
rocks: night-flow state machine (door⇄floor interleave + radio pulls), TechnoEngine
|
rocks: night-flow state machine (door⇄floor interleave + radio pulls), TechnoEngine
|
||||||
replacing StubBeatClock in the night, ui/Stamp + MeterHud replacing door's local
|
replacing StubBeatClock in the night, ui/Stamp + MeterHud replacing door's local
|
||||||
versions, save/progression wiring, then the tuning pass with a human.
|
versions, save/progression wiring, then the tuning pass with a human.
|
||||||
|
|
||||||
|
---
|
||||||
|
### SESSION — PHASE-2 INTEGRATION (executed by Fable, reviewer) — 2026-07-19 20:40
|
||||||
|
**Branch/commits:** main (integration is reviewer-owned per the Phase-1 ruling)
|
||||||
|
**Done:**
|
||||||
|
- **The night is whole.** Door and Floor run CONCURRENTLY for the entire night;
|
||||||
|
the player toggles by clicking the venue doorway (it glows on radio calls) and
|
||||||
|
pressing E at the floor's entry. `NightScene` is the flow machine: it owns
|
||||||
|
clock, meters, audio, radio, Kayden's stamp accuracy, and the run save.
|
||||||
|
- **Kayden.** While you're inside, `DoorScene.kaydenRule()` works the REAL queue
|
||||||
|
through the REAL `judge()` — admits 4 in 5, never tests, never clicks the
|
||||||
|
clicker, forgets one UV stamp in seven. His mistakes cost actual vibe/heat and
|
||||||
|
his unstamped admits are the floor's noStamp supply. Leaving him alone all
|
||||||
|
night reliably ends in LICENCE PULLED (verified: 97 admits, 3 audit strikes).
|
||||||
|
- **Floor in night mode:** `FloorDemoScene` gained a second registration
|
||||||
|
('Floor') that adopts the NightContext — fed by real door admissions with a
|
||||||
|
6–14s walk-in, MeterHud (initialHeat per ruling), keeps simulating while
|
||||||
|
you're at the rope (maggots ripen → radio pulls), demo mode untouched.
|
||||||
|
- **Audio in the night:** TechnoEngine + Sfx created by NightScene; unlock on
|
||||||
|
first click; StubBeatClock hands over on `audio:unlocked`; the lowpass sweeps
|
||||||
|
on every door⇄floor toggle; stamp/clicker/rope/phone/radio SFX wired.
|
||||||
|
`audio:unlocked.atMs` now carries the anchored beat-0 origin (juice Q1 ruling).
|
||||||
|
- **Run progression:** Thu 07-16 → Fri 07-17 → Sat 07-18 at The Royal, night
|
||||||
|
seed = runSeed + index*101, heat carried run-scoped via save.ts, third strike
|
||||||
|
= immediate LICENCE PULLED cut (new endReason + FAIL_TEXT.licence), run-over
|
||||||
|
and week-done both clear the save into a fresh week.
|
||||||
|
- **Widget swaps:** door deny path now uses ui/Stamp (decal destroyed ~900ms
|
||||||
|
post-slam); StampFx.ts deleted.
|
||||||
|
**How to see it:** `npm run dev` → play. Doorway = step inside; E at entry =
|
||||||
|
step out. To watch Kayden: step inside and just... stand there. Check INSIDE
|
||||||
|
count and the incident log fill up. Preview-pane note in CLAUDE.md still applies
|
||||||
|
(drive frames in ONE javascript_exec call).
|
||||||
|
**Tests:** 413 passing / 0 failing (no new pure logic — flow wiring; the night
|
||||||
|
was verified by driving full nights in-browser, including a complete 6-hour
|
||||||
|
Kayden-only night, the licence-pulled cut, and the Thu→Fri chain with carried
|
||||||
|
heat).
|
||||||
|
**Bugs found while verifying (both fixed):**
|
||||||
|
1. `kaydenRule` emitted `door:verdict` on top of `applyOutcome`'s own emission —
|
||||||
|
every Kayden admission spawned TWO pending floor walk-ins. Caught by
|
||||||
|
inside-vs-crowd arithmetic, not by tests.
|
||||||
|
2. Pre-existing: a deferred hit's vibe delta could end the night, and
|
||||||
|
`endNight()` reassigns `this.deferred` while `fireDeferred` iterates it →
|
||||||
|
crash at scale. Collect-then-process now; surfaced only by simulating a full
|
||||||
|
unattended night.
|
||||||
|
**Broke / known-wonky:**
|
||||||
|
- Balance: a Kayden-only night ends licence-pulled (intended), but the
|
||||||
|
alternation rhythm (how long CAN you stay inside) is untuned — that's the
|
||||||
|
John-playtest tuning pass, together with the Phase-1 economy findings.
|
||||||
|
- Floor overlays (stall/pat-down) still silent — LANE-JUICE2/FLOOR2 item.
|
||||||
|
- DoorScene keeps its bespoke top-bar meters; MeterHud lives on the floor side
|
||||||
|
only. Cosmetic unification optional (LANE_JUICE2 §4).
|
||||||
|
- `radio:call` maggot trigger only fires while at the door (by design — you can
|
||||||
|
hear the floor, you can't see it).
|
||||||
|
**Contract changes:** none needed — Phase-1 folds covered everything.
|
||||||
|
**Next:** Phase 3 lanes are OPEN — LANE_CONTENT / LANE_FLOOR2 / LANE_JUICE2
|
||||||
|
(files in lanes/). All three parallel, worktrees, own branches, usual protocol.
|
||||||
|
If only one session: CONTENT first (it's the game's heart), FLOOR2, then JUICE2
|
||||||
|
— except run JUICE2 first if John is available for the ear pass.
|
||||||
|
|||||||
@ -68,15 +68,20 @@ scene code. Beat sync example: audio engine emits `beat:tick` on the bus; the to
|
|||||||
rhythm game consumes `beat:tick`; until integration, LANE-FLOOR uses the
|
rhythm game consumes `beat:tick`; until integration, LANE-FLOOR uses the
|
||||||
`StubBeatClock` from core (same event, fixed 128 BPM).
|
`StubBeatClock` from core (same event, fixed 128 BPM).
|
||||||
|
|
||||||
### Phase 2 — Integration (ONE lane, sequential)
|
### Phase 2 — Integration — ✅ DONE 2026-07-19 (executed by Fable)
|
||||||
Night flow state machine (Door ⇄ Floor interleave, radio pulls, Last Drinks,
|
Night flow machine: Door and Floor run concurrently all night, player toggles
|
||||||
Summary), real audio engine replaces stubs, save/progression, Kayden-at-door
|
via the doorway / floor exit; Kayden rules the real queue while you're inside
|
||||||
simulation while player is on Floor. Playtest pass: one full night start→summary.
|
(80% admit, 1-in-7 unstamped); TechnoEngine + location filter + SFX live in the
|
||||||
|
night; ui/Stamp + MeterHud swapped in; Thu→Fri→Sat run with saved run-scoped
|
||||||
|
heat; 3rd strike = immediate LICENCE PULLED, run over. See the Phase-2 SESSION
|
||||||
|
block in LANEHANDOVER.md.
|
||||||
|
|
||||||
### Phase 3 — Content & polish (parallelizable again)
|
### Phase 3 — Content & polish (parallel lanes OPEN)
|
||||||
Archetypes, scripted moral encounters, inspector, incident report memory, regulars,
|
`lanes/LANE_CONTENT.md` (guest list, moral encounters, inspector, incident
|
||||||
venues 2–4, real pixel art swapped into the paper-doll layer slots, MODELBEAST-
|
report), `lanes/LANE_FLOOR2.md` (fights, smoking area, aggro decay),
|
||||||
assisted art generation if useful.
|
`lanes/LANE_JUICE2.md` (ear pass, ambience, night arc). Later in this phase:
|
||||||
|
venues 2–4, real pixel art into the paper-doll slots (ASSETS.md), regulars'
|
||||||
|
cross-night memory, the economy tuning pass (John + reviewer, NOT a lane).
|
||||||
|
|
||||||
### Phase 4 — The role ladder (design §6; future, do not start)
|
### Phase 4 — The role ladder (design §6; future, do not start)
|
||||||
Glassie, bartender, DJ roles + roster board. Not scoped yet — but it imposes ONE
|
Glassie, bartender, DJ roles + roster board. Not scoped yet — but it imposes ONE
|
||||||
|
|||||||
74
lanes/LANE_CONTENT.md
Normal file
74
lanes/LANE_CONTENT.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# LANE-CONTENT — Door depth & the moral layer (Phase 3, parallel)
|
||||||
|
|
||||||
|
**Executor:** Opus 4.8. **Branch:** `lane/content` (from main after Phase 2).
|
||||||
|
**Owns:** `src/scenes/door/`, `src/rules/`, `src/data/strings/` (door-related
|
||||||
|
files), tests. **Prereq reading:** CLAUDE.md → `docs/GAME_DESIGN.md` §3.1/§4 →
|
||||||
|
the Phase-2 REVIEW block in `LANEHANDOVER.md`.
|
||||||
|
|
||||||
|
## Phase-2 reality (read before coding)
|
||||||
|
- The night is now a real flow: `NightScene` owns clock/meters/audio/save and a
|
||||||
|
`NightContext` (see its interface — `sfx`, `requestLocation`, `beatIntervalMs`
|
||||||
|
are new since Phase 1). Door and Floor run concurrently ALL night; the player
|
||||||
|
toggles via the doorway ("step inside") and the floor's entry exit.
|
||||||
|
- While the player is on the floor, **Kayden rules the real queue** in
|
||||||
|
`DoorScene.kaydenRule()` (admit 80%, same `judge()`, no UV stamp 1-in-7 —
|
||||||
|
stamp accuracy is decided in `NightScene.onVerdict`).
|
||||||
|
- Heat is run-scoped and saved; 3rd strike ends the night AND the run
|
||||||
|
immediately (`endReason: 'licence'`). Nights chain Thu→Fri→Sat via
|
||||||
|
`NightSummaryScene.nextAction()`; `save.ts` carries `GameState`.
|
||||||
|
- `ui/Stamp` + sfx are live in the deny path; `data/strings/door.ts` has
|
||||||
|
`FAIL_TEXT.licence`.
|
||||||
|
|
||||||
|
## Deliverables (in priority order)
|
||||||
|
|
||||||
|
### 1. The Guest List clipboard (design §3.1 — specced, never built)
|
||||||
|
A desk clipboard panel: tonight's list (generated, 8–14 names, some misspelled
|
||||||
|
versions of real patron names). Patrons with `claimsGuestList` get a "'m on the
|
||||||
|
list" line when they step up; player cross-checks. `knowsOwner` patrons who are
|
||||||
|
DENIED trigger the existing furious-Dazza path (judge already scores this —
|
||||||
|
surface it: their step-up line should drop hints ("Terry said swing by"), and
|
||||||
|
`data/strings/dazza.ts` has the `owner-scare` text to foreshadow).
|
||||||
|
Misspelling rule: a name that ALMOST matches is usually legit (bad handwriting);
|
||||||
|
an exact-match name on a confident patron is sometimes a lift. Tells, not
|
||||||
|
certainty. Pure logic in `rules/guestList.ts` + tests.
|
||||||
|
|
||||||
|
### 2. Scripted moral encounters (design §4.3 — the heart)
|
||||||
|
2–3 scripted patrons injected per night by the generator override hook
|
||||||
|
(`generatePatron(ctx, clockMin, archetypeOverride)` + a new scripted-encounter
|
||||||
|
scheduler in `rules/` or `data/`): the girl who needs back in to grab her mate
|
||||||
|
(no stamp, over capacity), the freshly-dumped regular who just wants one quiet
|
||||||
|
beer (already `loose`). Multi-beat dialogue via the existing toast/dialogue
|
||||||
|
patterns. Doing the human thing costs Vibe or risks Heat; the game never says
|
||||||
|
which choice was right. NO achievement chimes, no morality meter — the design
|
||||||
|
doc is explicit that this stays unscored.
|
||||||
|
|
||||||
|
### 3. The Inspector (archetype exists, does nothing yet)
|
||||||
|
Activate `inspector`: reads as a boring `punter` at the rope (their doll is
|
||||||
|
deliberately unremarkable). If admitted, they observe for ~30 clock-min:
|
||||||
|
any live breach in that window (capacity over licensed, a `maggot` on the floor,
|
||||||
|
underage inside) converts its deferred strike to IMMEDIATE. Then they leave and
|
||||||
|
Dazza texts "that was the INSPECTOR you muppet". If denied — nothing happens,
|
||||||
|
ever (they were just a quiet person; paranoia is the tax). Pure logic
|
||||||
|
`rules/inspector.ts` + tests; wiring via bus events only.
|
||||||
|
|
||||||
|
### 4. The Incident Report (design §3.3 — lying groundwork)
|
||||||
|
On the Night Summary: a short form (3–4 incidents sampled from
|
||||||
|
`NightState.incidents`) where the player picks a written account per incident —
|
||||||
|
truthful or flattering. Store both truth and account in
|
||||||
|
`GameState.pastReports`. Cross-night audit (contradictions → strikes) is
|
||||||
|
Phase 4; THIS lane only builds the form + storage + one taste: a lie about a
|
||||||
|
`kayden` incident can be contradicted by Kayden's own log line next morning
|
||||||
|
(Dazza text). Keep the form fast — 20 seconds, not homework.
|
||||||
|
|
||||||
|
### 5. Sobriety line-walk (stretch)
|
||||||
|
The third challenge: cursor-follow line at the desk. Only if 1–4 land clean.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- All scoring through `judge()`/`meters:delta`/`heat:strike` — no new direct
|
||||||
|
meter paths. New events need a CONTRACT CHANGE REQUEST (declaration-merge
|
||||||
|
bridges are BANNED now — request and wait for the fold).
|
||||||
|
- Kayden must interact sanely with everything you add (e.g. he auto-admits
|
||||||
|
guest-list liars — that's fine and funny; make sure it can't crash).
|
||||||
|
- Every pure module tested; play a full 3-night run before your handover and
|
||||||
|
report what the run FELT like — the economy findings from Phase 1 are still
|
||||||
|
open and your content changes the mix.
|
||||||
62
lanes/LANE_FLOOR2.md
Normal file
62
lanes/LANE_FLOOR2.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# LANE-FLOOR2 — Floor completion & aggro physiology (Phase 3, parallel)
|
||||||
|
|
||||||
|
**Executor:** Opus 4.8. **Branch:** `lane/floor2` (from main after Phase 2).
|
||||||
|
**Owns:** `src/scenes/floor/`, `src/data/strings/floor.ts`, tests. **Prereq
|
||||||
|
reading:** CLAUDE.md → `docs/GAME_DESIGN.md` §3.2 → Phase-2 REVIEW block in
|
||||||
|
`LANEHANDOVER.md`.
|
||||||
|
|
||||||
|
## Phase-2 reality (read before coding)
|
||||||
|
- `FloorDemoScene` now runs in TWO modes: standalone `'FloorDemo'` (key F,
|
||||||
|
unchanged) and night mode `'Floor'` (launched by NightScene with a
|
||||||
|
`NightContext`; see `bindNight()`). Night mode: shared bus/clock/state, fed by
|
||||||
|
real door admissions (`door:verdict` → delayed `crowd.spawn`), `MeterHud` for
|
||||||
|
HUD, exit via E at the entry anchor, sim keeps running while the player is at
|
||||||
|
the door (`present === false` path in `update()`).
|
||||||
|
- Incidents flow through `incident:log` (single writer in core/meters).
|
||||||
|
- `NightContext.sfx` exists (SFX handle, always crisp past the location filter).
|
||||||
|
- Kayden's unstamped admits (1 in 7) are your `noStamp` supply in night mode.
|
||||||
|
|
||||||
|
## Deliverables (in priority order)
|
||||||
|
|
||||||
|
### 1. Fight brewing (design §3.2 — the last missing v0.2 infraction)
|
||||||
|
Two agents in escalating shove animation (crowd sim picks pairs with rising
|
||||||
|
aggro contribution when they collide repeatedly). A swing timer (~8s from first
|
||||||
|
shove): player must physically walk BETWEEN them before it ends. Interposed =
|
||||||
|
defused (+vibe, both marked handled); missed = fight (big −vibe, +aggro, both
|
||||||
|
auto-ejected, `incident:log` kind `fight`, and if the inspector is inside —
|
||||||
|
LANE-CONTENT's problem — it's a heat risk). Pure pair-selection + timer logic
|
||||||
|
in `floor/fights.ts`, tested. While the player is at the door, fights still
|
||||||
|
happen (that's radio-pull pressure — emit a `radio:call` urgency 3 via the
|
||||||
|
night's existing pattern: log the incident, NightScene already radios on
|
||||||
|
`maggotUnhandled`; add `fight` to that trigger via CONTRACT CHANGE REQUEST or
|
||||||
|
ask the reviewer to widen the trigger — one-line, will be approved).
|
||||||
|
|
||||||
|
### 2. Smoking area & pass-outs (closes the UV loop from the door side)
|
||||||
|
The smoking-area door becomes live: patrons drift out for a smoke (activity
|
||||||
|
'smoking' exists) and BACK IN through the entry. Re-entering smokers keep their
|
||||||
|
stamp — but a small % of NEW patrons try to slip in through the smoking door
|
||||||
|
(no stamp, never saw the front door). That's the noStamp mechanic getting a
|
||||||
|
source that isn't only Kayden. Numbers small (2–3 a night), tells readable
|
||||||
|
(they enter from the wrong door if you're watching).
|
||||||
|
|
||||||
|
### 3. Aggro physiology (FLOOR-2 CCR, deferred at Phase-1 review — now approved)
|
||||||
|
Implement slow aggro decay on a calm floor: when no unhandled infraction exists
|
||||||
|
and no fight is brewing, drift aggro down ~0.3/clock-min (numbers yours to
|
||||||
|
tune, state the final value in the handover). Implementation note: this is a
|
||||||
|
sim-level `meters:delta` emit from the floor's update, gated on `present`
|
||||||
|
being EITHER value (a calm floor calms the night whether you're watching it or
|
||||||
|
not). Add tests for the gating logic.
|
||||||
|
|
||||||
|
### 4. Night-mode polish
|
||||||
|
- Stall bangs and pat-down bin drops should hit `NightContext.sfx`
|
||||||
|
(`doorBang`, plus pick something for the bin thunk) — overlays take an
|
||||||
|
optional sfx handle via their constructors.
|
||||||
|
- Escorted patrons should exit via the ENTRY in night mode (they're being
|
||||||
|
walked out the front, past the queue — future door-side schadenfreude hook).
|
||||||
|
- Perf check at 44 agents + fights + smokers in night mode; report frame times.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- Demo mode must stay working and identical-feeling (it's the tuning harness).
|
||||||
|
- No imports from `scenes/door/`; the night talks to you via bus + context.
|
||||||
|
- Pure logic tested; verify each mechanic by playing BOTH modes; handover per
|
||||||
|
protocol.
|
||||||
59
lanes/LANE_JUICE2.md
Normal file
59
lanes/LANE_JUICE2.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# LANE-JUICE2 — The night has a sound (Phase 3, parallel)
|
||||||
|
|
||||||
|
**Executor:** Opus 4.8. **Branch:** `lane/juice2` (from main after Phase 2).
|
||||||
|
**Owns:** `src/audio/`, `src/ui/`, tests. **Prereq reading:** CLAUDE.md →
|
||||||
|
LANE_JUICE.md (your Phase-1 spec) → Phase-2 REVIEW block in `LANEHANDOVER.md`.
|
||||||
|
|
||||||
|
## Phase-2 reality (read before coding)
|
||||||
|
- `TechnoEngine` + `Sfx` now live inside the actual night (`NightScene` creates
|
||||||
|
them; unlock on first pointerdown; `StubBeatClock` hands over on
|
||||||
|
`audio:unlocked`). The location filter sweeps on every doorway/exit toggle.
|
||||||
|
- `audio:unlocked.atMs` now carries the ANCHORED beat-0 origin (your open
|
||||||
|
question, ruled and implemented).
|
||||||
|
- `MeterHud` has `initialHeat` (ruled: read run-scoped strikes at construction —
|
||||||
|
wired in the floor's night mode).
|
||||||
|
- `ui/Stamp` is the door's stamp, with `stampSlam`/`stampInk` wired. The decal
|
||||||
|
is destroyed ~900ms post-slam by DoorScene.
|
||||||
|
- Your `ui/juiceEvents.ts` was deleted at review — its events live in
|
||||||
|
`data/types.ts` now. Extensions go via CONTRACT CHANGE REQUEST only.
|
||||||
|
|
||||||
|
## Deliverables (in priority order)
|
||||||
|
|
||||||
|
### 1. Ear pass with John (the debt from Phase 1: "verified by ear: NOTHING")
|
||||||
|
Sit a human in front of the JuiceDemo AND a real night. Tune: does the kick
|
||||||
|
read as a kick, is the bassline musical, `typeTick` level (it fires per
|
||||||
|
character), stamp slam vs music balance, the door→floor sweep (does it feel
|
||||||
|
like a door opening). Document every constant you change and why. This is THE
|
||||||
|
deliverable — everything below is secondary.
|
||||||
|
|
||||||
|
### 2. Ambience layer
|
||||||
|
- `rainLoop` at the door (start/stop on `audio:location` — rain is outside).
|
||||||
|
- A crowd-murmur bed INSIDE (filtered noise, level follows
|
||||||
|
`NightState.capacity.inside` — read via a bus listener on `meters:changed`?
|
||||||
|
No — capacity isn't in that payload; request what you need or derive from
|
||||||
|
`door:clicker`/`door:verdict` counting. State your approach in the handover).
|
||||||
|
- The kebab-shop fluoro hum if you're feeling artistic (tiny, dry, mono).
|
||||||
|
|
||||||
|
### 3. The night has an arc
|
||||||
|
Track intensity should rise across the night: thin kick+hat at 9 PM, bassline
|
||||||
|
enters ~10, pads and full mix by midnight, strip back at Last Drinks. Drive it
|
||||||
|
from `clock:tick` (you know the clock minute). Keep every parameter externally
|
||||||
|
settable — this surface IS the future DJ role's control panel (BUILD_PLAN
|
||||||
|
Phase 4 rule).
|
||||||
|
|
||||||
|
### 4. Widget debts
|
||||||
|
- `Phone` thread: scroll instead of truncating the newest over-tall message.
|
||||||
|
- `DialogueBox` drunk-typo mode: wire into the floor cut-off overlay (it
|
||||||
|
currently renders plain text — coordinate via handover, LANE-FLOOR2 owns the
|
||||||
|
call site; you own the widget).
|
||||||
|
- Optional: `MeterHud` variant sized for the door's top bar, so DoorScene can
|
||||||
|
drop its bespoke `MeterBar`s (currently duplicated look). Only if clean —
|
||||||
|
the door HUD works and this is cosmetic unification, not a rescue.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- Location filter architecture is non-negotiable: music through the lowpass,
|
||||||
|
SFX and ambience around it (rain is OUTSIDE — it belongs on the sfx bus but
|
||||||
|
only audible at the door; that's a routing decision, justify yours).
|
||||||
|
- No imports from door/floor scenes. Bus + constructor config only.
|
||||||
|
- Pure-testable math tested; everything else honestly listed as ear-checked or
|
||||||
|
not, per your own Phase-1 standard.
|
||||||
@ -178,7 +178,9 @@ export class TechnoEngine {
|
|||||||
this.grid.originMs = atMs + START_LEAD_MS;
|
this.grid.originMs = atMs + START_LEAD_MS;
|
||||||
this.nextBeat = 0;
|
this.nextBeat = 0;
|
||||||
this.unlockedFlag = true;
|
this.unlockedFlag = true;
|
||||||
this.bus.emit('audio:unlocked', { atMs });
|
// Review ruling: atMs carries the ANCHORED beat-0 origin, not the resume
|
||||||
|
// instant — consumers may treat beat 0 as landing exactly at atMs.
|
||||||
|
this.bus.emit('audio:unlocked', { atMs: this.grid.originMs });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -125,6 +125,10 @@ export const FAIL_TEXT = {
|
|||||||
title: 'THE QUEUE WENT UP',
|
title: 'THE QUEUE WENT UP',
|
||||||
dazza: 'someone glassed the door. THE DOOR. u had one job and the job was the door',
|
dazza: 'someone glassed the door. THE DOOR. u had one job and the job was the door',
|
||||||
},
|
},
|
||||||
|
licence: {
|
||||||
|
title: 'LICENCE PULLED',
|
||||||
|
dazza: 'the inspector just walked out with the licence in a SLEEVE. do not text me. do not call me. terry is going to hear about this',
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Dazza's night-end verdicts, worst to best. Index chosen by final vibe. */
|
/** Dazza's night-end verdicts, worst to best. Index chosen by final vibe. */
|
||||||
|
|||||||
13
src/main.ts
13
src/main.ts
@ -21,7 +21,18 @@ 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: [NightScene, DoorScene, NightSummaryScene, BootScene, ParadeScene, FloorDemoScene, JuiceDemoScene],
|
// FloorDemoScene registers twice: 'Floor' is the night-mode instance NightScene
|
||||||
|
// launches with a NightContext; 'FloorDemo' stays the standalone demo (key F).
|
||||||
|
scene: [
|
||||||
|
NightScene,
|
||||||
|
DoorScene,
|
||||||
|
NightSummaryScene,
|
||||||
|
new FloorDemoScene('Floor'),
|
||||||
|
BootScene,
|
||||||
|
ParadeScene,
|
||||||
|
new FloorDemoScene('FloorDemo'),
|
||||||
|
JuiceDemoScene,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// debug handle (harmless in prod; used by dev tooling)
|
// debug handle (harmless in prod; used by dev tooling)
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import { IdCardView } from './IdCardView';
|
|||||||
import { PhoneWidget } from './PhoneWidget';
|
import { PhoneWidget } from './PhoneWidget';
|
||||||
import { DressCodeCard } from './DressCodeCard';
|
import { DressCodeCard } from './DressCodeCard';
|
||||||
import { SobrietyModal } from './SobrietyModal';
|
import { SobrietyModal } from './SobrietyModal';
|
||||||
import { StampFx } from './StampFx';
|
import { stamp, type StampHandle } from '../../ui/Stamp';
|
||||||
import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui';
|
import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui';
|
||||||
import { shouldRecordStrike, type NightContext } from './NightScene';
|
import { shouldRecordStrike, type NightContext } from './NightScene';
|
||||||
|
|
||||||
@ -28,6 +28,8 @@ const W = 640;
|
|||||||
const H = 360;
|
const H = 360;
|
||||||
const DESK_Y = 250;
|
const DESK_Y = 250;
|
||||||
const UP_X = 300;
|
const UP_X = 300;
|
||||||
|
/** Real-ms between Kayden's rulings while the player is on the floor (~2.3 clock-min). */
|
||||||
|
const KAYDEN_RULE_MS = 5000;
|
||||||
const UP_FOOT_Y = 244;
|
const UP_FOOT_Y = 244;
|
||||||
const DOOR_X = 566;
|
const DOOR_X = 566;
|
||||||
const QUEUE_FOOT_Y = 228;
|
const QUEUE_FOOT_Y = 228;
|
||||||
@ -58,7 +60,6 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
private phone!: PhoneWidget;
|
private phone!: PhoneWidget;
|
||||||
private codeCard!: DressCodeCard;
|
private codeCard!: DressCodeCard;
|
||||||
private sobriety!: SobrietyModal;
|
private sobriety!: SobrietyModal;
|
||||||
private stamp!: StampFx;
|
|
||||||
|
|
||||||
private queueSprites: QueueSprite[] = [];
|
private queueSprites: QueueSprite[] = [];
|
||||||
private trayCard: Phaser.GameObjects.Container | null = null;
|
private trayCard: Phaser.GameObjects.Container | null = null;
|
||||||
@ -80,6 +81,10 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
private testedThisPatron = false;
|
private testedThisPatron = false;
|
||||||
private pattedThisPatron = false;
|
private pattedThisPatron = false;
|
||||||
private offs: Array<() => void> = [];
|
private offs: Array<() => void> = [];
|
||||||
|
/** Is the player at the rope? When false, Kayden works the door. Badly. */
|
||||||
|
private present = true;
|
||||||
|
private kaydenAccMs = 0;
|
||||||
|
private venueDoorGlow: Phaser.GameObjects.Rectangle | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('Door');
|
super('Door');
|
||||||
@ -94,7 +99,8 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
|
this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
|
||||||
this.idCard = new IdCardView(this, this.night.nightDate);
|
this.idCard = new IdCardView(this, this.night.nightDate);
|
||||||
this.sobriety = new SobrietyModal(this);
|
this.sobriety = new SobrietyModal(this);
|
||||||
this.stamp = new StampFx(this);
|
this.present = true;
|
||||||
|
this.kaydenAccMs = 0;
|
||||||
|
|
||||||
this.buildStreet();
|
this.buildStreet();
|
||||||
this.buildDesk();
|
this.buildDesk();
|
||||||
@ -108,6 +114,7 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
this.hypeText.setText(`HYPE x${hype.toFixed(1)}`);
|
this.hypeText.setText(`HYPE x${hype.toFixed(1)}`);
|
||||||
}),
|
}),
|
||||||
this.night.bus.on('dazza:text', ({ rule }) => {
|
this.night.bus.on('dazza:text', ({ rule }) => {
|
||||||
|
this.night.sfx?.play('phoneBuzz');
|
||||||
// The bus payload is typed with the frozen DressCodeRule, which has no
|
// The bus payload is typed with the frozen DressCodeRule, which has no
|
||||||
// `short`. Narrowing back to the lane's own DoorDressCodeRule is a real
|
// `short`. Narrowing back to the lane's own DoorDressCodeRule is a real
|
||||||
// contract gap (CCR-4) — this predicate is the honest version of it
|
// contract gap (CCR-4) — this predicate is the honest version of it
|
||||||
@ -115,6 +122,20 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
if (isDoorRule(rule)) this.codeCard.add(this, rule, this.elapsedMs);
|
if (isDoorRule(rule)) this.codeCard.add(this, rule, this.elapsedMs);
|
||||||
}),
|
}),
|
||||||
this.night.bus.on('heat:strike', () => this.refreshCounters()),
|
this.night.bus.on('heat:strike', () => this.refreshCounters()),
|
||||||
|
this.night.bus.on('night:phaseChange', ({ location }) => {
|
||||||
|
this.present = location === 'door';
|
||||||
|
if (this.present) this.refreshCounters();
|
||||||
|
else this.kaydenAccMs = 0;
|
||||||
|
}),
|
||||||
|
this.night.bus.on('radio:call', ({ line, urgency }) => {
|
||||||
|
if (!this.present) return;
|
||||||
|
this.showToast(`RADIO — ${line}`);
|
||||||
|
// The venue door glows when the floor wants you: the way in IS the button.
|
||||||
|
if (this.venueDoorGlow) {
|
||||||
|
this.venueDoorGlow.setAlpha(urgency >= 3 ? 0.5 : 0.3);
|
||||||
|
this.tweens.add({ targets: this.venueDoorGlow, alpha: 0.08, duration: 6000 });
|
||||||
|
}
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
this.events.once('shutdown', () => this.teardown());
|
this.events.once('shutdown', () => this.teardown());
|
||||||
@ -144,6 +165,21 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
this.add.rectangle(DOOR_X, 140, 34, 74, 0x05050a);
|
this.add.rectangle(DOOR_X, 140, 34, 74, 0x05050a);
|
||||||
this.add.rectangle(DOOR_X, 214, 66, 8, DOOR_PALETTE.neon, 0.16);
|
this.add.rectangle(DOOR_X, 214, 66, 8, DOOR_PALETTE.neon, 0.16);
|
||||||
|
|
||||||
|
// The doorway is how you get to the floor — click it to step inside.
|
||||||
|
// It glows when the radio wants you in there.
|
||||||
|
this.venueDoorGlow = this.add.rectangle(DOOR_X, 140, 38, 78, DOOR_PALETTE.neon, 0.08);
|
||||||
|
const doorway = this.add
|
||||||
|
.rectangle(DOOR_X, 140, 40, 80, 0xffffff, 0)
|
||||||
|
.setInteractive({ useHandCursor: true });
|
||||||
|
doorway.on('pointerover', () => this.venueDoorGlow?.setAlpha(0.28));
|
||||||
|
doorway.on('pointerout', () => this.venueDoorGlow?.setAlpha(0.08));
|
||||||
|
doorway.on('pointerdown', () => {
|
||||||
|
if (!this.busy) this.night.requestLocation('floor');
|
||||||
|
});
|
||||||
|
this.add
|
||||||
|
.text(DOOR_X, 184, 'step inside', { fontFamily: MONO, fontSize: '7px', color: '#6a4a5a' })
|
||||||
|
.setOrigin(0.5, 0);
|
||||||
|
|
||||||
this.buildRain();
|
this.buildRain();
|
||||||
this.buildRope();
|
this.buildRope();
|
||||||
}
|
}
|
||||||
@ -302,6 +338,7 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
this.testedThisPatron = false;
|
this.testedThisPatron = false;
|
||||||
this.pattedThisPatron = false;
|
this.pattedThisPatron = false;
|
||||||
|
this.night.sfx?.play('ropeUnhook');
|
||||||
this.up.show(p);
|
this.up.show(p);
|
||||||
this.showTray(p);
|
this.showTray(p);
|
||||||
this.setVerdictButtons(true);
|
this.setVerdictButtons(true);
|
||||||
@ -325,6 +362,7 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private click(direction: 'in' | 'out'): void {
|
private click(direction: 'in' | 'out'): void {
|
||||||
|
this.night.sfx?.play('clickerClunk');
|
||||||
this.night.bus.emit('door:clicker', { direction });
|
this.night.bus.emit('door:clicker', { direction });
|
||||||
this.refreshCounters();
|
this.refreshCounters();
|
||||||
this.tweens.add({ targets: this.clickerText, scale: { from: 1.35, to: 1 }, duration: 120 });
|
this.tweens.add({ targets: this.clickerText, scale: { from: 1.35, to: 1 }, duration: 120 });
|
||||||
@ -416,15 +454,27 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
this.time.delayedCall(2600, release);
|
this.time.delayedCall(2600, release);
|
||||||
|
|
||||||
if (verdict === 'deny') {
|
if (verdict === 'deny') {
|
||||||
this.stamp.play({
|
// LANE-JUICE's stamp (screen shake, ink ring, sfx). The decal is ours to
|
||||||
|
// destroy — the denied patron takes their mark with them into the night.
|
||||||
|
let handle: StampHandle | null = null;
|
||||||
|
const sfx = this.night.sfx;
|
||||||
|
handle = stamp(this, {
|
||||||
x: UP_X,
|
x: UP_X,
|
||||||
y: UP_FOOT_Y - 70,
|
y: UP_FOOT_Y - 70,
|
||||||
label: 'NOT TONIGHT',
|
text: 'NOT TONIGHT',
|
||||||
onImpact: () => {
|
depth: 320,
|
||||||
|
seed: p.dollSeed,
|
||||||
|
...(sfx
|
||||||
|
? { sfx: { stampSlam: () => sfx.play('stampSlam'), stampInk: () => sfx.play('stampInk') } }
|
||||||
|
: {}),
|
||||||
|
onSlam: () => {
|
||||||
this.up.walkOffDenied();
|
this.up.walkOffDenied();
|
||||||
this.showToast(this.pick(DENY_REACTIONS, p));
|
this.showToast(this.pick(DENY_REACTIONS, p));
|
||||||
},
|
},
|
||||||
onDone: release,
|
onDone: () => {
|
||||||
|
release();
|
||||||
|
this.time.delayedCall(900, () => handle?.decal.destroy());
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -492,6 +542,31 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
this.buttons.pat?.setEnabled(on);
|
this.buttons.pat?.setEnabled(on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Kayden decision. Synchronous — no animation, no busy flag, and the
|
||||||
|
* patron never appears in the up-view, so returning to the door always finds
|
||||||
|
* a clean rope. Kayden never tests, never pats down, and admits four in five.
|
||||||
|
*/
|
||||||
|
private kaydenRule(): void {
|
||||||
|
if (this.queue.patronUp) return; // player left mid-ruling; don't double-serve
|
||||||
|
const p = this.queue.callNext();
|
||||||
|
if (!p) return;
|
||||||
|
const kayden = this.night.rng.stream('kayden');
|
||||||
|
const verdict: Extract<Verdict, 'admit' | 'deny'> = kayden.chance(0.8) ? 'admit' : 'deny';
|
||||||
|
const outcome = judge(p, verdict, this.judgeCtx());
|
||||||
|
if (verdict === 'admit') this.night.state.capacity.inside++;
|
||||||
|
// applyOutcome emits door:verdict (and the verdict incident) — same path as
|
||||||
|
// a player ruling, so the floor is fed exactly once per admission.
|
||||||
|
this.applyOutcome(p, verdict, outcome);
|
||||||
|
this.queue.resolveUp(verdict === 'deny');
|
||||||
|
this.night.bus.emit('incident:log', {
|
||||||
|
clockMin: this.night.state.clockMin,
|
||||||
|
kind: 'kayden',
|
||||||
|
patronId: p.id,
|
||||||
|
detail: `Kayden ${verdict === 'admit' ? 'waved through' : 'knocked back'} ${p.archetype}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private refreshCounters(): void {
|
private refreshCounters(): void {
|
||||||
const cap = this.night.state.capacity;
|
const cap = this.night.state.capacity;
|
||||||
this.clickerText.setText(String(cap.clickerShown));
|
this.clickerText.setText(String(cap.clickerShown));
|
||||||
@ -511,6 +586,18 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
this.codeCard.update(this.elapsedMs);
|
this.codeCard.update(this.elapsedMs);
|
||||||
this.night.reportQueue(this.queue.queueLength);
|
this.night.reportQueue(this.queue.queueLength);
|
||||||
|
|
||||||
|
// Player is on the floor: Trainee Kayden works the rope. He is quick,
|
||||||
|
// confident, and wrong a lot — his admits go through the same judge(), so
|
||||||
|
// his mistakes cost the same vibe/heat yours do. (His stamp accuracy is
|
||||||
|
// NightScene's problem; his verdicts are made here on the real queue.)
|
||||||
|
if (!this.present) {
|
||||||
|
this.kaydenAccMs += deltaMs;
|
||||||
|
while (this.kaydenAccMs >= KAYDEN_RULE_MS) {
|
||||||
|
this.kaydenAccMs -= KAYDEN_RULE_MS;
|
||||||
|
this.kaydenRule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.clockText.setText(this.night.clock.label);
|
this.clockText.setText(this.night.clock.label);
|
||||||
if (this.tutorial.alpha > 0 && this.elapsedMs > TUTORIAL_MS) {
|
if (this.tutorial.alpha > 0 && this.elapsedMs > TUTORIAL_MS) {
|
||||||
this.tweens.add({ targets: this.tutorial, alpha: 0, duration: 800 });
|
this.tweens.add({ targets: this.tutorial, alpha: 0, duration: 800 });
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { EventBus } from '../../core/EventBus';
|
import { EventBus } from '../../core/EventBus';
|
||||||
import { GameClock, DEFAULT_CLOCK } from '../../core/GameClock';
|
import { GameClock, DEFAULT_CLOCK, type ClockConfig } from '../../core/GameClock';
|
||||||
import { SeededRNG } from '../../core/SeededRNG';
|
import { SeededRNG } from '../../core/SeededRNG';
|
||||||
import { StubBeatClock } from '../../core/StubBeatClock';
|
import { StubBeatClock } from '../../core/StubBeatClock';
|
||||||
import { Meters, freshNightState } from '../../core/meters';
|
import { Meters, freshNightState } from '../../core/meters';
|
||||||
|
import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
|
||||||
import { _resetPatronSerial } from '../../patrons/generator';
|
import { _resetPatronSerial } from '../../patrons/generator';
|
||||||
import { ruleById } from '../../rules/dressCode';
|
import { ruleById } from '../../rules/dressCode';
|
||||||
|
import { TechnoEngine } from '../../audio/TechnoEngine';
|
||||||
|
import { Sfx } from '../../audio/Sfx';
|
||||||
import type { DeferredHit } from '../../rules/doorTypes';
|
import type { DeferredHit } from '../../rules/doorTypes';
|
||||||
import type { HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
|
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
|
||||||
import { nextDazza } from './dazzaSchedule';
|
import { nextDazza } from './dazzaSchedule';
|
||||||
|
|
||||||
// TODO(integration): Phase 2 replaces this shell with the real night-flow state
|
// Phase-2: the night-flow machine. Door and Floor both run for the whole night;
|
||||||
// machine (Door <-> Floor interleave, radio pulls, Last Drinks). Everything here
|
// this scene owns WHERE THE PLAYER IS (visibility + input), the clock, the
|
||||||
// is deliberately thin: it owns the night's lifetime, nothing about the door.
|
// meters, the audio engine (and the location filter that goes with it), Kayden's
|
||||||
//
|
// stamp accuracy, the radio, and the run-level save. It still renders nothing.
|
||||||
// It renders NOTHING. DoorScene is launched on top and draws everything, which
|
|
||||||
// keeps "when does the night end" and "what does the street look like" in
|
|
||||||
// separate files.
|
|
||||||
|
|
||||||
export const LICENSED_CAPACITY = 80;
|
export const LICENSED_CAPACITY = 80;
|
||||||
const VIBE_SAMPLE_EVERY_MIN = 5;
|
const VIBE_SAMPLE_EVERY_MIN = 5;
|
||||||
@ -24,18 +24,23 @@ const VIBE_SAMPLE_EVERY_MIN = 5;
|
|||||||
/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */
|
/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */
|
||||||
export const MAX_HEAT_STRIKES = 3;
|
export const MAX_HEAT_STRIKES = 3;
|
||||||
|
|
||||||
/**
|
/** Thu → Fri → Sat at The Royal. Index = nightIndex. */
|
||||||
* Whether a strike is worth recording. Two guards, both contract-driven:
|
export const NIGHT_DATES = ['2026-07-16', '2026-07-17', '2026-07-18'] as const;
|
||||||
* past three the run is already over, and a breach that persists (over capacity
|
export const TOTAL_NIGHTS = NIGHT_DATES.length;
|
||||||
* for an hour) is ONE offence the inspector writes up, not one per patron —
|
|
||||||
* admitting everybody used to log forty of them.
|
/** Kayden stamps most hands. Most. */
|
||||||
*/
|
export const KAYDEN_STAMP_CHANCE = 0.86;
|
||||||
|
/** Scripted "get in here" pulls: the floor demands a lap around these minutes. */
|
||||||
|
const RADIO_PULL_MINS = [120, 250] as const;
|
||||||
|
/** Dynamic maggot-radio rate limit (clock minutes). */
|
||||||
|
const RADIO_MAGGOT_COOLDOWN = 15;
|
||||||
|
|
||||||
export function shouldRecordStrike(state: NightState, strike: HeatStrike): boolean {
|
export function shouldRecordStrike(state: NightState, strike: HeatStrike): boolean {
|
||||||
if (state.heatStrikes.length >= MAX_HEAT_STRIKES) return false;
|
if (state.heatStrikes.length >= MAX_HEAT_STRIKES) return false;
|
||||||
return !state.heatStrikes.some((s) => s.reason === strike.reason);
|
return !state.heatStrikes.some((s) => s.reason === strike.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NightEndReason = 'clock' | 'vibe' | 'aggro';
|
export type NightEndReason = 'clock' | 'vibe' | 'aggro' | 'licence';
|
||||||
|
|
||||||
export interface VerdictRecord {
|
export interface VerdictRecord {
|
||||||
patron: Patron;
|
patron: Patron;
|
||||||
@ -57,7 +62,17 @@ export interface NightLog {
|
|||||||
seed: number;
|
seed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything DoorScene needs from the night. Passed by reference, not copied. */
|
/** Run-level result attached to the summary. */
|
||||||
|
export interface RunInfo {
|
||||||
|
nightIndex: number;
|
||||||
|
totalNights: number;
|
||||||
|
/** licence pulled — the run is dead, only a fresh one remains */
|
||||||
|
runOver: boolean;
|
||||||
|
/** survived all three nights */
|
||||||
|
weekDone: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the door and floor need from the night. Passed by reference. */
|
||||||
export interface NightContext {
|
export interface NightContext {
|
||||||
bus: EventBus;
|
bus: EventBus;
|
||||||
rng: SeededRNG;
|
rng: SeededRNG;
|
||||||
@ -65,51 +80,87 @@ export interface NightContext {
|
|||||||
state: NightState;
|
state: NightState;
|
||||||
seed: number;
|
seed: number;
|
||||||
nightDate: Date;
|
nightDate: Date;
|
||||||
|
/** UI sfx — routed around the location filter, always crisp. */
|
||||||
|
sfx: Sfx | null;
|
||||||
|
beatIntervalMs: number;
|
||||||
scheduleDeferred(hits: readonly DeferredHit[]): void;
|
scheduleDeferred(hits: readonly DeferredHit[]): void;
|
||||||
/** DoorScene owns the queue; Dazza's queue-gated whinges need to see it. */
|
|
||||||
reportQueue(length: number): void;
|
reportQueue(length: number): void;
|
||||||
|
/** Door/Floor ask to move the player; the night decides and flips the scenes. */
|
||||||
|
requestLocation(loc: 'door' | 'floor'): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NightScene extends Phaser.Scene {
|
export class NightScene extends Phaser.Scene {
|
||||||
private bus!: EventBus;
|
private bus!: EventBus;
|
||||||
private rng!: SeededRNG;
|
private rng!: SeededRNG;
|
||||||
private clock!: GameClock;
|
private clock!: GameClock;
|
||||||
private beat!: StubBeatClock;
|
private stubBeat: StubBeatClock | null = null;
|
||||||
|
private engine: TechnoEngine | null = null;
|
||||||
|
private sfx: Sfx | null = null;
|
||||||
private meters!: Meters;
|
private meters!: Meters;
|
||||||
private state!: NightState;
|
private state!: NightState;
|
||||||
|
private run!: GameState;
|
||||||
private seed = 4207;
|
private seed = 4207;
|
||||||
|
private nightIndex = 0;
|
||||||
private ended = false;
|
private ended = false;
|
||||||
private deferred: DeferredHit[] = [];
|
private deferred: DeferredHit[] = [];
|
||||||
private readonly firedDazza = new Set<string>();
|
private readonly firedDazza = new Set<string>();
|
||||||
private lastDazzaMin = -99;
|
private lastDazzaMin = -99;
|
||||||
private lastVibeSampleMin = -99;
|
private lastVibeSampleMin = -99;
|
||||||
|
private lastMaggotRadioMin = -99;
|
||||||
|
private readonly firedPulls = new Set<number>();
|
||||||
private queueLength = 0;
|
private queueLength = 0;
|
||||||
private log!: NightLog;
|
private log!: NightLog;
|
||||||
private offs: Array<() => void> = [];
|
private offs: Array<() => void> = [];
|
||||||
|
private ctx!: NightContext;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('Night');
|
super('Night');
|
||||||
}
|
}
|
||||||
|
|
||||||
init(data: { seed?: number }): void {
|
init(data: { seed?: number; nightIndex?: number; freshRun?: boolean }): void {
|
||||||
if (typeof data.seed === 'number') this.seed = data.seed;
|
if (data.freshRun) clearSave();
|
||||||
|
this.run = loadGame(typeof data.seed === 'number' ? data.seed : 4207);
|
||||||
|
if (typeof data.seed === 'number' && this.run.seed !== data.seed) {
|
||||||
|
this.run = freshGameState(data.seed);
|
||||||
|
}
|
||||||
|
this.nightIndex = data.nightIndex ?? this.run.nightIndex;
|
||||||
|
if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0;
|
||||||
|
// Each night is its own deterministic world derived from the run seed.
|
||||||
|
this.seed = this.run.seed + this.nightIndex * 101;
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
this.ended = false;
|
this.ended = false;
|
||||||
this.deferred = [];
|
this.deferred = [];
|
||||||
this.firedDazza.clear();
|
this.firedDazza.clear();
|
||||||
|
this.firedPulls.clear();
|
||||||
this.lastDazzaMin = -99;
|
this.lastDazzaMin = -99;
|
||||||
this.lastVibeSampleMin = -99;
|
this.lastVibeSampleMin = -99;
|
||||||
|
this.lastMaggotRadioMin = -99;
|
||||||
_resetPatronSerial();
|
_resetPatronSerial();
|
||||||
|
|
||||||
this.bus = new EventBus();
|
this.bus = new EventBus();
|
||||||
this.rng = new SeededRNG(this.seed);
|
this.rng = new SeededRNG(this.seed);
|
||||||
this.clock = new GameClock(this.bus, DEFAULT_CLOCK);
|
const clockCfg: ClockConfig = { ...DEFAULT_CLOCK, nightDate: NIGHT_DATES[this.nightIndex]! };
|
||||||
this.beat = new StubBeatClock(this.bus);
|
this.clock = new GameClock(this.bus, clockCfg);
|
||||||
this.state = freshNightState('voltage', 0, LICENSED_CAPACITY);
|
this.state = freshNightState('theRoyal', this.nightIndex, LICENSED_CAPACITY);
|
||||||
|
// Heat is run-scoped: strikes from earlier nights are already on the licence.
|
||||||
|
this.state.heatStrikes.push(...this.run.heatStrikes);
|
||||||
this.meters = new Meters(this.bus, this.state);
|
this.meters = new Meters(this.bus, this.state);
|
||||||
|
|
||||||
|
// Audio: the engine is the beat authority once the browser lets it speak.
|
||||||
|
// Until the first gesture, a stub keeps beat:tick honest (then hands over).
|
||||||
|
this.engine = new TechnoEngine(this.bus);
|
||||||
|
this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus);
|
||||||
|
this.stubBeat = new StubBeatClock(this.bus);
|
||||||
|
this.stubBeat.start();
|
||||||
|
this.engine.start();
|
||||||
|
const unlock = (): void => {
|
||||||
|
void this.engine?.unlock();
|
||||||
|
};
|
||||||
|
this.input.on('pointerdown', unlock);
|
||||||
|
this.offs.push(() => this.input.off('pointerdown', unlock));
|
||||||
|
|
||||||
this.log = {
|
this.log = {
|
||||||
vibeHistory: [this.state.vibe],
|
vibeHistory: [this.state.vibe],
|
||||||
verdicts: [],
|
verdicts: [],
|
||||||
@ -123,45 +174,94 @@ export class NightScene extends Phaser.Scene {
|
|||||||
seed: this.seed,
|
seed: this.seed,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.offs = [
|
this.offs.push(
|
||||||
this.bus.on('clock:tick', ({ clockMin }) => this.onMinute(clockMin)),
|
this.bus.on('clock:tick', ({ clockMin }) => this.onMinute(clockMin)),
|
||||||
this.bus.on('meters:changed', ({ vibe, aggro, hype }) => this.onMeters(vibe, aggro, hype)),
|
this.bus.on('meters:changed', ({ vibe, aggro, hype }) => this.onMeters(vibe, aggro, hype)),
|
||||||
this.bus.on('heat:strike', (s) => this.log.heatStrikes.push(s)),
|
this.bus.on('heat:strike', (s) => this.onStrike(s)),
|
||||||
this.bus.on('door:verdict', (v) => this.onVerdict(v)),
|
this.bus.on('door:verdict', (v) => this.onVerdict(v)),
|
||||||
];
|
this.bus.on('floor:ejection', () => {
|
||||||
|
this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1);
|
||||||
|
}),
|
||||||
|
this.bus.on('audio:unlocked', () => {
|
||||||
|
this.stubBeat?.stop();
|
||||||
|
this.stubBeat = null;
|
||||||
|
}),
|
||||||
|
this.bus.on('radio:call', () => this.sfx?.play('radioChirp')),
|
||||||
|
this.bus.on('incident:log', (inc) => {
|
||||||
|
if (inc.kind === 'maggotUnhandled' && this.state.location === 'door') this.maggotRadio();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const ctx: NightContext = {
|
this.ctx = {
|
||||||
bus: this.bus,
|
bus: this.bus,
|
||||||
rng: this.rng,
|
rng: this.rng,
|
||||||
clock: this.clock,
|
clock: this.clock,
|
||||||
state: this.state,
|
state: this.state,
|
||||||
seed: this.seed,
|
seed: this.seed,
|
||||||
nightDate: this.clock.nightDate,
|
nightDate: this.clock.nightDate,
|
||||||
|
sfx: this.sfx,
|
||||||
|
beatIntervalMs: 60_000 / 128,
|
||||||
scheduleDeferred: (hits) => this.deferred.push(...hits),
|
scheduleDeferred: (hits) => this.deferred.push(...hits),
|
||||||
reportQueue: (length) => {
|
reportQueue: (length) => {
|
||||||
this.queueLength = length;
|
this.queueLength = length;
|
||||||
},
|
},
|
||||||
|
requestLocation: (loc) => this.setLocation(loc),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.events.once('shutdown', () => this.teardown());
|
this.events.once('shutdown', () => this.teardown());
|
||||||
this.clock.start();
|
this.clock.start();
|
||||||
this.beat.start();
|
this.scene.launch('Door', { night: this.ctx });
|
||||||
this.scene.launch('Door', { night: ctx });
|
this.scene.launch('Floor', { night: this.ctx });
|
||||||
|
// Door and Floor BOTH run all night; the flags below decide which one the
|
||||||
|
// player is inhabiting. Floor starts hidden.
|
||||||
|
this.time.delayedCall(0, () => this.applyLocation('door'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- location ----------------------------------------------------------
|
||||||
|
|
||||||
|
private setLocation(loc: 'door' | 'floor'): void {
|
||||||
|
if (this.ended || this.state.location === loc) return;
|
||||||
|
this.applyLocation(loc);
|
||||||
|
this.bus.emit('night:phaseChange', { location: loc });
|
||||||
|
this.bus.emit('audio:location', { location: loc });
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyLocation(loc: 'door' | 'floor'): void {
|
||||||
|
const doorHere = loc === 'door';
|
||||||
|
this.scene.setVisible(doorHere, 'Door');
|
||||||
|
this.scene.setVisible(!doorHere, 'Floor');
|
||||||
|
const door = this.scene.get('Door');
|
||||||
|
const floor = this.scene.get('Floor');
|
||||||
|
if (door) door.input.enabled = doorHere;
|
||||||
|
if (floor) floor.input.enabled = !doorHere;
|
||||||
|
this.state.location = loc; // meters also tracks via night:phaseChange; direct set covers the first frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- per-event ---------------------------------------------------------
|
||||||
|
|
||||||
private onVerdict({ patron, verdict, outcome }: { patron: Patron; verdict: Verdict; outcome: VerdictOutcome }): void {
|
private onVerdict({ patron, verdict, outcome }: { patron: Patron; verdict: Verdict; outcome: VerdictOutcome }): void {
|
||||||
this.log.verdicts.push({ patron, verdict, outcome, clockMin: this.state.clockMin });
|
this.log.verdicts.push({ patron, verdict, outcome, clockMin: this.state.clockMin });
|
||||||
if (verdict === 'admit') this.log.admitted++;
|
if (verdict === 'admit') {
|
||||||
else if (verdict === 'deny') this.log.denied++;
|
this.log.admitted++;
|
||||||
|
// The UV stamp happens at the door. The player never forgets; Kayden
|
||||||
|
// forgets one hand in seven — those are the floor's noStamp infractions.
|
||||||
|
patron.flags.uvStamped =
|
||||||
|
this.state.location === 'door' ? true : this.rng.stream('kaydenStamp').chance(KAYDEN_STAMP_CHANCE);
|
||||||
|
} else if (verdict === 'deny') this.log.denied++;
|
||||||
else if (verdict === 'sobrietyTest') this.log.tested++;
|
else if (verdict === 'sobrietyTest') this.log.tested++;
|
||||||
else if (verdict === 'patDown') this.log.pattedDown++;
|
else if (verdict === 'patDown') this.log.pattedDown++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onStrike(s: HeatStrike): void {
|
||||||
|
this.log.heatStrikes.push(s);
|
||||||
|
// Third strike doesn't wait for 3 AM: the inspector pulls the licence on the
|
||||||
|
// spot and the run is over (review ruling, design §2).
|
||||||
|
if (this.state.heatStrikes.length >= MAX_HEAT_STRIKES) this.endNight('licence');
|
||||||
|
}
|
||||||
|
|
||||||
private onMeters(vibe: number, aggro: number, hype: number): void {
|
private onMeters(vibe: number, aggro: number, hype: number): void {
|
||||||
if (hype > this.log.hypePeak) this.log.hypePeak = hype;
|
if (hype > this.log.hypePeak) this.log.hypePeak = hype;
|
||||||
if (this.ended) return;
|
if (this.ended) return;
|
||||||
// Fail states are checked here rather than per-frame so they can only fire
|
|
||||||
// as a consequence of something that actually happened.
|
|
||||||
if (vibe <= 0) this.endNight('vibe');
|
if (vibe <= 0) this.endNight('vibe');
|
||||||
else if (aggro >= 100) this.endNight('aggro');
|
else if (aggro >= 100) this.endNight('aggro');
|
||||||
}
|
}
|
||||||
@ -176,16 +276,40 @@ export class NightScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.fireDeferred(clockMin);
|
this.fireDeferred(clockMin);
|
||||||
this.maybeDazza(clockMin);
|
this.maybeDazza(clockMin);
|
||||||
|
this.maybePull(clockMin);
|
||||||
|
|
||||||
if (clockMin >= this.clock.config.nightClockMinutes) this.endNight('clock');
|
if (clockMin >= this.clock.config.nightClockMinutes) this.endNight('clock');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Consequences you earned earlier arriving now, when you can no longer argue. */
|
/** Scripted floor pulls: the radio wants you inside around these times. */
|
||||||
|
private maybePull(clockMin: number): void {
|
||||||
|
for (const at of RADIO_PULL_MINS) {
|
||||||
|
if (clockMin < at || this.firedPulls.has(at)) continue;
|
||||||
|
this.firedPulls.add(at);
|
||||||
|
if (this.state.location === 'floor') continue; // already in there
|
||||||
|
this.bus.emit('radio:call', {
|
||||||
|
urgency: 2,
|
||||||
|
line: at === RADIO_PULL_MINS[0] ? 'mate do a lap, it is getting loose in here' : 'you are NEEDED inside. now.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private maggotRadio(): void {
|
||||||
|
const clockMin = this.state.clockMin;
|
||||||
|
if (clockMin - this.lastMaggotRadioMin < RADIO_MAGGOT_COOLDOWN) return;
|
||||||
|
this.lastMaggotRadioMin = clockMin;
|
||||||
|
this.bus.emit('radio:call', { urgency: 3, line: 'someone is MAGGOT on the dance floor, get in here' });
|
||||||
|
}
|
||||||
|
|
||||||
private fireDeferred(clockMin: number): void {
|
private fireDeferred(clockMin: number): void {
|
||||||
|
// Collect first, THEN process: a hit's vibe delta can end the night, and
|
||||||
|
// endNight() reassigns this.deferred — mutating the array we'd be iterating.
|
||||||
|
const due: DeferredHit[] = [];
|
||||||
for (let i = this.deferred.length - 1; i >= 0; i--) {
|
for (let i = this.deferred.length - 1; i >= 0; i--) {
|
||||||
const hit = this.deferred[i]!;
|
if (clockMin >= this.deferred[i]!.atClockMin) due.push(...this.deferred.splice(i, 1));
|
||||||
if (clockMin < hit.atClockMin) continue;
|
}
|
||||||
this.deferred.splice(i, 1);
|
for (const hit of due) {
|
||||||
|
if (this.ended) return;
|
||||||
|
|
||||||
if (hit.vibeDelta !== undefined || hit.aggroDelta !== undefined) {
|
if (hit.vibeDelta !== undefined || hit.aggroDelta !== undefined) {
|
||||||
this.bus.emit('meters:delta', {
|
this.bus.emit('meters:delta', {
|
||||||
@ -228,36 +352,54 @@ export class NightScene extends Phaser.Scene {
|
|||||||
this.ended = true;
|
this.ended = true;
|
||||||
this.log.endReason = reason;
|
this.log.endReason = reason;
|
||||||
this.clock.pause();
|
this.clock.pause();
|
||||||
this.beat.stop();
|
this.stubBeat?.stop();
|
||||||
|
|
||||||
// Unfired deferred hits are the 3 AM audit — they land whether you made it
|
// Unfired deferred hits are the 3 AM audit — they land whether you made it
|
||||||
// to closing or not.
|
// to closing or not. (Not after a pulled licence: the run is already dead.)
|
||||||
for (const hit of this.deferred) {
|
if (reason !== 'licence') {
|
||||||
// Emit rather than push: core/meters.ts is the only thing allowed to
|
for (const hit of this.deferred) {
|
||||||
// mutate NightState (CONTRACTS.md §4), and the log listener picks it up.
|
if (hit.heatStrike && shouldRecordStrike(this.state, hit.heatStrike)) {
|
||||||
if (hit.heatStrike && shouldRecordStrike(this.state, hit.heatStrike)) {
|
this.bus.emit('heat:strike', hit.heatStrike);
|
||||||
this.bus.emit('heat:strike', hit.heatStrike);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.deferred = [];
|
this.deferred = [];
|
||||||
this.log.vibeHistory.push(this.state.vibe);
|
this.log.vibeHistory.push(this.state.vibe);
|
||||||
|
|
||||||
|
// ---- run bookkeeping (save) ----
|
||||||
|
const runOver = reason === 'licence';
|
||||||
|
const survived = reason === 'clock';
|
||||||
|
const weekDone = survived && this.nightIndex >= TOTAL_NIGHTS - 1;
|
||||||
|
this.run.heatStrikes = [...this.state.heatStrikes];
|
||||||
|
this.run.pastReports.push([...this.state.incidents]);
|
||||||
|
if (survived) this.run.nightIndex = this.nightIndex + 1;
|
||||||
|
if (runOver || weekDone) {
|
||||||
|
clearSave();
|
||||||
|
} else {
|
||||||
|
saveGame(this.run);
|
||||||
|
}
|
||||||
|
const runInfo: RunInfo = { nightIndex: this.nightIndex, totalNights: TOTAL_NIGHTS, runOver, weekDone };
|
||||||
|
|
||||||
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
|
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
|
||||||
this.scene.stop('Door');
|
this.scene.stop('Door');
|
||||||
this.scene.start('NightSummary', { log: this.log, state: this.state });
|
this.scene.stop('Floor');
|
||||||
|
this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
override update(_time: number, deltaMs: number): void {
|
override update(_time: number, deltaMs: number): void {
|
||||||
if (this.ended) return;
|
if (this.ended) return;
|
||||||
this.clock.update(deltaMs);
|
this.clock.update(deltaMs);
|
||||||
this.beat.update(deltaMs);
|
this.stubBeat?.update(deltaMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private teardown(): void {
|
private teardown(): void {
|
||||||
for (const off of this.offs) off();
|
for (const off of this.offs) off();
|
||||||
this.offs = [];
|
this.offs = [];
|
||||||
this.meters?.destroy();
|
this.meters?.destroy();
|
||||||
|
this.engine?.destroy();
|
||||||
|
this.engine = null;
|
||||||
|
this.sfx = null;
|
||||||
this.bus?.removeAll();
|
this.bus?.removeAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import type { NightState } from '../../data/types';
|
import type { NightState } from '../../data/types';
|
||||||
import { DAZZA_SIGNOFF, FAIL_TEXT, SUMMARY_UI } from '../../data/strings/door';
|
import { DAZZA_SIGNOFF, FAIL_TEXT, SUMMARY_UI } from '../../data/strings/door';
|
||||||
import { MAX_HEAT_STRIKES, type NightLog } from './NightScene';
|
import { MAX_HEAT_STRIKES, NIGHT_DATES, type NightLog, type RunInfo } from './NightScene';
|
||||||
import { DOOR_PALETTE, MONO, panel, text } from './ui';
|
import { DOOR_PALETTE, MONO, panel, text } from './ui';
|
||||||
|
|
||||||
// TODO(integration): Phase 2 owns the real summary (incident report form,
|
// Phase-2: run-aware summary. Still owes Phase 3 the incident-report form and
|
||||||
// cross-night audit, progression). This is the honest v0.1 version: what
|
// the cross-night audit.
|
||||||
// happened, what it cost, and Dazza's opinion.
|
|
||||||
|
|
||||||
const W = 640;
|
const W = 640;
|
||||||
const H = 360;
|
const H = 360;
|
||||||
|
|
||||||
|
const NIGHT_NAMES = ['thursday', 'friday', 'saturday'] as const;
|
||||||
|
|
||||||
export function nightCash(log: NightLog, state: NightState): number {
|
export function nightCash(log: NightLog, state: NightState): number {
|
||||||
// Wage plus a hype bonus, minus a strike deduction. Vibe is not paid directly —
|
// Wage plus a hype bonus, minus a strike deduction. Vibe is not paid directly —
|
||||||
// Dazza pays for the room feeling busy, not for you being right.
|
// Dazza pays for the room feeling busy, not for you being right.
|
||||||
@ -30,25 +31,25 @@ export function signoffFor(vibe: number, strikes: number): string {
|
|||||||
export class NightSummaryScene extends Phaser.Scene {
|
export class NightSummaryScene extends Phaser.Scene {
|
||||||
private log!: NightLog;
|
private log!: NightLog;
|
||||||
private state!: NightState;
|
private state!: NightState;
|
||||||
|
private run: RunInfo | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('NightSummary');
|
super('NightSummary');
|
||||||
}
|
}
|
||||||
|
|
||||||
init(data: { log: NightLog; state: NightState }): void {
|
init(data: { log: NightLog; state: NightState; run?: RunInfo }): void {
|
||||||
this.log = data.log;
|
this.log = data.log;
|
||||||
this.state = data.state;
|
this.state = data.state;
|
||||||
|
this.run = data.run ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
const failed = this.log.endReason !== 'clock';
|
const reason = this.log.endReason;
|
||||||
|
const failed = reason !== 'clock';
|
||||||
this.add.rectangle(W / 2, H / 2, W, H, failed ? 0x160a0e : 0x0a0a12);
|
this.add.rectangle(W / 2, H / 2, W, H, failed ? 0x160a0e : 0x0a0a12);
|
||||||
|
|
||||||
const title = failed
|
const title = reason !== 'clock' ? FAIL_TEXT[reason].title : SUMMARY_UI.title;
|
||||||
? this.log.endReason === 'vibe'
|
const nightName = this.run ? (NIGHT_NAMES[this.run.nightIndex] ?? '') : '';
|
||||||
? FAIL_TEXT.vibe.title
|
|
||||||
: FAIL_TEXT.aggro.title
|
|
||||||
: SUMMARY_UI.title;
|
|
||||||
|
|
||||||
this.add
|
this.add
|
||||||
.text(W / 2, 26, title, {
|
.text(W / 2, 26, title, {
|
||||||
@ -58,11 +59,18 @@ export class NightSummaryScene extends Phaser.Scene {
|
|||||||
})
|
})
|
||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
this.add
|
this.add
|
||||||
.text(W / 2, 46, failed ? `${this.state.venueId} · night over early` : SUMMARY_UI.subtitle, {
|
.text(
|
||||||
fontFamily: MONO,
|
W / 2,
|
||||||
fontSize: '8px',
|
46,
|
||||||
color: DOOR_PALETTE.inkDim,
|
failed
|
||||||
})
|
? `${this.state.venueId} · ${nightName} · night over early`
|
||||||
|
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''}`,
|
||||||
|
{
|
||||||
|
fontFamily: MONO,
|
||||||
|
fontSize: '8px',
|
||||||
|
color: DOOR_PALETTE.inkDim,
|
||||||
|
},
|
||||||
|
)
|
||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
|
|
||||||
this.drawVibeGraph(30, 64, 300, 88);
|
this.drawVibeGraph(30, 64, 300, 88);
|
||||||
@ -70,16 +78,48 @@ export class NightSummaryScene extends Phaser.Scene {
|
|||||||
this.drawStats(354, 64);
|
this.drawStats(354, 64);
|
||||||
this.drawDazza(30, 250);
|
this.drawDazza(30, 250);
|
||||||
|
|
||||||
|
const { prompt, next } = this.nextAction();
|
||||||
this.add
|
this.add
|
||||||
.text(W / 2, H - 14, `${SUMMARY_UI.replay} · seed ${this.log.seed}`, {
|
.text(W / 2, H - 14, `${prompt} · seed ${this.log.seed}`, {
|
||||||
fontFamily: MONO,
|
fontFamily: MONO,
|
||||||
fontSize: '8px',
|
fontSize: '8px',
|
||||||
color: DOOR_PALETTE.inkDim,
|
color: this.run?.runOver ? '#ff8080' : DOOR_PALETTE.inkDim,
|
||||||
})
|
})
|
||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
|
|
||||||
this.input.once('pointerdown', () => this.scene.start('Night', { seed: this.log.seed + 1 }));
|
this.input.once('pointerdown', next);
|
||||||
this.input.keyboard?.once('keydown-SPACE', () => this.scene.start('Night', { seed: this.log.seed + 1 }));
|
this.input.keyboard?.once('keydown-SPACE', next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What clicking through leads to: the next shift, or a fresh run. */
|
||||||
|
private nextAction(): { prompt: string; next: () => void } {
|
||||||
|
const r = this.run;
|
||||||
|
if (!r) {
|
||||||
|
return {
|
||||||
|
prompt: SUMMARY_UI.replay,
|
||||||
|
next: () => this.scene.start('Night', { seed: this.log.seed + 1 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (r.runOver) {
|
||||||
|
return {
|
||||||
|
prompt: 'licence gone · click to start a fresh week somewhere that has not heard of you',
|
||||||
|
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 13 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (r.weekDone) {
|
||||||
|
return {
|
||||||
|
prompt: 'WEEK SURVIVED. click for a new week (new seed, same rain)',
|
||||||
|
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 7 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const failedNight = this.log.endReason !== 'clock';
|
||||||
|
const nextName = failedNight
|
||||||
|
? (NIGHT_NAMES[r.nightIndex] ?? 'the same night')
|
||||||
|
: (NIGHT_NAMES[r.nightIndex + 1] ?? 'the next night');
|
||||||
|
return {
|
||||||
|
prompt: failedNight ? `click to run ${nextName} back` : `click for ${nextName} · ${NIGHT_DATES[r.nightIndex + 1] ?? ''}`,
|
||||||
|
next: () => this.scene.start('Night', {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawVibeGraph(x: number, y: number, w: number, h: number): void {
|
private drawVibeGraph(x: number, y: number, w: number, h: number): void {
|
||||||
@ -185,17 +225,16 @@ export class NightSummaryScene extends Phaser.Scene {
|
|||||||
panel(this, x, y, w, 74, 0x0d1116, 0x2c4a38);
|
panel(this, x, y, w, 74, 0x0d1116, 0x2c4a38);
|
||||||
text(this, x + 8, y + 6, 'DAZZA', 8, '#7de08a');
|
text(this, x + 8, y + 6, 'DAZZA', 8, '#7de08a');
|
||||||
|
|
||||||
const failed = this.log.endReason !== 'clock';
|
const reason = this.log.endReason;
|
||||||
const body = failed
|
const body =
|
||||||
? this.log.endReason === 'vibe'
|
reason !== 'clock'
|
||||||
? FAIL_TEXT.vibe.dazza
|
? FAIL_TEXT[reason].dazza
|
||||||
: FAIL_TEXT.aggro.dazza
|
: signoffFor(this.state.vibe, this.state.heatStrikes.length);
|
||||||
: signoffFor(this.state.vibe, this.state.heatStrikes.length);
|
|
||||||
|
|
||||||
this.add.text(x + 8, y + 20, body, {
|
this.add.text(x + 8, y + 20, body, {
|
||||||
fontFamily: MONO,
|
fontFamily: MONO,
|
||||||
fontSize: '9px',
|
fontSize: '9px',
|
||||||
color: failed ? '#ffb0b0' : '#bfe8c8',
|
color: reason !== 'clock' ? '#ffb0b0' : '#bfe8c8',
|
||||||
wordWrap: { width: w - 16 },
|
wordWrap: { width: w - 16 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,147 +0,0 @@
|
|||||||
import Phaser from 'phaser';
|
|
||||||
import { DOOR_PALETTE, MONO } from './ui';
|
|
||||||
|
|
||||||
// The signature interaction. Everything here exists to make one click feel like
|
|
||||||
// dropping a brick: overshoot, slam, shake, dust, ink that stays a beat too long.
|
|
||||||
// TODO(juice): swap for ui/Stamp + the stamp SFX when LANE-JUICE lands. Keep the
|
|
||||||
// timings — they were tuned by feel, not by theory.
|
|
||||||
|
|
||||||
const STAMP_KEY = 'door:stampInk';
|
|
||||||
const DUST_KEY = 'door:stampDust';
|
|
||||||
|
|
||||||
const TIMING = {
|
|
||||||
/** wind-up: stamp hangs above the patron, tilted */
|
|
||||||
rise: 90,
|
|
||||||
/** the drop itself. Short. A stamp does not float. */
|
|
||||||
slam: 110,
|
|
||||||
/** how long the ink sits before it starts fading */
|
|
||||||
hold: 520,
|
|
||||||
fade: 340,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function ensureTextures(scene: Phaser.Scene): void {
|
|
||||||
if (!scene.textures.exists(DUST_KEY)) {
|
|
||||||
const g = scene.add.graphics();
|
|
||||||
g.fillStyle(0xb0a898, 1);
|
|
||||||
g.fillRect(0, 0, 2, 2);
|
|
||||||
g.generateTexture(DUST_KEY, 2, 2);
|
|
||||||
g.destroy();
|
|
||||||
}
|
|
||||||
if (scene.textures.exists(STAMP_KEY)) return;
|
|
||||||
|
|
||||||
// Ink block with a ragged edge — a clean rectangle reads as UI, a chewed one
|
|
||||||
// reads as rubber on a wet stamp pad.
|
|
||||||
const w = 118;
|
|
||||||
const h = 34;
|
|
||||||
const g = scene.add.graphics();
|
|
||||||
g.fillStyle(DOOR_PALETTE.red, 1);
|
|
||||||
g.fillRect(0, 3, w, h - 6);
|
|
||||||
g.fillRect(3, 0, w - 6, h);
|
|
||||||
g.fillStyle(0x000000, 1);
|
|
||||||
for (let i = 0; i < 26; i++) {
|
|
||||||
const x = (i * 37) % (w - 4);
|
|
||||||
const y = i % 2 === 0 ? 2 : h - 5;
|
|
||||||
g.fillRect(x, y, 3, 3);
|
|
||||||
}
|
|
||||||
g.generateTexture(STAMP_KEY, w, h);
|
|
||||||
g.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StampOpts {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
label: string;
|
|
||||||
onImpact?: () => void;
|
|
||||||
onDone?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StampFx {
|
|
||||||
constructor(private readonly scene: Phaser.Scene) {
|
|
||||||
ensureTextures(scene);
|
|
||||||
}
|
|
||||||
|
|
||||||
play(opts: StampOpts): void {
|
|
||||||
const { scene } = this;
|
|
||||||
ensureTextures(scene);
|
|
||||||
|
|
||||||
// Above the street and the patron, BELOW every modal (ID card 700, phone
|
|
||||||
// thread 720, sobriety 760) — a stamp landing on top of an open ID card
|
|
||||||
// reads as a rendering bug.
|
|
||||||
const root = scene.add.container(opts.x, opts.y).setDepth(640);
|
|
||||||
const ink = scene.add.image(0, 0, STAMP_KEY).setOrigin(0.5);
|
|
||||||
const label = scene.add
|
|
||||||
.text(0, 1, opts.label, { fontFamily: MONO, fontSize: '13px', color: '#ffe8e8' })
|
|
||||||
.setOrigin(0.5);
|
|
||||||
root.add([ink, label]);
|
|
||||||
|
|
||||||
root.setAngle(-11);
|
|
||||||
root.setScale(3.4);
|
|
||||||
root.setAlpha(0);
|
|
||||||
|
|
||||||
// rise: fade in high and slightly rotated, so the drop has somewhere to come from
|
|
||||||
scene.tweens.add({
|
|
||||||
targets: root,
|
|
||||||
alpha: 1,
|
|
||||||
scale: 2.6,
|
|
||||||
duration: TIMING.rise,
|
|
||||||
ease: 'Quad.easeOut',
|
|
||||||
onComplete: () => this.slam(root, opts),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private slam(root: Phaser.GameObjects.Container, opts: StampOpts): void {
|
|
||||||
const { scene } = this;
|
|
||||||
scene.tweens.add({
|
|
||||||
targets: root,
|
|
||||||
scale: 1,
|
|
||||||
angle: -6,
|
|
||||||
duration: TIMING.slam,
|
|
||||||
ease: 'Cubic.easeIn',
|
|
||||||
onComplete: () => {
|
|
||||||
opts.onImpact?.();
|
|
||||||
scene.cameras.main.shake(140, 0.012);
|
|
||||||
this.dustRing(opts.x, opts.y);
|
|
||||||
|
|
||||||
// A 2px rebound after contact. Without it the stamp reads as a decal.
|
|
||||||
scene.tweens.add({
|
|
||||||
targets: root,
|
|
||||||
scaleX: 1.06,
|
|
||||||
scaleY: 0.94,
|
|
||||||
duration: 60,
|
|
||||||
yoyo: true,
|
|
||||||
ease: 'Quad.easeOut',
|
|
||||||
});
|
|
||||||
|
|
||||||
scene.time.delayedCall(TIMING.hold, () => {
|
|
||||||
scene.tweens.add({
|
|
||||||
targets: root,
|
|
||||||
alpha: 0,
|
|
||||||
y: root.y - 6,
|
|
||||||
duration: TIMING.fade,
|
|
||||||
ease: 'Quad.easeIn',
|
|
||||||
onComplete: () => {
|
|
||||||
root.destroy();
|
|
||||||
opts.onDone?.();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private dustRing(x: number, y: number): void {
|
|
||||||
const emitter = this.scene.add.particles(x, y, DUST_KEY, {
|
|
||||||
speed: { min: 40, max: 130 },
|
|
||||||
angle: { min: 190, max: 350 },
|
|
||||||
gravityY: 190,
|
|
||||||
lifespan: { min: 260, max: 520 },
|
|
||||||
quantity: 16,
|
|
||||||
scale: { start: 1.4, end: 0 },
|
|
||||||
alpha: { start: 0.65, end: 0 },
|
|
||||||
emitting: false,
|
|
||||||
});
|
|
||||||
emitter.setDepth(639);
|
|
||||||
emitter.explode(16);
|
|
||||||
this.scene.time.delayedCall(700, () => emitter.destroy());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -20,14 +20,19 @@ import { CutOffOverlay } from './overlays/CutOffOverlay';
|
|||||||
import { StallOverlay } from './overlays/StallOverlay';
|
import { StallOverlay } from './overlays/StallOverlay';
|
||||||
import { PatDownOverlay } from './overlays/PatDownOverlay';
|
import { PatDownOverlay } from './overlays/PatDownOverlay';
|
||||||
import { HELP_TEXT, NO_STAMP_RADIO } from '../../data/strings/floor';
|
import { HELP_TEXT, NO_STAMP_RADIO } from '../../data/strings/floor';
|
||||||
|
import { MeterHud } from '../../ui/MeterHud';
|
||||||
|
import type { NightContext } from '../door/NightScene';
|
||||||
|
import type { Patron } from '../../data/types';
|
||||||
|
|
||||||
const VIEW_H = 360;
|
const VIEW_H = 360;
|
||||||
const INTERACT_RANGE = 46;
|
const INTERACT_RANGE = 46;
|
||||||
const MAX_CROWD = 44;
|
const MAX_CROWD = 44;
|
||||||
const SPAWN_EVERY_MS = 420;
|
const SPAWN_EVERY_MS = 420;
|
||||||
|
/** Admitted at the rope → appears on the floor after the walk in. */
|
||||||
|
const WALK_IN_MS: [number, number] = [6000, 14000];
|
||||||
|
|
||||||
/** What pressing E on the current target would do. */
|
/** What pressing E on the current target would do. */
|
||||||
type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp';
|
type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit';
|
||||||
|
|
||||||
interface InteractTarget {
|
interface InteractTarget {
|
||||||
kind: InteractKind;
|
kind: InteractKind;
|
||||||
@ -37,19 +42,28 @@ interface InteractTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LANE-FLOOR demo: a populated venue you patrol in the dark, with all three v0.2
|
* The venue floor. Two modes, one scene class:
|
||||||
* infractions plus the UV stamp check live. Self-populates from the patron
|
* - 'FloorDemo' (standalone): self-populates from the patron generator, owns its
|
||||||
* generator; Phase 2 integration will hand it the real admitted-patron list
|
* own bus/clock/meters. The Phase-1 demo, unchanged.
|
||||||
* instead, which is why everything downstream only ever sees Patron[].
|
* - 'Floor' (night mode): launched by NightScene with a NightContext. Shares the
|
||||||
|
* night's bus/clock/state, is populated by ACTUAL door admissions, runs its
|
||||||
|
* sim all night (even while the player works the rope), and hands the player
|
||||||
|
* back to the door via the exit.
|
||||||
*/
|
*/
|
||||||
export class FloorDemoScene extends Phaser.Scene {
|
export class FloorDemoScene extends Phaser.Scene {
|
||||||
private bus!: EventBus;
|
private bus!: EventBus;
|
||||||
private rng!: SeededRNG;
|
private rng!: SeededRNG;
|
||||||
private clock!: GameClock;
|
private clock!: GameClock;
|
||||||
private beat!: StubBeatClock;
|
private beat: StubBeatClock | null = null;
|
||||||
private meters!: Meters;
|
private meters: Meters | null = null;
|
||||||
private night!: NightState;
|
private night!: NightState;
|
||||||
private hud!: HudStub;
|
private hud!: HudStub | MeterHud;
|
||||||
|
private nightCtx: NightContext | null = null;
|
||||||
|
/** In night mode: is the player physically on the floor right now? */
|
||||||
|
private present = true;
|
||||||
|
private elapsedMs = 0;
|
||||||
|
private pendingSpawns: Array<{ patron: Patron; atMs: number }> = [];
|
||||||
|
private ctxOffs: Array<() => void> = [];
|
||||||
private view!: FloorView;
|
private view!: FloorView;
|
||||||
private crowd!: CrowdSim;
|
private crowd!: CrowdSim;
|
||||||
private player!: PlayerState;
|
private player!: PlayerState;
|
||||||
@ -73,11 +87,13 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
private radioText!: Phaser.GameObjects.Text;
|
private radioText!: Phaser.GameObjects.Text;
|
||||||
private radioMs = 0;
|
private radioMs = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor(key: string = 'FloorDemo') {
|
||||||
super('FloorDemo');
|
super(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(data?: { night?: NightContext }): void {
|
||||||
|
this.nightCtx = data?.night ?? null;
|
||||||
|
this.events.once('shutdown', () => this.shutdown());
|
||||||
this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16);
|
this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16);
|
||||||
this.cameras.main.setBackgroundColor('#04040a');
|
this.cameras.main.setBackgroundColor('#04040a');
|
||||||
|
|
||||||
@ -98,7 +114,49 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
// exactly one thing consumes input at a time.
|
// exactly one thing consumes input at a time.
|
||||||
this.input.keyboard!.on('keydown', (ev: KeyboardEvent) => this.onKey(ev));
|
this.input.keyboard!.on('keydown', (ev: KeyboardEvent) => this.onKey(ev));
|
||||||
|
|
||||||
this.resetRun(this.seed);
|
if (this.nightCtx) this.bindNight(this.nightCtx);
|
||||||
|
else this.resetRun(this.seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Night mode: adopt the night's world instead of building our own. */
|
||||||
|
private bindNight(ctx: NightContext): void {
|
||||||
|
this.seed = ctx.seed;
|
||||||
|
this.bus = ctx.bus;
|
||||||
|
this.rng = ctx.rng;
|
||||||
|
this.clock = ctx.clock;
|
||||||
|
this.night = ctx.state;
|
||||||
|
this.beat = null; // beat:tick arrives on the shared bus (stub, then TechnoEngine)
|
||||||
|
this.meters = null; // NightScene owns the single writer
|
||||||
|
this.hud = new MeterHud(this, this.bus, { initialHeat: this.night.heatStrikes.length });
|
||||||
|
this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs);
|
||||||
|
|
||||||
|
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
|
||||||
|
|
||||||
|
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.present = false; // the night starts at the rope
|
||||||
|
this.elapsedMs = 0;
|
||||||
|
this.pendingSpawns = [];
|
||||||
|
this.maggotSeen.clear();
|
||||||
|
this.spotted.clear();
|
||||||
|
|
||||||
|
this.ctxOffs = [
|
||||||
|
// The floor is fed by the door: every admission walks in a few seconds later.
|
||||||
|
this.bus.on('door:verdict', ({ patron, verdict }) => {
|
||||||
|
if (verdict !== 'admit') return;
|
||||||
|
const delay = this.rng.stream('floorArrive').int(WALK_IN_MS[0], WALK_IN_MS[1]);
|
||||||
|
this.pendingSpawns.push({ patron, atMs: this.elapsedMs + delay });
|
||||||
|
}),
|
||||||
|
this.bus.on('night:phaseChange', ({ location }) => {
|
||||||
|
this.present = location === 'floor';
|
||||||
|
if (this.present) this.target = null;
|
||||||
|
}),
|
||||||
|
this.bus.on('beat:tick', ({ beatIndex }) => this.crowd.onBeat(beatIndex)),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private fixedText(x: number, y: number, colour: string): Phaser.GameObjects.Text {
|
private fixedText(x: number, y: number, colour: string): Phaser.GameObjects.Text {
|
||||||
@ -159,7 +217,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.uvMode = !this.uvMode;
|
this.uvMode = !this.uvMode;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key === 'R') return this.resetRun(this.seed + 1);
|
if (key === 'R' && !this.nightCtx) return this.resetRun(this.seed + 1);
|
||||||
if (key === 'E') return this.interact();
|
if (key === 'E') return this.interact();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,8 +246,33 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
// opened a cubicle door — and the stall minigame judges against beat:tick,
|
// opened a cubicle door — and the stall minigame judges against beat:tick,
|
||||||
// so freezing the beat clock behind a modal makes it literally unplayable.
|
// 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.
|
// The night clock keeps running too: time is the one thing you never get back.
|
||||||
this.clock.update(dt);
|
// (In night mode NightScene drives both; this scene just consumes the bus.)
|
||||||
this.beat.update(dt);
|
if (!this.nightCtx) {
|
||||||
|
this.clock.update(dt);
|
||||||
|
this.beat?.update(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.elapsedMs += dt;
|
||||||
|
if (this.nightCtx) {
|
||||||
|
for (let i = this.pendingSpawns.length - 1; i >= 0; i--) {
|
||||||
|
const s = this.pendingSpawns[i]!;
|
||||||
|
if (this.elapsedMs < s.atMs) continue;
|
||||||
|
this.pendingSpawns.splice(i, 1);
|
||||||
|
// Licensed capacity is 80 but 44 agents is the sim's perf budget; past
|
||||||
|
// that, arrivals dissolve into the (conceptual) back room. log()-style
|
||||||
|
// honesty: this is a cap, not a simulation.
|
||||||
|
if (this.crowd.agents.length < MAX_CROWD) this.crowd.spawn(s.patron);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player at the door: the floor keeps living (drinks keep being drunk,
|
||||||
|
// stalls keep filling, maggots keep ripening → radio pressure), but
|
||||||
|
// nothing needs the camera, the cone, or input.
|
||||||
|
if (!this.present) {
|
||||||
|
this.crowd.update(dt, this.clock.clockMin);
|
||||||
|
this.trackMaggots();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.overlayOpen) {
|
if (this.overlayOpen) {
|
||||||
this.cutOff.update(dt);
|
this.cutOff.update(dt);
|
||||||
@ -206,12 +289,17 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
const input = this.readInput();
|
const input = this.readInput();
|
||||||
this.player = stepPlayer(this.player, input, dt, VENUE);
|
this.player = stepPlayer(this.player, input, dt, VENUE);
|
||||||
|
|
||||||
this.spawnAccMs += dt;
|
if (!this.nightCtx) {
|
||||||
while (this.spawnAccMs > SPAWN_EVERY_MS && this.crowd.agents.length < MAX_CROWD) {
|
// Demo mode stands in for the door with a synthetic spawner.
|
||||||
this.spawnAccMs -= SPAWN_EVERY_MS;
|
this.spawnAccMs += dt;
|
||||||
this.spawnOne();
|
while (this.spawnAccMs > SPAWN_EVERY_MS && this.crowd.agents.length < MAX_CROWD) {
|
||||||
|
this.spawnAccMs -= SPAWN_EVERY_MS;
|
||||||
|
this.spawnOne();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.hud instanceof MeterHud) this.hud.update(dt);
|
||||||
|
|
||||||
this.crowd.update(dt, this.clock.clockMin);
|
this.crowd.update(dt, this.clock.clockMin);
|
||||||
if (this.escorting) this.tickEscort(dt);
|
if (this.escorting) this.tickEscort(dt);
|
||||||
|
|
||||||
@ -271,6 +359,15 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
private findTarget(): InteractTarget | null {
|
private findTarget(): InteractTarget | null {
|
||||||
const spec = this.coneSpec();
|
const spec = this.coneSpec();
|
||||||
|
|
||||||
|
// Night mode: the way back out to the rope is itself an interaction.
|
||||||
|
if (this.nightCtx) {
|
||||||
|
const entry = VENUE.anchors.entry[0];
|
||||||
|
if (entry) {
|
||||||
|
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y);
|
||||||
|
if (d <= INTERACT_RANGE) return { kind: 'exit', prompt: 'E — back out to the door' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stall doors first — you interact with the door, not a person.
|
// Stall doors first — you interact with the door, not a person.
|
||||||
for (const s of VENUE.stalls) {
|
for (const s of VENUE.stalls) {
|
||||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.door.x, s.door.y);
|
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.door.x, s.door.y);
|
||||||
@ -318,6 +415,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
const t = this.target;
|
const t = this.target;
|
||||||
if (!t || this.escorting) return;
|
if (!t || this.escorting) return;
|
||||||
|
|
||||||
|
if (t.kind === 'exit') return this.nightCtx?.requestLocation('door');
|
||||||
if (t.kind === 'stall' && t.stallId) return this.openStall(t.stallId);
|
if (t.kind === 'stall' && t.stallId) return this.openStall(t.stallId);
|
||||||
if (!t.agent) return;
|
if (!t.agent) return;
|
||||||
if (t.kind === 'noStamp') return this.startEscort(t.agent, 'clean', 'noStamp');
|
if (t.kind === 'noStamp') return this.startEscort(t.agent, 'clean', 'noStamp');
|
||||||
@ -418,11 +516,14 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shutdown(): void {
|
shutdown(): void {
|
||||||
|
for (const off of this.ctxOffs) off();
|
||||||
|
this.ctxOffs = [];
|
||||||
this.crowd?.destroy();
|
this.crowd?.destroy();
|
||||||
this.view?.destroy();
|
this.view?.destroy();
|
||||||
this.cutOff?.destroy();
|
this.cutOff?.destroy();
|
||||||
this.stall?.destroy();
|
this.stall?.destroy();
|
||||||
this.patDown?.destroy();
|
this.patDown?.destroy();
|
||||||
|
// Night mode: the bus/meters belong to NightScene — touch nothing shared.
|
||||||
this.meters?.destroy();
|
this.meters?.destroy();
|
||||||
this.hud?.destroy();
|
this.hud?.destroy();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,12 @@ export interface MeterHudOptions {
|
|||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
|
/**
|
||||||
|
* Strikes already on the licence when this HUD is built (review ruling: read
|
||||||
|
* `NightState.heatStrikes.length` at construction — heat is run-scoped and a
|
||||||
|
* mid-run scene swap must not reset the display to zero).
|
||||||
|
*/
|
||||||
|
initialHeat?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BAR_X = 30;
|
const BAR_X = 30;
|
||||||
@ -165,6 +171,11 @@ export class MeterHud {
|
|||||||
this.slots.push({ mark, scale: 1 });
|
this.slots.push({ mark, scale: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(opts.initialHeat ?? 0, HEAT_SLOTS); i++) {
|
||||||
|
this.strikes++;
|
||||||
|
this.slots[i]?.mark.setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
this.offs.push(
|
this.offs.push(
|
||||||
bus.on('meters:changed', ({ vibe, aggro, hype }) => {
|
bus.on('meters:changed', ({ vibe, aggro, hype }) => {
|
||||||
this.vibe = vibe;
|
this.vibe = vibe;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user