Four venues, four rooms: the ladder stops being one map with different numbers
The venue ladder has always listed four venues in data/venues.ts, but they shared ONE floor map and differed only by difficulty knobs — so a promotion you survived a whole week for looked exactly like the room you had just left. - venueMap.ts grows a FloorLayout contract (map + props + lights + posts + probes + palette) and the newGrid() primitives every room is painted with. Voltage stays IN venueMap rather than moving to layouts/, so the new rooms can import primitives from it without closing an import cycle. - Three new rooms in src/scenes/floor/layouts/: The Royal (horseshoe public bar, pool room, pokies corner, trough + two cubicles, a beer garden that is plainly the nicest room in the pub, and a DJ "corner" that is a folding table because this pub never built a booth), Elevate (open-air rooftop — most of the grid is sky, the DJ is on an unwalled plinth, you arrive by lift), ROOM (concrete warehouse, pillars you path around, central booth, loading-dock smoking area, twelve lights in the whole venue). - Nothing about a room is a module singleton any more. FloorView, sweep and FloorDemoScene take the layout; the door picks its street plate by venue id. - FloorDemoScene's six hardcoded station spots (TAPS_SPOT, DECKS_SPOT, ...) were Voltage's tile coordinates. At four venues a hardcoded (47,3) puts the bar shift inside The Royal's pool room, so each layout now names its own stations and the scene reads them from there. - tests/floor/layoutInvariants.ts is an executable rulebook: perimeter holds, every walkable tile reachable from the entry, anchors on walkable ground, posts not sealed (a post on a sealed tile freezes the player for the night — that has shipped twice), probes reachable, props on-grid. Proved against Voltage BEFORE the new rooms were authored against it. Dev route #floor:<venueId> boots the floor straight into one room, because otherwise seeing ROOM means surviving three weeks of the ladder. Gate: lint clean, build clean, 821 tests passing (was 784). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ae036d9d0e
commit
4caa901df2
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist/
|
dist/
|
||||||
art_incoming/
|
art_incoming/
|
||||||
|
public/props/manifest.lock
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
25
CLAUDE.md
25
CLAUDE.md
@ -29,6 +29,31 @@ 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 (venue expansion landed 2026-07-28)
|
||||||
|
- **The ladder's four venues now have four different rooms** (`docs/VENUES.md`).
|
||||||
|
Layouts live in `src/scenes/floor/layouts/` (`theRoyal`, `elevate`, `room`);
|
||||||
|
**Voltage stays in `venueMap.ts`** so the others can import primitives from it
|
||||||
|
without an import cycle. A night picks its room from `NightContext.venue.id`.
|
||||||
|
- Nothing about a room may be a module singleton any more. Props, lights, posts,
|
||||||
|
probes and the tile palette all hang off `FloorLayout`. `venueMap.VENUE/PROPS/
|
||||||
|
LIGHTS` still exist and are still Voltage's — they are the reference room, not
|
||||||
|
"the venue".
|
||||||
|
- **Dev route `#floor:<venueId>`** boots the floor demo straight into one room
|
||||||
|
(`#floor:room`, `#floor:theRoyal`, `#floor:elevate`). Plain `#floor`/F = Voltage.
|
||||||
|
- `tests/floor/layoutInvariants.ts` is the executable rulebook every layout runs:
|
||||||
|
perimeter, reachability, anchors on walkable tiles, posts not sealed, probes
|
||||||
|
reachable, props on-grid. Add a room, add it to `LAYOUTS`, it gets checked.
|
||||||
|
- Asset generation is `tools/gen/` — `assets.py` is the manifest (one entry per
|
||||||
|
sprite), `run_batch.py` walks it, resumable, `--workers N`. **Cloudflare Workers
|
||||||
|
AI first** (10k free neurons/day, resets 00:00 UTC), falling back to MODELBEAST
|
||||||
|
`flux_local` automatically when the day's allocation is spent. Meshes use the
|
||||||
|
**512 tier + vertex baker** (`mb.FAST_MESH`): ~90s vs ~6min, and the difference
|
||||||
|
does not survive the downscale to 32px. Heartbeat: `~/.jobs/not-tonight-assets.json`.
|
||||||
|
- `props_import.py` takes its sizes FROM `tools/gen/assets.py`, so sizes cannot
|
||||||
|
drift between the farm and the game. It locks `manifest.json` — parallel imports
|
||||||
|
used to lose entries to a read-modify-write race, which shows up as a prop that
|
||||||
|
is on disk but renders as a placeholder. `tools/gen/rebuild_manifest.py` repairs.
|
||||||
|
|
||||||
## Dev notes (Phase 2 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
|
- 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
|
rope/verdicts at the door; click the venue doorway to step inside; on the
|
||||||
|
|||||||
@ -5,6 +5,14 @@ fleet's asset factories. Written 2026-07-20. Code contract: everything lands
|
|||||||
behind data tables (`venueMap.PROPS` / `LIGHTS`) and a sprite drop-in convention —
|
behind data tables (`venueMap.PROPS` / `LIGHTS`) and a sprite drop-in convention —
|
||||||
generated art replaces placeholders with zero code changes.*
|
generated art replaces placeholders with zero code changes.*
|
||||||
|
|
||||||
|
> **Superseded in part, 2026-07-28 — see [VENUES.md](VENUES.md).** This document
|
||||||
|
> describes ONE venue floor, which is what the game had when it was written. The
|
||||||
|
> venue ladder now has four distinct rooms, and props/lights/posts/probes hang
|
||||||
|
> off a per-venue `FloorLayout` rather than the module singletons named below.
|
||||||
|
> The pipeline sections (§4 sprite drop-in, and the settled prop pipeline at the
|
||||||
|
> foot of this file) are still exactly right and still the house rules — only
|
||||||
|
> "the venue floor" in §2 is now specifically *Voltage's* floor.
|
||||||
|
|
||||||
## 1. Design principles
|
## 1. Design principles
|
||||||
|
|
||||||
1. **Darkness stays the mechanic.** Props read as silhouettes in ambient dark and
|
1. **Darkness stays the mechanic.** Props read as silhouettes in ambient dark and
|
||||||
|
|||||||
137
docs/VENUES.md
Normal file
137
docs/VENUES.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# NOT TONIGHT — the four venues
|
||||||
|
|
||||||
|
*Written 2026-07-28. The venue ladder in `src/data/venues.ts` has always been
|
||||||
|
four venues; up to now they shared ONE floor map and differed only by difficulty
|
||||||
|
knobs. This document is the design contract for giving each its own room.*
|
||||||
|
|
||||||
|
## 0. Why bother
|
||||||
|
|
||||||
|
Design §6 sells the ladder as *promotion* — survive a week, get a better door.
|
||||||
|
But a promotion you cannot see is a number, not a reward. Walking into Elevate
|
||||||
|
and finding The Royal's carpet under your feet undoes the fiction the whole run
|
||||||
|
is built on. Each venue now has its own bar layout, its own dunnies, its own
|
||||||
|
smoking area and its own DJ booth, because those four things are exactly what a
|
||||||
|
bouncer actually learns about a room.
|
||||||
|
|
||||||
|
## 1. Shared contract (all four)
|
||||||
|
|
||||||
|
- **Grid is fixed at 80×45 tiles.** Venues differ by ARRANGEMENT, not extent —
|
||||||
|
the camera, minimap and bounds maths all key off `MAP_W`/`MAP_H`, and a
|
||||||
|
per-venue grid size would be a refactor with no visible payoff. A small venue
|
||||||
|
reads small by walling its unused grid to `void`, not by shrinking the array.
|
||||||
|
- **Every layout exports a `FloorLayout`**: `{ id, map, props, lights, posts,
|
||||||
|
probes }`. Nothing else may be a module singleton — that was the old shape and
|
||||||
|
it is what made four venues impossible.
|
||||||
|
- **Every layout must satisfy the same invariants** (`tests/floor/layouts.test.ts`):
|
||||||
|
1. every `AnchorKind` has ≥1 anchor, and every anchor is walkable;
|
||||||
|
2. all walkable tiles are mutually reachable (flood fill), except sealed
|
||||||
|
cubicle interiors, which must be reachable from their `StallDef.door`;
|
||||||
|
3. every `POSTS` entry is walkable and has a walkable neighbour (a post on a
|
||||||
|
sealed tile freezes the player for the night — this has shipped twice);
|
||||||
|
4. every `PROBES` entry has a walkable tile within reach;
|
||||||
|
5. no prop hangs off the grid.
|
||||||
|
- **Light signatures survive** (SPACES.md §1): bar = amber, dance = pulsing
|
||||||
|
pink on the real beat, DJ = deep blue, toilets = flickering green, smoking =
|
||||||
|
cool blue, entry = venue pink. A venue may add ONE signature colour of its
|
||||||
|
own — Elevate's skyline gold, ROOM's sodium orange — and no more, or the
|
||||||
|
navigate-by-glow rule stops working.
|
||||||
|
- **Darkness stays the mechanic.** No venue may be bright enough to scan from
|
||||||
|
the doorway.
|
||||||
|
|
||||||
|
## 2. The Royal — "a dive with a dance floor"
|
||||||
|
|
||||||
|
Licensed 80 · regulars and punters · nobody checks anything.
|
||||||
|
|
||||||
|
A suburban Australian pub that had a dance floor bolted on in about 2004 and has
|
||||||
|
not redecorated since. Patterned carpet, a horseshoe public bar, and the dance
|
||||||
|
floor is plainly the least important thing in the building.
|
||||||
|
|
||||||
|
| Space | What's in it |
|
||||||
|
|---|---|
|
||||||
|
| **Public bar** (horseshoe, west) | the long counter, taps, till, keg stack, a TV showing the footy, bar fridges |
|
||||||
|
| **Pool room** (north-west) | two pool tables, cue rack, a dartboard on the wall |
|
||||||
|
| **TAB / pokies corner** (north-east) | bank of poker machines, TAB screens, stools, its own miserable light |
|
||||||
|
| **Bistro** (centre-north) | bistro tables, bain-marie counter, the meat-raffle drum |
|
||||||
|
| **Dance floor** (south-east) | small, an afterthought, one mirror ball, a jukebox on the wall |
|
||||||
|
| **DJ corner** | not a booth — a folding table with a controller, beside the dance floor |
|
||||||
|
| **Dunnies** (east) | a stainless TROUGH, two cubicles (one out of order), one basin |
|
||||||
|
| **Beer garden** (south, open sky) | picnic tables, umbrellas, patio heater, butt bin — the smoking area, and the nicest room in the pub |
|
||||||
|
|
||||||
|
Light: warm amber everywhere, green cone over each pool table, the pokies' own
|
||||||
|
sickly blue, and the beer garden cool blue. No pink except the dance corner.
|
||||||
|
|
||||||
|
## 3. Voltage — "valley institution"
|
||||||
|
|
||||||
|
Licensed 110 · the tuning baseline · the inspector drinks here.
|
||||||
|
|
||||||
|
**This is the existing map and it does not change.** It is tuned under the
|
||||||
|
darkness sheet, its reachability is asserted, and every existing floor test
|
||||||
|
targets it. It stays the default layout and the reference for the other three.
|
||||||
|
|
||||||
|
## 4. Elevate — "rooftop, small room, big opinions"
|
||||||
|
|
||||||
|
Licensed 70 · influencers and owner's mates · every second person is a VIP.
|
||||||
|
|
||||||
|
The whole venue is outdoors, which makes the rain a design element rather than a
|
||||||
|
yard-only effect. Small footprint — a third of the grid is sky. You arrive by
|
||||||
|
lift, not off the street, so the entry is a lobby.
|
||||||
|
|
||||||
|
| Space | What's in it |
|
||||||
|
|---|---|
|
||||||
|
| **Lift lobby** (west) | lift doors, a rope line, the stamp podium, one mirror |
|
||||||
|
| **Cocktail bar** (north) | short backlit bar, no taps — bottles and a champagne bucket |
|
||||||
|
| **VIP booths** (east) | banquettes behind velvet rope posts, low tables |
|
||||||
|
| **Dance floor** (centre, small) | it is a terrace with a dance floor, not a club |
|
||||||
|
| **DJ plinth** | raised, open, no booth walls — the DJ is on display |
|
||||||
|
| **Toilets** (north-east) | two unisex cubicles, marble basin, enormous mirror |
|
||||||
|
| **Terrace edge** (south + east) | glass balustrade, planters, the city skyline beyond |
|
||||||
|
| **Smoking** | the whole roof is open air; a designated corner with a standing ash urn and heat lamps |
|
||||||
|
|
||||||
|
Light: warm amber bar, cool blue-white terrace, deep blue DJ, and the venue's
|
||||||
|
own signature — **skyline gold** spilling from the city card beyond the glass.
|
||||||
|
|
||||||
|
## 5. ROOM — "no sign, no socials"
|
||||||
|
|
||||||
|
Licensed 60 · heads and chancers · the board is the law.
|
||||||
|
|
||||||
|
A concrete warehouse. No branding anywhere, because branding is how you get
|
||||||
|
found. The sound system is the most expensive thing in the building by an order
|
||||||
|
of magnitude and everything else is borrowed.
|
||||||
|
|
||||||
|
| Space | What's in it |
|
||||||
|
|---|---|
|
||||||
|
| **Main floor** | concrete pillars, no booths, the crowd is the furniture |
|
||||||
|
| **The stacks** | Funktion-One-style speaker towers + subs — huge, four corners |
|
||||||
|
| **DJ booth** | low, central, facing the floor, no barrier — CDJs not turntables |
|
||||||
|
| **Bar** (west) | a plywood plank on scaffolding, cans only, no taps, no till |
|
||||||
|
| **Toilets** (north-east) | industrial, graffiti on every surface, three cubicles of which one works, a cracked basin, no mirror |
|
||||||
|
| **Loading dock** (south, open sky) | roller door, pallets, a wheelie bin — this is the smoking area |
|
||||||
|
| **Chill-out** (north-west) | two ruined couches under a single bulb |
|
||||||
|
| **The board** | the rule board that rewrites itself (`shuffleDressCode`) — a physical prop by the entry |
|
||||||
|
|
||||||
|
Light: almost none. Sodium orange over the loading dock (ROOM's signature),
|
||||||
|
one flickering green fluoro in the dunnies, strobes on the floor, and the
|
||||||
|
stacks' own power LEDs. This is the darkest venue in the game on purpose.
|
||||||
|
|
||||||
|
## 6. Prop vocabulary added for these rooms
|
||||||
|
|
||||||
|
Route per SPACES.md §"Prop pipeline": **[F]** = flux direct (flat things —
|
||||||
|
signage, boards, screens, backdrops); **[M]** = product shot → TRELLIS.2 →
|
||||||
|
Blender ortho render (things with volume). Rake follows the house rule —
|
||||||
|
**60° low and wide, ~76° tall**.
|
||||||
|
|
||||||
|
| Venue | Kinds |
|
||||||
|
|---|---|
|
||||||
|
| The Royal | `pokie`[M76] `tabBoard`[F] `bistroTable`[M60] `picnicTable`[M60] `trough`[M70] `keg`[M76] `dartboard`[F] `jukebox`[M76] `raffleDrum`[M60] `bainMarie`[M60] `tvSport`[F] `beerUmbrella`[M80] `ashtray`[M60] |
|
||||||
|
| Elevate | `cocktailBar`[M60] `banquette`[M60] `ropePost`[M76] `planter`[M60] `balustrade`[M76] `djPlinth`[M60] `marbleSink`[M70] `ashUrn`[M76] `champBucket`[M60] `loungeChair`[M60] `liftDoor`[F] `skylineCard`[F] |
|
||||||
|
| ROOM | `stackF1`[M76] `subBass`[M60] `plywoodBar`[M60] `rollerDoor`[F] `pallet`[M60] `wheelieBin`[M76] `oldCouch`[M60] `pillar`[M76] `ruleBoard`[F] `strobe`[M76] `smokeMachine`[M60] `cableSnake`[M60] `brokenBasin`[M70] `canStack`[M60] `cdj`[M60] |
|
||||||
|
| Staff (set dressing, all venues) | `staffBartender` `staffDj` `staffGlassie` `staffSecurity` — all [M76] |
|
||||||
|
|
||||||
|
Staff figures are PROPS, not patrons. The procedural dolls stay exactly as they
|
||||||
|
are: `dollPlan` draws the specific items the dress code convicts on, so
|
||||||
|
replacing patrons with pre-rendered art would make the convictions invisible
|
||||||
|
(recorded in LANEHANDOVER, FABLE-SOLO-23). A bartender who never moves is set
|
||||||
|
dressing and carries no mechanical weight, which is why she can be a sprite.
|
||||||
|
|
||||||
|
Street plates (door scene, one per venue, [F], 640×176):
|
||||||
|
`street:royal` `street:elevate` `street:room` — `street:backdrop` stays Voltage's.
|
||||||
BIN
public/props/dartboard.png
Normal file
BIN
public/props/dartboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 592 B |
BIN
public/props/jukebox.png
Normal file
BIN
public/props/jukebox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/props/keg.png
Normal file
BIN
public/props/keg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 668 B |
@ -11,6 +11,7 @@
|
|||||||
"crate",
|
"crate",
|
||||||
"cubicleDoor",
|
"cubicleDoor",
|
||||||
"cueRack",
|
"cueRack",
|
||||||
|
"dartboard",
|
||||||
"deck",
|
"deck",
|
||||||
"discoball",
|
"discoball",
|
||||||
"dishwasher",
|
"dishwasher",
|
||||||
@ -19,9 +20,12 @@
|
|||||||
"handDryer",
|
"handDryer",
|
||||||
"highTable",
|
"highTable",
|
||||||
"iceWell",
|
"iceWell",
|
||||||
|
"jukebox",
|
||||||
|
"keg",
|
||||||
"mixer",
|
"mixer",
|
||||||
"monitor",
|
"monitor",
|
||||||
"mopBucket",
|
"mopBucket",
|
||||||
|
"pallet",
|
||||||
"patioHeater",
|
"patioHeater",
|
||||||
"plant",
|
"plant",
|
||||||
"poolTable",
|
"poolTable",
|
||||||
@ -34,6 +38,7 @@
|
|||||||
"street_backdrop",
|
"street_backdrop",
|
||||||
"taps",
|
"taps",
|
||||||
"till",
|
"till",
|
||||||
|
"trough",
|
||||||
"truss",
|
"truss",
|
||||||
"urinal",
|
"urinal",
|
||||||
"wetFloor"
|
"wetFloor"
|
||||||
|
|||||||
BIN
public/props/pallet.png
Normal file
BIN
public/props/pallet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 888 B |
BIN
public/props/trough.png
Normal file
BIN
public/props/trough.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 767 B |
17
src/main.ts
17
src/main.ts
@ -53,30 +53,39 @@ const game = new Phaser.Game({
|
|||||||
const ROUTES = { night: 'Night', parade: 'Parade', floor: 'FloorDemo', juice: 'JuiceDemo' } as const;
|
const ROUTES = { night: 'Night', parade: 'Parade', floor: 'FloorDemo', juice: 'JuiceDemo' } as const;
|
||||||
type RouteKey = keyof typeof ROUTES;
|
type RouteKey = keyof typeof ROUTES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `#floor:room` boots the floor demo straight into one venue's layout
|
||||||
|
* (docs/VENUES.md). Without this the demo route always builds Voltage, and the
|
||||||
|
* only way to see The Royal or ROOM would be to survive one or three weeks of
|
||||||
|
* the ladder — which is a fine thing to ask of a player and a terrible thing to
|
||||||
|
* ask of whoever is checking whether the room renders.
|
||||||
|
*/
|
||||||
|
const [hashRoute, hashVenue] = window.location.hash.slice(1).split(':');
|
||||||
|
|
||||||
// Only ROOT scenes are routed. Door, Floor and NightSummary belong to Night and
|
// Only ROOT scenes are routed. Door, Floor and NightSummary belong to Night and
|
||||||
// go down with its teardown; stopping them from here used to leave a live Night
|
// go down with its teardown; stopping them from here used to leave a live Night
|
||||||
// driving a dead Door — a black screen with no way back, because the guard
|
// driving a dead Door — a black screen with no way back, because the guard
|
||||||
// below then declined to restart the already-active Night.
|
// below then declined to restart the already-active Night.
|
||||||
const ROOT_SCENES = ['Roster', 'Night', 'Parade', 'FloorDemo', 'JuiceDemo'] as const;
|
const ROOT_SCENES = ['Roster', 'Night', 'Parade', 'FloorDemo', 'JuiceDemo'] as const;
|
||||||
|
|
||||||
const showScene = (target: (typeof ROUTES)[RouteKey]): void => {
|
const showScene = (target: (typeof ROUTES)[RouteKey], data?: object): void => {
|
||||||
for (const key of ROOT_SCENES) {
|
for (const key of ROOT_SCENES) {
|
||||||
if (key !== target && game.scene.isActive(key)) game.scene.stop(key);
|
if (key !== target && game.scene.isActive(key)) game.scene.stop(key);
|
||||||
}
|
}
|
||||||
// Pressing a route key for what is already running means "start it over".
|
// Pressing a route key for what is already running means "start it over".
|
||||||
// It is the only way to get a fresh night out of N mid-shift.
|
// It is the only way to get a fresh night out of N mid-shift.
|
||||||
if (game.scene.isActive(target)) game.scene.stop(target);
|
if (game.scene.isActive(target)) game.scene.stop(target);
|
||||||
game.scene.start(target);
|
game.scene.start(target, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
// RosterScene is scene[0] and auto-starts. Hash routing must wait until it is
|
// RosterScene is scene[0] and auto-starts. Hash routing must wait until it is
|
||||||
// actually up before swapping, or both scenes end up running on top of each other.
|
// actually up before swapping, or both scenes end up running on top of each other.
|
||||||
const hash = window.location.hash.slice(1) as RouteKey;
|
const hash = hashRoute as RouteKey;
|
||||||
if (hash in ROUTES) {
|
if (hash in ROUTES) {
|
||||||
const route = (): void => {
|
const route = (): void => {
|
||||||
if (!game.scene.isActive('Roster')) return;
|
if (!game.scene.isActive('Roster')) return;
|
||||||
game.events.off(Phaser.Core.Events.POST_STEP, route);
|
game.events.off(Phaser.Core.Events.POST_STEP, route);
|
||||||
showScene(ROUTES[hash]);
|
showScene(ROUTES[hash], hashVenue ? { venueId: hashVenue } : undefined);
|
||||||
};
|
};
|
||||||
game.events.on(Phaser.Core.Events.POST_STEP, route);
|
game.events.on(Phaser.Core.Events.POST_STEP, route);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -258,11 +258,26 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ---------- construction ----------
|
// ---------- construction ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plate behind the rope, chosen by which venue you are working tonight
|
||||||
|
* (docs/VENUES.md). The ladder's whole promise is that a promotion is visible,
|
||||||
|
* and the street is the first thing you see of a new venue — The Royal is a
|
||||||
|
* suburban pub frontage, Elevate is a lift lobby because you arrive by lift,
|
||||||
|
* ROOM is an unmarked wall with no signage at all.
|
||||||
|
*
|
||||||
|
* Falls back through Voltage's plate to the flat wall-of-night rect, so a
|
||||||
|
* venue whose art has not landed yet still opens for business.
|
||||||
|
*/
|
||||||
private buildStreet(): void {
|
private buildStreet(): void {
|
||||||
if (this.textures.exists('gen:prop:street_backdrop')) {
|
// Keyed on the venue id EXACTLY (tools/gen/assets.py generates
|
||||||
// The generated plate (SPACES §5 item 11): brick, posters, grime. Sits
|
// `street:<venueId>`, and props_import writes ':' as '_'). Not lowercased:
|
||||||
// where the flat wall-of-night rect went; everything else layers on top.
|
// 'theRoyal' is the id, and a case fold would look for a plate nobody made.
|
||||||
this.add.image(0, 0, 'gen:prop:street_backdrop').setOrigin(0, 0).setDisplaySize(W, 176).setAlpha(0.9);
|
const plate = [
|
||||||
|
`gen:prop:street_${this.night.venue.id}`,
|
||||||
|
'gen:prop:street_backdrop',
|
||||||
|
].find((k) => this.textures.exists(k));
|
||||||
|
if (plate) {
|
||||||
|
this.add.image(0, 0, plate).setOrigin(0, 0).setDisplaySize(W, 176).setAlpha(0.9);
|
||||||
} else {
|
} else {
|
||||||
this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night
|
this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import { generatePatron, _resetPatronSerial } from '../../patrons/generator';
|
|||||||
import type { DrunkStage, NightState } from '../../data/types';
|
import type { DrunkStage, NightState } from '../../data/types';
|
||||||
import { HudStub } from '../shared/HudStub';
|
import { HudStub } from '../shared/HudStub';
|
||||||
|
|
||||||
import { VENUE } from './venueMap';
|
import { MAP_H, MAP_W, type FloorLayout, type WorldPoint } from './venueMap';
|
||||||
|
import { DEFAULT_LAYOUT, layoutFor } from './layouts';
|
||||||
import { NORMAL_CONE, UV_CONE, inCone, type ConeSpec } from './cone';
|
import { NORMAL_CONE, UV_CONE, inCone, type ConeSpec } from './cone';
|
||||||
import { CrowdSim, type Agent } from './crowdSim';
|
import { CrowdSim, type Agent } from './crowdSim';
|
||||||
import { FightDirector } from './fightDirector';
|
import { FightDirector } from './fightDirector';
|
||||||
@ -69,28 +70,50 @@ 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. */
|
/** Admitted at the rope → appears on the floor after the walk in. */
|
||||||
const WALK_IN_MS: [number, number] = [6000, 14000];
|
const WALK_IN_MS: [number, number] = [6000, 14000];
|
||||||
/** Where a full rack of empties appears (bar's east end) and where it goes. */
|
|
||||||
const RACK_SPOT = tileToWorld(51, 7);
|
|
||||||
const DISH_SPOT = tileToWorld(17, 3);
|
|
||||||
/** The sinks (venueMap rect 66..71,15): where a cup of water comes from. */
|
|
||||||
const WATER_SPOT = tileToWorld(68, 15);
|
|
||||||
/** The booth (venueMap rect 52..58,19..25): where you hold the decks. */
|
|
||||||
/**
|
/**
|
||||||
* Where you stand to work the decks: the booth's WEST COUNTER, not its inside.
|
* The playable stations, resolved from the LAYOUT rather than hardcoded.
|
||||||
* venueMap seals the booth interior (`djbooth`/`djfloor` are not in WALKABLE) —
|
*
|
||||||
* it was built as a stage back when the DJ was set dressing. Standing the player
|
* These were six module constants pointing at Voltage's tile coordinates, which
|
||||||
* on (55,22) put them inside that seal: every direction blocked, for the rest of
|
* was fine while there was one room. With four, a hardcoded (47,3) puts the bar
|
||||||
* the night, and the `dj` roster role posts you there automatically. The counter
|
* shift inside The Royal's pool room — so each layout names its own stations in
|
||||||
* is where the map comment always said the gear faces from.
|
* `posts` (where a role PLANTS you: must be walkable) and `probes` (what you
|
||||||
|
* walk UP TO: may sit inside furniture), and the scene reads them from there.
|
||||||
|
* `tests/floor/layoutInvariants.ts` asserts both for every venue, because a post
|
||||||
|
* on a sealed tile is a night spent frozen in place — which has shipped twice.
|
||||||
*/
|
*/
|
||||||
const DECKS_SPOT = tileToWorld(51, 22);
|
interface Stations {
|
||||||
/** Behind the till (venueMap prop 46..47,4): where you hold the taps. */
|
/** Where a full rack of empties appears, and where it goes. */
|
||||||
const TAPS_SPOT = tileToWorld(47, 3);
|
rack: WorldPoint;
|
||||||
/**
|
dish: WorldPoint;
|
||||||
* The customer side of the till — where E offers you the apron. Row 7, not row
|
/** Where a cup of water comes from (RSA duty). */
|
||||||
* 6: row 6 is the `stool` rect, and `stool` is not in venueMap's WALKABLE set.
|
water: WorldPoint;
|
||||||
*/
|
/** Where you STAND to work the decks — never inside a sealed booth. */
|
||||||
const TAPS_FRONT = tileToWorld(47, 7);
|
decks: WorldPoint;
|
||||||
|
/** Behind the till: where you hold the taps. */
|
||||||
|
taps: WorldPoint;
|
||||||
|
/** The customer side of the till, where E offers you the apron. */
|
||||||
|
tapsFront: WorldPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationsFor(layout: FloorLayout): Stations {
|
||||||
|
const post = (name: string, fallback: WorldPoint): WorldPoint => {
|
||||||
|
const p = layout.posts[name];
|
||||||
|
return p ? tileToWorld(p.tx, p.ty) : fallback;
|
||||||
|
};
|
||||||
|
const probe = (name: string, fallback: WorldPoint): WorldPoint => {
|
||||||
|
const p = layout.probes[name];
|
||||||
|
return p ? tileToWorld(p.tx, p.ty) : fallback;
|
||||||
|
};
|
||||||
|
const centre = tileToWorld(Math.floor(MAP_W / 2), Math.floor(MAP_H / 2));
|
||||||
|
return {
|
||||||
|
rack: probe('rack', centre),
|
||||||
|
dish: probe('dishwasher', centre),
|
||||||
|
water: probe('water', centre),
|
||||||
|
decks: post('decks', centre),
|
||||||
|
taps: post('taps', centre),
|
||||||
|
tapsFront: probe('tapsFront', centre),
|
||||||
|
};
|
||||||
|
}
|
||||||
/** Crowd contact distance while carrying, and its debounce. */
|
/** Crowd contact distance while carrying, and its debounce. */
|
||||||
const CARRY_BUMP_PX = 14;
|
const CARRY_BUMP_PX = 14;
|
||||||
const CARRY_BUMP_COOLDOWN_MS = 350;
|
const CARRY_BUMP_COOLDOWN_MS = 350;
|
||||||
@ -131,6 +154,9 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
private night!: NightState;
|
private night!: NightState;
|
||||||
private hud!: HudStub | MeterHud;
|
private hud!: HudStub | MeterHud;
|
||||||
private nightCtx: NightContext | null = null;
|
private nightCtx: NightContext | null = null;
|
||||||
|
/** Tonight's room, and the stations it puts the playable shifts in. */
|
||||||
|
private layout: FloorLayout = DEFAULT_LAYOUT;
|
||||||
|
private spots: Stations = stationsFor(DEFAULT_LAYOUT);
|
||||||
/** In night mode: is the player physically on the floor right now? */
|
/** In night mode: is the player physically on the floor right now? */
|
||||||
private present = true;
|
private present = true;
|
||||||
private elapsedMs = 0;
|
private elapsedMs = 0;
|
||||||
@ -214,13 +240,20 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
super(key);
|
super(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
create(data?: { night?: NightContext }): void {
|
create(data?: { night?: NightContext; venueId?: string }): void {
|
||||||
this.nightCtx = data?.night ?? null;
|
this.nightCtx = data?.night ?? null;
|
||||||
|
// Which ROOM tonight happens in (docs/VENUES.md). Resolved here rather than
|
||||||
|
// in a field initialiser because Phaser reuses the scene instance across
|
||||||
|
// restarts, so a field initialiser would pin week one's venue for the whole
|
||||||
|
// run — the same trap resetNightState() exists to close.
|
||||||
|
// `venueId` is the dev route (`#floor:room`) and loses to a real night.
|
||||||
|
this.layout = layoutFor(this.nightCtx?.venue.id ?? data?.venueId);
|
||||||
|
this.spots = stationsFor(this.layout);
|
||||||
this.resetNightState();
|
this.resetNightState();
|
||||||
// Wired explicitly: Phaser does not invoke a `shutdown` METHOD on a Scene
|
// Wired explicitly: Phaser does not invoke a `shutdown` METHOD on a Scene
|
||||||
// subclass; without this, listeners and sprites leak on every scene stop.
|
// subclass; without this, listeners and sprites leak on every scene stop.
|
||||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.teardown());
|
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.teardown());
|
||||||
this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16);
|
this.cameras.main.setBounds(0, 0, this.layout.map.width * 16, this.layout.map.height * 16);
|
||||||
this.cameras.main.setBackgroundColor('#04040a');
|
this.cameras.main.setBackgroundColor('#04040a');
|
||||||
|
|
||||||
// Generated prop art loads FIRST (public/props + manifest — docs/SPACES.md
|
// Generated prop art loads FIRST (public/props + manifest — docs/SPACES.md
|
||||||
@ -286,7 +319,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildWorld(): void {
|
private buildWorld(): void {
|
||||||
this.view = new FloorView(this, VENUE);
|
this.view = new FloorView(this, this.layout);
|
||||||
this.cutOff = new CutOffOverlay(this);
|
this.cutOff = new CutOffOverlay(this);
|
||||||
this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null);
|
this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null);
|
||||||
this.choice = new ChoiceOverlay(this);
|
this.choice = new ChoiceOverlay(this);
|
||||||
@ -301,7 +334,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
? faintRng.int(FAINT_WINDOW[0], FAINT_WINDOW[1])
|
? faintRng.int(FAINT_WINDOW[0], FAINT_WINDOW[1])
|
||||||
: null;
|
: null;
|
||||||
this.rackMarker = this.add
|
this.rackMarker = this.add
|
||||||
.image(RACK_SPOT.x, RACK_SPOT.y, 'floor:glow')
|
.image(this.spots.rack.x, this.spots.rack.y, 'floor:glow')
|
||||||
.setDepth(17)
|
.setDepth(17)
|
||||||
.setBlendMode(Phaser.BlendModes.ADD)
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
.setTint(0xd8c23a)
|
.setTint(0xd8c23a)
|
||||||
@ -363,11 +396,11 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.beatMs = ctx.beatIntervalMs;
|
this.beatMs = ctx.beatIntervalMs;
|
||||||
this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs, ctx.sfx);
|
this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs, ctx.sfx);
|
||||||
|
|
||||||
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
|
this.crowd = new CrowdSim({ map: this.layout.map, bus: this.bus, rng: this.rng.stream('crowd') });
|
||||||
this.fights?.destroy();
|
this.fights?.destroy();
|
||||||
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
|
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
|
||||||
|
|
||||||
const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
|
const entry = this.layout.map.anchors.entry[0] ?? { x: 64, y: 64 };
|
||||||
this.player = freshPlayer(entry.x, entry.y);
|
this.player = freshPlayer(entry.x, entry.y);
|
||||||
|
|
||||||
this.view.reset();
|
this.view.reset();
|
||||||
@ -449,7 +482,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.beatMs = this.beat.beatIntervalMs;
|
this.beatMs = this.beat.beatIntervalMs;
|
||||||
this.stall = new StallOverlay(this, this.bus, this.beat.beatIntervalMs);
|
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.crowd = new CrowdSim({ map: this.layout.map, bus: this.bus, rng: this.rng.stream('crowd') });
|
||||||
this.fights?.destroy();
|
this.fights?.destroy();
|
||||||
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
|
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
|
||||||
this.bus.on('beat:tick', ({ beatIndex }) => {
|
this.bus.on('beat:tick', ({ beatIndex }) => {
|
||||||
@ -458,7 +491,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.view.onBeat(beatIndex);
|
this.view.onBeat(beatIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
|
const entry = this.layout.map.anchors.entry[0] ?? { x: 64, y: 64 };
|
||||||
this.player = freshPlayer(entry.x, entry.y);
|
this.player = freshPlayer(entry.x, entry.y);
|
||||||
|
|
||||||
this.view.reset();
|
this.view.reset();
|
||||||
@ -631,7 +664,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Escorting costs you the walk: no interacting, slower going.
|
// Escorting costs you the walk: no interacting, slower going.
|
||||||
const input = this.readInput();
|
const input = this.readInput();
|
||||||
this.player = this.djMode || this.barMode ? this.player : stepPlayer(this.player, input, dt, VENUE);
|
this.player = this.djMode || this.barMode ? this.player : stepPlayer(this.player, input, dt, this.layout.map);
|
||||||
|
|
||||||
if (!this.nightCtx) {
|
if (!this.nightCtx) {
|
||||||
// Demo mode stands in for the door with a synthetic spawner.
|
// Demo mode stands in for the door with a synthetic spawner.
|
||||||
@ -772,14 +805,14 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
// Hands full: the only thing in the world is the dishwasher. Everything
|
// Hands full: the only thing in the world is the dishwasher. Everything
|
||||||
// else (including the door home) waits until the rack is down.
|
// else (including the door home) waits until the rack is down.
|
||||||
if (this.rack) {
|
if (this.rack) {
|
||||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, DISH_SPOT.x, DISH_SPOT.y);
|
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, this.spots.dish.x, this.spots.dish.y);
|
||||||
if (d <= INTERACT_RANGE) return { kind: 'dishwasher', prompt: 'E — rack into the dishwasher' };
|
if (d <= INTERACT_RANGE) return { kind: 'dishwasher', prompt: 'E — rack into the dishwasher' };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A full rack waiting at the bar's east end.
|
// A full rack waiting at the bar's east end.
|
||||||
if (this.clock.clockMin >= this.rackReadyAtMin) {
|
if (this.clock.clockMin >= this.rackReadyAtMin) {
|
||||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, RACK_SPOT.x, RACK_SPOT.y);
|
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, this.spots.rack.x, this.spots.rack.y);
|
||||||
if (d <= INTERACT_RANGE) {
|
if (d <= INTERACT_RANGE) {
|
||||||
return { kind: 'rack', prompt: 'E — take the rack through to the kitchen' };
|
return { kind: 'rack', prompt: 'E — take the rack through to the kitchen' };
|
||||||
}
|
}
|
||||||
@ -787,7 +820,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Night mode: the way back out to the rope is itself an interaction.
|
// Night mode: the way back out to the rope is itself an interaction.
|
||||||
if (this.nightCtx) {
|
if (this.nightCtx) {
|
||||||
const entry = VENUE.anchors.entry[0];
|
const entry = this.layout.map.anchors.entry[0];
|
||||||
if (entry) {
|
if (entry) {
|
||||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y);
|
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' };
|
if (d <= INTERACT_RANGE) return { kind: 'exit', prompt: 'E — back out to the door' };
|
||||||
@ -811,7 +844,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
// The water station (RSA duty): a cup from the sinks, carried to someone
|
// The water station (RSA duty): a cup from the sinks, carried to someone
|
||||||
// who needs it. One cup at a time; the walk is the cost.
|
// who needs it. One cup at a time; the walk is the cost.
|
||||||
if (!this.carryingCup) {
|
if (!this.carryingCup) {
|
||||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, WATER_SPOT.x, WATER_SPOT.y);
|
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, this.spots.water.x, this.spots.water.y);
|
||||||
if (d <= INTERACT_RANGE) return { kind: 'water', prompt: WATER_UI.pickup };
|
if (d <= INTERACT_RANGE) return { kind: 'water', prompt: WATER_UI.pickup };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -821,18 +854,18 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// The booth: hold the decks. Blocked while your hands are otherwise full.
|
// The booth: hold the decks. Blocked while your hands are otherwise full.
|
||||||
if (!this.carryingCup) {
|
if (!this.carryingCup) {
|
||||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, DECKS_SPOT.x, DECKS_SPOT.y);
|
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, this.spots.decks.x, this.spots.decks.y);
|
||||||
if (d <= INTERACT_RANGE) return { kind: 'decks', prompt: DJ_UI.enter };
|
if (d <= INTERACT_RANGE) return { kind: 'decks', prompt: DJ_UI.enter };
|
||||||
}
|
}
|
||||||
|
|
||||||
// The till: hold the taps. Same both-hands rule as the decks.
|
// The till: hold the taps. Same both-hands rule as the decks.
|
||||||
if (!this.carryingCup && !this.rack) {
|
if (!this.carryingCup && !this.rack) {
|
||||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, TAPS_FRONT.x, TAPS_FRONT.y);
|
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, this.spots.tapsFront.x, this.spots.tapsFront.y);
|
||||||
if (d <= INTERACT_RANGE) return { kind: 'taps', prompt: BAR_UI.enter };
|
if (d <= INTERACT_RANGE) return { kind: 'taps', prompt: BAR_UI.enter };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 this.layout.map.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);
|
||||||
if (d > INTERACT_RANGE) continue;
|
if (d > INTERACT_RANGE) continue;
|
||||||
const occupants = this.crowd.stallOccupancy().get(s.id) ?? [];
|
const occupants = this.crowd.stallOccupancy().get(s.id) ?? [];
|
||||||
@ -1232,8 +1265,8 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
private toggleDecks(): void {
|
private toggleDecks(): void {
|
||||||
if (!this.djMode) {
|
if (!this.djMode) {
|
||||||
this.djMode = true;
|
this.djMode = true;
|
||||||
this.player.x = DECKS_SPOT.x;
|
this.player.x = this.spots.decks.x;
|
||||||
this.player.y = DECKS_SPOT.y;
|
this.player.y = this.spots.decks.y;
|
||||||
this.toast(DJ_UI.handover);
|
this.toast(DJ_UI.handover);
|
||||||
this.promptText.setText(DJ_UI.help);
|
this.promptText.setText(DJ_UI.help);
|
||||||
this.logIncident('djShift', undefined, 'Held the decks while the resident stepped out.');
|
this.logIncident('djShift', undefined, 'Held the decks while the resident stepped out.');
|
||||||
@ -1291,8 +1324,8 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
private toggleTaps(): void {
|
private toggleTaps(): void {
|
||||||
if (!this.barMode) {
|
if (!this.barMode) {
|
||||||
this.barMode = true;
|
this.barMode = true;
|
||||||
this.player.x = TAPS_SPOT.x;
|
this.player.x = this.spots.taps.x;
|
||||||
this.player.y = TAPS_SPOT.y;
|
this.player.y = this.spots.taps.y;
|
||||||
this.toast(BAR_UI.handover);
|
this.toast(BAR_UI.handover);
|
||||||
this.promptText.setText(BAR_UI.help);
|
this.promptText.setText(BAR_UI.help);
|
||||||
this.logIncident('barShift', undefined, 'Held the taps while the bartender stepped out.');
|
this.logIncident('barShift', undefined, 'Held the taps while the bartender stepped out.');
|
||||||
@ -1485,6 +1518,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
return {
|
return {
|
||||||
crowd: () => this.crowd,
|
crowd: () => this.crowd,
|
||||||
view: () => this.view,
|
view: () => this.view,
|
||||||
|
map: () => this.layout.map,
|
||||||
getPlayer: () => this.player,
|
getPlayer: () => this.player,
|
||||||
setPlayer: (p) => { this.player = p; },
|
setPlayer: (p) => { this.player = p; },
|
||||||
readInput: () => this.readInput(),
|
readInput: () => this.readInput(),
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { renderDoll } from '../../patrons/doll';
|
|||||||
import { dollPlan } from '../../patrons/dollPlan';
|
import { dollPlan } from '../../patrons/dollPlan';
|
||||||
import { conePolygon, type ConeSpec } from './cone';
|
import { conePolygon, type ConeSpec } from './cone';
|
||||||
import { FIGHT_FUSE_MS } from './fight';
|
import { FIGHT_FUSE_MS } from './fight';
|
||||||
import { LIGHTS, PROPS, TILE, tileAt, tileColour, type PropKind, type VenueMap } from './venueMap';
|
import { TILE, tileAt, tileColour, type FloorLayout, type PropDef, type PropKind, type VenueMap, type ZoneLight } from './venueMap';
|
||||||
import type { Agent } from './crowdSim';
|
import type { Agent } from './crowdSim';
|
||||||
|
|
||||||
// Pure drawing for the floor. Darkness is the mechanic: the venue is painted
|
// Pure drawing for the floor. Darkness is the mechanic: the venue is painted
|
||||||
@ -65,6 +65,9 @@ interface AgentGfx {
|
|||||||
export class FloorView {
|
export class FloorView {
|
||||||
private readonly scene: Phaser.Scene;
|
private readonly scene: Phaser.Scene;
|
||||||
private readonly map: VenueMap;
|
private readonly map: VenueMap;
|
||||||
|
private readonly props: readonly PropDef[];
|
||||||
|
private readonly lights: readonly ZoneLight[];
|
||||||
|
private readonly palette: FloorLayout['palette'];
|
||||||
|
|
||||||
private readonly tileImg: Phaser.GameObjects.Image;
|
private readonly tileImg: Phaser.GameObjects.Image;
|
||||||
private readonly neon: Phaser.GameObjects.Image[] = [];
|
private readonly neon: Phaser.GameObjects.Image[] = [];
|
||||||
@ -100,9 +103,17 @@ export class FloorView {
|
|||||||
private frame = 0;
|
private frame = 0;
|
||||||
private uv = false;
|
private uv = false;
|
||||||
|
|
||||||
constructor(scene: Phaser.Scene, map: VenueMap) {
|
constructor(scene: Phaser.Scene, layout: FloorLayout) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
|
const map = layout.map;
|
||||||
this.map = map;
|
this.map = map;
|
||||||
|
// Props, lights and materials come off the LAYOUT, not module singletons.
|
||||||
|
// That is the whole reason the venue ladder can have four different rooms:
|
||||||
|
// the old shape imported one PROPS and one LIGHTS, so every venue in the
|
||||||
|
// ladder was the same room with different difficulty numbers on it.
|
||||||
|
this.props = layout.props;
|
||||||
|
this.lights = layout.lights;
|
||||||
|
this.palette = layout.palette;
|
||||||
const worldW = map.width * TILE;
|
const worldW = map.width * TILE;
|
||||||
const worldH = map.height * TILE;
|
const worldH = map.height * TILE;
|
||||||
|
|
||||||
@ -183,7 +194,7 @@ export class FloorView {
|
|||||||
* read as silhouettes until the torch finds them.
|
* read as silhouettes until the torch finds them.
|
||||||
*/
|
*/
|
||||||
private buildProps(): void {
|
private buildProps(): void {
|
||||||
for (const p of PROPS) {
|
for (const p of this.props) {
|
||||||
const gen = `gen:prop:${p.kind}`;
|
const gen = `gen:prop:${p.kind}`;
|
||||||
const key = this.scene.textures.exists(gen) ? gen : bakePropPlaceholder(this.scene, p.kind);
|
const key = this.scene.textures.exists(gen) ? gen : bakePropPlaceholder(this.scene, p.kind);
|
||||||
const img = this.scene.add
|
const img = this.scene.add
|
||||||
@ -198,7 +209,7 @@ export class FloorView {
|
|||||||
|
|
||||||
/** Zone light signatures — navigate by glow colour (docs/SPACES.md §1). */
|
/** Zone light signatures — navigate by glow colour (docs/SPACES.md §1). */
|
||||||
private buildLights(): void {
|
private buildLights(): void {
|
||||||
for (const l of LIGHTS) this.addGlow(l, l.colour, l.radius, l.pulse);
|
for (const l of this.lights) this.addGlow(l, l.colour, l.radius, l.pulse);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addGlow(
|
private addGlow(
|
||||||
@ -585,7 +596,7 @@ export class FloorView {
|
|||||||
const g = this.scene.make.graphics({}, false);
|
const g = this.scene.make.graphics({}, false);
|
||||||
for (let ty = 0; ty < this.map.height; ty++) {
|
for (let ty = 0; ty < this.map.height; ty++) {
|
||||||
for (let tx = 0; tx < this.map.width; tx++) {
|
for (let tx = 0; tx < this.map.width; tx++) {
|
||||||
g.fillStyle(tileColour(tileAt(this.map, tx, ty)), 1);
|
g.fillStyle(tileColour(tileAt(this.map, tx, ty), this.palette), 1);
|
||||||
g.fillRect(tx * TILE, ty * TILE, TILE, TILE);
|
g.fillRect(tx * TILE, ty * TILE, TILE, TILE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -644,6 +655,26 @@ const PROP_SIZES: Record<PropKind, [number, number]> = {
|
|||||||
highTable: [16, 16], plant: [16, 16], wetFloor: [16, 16], poolTable: [48, 32],
|
highTable: [16, 16], plant: [16, 16], wetFloor: [16, 16], poolTable: [48, 32],
|
||||||
dishwasher: [32, 16],
|
dishwasher: [32, 16],
|
||||||
urinal: [48, 16], handDryer: [16, 16], cubicleDoor: [16, 16], mopBucket: [16, 16], cueRack: [16, 16], barFridge: [32, 32], iceWell: [32, 16], patioHeater: [16, 32], truss: [48, 16],
|
urinal: [48, 16], handDryer: [16, 16], cubicleDoor: [16, 16], mopBucket: [16, 16], cueRack: [16, 16], barFridge: [32, 32], iceWell: [32, 16], patioHeater: [16, 32], truss: [48, 16],
|
||||||
|
// ---- the venue expansion (docs/VENUES.md §6) --------------------------------
|
||||||
|
// These MUST match tools/gen/assets.py, which is what the farm actually
|
||||||
|
// renders to; props_import.py reads its targets from that same manifest, so a
|
||||||
|
// disagreement here ships a sprite at the wrong scale rather than erroring.
|
||||||
|
// The Royal
|
||||||
|
pokie: [16, 32], bistroTable: [16, 16], picnicTable: [32, 16], trough: [48, 16],
|
||||||
|
keg: [16, 16], jukebox: [16, 32], raffleDrum: [16, 16], bainMarie: [48, 16],
|
||||||
|
beerUmbrella: [32, 32], ashtray: [16, 16], tabBoard: [48, 16], dartboard: [16, 16],
|
||||||
|
tvSport: [32, 16],
|
||||||
|
// Elevate
|
||||||
|
cocktailBar: [96, 16], banquette: [32, 32], ropePost: [16, 16], planter: [32, 16],
|
||||||
|
balustrade: [64, 16], djPlinth: [48, 32], marbleSink: [48, 16], ashUrn: [16, 16],
|
||||||
|
champBucket: [16, 16], loungeChair: [16, 16], liftDoor: [32, 32], skylineCard: [128, 32],
|
||||||
|
// ROOM
|
||||||
|
stackF1: [32, 48], subBass: [32, 16], plywoodBar: [128, 16], pallet: [32, 16],
|
||||||
|
wheelieBin: [16, 16], oldCouch: [48, 16], pillar: [16, 16], strobe: [16, 16],
|
||||||
|
smokeMachine: [16, 16], cableSnake: [32, 16], brokenBasin: [32, 16], canStack: [16, 16],
|
||||||
|
cdj: [32, 32], rollerDoor: [64, 32], ruleBoard: [32, 32],
|
||||||
|
// Staff figures (set dressing, all venues)
|
||||||
|
staffBartender: [16, 32], staffDj: [16, 32], staffGlassie: [16, 32], staffSecurity: [16, 32],
|
||||||
};
|
};
|
||||||
|
|
||||||
function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
|
function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
|
||||||
@ -759,6 +790,19 @@ function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
|
|||||||
}
|
}
|
||||||
f(20, 14, 3, 3, '#f2f2f2'); f(28, 12, 3, 3, '#c03434'); f(31, 18, 3, 3, '#d8c23a');
|
f(20, 14, 3, 3, '#f2f2f2'); f(28, 12, 3, 3, '#c03434'); f(31, 18, 3, 3, '#d8c23a');
|
||||||
break;
|
break;
|
||||||
|
default: {
|
||||||
|
// The venue expansion added ~47 kinds at once, and hand-baking a bespoke
|
||||||
|
// placeholder for each would be a lot of canvas code for pixels nobody
|
||||||
|
// sees once the art lands. But a kind with NO case at all bakes an empty
|
||||||
|
// transparent canvas, so a failed mesh job would read as "the prop isn't
|
||||||
|
// placed" rather than "the prop has no sprite yet" — the two look
|
||||||
|
// identical in the dark and only one is a bug. A generic crate-ish
|
||||||
|
// silhouette makes the difference obvious.
|
||||||
|
f(1, 1, w - 2, h - 2, '#3a3444');
|
||||||
|
f(2, 2, w - 4, Math.max(1, (h - 4) >> 1), '#4a4458');
|
||||||
|
f(2, h - 4, w - 4, 2, '#2a2434');
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
canvas.refresh();
|
canvas.refresh();
|
||||||
return key;
|
return key;
|
||||||
|
|||||||
356
src/scenes/floor/layouts/elevate.ts
Normal file
356
src/scenes/floor/layouts/elevate.ts
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
// Elevate — the rooftop. Licensed 70, and every second person is a VIP
|
||||||
|
// apparently (docs/VENUES.md §4).
|
||||||
|
//
|
||||||
|
// Two things about this room will look like bugs to whoever reads it next, and
|
||||||
|
// both are deliberate:
|
||||||
|
//
|
||||||
|
// 1. THE FLOOR IS 'yard', NOT 'floor'. The whole venue is outdoors, so the
|
||||||
|
// terrace is painted with the tile kind the renderer falls rain on
|
||||||
|
// (FloorView keys its rain emitter off 'yard'). Getting rained on at the
|
||||||
|
// cocktail bar is the joke and the weather is the venue's character. Only
|
||||||
|
// the lift lobby and the dunnies — the two roofed pockets — are 'floor'.
|
||||||
|
// Reskinning the terrace to 'floor' would silently switch the rain off.
|
||||||
|
//
|
||||||
|
// 2. THE DJ PLINTH HAS NO WALLS. Voltage's DJ sits in a sealed `djbooth` ring
|
||||||
|
// with `djfloor` inside it; here there is no ring at all, just a raised
|
||||||
|
// `djfloor` island the crowd walks around. Elevate's DJ is on display, which
|
||||||
|
// is the whole point of a plinth, so the `decks` post stands the player on
|
||||||
|
// the open terrace tile behind the gear rather than inside anything.
|
||||||
|
//
|
||||||
|
// Everything else follows from "small": more than half the grid is 'void' —
|
||||||
|
// open sky past the roof edge — because the grid is fixed at 80x45 for every
|
||||||
|
// venue (VENUES.md §1) and a small room reads small by walling the rest away.
|
||||||
|
import {
|
||||||
|
MAP_H, MAP_W, TILE, newGrid, tileToWorld,
|
||||||
|
} from '../venueMap';
|
||||||
|
import type {
|
||||||
|
AnchorKind, FloorLayout, PropDef, PropEmissive, PropKind, StallDef, TileKind,
|
||||||
|
TilePoint, VenueMap, WorldPoint, ZoneLight,
|
||||||
|
} from '../venueMap';
|
||||||
|
|
||||||
|
// The roof deck's outer ring. North and west are solid parapet/lift-core wall;
|
||||||
|
// south and east are the glass balustrade, which is why they are 'fence' — the
|
||||||
|
// city is visible through them and the skyline cards sit out in the void beyond.
|
||||||
|
const ROOF_X0 = 14;
|
||||||
|
const ROOF_Y0 = 7;
|
||||||
|
const ROOF_X1 = 58;
|
||||||
|
const ROOF_Y1 = 37;
|
||||||
|
|
||||||
|
// The lift lobby hangs off the west side, outside the roof ring: you arrive in
|
||||||
|
// the building's core and step OUT onto the terrace, rather than off a street.
|
||||||
|
const LOBBY_X1 = ROOF_X0;
|
||||||
|
const LOBBY_Y0 = 18;
|
||||||
|
const LOBBY_Y1 = 28;
|
||||||
|
/** Lift doors on column 0 — 'exit' here is the lift back down to the door scene. */
|
||||||
|
const LIFT_Y = [22, 23, 24] as const;
|
||||||
|
|
||||||
|
// Two unisex cubicles, per the brief — no urinals, no third door. Each is the
|
||||||
|
// standard 4x4 walled box; the west one shares no wall with the dunny room
|
||||||
|
// (tx 46 is the room wall) so both boxes sit clear of it.
|
||||||
|
const CUBICLE_X = [47, 51] as const;
|
||||||
|
const CUBICLE_Y = 8;
|
||||||
|
|
||||||
|
// Banquette pairs down the east side, behind their velvet rope.
|
||||||
|
const BOOTH_Y = [21, 25, 29, 33] as const;
|
||||||
|
|
||||||
|
const BAR_X0 = 18;
|
||||||
|
const BAR_X1 = 32;
|
||||||
|
const BAR_Y = 10;
|
||||||
|
/** Two tiles of service lane behind the bar, not one — the bartender has to fit. */
|
||||||
|
const BAR_LANE_Y = 9;
|
||||||
|
|
||||||
|
function buildTiles(): TileKind[] {
|
||||||
|
const { tiles, set, rect, box } = newGrid('void');
|
||||||
|
|
||||||
|
// --- the roof deck ---------------------------------------------------------
|
||||||
|
rect(ROOF_X0, ROOF_Y0, ROOF_X1, ROOF_Y0, 'wall'); // north parapet
|
||||||
|
rect(ROOF_X0, ROOF_Y0, ROOF_X0, ROOF_Y1, 'wall'); // lift-core wall
|
||||||
|
rect(ROOF_X0, ROOF_Y1, ROOF_X1, ROOF_Y1, 'fence'); // south glass balustrade
|
||||||
|
rect(ROOF_X1, ROOF_Y0, ROOF_X1, ROOF_Y1, 'fence'); // east glass balustrade
|
||||||
|
rect(ROOF_X0 + 1, ROOF_Y0 + 1, ROOF_X1 - 1, ROOF_Y1 - 1, 'yard');
|
||||||
|
|
||||||
|
// The venue's name, in pink, facing nobody but the building opposite.
|
||||||
|
for (const tx of [22, 28, 34]) set(tx, ROOF_Y0, 'neon');
|
||||||
|
|
||||||
|
// --- lift lobby ------------------------------------------------------------
|
||||||
|
box(0, LOBBY_Y0, LOBBY_X1, LOBBY_Y1, 'wall');
|
||||||
|
rect(1, LOBBY_Y0 + 1, LOBBY_X1 - 1, LOBBY_Y1 - 1, 'floor');
|
||||||
|
for (const ty of LIFT_Y) set(0, ty, 'exit');
|
||||||
|
// Threshold out of the core onto the terrace, carved after both walls exist.
|
||||||
|
for (const ty of LIFT_Y) set(LOBBY_X1, ty, 'floor');
|
||||||
|
|
||||||
|
// --- cocktail bar (north) --------------------------------------------------
|
||||||
|
// Short counter, no taps anywhere in this venue — bottles and a bucket.
|
||||||
|
rect(BAR_X0, BAR_Y, BAR_X1, BAR_Y + 1, 'bar');
|
||||||
|
// Loose stools rather than Voltage's solid run: patrons filter between them,
|
||||||
|
// which keeps the bar apron from behaving like a second wall.
|
||||||
|
for (const tx of [20, 23, 26, 29, 32]) set(tx, BAR_Y + 2, 'stool');
|
||||||
|
|
||||||
|
// --- DJ plinth (open, raised, unenclosed — see the header) ------------------
|
||||||
|
rect(29, 16, 35, 17, 'djfloor');
|
||||||
|
|
||||||
|
// --- dance floor -----------------------------------------------------------
|
||||||
|
// It is a terrace with a dance floor on it, not a club, so this is small.
|
||||||
|
rect(26, 21, 37, 29, 'dance');
|
||||||
|
|
||||||
|
// --- VIP booths (east) -----------------------------------------------------
|
||||||
|
for (const ty of BOOTH_Y) rect(54, ty, 55, ty + 1, 'booth');
|
||||||
|
|
||||||
|
// --- toilets (north-east) --------------------------------------------------
|
||||||
|
box(46, ROOF_Y0, 56, 18, 'wall');
|
||||||
|
rect(47, ROOF_Y0 + 1, 55, 17, 'floor');
|
||||||
|
rect(50, 18, 51, 18, 'floor'); // the only way in
|
||||||
|
rect(48, 15, 52, 15, 'sink'); // the marble basin run
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, not 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 [23, 27]) for (const tx of [28, 32, 36]) dance.push(tileToWorld(tx, ty));
|
||||||
|
return {
|
||||||
|
// Punter side of the counter, one tile clear of the stools.
|
||||||
|
bar: [19, 22, 25, 28, 31].map((tx) => tileToWorld(tx, BAR_Y + 3)),
|
||||||
|
dance,
|
||||||
|
// Standing room in front of each banquette, outside the rope.
|
||||||
|
booth: [
|
||||||
|
...BOOTH_Y.map((ty) => tileToWorld(52, ty)),
|
||||||
|
tileToWorld(52, BOOTH_Y[0] + 1),
|
||||||
|
tileToWorld(52, BOOTH_Y[2] + 1),
|
||||||
|
],
|
||||||
|
toilet: CUBICLE_X.map((x) => tileToWorld(x + 1, CUBICLE_Y + 4)),
|
||||||
|
// No smoking ROOM and no smokeDoor: the whole roof is open air, so the
|
||||||
|
// smokers get a designated corner of the terrace instead of a pocket.
|
||||||
|
smoke: [tileToWorld(18, 33), tileToWorld(21, 33), tileToWorld(19, 35)],
|
||||||
|
entry: [tileToWorld(3, 23), tileToWorld(7, 23), tileToWorld(11, 23)],
|
||||||
|
exit: [tileToWorld(0, 23)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const P = (
|
||||||
|
id: string, kind: PropKind, tx: number, ty: number, tw: number, th: number,
|
||||||
|
emissive?: PropEmissive,
|
||||||
|
): PropDef => (emissive ? { id, kind, tx, ty, tw, th, emissive } : { id, kind, tx, ty, tw, th });
|
||||||
|
|
||||||
|
/** The skyline gold every card throws back over the balustrade. */
|
||||||
|
const SKYLINE: PropEmissive = { colour: 0xd8a848, radius: 56 };
|
||||||
|
|
||||||
|
const PROPS: readonly PropDef[] = Object.freeze([
|
||||||
|
// --- lift lobby ------------------------------------------------------------
|
||||||
|
P('liftDoors', 'liftDoor', 0, 22, 1, 3),
|
||||||
|
// No 'mirror' PropKind exists and the union is frozen after Phase 0, so both
|
||||||
|
// of this venue's mirrors are flat wall plates borrowed from the poster slot.
|
||||||
|
// TODO(contract): a `mirror` kind would let these stop lying.
|
||||||
|
P('lobbyMirror', 'poster3', 1, 19, 2, 1),
|
||||||
|
P('lobbyArt', 'poster1', 1, 26, 2, 1),
|
||||||
|
P('podium', 'stampPodium', 11, 23, 1, 1),
|
||||||
|
P('ropeL1', 'ropePost', 5, 21, 1, 1),
|
||||||
|
P('ropeL2', 'ropePost', 8, 21, 1, 1),
|
||||||
|
P('ropeL3', 'ropePost', 5, 25, 1, 1),
|
||||||
|
P('ropeL4', 'ropePost', 8, 25, 1, 1),
|
||||||
|
P('cloak', 'cloak', 13, 19, 1, 3),
|
||||||
|
P('lobbyPlanter', 'planter', 2, 22, 1, 1),
|
||||||
|
P('doorStaff', 'staffSecurity', 12, 26, 1, 1),
|
||||||
|
|
||||||
|
// --- cocktail bar ----------------------------------------------------------
|
||||||
|
P('backbar', 'barShelf', BAR_X0, 8, 15, 1, { colour: 0xd8a020, radius: 44 }),
|
||||||
|
P('counter', 'cocktailBar', BAR_X0, BAR_Y, 15, 2),
|
||||||
|
P('glassStack', 'glassStack', 21, BAR_Y, 2, 1),
|
||||||
|
P('champA', 'champBucket', 24, BAR_Y, 1, 1),
|
||||||
|
P('iceWell', 'iceWell', 27, BAR_Y, 2, 1),
|
||||||
|
P('champB', 'champBucket', 30, BAR_Y, 1, 1),
|
||||||
|
P('till', 'till', 31, BAR_Y, 1, 1),
|
||||||
|
P('fridge', 'barFridge', 33, 8, 2, 2, { colour: 0x60c0e0, radius: 16 }),
|
||||||
|
P('dish', 'dishwasher', 20, 8, 2, 1, { colour: 0x50c060, radius: 20 }),
|
||||||
|
P('rack', 'glassRack', 36, 9, 2, 1),
|
||||||
|
P('bartender', 'staffBartender', 25, BAR_LANE_Y, 1, 1),
|
||||||
|
|
||||||
|
// --- DJ plinth -------------------------------------------------------------
|
||||||
|
P('plinth', 'djPlinth', 29, 16, 7, 2),
|
||||||
|
P('deckA', 'deck', 30, 16, 2, 2),
|
||||||
|
P('mixer', 'mixer', 32, 16, 1, 2),
|
||||||
|
P('deckB', 'deck', 33, 16, 2, 2),
|
||||||
|
P('monitor', 'monitor', 29, 17, 1, 1),
|
||||||
|
P('djLamp', 'boothLamp', 35, 16, 1, 1, { colour: 0x3060c0, radius: 38, pulse: 'flicker' }),
|
||||||
|
P('theDj', 'staffDj', 34, 15, 1, 1),
|
||||||
|
|
||||||
|
// --- dance floor -----------------------------------------------------------
|
||||||
|
P('spkNW', 'speaker', 25, 20, 1, 2),
|
||||||
|
P('spkNE', 'speaker', 38, 20, 1, 2),
|
||||||
|
P('spkSW', 'speaker', 25, 28, 1, 2),
|
||||||
|
P('spkSE', 'speaker', 38, 28, 1, 2),
|
||||||
|
P('trussN', 'truss', 30, 20, 3, 1),
|
||||||
|
P('trussS', 'truss', 30, 30, 3, 1),
|
||||||
|
P('ball', 'discoball', 31, 24, 2, 2, { colour: 0xe8c8e0, radius: 22 }),
|
||||||
|
|
||||||
|
// --- VIP booths ------------------------------------------------------------
|
||||||
|
...BOOTH_Y.flatMap((ty, i) => {
|
||||||
|
const tag = String.fromCharCode(65 + i);
|
||||||
|
return [
|
||||||
|
P(`banq${tag}`, 'banquette', 54, ty, 2, 2),
|
||||||
|
P(`vipTbl${tag}`, 'boothTable', 53, ty, 1, 1, { colour: 0xd8a050, radius: 12 }),
|
||||||
|
P(`vipRope${tag}`, 'ropePost', 52, ty - 1, 1, 1),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
P('vipChamp', 'champBucket', 53, 26, 1, 1),
|
||||||
|
P('vipLounge', 'loungeChair', 51, 35, 2, 1),
|
||||||
|
|
||||||
|
// --- toilets ---------------------------------------------------------------
|
||||||
|
P('basin', 'marbleSink', 48, 15, 5, 1),
|
||||||
|
P('looMirror', 'poster3', 48, 14, 5, 1),
|
||||||
|
P('cubDoorA', 'cubicleDoor', 48, 11, 1, 1),
|
||||||
|
P('cubDoorB', 'cubicleDoor', 52, 11, 1, 1),
|
||||||
|
P('dryer', 'handDryer', 55, 13, 1, 1),
|
||||||
|
P('mop', 'mopBucket', 47, 17, 1, 1),
|
||||||
|
P('looPlanter', 'planter', 54, 16, 1, 1),
|
||||||
|
P('wet', 'wetFloor', 50, 19, 1, 1),
|
||||||
|
|
||||||
|
// --- terrace edge ----------------------------------------------------------
|
||||||
|
P('balS1', 'balustrade', 16, ROOF_Y1, 10, 1),
|
||||||
|
P('balS2', 'balustrade', 27, ROOF_Y1, 10, 1),
|
||||||
|
P('balS3', 'balustrade', 38, ROOF_Y1, 10, 1),
|
||||||
|
P('balS4', 'balustrade', 49, ROOF_Y1, 9, 1),
|
||||||
|
P('balE1', 'balustrade', ROOF_X1, 9, 1, 9),
|
||||||
|
P('balE2', 'balustrade', ROOF_X1, 19, 1, 9),
|
||||||
|
P('balE3', 'balustrade', ROOF_X1, 29, 1, 8),
|
||||||
|
P('planterA', 'planter', 26, 36, 1, 1),
|
||||||
|
P('planterB', 'planter', 33, 36, 1, 1),
|
||||||
|
P('planterC', 'planter', 40, 36, 1, 1),
|
||||||
|
P('planterD', 'planter', 47, 36, 1, 1),
|
||||||
|
P('planterE', 'planter', 57, 12, 1, 1),
|
||||||
|
P('planterF', 'planter', 57, 22, 1, 1),
|
||||||
|
P('planterG', 'planter', 57, 32, 1, 1),
|
||||||
|
// The city itself, out in the void past the glass. These are the only props
|
||||||
|
// deliberately placed OFF the deck — they are scenery, nobody walks to them.
|
||||||
|
P('skyS1', 'skylineCard', 16, 39, 14, 5, SKYLINE),
|
||||||
|
P('skyS2', 'skylineCard', 32, 39, 14, 5, SKYLINE),
|
||||||
|
P('skyS3', 'skylineCard', 48, 39, 12, 5, SKYLINE),
|
||||||
|
P('skyE1', 'skylineCard', 60, 8, 14, 12, SKYLINE),
|
||||||
|
P('skyE2', 'skylineCard', 60, 22, 16, 14, SKYLINE),
|
||||||
|
|
||||||
|
// --- the smoking corner (south-west of the deck) ---------------------------
|
||||||
|
P('heaterA', 'patioHeater', 17, 31, 1, 2, { colour: 0xe07030, radius: 34, pulse: 'flicker' }),
|
||||||
|
P('heaterB', 'patioHeater', 23, 31, 1, 2, { colour: 0xe07030, radius: 34, pulse: 'flicker' }),
|
||||||
|
P('ashUrnA', 'ashUrn', 18, 32, 1, 1),
|
||||||
|
P('ashUrnB', 'ashUrn', 22, 34, 1, 1),
|
||||||
|
P('loungeA', 'loungeChair', 19, 34, 2, 1),
|
||||||
|
P('loungeB', 'loungeChair', 16, 35, 2, 1),
|
||||||
|
P('smokePlanter', 'planter', 24, 33, 1, 1),
|
||||||
|
|
||||||
|
// --- terrace dressing ------------------------------------------------------
|
||||||
|
P('highA', 'highTable', 21, 17, 1, 1),
|
||||||
|
P('highB', 'highTable', 40, 14, 1, 1),
|
||||||
|
P('highC', 'highTable', 43, 24, 1, 1),
|
||||||
|
P('highD', 'highTable', 20, 24, 1, 1),
|
||||||
|
P('loungeC', 'loungeChair', 41, 32, 2, 1),
|
||||||
|
P('terraceArt', 'poster2', 15, 14, 1, 2),
|
||||||
|
P('glassie', 'staffGlassie', 44, 20, 1, 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const L = (
|
||||||
|
id: string, tx: number, ty: number, colour: number, radius: number,
|
||||||
|
pulse?: 'beat' | 'flicker',
|
||||||
|
): ZoneLight => (pulse ? { id, tx, ty, colour, radius, pulse } : { id, tx, ty, colour, radius });
|
||||||
|
|
||||||
|
// Zone light signatures (docs/SPACES.md §1) — you navigate by glow colour, so
|
||||||
|
// the house colours are fixed and a venue gets exactly ONE of its own. Elevate's
|
||||||
|
// is SKYLINE GOLD, thrown back off the city beyond the glass; that is why the
|
||||||
|
// terrace edge is lit and the middle of the deck largely is not.
|
||||||
|
const TERRACE_BLUE = 0x7090a8;
|
||||||
|
const SKYLINE_GOLD = 0xd8a848;
|
||||||
|
|
||||||
|
const LIGHTS: readonly ZoneLight[] = Object.freeze([
|
||||||
|
...[20, 25, 30].map((tx, i) => L(`bar${i}`, tx, BAR_LANE_Y, 0xd8a020, 40)),
|
||||||
|
...[28, 32, 36].flatMap((tx) =>
|
||||||
|
[22, 25, 28].map((ty) => L(`dg${tx}x${ty}`, tx, ty, 0xd03470, 26, 'beat')),
|
||||||
|
),
|
||||||
|
L('dj', 32, 17, 0x3060c0, 44),
|
||||||
|
L('loo', 51, 13, 0x50c060, 50, 'flicker'),
|
||||||
|
L('smoke', 20, 33, TERRACE_BLUE, 44),
|
||||||
|
L('terraceS', 38, 34, TERRACE_BLUE, 40),
|
||||||
|
L('terraceE', 56, 26, TERRACE_BLUE, 40),
|
||||||
|
L('terraceW', 16, 20, TERRACE_BLUE, 36),
|
||||||
|
L('entry', 2, 23, 0xd03470, 40),
|
||||||
|
L('lobby', 10, 23, 0xd03470, 32),
|
||||||
|
L('gold0', ROOF_X1, 14, SKYLINE_GOLD, 54),
|
||||||
|
L('gold1', ROOF_X1, 26, SKYLINE_GOLD, 54),
|
||||||
|
L('gold2', ROOF_X1, 34, SKYLINE_GOLD, 54),
|
||||||
|
L('gold3', 24, ROOF_Y1, SKYLINE_GOLD, 54),
|
||||||
|
L('gold4', 38, ROOF_Y1, SKYLINE_GOLD, 54),
|
||||||
|
L('gold5', 50, ROOF_Y1, SKYLINE_GOLD, 54),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MAP: VenueMap = Object.freeze({
|
||||||
|
width: MAP_W,
|
||||||
|
height: MAP_H,
|
||||||
|
tiles: Object.freeze(buildTiles()),
|
||||||
|
anchors: Object.freeze(buildAnchors()),
|
||||||
|
stalls: Object.freeze(buildStalls()),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a ROLE plants the player. Both must be walkable and reachable — a post
|
||||||
|
* on a sealed tile is a night spent frozen in place, and that has shipped twice.
|
||||||
|
* Elevate has no beer taps at all, so 'taps' is the cocktail bar's serving side.
|
||||||
|
*/
|
||||||
|
const POSTS: Readonly<Record<string, TilePoint>> = Object.freeze({
|
||||||
|
taps: { tx: 25, ty: BAR_LANE_Y },
|
||||||
|
/** Behind the plinth, facing the crowd across the gear. Open terrace, no box. */
|
||||||
|
decks: { tx: 31, ty: 15 },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Interaction anchors the player walks UP TO; these may sit inside furniture. */
|
||||||
|
const PROBES: Readonly<Record<string, TilePoint>> = Object.freeze({
|
||||||
|
tapsFront: { tx: 25, ty: BAR_Y + 3 },
|
||||||
|
rack: { tx: 36, ty: 9 },
|
||||||
|
dishwasher: { tx: 20, ty: BAR_LANE_Y },
|
||||||
|
/** RSA water duty runs off the marble basin, the only plumbing on the roof. */
|
||||||
|
water: { tx: 50, ty: 15 },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poured concrete and pale decking, cooler and a stop lighter than Voltage's
|
||||||
|
* sticky purple — but only a stop. These values are read UNDER the renderer's
|
||||||
|
* darkness sheet, which multiplies everything down; a palette picked to look
|
||||||
|
* right in isolation renders as black in the room.
|
||||||
|
*/
|
||||||
|
const PALETTE: Readonly<Partial<Record<TileKind, number>>> = Object.freeze({
|
||||||
|
void: 0x080b16, // city night, not a hole in the world
|
||||||
|
yard: 0x323b42, // the deck itself, and most of the venue
|
||||||
|
floor: 0x2c3238, // lift lobby + dunnies, the two roofed pockets
|
||||||
|
wall: 0x3f4750,
|
||||||
|
fence: 0x5a6a76, // glass balustrade — catches every light on the roof
|
||||||
|
dance: 0x38344c,
|
||||||
|
booth: 0x4a3040, // velvet
|
||||||
|
bar: 0x554e46, // pale stone counter, not Voltage's timber
|
||||||
|
stool: 0x6a5c48,
|
||||||
|
djfloor: 0x2e3646,
|
||||||
|
sink: 0x6a6f74, // marble
|
||||||
|
stall: 0x353d40,
|
||||||
|
stallDoor: 0x55605e,
|
||||||
|
exit: 0x36624a,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ELEVATE: FloorLayout = Object.freeze({
|
||||||
|
id: 'elevate',
|
||||||
|
map: MAP,
|
||||||
|
props: PROPS,
|
||||||
|
lights: LIGHTS,
|
||||||
|
posts: POSTS,
|
||||||
|
probes: PROBES,
|
||||||
|
palette: PALETTE,
|
||||||
|
});
|
||||||
33
src/scenes/floor/layouts/index.ts
Normal file
33
src/scenes/floor/layouts/index.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// The venue ladder's rooms (docs/VENUES.md).
|
||||||
|
//
|
||||||
|
// `data/venues.ts` has always listed four venues; until now they shared one
|
||||||
|
// floor map and differed only by difficulty knobs, so a promotion you had
|
||||||
|
// survived a whole week for looked exactly like the room you had just left.
|
||||||
|
// This registry is the join: a night carries its `VenueDef`, and its id picks
|
||||||
|
// the room it happens in.
|
||||||
|
//
|
||||||
|
// Voltage lives in venueMap.ts rather than here, and deliberately: the other
|
||||||
|
// layouts import their primitives from that module, so a layouts/voltage.ts
|
||||||
|
// would close an import cycle for no benefit.
|
||||||
|
import { VOLTAGE, type FloorLayout } from '../venueMap';
|
||||||
|
import { THE_ROYAL } from './theRoyal';
|
||||||
|
import { ELEVATE } from './elevate';
|
||||||
|
import { ROOM } from './room';
|
||||||
|
|
||||||
|
export const LAYOUTS: readonly FloorLayout[] = Object.freeze([
|
||||||
|
THE_ROYAL, VOLTAGE, ELEVATE, ROOM,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Voltage is the tuned reference room, so it is also the fallback. */
|
||||||
|
export const DEFAULT_LAYOUT = VOLTAGE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The room a venue is played in. Unknown ids fall back to Voltage rather than
|
||||||
|
* throwing: the dev routes (F, J) boot the floor with no night context at all,
|
||||||
|
* and a missing room should cost you the venue's character, not the session.
|
||||||
|
*/
|
||||||
|
export function layoutFor(venueId: string | undefined): FloorLayout {
|
||||||
|
return LAYOUTS.find((l) => l.id === venueId) ?? DEFAULT_LAYOUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { FloorLayout };
|
||||||
344
src/scenes/floor/layouts/room.ts
Normal file
344
src/scenes/floor/layouts/room.ts
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
// ROOM — "no sign. no socials." The last rung of the ladder (docs/VENUES.md §5).
|
||||||
|
//
|
||||||
|
// A concrete warehouse with a world-class rig bolted into it and nothing else
|
||||||
|
// spent on anything. Everything that isn't the sound system is borrowed, taped
|
||||||
|
// down, or wrecked. There is no branding ANYWHERE, which is why this is the one
|
||||||
|
// venue in the game with zero `neon` tiles — branding is how you get found.
|
||||||
|
//
|
||||||
|
// Two decisions a future reader would otherwise "tidy up" by accident:
|
||||||
|
//
|
||||||
|
// 1. THE PILLARS ARE LOAD-BEARING, MECHANICALLY. The scattered 2x2 `wall`
|
||||||
|
// blocks through the main floor are not decoration and they are not a
|
||||||
|
// mistake in the flood fill — they are the reason walking across ROOM feels
|
||||||
|
// different to walking across Voltage. Deleting them to "open the room up"
|
||||||
|
// deletes the venue's whole movement character. They must stay `wall` (props
|
||||||
|
// never affect walkability), and they must stay irregular: a tidy grid of
|
||||||
|
// them reads as a car park.
|
||||||
|
//
|
||||||
|
// 2. THE DJ IS ON THE FLOOR, NOT IN A BOOTH. Voltage seals its DJ inside a
|
||||||
|
// `djbooth` island. ROOM does not: the desk is a low 7x2 plinth in the dead
|
||||||
|
// centre of the room and the DJ stands on the same `dance` tiles as the
|
||||||
|
// crowd, one tile north of it. That is why `posts.decks` is a bare dance
|
||||||
|
// tile with no walls around it — it is correct, not an oversight. Walling
|
||||||
|
// it in would be the single fastest way to turn this room back into Voltage.
|
||||||
|
//
|
||||||
|
// Third thing, quieter: ROOM is the darkest venue on purpose. Every light here
|
||||||
|
// is fewer and smaller than Voltage's equivalent. If this room ever becomes
|
||||||
|
// comfortable to navigate without the torch, something has been "fixed".
|
||||||
|
|
||||||
|
import { MAP_H, MAP_W, TILE, newGrid, tileToWorld } from '../venueMap';
|
||||||
|
import type {
|
||||||
|
AnchorKind, FloorLayout, PropDef, PropEmissive, PropKind, StallDef,
|
||||||
|
TileKind, TilePoint, VenueMap, WorldPoint, ZoneLight,
|
||||||
|
} from '../venueMap';
|
||||||
|
|
||||||
|
// Three cubicles, one of which works. West edges; each is a 4x4 walled box, so
|
||||||
|
// the last one leans on the dunny's east interior column. CUBICLE_Y sits flush
|
||||||
|
// under the dunny's north wall — a gap there strands a dead strip of floor.
|
||||||
|
const CUBICLE_X = [59, 64, 69] as const;
|
||||||
|
const CUBICLE_Y = 3;
|
||||||
|
|
||||||
|
// Top-left corner of each 2x2 concrete pillar. Deliberately off-grid from each
|
||||||
|
// other: the room should never let you sight a clean line from one wall to the
|
||||||
|
// other. Kept clear of the DJ plinth, the stacks and every anchor.
|
||||||
|
const PILLARS: readonly (readonly [number, number])[] = [
|
||||||
|
[16, 6], [16, 28], [26, 14], [26, 29], [34, 8], [45, 8],
|
||||||
|
[35, 30], [46, 30], [50, 15], [63, 20], [63, 30],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// The stacks: four Funktion-One towers on the corners of the dance floor, four
|
||||||
|
// subs shoved into the corners of the shell. They are `wall` because a stack of
|
||||||
|
// that size IS a wall — you do not walk through it, you walk around it.
|
||||||
|
const STACKS: readonly (readonly [number, number])[] = [
|
||||||
|
[23, 12], [52, 12], [23, 28], [52, 28],
|
||||||
|
] as const;
|
||||||
|
const SUBS: readonly (readonly [number, number])[] = [
|
||||||
|
[19, 3], [54, 3], [16, 32], [67, 32],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Standing room, spread wide rather than clustered: with no booths and no
|
||||||
|
// tables, the crowd is what fills this room, so the sim needs targets all over
|
||||||
|
// it. Every one of these is checked clear of a pillar, a stack and the plinth.
|
||||||
|
const DANCE_SPOTS: readonly (readonly [number, number])[] = [
|
||||||
|
[30, 12], [38, 16], [48, 18], [28, 18],
|
||||||
|
[32, 26], [44, 26], [50, 25], [40, 28],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function buildTiles(): TileKind[] {
|
||||||
|
// Fills `void`, not `floor`: ROOM is a licensed-60 warehouse in an 80x45 grid,
|
||||||
|
// and the unused margin has to read as nothing rather than as unlit room.
|
||||||
|
const { tiles, set, rect, box } = newGrid('void');
|
||||||
|
|
||||||
|
box(0, 0, MAP_W - 1, MAP_H - 1, 'wall');
|
||||||
|
|
||||||
|
// The shell. One big concrete box — no back of house, no offices, no signage.
|
||||||
|
box(8, 2, 73, 35, 'wall');
|
||||||
|
rect(9, 3, 72, 34, 'floor');
|
||||||
|
|
||||||
|
// The main floor. Almost the whole room is dance floor because almost the
|
||||||
|
// whole room is people; ROOM has no booths and no tables to hide behind.
|
||||||
|
rect(21, 10, 55, 32, 'dance');
|
||||||
|
|
||||||
|
// Entry: a corridor off the lane, and the way back out to the door scene.
|
||||||
|
// The `exit` run reaches column 0 — the invariants allow a walkable west edge
|
||||||
|
// precisely here, and nowhere else.
|
||||||
|
rect(1, 18, 8, 20, 'floor');
|
||||||
|
rect(0, 18, 1, 20, 'exit');
|
||||||
|
|
||||||
|
// The airlock. Two stub walls make the entry a room rather than a hole in the
|
||||||
|
// wall, and give THE BOARD something to hang on. Both stubs stop at tx 13 so
|
||||||
|
// the vestibule stays open to the east — seal that side and the night ends
|
||||||
|
// before it starts.
|
||||||
|
rect(9, 15, 13, 15, 'wall');
|
||||||
|
rect(9, 22, 13, 22, 'wall');
|
||||||
|
|
||||||
|
// The bar: a plywood plank on scaffolding down the west wall, open at the
|
||||||
|
// north end so whoever's serving can get behind it. No taps. No till.
|
||||||
|
rect(10, 24, 11, 33, 'bar');
|
||||||
|
|
||||||
|
// Chill-out: two ruined couches under one bulb, as far from the stacks as the
|
||||||
|
// building allows, which is not far enough.
|
||||||
|
rect(11, 5, 13, 6, 'booth');
|
||||||
|
rect(11, 9, 13, 10, 'booth');
|
||||||
|
|
||||||
|
// Dunnies: industrial, wrecked, one door, no mirror. The basin is `sink` and
|
||||||
|
// it is cracked; there is exactly one of it for sixty people.
|
||||||
|
box(57, 2, 73, 15, 'wall');
|
||||||
|
rect(58, 3, 72, 14, 'floor');
|
||||||
|
rect(60, 15, 61, 15, 'floor');
|
||||||
|
rect(58, 9, 59, 9, 'sink');
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading dock: open sky behind a roller door, and therefore the smoking
|
||||||
|
// area. Painted solid `fence` then hollowed to `yard` so the rain lands
|
||||||
|
// inside the fence line (the renderer keys off 'yard').
|
||||||
|
rect(30, 35, 50, 43, 'fence');
|
||||||
|
rect(31, 36, 49, 42, 'yard');
|
||||||
|
rect(39, 35, 40, 35, 'smokeDoor');
|
||||||
|
|
||||||
|
// The rig's real furniture. Stacks and subs before pillars so an overlap
|
||||||
|
// would show up as a missing pillar rather than a missing stack.
|
||||||
|
for (const [tx, ty] of STACKS) rect(tx, ty, tx + 1, ty + 2, 'wall');
|
||||||
|
for (const [tx, ty] of SUBS) rect(tx, ty, tx + 1, ty + 1, 'wall');
|
||||||
|
for (const [tx, ty] of PILLARS) rect(tx, ty, tx + 1, ty + 1, 'wall');
|
||||||
|
|
||||||
|
// The DJ plinth: low, central, facing everywhere, no barrier. The two
|
||||||
|
// `djfloor` tiles are the wedge monitors sat on the ends of the desk — the
|
||||||
|
// desk is NOT ringed, see the header note.
|
||||||
|
rect(37, 21, 43, 22, 'djbooth');
|
||||||
|
set(36, 22, 'djfloor');
|
||||||
|
set(44, 22, 'djfloor');
|
||||||
|
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, not a centre.
|
||||||
|
inside: { x: (x + 2) * TILE, y: (CUBICLE_Y + 2) * TILE },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnchors(): Record<AnchorKind, readonly WorldPoint[]> {
|
||||||
|
return {
|
||||||
|
// The punter side of the plank. Nobody queues in an orderly fashion here,
|
||||||
|
// but the sim needs somewhere to put them.
|
||||||
|
bar: [25, 27, 29, 31, 33].map((ty) => tileToWorld(12, ty)),
|
||||||
|
dance: DANCE_SPOTS.map(([tx, ty]) => tileToWorld(tx, ty)),
|
||||||
|
// ROOM has no booths — the crowd is the furniture — so these are the
|
||||||
|
// couches in the chill-out corner (docs/VENUES.md §5).
|
||||||
|
booth: [tileToWorld(14, 6), tileToWorld(14, 10), tileToWorld(10, 7)],
|
||||||
|
toilet: CUBICLE_X.map((x) => tileToWorld(x + 1, CUBICLE_Y + 4)),
|
||||||
|
smoke: [tileToWorld(35, 39), tileToWorld(45, 40), tileToWorld(40, 37)],
|
||||||
|
entry: [tileToWorld(4, 19), tileToWorld(11, 19)],
|
||||||
|
exit: [tileToWorld(0, 19)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const P = (
|
||||||
|
id: string, kind: PropKind, tx: number, ty: number, tw: number, th: number,
|
||||||
|
emissive?: PropEmissive,
|
||||||
|
): PropDef => (emissive ? { id, kind, tx, ty, tw, th, emissive } : { id, kind, tx, ty, tw, th });
|
||||||
|
|
||||||
|
// The stacks' own power LEDs — the only thing in the building with a light on
|
||||||
|
// it that isn't pointed at the floor. Radius 8 keeps them landmarks, not lamps;
|
||||||
|
// they borrow the DJ's deep blue rather than inventing a colour, because the
|
||||||
|
// venue's one signature colour is spent on the sodium light over the dock.
|
||||||
|
const STACK_LED: PropEmissive = { colour: 0x3060c0, radius: 8 };
|
||||||
|
|
||||||
|
const ROOM_PROPS: readonly PropDef[] = Object.freeze([
|
||||||
|
// ---- the stacks ------------------------------------------------------------
|
||||||
|
...STACKS.map(([tx, ty], i) => P(`stack${i}`, 'stackF1', tx, ty, 2, 3, STACK_LED)),
|
||||||
|
...SUBS.map(([tx, ty], i) => P(`sub${i}`, 'subBass', tx, ty, 2, 2)),
|
||||||
|
|
||||||
|
// ---- the pillars -----------------------------------------------------------
|
||||||
|
...PILLARS.map(([tx, ty], i) => P(`pillar${i}`, 'pillar', tx, ty, 2, 2)),
|
||||||
|
|
||||||
|
// ---- the DJ: CDJs, not turntables. Nobody is carrying records to this room --
|
||||||
|
P('cdjL', 'cdj', 37, 21, 2, 2),
|
||||||
|
P('cdjR', 'cdj', 42, 21, 2, 2),
|
||||||
|
P('djMixer', 'mixer', 39, 21, 3, 2),
|
||||||
|
P('wedgeL', 'monitor', 36, 22, 1, 1),
|
||||||
|
P('wedgeR', 'monitor', 44, 22, 1, 1),
|
||||||
|
|
||||||
|
// ---- the rig, visible and taped down, because nobody built this properly ----
|
||||||
|
P('snakeMain', 'cableSnake', 36, 23, 8, 1),
|
||||||
|
P('snakeW', 'cableSnake', 25, 16, 1, 12),
|
||||||
|
P('snakeE', 'cableSnake', 54, 16, 1, 12),
|
||||||
|
P('hazeL', 'smokeMachine', 34, 24, 2, 1),
|
||||||
|
P('hazeR', 'smokeMachine', 45, 24, 2, 1),
|
||||||
|
// Strobes sit ON the floor with the crowd. They pulse the dance pink rather
|
||||||
|
// than white: white belongs to the torch (docs/SPACES.md §1), and in this
|
||||||
|
// room the strobes ARE the dance lighting.
|
||||||
|
P('strobeNW', 'strobe', 30, 20, 1, 1, { colour: 0xd03470, radius: 12, pulse: 'beat' }),
|
||||||
|
P('strobeNE', 'strobe', 50, 20, 1, 1, { colour: 0xd03470, radius: 12, pulse: 'beat' }),
|
||||||
|
P('strobeN', 'strobe', 40, 14, 1, 1, { colour: 0xd03470, radius: 12, pulse: 'beat' }),
|
||||||
|
P('strobeS', 'strobe', 40, 28, 1, 1, { colour: 0xd03470, radius: 12, pulse: 'beat' }),
|
||||||
|
|
||||||
|
// ---- the bar: a plank, some scaffolding, and cans --------------------------
|
||||||
|
P('plank', 'plywoodBar', 10, 24, 2, 10),
|
||||||
|
P('cansA', 'canStack', 10, 26, 2, 1),
|
||||||
|
P('cansB', 'canStack', 10, 31, 2, 1),
|
||||||
|
P('cansBackstock', 'canStack', 9, 33, 1, 1),
|
||||||
|
P('tub', 'crate', 9, 24, 1, 1),
|
||||||
|
P('empties', 'crate', 9, 32, 1, 1),
|
||||||
|
P('barkeep', 'staffBartender', 9, 26, 1, 1),
|
||||||
|
|
||||||
|
// ---- THE BOARD: this venue's entire ruleset, on a sheet of MDF -------------
|
||||||
|
// Hung on the vestibule stub wall so it is the first and only thing you can
|
||||||
|
// read on the way in. `shuffleDressCode` rewrites what it says (data/venues.ts).
|
||||||
|
P('board', 'ruleBoard', 9, 15, 4, 1),
|
||||||
|
P('doorstaff', 'staffSecurity', 14, 19, 1, 1),
|
||||||
|
|
||||||
|
// ---- chill-out: two couches, one bulb, no conversation ---------------------
|
||||||
|
P('couchA', 'oldCouch', 11, 5, 3, 2),
|
||||||
|
P('couchB', 'oldCouch', 11, 9, 3, 2),
|
||||||
|
P('couchCrate', 'crate', 15, 7, 1, 1),
|
||||||
|
|
||||||
|
// ---- dunnies ---------------------------------------------------------------
|
||||||
|
P('basin', 'brokenBasin', 58, 9, 2, 1),
|
||||||
|
// Only the first cubicle still has a door on it. The other two tell their
|
||||||
|
// story with what's on the floor outside them instead.
|
||||||
|
P('cubDoor', 'cubicleDoor', 60, 6, 1, 1),
|
||||||
|
P('wetA', 'wetFloor', 64, 8, 1, 1),
|
||||||
|
P('wetB', 'wetFloor', 69, 8, 1, 1),
|
||||||
|
P('mop', 'mopBucket', 67, 12, 1, 1),
|
||||||
|
|
||||||
|
// ---- loading dock ----------------------------------------------------------
|
||||||
|
P('roller', 'rollerDoor', 38, 35, 4, 1),
|
||||||
|
P('palletA', 'pallet', 33, 38, 2, 2),
|
||||||
|
P('palletB', 'pallet', 33, 41, 2, 2),
|
||||||
|
P('palletC', 'pallet', 46, 37, 2, 2),
|
||||||
|
P('bin', 'wheelieBin', 47, 40, 1, 2),
|
||||||
|
P('butts', 'buttBin', 36, 41, 1, 1),
|
||||||
|
P('dockCrate', 'crate', 43, 38, 1, 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const L = (
|
||||||
|
id: string, tx: number, ty: number, colour: number, radius: number,
|
||||||
|
pulse?: 'beat' | 'flicker',
|
||||||
|
): ZoneLight => (pulse ? { id, tx, ty, colour, radius, pulse } : { id, tx, ty, colour, radius });
|
||||||
|
|
||||||
|
// Twelve lights for a room the size of Voltage's nineteen, every radius pulled
|
||||||
|
// in. The signatures still hold (docs/SPACES.md §1) — you can find the bar, the
|
||||||
|
// dunnies and the way out by colour — but between those pools there is nothing,
|
||||||
|
// and that is the point of the venue.
|
||||||
|
const ROOM_LIGHTS: readonly ZoneLight[] = Object.freeze([
|
||||||
|
// Two work lamps clipped to the scaffolding. Amber, because a bar is amber.
|
||||||
|
L('bar0', 10, 26, 0xd8a020, 26),
|
||||||
|
L('bar1', 10, 31, 0xd8a020, 26),
|
||||||
|
// Dance: four, not nine, and radius 16 instead of 26. On the actual beat.
|
||||||
|
L('dg0', 28, 16, 0xd03470, 16, 'beat'),
|
||||||
|
L('dg1', 48, 16, 0xd03470, 16, 'beat'),
|
||||||
|
L('dg2', 28, 28, 0xd03470, 16, 'beat'),
|
||||||
|
L('dg3', 48, 28, 0xd03470, 16, 'beat'),
|
||||||
|
L('dj', 40, 21, 0x3060c0, 30),
|
||||||
|
// One fluoro in the dunnies and it is on its way out.
|
||||||
|
L('loo', 65, 10, 0x50c060, 34, 'flicker'),
|
||||||
|
// ROOM's signature: the sodium lamp over the loading dock, the only warm
|
||||||
|
// thing outside the bar and the reason everyone ends up out here.
|
||||||
|
L('sodium', 40, 39, 0xe08420, 40, 'flicker'),
|
||||||
|
// A thin cool-blue spill at the roller door keeps the house rule intact —
|
||||||
|
// blue still means outside, even in the venue that owns orange.
|
||||||
|
L('dockDoor', 40, 34, 0x6080a0, 18),
|
||||||
|
L('entry', 3, 19, 0xd03470, 24),
|
||||||
|
// The single bulb over the couches. Nobody has replaced it since the lease.
|
||||||
|
L('chill', 12, 8, 0xd8a020, 18),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ROOM_MAP: VenueMap = Object.freeze({
|
||||||
|
width: MAP_W,
|
||||||
|
height: MAP_H,
|
||||||
|
tiles: Object.freeze(buildTiles()),
|
||||||
|
anchors: Object.freeze(buildAnchors()),
|
||||||
|
stalls: Object.freeze(buildStalls()),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a role plants the player. Both must stay walkable and reachable — a
|
||||||
|
* post on a sealed tile is a night spent frozen in place, and that has shipped
|
||||||
|
* twice already (see tests/floor/layoutInvariants.ts).
|
||||||
|
*/
|
||||||
|
const ROOM_POSTS: Readonly<Record<string, TilePoint>> = Object.freeze({
|
||||||
|
/** ROOM has no taps, so this is the service side of the plywood plank. */
|
||||||
|
taps: { tx: 9, ty: 28 },
|
||||||
|
/** North of the plinth, on the same floor as everyone else. No booth. */
|
||||||
|
decks: { tx: 40, ty: 20 },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interaction anchors the player walks UP TO. Allowed to sit inside furniture,
|
||||||
|
* but each needs somewhere walkable to be used from.
|
||||||
|
*/
|
||||||
|
const ROOM_PROBES: Readonly<Record<string, TilePoint>> = Object.freeze({
|
||||||
|
/** The punter side of the plank, where E offers you the shift. */
|
||||||
|
tapsFront: { tx: 12, ty: 28 },
|
||||||
|
/** Nothing comes back in a rack here — it comes back as empties in a crate. */
|
||||||
|
rack: { tx: 9, ty: 32 },
|
||||||
|
/** There is no dishwasher. There is a tub, and there is ice in it. */
|
||||||
|
dishwasher: { tx: 9, ty: 24 },
|
||||||
|
/** RSA water: the one cracked basin, which is in the dunnies, which is the joke. */
|
||||||
|
water: { tx: 58, ty: 9 },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bare grey concrete, and darker than Voltage across the board.
|
||||||
|
*
|
||||||
|
* These sit UNDER the renderer's darkness sheet, which multiplies everything
|
||||||
|
* down — they are already close to the floor of what stays legible, so treat
|
||||||
|
* this table as a lower bound rather than a starting point. Note there is no
|
||||||
|
* `neon` entry: ROOM never paints a neon tile, because ROOM has no signage.
|
||||||
|
*/
|
||||||
|
const ROOM_PALETTE: Readonly<Partial<Record<TileKind, number>>> = Object.freeze({
|
||||||
|
void: 0x030305,
|
||||||
|
floor: 0x1e2022,
|
||||||
|
wall: 0x30333a,
|
||||||
|
dance: 0x24262c,
|
||||||
|
bar: 0x4a3a24,
|
||||||
|
booth: 0x3a2f30,
|
||||||
|
stall: 0x262e2e,
|
||||||
|
stallDoor: 0x3e4e48,
|
||||||
|
sink: 0x3e4a4c,
|
||||||
|
djbooth: 0x262c38,
|
||||||
|
djfloor: 0x1e2430,
|
||||||
|
yard: 0x22282e,
|
||||||
|
fence: 0x3c444c,
|
||||||
|
exit: 0x24462c,
|
||||||
|
smokeDoor: 0x33404c,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ROOM: FloorLayout = Object.freeze({
|
||||||
|
id: 'room',
|
||||||
|
map: ROOM_MAP,
|
||||||
|
props: ROOM_PROPS,
|
||||||
|
lights: ROOM_LIGHTS,
|
||||||
|
posts: ROOM_POSTS,
|
||||||
|
probes: ROOM_PROBES,
|
||||||
|
palette: ROOM_PALETTE,
|
||||||
|
});
|
||||||
364
src/scenes/floor/layouts/theRoyal.ts
Normal file
364
src/scenes/floor/layouts/theRoyal.ts
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* The Royal — "a dive with a dance floor" (docs/VENUES.md §2).
|
||||||
|
*
|
||||||
|
* A suburban Australian pub that bolted a dance floor on in about 2004 and has
|
||||||
|
* not redecorated since: patterned carpet, a horseshoe public bar you can work
|
||||||
|
* from the inside, a pool room you can hear but not see, the pokies humming in
|
||||||
|
* their own corner, and a beer garden that is plainly the nicest room in the
|
||||||
|
* building. Week one of the ladder, and nobody here checks anything.
|
||||||
|
*
|
||||||
|
* Two decisions a future reader would otherwise undo by accident:
|
||||||
|
*
|
||||||
|
* 1. **The horseshoe's open end faces the cellar lane on purpose.** The bar is a
|
||||||
|
* ⊐ of counter with a walkable pit inside it, and the `taps` post stands in
|
||||||
|
* that pit. Closing the west end to make the island "tidy" seals the post and
|
||||||
|
* freezes the bar shift for the night — the failure the invariants exist for.
|
||||||
|
* 2. **The DJ corner is NOT a booth.** It is a folding table and a controller on
|
||||||
|
* the carpet beside the dance floor, which is the joke: this pub never built
|
||||||
|
* a booth and never will. Painting `djbooth`/`djfloor` tiles there would wall
|
||||||
|
* the DJ off from the room and flatten The Royal back into Voltage.
|
||||||
|
*
|
||||||
|
* Everything else follows Voltage's conventions: props never block (anything
|
||||||
|
* that should stop you stands on non-walkable tiles), cubicle interiors are
|
||||||
|
* sealed pockets entered via `StallDef.inside`, and the palette is tuned UNDER
|
||||||
|
* the darkness sheet rather than in isolation.
|
||||||
|
*/
|
||||||
|
import { MAP_H, MAP_W, TILE, newGrid, tileToWorld } from '../venueMap';
|
||||||
|
import type {
|
||||||
|
AnchorKind, FloorLayout, PropDef, PropEmissive, PropKind, StallDef,
|
||||||
|
TileKind, TilePoint, VenueMap, WorldPoint, ZoneLight,
|
||||||
|
} from '../venueMap';
|
||||||
|
|
||||||
|
// Two cubicles, not four — it is a pub, not a club. They share a wall column so
|
||||||
|
// there is no useless one-tile slot between them, and CUBICLE_Y sits flush under
|
||||||
|
// the dunny's north wall.
|
||||||
|
const CUBICLE_X = [65, 69] as const;
|
||||||
|
const CUBICLE_Y = 17;
|
||||||
|
|
||||||
|
/** Stools hug the counter in ones, so punters can still squeeze between them. */
|
||||||
|
const BAR_STOOLS = [17, 19, 21, 23, 25, 27] as const;
|
||||||
|
/** Banquette pairs along the bistro's north wall. */
|
||||||
|
const BISTRO_BOOTHS = [33, 37, 41, 45] as const;
|
||||||
|
/** North-west corners of the two booths that overlook the dance corner. */
|
||||||
|
const DANCE_BOOTHS = [[74, 34], [74, 39]] as const;
|
||||||
|
|
||||||
|
function buildTiles(): TileKind[] {
|
||||||
|
const { tiles, set, rect, box } = newGrid('floor');
|
||||||
|
|
||||||
|
box(0, 0, MAP_W - 1, MAP_H - 1, 'wall');
|
||||||
|
|
||||||
|
// West of the pub is not the pub — the bottle shop next door and the cellar
|
||||||
|
// stairs, neither of which you can walk into. The street door punches one
|
||||||
|
// corridor through it at the pub's midline, and that corridor is the spine
|
||||||
|
// every other room hangs off.
|
||||||
|
rect(1, 1, 10, 43, 'void');
|
||||||
|
rect(1, 13, 10, 15, 'floor');
|
||||||
|
rect(1, 12, 10, 12, 'wall');
|
||||||
|
rect(1, 16, 10, 16, 'wall');
|
||||||
|
rect(0, 13, 1, 15, 'exit');
|
||||||
|
|
||||||
|
// Pool room (north-west): a walled alcove with one wide opening, the way every
|
||||||
|
// pub pool room is — you hear it from the bar long before you see the table.
|
||||||
|
rect(11, 1, 11, 12, 'wall');
|
||||||
|
rect(31, 1, 31, 12, 'wall');
|
||||||
|
rect(11, 12, 31, 12, 'wall');
|
||||||
|
rect(19, 12, 23, 12, 'floor');
|
||||||
|
|
||||||
|
// The pub's west wall below the corridor. Between it and the horseshoe runs
|
||||||
|
// the cellar lane: kegs, fridges, the glasswasher, and the way staff get in.
|
||||||
|
rect(11, 16, 11, 43, 'wall');
|
||||||
|
|
||||||
|
// Public bar: a horseshoe island. Three runs of counter, open to the west so
|
||||||
|
// the pit inside stays reachable (see the file header), punters on the other
|
||||||
|
// three sides.
|
||||||
|
rect(16, 17, 28, 17, 'bar');
|
||||||
|
rect(16, 25, 28, 25, 'bar');
|
||||||
|
rect(28, 17, 28, 25, 'bar');
|
||||||
|
for (const tx of BAR_STOOLS) {
|
||||||
|
set(tx, 16, 'stool');
|
||||||
|
set(tx, 26, 'stool');
|
||||||
|
}
|
||||||
|
for (const ty of [18, 20, 22, 24]) set(29, ty, 'stool');
|
||||||
|
|
||||||
|
// Bistro (centre-north): open to the floor, banquettes along the north wall,
|
||||||
|
// and the bain-marie servery counter shielding its own staff side.
|
||||||
|
for (const tx of BISTRO_BOOTHS) rect(tx, 1, tx + 1, 2, 'booth');
|
||||||
|
rect(49, 2, 53, 2, 'bar');
|
||||||
|
|
||||||
|
// TAB / pokies (north-east): its own room off the spine, because the light in
|
||||||
|
// there is nobody else's business. Betting ledge and stools on the north wall.
|
||||||
|
rect(55, 1, 55, 12, 'wall');
|
||||||
|
rect(55, 12, 78, 12, 'wall');
|
||||||
|
rect(59, 12, 62, 12, 'floor');
|
||||||
|
rect(69, 3, 77, 3, 'bar');
|
||||||
|
for (const tx of [70, 72, 74, 76]) set(tx, 4, 'stool');
|
||||||
|
|
||||||
|
// Dunnies (east): one door on the west face, the trough along the north wall,
|
||||||
|
// one basin, and two cubicles of which one is a story rather than a toilet.
|
||||||
|
box(64, 16, 78, 28, 'wall');
|
||||||
|
rect(64, 21, 64, 22, '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');
|
||||||
|
}
|
||||||
|
rect(74, 17, 77, 17, 'sink');
|
||||||
|
set(70, 27, 'sink');
|
||||||
|
|
||||||
|
// Dance floor (south-east): small, late, and clearly an afterthought — a third
|
||||||
|
// of Voltage's and tucked in a corner rather than given the middle of the room.
|
||||||
|
rect(58, 34, 70, 41, 'dance');
|
||||||
|
for (const [tx, ty] of DANCE_BOOTHS) rect(tx, ty, tx + 1, ty + 1, 'booth');
|
||||||
|
|
||||||
|
// Beer garden (south): open sky inside a paling fence, one gate off the floor.
|
||||||
|
// Rain falls INSIDE the fence — the renderer keys off 'yard'.
|
||||||
|
box(12, 33, 45, 43, 'fence');
|
||||||
|
rect(13, 34, 44, 42, 'yard');
|
||||||
|
set(29, 33, 'smokeDoor');
|
||||||
|
|
||||||
|
// The only neon in the building: two beer signs in the front windows, the TAB
|
||||||
|
// sign over the pokie room door, and whatever is left of the one by the decks.
|
||||||
|
for (const tx of [40, 52]) set(tx, 0, 'neon');
|
||||||
|
set(66, 12, 'neon');
|
||||||
|
set(MAP_W - 1, 37, 'neon');
|
||||||
|
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]> {
|
||||||
|
return {
|
||||||
|
// Three faces of the horseshoe: the spine side, the garden side, and the
|
||||||
|
// short east end where the taps queue backs up.
|
||||||
|
bar: [
|
||||||
|
...[18, 20, 22, 24, 26].map((tx) => tileToWorld(tx, 15)),
|
||||||
|
...[18, 22, 26].map((tx) => tileToWorld(tx, 27)),
|
||||||
|
tileToWorld(30, 19),
|
||||||
|
tileToWorld(30, 23),
|
||||||
|
],
|
||||||
|
dance: [60, 64, 68].flatMap((tx) => [tileToWorld(tx, 36), tileToWorld(tx, 40)]),
|
||||||
|
booth: [
|
||||||
|
...BISTRO_BOOTHS.map((tx) => tileToWorld(tx, 3)),
|
||||||
|
...DANCE_BOOTHS.map(([, ty]) => tileToWorld(73, ty)),
|
||||||
|
],
|
||||||
|
toilet: CUBICLE_X.map((x) => tileToWorld(x + 1, CUBICLE_Y + 4)),
|
||||||
|
smoke: [tileToWorld(20, 37), tileToWorld(30, 38), tileToWorld(40, 36)],
|
||||||
|
entry: [tileToWorld(5, 14), tileToWorld(9, 14)],
|
||||||
|
exit: [tileToWorld(0, 14), tileToWorld(1, 14)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- props & lights ----------------------------------------------------------
|
||||||
|
|
||||||
|
const P = (
|
||||||
|
id: string, kind: PropKind, tx: number, ty: number, tw: number, th: number,
|
||||||
|
emissive?: PropEmissive,
|
||||||
|
): PropDef => (emissive ? { id, kind, tx, ty, tw, th, emissive } : { id, kind, tx, ty, tw, th });
|
||||||
|
|
||||||
|
const POKIE_GLOW: PropEmissive = { colour: 0x50b0c0, radius: 16, pulse: 'flicker' };
|
||||||
|
const FRIDGE_GLOW: PropEmissive = { colour: 0x60c0e0, radius: 16 };
|
||||||
|
const TABLE_CANDLE: PropEmissive = { colour: 0xd8a050, radius: 12 };
|
||||||
|
|
||||||
|
const PROPS: readonly PropDef[] = Object.freeze([
|
||||||
|
// public bar — the counter, then the pit, then the lane behind it
|
||||||
|
P('taps', 'taps', 21, 17, 3, 1),
|
||||||
|
P('till', 'till', 25, 17, 2, 1),
|
||||||
|
P('gantry', 'barShelf', 19, 20, 6, 2, { colour: 0xd8a020, radius: 34 }),
|
||||||
|
P('glassStack', 'glassStack', 17, 18, 2, 1),
|
||||||
|
P('iceWell', 'iceWell', 25, 22, 2, 1),
|
||||||
|
P('fridgeA', 'barFridge', 17, 22, 2, 2, FRIDGE_GLOW),
|
||||||
|
P('fridgeB', 'barFridge', 21, 23, 2, 2, FRIDGE_GLOW),
|
||||||
|
P('barmaid', 'staffBartender', 26, 19, 1, 2),
|
||||||
|
P('kegsA', 'keg', 12, 18, 2, 2),
|
||||||
|
P('kegsB', 'keg', 12, 21, 2, 2),
|
||||||
|
P('dish', 'dishwasher', 12, 28, 2, 1, { colour: 0x50c060, radius: 20 }),
|
||||||
|
P('rackA', 'glassRack', 14, 27, 2, 1),
|
||||||
|
P('rackB', 'glassRack', 14, 29, 2, 1),
|
||||||
|
P('glassie', 'staffGlassie', 13, 24, 1, 2),
|
||||||
|
// The footy is bracketed to the pool room's south wall, which is the one bit
|
||||||
|
// of wall visible from every stool on the horseshoe.
|
||||||
|
P('tvFooty', 'tvSport', 25, 12, 3, 1, { colour: 0x6090c0, radius: 22, pulse: 'flicker' }),
|
||||||
|
|
||||||
|
// pool room
|
||||||
|
P('poolA', 'poolTable', 14, 4, 3, 2),
|
||||||
|
P('poolB', 'poolTable', 22, 4, 3, 2),
|
||||||
|
P('cues', 'cueRack', 13, 8, 1, 2),
|
||||||
|
P('dartboard', 'dartboard', 18, 1, 2, 1),
|
||||||
|
P('poolLeaner', 'highTable', 27, 8, 1, 1),
|
||||||
|
|
||||||
|
// bistro
|
||||||
|
P('bistroA', 'bistroTable', 33, 5, 2, 1),
|
||||||
|
P('bistroB', 'bistroTable', 37, 5, 2, 1),
|
||||||
|
P('bistroC', 'bistroTable', 41, 5, 2, 1),
|
||||||
|
P('bistroD', 'bistroTable', 45, 5, 2, 1),
|
||||||
|
P('bistroE', 'bistroTable', 35, 8, 2, 1),
|
||||||
|
P('bistroF', 'bistroTable', 42, 8, 2, 1),
|
||||||
|
P('bainMarie', 'bainMarie', 49, 2, 5, 1, { colour: 0xe0a040, radius: 20 }),
|
||||||
|
P('raffle', 'raffleDrum', 47, 7, 2, 2),
|
||||||
|
P('bistroPlant', 'plant', 54, 10, 1, 1),
|
||||||
|
|
||||||
|
// TAB / pokies
|
||||||
|
P('pokieA', 'pokie', 57, 2, 2, 2, POKIE_GLOW),
|
||||||
|
P('pokieB', 'pokie', 60, 2, 2, 2, POKIE_GLOW),
|
||||||
|
P('pokieC', 'pokie', 63, 2, 2, 2, POKIE_GLOW),
|
||||||
|
P('pokieD', 'pokie', 57, 6, 2, 2, POKIE_GLOW),
|
||||||
|
P('pokieE', 'pokie', 60, 6, 2, 2, POKIE_GLOW),
|
||||||
|
P('pokieF', 'pokie', 63, 6, 2, 2, POKIE_GLOW),
|
||||||
|
P('tabA', 'tabBoard', 69, 1, 4, 2, { colour: 0x50b0c0, radius: 18 }),
|
||||||
|
P('tabB', 'tabBoard', 74, 1, 4, 2, { colour: 0x50b0c0, radius: 18 }),
|
||||||
|
P('tabRace', 'tvSport', 66, 9, 3, 2, { colour: 0x6090c0, radius: 18 }),
|
||||||
|
|
||||||
|
// dunnies — the trough, the one basin, and the cubicle that has been "getting
|
||||||
|
// looked at" since the Blues won a series
|
||||||
|
P('trough', 'trough', 74, 17, 4, 1),
|
||||||
|
P('basin', 'sinkRow', 70, 27, 1, 1),
|
||||||
|
P('cubDoorA', 'cubicleDoor', 66, 20, 1, 1),
|
||||||
|
P('cubDoorB', 'cubicleDoor', 70, 20, 1, 1),
|
||||||
|
P('outOfOrder', 'wetFloor', 70, 21, 1, 1),
|
||||||
|
P('mopBucket', 'mopBucket', 71, 22, 1, 1),
|
||||||
|
P('dryer', 'handDryer', 77, 26, 1, 1),
|
||||||
|
|
||||||
|
// dance corner + the folding-table DJ
|
||||||
|
P('ball', 'discoball', 63, 37, 2, 2, { colour: 0xe8c8e0, radius: 20 }),
|
||||||
|
P('jukebox', 'jukebox', 77, 36, 2, 2, { colour: 0xd03470, radius: 18, pulse: 'flicker' }),
|
||||||
|
P('spkA', 'speaker', 57, 34, 1, 2),
|
||||||
|
P('spkB', 'speaker', 71, 34, 1, 2),
|
||||||
|
P('djTable', 'highTable', 53, 36, 3, 1),
|
||||||
|
P('djDeck', 'deck', 53, 35, 2, 1),
|
||||||
|
P('djMixer', 'mixer', 55, 35, 1, 1),
|
||||||
|
P('djMonitor', 'monitor', 56, 37, 1, 1),
|
||||||
|
P('tblDanceN', 'boothTable', 73, 35, 1, 1, TABLE_CANDLE),
|
||||||
|
P('tblDanceS', 'boothTable', 73, 40, 1, 1, TABLE_CANDLE),
|
||||||
|
|
||||||
|
// beer garden — the nicest room in the pub, and everyone knows it
|
||||||
|
P('picnicA', 'picnicTable', 15, 36, 3, 2),
|
||||||
|
P('picnicB', 'picnicTable', 21, 36, 3, 2),
|
||||||
|
P('picnicC', 'picnicTable', 27, 36, 3, 2),
|
||||||
|
P('picnicD', 'picnicTable', 33, 36, 3, 2),
|
||||||
|
P('picnicE', 'picnicTable', 39, 36, 3, 2),
|
||||||
|
// Umbrellas are listed after their tables so they draw over them — top-down,
|
||||||
|
// an umbrella is the roof of the table it belongs to.
|
||||||
|
P('umbA', 'beerUmbrella', 21, 35, 3, 3),
|
||||||
|
P('umbB', 'beerUmbrella', 33, 35, 3, 3),
|
||||||
|
P('heater', 'patioHeater', 43, 38, 1, 2, { colour: 0xe07030, radius: 34, pulse: 'flicker' }),
|
||||||
|
P('buttBin', 'buttBin', 14, 34, 1, 1),
|
||||||
|
P('ashA', 'ashtray', 22, 40, 1, 1),
|
||||||
|
P('ashB', 'ashtray', 34, 40, 1, 1),
|
||||||
|
P('yardPlant', 'plant', 43, 34, 1, 1),
|
||||||
|
P('yardCrate', 'crate', 14, 41, 1, 1),
|
||||||
|
|
||||||
|
// the middle of the room, which in a pub is where you stand rather than sit
|
||||||
|
P('highA', 'highTable', 36, 20, 1, 1),
|
||||||
|
P('highB', 'highTable', 44, 24, 1, 1),
|
||||||
|
P('highC', 'highTable', 52, 20, 1, 1),
|
||||||
|
P('midPlant', 'plant', 38, 30, 1, 1),
|
||||||
|
|
||||||
|
// entry corridor
|
||||||
|
P('poster1', 'poster1', 3, 13, 2, 1),
|
||||||
|
P('poster2', 'poster2', 6, 13, 2, 1),
|
||||||
|
P('poster3', 'poster3', 9, 13, 2, 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const L = (
|
||||||
|
id: string, tx: number, ty: number, colour: number, radius: number,
|
||||||
|
pulse?: 'beat' | 'flicker',
|
||||||
|
): ZoneLight => (pulse ? { id, tx, ty, colour, radius, pulse } : { id, tx, ty, colour, radius });
|
||||||
|
|
||||||
|
const AMBER = 0xd8a020;
|
||||||
|
/** The Royal's one signature colour (docs/VENUES.md §1): the pokies' own sick blue. */
|
||||||
|
const POKIE_BLUE = 0x4aa0c0;
|
||||||
|
|
||||||
|
const LIGHTS: readonly ZoneLight[] = Object.freeze([
|
||||||
|
// Warm amber is the house light here — a pub lights the beer, not the room.
|
||||||
|
L('bar0', 18, 16, AMBER, 40),
|
||||||
|
L('bar1', 24, 16, AMBER, 40),
|
||||||
|
L('bar2', 29, 21, AMBER, 40),
|
||||||
|
L('bar3', 22, 26, AMBER, 40),
|
||||||
|
L('lane', 15, 21, AMBER, 34),
|
||||||
|
L('hallW', 33, 14, AMBER, 36),
|
||||||
|
L('hallE', 58, 15, AMBER, 36),
|
||||||
|
L('hallS', 40, 30, AMBER, 36),
|
||||||
|
// Low green cone over each table, like every pool table ever hung.
|
||||||
|
L('poolA', 15, 5, 0x30a050, 34),
|
||||||
|
L('poolB', 23, 5, 0x30a050, 34),
|
||||||
|
L('poolRoom', 20, 10, AMBER, 30),
|
||||||
|
L('bistroW', 36, 7, AMBER, 40),
|
||||||
|
L('bistroE', 47, 4, AMBER, 40),
|
||||||
|
L('bain', 51, 2, AMBER, 26),
|
||||||
|
L('pokieN', 60, 4, POKIE_BLUE, 42),
|
||||||
|
L('pokieS', 60, 8, POKIE_BLUE, 36),
|
||||||
|
L('tab', 73, 3, POKIE_BLUE, 40),
|
||||||
|
L('looN', 69, 18, 0x50c060, 40, 'flicker'),
|
||||||
|
L('looS', 70, 24, 0x50c060, 46, 'flicker'),
|
||||||
|
// The only pink in the building bar the entry spill — pulses on the real beat.
|
||||||
|
...[60, 64, 68].flatMap((tx) =>
|
||||||
|
[36, 40].map((ty) => L(`dg${tx}x${ty}`, tx, ty, 0xd03470, 24, 'beat')),
|
||||||
|
),
|
||||||
|
L('dj', 55, 36, 0x3060c0, 36),
|
||||||
|
L('yardW', 20, 38, 0x6080a0, 48),
|
||||||
|
L('yardE', 37, 38, 0x6080a0, 48),
|
||||||
|
// Entry keeps the house pink: navigate-by-glow is a rule across all four
|
||||||
|
// venues (SPACES.md §1), so the way out has to read the same everywhere.
|
||||||
|
L('entry', 3, 14, 0xd03470, 34),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts (docs/VENUES.md §1). `taps` stands in the horseshoe's pit — the whole
|
||||||
|
* reason its west end is open. `decks` is the carpet behind the folding table,
|
||||||
|
* because this pub has no booth to stand in.
|
||||||
|
*/
|
||||||
|
const POSTS: Readonly<Record<string, TilePoint>> = Object.freeze({
|
||||||
|
taps: { tx: 22, ty: 18 },
|
||||||
|
decks: { tx: 54, ty: 37 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const PROBES: Readonly<Record<string, TilePoint>> = Object.freeze({
|
||||||
|
/** The punter side of the taps, between two stools. */
|
||||||
|
tapsFront: { tx: 22, ty: 16 },
|
||||||
|
/** The glassie run: full racks land at the east end of the counter. */
|
||||||
|
rack: { tx: 30, ty: 20 },
|
||||||
|
dishwasher: { tx: 13, ty: 28 },
|
||||||
|
/** RSA water is a jug on the bar here — the dunnies have a trough, not a tap. */
|
||||||
|
water: { tx: 20, ty: 17 },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pub materials. Values sit in the same brightness band as venueMap's COLOURS
|
||||||
|
* because the renderer multiplies all of this down — a carpet picked in
|
||||||
|
* isolation renders as black in the room.
|
||||||
|
*/
|
||||||
|
const PALETTE: FloorLayout['palette'] = Object.freeze({
|
||||||
|
floor: 0x2e2018, // patterned carpet, red-brown, tacky underfoot
|
||||||
|
wall: 0x403528, // plaster the colour of thirty years of cigarettes
|
||||||
|
bar: 0x63432a, // varnished timber counter
|
||||||
|
booth: 0x4a2b2b, // maroon vinyl banquette
|
||||||
|
dance: 0x3a2c3a, // the 2004 parquet, laid over the carpet, never re-laid
|
||||||
|
yard: 0x2f3134, // decking under open sky
|
||||||
|
fence: 0x50483c, // paling
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAP: VenueMap = Object.freeze({
|
||||||
|
width: MAP_W,
|
||||||
|
height: MAP_H,
|
||||||
|
tiles: Object.freeze(buildTiles()),
|
||||||
|
anchors: Object.freeze(buildAnchors()),
|
||||||
|
stalls: Object.freeze(buildStalls()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const THE_ROYAL: FloorLayout = Object.freeze({
|
||||||
|
id: 'theRoyal',
|
||||||
|
map: MAP,
|
||||||
|
props: PROPS,
|
||||||
|
lights: LIGHTS,
|
||||||
|
posts: POSTS,
|
||||||
|
probes: PROBES,
|
||||||
|
palette: PALETTE,
|
||||||
|
});
|
||||||
@ -4,7 +4,8 @@ import { SWEEP_UI } from '../../data/strings/floor';
|
|||||||
import type { RngStream } from '../../core/SeededRNG';
|
import type { RngStream } from '../../core/SeededRNG';
|
||||||
import type { CrowdSim } from './crowdSim';
|
import type { CrowdSim } from './crowdSim';
|
||||||
import type { FloorView } from './FloorView';
|
import type { FloorView } from './FloorView';
|
||||||
import { MAP_H, MAP_W, VENUE, isWalkable, tileToWorld } from './venueMap';
|
import { MAP_H, MAP_W, isWalkable, tileToWorld } from './venueMap';
|
||||||
|
import type { VenueMap } from './venueMap';
|
||||||
import { stepPlayer, type PlayerInput, type PlayerState } from './player';
|
import { stepPlayer, type PlayerInput, type PlayerState } from './player';
|
||||||
|
|
||||||
// The floor score (docs/SCENARIOS.md): lights on at close, and the floor tells
|
// The floor score (docs/SCENARIOS.md): lights on at close, and the floor tells
|
||||||
@ -18,6 +19,9 @@ export interface SweepHost {
|
|||||||
* sweep is constructed, and the sweep walks the player the scene renders. */
|
* sweep is constructed, and the sweep walks the player the scene renders. */
|
||||||
crowd(): CrowdSim;
|
crowd(): CrowdSim;
|
||||||
view(): FloorView;
|
view(): FloorView;
|
||||||
|
/** Tonight's room. An accessor for the same reason the rest are: the scene
|
||||||
|
* resolves its layout in create(), and the sweep outlives no single night. */
|
||||||
|
map(): VenueMap;
|
||||||
getPlayer(): PlayerState;
|
getPlayer(): PlayerState;
|
||||||
setPlayer(p: PlayerState): void;
|
setPlayer(p: PlayerState): void;
|
||||||
readInput(): PlayerInput;
|
readInput(): PlayerInput;
|
||||||
@ -82,7 +86,7 @@ export class Sweep {
|
|||||||
for (let tries = 0; tries < 60; tries++) {
|
for (let tries = 0; tries < 60; tries++) {
|
||||||
tx = rng.int(2, MAP_W - 3);
|
tx = rng.int(2, MAP_W - 3);
|
||||||
ty = rng.int(2, MAP_H - 3);
|
ty = rng.int(2, MAP_H - 3);
|
||||||
if (isWalkable(VENUE, tx, ty)) break;
|
if (isWalkable(this.host.map(), tx, ty)) break;
|
||||||
}
|
}
|
||||||
const { x, y } = tileToWorld(tx, ty);
|
const { x, y } = tileToWorld(tx, ty);
|
||||||
const gfx = this.scene.add.container(x, y).setDepth(39);
|
const gfx = this.scene.add.container(x, y).setDepth(39);
|
||||||
@ -101,7 +105,7 @@ export class Sweep {
|
|||||||
update(dt: number): void {
|
update(dt: number): void {
|
||||||
if (!this.active) return;
|
if (!this.active) return;
|
||||||
if (!this.baggiePending) {
|
if (!this.baggiePending) {
|
||||||
this.host.setPlayer(stepPlayer(this.host.getPlayer(), this.host.readInput(), dt, VENUE));
|
this.host.setPlayer(stepPlayer(this.host.getPlayer(), this.host.readInput(), dt, this.host.map()));
|
||||||
}
|
}
|
||||||
const p = this.host.getPlayer();
|
const p = this.host.getPlayer();
|
||||||
this.scene.cameras.main.centerOn(p.x, p.y);
|
this.scene.cameras.main.centerOn(p.x, p.y);
|
||||||
@ -125,7 +129,7 @@ export class Sweep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private atExit(): boolean {
|
private atExit(): boolean {
|
||||||
const entry = VENUE.anchors.entry[0];
|
const entry = this.host.map().anchors.entry[0];
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
const p = this.host.getPlayer();
|
const p = this.host.getPlayer();
|
||||||
return Phaser.Math.Distance.Between(p.x, p.y, entry.x, entry.y) <= this.interactRange;
|
return Phaser.Math.Distance.Between(p.x, p.y, entry.x, entry.y) <= this.interactRange;
|
||||||
|
|||||||
@ -66,8 +66,25 @@ const COLOURS: Record<TileKind, number> = {
|
|||||||
const CUBICLE_X = [61, 65, 69, 73] as const;
|
const CUBICLE_X = [61, 65, 69, 73] as const;
|
||||||
const CUBICLE_Y = 4;
|
const CUBICLE_Y = 4;
|
||||||
|
|
||||||
function buildTiles(): TileKind[] {
|
/**
|
||||||
const tiles = new Array<TileKind>(MAP_W * MAP_H).fill('floor');
|
* The tile-painting primitives every layout is built from (docs/VENUES.md §1).
|
||||||
|
*
|
||||||
|
* Pulled out of Voltage's own builder when the venue ladder grew four rooms:
|
||||||
|
* each layout paints the same 80x45 grid with the same three verbs, so a wall
|
||||||
|
* means a wall everywhere and the reachability invariants can be asserted
|
||||||
|
* uniformly. `set` clips silently at the grid edge — a layout that paints one
|
||||||
|
* tile past the boundary should not take the whole venue down with it.
|
||||||
|
*/
|
||||||
|
export interface GridBuilder {
|
||||||
|
readonly tiles: TileKind[];
|
||||||
|
set(tx: number, ty: number, kind: TileKind): void;
|
||||||
|
rect(x0: number, y0: number, x1: number, y1: number, kind: TileKind): void;
|
||||||
|
/** Outline only — the four edges, hollow centre. */
|
||||||
|
box(x0: number, y0: number, x1: number, y1: number, kind: TileKind): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newGrid(fill: TileKind = 'floor'): GridBuilder {
|
||||||
|
const tiles = new Array<TileKind>(MAP_W * MAP_H).fill(fill);
|
||||||
const set = (tx: number, ty: number, kind: TileKind): void => {
|
const set = (tx: number, ty: number, kind: TileKind): void => {
|
||||||
if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) return;
|
if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) return;
|
||||||
tiles[ty * MAP_W + tx] = kind;
|
tiles[ty * MAP_W + tx] = kind;
|
||||||
@ -81,6 +98,11 @@ function buildTiles(): TileKind[] {
|
|||||||
rect(x0, y0, x0, y1, kind);
|
rect(x0, y0, x0, y1, kind);
|
||||||
rect(x1, y0, x1, y1, kind);
|
rect(x1, y0, x1, y1, kind);
|
||||||
};
|
};
|
||||||
|
return { tiles, set, rect, box };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTiles(): TileKind[] {
|
||||||
|
const { tiles, set, rect, box } = newGrid('floor');
|
||||||
|
|
||||||
box(0, 0, MAP_W - 1, MAP_H - 1, 'wall');
|
box(0, 0, MAP_W - 1, MAP_H - 1, 'wall');
|
||||||
|
|
||||||
@ -186,7 +208,25 @@ export type PropKind =
|
|||||||
| 'buttBin' | 'crate' | 'plant' | 'wetFloor' | 'poolTable' | 'dishwasher'
|
| 'buttBin' | 'crate' | 'plant' | 'wetFloor' | 'poolTable' | 'dishwasher'
|
||||||
| 'urinal' | 'handDryer' | 'cubicleDoor' | 'mopBucket' | 'cueRack'
|
| 'urinal' | 'handDryer' | 'cubicleDoor' | 'mopBucket' | 'cueRack'
|
||||||
| 'barFridge' | 'iceWell' | 'patioHeater' | 'truss'
|
| 'barFridge' | 'iceWell' | 'patioHeater' | 'truss'
|
||||||
| 'poster1' | 'poster2' | 'poster3' | 'cloak' | 'stampPodium' | 'arcade';
|
| 'poster1' | 'poster2' | 'poster3' | 'cloak' | 'stampPodium' | 'arcade'
|
||||||
|
// ---- the venue expansion (docs/VENUES.md §6) --------------------------------
|
||||||
|
// The Royal — a suburban pub that had a dance floor bolted on in 2004.
|
||||||
|
| 'pokie' | 'tabBoard' | 'bistroTable' | 'picnicTable' | 'trough' | 'keg'
|
||||||
|
| 'dartboard' | 'jukebox' | 'raffleDrum' | 'bainMarie' | 'tvSport'
|
||||||
|
| 'beerUmbrella' | 'ashtray'
|
||||||
|
// Elevate — rooftop, open sky, everyone is on a list.
|
||||||
|
| 'cocktailBar' | 'banquette' | 'ropePost' | 'planter' | 'balustrade'
|
||||||
|
| 'djPlinth' | 'marbleSink' | 'ashUrn' | 'champBucket' | 'loungeChair'
|
||||||
|
| 'liftDoor' | 'skylineCard'
|
||||||
|
// ROOM — concrete, no signage, the sound system is the whole budget.
|
||||||
|
| 'stackF1' | 'subBass' | 'plywoodBar' | 'rollerDoor' | 'pallet'
|
||||||
|
| 'wheelieBin' | 'oldCouch' | 'pillar' | 'ruleBoard' | 'strobe'
|
||||||
|
| 'smokeMachine' | 'cableSnake' | 'brokenBasin' | 'canStack' | 'cdj'
|
||||||
|
// Staff figures are SET DRESSING, never patrons. The procedural dolls stay:
|
||||||
|
// dollPlan draws the exact items the dress code convicts on, so a
|
||||||
|
// pre-rendered patron would make the convictions invisible (FABLE-SOLO-23).
|
||||||
|
// A bartender who never moves carries no mechanical weight, so she can be art.
|
||||||
|
| 'staffBartender' | 'staffDj' | 'staffGlassie' | 'staffSecurity';
|
||||||
|
|
||||||
export interface PropEmissive {
|
export interface PropEmissive {
|
||||||
colour: number;
|
colour: number;
|
||||||
@ -350,6 +390,31 @@ export const PROBES: Readonly<Record<string, TilePoint>> = Object.freeze({
|
|||||||
water: { tx: 68, ty: 15 },
|
water: { tx: 68, ty: 15 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One venue's room, complete (docs/VENUES.md §1).
|
||||||
|
*
|
||||||
|
* Everything a floor needs to exist lives in here rather than in module
|
||||||
|
* singletons, which is the change that made four venues possible at all — the
|
||||||
|
* old shape exported ONE `VENUE`/`PROPS`/`LIGHTS`, so every venue in the ladder
|
||||||
|
* was the same room with different difficulty numbers on it.
|
||||||
|
*
|
||||||
|
* `palette` is how a venue gets its own materials without inventing TileKinds:
|
||||||
|
* The Royal's floor is pub carpet and ROOM's is bare concrete, but both are
|
||||||
|
* still `'floor'` to the sim, so walkability, pathing and every existing test
|
||||||
|
* keep working untouched.
|
||||||
|
*/
|
||||||
|
export interface FloorLayout {
|
||||||
|
/** Matches `VenueDef.id` in data/venues.ts — that is how a night finds its room. */
|
||||||
|
id: string;
|
||||||
|
map: VenueMap;
|
||||||
|
props: readonly PropDef[];
|
||||||
|
lights: readonly ZoneLight[];
|
||||||
|
posts: Readonly<Record<string, TilePoint>>;
|
||||||
|
probes: Readonly<Record<string, TilePoint>>;
|
||||||
|
/** Per-venue tile colours, merged over COLOURS. */
|
||||||
|
palette?: Readonly<Partial<Record<TileKind, number>>>;
|
||||||
|
}
|
||||||
|
|
||||||
export function tileAt(map: VenueMap, tx: number, ty: number): TileKind {
|
export function tileAt(map: VenueMap, tx: number, ty: number): TileKind {
|
||||||
if (tx < 0 || tx >= map.width || ty < 0 || ty >= map.height) return 'void';
|
if (tx < 0 || tx >= map.width || ty < 0 || ty >= map.height) return 'void';
|
||||||
return map.tiles[ty * map.width + tx] ?? 'void';
|
return map.tiles[ty * map.width + tx] ?? 'void';
|
||||||
@ -364,7 +429,29 @@ export function isWalkableWorld(map: VenueMap, x: number, y: number): boolean {
|
|||||||
return isWalkable(map, tx, ty);
|
return isWalkable(map, tx, ty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Colour for painting a tile in the dark venue. */
|
/**
|
||||||
export function tileColour(kind: TileKind): number {
|
* Colour for painting a tile in the dark venue, with the venue's own materials
|
||||||
return COLOURS[kind];
|
* layered over the house defaults. Tuned UNDER the darkness sheet — a palette
|
||||||
|
* that looks right in isolation multiplies down to near-black in the room.
|
||||||
|
*/
|
||||||
|
export function tileColour(kind: TileKind, palette?: FloorLayout['palette']): number {
|
||||||
|
return palette?.[kind] ?? COLOURS[kind];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voltage — the valley institution, and the reference room.
|
||||||
|
*
|
||||||
|
* This is the map that shipped, tuned under the darkness sheet with its
|
||||||
|
* reachability asserted, so it stays the DEFAULT layout and the baseline the
|
||||||
|
* other three are judged against. It lives here rather than in layouts/ purely
|
||||||
|
* to keep the module graph acyclic: the other layouts import these primitives,
|
||||||
|
* and nothing imports back.
|
||||||
|
*/
|
||||||
|
export const VOLTAGE: FloorLayout = Object.freeze({
|
||||||
|
id: 'voltage',
|
||||||
|
map: VENUE,
|
||||||
|
props: PROPS,
|
||||||
|
lights: LIGHTS,
|
||||||
|
posts: POSTS,
|
||||||
|
probes: PROBES,
|
||||||
|
});
|
||||||
|
|||||||
122
tests/floor/layout-elevate.test.ts
Normal file
122
tests/floor/layout-elevate.test.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
// Elevate is the venue that reads SMALL and OUTDOORS. Both of those are carried
|
||||||
|
// entirely by tile kinds — void for sky, yard for the open deck — so they are
|
||||||
|
// exactly the properties a well-meaning tidy-up would flatten back into
|
||||||
|
// Voltage without noticing. These pin them.
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ELEVATE } from '../../src/scenes/floor/layouts/elevate';
|
||||||
|
import { assertLayout } from './layoutInvariants';
|
||||||
|
import {
|
||||||
|
MAP_H, MAP_W, isWalkable, tileAt, worldToTile,
|
||||||
|
} from '../../src/scenes/floor/venueMap';
|
||||||
|
import type { TileKind } from '../../src/scenes/floor/venueMap';
|
||||||
|
|
||||||
|
const { map, props, lights } = ELEVATE;
|
||||||
|
|
||||||
|
const countTiles = (kind: TileKind): number =>
|
||||||
|
map.tiles.reduce((n, t) => (t === kind ? n + 1 : n), 0);
|
||||||
|
|
||||||
|
const NEIGHBOURS = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const;
|
||||||
|
|
||||||
|
describe('ELEVATE layout', () => {
|
||||||
|
it('satisfies the shared venue invariants', () => {
|
||||||
|
assertLayout(ELEVATE, {
|
||||||
|
bar: 3, dance: 4, booth: 4, toilet: 2, smoke: 2, entry: 1, exit: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a small room in a lot of sky', () => {
|
||||||
|
// VENUES.md §1: venues differ by arrangement, never by grid extent, so
|
||||||
|
// "small" is spelled with void. More than a third of the grid is sky.
|
||||||
|
expect(countTiles('void')).toBeGreaterThan((MAP_W * MAP_H) / 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is outdoors — the deck is yard, not floor', () => {
|
||||||
|
// FloorView hangs its rain emitter off 'yard'. Repainting the terrace as
|
||||||
|
// 'floor' would switch the weather off and cost the venue its character.
|
||||||
|
expect(countTiles('yard')).toBeGreaterThan(3 * countTiles('floor'));
|
||||||
|
// The only roofed pockets are the lift lobby and the dunnies.
|
||||||
|
for (let ty = 0; ty < MAP_H; ty++) {
|
||||||
|
for (let tx = 0; tx < MAP_W; tx++) {
|
||||||
|
if (tileAt(map, tx, ty) !== 'floor') continue;
|
||||||
|
const indoors = tx <= 14 || (tx >= 46 && tx <= 56);
|
||||||
|
expect(indoors, `floor tile ${tx},${ty} is out in the rain`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no smoking room — the whole roof is open air', () => {
|
||||||
|
expect(countTiles('smokeDoor')).toBe(0);
|
||||||
|
for (const a of map.anchors.smoke) {
|
||||||
|
const t = worldToTile(a.x, a.y);
|
||||||
|
expect(tileAt(map, t.tx, t.ty)).toBe('yard');
|
||||||
|
}
|
||||||
|
// The designated corner is dressed with an urn and heat lamps, not a fence.
|
||||||
|
expect(props.filter((p) => p.kind === 'ashUrn').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(props.filter((p) => p.kind === 'patioHeater').length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the DJ on display: a plinth, not a sealed booth', () => {
|
||||||
|
expect(countTiles('djbooth')).toBe(0);
|
||||||
|
expect(countTiles('djfloor')).toBeGreaterThan(0);
|
||||||
|
// Every plinth tile touches somewhere the crowd can stand — nothing is
|
||||||
|
// walled off around the DJ, which is the difference from Voltage.
|
||||||
|
for (let ty = 0; ty < MAP_H; ty++) {
|
||||||
|
for (let tx = 0; tx < MAP_W; tx++) {
|
||||||
|
if (tileAt(map, tx, ty) !== 'djfloor') continue;
|
||||||
|
const open = NEIGHBOURS.some(([dx, dy]) => isWalkable(map, tx + dx, ty + dy));
|
||||||
|
expect(open, `plinth tile ${tx},${ty} is enclosed`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(props.some((p) => p.kind === 'djPlinth')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has two unisex cubicles and no urinals', () => {
|
||||||
|
expect(map.stalls).toHaveLength(2);
|
||||||
|
expect(props.some((p) => p.kind === 'urinal')).toBe(false);
|
||||||
|
expect(props.some((p) => p.kind === 'marbleSink')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves cocktails, not beer', () => {
|
||||||
|
expect(props.some((p) => p.kind === 'taps')).toBe(false);
|
||||||
|
expect(props.some((p) => p.kind === 'cocktailBar')).toBe(true);
|
||||||
|
expect(props.filter((p) => p.kind === 'champBucket').length).toBeGreaterThanOrEqual(2);
|
||||||
|
// The 'taps' post still has to exist — it is the bartender role's station,
|
||||||
|
// here the serving side of the cocktail bar.
|
||||||
|
expect(isWalkable(map, ELEVATE.posts.taps!.tx, ELEVATE.posts.taps!.ty)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('you arrive by lift, not off the street', () => {
|
||||||
|
for (const a of map.anchors.exit) {
|
||||||
|
const t = worldToTile(a.x, a.y);
|
||||||
|
expect(t.tx).toBe(0);
|
||||||
|
expect(tileAt(map, t.tx, t.ty)).toBe('exit');
|
||||||
|
}
|
||||||
|
expect(props.some((p) => p.kind === 'liftDoor')).toBe(true);
|
||||||
|
// The lobby is the one place you are dry and being looked at.
|
||||||
|
expect(props.some((p) => p.kind === 'stampPodium')).toBe(true);
|
||||||
|
expect(props.filter((p) => p.kind === 'ropePost').length).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edges the terrace in glass with the city beyond it', () => {
|
||||||
|
// South and east boundaries are balustrade, not parapet.
|
||||||
|
for (let tx = 14; tx <= 58; tx++) expect(tileAt(map, tx, 37)).toBe('fence');
|
||||||
|
for (let ty = 7; ty <= 37; ty++) expect(tileAt(map, 58, ty)).toBe('fence');
|
||||||
|
expect(props.filter((p) => p.kind === 'balustrade').length).toBeGreaterThanOrEqual(4);
|
||||||
|
// Skyline cards are scenery: they hang out in the void past the glass.
|
||||||
|
const sky = props.filter((p) => p.kind === 'skylineCard');
|
||||||
|
expect(sky.length).toBeGreaterThanOrEqual(4);
|
||||||
|
for (const card of sky) {
|
||||||
|
expect(tileAt(map, card.tx, card.ty), `${card.id} is on the deck`).toBe('void');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds exactly one signature colour to the house set', () => {
|
||||||
|
// SPACES.md §1: you navigate by glow. Elevate's own colour is skyline gold;
|
||||||
|
// a second invention breaks the rule for every venue at once.
|
||||||
|
const colours = new Set(lights.map((l) => l.colour));
|
||||||
|
expect(colours.has(0xd8a848)).toBe(true);
|
||||||
|
expect(colours.size).toBe(6);
|
||||||
|
expect(lights.some((l) => l.pulse === 'beat')).toBe(true);
|
||||||
|
expect(lights.some((l) => l.pulse === 'flicker')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
135
tests/floor/layout-room.test.ts
Normal file
135
tests/floor/layout-room.test.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ROOM } from '../../src/scenes/floor/layouts/room';
|
||||||
|
import { isWalkable, tileAt, worldToTile } from '../../src/scenes/floor/venueMap';
|
||||||
|
import { assertLayout } from './layoutInvariants';
|
||||||
|
|
||||||
|
const tiles = (predicate: (kind: ReturnType<typeof tileAt>) => boolean): number => {
|
||||||
|
let n = 0;
|
||||||
|
for (let ty = 0; ty < ROOM.map.height; ty++) {
|
||||||
|
for (let tx = 0; tx < ROOM.map.width; tx++) if (predicate(tileAt(ROOM.map, tx, ty))) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ROOM layout', () => {
|
||||||
|
it('satisfies the shared venue invariants', () => {
|
||||||
|
assertLayout(ROOM, { bar: 4, dance: 6, booth: 1, toilet: 3, smoke: 2, entry: 1, exit: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the venue id the ladder looks it up by', () => {
|
||||||
|
// data/venues.ts: a night finds its room by VenueDef.id, so a typo here is
|
||||||
|
// a venue that silently falls back to Voltage.
|
||||||
|
expect(ROOM.id).toBe('room');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the pillars: this venue's movement character ---------------------------
|
||||||
|
it('scatters concrete pillars through the main floor as real obstacles', () => {
|
||||||
|
const pillars = ROOM.props.filter((p) => p.kind === 'pillar');
|
||||||
|
expect(pillars.length).toBeGreaterThanOrEqual(8);
|
||||||
|
for (const p of pillars) {
|
||||||
|
// A pillar prop over walkable tiles is decoration; the whole point is
|
||||||
|
// that you path AROUND these.
|
||||||
|
for (let ty = p.ty; ty < p.ty + p.th; ty++) {
|
||||||
|
for (let tx = p.tx; tx < p.tx + p.tw; tx++) {
|
||||||
|
expect(isWalkable(ROOM.map, tx, ty), `pillar '${p.id}' is walkable at ${tx},${ty}`).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the DJ: on the floor, not in a booth ------------------------------------
|
||||||
|
it('puts the DJ low and central with no barrier round the desk', () => {
|
||||||
|
const decks = ROOM.posts.decks!;
|
||||||
|
// Central-ish: within a quarter of the grid of the middle, in both axes.
|
||||||
|
expect(Math.abs(decks.tx - ROOM.map.width / 2)).toBeLessThan(ROOM.map.width / 4);
|
||||||
|
expect(Math.abs(decks.ty - ROOM.map.height / 2)).toBeLessThan(ROOM.map.height / 4);
|
||||||
|
// The DJ stands on the same tiles the crowd does — a `djbooth` or `djfloor`
|
||||||
|
// tile under the post means somebody has walled the booth back in.
|
||||||
|
expect(tileAt(ROOM.map, decks.tx, decks.ty)).toBe('dance');
|
||||||
|
// ...and the desk is genuinely open: three of the four sides of the post
|
||||||
|
// are walkable, so you can be shoved off it.
|
||||||
|
const sides: readonly (readonly [number, number])[] = [[1, 0], [-1, 0], [0, -1]];
|
||||||
|
const open = sides.filter(([dx, dy]) => isWalkable(ROOM.map, decks.tx + dx, decks.ty + dy));
|
||||||
|
expect(open).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs CDJs, not turntables', () => {
|
||||||
|
expect(ROOM.props.some((p) => p.kind === 'cdj')).toBe(true);
|
||||||
|
expect(ROOM.props.some((p) => p.kind === 'deck')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the dunnies -------------------------------------------------------------
|
||||||
|
it('has three cubicles, a cracked basin and no mirror', () => {
|
||||||
|
expect(ROOM.map.stalls).toHaveLength(3);
|
||||||
|
expect(ROOM.props.filter((p) => p.kind === 'brokenBasin')).toHaveLength(1);
|
||||||
|
// Only one of the three still has a door on it; the other two tell their
|
||||||
|
// story with what is on the floor outside them.
|
||||||
|
expect(ROOM.props.filter((p) => p.kind === 'cubicleDoor')).toHaveLength(1);
|
||||||
|
expect(ROOM.props.filter((p) => p.kind === 'wetFloor').length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(ROOM.props.some((p) => p.kind === 'mopBucket')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the loading dock --------------------------------------------------------
|
||||||
|
it('smokes on the loading dock: open-sky yard behind a fence and a roller door', () => {
|
||||||
|
for (const a of ROOM.map.anchors.smoke) {
|
||||||
|
const t = worldToTile(a.x, a.y);
|
||||||
|
expect(tileAt(ROOM.map, t.tx, t.ty), `smoke anchor ${t.tx},${t.ty} is not open sky`).toBe('yard');
|
||||||
|
}
|
||||||
|
expect(tiles((k) => k === 'yard')).toBeGreaterThan(80);
|
||||||
|
expect(tiles((k) => k === 'fence')).toBeGreaterThan(0);
|
||||||
|
expect(ROOM.props.some((p) => p.kind === 'rollerDoor')).toBe(true);
|
||||||
|
expect(ROOM.props.some((p) => p.kind === 'wheelieBin')).toBe(true);
|
||||||
|
expect(ROOM.props.filter((p) => p.kind === 'pallet').length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the bar -----------------------------------------------------------------
|
||||||
|
it('serves cans off a plywood plank, with no taps and no till', () => {
|
||||||
|
expect(ROOM.props.some((p) => p.kind === 'plywoodBar')).toBe(true);
|
||||||
|
expect(ROOM.props.some((p) => p.kind === 'canStack')).toBe(true);
|
||||||
|
expect(ROOM.props.some((p) => p.kind === 'taps' || p.kind === 'till')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the board ---------------------------------------------------------------
|
||||||
|
it('hangs THE BOARD by the entry, where shuffleDressCode can be read', () => {
|
||||||
|
const board = ROOM.props.find((p) => p.kind === 'ruleBoard');
|
||||||
|
expect(board).toBeDefined();
|
||||||
|
const entry = worldToTile(ROOM.map.anchors.entry[0]!.x, ROOM.map.anchors.entry[0]!.y);
|
||||||
|
expect(Math.abs(board!.tx - entry.tx)).toBeLessThanOrEqual(12);
|
||||||
|
expect(Math.abs(board!.ty - entry.ty)).toBeLessThanOrEqual(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- no branding, and no light ------------------------------------------------
|
||||||
|
it('paints no neon anywhere, because branding is how you get found', () => {
|
||||||
|
expect(tiles((k) => k === 'neon')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is darker than Voltage: fewer lights, tighter radii, sodium over the dock', () => {
|
||||||
|
// Voltage runs 19 zone lights with dance pools at radius 26. If ROOM ever
|
||||||
|
// catches up with that, the torch has stopped being the mechanic.
|
||||||
|
expect(ROOM.lights.length).toBeLessThanOrEqual(14);
|
||||||
|
for (const l of ROOM.lights) expect(l.radius, `light '${l.id}' is too big for ROOM`).toBeLessThanOrEqual(40);
|
||||||
|
const dance = ROOM.lights.filter((l) => l.colour === 0xd03470 && l.pulse === 'beat');
|
||||||
|
expect(dance.length).toBeGreaterThanOrEqual(4);
|
||||||
|
for (const l of dance) expect(l.radius).toBeLessThan(26);
|
||||||
|
// The one signature colour ROOM is allowed, and it belongs to the dock.
|
||||||
|
const sodium = ROOM.lights.find((l) => l.colour === 0xe08420);
|
||||||
|
expect(sodium).toBeDefined();
|
||||||
|
expect(tileAt(ROOM.map, sodium!.tx, sodium!.ty)).toBe('yard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the rig on the floor, taped down', () => {
|
||||||
|
for (const kind of ['strobe', 'smokeMachine', 'cableSnake', 'stackF1', 'subBass'] as const) {
|
||||||
|
expect(ROOM.props.some((p) => p.kind === kind), `no '${kind}' props`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is bare concrete, kept inside the darkness sheet brightness band', () => {
|
||||||
|
const luma = (c: number): number => (((c >> 16) & 0xff) + ((c >> 8) & 0xff) + (c & 0xff)) / 3;
|
||||||
|
const palette = ROOM.palette!;
|
||||||
|
// Voltage's floor sits at 0x221e2b (~36). ROOM must be darker but not black:
|
||||||
|
// a palette that reads right in isolation renders as nothing in the room.
|
||||||
|
expect(luma(palette.floor!)).toBeLessThan(36);
|
||||||
|
expect(luma(palette.floor!)).toBeGreaterThan(16);
|
||||||
|
expect(luma(palette.dance!)).toBeLessThan(luma(palette.floor!) + 12);
|
||||||
|
});
|
||||||
|
});
|
||||||
157
tests/floor/layout-theRoyal.test.ts
Normal file
157
tests/floor/layout-theRoyal.test.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { THE_ROYAL } from '../../src/scenes/floor/layouts/theRoyal';
|
||||||
|
import { isWalkable, tileAt, worldToTile } from '../../src/scenes/floor/venueMap';
|
||||||
|
import { assertLayout } from './layoutInvariants';
|
||||||
|
|
||||||
|
const map = THE_ROYAL.map;
|
||||||
|
|
||||||
|
const countTiles = (predicate: (kind: ReturnType<typeof tileAt>) => boolean): number => {
|
||||||
|
let n = 0;
|
||||||
|
for (let ty = 0; ty < map.height; ty++) {
|
||||||
|
for (let tx = 0; tx < map.width; tx++) if (predicate(tileAt(map, tx, ty))) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const propsOf = (kind: string) => THE_ROYAL.props.filter((p) => p.kind === kind);
|
||||||
|
|
||||||
|
describe('THE_ROYAL layout', () => {
|
||||||
|
it('satisfies the shared venue invariants', () => {
|
||||||
|
assertLayout(THE_ROYAL, { bar: 6, dance: 4, booth: 2, toilet: 2, smoke: 2, entry: 1, exit: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the venue id the ladder looks it up by', () => {
|
||||||
|
// data/venues.ts finds a night's room by VenueDef.id — a typo here is a
|
||||||
|
// venue that silently falls back to Voltage.
|
||||||
|
expect(THE_ROYAL.id).toBe('theRoyal');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the horseshoe ---------------------------------------------------------
|
||||||
|
it('wraps the public bar in counter on three sides with a workable pit inside', () => {
|
||||||
|
expect(tileAt(map, 22, 17)).toBe('bar'); // north run
|
||||||
|
expect(tileAt(map, 22, 25)).toBe('bar'); // south run
|
||||||
|
expect(tileAt(map, 28, 21)).toBe('bar'); // the bend
|
||||||
|
// The open west end is the whole point: the taps post lives in the pit, so
|
||||||
|
// walling this in freezes the bar shift for the night.
|
||||||
|
expect(isWalkable(map, 15, 21)).toBe(true);
|
||||||
|
const taps = THE_ROYAL.posts.taps;
|
||||||
|
expect(taps).toBeDefined();
|
||||||
|
expect(tileAt(map, taps!.tx, taps!.ty)).toBe('floor');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the DJ corner ---------------------------------------------------------
|
||||||
|
it('gives the DJ a folding table on the carpet, not a booth', () => {
|
||||||
|
expect(countTiles((k) => k === 'djbooth' || k === 'djfloor')).toBe(0);
|
||||||
|
const decks = THE_ROYAL.posts.decks;
|
||||||
|
expect(decks).toBeDefined();
|
||||||
|
expect(isWalkable(map, decks!.tx, decks!.ty)).toBe(true);
|
||||||
|
for (const p of [...propsOf('deck'), ...propsOf('mixer'), ...propsOf('highTable')]) {
|
||||||
|
if (p.tx < 50 || p.ty < 33) continue; // the pool-room leaner is not DJ gear
|
||||||
|
expect(isWalkable(map, p.tx, p.ty), `'${p.id}' is not on plain floor`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the dunnies -----------------------------------------------------------
|
||||||
|
it('has two cubicles, one of them plainly out of order', () => {
|
||||||
|
expect(map.stalls).toHaveLength(2);
|
||||||
|
expect(map.anchors.toilet).toHaveLength(2);
|
||||||
|
for (const stall of map.stalls) {
|
||||||
|
const inside = worldToTile(stall.inside.x, stall.inside.y);
|
||||||
|
expect(tileAt(map, inside.tx, inside.ty)).toBe('stall');
|
||||||
|
}
|
||||||
|
// The story prop: a wet floor sign and a mop bucket parked at cubicle two.
|
||||||
|
const wet = propsOf('wetFloor');
|
||||||
|
expect(wet).toHaveLength(1);
|
||||||
|
expect(propsOf('mopBucket')).toHaveLength(1);
|
||||||
|
const secondDoor = worldToTile(map.stalls[1]!.door.x, map.stalls[1]!.door.y);
|
||||||
|
expect(Math.abs(wet[0]!.tx - secondDoor.tx)).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plumbs the dunnies with a trough and a single basin', () => {
|
||||||
|
expect(propsOf('trough')).toHaveLength(1);
|
||||||
|
expect(propsOf('sinkRow')).toHaveLength(1);
|
||||||
|
// Both stand on 'sink' tiles, which are furniture: you cannot walk the trough.
|
||||||
|
for (const p of [...propsOf('trough'), ...propsOf('sinkRow')]) {
|
||||||
|
expect(tileAt(map, p.tx, p.ty), `'${p.id}' is not plumbed in`).toBe('sink');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the beer garden -------------------------------------------------------
|
||||||
|
it('opens the beer garden to the sky inside a fence, with one gate', () => {
|
||||||
|
const yard = countTiles((k) => k === 'yard');
|
||||||
|
expect(yard).toBeGreaterThan(200);
|
||||||
|
// Every yard tile must sit inside the fence rectangle, or the rain falls in
|
||||||
|
// the pub (the renderer keys the weather off 'yard', not off a room id).
|
||||||
|
for (let ty = 0; ty < map.height; ty++) {
|
||||||
|
for (let tx = 0; tx < map.width; tx++) {
|
||||||
|
if (tileAt(map, tx, ty) !== 'yard') continue;
|
||||||
|
expect(tx).toBeGreaterThan(12);
|
||||||
|
expect(tx).toBeLessThan(45);
|
||||||
|
expect(ty).toBeGreaterThan(33);
|
||||||
|
expect(ty).toBeLessThan(43);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(countTiles((k) => k === 'smokeDoor')).toBe(1);
|
||||||
|
expect(tileAt(map, 29, 33)).toBe('smokeDoor');
|
||||||
|
expect(tileAt(map, 29, 32)).toBe('floor');
|
||||||
|
expect(tileAt(map, 29, 34)).toBe('yard');
|
||||||
|
// It is the nicest room in the pub, so it is furnished like one.
|
||||||
|
expect(propsOf('picnicTable').length).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(propsOf('beerUmbrella').length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(propsOf('patioHeater')).toHaveLength(1);
|
||||||
|
expect(propsOf('buttBin')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the rooms that are not the dance floor ---------------------------------
|
||||||
|
it('keeps the dance floor smaller than the pool room it plays second fiddle to', () => {
|
||||||
|
const dance = countTiles((k) => k === 'dance');
|
||||||
|
expect(dance).toBeGreaterThan(0);
|
||||||
|
// Voltage's floor is 375 tiles. This one is an afterthought bolted on in 2004.
|
||||||
|
expect(dance).toBeLessThan(150);
|
||||||
|
expect(propsOf('poolTable')).toHaveLength(2);
|
||||||
|
expect(propsOf('cueRack')).toHaveLength(1);
|
||||||
|
expect(propsOf('dartboard')).toHaveLength(1);
|
||||||
|
expect(propsOf('pokie')).toHaveLength(6);
|
||||||
|
expect(propsOf('tabBoard').length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(propsOf('bistroTable').length).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(propsOf('bainMarie')).toHaveLength(1);
|
||||||
|
expect(propsOf('raffleDrum')).toHaveLength(1);
|
||||||
|
expect(propsOf('jukebox')).toHaveLength(1);
|
||||||
|
expect(propsOf('discoball')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- light signature -------------------------------------------------------
|
||||||
|
it('lights the pub amber and spends its one signature colour on the pokies', () => {
|
||||||
|
const AMBER = 0xd8a020;
|
||||||
|
const POKIE_BLUE = 0x4aa0c0;
|
||||||
|
const allowed = new Set([
|
||||||
|
AMBER, POKIE_BLUE,
|
||||||
|
0x30a050, // pool-table cone
|
||||||
|
0x50c060, // dunnies fluoro
|
||||||
|
0xd03470, // dance + entry pink
|
||||||
|
0x3060c0, // DJ
|
||||||
|
0x6080a0, // beer garden
|
||||||
|
]);
|
||||||
|
for (const l of THE_ROYAL.lights) {
|
||||||
|
expect(allowed.has(l.colour), `light '${l.id}' invents a colour`).toBe(true);
|
||||||
|
}
|
||||||
|
expect(THE_ROYAL.lights.filter((l) => l.colour === AMBER).length).toBeGreaterThanOrEqual(6);
|
||||||
|
expect(THE_ROYAL.lights.filter((l) => l.colour === POKIE_BLUE).length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(THE_ROYAL.lights.filter((l) => l.colour === 0x30a050)).toHaveLength(2);
|
||||||
|
|
||||||
|
// No pink anywhere but the dance corner and the way out (VENUES.md §2).
|
||||||
|
for (const l of THE_ROYAL.lights) {
|
||||||
|
if (l.colour !== 0xd03470) continue;
|
||||||
|
const onDance = tileAt(map, l.tx, l.ty) === 'dance';
|
||||||
|
expect(onDance || l.id === 'entry', `pink light '${l.id}' is loose in the pub`).toBe(true);
|
||||||
|
if (onDance) expect(l.pulse).toBe('beat');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays pub carpet rather than club floor', () => {
|
||||||
|
expect(THE_ROYAL.palette?.floor).toBeDefined();
|
||||||
|
// Warm: more red than blue, which is the whole difference from Voltage.
|
||||||
|
const floor = THE_ROYAL.palette!.floor!;
|
||||||
|
expect((floor >> 16) & 0xff).toBeGreaterThan(floor & 0xff);
|
||||||
|
});
|
||||||
|
});
|
||||||
205
tests/floor/layoutInvariants.ts
Normal file
205
tests/floor/layoutInvariants.ts
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
// Shared invariants every venue layout must satisfy (docs/VENUES.md §1).
|
||||||
|
//
|
||||||
|
// Four rooms authored by hand is four chances to strand the player behind a
|
||||||
|
// wall, and two of those bugs have already shipped from ONE room: the taps post
|
||||||
|
// handed you back onto a `stool`, and the decks post stood you inside the
|
||||||
|
// sealed `djbooth`. Neither is visible in review — you find them by spending a
|
||||||
|
// night unable to move. So the rules are executable, and every layout runs the
|
||||||
|
// same set.
|
||||||
|
import { expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
MAP_H, MAP_W, isWalkable, tileAt, worldToTile,
|
||||||
|
} from '../../src/scenes/floor/venueMap';
|
||||||
|
import type {
|
||||||
|
AnchorKind, FloorLayout, TileKind, TilePoint, VenueMap, WorldPoint,
|
||||||
|
} from '../../src/scenes/floor/venueMap';
|
||||||
|
|
||||||
|
export const ANCHOR_KINDS: readonly AnchorKind[] = [
|
||||||
|
'bar', 'dance', 'booth', 'toilet', 'smoke', 'entry', 'exit',
|
||||||
|
];
|
||||||
|
|
||||||
|
const KNOWN_TILES: ReadonlySet<TileKind> = new Set<TileKind>([
|
||||||
|
'void', 'floor', 'wall', 'bar', 'stool', 'dance',
|
||||||
|
'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon',
|
||||||
|
'djbooth', 'djfloor', 'yard', 'fence', 'sink',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const key = (tx: number, ty: number): number => ty * MAP_W + tx;
|
||||||
|
const NEIGHBOURS = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const;
|
||||||
|
|
||||||
|
/** Every walkable tile 4-connected to the seed. */
|
||||||
|
export function flood(map: VenueMap, seed: TilePoint): Set<number> {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
if (!isWalkable(map, seed.tx, seed.ty)) return seen;
|
||||||
|
const stack: TilePoint[] = [seed];
|
||||||
|
seen.add(key(seed.tx, seed.ty));
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const cur = stack.pop()!;
|
||||||
|
for (const [dx, dy] of NEIGHBOURS) {
|
||||||
|
const tx = cur.tx + dx;
|
||||||
|
const ty = cur.ty + dy;
|
||||||
|
const k = key(tx, ty);
|
||||||
|
if (seen.has(k) || !isWalkable(map, tx, ty)) continue;
|
||||||
|
seen.add(k);
|
||||||
|
stack.push({ tx, ty });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWalkableNeighbour = (map: VenueMap, tx: number, ty: number): boolean =>
|
||||||
|
NEIGHBOURS.some(([dx, dy]) => isWalkable(map, tx + dx, ty + dy));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the whole rulebook against one layout.
|
||||||
|
*
|
||||||
|
* `minAnchors` is per-venue because the rooms are genuinely different sizes:
|
||||||
|
* ROOM has no booths to speak of and Elevate is a terrace, so demanding
|
||||||
|
* Voltage's six bar anchors everywhere would only teach layout authors to add
|
||||||
|
* furniture they do not want.
|
||||||
|
*/
|
||||||
|
export function assertLayout(
|
||||||
|
layout: FloorLayout,
|
||||||
|
minAnchors: Partial<Record<AnchorKind, number>> = {},
|
||||||
|
): void {
|
||||||
|
const { map, props, lights, posts, probes } = layout;
|
||||||
|
|
||||||
|
// --- grid ------------------------------------------------------------------
|
||||||
|
expect(map.width).toBe(MAP_W);
|
||||||
|
expect(map.height).toBe(MAP_H);
|
||||||
|
expect(map.tiles).toHaveLength(MAP_W * MAP_H);
|
||||||
|
for (const t of map.tiles) expect(KNOWN_TILES.has(t)).toBe(true);
|
||||||
|
|
||||||
|
// The perimeter must hold. A walkable edge tile is a player walking out of
|
||||||
|
// the world, and the camera bounds do not stop them.
|
||||||
|
for (let tx = 0; tx < MAP_W; tx++) {
|
||||||
|
for (const ty of [0, MAP_H - 1]) {
|
||||||
|
if (tileAt(map, tx, ty) === 'smokeDoor') continue;
|
||||||
|
expect(isWalkable(map, tx, ty)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let ty = 0; ty < MAP_H; ty++) {
|
||||||
|
for (const tx of [0, MAP_W - 1]) {
|
||||||
|
const kind = tileAt(map, tx, ty);
|
||||||
|
// `exit` on the west edge is the way out to the door scene.
|
||||||
|
if (kind === 'smokeDoor' || kind === 'exit') continue;
|
||||||
|
expect(isWalkable(map, tx, ty)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- anchors ---------------------------------------------------------------
|
||||||
|
for (const k of ANCHOR_KINDS) {
|
||||||
|
const at = map.anchors[k];
|
||||||
|
expect(at.length, `${layout.id}: no '${k}' anchors`).toBeGreaterThanOrEqual(minAnchors[k] ?? 1);
|
||||||
|
for (const a of at) {
|
||||||
|
const t = worldToTile(a.x, a.y);
|
||||||
|
expect(
|
||||||
|
isWalkable(map, t.tx, t.ty),
|
||||||
|
`${layout.id}: '${k}' anchor at ${t.tx},${t.ty} is ${tileAt(map, t.tx, t.ty)}, not walkable`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reachability ----------------------------------------------------------
|
||||||
|
// Every walkable tile must be reachable from the entry, EXCEPT cubicle
|
||||||
|
// interiors, which are sealed on purpose: a patron is placed inside via
|
||||||
|
// StallDef.inside, nobody paths in.
|
||||||
|
const entry = map.anchors.entry[0]!;
|
||||||
|
const reached = flood(map, worldToTile(entry.x, entry.y));
|
||||||
|
const stallTiles = new Set<number>();
|
||||||
|
for (let ty = 0; ty < MAP_H; ty++) {
|
||||||
|
for (let tx = 0; tx < MAP_W; tx++) {
|
||||||
|
if (tileAt(map, tx, ty) === 'stall') stallTiles.add(key(tx, ty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let ty = 0; ty < MAP_H; ty++) {
|
||||||
|
for (let tx = 0; tx < MAP_W; tx++) {
|
||||||
|
if (!isWalkable(map, tx, ty)) continue;
|
||||||
|
const k = key(tx, ty);
|
||||||
|
if (stallTiles.has(k)) continue;
|
||||||
|
expect(
|
||||||
|
reached.has(k),
|
||||||
|
`${layout.id}: walkable tile ${tx},${ty} (${tileAt(map, tx, ty)}) is cut off from the entry`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every stall must be usable: you stand at its door, and its interior is a
|
||||||
|
// real sealed pocket rather than a hole in the wall.
|
||||||
|
for (const stall of map.stalls) {
|
||||||
|
const door = worldToTile(stall.door.x, stall.door.y);
|
||||||
|
expect(
|
||||||
|
isWalkable(map, door.tx, door.ty),
|
||||||
|
`${layout.id}: stall ${stall.id} door at ${door.tx},${door.ty} is not walkable`,
|
||||||
|
).toBe(true);
|
||||||
|
expect(reached.has(key(door.tx, door.ty)), `${layout.id}: stall ${stall.id} door unreachable`).toBe(true);
|
||||||
|
const inside = worldToTile(stall.inside.x, stall.inside.y);
|
||||||
|
expect(
|
||||||
|
tileAt(map, inside.tx, inside.ty),
|
||||||
|
`${layout.id}: stall ${stall.id} interior is not a 'stall' tile`,
|
||||||
|
).toBe('stall');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- posts and probes ------------------------------------------------------
|
||||||
|
// A post is where a ROLE plants the player. On a sealed tile it is a night
|
||||||
|
// spent frozen in place; this has shipped twice, so it is asserted.
|
||||||
|
for (const [name, at] of Object.entries(posts)) {
|
||||||
|
expect(
|
||||||
|
isWalkable(map, at.tx, at.ty),
|
||||||
|
`${layout.id}: post '${name}' at ${at.tx},${at.ty} is ${tileAt(map, at.tx, at.ty)}, not walkable`,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
reached.has(key(at.tx, at.ty)),
|
||||||
|
`${layout.id}: post '${name}' is walkable but cut off from the entry`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
// A probe may sit INSIDE furniture — you walk up to it, you don't stand on
|
||||||
|
// it — but it must have somewhere walkable to be used from.
|
||||||
|
for (const [name, at] of Object.entries(probes)) {
|
||||||
|
expect(
|
||||||
|
isWalkable(map, at.tx, at.ty) || hasWalkableNeighbour(map, at.tx, at.ty),
|
||||||
|
`${layout.id}: probe '${name}' at ${at.tx},${at.ty} has nowhere to stand`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- props and lights ------------------------------------------------------
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const p of props) {
|
||||||
|
expect(ids.has(p.id), `${layout.id}: duplicate prop id '${p.id}'`).toBe(false);
|
||||||
|
ids.add(p.id);
|
||||||
|
expect(p.tw, `${layout.id}: prop '${p.id}' has no width`).toBeGreaterThan(0);
|
||||||
|
expect(p.th, `${layout.id}: prop '${p.id}' has no height`).toBeGreaterThan(0);
|
||||||
|
expect(p.tx, `${layout.id}: prop '${p.id}' starts off-grid`).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(p.ty, `${layout.id}: prop '${p.id}' starts off-grid`).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(p.tx + p.tw, `${layout.id}: prop '${p.id}' overhangs the east edge`).toBeLessThanOrEqual(MAP_W);
|
||||||
|
expect(p.ty + p.th, `${layout.id}: prop '${p.id}' overhangs the south edge`).toBeLessThanOrEqual(MAP_H);
|
||||||
|
}
|
||||||
|
const lightIds = new Set<string>();
|
||||||
|
for (const l of lights) {
|
||||||
|
expect(lightIds.has(l.id), `${layout.id}: duplicate light id '${l.id}'`).toBe(false);
|
||||||
|
lightIds.add(l.id);
|
||||||
|
expect(l.radius, `${layout.id}: light '${l.id}' has no radius`).toBeGreaterThan(0);
|
||||||
|
expect(l.tx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(l.tx).toBeLessThan(MAP_W);
|
||||||
|
expect(l.ty).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(l.ty).toBeLessThan(MAP_H);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience for layout tests that want to eyeball a region in a failure. */
|
||||||
|
export function render(map: VenueMap, x0 = 0, y0 = 0, x1 = MAP_W - 1, y1 = MAP_H - 1): string {
|
||||||
|
const glyph: Record<TileKind, string> = {
|
||||||
|
void: ' ', floor: '.', wall: '#', bar: '=', stool: 'o', dance: ':',
|
||||||
|
booth: 'b', stall: 's', stallDoor: 'd', exit: 'E', smokeDoor: 'S', neon: 'n',
|
||||||
|
djbooth: 'D', djfloor: 'j', yard: 'y', fence: 'f', sink: '~',
|
||||||
|
};
|
||||||
|
const rows: string[] = [];
|
||||||
|
for (let ty = y0; ty <= y1; ty++) {
|
||||||
|
let row = '';
|
||||||
|
for (let tx = x0; tx <= x1; tx++) row += glyph[tileAt(map, tx, ty)];
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return rows.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { FloorLayout, WorldPoint };
|
||||||
58
tests/floor/layouts.test.ts
Normal file
58
tests/floor/layouts.test.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { VOLTAGE } from '../../src/scenes/floor/venueMap';
|
||||||
|
import { DEFAULT_LAYOUT, LAYOUTS, layoutFor } from '../../src/scenes/floor/layouts';
|
||||||
|
import { VENUES } from '../../src/data/venues';
|
||||||
|
import { assertLayout } from './layoutInvariants';
|
||||||
|
|
||||||
|
// Voltage is the reference room — it shipped, it is tuned, and its
|
||||||
|
// reachability was already asserted the long way round in venueMap.test.ts.
|
||||||
|
// Running it through the shared rulebook proves the rulebook, not the room: if
|
||||||
|
// assertLayout is wrong, it is wrong HERE first, before three new layouts are
|
||||||
|
// authored against it.
|
||||||
|
describe('VOLTAGE layout', () => {
|
||||||
|
it('satisfies the shared venue invariants', () => {
|
||||||
|
assertLayout(VOLTAGE, { bar: 6, dance: 8, booth: 4, toilet: 4, smoke: 1, entry: 1, exit: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the layout registry', () => {
|
||||||
|
// layoutFor() falls back to Voltage on an unknown id so the dev routes can
|
||||||
|
// boot the floor with no night at all. That kindness also means a venue whose
|
||||||
|
// room was never built fails SILENTLY — you would play a week at "Elevate"
|
||||||
|
// in Voltage's room and the only symptom is a nagging sense of déjà vu.
|
||||||
|
it('has a room for every venue in the ladder', () => {
|
||||||
|
for (const venue of VENUES) {
|
||||||
|
const layout = layoutFor(venue.id);
|
||||||
|
expect(layout.id, `venue '${venue.id}' has no layout of its own`).toBe(venue.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no layout without a venue, and no duplicate ids', () => {
|
||||||
|
const venueIds = new Set(VENUES.map((v) => v.id));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const layout of LAYOUTS) {
|
||||||
|
expect(venueIds.has(layout.id), `layout '${layout.id}' belongs to no venue`).toBe(true);
|
||||||
|
expect(seen.has(layout.id), `duplicate layout id '${layout.id}'`).toBe(false);
|
||||||
|
seen.add(layout.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the tuned reference room, not to nothing', () => {
|
||||||
|
expect(layoutFor(undefined)).toBe(DEFAULT_LAYOUT);
|
||||||
|
expect(layoutFor('a venue that does not exist')).toBe(DEFAULT_LAYOUT);
|
||||||
|
expect(DEFAULT_LAYOUT).toBe(VOLTAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every room the stations the floor scene needs', () => {
|
||||||
|
// FloorDemoScene resolves the playable shifts from these names. A layout
|
||||||
|
// missing one silently parks that station in the middle of the map.
|
||||||
|
for (const layout of LAYOUTS) {
|
||||||
|
for (const post of ['taps', 'decks']) {
|
||||||
|
expect(layout.posts[post], `${layout.id} has no '${post}' post`).toBeDefined();
|
||||||
|
}
|
||||||
|
for (const probe of ['tapsFront', 'rack', 'dishwasher', 'water']) {
|
||||||
|
expect(layout.probes[probe], `${layout.id} has no '${probe}' probe`).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -218,7 +218,9 @@ describe('tileColour', () => {
|
|||||||
'void', 'floor', 'wall', 'bar', 'stool', 'dance',
|
'void', 'floor', 'wall', 'bar', 'stool', 'dance',
|
||||||
'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon',
|
'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon',
|
||||||
];
|
];
|
||||||
const colours = kinds.map(tileColour);
|
// Not a bare `.map(tileColour)`: tileColour takes an optional palette as its
|
||||||
|
// second argument, and Array.map would hand it the index.
|
||||||
|
const colours = kinds.map((k) => tileColour(k));
|
||||||
for (const c of colours) {
|
for (const c of colours) {
|
||||||
expect(Number.isInteger(c)).toBe(true);
|
expect(Number.isInteger(c)).toBe(true);
|
||||||
expect(c).toBeGreaterThanOrEqual(0);
|
expect(c).toBeGreaterThanOrEqual(0);
|
||||||
|
|||||||
203
tools/gen/assets.py
Normal file
203
tools/gen/assets.py
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""assets.py — the not-tonight generation manifest (docs/VENUES.md §6).
|
||||||
|
|
||||||
|
One entry per sprite the three new venues need. `run_batch.py` walks this list;
|
||||||
|
nothing here executes anything itself, so it stays readable and diffable.
|
||||||
|
|
||||||
|
Two routes, per the A/B recorded in docs/SPACES.md:
|
||||||
|
|
||||||
|
flat — flux draws the finished sprite. For things with no volume: signage,
|
||||||
|
screens, boards, wall art, backdrops. Flux picking its own camera does
|
||||||
|
not matter when the object IS a flat plane.
|
||||||
|
mesh — flux draws a clean PRODUCT SHOT, TRELLIS.2 reconstructs it, Blender
|
||||||
|
renders it from the ONE fixed ortho camera every prop shares. For
|
||||||
|
anything with volume. The product shot is deliberately NOT a top-down:
|
||||||
|
TRELLIS reconstructs best from a clear view of the object, and the
|
||||||
|
game's camera angle is applied at render time by `rake`.
|
||||||
|
|
||||||
|
`rake` follows the house rule the hard way round (LANEHANDOVER, FABLE-SOLO-25):
|
||||||
|
**60° for low and wide, ~76° for tall.** Lowering the rake for a tall object to
|
||||||
|
"see more face" is wrong — a tall object is already all face, and at 55° it
|
||||||
|
renders as a featureless slab.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
# Shared prompt tails. The mesh tail is tuned for TRELLIS: one object, no
|
||||||
|
# cropping, plain ground, even light, nothing for the reconstructor to mistake
|
||||||
|
# for geometry.
|
||||||
|
MESH_TAIL = (
|
||||||
|
"single object centred, full object visible, product photograph, "
|
||||||
|
"plain mid-grey seamless background, soft even studio lighting, sharp focus, "
|
||||||
|
"no people, no text, no lettering, no watermark"
|
||||||
|
)
|
||||||
|
FLAT_TAIL = (
|
||||||
|
"front-on orthographic view, flat to camera, no perspective, "
|
||||||
|
"dark background, grimy nightclub, video game texture art, "
|
||||||
|
# flux likes to sign its work in the bottom corner. Invisible on a 16px
|
||||||
|
# prop, very visible on a 640px street plate.
|
||||||
|
"no watermark, no logo, no signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Asset(NamedTuple):
|
||||||
|
kind: str
|
||||||
|
route: str # 'flat' | 'mesh'
|
||||||
|
size: tuple[int, int] # ship size in px, must match PROP_SIZES/TARGETS
|
||||||
|
prompt: str
|
||||||
|
seed: int
|
||||||
|
rake: int = 60 # mesh route only
|
||||||
|
gen: tuple[int, int] = (1024, 1024) # generation resolution
|
||||||
|
quantise: bool = True
|
||||||
|
# True for PLATES (backdrops, skylines, a roller door filling its wall):
|
||||||
|
# they are meant to be opaque, and the importer's near-black-to-alpha pass
|
||||||
|
# would eat exactly the parts that make a night scene look like night.
|
||||||
|
# False for OBJECTS, which want cutting out of their background.
|
||||||
|
keep_bg: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def M(kind: str, size: tuple[int, int], seed: int, rake: int, subject: str) -> Asset:
|
||||||
|
return Asset(kind, "mesh", size, f"{subject}, {MESH_TAIL}", seed, rake=rake)
|
||||||
|
|
||||||
|
|
||||||
|
def F(kind: str, size: tuple[int, int], seed: int, subject: str,
|
||||||
|
gen: tuple[int, int] = (1024, 1024), quantise: bool = True,
|
||||||
|
keep_bg: bool = False) -> Asset:
|
||||||
|
return Asset(kind, "flat", size, f"{subject}, {FLAT_TAIL}", seed,
|
||||||
|
gen=gen, quantise=quantise, keep_bg=keep_bg)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- The Royal — suburban pub -------------------------------------------------
|
||||||
|
ROYAL = [
|
||||||
|
M("pokie", (16, 32), 4400, 76,
|
||||||
|
"a single poker machine gaming cabinet, glowing screen, red and gold trim, worn"),
|
||||||
|
M("bistroTable", (16, 16), 4401, 60,
|
||||||
|
"a small square pub bistro table with two dining chairs, laminate top"),
|
||||||
|
M("picnicTable", (32, 16), 4402, 60,
|
||||||
|
"a weathered wooden picnic table with attached bench seats, beer garden furniture"),
|
||||||
|
M("trough", (48, 16), 4403, 70,
|
||||||
|
"a long stainless steel trough urinal, wall mounted, public bar toilet fixture"),
|
||||||
|
M("keg", (16, 16), 4404, 76,
|
||||||
|
"a stack of two stainless steel beer kegs, brewery barrels"),
|
||||||
|
M("jukebox", (16, 32), 4405, 76,
|
||||||
|
"a classic jukebox with illuminated arched top, chrome and coloured lights"),
|
||||||
|
M("raffleDrum", (16, 16), 4406, 60,
|
||||||
|
"a small wire mesh raffle barrel on a stand with a crank handle"),
|
||||||
|
M("bainMarie", (48, 16), 4407, 60,
|
||||||
|
"a stainless steel bain-marie hot food counter with sneeze guard, pub bistro"),
|
||||||
|
M("beerUmbrella", (32, 32), 4408, 80,
|
||||||
|
"a large outdoor patio umbrella open, canvas canopy seen from high above"),
|
||||||
|
M("ashtray", (16, 16), 4409, 60,
|
||||||
|
"a battered metal outdoor ashtray bin on a short pole"),
|
||||||
|
F("tabBoard", (48, 16), 4410,
|
||||||
|
"a bank of three wall mounted betting screens showing horse racing odds, glowing blue"),
|
||||||
|
F("dartboard", (16, 16), 4411,
|
||||||
|
"a dartboard on a scuffed wooden backboard with darts in it"),
|
||||||
|
F("tvSport", (32, 16), 4412,
|
||||||
|
"a wall mounted flat screen television showing a night rugby match, glowing"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---- Elevate — rooftop --------------------------------------------------------
|
||||||
|
ELEVATE = [
|
||||||
|
M("cocktailBar", (96, 16), 4420, 60,
|
||||||
|
"a short modern cocktail bar counter with backlit bottle shelving, marble top"),
|
||||||
|
M("banquette", (32, 32), 4421, 60,
|
||||||
|
"a curved upholstered velvet banquette booth seat, luxury lounge furniture"),
|
||||||
|
M("ropePost", (16, 16), 4422, 76,
|
||||||
|
"a single polished brass rope stanchion post with red velvet rope"),
|
||||||
|
M("planter", (32, 16), 4423, 60,
|
||||||
|
"a long rectangular concrete planter box full of green foliage and grasses"),
|
||||||
|
M("balustrade", (64, 16), 4424, 76,
|
||||||
|
"a section of frameless glass balustrade railing with a steel handrail"),
|
||||||
|
M("djPlinth", (48, 32), 4425, 60,
|
||||||
|
"a raised white platform plinth stage with a DJ controller on top"),
|
||||||
|
M("marbleSink", (48, 16), 4426, 70,
|
||||||
|
"a luxury marble bathroom vanity with two vessel basins and brass taps"),
|
||||||
|
M("ashUrn", (16, 16), 4427, 76,
|
||||||
|
"a tall stainless steel standing ashtray urn, hotel lobby style"),
|
||||||
|
M("champBucket", (16, 16), 4428, 60,
|
||||||
|
"a silver champagne bucket on a stand full of ice and a bottle"),
|
||||||
|
M("loungeChair", (16, 16), 4429, 60,
|
||||||
|
"a low modern outdoor rattan lounge chair with a cushion"),
|
||||||
|
F("liftDoor", (32, 32), 4430,
|
||||||
|
"a pair of closed brushed steel lift elevator doors with a call panel"),
|
||||||
|
F("skylineCard", (128, 32), 4431,
|
||||||
|
"a distant night city skyline of lit office towers, warm gold windows, "
|
||||||
|
"seen across a dark horizon", gen=(2048, 512), quantise=False, keep_bg=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---- ROOM — concrete warehouse -----------------------------------------------
|
||||||
|
ROOM = [
|
||||||
|
M("stackF1", (32, 48), 4440, 76,
|
||||||
|
"a huge professional nightclub speaker stack tower, three stacked black "
|
||||||
|
"bass bins and horns, sound system rig"),
|
||||||
|
M("subBass", (32, 16), 4441, 60,
|
||||||
|
"a large black subwoofer bass bin cabinet lying wide, professional audio"),
|
||||||
|
M("plywoodBar", (128, 16), 4442, 60,
|
||||||
|
"a rough improvised bar counter made of plywood sheets on scaffolding poles"),
|
||||||
|
M("pallet", (32, 16), 4443, 60,
|
||||||
|
"a stack of two wooden shipping pallets"),
|
||||||
|
M("wheelieBin", (16, 16), 4444, 76,
|
||||||
|
"a large plastic wheelie rubbish bin with the lid closed, industrial"),
|
||||||
|
M("oldCouch", (48, 16), 4445, 60,
|
||||||
|
"a sagging ruined old fabric three seater couch, stained and torn"),
|
||||||
|
M("pillar", (16, 16), 4446, 76,
|
||||||
|
"a square raw concrete structural column pillar, industrial warehouse"),
|
||||||
|
M("strobe", (16, 16), 4447, 76,
|
||||||
|
"a black professional strobe light fixture on a short stand"),
|
||||||
|
M("smokeMachine", (16, 16), 4448, 60,
|
||||||
|
"a black fog smoke machine unit with a nozzle, stage equipment"),
|
||||||
|
M("cableSnake", (32, 16), 4449, 60,
|
||||||
|
"a coiled bundle of thick black stage power cables taped to the floor"),
|
||||||
|
M("brokenBasin", (32, 16), 4450, 70,
|
||||||
|
"a cracked dirty ceramic wall mounted bathroom basin with exposed pipes"),
|
||||||
|
M("canStack", (16, 16), 4451, 60,
|
||||||
|
"a stack of plain unbranded aluminium drink cans in a cardboard tray"),
|
||||||
|
M("cdj", (32, 32), 4452, 60,
|
||||||
|
"a professional CDJ media player deck, black with a large jog wheel"),
|
||||||
|
F("rollerDoor", (64, 32), 4453,
|
||||||
|
"a large corrugated steel roller shutter door, half open, loading dock",
|
||||||
|
keep_bg=True),
|
||||||
|
F("ruleBoard", (32, 32), 4454,
|
||||||
|
"a scuffed black letter board sign with white push-in plastic letters, "
|
||||||
|
"cryptic house rules, chipped frame"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---- Staff figures (set dressing props, never patrons) ------------------------
|
||||||
|
STAFF = [
|
||||||
|
M("staffBartender", (16, 32), 4460, 76,
|
||||||
|
"a single bartender standing, black shirt, apron, arms at sides, full body"),
|
||||||
|
M("staffDj", (16, 32), 4461, 76,
|
||||||
|
"a single DJ standing wearing headphones around the neck, full body"),
|
||||||
|
M("staffGlassie", (16, 32), 4462, 76,
|
||||||
|
"a single hospitality worker standing holding a stack of empty pint glasses, full body"),
|
||||||
|
M("staffSecurity", (16, 32), 4463, 76,
|
||||||
|
"a single security guard standing, black bomber jacket, arms folded, full body"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---- Door-scene street plates, one per venue ---------------------------------
|
||||||
|
STREETS = [
|
||||||
|
F("street:theRoyal", (640, 176), 4470,
|
||||||
|
"a wide suburban Australian pub frontage at night, painted brick, tiled dado, "
|
||||||
|
"a lit beer sign, bins, wet footpath, warm amber light",
|
||||||
|
gen=(2048, 576), quantise=False, keep_bg=True),
|
||||||
|
F("street:elevate", (640, 176), 4471,
|
||||||
|
"a wide polished lobby entrance of a rooftop bar at night, brushed steel lift doors, "
|
||||||
|
"marble, planters, cool white downlight, glass",
|
||||||
|
gen=(2048, 576), quantise=False, keep_bg=True),
|
||||||
|
F("street:room", (640, 176), 4472,
|
||||||
|
"a wide unmarked concrete warehouse wall at night, no signage, a single steel door, "
|
||||||
|
"graffiti, sodium orange streetlight, puddles, rain",
|
||||||
|
gen=(2048, 576), quantise=False, keep_bg=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
ALL: list[Asset] = [*ROYAL, *ELEVATE, *ROOM, *STAFF, *STREETS]
|
||||||
|
|
||||||
|
BY_KIND = {a.kind: a for a in ALL}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
flat = sum(1 for a in ALL if a.route == "flat")
|
||||||
|
print(f"{len(ALL)} assets — {len(ALL) - flat} mesh, {flat} flat")
|
||||||
|
for a in ALL:
|
||||||
|
print(f" {a.route:4} {a.kind:16} {a.size[0]:>4}x{a.size[1]:<3} rake={a.rake}")
|
||||||
123
tools/gen/cf_flux.py
Normal file
123
tools/gen/cf_flux.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""cf_flux.py — FLUX-1-schnell images from Cloudflare Workers AI.
|
||||||
|
|
||||||
|
python3 tools/gen/cf_flux.py <out.png> "<prompt>" [--steps 6] [--seed 42]
|
||||||
|
python3 tools/gen/cf_flux.py --probe # is there budget left today?
|
||||||
|
|
||||||
|
Workers AI gives 10,000 Neurons free every day (resets 00:00 UTC), which is the
|
||||||
|
cheapest image generation on the fleet — so it goes FIRST, ahead of the farm.
|
||||||
|
When it runs dry, callers fall back to MODELBEAST `flux_local`, which is slower
|
||||||
|
but genuinely unlimited.
|
||||||
|
|
||||||
|
The whole reason this exists rather than the one-liner in toastsim: urllib
|
||||||
|
raises HTTPError on 4xx and throws the response BODY away, so a 429 is
|
||||||
|
indistinguishable between "you have burned today's neurons, go to the farm" and
|
||||||
|
"you sent three at once, wait a second". Those want opposite responses, and
|
||||||
|
guessing wrong either wastes the free tier or wedges the batch. Here the body is
|
||||||
|
read and classified.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import creds # noqa: E402
|
||||||
|
|
||||||
|
MODEL = "@cf/black-forest-labs/flux-1-schnell"
|
||||||
|
# schnell is a distilled 4-step model; past ~8 steps you pay neurons for nothing.
|
||||||
|
DEFAULT_STEPS = 6
|
||||||
|
|
||||||
|
|
||||||
|
class Exhausted(RuntimeError):
|
||||||
|
"""Today's free neurons are gone. Fall back to the farm; retrying won't help."""
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimited(RuntimeError):
|
||||||
|
"""Too fast, not too much. Backing off DOES help."""
|
||||||
|
|
||||||
|
|
||||||
|
def _post(prompt: str, steps: int, seed: int) -> bytes:
|
||||||
|
account = creds.get("CLOUDFLARE_ACCOUNT_ID")
|
||||||
|
token = creds.get("CLOUDFLARE_API_TOKEN")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.cloudflare.com/client/v4/accounts/{account}/ai/run/{MODEL}",
|
||||||
|
data=json.dumps({"prompt": prompt, "steps": steps, "seed": seed}).encode(),
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
raw = urllib.request.urlopen(req, timeout=180).read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = exc.read().decode(errors="ignore")[:600]
|
||||||
|
low = body.lower()
|
||||||
|
if exc.code == 429:
|
||||||
|
# Cloudflare says "capacity temporarily exceeded" for burst limits and
|
||||||
|
# names the neuron/daily allocation when the free tier is spent.
|
||||||
|
if "neuron" in low or "daily" in low or "limit" in low and "rate" not in low:
|
||||||
|
raise Exhausted(body) from None
|
||||||
|
raise RateLimited(body) from None
|
||||||
|
if exc.code in (401, 403):
|
||||||
|
raise SystemExit(f"Cloudflare rejected the token (HTTP {exc.code}): {body}")
|
||||||
|
raise SystemExit(f"Cloudflare HTTP {exc.code}: {body}") from None
|
||||||
|
|
||||||
|
payload = json.loads(raw)
|
||||||
|
if not payload.get("success"):
|
||||||
|
errors = json.dumps(payload.get("errors"))
|
||||||
|
if "neuron" in errors.lower() or "limit" in errors.lower():
|
||||||
|
raise Exhausted(errors)
|
||||||
|
raise SystemExit("Cloudflare error: " + errors)
|
||||||
|
return base64.b64decode(payload["result"]["image"])
|
||||||
|
|
||||||
|
|
||||||
|
def generate(out: Path, prompt: str, steps: int = DEFAULT_STEPS, seed: int = 0,
|
||||||
|
*, attempts: int = 4) -> Path:
|
||||||
|
"""One image to `out`. Retries burst limits, gives up fast on exhaustion."""
|
||||||
|
delay = 2.0
|
||||||
|
for attempt in range(1, attempts + 1):
|
||||||
|
try:
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_bytes(_post(prompt, steps, seed))
|
||||||
|
return out
|
||||||
|
except RateLimited:
|
||||||
|
if attempt == attempts:
|
||||||
|
raise
|
||||||
|
time.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
raise RateLimited("exhausted retries")
|
||||||
|
|
||||||
|
|
||||||
|
def probe() -> bool:
|
||||||
|
"""Cheapest possible call — is there budget left right now?"""
|
||||||
|
try:
|
||||||
|
_post("a grey square", 1, 1)
|
||||||
|
return True
|
||||||
|
except Exhausted:
|
||||||
|
return False
|
||||||
|
except RateLimited:
|
||||||
|
# Burst-limited means the account is alive and has budget.
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if "--probe" in sys.argv:
|
||||||
|
ok = probe()
|
||||||
|
print("cloudflare flux: BUDGET AVAILABLE" if ok else "cloudflare flux: EXHAUSTED for today")
|
||||||
|
sys.exit(0 if ok else 3)
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
out, prompt = Path(sys.argv[1]), sys.argv[2]
|
||||||
|
steps = int(sys.argv[sys.argv.index("--steps") + 1]) if "--steps" in sys.argv else DEFAULT_STEPS
|
||||||
|
seed = int(sys.argv[sys.argv.index("--seed") + 1]) if "--seed" in sys.argv else 0
|
||||||
|
try:
|
||||||
|
print(generate(out, prompt, steps, seed))
|
||||||
|
except Exhausted as exc:
|
||||||
|
sys.exit(f"cloudflare free tier exhausted for today — use MODELBEAST flux_local.\n{exc}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
91
tools/gen/creds.py
Normal file
91
tools/gen/creds.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""creds.py — resolve fleet credentials from on-disk .env files, in process.
|
||||||
|
|
||||||
|
The fleet rule is that secrets live in .env files per project and are READ FROM
|
||||||
|
DISK by whatever needs them; they never travel through a shell, a chat, a doc or
|
||||||
|
a commit. So this module takes a key name and hands back its value to the
|
||||||
|
caller's own process, and its failure mode prints the paths it TRIED — never a
|
||||||
|
value, never a fragment of one.
|
||||||
|
|
||||||
|
Candidate paths are ordered cheapest-first (this machine, then the fleet's
|
||||||
|
shared locations). Every asset script in tools/gen goes through here, which is
|
||||||
|
why the batch driver runs unmodified on ultra (Cloudflare creds) and on m3ultra
|
||||||
|
(MODELBEAST creds) without a flag.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Every .env on the fleet that has ever legitimately held a generation
|
||||||
|
# credential. Missing files are skipped silently — most machines have most of
|
||||||
|
# these missing, and that is normal, not an error.
|
||||||
|
CANDIDATES = (
|
||||||
|
"~/Documents/backnforth/.env",
|
||||||
|
"~/Documents/MODELBEAST/.env",
|
||||||
|
"~/Documents/MODELBEAST/data/agent.env",
|
||||||
|
"~/Documents/fluxgod-work/.env",
|
||||||
|
"~/Documents/not-tonight/.env",
|
||||||
|
)
|
||||||
|
|
||||||
|
_cache: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load() -> dict[str, str]:
|
||||||
|
"""Merge every candidate .env, first file to define a key wins."""
|
||||||
|
global _cache
|
||||||
|
if _cache is not None:
|
||||||
|
return _cache
|
||||||
|
merged: dict[str, str] = {}
|
||||||
|
for cand in CANDIDATES:
|
||||||
|
path = Path(os.path.expanduser(cand))
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text = path.read_text(errors="ignore")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
# Tolerate `export FOO=bar` and quoted values — both appear on the fleet.
|
||||||
|
if key.startswith("export "):
|
||||||
|
key = key[len("export "):].strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
if key and value and key not in merged:
|
||||||
|
merged[key] = value
|
||||||
|
# A real environment variable beats any file: that is how you override for
|
||||||
|
# a one-off without editing a shared .env.
|
||||||
|
for key in ("CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN", "MB_TOKEN"):
|
||||||
|
if os.environ.get(key):
|
||||||
|
merged[key] = os.environ[key]
|
||||||
|
_cache = merged
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def get(key: str, *, required: bool = True) -> str:
|
||||||
|
"""Value for `key`, or exit with the paths tried (never the values)."""
|
||||||
|
value = _load().get(key, "")
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
if not required:
|
||||||
|
return ""
|
||||||
|
tried = "\n ".join(CANDIDATES)
|
||||||
|
raise SystemExit(
|
||||||
|
f"credential {key!r} not found on this machine.\n"
|
||||||
|
f"looked in (plus the process environment):\n {tried}\n"
|
||||||
|
f"put it in one of those, or run this script on a machine that has it."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def have(key: str) -> bool:
|
||||||
|
return bool(_load().get(key))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Diagnostic only: which credentials EXIST here. Booleans, never values.
|
||||||
|
for k in ("CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN", "MB_TOKEN"):
|
||||||
|
print(f"{k}: {'present' if have(k) else 'MISSING'}")
|
||||||
192
tools/gen/mb.py
Normal file
192
tools/gen/mb.py
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""mb.py — MODELBEAST client for the not-tonight asset pipeline.
|
||||||
|
|
||||||
|
python3 tools/gen/mb.py image <out.png> "<prompt>" [--seed 4300]
|
||||||
|
python3 tools/gen/mb.py mesh <in.png> <out.glb> [--operator trellis2_mlx]
|
||||||
|
python3 tools/gen/mb.py ops
|
||||||
|
|
||||||
|
The farm is free and unlimited on our own silicon, which makes it the fallback
|
||||||
|
when Cloudflare's daily neurons run out — and the ONLY route for meshes.
|
||||||
|
|
||||||
|
Contract notes that cost previous sessions time, encoded here so they cannot be
|
||||||
|
got wrong again:
|
||||||
|
|
||||||
|
- `asset_id` is a **TOP-LEVEL** job field, not a member of `params`. Nested, the
|
||||||
|
mesh operators fail with a flat "no input image" that reads like a bad upload.
|
||||||
|
- Job logs carry raw control characters, so every response is scrubbed before
|
||||||
|
`json.loads` — otherwise polling dies mid-batch on a malformed payload.
|
||||||
|
- Jobs do not back-link their output asset id. Retrieval is "newest asset whose
|
||||||
|
`parent_job` is my job id"; the newest-of-type shortcut RACES when two jobs
|
||||||
|
run at once, and on 2026-07-16 that handed one project's mesh to another.
|
||||||
|
- The asset list field is `name`, not `filename`.
|
||||||
|
- The guest token allows 4 active jobs, so the batch driver keeps its own gate.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import creds # noqa: E402
|
||||||
|
|
||||||
|
HOST = "http://100.89.131.57:8777"
|
||||||
|
MAX_ACTIVE = 4 # guest-token ceiling
|
||||||
|
POLL_SECONDS = 6
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub(raw: bytes) -> object:
|
||||||
|
text = raw.decode("utf-8", "replace")
|
||||||
|
return json.loads("".join(c if c >= " " or c == "\t" else " " for c in text))
|
||||||
|
|
||||||
|
|
||||||
|
def _req(path: str, data: bytes | None = None, headers: dict[str, str] | None = None,
|
||||||
|
raw: bool = False, timeout: int = 180) -> object:
|
||||||
|
head = {"Authorization": f"Bearer {creds.get('MB_TOKEN')}"}
|
||||||
|
head.update(headers or {})
|
||||||
|
req = urllib.request.Request(HOST + path, data=data, headers=head)
|
||||||
|
try:
|
||||||
|
body = urllib.request.urlopen(req, timeout=timeout).read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode(errors="ignore")[:400]
|
||||||
|
raise SystemExit(f"MODELBEAST HTTP {exc.code} on {path}: {detail}") from None
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise SystemExit(f"MODELBEAST unreachable at {HOST}: {exc.reason}") from None
|
||||||
|
return body if raw else _scrub(body)
|
||||||
|
|
||||||
|
|
||||||
|
def ops() -> list[str]:
|
||||||
|
data = _req("/api/operators")
|
||||||
|
items = data if isinstance(data, list) else data.get("operators", data.get("items", []))
|
||||||
|
return sorted(o.get("name", str(o)) if isinstance(o, dict) else str(o) for o in items)
|
||||||
|
|
||||||
|
|
||||||
|
def upload(path: Path) -> str:
|
||||||
|
boundary = uuid.uuid4().hex
|
||||||
|
ctype = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
||||||
|
body = (
|
||||||
|
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{path.name}"\r\n'
|
||||||
|
f"Content-Type: {ctype}\r\n\r\n"
|
||||||
|
).encode() + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
||||||
|
res = _req("/api/assets", data=body,
|
||||||
|
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
|
||||||
|
if isinstance(res, dict):
|
||||||
|
return res.get("id") or (res.get("items") or [{}])[0].get("id", "")
|
||||||
|
return res[0]["id"] if res else ""
|
||||||
|
|
||||||
|
|
||||||
|
def submit(operator: str, params: dict | None = None, asset_id: str | None = None) -> str:
|
||||||
|
"""Submit a job. `asset_id` goes TOP-LEVEL — see the module docstring."""
|
||||||
|
payload: dict[str, object] = {"operator": operator, "params": params or {}}
|
||||||
|
if asset_id:
|
||||||
|
payload["asset_id"] = asset_id
|
||||||
|
res = _req("/api/jobs", data=json.dumps(payload).encode(),
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
return res["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def status(job_id: str) -> str:
|
||||||
|
return _req(f"/api/jobs/{job_id}").get("status", "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def wait(job_id: str, *, timeout: int = 2400, label: str = "") -> str:
|
||||||
|
t0 = time.time()
|
||||||
|
last = ""
|
||||||
|
while True:
|
||||||
|
state = status(job_id)
|
||||||
|
if state != last:
|
||||||
|
print(f"[mb] {label or job_id}: {state} ({int(time.time() - t0)}s)", flush=True)
|
||||||
|
last = state
|
||||||
|
if state in ("done", "error", "cancelled", "failed"):
|
||||||
|
return state
|
||||||
|
if time.time() - t0 > timeout:
|
||||||
|
return "timeout"
|
||||||
|
time.sleep(POLL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def output_asset(job_id: str, suffix: str) -> str | None:
|
||||||
|
"""The asset THIS job produced. parent_job only — never newest-of-type."""
|
||||||
|
data = _req("/api/assets?limit=200")
|
||||||
|
items = data if isinstance(data, list) else data.get("items", [])
|
||||||
|
mine = [
|
||||||
|
a for a in items
|
||||||
|
if a.get("parent_job") == job_id
|
||||||
|
and str(a.get("name", a.get("filename", ""))).lower().endswith(suffix)
|
||||||
|
]
|
||||||
|
return mine[0]["id"] if mine else None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(asset_id: str, out: Path) -> Path:
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_bytes(_req(f"/api/assets/{asset_id}/file", raw=True, timeout=600))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the two things the asset batch actually asks for ------------------------
|
||||||
|
|
||||||
|
def image(out: Path, prompt: str, *, seed: int = 0, steps: int = 4,
|
||||||
|
width: int = 1024, height: int = 1024, label: str = "") -> Path:
|
||||||
|
job = submit("flux_local", {
|
||||||
|
"prompt": prompt, "seed": seed, "steps": steps,
|
||||||
|
"width": width, "height": height, "model": "flux2-klein-4b",
|
||||||
|
})
|
||||||
|
state = wait(job, label=label or out.stem)
|
||||||
|
if state != "done":
|
||||||
|
raise RuntimeError(f"flux_local {state} for {out.stem}")
|
||||||
|
asset = output_asset(job, ".png")
|
||||||
|
if not asset:
|
||||||
|
raise RuntimeError(f"flux_local produced no png for {out.stem}")
|
||||||
|
return fetch(asset, out)
|
||||||
|
|
||||||
|
|
||||||
|
# Props ship at 16-48px. TRELLIS's defaults (1024_cascade + a 2048px metal PBR
|
||||||
|
# bake) are tuned for hero meshes you look at up close, and they cost 5-12
|
||||||
|
# minutes each on a queue that runs ONE gpu job at a time — call it four hours
|
||||||
|
# for a 37-prop tileset. The 512 tier with a vertex bake lands in ~90s, and at
|
||||||
|
# the size these are actually seen (docs/SPACES.md: "judge at ship size, not
|
||||||
|
# render size") the difference does not survive the downscale to 32x32, let
|
||||||
|
# alone the venue's own darkness sheet.
|
||||||
|
FAST_MESH = {
|
||||||
|
"pipeline_type": "512",
|
||||||
|
"baker": "vertex", # 1s bake, cleanest colours, no MR maps we'd use
|
||||||
|
"max_bake_faces": 200000,
|
||||||
|
"alpha_mode": "opaque", # props_import keys off Blender's alpha, not the mesh's
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mesh(src: Path, out: Path, *, operator: str = "trellis2_mlx", label: str = "",
|
||||||
|
params: dict | None = None) -> Path:
|
||||||
|
asset_id = upload(src)
|
||||||
|
job = submit(operator, {**FAST_MESH, **(params or {})}, asset_id=asset_id)
|
||||||
|
state = wait(job, timeout=3600, label=label or out.stem)
|
||||||
|
if state != "done":
|
||||||
|
raise RuntimeError(f"{operator} {state} for {out.stem}")
|
||||||
|
glb = output_asset(job, ".glb")
|
||||||
|
if not glb:
|
||||||
|
raise RuntimeError(f"{operator} produced no glb for {out.stem}")
|
||||||
|
return fetch(glb, out)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
if cmd == "ops":
|
||||||
|
print("\n".join(ops()))
|
||||||
|
elif cmd == "image":
|
||||||
|
seed = int(sys.argv[sys.argv.index("--seed") + 1]) if "--seed" in sys.argv else 0
|
||||||
|
print(image(Path(sys.argv[2]), sys.argv[3], seed=seed))
|
||||||
|
elif cmd == "mesh":
|
||||||
|
op = sys.argv[sys.argv.index("--operator") + 1] if "--operator" in sys.argv else "trellis2_mlx"
|
||||||
|
print(mesh(Path(sys.argv[2]), Path(sys.argv[3]), operator=op))
|
||||||
|
else:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
39
tools/gen/rebuild_manifest.py
Normal file
39
tools/gen/rebuild_manifest.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""rebuild_manifest.py — make public/props/manifest.json match what is on disk.
|
||||||
|
|
||||||
|
python3 tools/gen/rebuild_manifest.py
|
||||||
|
|
||||||
|
The manifest is the list the game loads at boot; a prop missing from it renders
|
||||||
|
as a placeholder even though its PNG is sitting right there. Two ways that
|
||||||
|
happens: a concurrent batch losing an entry to a read-modify-write race (now
|
||||||
|
locked in props_import.py, but earlier runs were not), or a sprite copied into
|
||||||
|
the directory by hand.
|
||||||
|
|
||||||
|
Directory listing is the source of truth here, deliberately — the PNGs are what
|
||||||
|
the game can actually load.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROPS = Path(__file__).resolve().parent.parent.parent / "public" / "props"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
names = sorted(p.stem for p in PROPS.glob("*.png"))
|
||||||
|
man_path = PROPS / "manifest.json"
|
||||||
|
before = set(json.loads(man_path.read_text()).get("props", [])) if man_path.exists() else set()
|
||||||
|
man_path.write_text(json.dumps({"props": names}, indent=2) + "\n")
|
||||||
|
|
||||||
|
added = [n for n in names if n not in before]
|
||||||
|
dropped = sorted(before - set(names))
|
||||||
|
print(f"manifest now lists {len(names)} props")
|
||||||
|
if added:
|
||||||
|
print(f" recovered (on disk, were missing from the manifest): {', '.join(added)}")
|
||||||
|
if dropped:
|
||||||
|
print(f" removed (listed but no PNG on disk): {', '.join(dropped)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
195
tools/gen/run_batch.py
Normal file
195
tools/gen/run_batch.py
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""run_batch.py — generate every sprite in assets.py, unattended.
|
||||||
|
|
||||||
|
python3 tools/gen/run_batch.py # everything still missing
|
||||||
|
python3 tools/gen/run_batch.py pokie jukebox # just these kinds
|
||||||
|
python3 tools/gen/run_batch.py --force # redo even if the png exists
|
||||||
|
python3 tools/gen/run_batch.py --flat-only # cheap pass, no mesh jobs
|
||||||
|
|
||||||
|
Resumable by design: a kind whose `public/props/<kind>.png` already exists is
|
||||||
|
skipped, so a run that dies at asset 30 of 47 costs 17 assets to finish, not 47.
|
||||||
|
Every intermediate (product shot, cutout, GLB) is kept in `art_incoming/` for
|
||||||
|
the same reason — re-angling a prop should never mean re-meshing it, which is
|
||||||
|
the [GLB] route's whole advantage (LANEHANDOVER, FABLE-SOLO-25).
|
||||||
|
|
||||||
|
Image tier order is Cloudflare FIRST — 10,000 free neurons a day, ~4s an image —
|
||||||
|
then MODELBEAST `flux_local`, which is slower but unmetered. The fallback is
|
||||||
|
automatic and one-way per run: once Cloudflare reports the daily allocation
|
||||||
|
spent, we stop asking it and go to the farm for the rest of the batch.
|
||||||
|
|
||||||
|
Heartbeat lands in ~/.jobs/not-tonight-assets.json per the fleet jobs
|
||||||
|
convention, so "where are we at" is answerable without reading the log.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import concurrent.futures as cf
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import assets as manifest # noqa: E402
|
||||||
|
import cf_flux # noqa: E402
|
||||||
|
import mb # noqa: E402
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent.parent
|
||||||
|
INCOMING = REPO / "art_incoming"
|
||||||
|
PROPS = REPO / "public" / "props"
|
||||||
|
HEARTBEAT = Path.home() / ".jobs" / "not-tonight-assets.json"
|
||||||
|
|
||||||
|
# Flipped the first time Cloudflare says the day's neurons are gone. One-way:
|
||||||
|
# re-probing per asset would waste a call each time to learn the same thing.
|
||||||
|
_cf_spent = False
|
||||||
|
|
||||||
|
|
||||||
|
def beat(**fields: object) -> None:
|
||||||
|
HEARTBEAT.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {"job": "not-tonight-assets", "updated": time.strftime("%Y-%m-%d %H:%M:%S"), **fields}
|
||||||
|
HEARTBEAT.write_text(json.dumps(payload, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def out_png(kind: str) -> Path:
|
||||||
|
return PROPS / f"{kind.replace(':', '_')}.png"
|
||||||
|
|
||||||
|
|
||||||
|
def draw(a: manifest.Asset, dest: Path) -> str:
|
||||||
|
"""One image from the cheapest tier that still has budget. Returns the tier."""
|
||||||
|
global _cf_spent
|
||||||
|
w, h = a.gen
|
||||||
|
if not _cf_spent:
|
||||||
|
try:
|
||||||
|
# Workers AI schnell has no width/height knob — it returns square.
|
||||||
|
# Fine for props; the wide plates below need the farm regardless.
|
||||||
|
if w == h:
|
||||||
|
cf_flux.generate(dest, a.prompt, steps=6, seed=a.seed)
|
||||||
|
return "cloudflare"
|
||||||
|
except cf_flux.Exhausted:
|
||||||
|
_cf_spent = True
|
||||||
|
print("[gen] cloudflare free tier spent — rest of the batch on the farm", flush=True)
|
||||||
|
except Exception as exc: # network wobble: fall through to the farm
|
||||||
|
print(f"[gen] cloudflare failed ({exc.__class__.__name__}), using farm", flush=True)
|
||||||
|
mb.image(dest, a.prompt, seed=a.seed, steps=4, width=w, height=h, label=a.kind)
|
||||||
|
return "farm"
|
||||||
|
|
||||||
|
|
||||||
|
def do_flat(a: manifest.Asset) -> None:
|
||||||
|
raw = INCOMING / f"{a.kind.replace(':', '_')}_raw.png"
|
||||||
|
if not raw.exists():
|
||||||
|
draw(a, raw)
|
||||||
|
cmd = [sys.executable, str(REPO / "tools" / "props_import.py"), str(raw), a.kind]
|
||||||
|
if not a.quantise:
|
||||||
|
cmd.append("--no-quantise")
|
||||||
|
if a.keep_bg:
|
||||||
|
cmd.append("--keep-bg")
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def do_mesh(a: manifest.Asset) -> None:
|
||||||
|
stem = a.kind.replace(":", "_")
|
||||||
|
shot = INCOMING / f"{stem}_shot.png"
|
||||||
|
cut = INCOMING / f"{stem}_cut.png"
|
||||||
|
glb = INCOMING / f"{stem}.glb"
|
||||||
|
|
||||||
|
if not shot.exists():
|
||||||
|
draw(a, shot)
|
||||||
|
if not cut.exists():
|
||||||
|
# TRELLIS reconstructs markedly better from a clean cutout than from an
|
||||||
|
# object sitting on a grey sweep — the operator's own docs say so, and
|
||||||
|
# it costs one cheap local job.
|
||||||
|
try:
|
||||||
|
asset_id = mb.upload(shot)
|
||||||
|
job = mb.submit("bg_remove_local", {"resolution": 1024}, asset_id=asset_id)
|
||||||
|
if mb.wait(job, label=f"{a.kind}:cut") == "done":
|
||||||
|
got = mb.output_asset(job, ".png")
|
||||||
|
if got:
|
||||||
|
mb.fetch(got, cut)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[gen] {a.kind}: bg_remove failed ({exc}) — meshing the raw shot", flush=True)
|
||||||
|
src = cut if cut.exists() else shot
|
||||||
|
|
||||||
|
if not glb.exists():
|
||||||
|
mb.mesh(src, glb, operator="trellis2_mlx", label=a.kind)
|
||||||
|
|
||||||
|
# Blender ortho render at the house rake, then the normal sprite import.
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, str(REPO / "tools" / "glb_to_sprite.py"), str(glb), a.kind,
|
||||||
|
"--rake", str(a.rake), "--px", "512"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
raw_args = sys.argv[1:]
|
||||||
|
force = "--force" in raw_args
|
||||||
|
flat_only = "--flat-only" in raw_args
|
||||||
|
# Drop flags AND the value that follows a value-taking flag — otherwise
|
||||||
|
# `--workers 3` leaves a bare "3" in the positional list and the batch
|
||||||
|
# filters down to the kind named "3", i.e. nothing.
|
||||||
|
argv: list[str] = []
|
||||||
|
skip_next = False
|
||||||
|
for i, tok in enumerate(raw_args):
|
||||||
|
if skip_next:
|
||||||
|
skip_next = False
|
||||||
|
continue
|
||||||
|
if tok in ("--workers",):
|
||||||
|
skip_next = True
|
||||||
|
continue
|
||||||
|
if tok.startswith("--"):
|
||||||
|
continue
|
||||||
|
argv.append(tok)
|
||||||
|
|
||||||
|
todo = [a for a in manifest.ALL if not argv or a.kind in argv]
|
||||||
|
if flat_only:
|
||||||
|
todo = [a for a in todo if a.route == "flat"]
|
||||||
|
if not force:
|
||||||
|
todo = [a for a in todo if not out_png(a.kind).exists()]
|
||||||
|
|
||||||
|
workers = int(sys.argv[sys.argv.index("--workers") + 1]) if "--workers" in sys.argv else 3
|
||||||
|
INCOMING.mkdir(exist_ok=True)
|
||||||
|
done: list[str] = []
|
||||||
|
failed: list[str] = []
|
||||||
|
print(f"[gen] {len(todo)} assets to make, {workers} at a time", flush=True)
|
||||||
|
beat(status="running", total=len(todo), done=0, failed=0)
|
||||||
|
|
||||||
|
# The farm load-balances gpu jobs across nodes, so a strictly sequential
|
||||||
|
# batch leaves most of the cluster idle — 37 meshes at ~7 min each is over
|
||||||
|
# four hours in a queue of one. Three in flight stays under the guest
|
||||||
|
# token's 4-active ceiling and keeps every node fed. Safe to overlap
|
||||||
|
# because output retrieval matches on `parent_job`, never newest-of-type
|
||||||
|
# (the 2026-07-16 race that handed one project's mesh to another).
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def run_one(a: manifest.Asset) -> None:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
(do_flat if a.route == "flat" else do_mesh)(a)
|
||||||
|
with lock:
|
||||||
|
done.append(a.kind)
|
||||||
|
print(f"[gen] {a.kind} OK in {int(time.time() - t0)}s", flush=True)
|
||||||
|
except Exception as exc:
|
||||||
|
with lock:
|
||||||
|
failed.append(a.kind)
|
||||||
|
print(f"[gen] {a.kind} FAILED: {exc}", flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
with lock:
|
||||||
|
beat(status="running", total=len(todo), done=len(done), failed=len(failed),
|
||||||
|
last=a.kind, still_to_go=len(todo) - len(done) - len(failed))
|
||||||
|
|
||||||
|
with cf.ThreadPoolExecutor(max_workers=workers) as pool:
|
||||||
|
list(pool.map(run_one, todo))
|
||||||
|
|
||||||
|
beat(status="finished", total=len(todo), done=len(done), failed=len(failed),
|
||||||
|
failed_kinds=failed)
|
||||||
|
print(f"\n[gen] DONE {len(done)}/{len(todo)}"
|
||||||
|
+ (f" — FAILED: {', '.join(failed)}" if failed else ""))
|
||||||
|
# A partial batch is a normal outcome; the venue layouts fall back to
|
||||||
|
# placeholders for anything missing, so this must not read as total failure.
|
||||||
|
sys.exit(0 if not failed else 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -114,7 +114,11 @@ def main() -> None:
|
|||||||
if not Path(BLENDER).exists():
|
if not Path(BLENDER).exists():
|
||||||
sys.exit(f"Blender not found at {BLENDER}")
|
sys.exit(f"Blender not found at {BLENDER}")
|
||||||
|
|
||||||
script = REPO / "art_incoming" / "_glb_render.py"
|
# Per-kind, not a single shared `_glb_render.py`: the batch generator runs
|
||||||
|
# several of these at once, and on one shared path a finishing render
|
||||||
|
# unlinks the script out from under a starting one — which fails as a
|
||||||
|
# baffling "cannot read script" on a prop that has nothing wrong with it.
|
||||||
|
script = REPO / "art_incoming" / f"_glb_render_{kind.replace(':', '_')}.py"
|
||||||
script.parent.mkdir(exist_ok=True)
|
script.parent.mkdir(exist_ok=True)
|
||||||
script.write_text(BLENDER_SCRIPT)
|
script.write_text(BLENDER_SCRIPT)
|
||||||
raw = REPO / "art_incoming" / f"{kind}.png"
|
raw = REPO / "art_incoming" / f"{kind}.png"
|
||||||
|
|||||||
@ -1,16 +1,23 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""props_import.py — turn raw generated art into game-ready prop sprites.
|
"""props_import.py — turn raw generated art into game-ready prop sprites.
|
||||||
|
|
||||||
python3 tools/props_import.py <raw-image> <kind> [--no-quantise]
|
python3 tools/props_import.py <raw-image> <kind> [--no-quantise] [--keep-bg]
|
||||||
|
|
||||||
Pipeline per docs/SPACES.md §4: crop to content, remove near-black/flat
|
Pipeline per docs/SPACES.md §4: crop to content, remove near-black/flat
|
||||||
background to alpha, nearest-neighbour downscale to the kind's target size,
|
background to alpha, nearest-neighbour downscale to the kind's target size,
|
||||||
optional 16-colour quantise (keeps the pixel-art register), write
|
optional 16-colour quantise (keeps the pixel-art register), write
|
||||||
public/props/<kind>.png and add <kind> to public/props/manifest.json.
|
public/props/<kind>.png and add <kind> to public/props/manifest.json.
|
||||||
|
|
||||||
|
`--keep-bg` skips the background-to-alpha pass. Backdrop and texture plates need
|
||||||
|
it: they are meant to be fully opaque, and the near-black cut punches holes
|
||||||
|
through exactly the parts that make a night street look like night.
|
||||||
|
|
||||||
Kinds and target sizes mirror PROP_SIZES in src/scenes/floor/FloorView.ts —
|
Kinds and target sizes mirror PROP_SIZES in src/scenes/floor/FloorView.ts —
|
||||||
unknown kinds are refused so a typo cannot ship an orphan sprite.
|
unknown kinds are refused so a typo cannot ship an orphan sprite. Sizes for the
|
||||||
|
venue expansion come from tools/gen/assets.py, which is the manifest the
|
||||||
|
generator itself walks, so the two cannot drift apart.
|
||||||
"""
|
"""
|
||||||
|
import fcntl
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -34,29 +41,42 @@ TARGETS = {
|
|||||||
"carpet": (128, 32),
|
"carpet": (128, 32),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The venue expansion's kinds live in the generator manifest so one edit feeds
|
||||||
|
# both the farm and this importer. Absent (a bare checkout, an older tools dir),
|
||||||
|
# the base table above still works exactly as it did.
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent / "gen"))
|
||||||
|
import assets as _assets # type: ignore[import-not-found]
|
||||||
|
TARGETS.update({a.kind: a.size for a in _assets.ALL})
|
||||||
|
except Exception: # pragma: no cover - optional dependency by design
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
sys.exit(__doc__)
|
sys.exit(__doc__)
|
||||||
raw, kind = Path(sys.argv[1]), sys.argv[2]
|
raw, kind = Path(sys.argv[1]), sys.argv[2]
|
||||||
quantise = "--no-quantise" not in sys.argv
|
quantise = "--no-quantise" not in sys.argv
|
||||||
|
keep_bg = "--keep-bg" in sys.argv
|
||||||
if kind not in TARGETS:
|
if kind not in TARGETS:
|
||||||
sys.exit(f"unknown kind '{kind}' — add it to PROP_SIZES + PROPS first, then here")
|
sys.exit(f"unknown kind '{kind}' — add it to PROP_SIZES + PROPS first, then here")
|
||||||
|
|
||||||
img = Image.open(raw).convert("RGBA")
|
img = Image.open(raw).convert("RGBA")
|
||||||
# near-black background -> transparent (generated props sit on dark bg by prompt)
|
if not keep_bg:
|
||||||
px = img.load()
|
# near-black background -> transparent (generated props sit on dark bg by prompt)
|
||||||
for y in range(img.height):
|
px = img.load()
|
||||||
for x in range(img.width):
|
for y in range(img.height):
|
||||||
r, g, b, a = px[x, y]
|
for x in range(img.width):
|
||||||
if r < 18 and g < 18 and b < 22:
|
r, g, b, a = px[x, y]
|
||||||
px[x, y] = (0, 0, 0, 0)
|
if r < 18 and g < 18 and b < 22:
|
||||||
bbox = img.getbbox()
|
px[x, y] = (0, 0, 0, 0)
|
||||||
if bbox:
|
bbox = img.getbbox()
|
||||||
img = img.crop(bbox)
|
if bbox:
|
||||||
|
img = img.crop(bbox)
|
||||||
|
|
||||||
w, h = TARGETS[kind]
|
w, h = TARGETS[kind]
|
||||||
img = img.resize((w, h), Image.NEAREST)
|
img = img.resize((w, h), Image.NEAREST)
|
||||||
if quantise and kind != "street:backdrop":
|
if quantise and not kind.startswith("street:"):
|
||||||
alpha = img.getchannel("A")
|
alpha = img.getchannel("A")
|
||||||
img = img.convert("RGB").quantize(16).convert("RGBA")
|
img = img.convert("RGB").quantize(16).convert("RGBA")
|
||||||
img.putalpha(alpha)
|
img.putalpha(alpha)
|
||||||
@ -67,12 +87,24 @@ def main() -> None:
|
|||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
img.save(out)
|
img.save(out)
|
||||||
|
|
||||||
|
# The manifest is a read-modify-write, and the batch generator runs several
|
||||||
|
# imports at once — unlocked, two finishing together each read the same
|
||||||
|
# list, and the second write silently drops the first one's entry. The prop
|
||||||
|
# is then on disk but not in the manifest, so the game never loads it and
|
||||||
|
# renders a placeholder over perfectly good art. An exclusive lock for the
|
||||||
|
# few milliseconds this takes is the whole fix.
|
||||||
man_path = root / "public" / "props" / "manifest.json"
|
man_path = root / "public" / "props" / "manifest.json"
|
||||||
man = json.loads(man_path.read_text()) if man_path.exists() else {"props": []}
|
lock_path = man_path.with_suffix(".lock")
|
||||||
if out_name not in man["props"]:
|
with open(lock_path, "w") as lock:
|
||||||
man["props"].append(out_name)
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
man["props"].sort()
|
try:
|
||||||
man_path.write_text(json.dumps(man, indent=2) + "\n")
|
man = json.loads(man_path.read_text()) if man_path.exists() else {"props": []}
|
||||||
|
if out_name not in man["props"]:
|
||||||
|
man["props"].append(out_name)
|
||||||
|
man["props"].sort()
|
||||||
|
man_path.write_text(json.dumps(man, indent=2) + "\n")
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_UN)
|
||||||
print(f"wrote {out} and updated manifest ({len(man['props'])} generated props live)")
|
print(f"wrote {out} and updated manifest ({len(man['props'])} generated props live)")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user