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
|
||||
dist/
|
||||
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.
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
@ -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 —
|
||||
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. **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",
|
||||
"cubicleDoor",
|
||||
"cueRack",
|
||||
"dartboard",
|
||||
"deck",
|
||||
"discoball",
|
||||
"dishwasher",
|
||||
@ -19,9 +20,12 @@
|
||||
"handDryer",
|
||||
"highTable",
|
||||
"iceWell",
|
||||
"jukebox",
|
||||
"keg",
|
||||
"mixer",
|
||||
"monitor",
|
||||
"mopBucket",
|
||||
"pallet",
|
||||
"patioHeater",
|
||||
"plant",
|
||||
"poolTable",
|
||||
@ -34,6 +38,7 @@
|
||||
"street_backdrop",
|
||||
"taps",
|
||||
"till",
|
||||
"trough",
|
||||
"truss",
|
||||
"urinal",
|
||||
"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;
|
||||
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
|
||||
// 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
|
||||
// below then declined to restart the already-active Night.
|
||||
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) {
|
||||
if (key !== target && game.scene.isActive(key)) game.scene.stop(key);
|
||||
}
|
||||
// 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.
|
||||
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
|
||||
// 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) {
|
||||
const route = (): void => {
|
||||
if (!game.scene.isActive('Roster')) return;
|
||||
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);
|
||||
}
|
||||
|
||||
@ -258,11 +258,26 @@ export class DoorScene extends Phaser.Scene {
|
||||
|
||||
// ---------- 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 {
|
||||
if (this.textures.exists('gen:prop:street_backdrop')) {
|
||||
// The generated plate (SPACES §5 item 11): brick, posters, grime. Sits
|
||||
// where the flat wall-of-night rect went; everything else layers on top.
|
||||
this.add.image(0, 0, 'gen:prop:street_backdrop').setOrigin(0, 0).setDisplaySize(W, 176).setAlpha(0.9);
|
||||
// Keyed on the venue id EXACTLY (tools/gen/assets.py generates
|
||||
// `street:<venueId>`, and props_import writes ':' as '_'). Not lowercased:
|
||||
// 'theRoyal' is the id, and a case fold would look for a plate nobody made.
|
||||
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 {
|
||||
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 { 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 { CrowdSim, type Agent } from './crowdSim';
|
||||
import { FightDirector } from './fightDirector';
|
||||
@ -69,28 +70,50 @@ const MAX_CROWD = 44;
|
||||
const SPAWN_EVERY_MS = 420;
|
||||
/** Admitted at the rope → appears on the floor after the walk in. */
|
||||
const WALK_IN_MS: [number, number] = [6000, 14000];
|
||||
/** Where a full rack of empties appears (bar's east end) and where it goes. */
|
||||
const RACK_SPOT = tileToWorld(51, 7);
|
||||
const DISH_SPOT = tileToWorld(17, 3);
|
||||
/** 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.
|
||||
* 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
|
||||
* on (55,22) put them inside that seal: every direction blocked, for the rest of
|
||||
* the night, and the `dj` roster role posts you there automatically. The counter
|
||||
* is where the map comment always said the gear faces from.
|
||||
* The playable stations, resolved from the LAYOUT rather than hardcoded.
|
||||
*
|
||||
* These were six module constants pointing at Voltage's tile coordinates, which
|
||||
* was fine while there was one room. With four, a hardcoded (47,3) puts the bar
|
||||
* shift inside The Royal's pool room — so each layout names its own stations in
|
||||
* `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);
|
||||
/** Behind the till (venueMap prop 46..47,4): where you hold the taps. */
|
||||
const TAPS_SPOT = tileToWorld(47, 3);
|
||||
/**
|
||||
* The customer side of the till — where E offers you the apron. Row 7, not row
|
||||
* 6: row 6 is the `stool` rect, and `stool` is not in venueMap's WALKABLE set.
|
||||
*/
|
||||
const TAPS_FRONT = tileToWorld(47, 7);
|
||||
interface Stations {
|
||||
/** Where a full rack of empties appears, and where it goes. */
|
||||
rack: WorldPoint;
|
||||
dish: WorldPoint;
|
||||
/** Where a cup of water comes from (RSA duty). */
|
||||
water: WorldPoint;
|
||||
/** Where you STAND to work the decks — never inside a sealed booth. */
|
||||
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. */
|
||||
const CARRY_BUMP_PX = 14;
|
||||
const CARRY_BUMP_COOLDOWN_MS = 350;
|
||||
@ -131,6 +154,9 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
private night!: NightState;
|
||||
private hud!: HudStub | MeterHud;
|
||||
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? */
|
||||
private present = true;
|
||||
private elapsedMs = 0;
|
||||
@ -214,13 +240,20 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
super(key);
|
||||
}
|
||||
|
||||
create(data?: { night?: NightContext }): void {
|
||||
create(data?: { night?: NightContext; venueId?: string }): void {
|
||||
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();
|
||||
// Wired explicitly: Phaser does not invoke a `shutdown` METHOD on a Scene
|
||||
// subclass; without this, listeners and sprites leak on every scene stop.
|
||||
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');
|
||||
|
||||
// Generated prop art loads FIRST (public/props + manifest — docs/SPACES.md
|
||||
@ -286,7 +319,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private buildWorld(): void {
|
||||
this.view = new FloorView(this, VENUE);
|
||||
this.view = new FloorView(this, this.layout);
|
||||
this.cutOff = new CutOffOverlay(this);
|
||||
this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null);
|
||||
this.choice = new ChoiceOverlay(this);
|
||||
@ -301,7 +334,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
? faintRng.int(FAINT_WINDOW[0], FAINT_WINDOW[1])
|
||||
: null;
|
||||
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)
|
||||
.setBlendMode(Phaser.BlendModes.ADD)
|
||||
.setTint(0xd8c23a)
|
||||
@ -363,11 +396,11 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
this.beatMs = ctx.beatIntervalMs;
|
||||
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 = 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.view.reset();
|
||||
@ -449,7 +482,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
this.beatMs = 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 = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
|
||||
this.bus.on('beat:tick', ({ beatIndex }) => {
|
||||
@ -458,7 +491,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
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.view.reset();
|
||||
@ -631,7 +664,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
|
||||
// Escorting costs you the walk: no interacting, slower going.
|
||||
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) {
|
||||
// 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
|
||||
// else (including the door home) waits until the rack is down.
|
||||
if (this.rack) {
|
||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, DISH_SPOT.x, DISH_SPOT.y);
|
||||
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' };
|
||||
return null;
|
||||
}
|
||||
|
||||
// A full rack waiting at the bar's east end.
|
||||
if (this.clock.clockMin >= this.rackReadyAtMin) {
|
||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, RACK_SPOT.x, RACK_SPOT.y);
|
||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, this.spots.rack.x, this.spots.rack.y);
|
||||
if (d <= INTERACT_RANGE) {
|
||||
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.
|
||||
if (this.nightCtx) {
|
||||
const entry = VENUE.anchors.entry[0];
|
||||
const entry = this.layout.map.anchors.entry[0];
|
||||
if (entry) {
|
||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y);
|
||||
if (d <= INTERACT_RANGE) return { kind: 'exit', prompt: 'E — back out to the door' };
|
||||
@ -811,7 +844,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
// 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.
|
||||
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 };
|
||||
}
|
||||
|
||||
@ -821,18 +854,18 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
|
||||
// The booth: hold the decks. Blocked while your hands are otherwise full.
|
||||
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 };
|
||||
}
|
||||
|
||||
// The till: hold the taps. Same both-hands rule as the decks.
|
||||
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 };
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (d > INTERACT_RANGE) continue;
|
||||
const occupants = this.crowd.stallOccupancy().get(s.id) ?? [];
|
||||
@ -1232,8 +1265,8 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
private toggleDecks(): void {
|
||||
if (!this.djMode) {
|
||||
this.djMode = true;
|
||||
this.player.x = DECKS_SPOT.x;
|
||||
this.player.y = DECKS_SPOT.y;
|
||||
this.player.x = this.spots.decks.x;
|
||||
this.player.y = this.spots.decks.y;
|
||||
this.toast(DJ_UI.handover);
|
||||
this.promptText.setText(DJ_UI.help);
|
||||
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 {
|
||||
if (!this.barMode) {
|
||||
this.barMode = true;
|
||||
this.player.x = TAPS_SPOT.x;
|
||||
this.player.y = TAPS_SPOT.y;
|
||||
this.player.x = this.spots.taps.x;
|
||||
this.player.y = this.spots.taps.y;
|
||||
this.toast(BAR_UI.handover);
|
||||
this.promptText.setText(BAR_UI.help);
|
||||
this.logIncident('barShift', undefined, 'Held the taps while the bartender stepped out.');
|
||||
@ -1485,6 +1518,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
return {
|
||||
crowd: () => this.crowd,
|
||||
view: () => this.view,
|
||||
map: () => this.layout.map,
|
||||
getPlayer: () => this.player,
|
||||
setPlayer: (p) => { this.player = p; },
|
||||
readInput: () => this.readInput(),
|
||||
|
||||
@ -3,7 +3,7 @@ import { renderDoll } from '../../patrons/doll';
|
||||
import { dollPlan } from '../../patrons/dollPlan';
|
||||
import { conePolygon, type ConeSpec } from './cone';
|
||||
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';
|
||||
|
||||
// Pure drawing for the floor. Darkness is the mechanic: the venue is painted
|
||||
@ -65,6 +65,9 @@ interface AgentGfx {
|
||||
export class FloorView {
|
||||
private readonly scene: Phaser.Scene;
|
||||
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 neon: Phaser.GameObjects.Image[] = [];
|
||||
@ -100,9 +103,17 @@ export class FloorView {
|
||||
private frame = 0;
|
||||
private uv = false;
|
||||
|
||||
constructor(scene: Phaser.Scene, map: VenueMap) {
|
||||
constructor(scene: Phaser.Scene, layout: FloorLayout) {
|
||||
this.scene = scene;
|
||||
const map = layout.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 worldH = map.height * TILE;
|
||||
|
||||
@ -183,7 +194,7 @@ export class FloorView {
|
||||
* read as silhouettes until the torch finds them.
|
||||
*/
|
||||
private buildProps(): void {
|
||||
for (const p of PROPS) {
|
||||
for (const p of this.props) {
|
||||
const gen = `gen:prop:${p.kind}`;
|
||||
const key = this.scene.textures.exists(gen) ? gen : bakePropPlaceholder(this.scene, p.kind);
|
||||
const img = this.scene.add
|
||||
@ -198,7 +209,7 @@ export class FloorView {
|
||||
|
||||
/** Zone light signatures — navigate by glow colour (docs/SPACES.md §1). */
|
||||
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(
|
||||
@ -585,7 +596,7 @@ export class FloorView {
|
||||
const g = this.scene.make.graphics({}, false);
|
||||
for (let ty = 0; ty < this.map.height; ty++) {
|
||||
for (let tx = 0; tx < this.map.width; tx++) {
|
||||
g.fillStyle(tileColour(tileAt(this.map, tx, ty)), 1);
|
||||
g.fillStyle(tileColour(tileAt(this.map, tx, ty), this.palette), 1);
|
||||
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],
|
||||
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],
|
||||
// ---- 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 {
|
||||
@ -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');
|
||||
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();
|
||||
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 { CrowdSim } from './crowdSim';
|
||||
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';
|
||||
|
||||
// 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. */
|
||||
crowd(): CrowdSim;
|
||||
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;
|
||||
setPlayer(p: PlayerState): void;
|
||||
readInput(): PlayerInput;
|
||||
@ -82,7 +86,7 @@ export class Sweep {
|
||||
for (let tries = 0; tries < 60; tries++) {
|
||||
tx = rng.int(2, MAP_W - 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 gfx = this.scene.add.container(x, y).setDepth(39);
|
||||
@ -101,7 +105,7 @@ export class Sweep {
|
||||
update(dt: number): void {
|
||||
if (!this.active) return;
|
||||
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();
|
||||
this.scene.cameras.main.centerOn(p.x, p.y);
|
||||
@ -125,7 +129,7 @@ export class Sweep {
|
||||
}
|
||||
|
||||
private atExit(): boolean {
|
||||
const entry = VENUE.anchors.entry[0];
|
||||
const entry = this.host.map().anchors.entry[0];
|
||||
if (!entry) return false;
|
||||
const p = this.host.getPlayer();
|
||||
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_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 => {
|
||||
if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) return;
|
||||
tiles[ty * MAP_W + tx] = kind;
|
||||
@ -81,6 +98,11 @@ function buildTiles(): TileKind[] {
|
||||
rect(x0, y0, x0, 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');
|
||||
|
||||
@ -186,7 +208,25 @@ export type PropKind =
|
||||
| 'buttBin' | 'crate' | 'plant' | 'wetFloor' | 'poolTable' | 'dishwasher'
|
||||
| 'urinal' | 'handDryer' | 'cubicleDoor' | 'mopBucket' | 'cueRack'
|
||||
| '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 {
|
||||
colour: number;
|
||||
@ -350,6 +390,31 @@ export const PROBES: Readonly<Record<string, TilePoint>> = Object.freeze({
|
||||
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 {
|
||||
if (tx < 0 || tx >= map.width || ty < 0 || ty >= map.height) return '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);
|
||||
}
|
||||
|
||||
/** Colour for painting a tile in the dark venue. */
|
||||
export function tileColour(kind: TileKind): number {
|
||||
return COLOURS[kind];
|
||||
/**
|
||||
* Colour for painting a tile in the dark venue, with the venue's own materials
|
||||
* 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',
|
||||
'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) {
|
||||
expect(Number.isInteger(c)).toBe(true);
|
||||
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():
|
||||
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.write_text(BLENDER_SCRIPT)
|
||||
raw = REPO / "art_incoming" / f"{kind}.png"
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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
|
||||
background to alpha, nearest-neighbour downscale to the kind's target size,
|
||||
optional 16-colour quantise (keeps the pixel-art register), write
|
||||
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 —
|
||||
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 sys
|
||||
from pathlib import Path
|
||||
@ -34,29 +41,42 @@ TARGETS = {
|
||||
"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:
|
||||
if len(sys.argv) < 3:
|
||||
sys.exit(__doc__)
|
||||
raw, kind = Path(sys.argv[1]), sys.argv[2]
|
||||
quantise = "--no-quantise" not in sys.argv
|
||||
keep_bg = "--keep-bg" in sys.argv
|
||||
if kind not in TARGETS:
|
||||
sys.exit(f"unknown kind '{kind}' — add it to PROP_SIZES + PROPS first, then here")
|
||||
|
||||
img = Image.open(raw).convert("RGBA")
|
||||
# near-black background -> transparent (generated props sit on dark bg by prompt)
|
||||
px = img.load()
|
||||
for y in range(img.height):
|
||||
for x in range(img.width):
|
||||
r, g, b, a = px[x, y]
|
||||
if r < 18 and g < 18 and b < 22:
|
||||
px[x, y] = (0, 0, 0, 0)
|
||||
bbox = img.getbbox()
|
||||
if bbox:
|
||||
img = img.crop(bbox)
|
||||
if not keep_bg:
|
||||
# near-black background -> transparent (generated props sit on dark bg by prompt)
|
||||
px = img.load()
|
||||
for y in range(img.height):
|
||||
for x in range(img.width):
|
||||
r, g, b, a = px[x, y]
|
||||
if r < 18 and g < 18 and b < 22:
|
||||
px[x, y] = (0, 0, 0, 0)
|
||||
bbox = img.getbbox()
|
||||
if bbox:
|
||||
img = img.crop(bbox)
|
||||
|
||||
w, h = TARGETS[kind]
|
||||
img = img.resize((w, h), Image.NEAREST)
|
||||
if quantise and kind != "street:backdrop":
|
||||
if quantise and not kind.startswith("street:"):
|
||||
alpha = img.getchannel("A")
|
||||
img = img.convert("RGB").quantize(16).convert("RGBA")
|
||||
img.putalpha(alpha)
|
||||
@ -67,12 +87,24 @@ def main() -> None:
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
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 = 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")
|
||||
lock_path = man_path.with_suffix(".lock")
|
||||
with open(lock_path, "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
try:
|
||||
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)")
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Loading…
Reference in New Issue
Block a user