Spaces: DJ booth, smoking yard, sinks, glassie station; data-driven PROPS/LIGHTS with beat-pulsed zone lighting; art drop-in pipeline + generation queue

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 14:38:19 +10:00
parent 2e0b1917b8
commit b1e2f038bc
10 changed files with 639 additions and 15 deletions

View File

@ -39,6 +39,9 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
`localStorage.removeItem('not-tonight-save')` for a clean run.
- Audio unlocks on first click (TechnoEngine + location filter). Before that a
StubBeatClock keeps `beat:tick` honest.
- Venue spaces/props/lights are DATA (`venueMap.PROPS`/`LIGHTS`, plan in
docs/SPACES.md). Generated art drops into `public/props/` via
`tools/props_import.py` + manifest — placeholders render until then.
## Dev notes (Phase 0, still true)
- Gate: `npm run lint && npm run build && npm test` — all three must pass before

View File

@ -1002,3 +1002,40 @@ https://monsterrobot.games/not-tonight/ (bundle index-W0Kf_Kh7.js, verified 200)
everything tanks the room via laps/ripens) but worth a human sanity-check.
**Next:** John's ear pass (M in a live night) and a human feel-pass over the
new economy — the bands make it safe to turn knobs. Then venue 2.
---
### SESSION — SPACES (Fable, solo) — 2026-07-20 14:30
**Branch/commits:** main; deployed live.
**Done — the venue became a place (docs/SPACES.md is the plan):**
- **venueMap grew rooms**: sealed DJ BOOTH east of the dance floor (decks +
mixer + monitor from the fleet's own gear lineage), fenced open-sky SMOKING
YARD in the SW (smoke anchor moved inside — wanderers now actually go out for
one; rain-blue light spill), sink row in the dunnies, glassie-station alcove
(role-ladder hook), entry-corridor poster wall/cloak/stamp podium, one arcade
cabinet. South booths shifted east of the yard. 5 new tile kinds; all
reachability/orphan tests extended and green.
- **Props are DATA**: `venueMap.PROPS` (31 placements, tile footprints,
optional emissive) + `LIGHTS` (zone light signatures — bar ambers, 3×3 dance
grid that pulses on the ACTUAL beat via FloorView.onBeat, DJ blue, dunny
green fluoro that flickers like dying fluoro, yard blue, entry pink).
FloorView renders every prop from `gen:prop:<kind>` if a generated texture
exists, else a baked pixel placeholder — the game is fully dressed TODAY.
- **The art drop-in contract**: `public/props/manifest.json` + PNGs; the floor
loads the manifest before building (both modes, missing manifest = fine).
`tools/props_import.py` (PIL) turns raw MODELBEAST/Blender output into
game-ready sprites (crop→alpha→NEAREST resize→16-colour quantise→manifest).
- **John's generation queue** is docs/SPACES.md §5 — 13 prioritised items with
pipelines, prompts, seeds and sizes ([GLB] prerenders of 90sDJsim gear +
[MB] flux jobs). Raw drops: `art_incoming/`.
**Tests:** 637 passing (+5 venue). Gate clean. **Deployed + verified live**
(game 200, manifest 200). Screenshots: dance floor + DJ booth reading as real
space; bar run with amber shelf/taps/glassie racks/posters.
**Broke / known-wonky:**
- Prop art is placeholder until John's queue lands — by design.
- The pane's navigate() strips URL hashes (#floor) — dev routes fine in a real
browser; in the pane use the N/P/F/J keys instead.
- Yard rain particles not yet keyed to 'yard' tiles (renderer knows the kind;
particles are a small FLOOR follow-up with the smoking loop).
**Next:** John runs the §5 queue (I process + deploy as they land); the two
human passes (ear, economy feel) still open; then venue 2 planning can reuse
the whole PROPS/LIGHTS system as-is.

1
art_incoming/README.txt Normal file
View File

@ -0,0 +1 @@
raw drops go here; process with tools/props_import.py

115
docs/SPACES.md Normal file
View File

@ -0,0 +1,115 @@
# NOT TONIGHT — Spaces Plan
*How the game's two spaces grow from readable-but-abstract into places, using the
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.*
## 1. Design principles
1. **Darkness stays the mechanic.** Props read as silhouettes in ambient dark and
resolve under the torch. Nothing may make the room bright enough to scan from
the doorway.
2. **Navigate by light signature.** Each zone owns a colour of light, so the
player orients by glow before geometry: bar = warm amber · dance floor =
pulsing pink (on the actual beat) · DJ booth = deep blue · toilets =
flickering green fluoro · smoking yard = cool rain-blue spill · entry/exit =
venue pink. The torch is white; UV is purple. No other light sources.
3. **Props are data.** `PropDef { kind, tile pos, footprint, emissive? }` — the
renderer prefers a generated texture (`gen:prop:<kind>`) and falls back to a
procedural placeholder, so the game is complete TONIGHT and gets prettier as
art lands.
4. **Spaces anticipate the role ladder** (design §6): the bar interior is laid
out for a future bartender shift, the DJ booth for the DJ shift, the glassie
station and smoking yard for floor loops that already have lanes speccing them.
## 2. The venue floor (80×45 tiles, one room + pockets)
```
┌──────────────────────────────────────────────────────────────────┐
│ N ═ BAR ══════════ back-shelf glow ┌─ DUNNIES ────────┐│
│ taps · till · glasses │ s1 s2 s3 s4 ││
│ ○○○○○○ stools │ sinks + mirror ││
│ └───door───────────┘│
│ W │
│ ┈entry┈ ┌ DANCE FLOOR (pulsing grid) ┐ ╔═ DJ BOOTH ╗ booths│
│ corridor │ speaker · · · · · speaker │ ║ decks+mixer║ E│
│ posters │ · · · disco ball · · · │ ║ (sealed) ║ booths│
│ cloak │ speaker · · · · · speaker │ ╚════════════╝ │
│ glassie └───────────────────────────┘ booths│
│ station │
│ ▓ SMOKING YARD (open sky, rain) ▓ tables+candles S │
└────────▓ fence · butt bin ▓──────────────────────────────────────┘
```
Zone-by-zone (tile coords in `venueMap.ts`):
| Zone | What's there (props) | Light signature | Future role hook |
|---|---|---|---|
| **Bar** (north run) | back-bar shelf (emissive), 3 beer taps, till, glass stacks | steady amber pools along the shelf | Bartender shift: the serving lane between bar tiles and shelf is already unwalkable-to-patrons |
| **Dance floor** (centre) | 4 corner speaker stacks, disco ball (centre), floor grid cells | pink grid cells pulse ON `beat:tick`; ball throws a slow-rotating glint | DJ role reads crowd here |
| **DJ booth** (east of dance, sealed box facing the floor) | 2 decks + mixer (from the 90sDJsim gear GLBs!), booth monitor, lamp | deep blue wash + VU flicker | The DJ shift's playable pocket |
| **Booths** (south + east walls) | table per booth pair, candle | tiny warm candle pools | — |
| **Dunnies** (NE walled room) | sink row + mirror on west wall, hand dryer | green fluoro, FLICKERS (torch almost redundant inside — deliberate) | — |
| **Smoking yard** (SW walled pocket, open sky) | butt bin, milk crate seats, fence | cool blue outdoor spill + rain particles INSIDE the yard | FLOOR2's smoking loop (specced) |
| **Entry corridor** (west) | poster wall (3 art slots — MODELBEAST posters!), cloakroom window, stamp podium | venue-pink spill from the door | UV re-entry checks happen here |
| **Glassie station** (bar's west end alcove) | glass racks, tub trolley | dim amber | Glassie role's home base |
Sim impact: walkability only changes where NEW walls go (DJ booth box, yard
fence, sink row). All existing anchors keep working; reachability is asserted in
tests. The yard gets a `smoke` anchor upgrade so wanderers actually go out for one.
## 3. The door street (640×360 tableau)
Already reads well. Two upgrades, both drop-in images (no layout change):
1. **Backdrop plate** behind the existing rects: one wide pixel-art street wall
(brick, posters, grime) — swaps in as `gen:street:backdrop` when generated.
2. **Kebab shop interior glow card** in its window + rotating poster art in two
street frames (same poster slots as the entry corridor).
## 4. The sprite drop-in contract (how John's art lands)
- Drop processed PNGs in **`public/props/`** named `<kind>.png` and list them in
**`public/props/manifest.json`** (`{"props": ["deck", "speaker", ...]}`).
- The floor loads the manifest at boot; anything listed replaces its placeholder
(texture key `gen:prop:<kind>`). Missing = placeholder. No code changes.
- **`tools/props_import.py`** (PIL, runs anywhere with python3+PIL — ultra has it)
takes raw generated images → target pixel size + palette quantise + transparent
background → writes `public/props/` + updates the manifest:
`python3 tools/props_import.py incoming/deck_raw.png deck`
- Target sizes are in the script (`TARGETS`): hero props 4864 px, furniture
2432 px, small items 16 px. It refuses unknown kinds so typos can't ship.
## 5. GENERATION QUEUE — John's list (in priority order)
Pipelines: **[MB]** = MODELBEAST `flux_local` (params given) → drop raw output in
`art_incoming/`, run the import script (or hand me the raws — either works).
**[GLB]** = Blender ortho prerender of an existing fleet mesh (camera: orthographic,
top-down 90° for floor props / front 0° for the street, transparent film, ~512px,
neutral even lighting) → same import path.
| # | Item (kind) | Pipeline & source | Notes |
|---|---|---|---|
| 1 | `deck` | [GLB] `ultra:~/Documents/90sDJsim/web/world/models/gear/deck_t3_technics_sl_1200mk2.glb`, top-down | The hero. One render serves both booth decks |
| 2 | `mixer` | [GLB] same dir, `mixer.glb` (or the urei once landed), top-down | Sits between the decks |
| 3 | `speaker` | [GLB] 90sDJsim `models/props/` has boombox/speaker meshes; else [MB] prompt: "top-down view of a large black PA speaker stack, pixel art game sprite, dark background, viewed directly from above" 512×512 seed 4210 | 4 placements |
| 4 | `discoball` | [MB] "mirrored disco ball seen directly from above, glinting, pixel art sprite, black background" 512×512 seed 4211 | Centre of dance floor |
| 5 | `barShelf` | [MB] "top-down bar back-shelf with glowing bottles rows, amber light, pixel art sprite, dark" 1024×256 seed 4212 | Emissive — the amber source |
| 6 | `taps` | [MB] "row of three beer taps on a bar counter viewed from above, pixel art, dark bg" 256×256 seed 4213 | |
| 7 | `boothTable` | [MB] "small round pub table with two drinks seen from directly above, pixel art, dark bg" 256×256 seed 4214 | 6 placements |
| 8 | `sink` | [MB] "public bathroom sink row with mirror viewed from above, green fluorescent light, pixel art, dark" 512×256 seed 4215 | |
| 9 | `buttBin` + `crate` | [MB] one sheet: "outdoor smoking area props: cigarette butt bin and milk crate, top-down pixel art, dark bg" 512×256 seed 4216 | Yard dressing |
| 10 | `poster1..3` | [MB] three runs: "grimy nightclub gig poster, 90s Australian pub rock / techno rave / missing cat, pixel art, portrait" 256×384 seeds 4217-19 | FRONT view (posters hang on walls) — used at door + corridor |
| 11 | `street:backdrop` | [MB] "wide pixel art night street wall: dark brick, torn posters, graffiti, neon reflections, rain, front-on orthographic, game background" 1600×440 seed 4220 | The door scene plate. Biggest single visual win |
| 12 | `cloak` / `glassRack` | [MB] "cloakroom window with hanging jackets / metal glass rack with stacked pint glasses, top-down pixel art, dark" 256×256 seeds 4221-22 | Corridor + glassie station |
| 13 | `arcade` | [GLB] `m3ultra:~/Documents/procity/pipeline/_normalized/procity_fit_arcade_cabinet_01.glb`, top-down | Corner easter egg near entry |
**Ambition tier (later, not blocking):** pre-rendered patron sprite sheets from
the modular character kit + RIGROOM clips (`Searching_Pockets`, boxing pack,
`Leaning_Idle02_Smoking`, zombie stagger for maggots) — replaces the procedural
dolls entirely per docs/ASSETS.md §2. Wants its own session to set the
palette/density once.
*(flux params for all [MB] jobs: `model flux2-klein-4b, steps 4`; put "no text,
no lettering" in every prompt EXCEPT the posters, where mangled text reads as
authentic gig-poster grot.)*

View File

@ -0,0 +1 @@
{ "props": [] }

View File

@ -109,6 +109,28 @@ export class FloorDemoScene extends Phaser.Scene {
this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16);
this.cameras.main.setBackgroundColor('#04040a');
// Generated prop art loads FIRST (public/props + manifest — docs/SPACES.md
// §4) so FloorView can prefer 'gen:prop:*' textures at build time. A missing
// or empty manifest just builds with placeholders; nothing waits on art.
this.load.json('propManifest', 'props/manifest.json');
this.load.once(Phaser.Loader.Events.COMPLETE, () => this.loadGenProps());
this.load.start();
}
private loadGenProps(): void {
const manifest = this.cache.json.get('propManifest') as { props?: string[] } | undefined;
const kinds = manifest?.props ?? [];
const missing = kinds.filter((k) => !this.textures.exists(`gen:prop:${k}`));
if (missing.length === 0) {
this.buildWorld();
return;
}
for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`);
this.load.once(Phaser.Loader.Events.COMPLETE, () => this.buildWorld());
this.load.start();
}
private buildWorld(): void {
this.view = new FloorView(this, VENUE);
this.cutOff = new CutOffOverlay(this);
this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null);
@ -172,7 +194,10 @@ export class FloorDemoScene extends Phaser.Scene {
this.present = location === 'floor';
if (this.present) this.target = null;
}),
this.bus.on('beat:tick', ({ beatIndex }) => this.crowd.onBeat(beatIndex)),
this.bus.on('beat:tick', ({ beatIndex }) => {
this.crowd.onBeat(beatIndex);
this.view.onBeat(beatIndex);
}),
];
}
@ -206,7 +231,10 @@ export class FloorDemoScene extends Phaser.Scene {
this.crowd = new CrowdSim({ map: VENUE, 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 }) => this.crowd.onBeat(beatIndex));
this.bus.on('beat:tick', ({ beatIndex }) => {
this.crowd.onBeat(beatIndex);
this.view.onBeat(beatIndex);
});
const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
this.player = freshPlayer(entry.x, entry.y);
@ -262,6 +290,7 @@ export class FloorDemoScene extends Phaser.Scene {
// ---- the loop ----------------------------------------------------------
override update(_time: number, deltaMs: number): void {
if (!this.view) return; // world still waiting on the prop manifest
const dt = Math.min(deltaMs, 50);
// These run REGARDLESS of overlays. The music doesn't stop because you
@ -316,6 +345,7 @@ export class FloorDemoScene extends Phaser.Scene {
// World keeps breathing behind the panel, but you can't move while you're
// dealing with someone — that's the point of the mechanic.
this.crowd.update(dt, this.clock.clockMin);
this.view.updateLights(dt);
this.view.syncAgents(this.crowd.agents);
return;
}
@ -346,6 +376,7 @@ export class FloorDemoScene extends Phaser.Scene {
this.target = this.escorting ? null : this.findTarget();
this.cameras.main.centerOn(this.player.x, this.player.y);
this.view.updateLights(dt);
this.view.updatePlayer(this.player.x, this.player.y, this.player.facing);
this.view.updateCone(this.coneSpec(), this.uvMode);
this.view.syncAgents(this.crowd.agents);

View File

@ -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 { TILE, tileAt, tileColour, type VenueMap } from './venueMap';
import { LIGHTS, PROPS, TILE, tileAt, tileColour, type PropKind, type VenueMap } from './venueMap';
import type { Agent } from './crowdSim';
// Pure drawing for the floor. Darkness is the mechanic: the venue is painted
@ -80,6 +80,12 @@ export class FloorView {
private readonly fightPip: Phaser.GameObjects.Image;
private readonly agents = new Map<string, AgentGfx>();
private readonly propImgs: Phaser.GameObjects.Image[] = [];
/** glow images that pulse on beat:tick (dance grid + emissive beat props) */
private readonly beatLights: Phaser.GameObjects.Image[] = [];
/** glow images that flicker (dunny fluoro, booth lamp) with their base alpha */
private readonly flickerLights: Array<{ img: Phaser.GameObjects.Image; base: number; phase: number }> = [];
private beatKick = 0;
// Render-only shove displacement by patron id. The sim owns agent.x/agent.y;
// syncAgents adds this on top so a shoving pair jitters without the crowd
// simulation ever hearing about it.
@ -134,6 +140,9 @@ export class FloorView {
}
}
this.buildProps();
this.buildLights();
this.maskG = scene.make.graphics({}, false);
this.mask = this.maskG.createGeometryMask();
this.detailC = scene.add.container(0, 0).setDepth(D_DETAIL);
@ -157,6 +166,74 @@ export class FloorView {
.setVisible(false);
}
/**
* Props prefer a GENERATED texture ('gen:prop:<kind>', loaded from
* public/props via the manifest docs/SPACES.md §4) and fall back to a baked
* placeholder. They sit under the darkness sheet like the tiles do, so they
* read as silhouettes until the torch finds them.
*/
private buildProps(): void {
for (const p of 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
.image(p.tx * TILE, p.ty * TILE, key)
.setOrigin(0, 0)
.setDepth(D_TILES + 1)
.setDisplaySize(p.tw * TILE, p.th * TILE);
this.propImgs.push(img);
if (p.emissive) this.addGlow(p, p.emissive.colour, p.emissive.radius, p.emissive.pulse);
}
}
/** 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);
}
private addGlow(
at: { tx: number; ty: number; tw?: number; th?: number },
colour: number,
radius: number,
pulse?: 'beat' | 'flicker',
): void {
const cx = (at.tx + (at.tw ?? 1) / 2) * TILE;
const cy = (at.ty + (at.th ?? 1) / 2) * TILE;
const alpha = pulse === 'beat' ? 0.22 : 0.4;
const img = this.scene.add
.image(cx, cy, GLOW_TEX)
.setDepth(D_NEON)
.setBlendMode(Phaser.BlendModes.ADD)
.setTint(colour)
.setAlpha(alpha)
.setScale(radius / 32);
this.neon.push(img); // shares neon lifecycle (destroy)
if (pulse === 'beat') this.beatLights.push(img);
else if (pulse === 'flicker') {
this.flickerLights.push({ img, base: alpha, phase: (cx * 13 + cy * 7) % 100 });
}
}
/** Call from the scene's beat:tick handler — the dance floor breathes in time. */
onBeat(beatIndex: number): void {
this.beatKick = beatIndex % 4 === 0 ? 1 : 0.6;
}
/** Per-frame light life: beat decay + fluoro flicker. */
updateLights(dtMs: number): void {
if (this.beatKick > 0) {
this.beatKick = Math.max(0, this.beatKick - dtMs / 260);
for (const img of this.beatLights) img.setAlpha(0.22 + 0.3 * this.beatKick);
}
const t = this.scene.time.now;
for (const f of this.flickerLights) {
// Two incommensurate sines + a hard dropout band reads as dying fluoro.
const w = Math.sin(t * 0.013 + f.phase) * Math.sin(t * 0.0037 + f.phase * 2);
const drop = w > 0.93 ? 0 : 1;
f.img.setAlpha(f.base * (0.75 + 0.25 * w) * drop);
}
}
syncAgents(agents: readonly Agent[]): void {
this.frame++;
const t = this.scene.time.now;
@ -303,6 +380,10 @@ export class FloorView {
this.dark.destroy();
this.tileImg.destroy();
for (const n of this.neon) n.destroy();
for (const pr of this.propImgs) pr.destroy();
this.propImgs.length = 0;
this.beatLights.length = 0;
this.flickerLights.length = 0;
this.neon.length = 0;
this.playerLight.destroy();
this.playerImg.destroy();
@ -406,3 +487,116 @@ export class FloorView {
g.destroy();
}
}
// ---- prop placeholders --------------------------------------------------------
// Chunky pixel stand-ins, one per PropKind, drawn at the prop's natural footprint.
// Generated art (public/props/<kind>.png via tools/props_import.py) replaces
// these by texture key without touching any of this.
const PROP_SIZES: Record<PropKind, [number, number]> = {
deck: [32, 32], mixer: [32, 16], monitor: [16, 16], boothLamp: [16, 16],
speaker: [16, 32], discoball: [32, 32],
barShelf: [496, 16], taps: [48, 16], till: [32, 16], glassStack: [32, 16],
glassRack: [32, 16], boothTable: [32, 16], sinkRow: [96, 16],
buttBin: [16, 16], crate: [16, 16],
poster1: [32, 16], poster2: [32, 16], poster3: [32, 16],
cloak: [16, 48], stampPodium: [16, 16], arcade: [32, 32],
};
function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
const key = `prop:${kind}`;
if (scene.textures.exists(key)) return key;
const [w, h] = PROP_SIZES[kind];
const canvas = scene.textures.createCanvas(key, w, h);
if (!canvas) return key;
const c = canvas.getContext();
const f = (x: number, y: number, fw: number, fh: number, col: string): void => {
c.fillStyle = col;
c.fillRect(x, y, fw, fh);
};
switch (kind) {
case 'deck':
f(1, 1, 30, 30, '#3a3a46'); f(4, 4, 24, 24, '#18181f');
c.beginPath(); c.arc(16, 16, 10, 0, Math.PI * 2); c.fillStyle = '#242430'; c.fill();
c.beginPath(); c.arc(16, 16, 3, 0, Math.PI * 2); c.fillStyle = '#c0c0c8'; c.fill();
f(25, 6, 3, 14, '#8a8a94');
break;
case 'mixer':
f(1, 1, 30, 14, '#2c2c36');
for (const x of [7, 15, 23]) { f(x, 3, 2, 10, '#101018'); f(x - 1, 7, 4, 2, '#d03470'); }
break;
case 'monitor':
f(2, 3, 12, 10, '#1a1a24'); f(4, 5, 8, 6, '#3a86c8');
break;
case 'boothLamp':
f(6, 8, 4, 7, '#2a2a34'); f(4, 3, 8, 6, '#3060c0');
break;
case 'speaker':
f(1, 1, 14, 30, '#15151c');
c.beginPath(); c.arc(8, 10, 5, 0, Math.PI * 2); c.fillStyle = '#2e2e3a'; c.fill();
c.beginPath(); c.arc(8, 23, 5, 0, Math.PI * 2); c.fillStyle = '#2e2e3a'; c.fill();
break;
case 'discoball':
c.beginPath(); c.arc(16, 16, 12, 0, Math.PI * 2); c.fillStyle = '#8a8a98'; c.fill();
for (let y = 6; y < 28; y += 5) for (let x = 6 + (y % 2) * 2; x < 28; x += 5) f(x, y, 2, 2, '#e8e8f2');
break;
case 'barShelf':
f(0, 0, 496, 16, '#2a2018');
for (let x = 6; x < 490; x += 10) {
const cols = ['#d8a020', '#7a4fc0', '#2e7d46', '#c03434'];
f(x, 3, 4, 10, cols[(x / 10) % 4 | 0] ?? '#d8a020');
}
break;
case 'taps':
f(0, 10, 48, 6, '#4a3a28');
for (const x of [8, 22, 36]) { f(x, 2, 3, 9, '#8a8a94'); f(x - 2, 1, 7, 3, '#d8c23a'); }
break;
case 'till':
f(2, 4, 28, 11, '#3a3a46'); f(5, 6, 10, 5, '#9fe8a0'); f(18, 7, 9, 6, '#22222c');
break;
case 'glassStack':
for (const x of [3, 11, 19, 25]) { f(x, 4, 5, 9, '#aeb8c8'); f(x + 1, 2, 3, 3, '#dce4ee'); }
break;
case 'glassRack':
f(0, 0, 32, 16, '#33333e');
for (let x = 3; x < 30; x += 6) f(x, 3, 4, 10, '#9fb2c8');
break;
case 'boothTable':
c.beginPath(); c.arc(16, 8, 7, 0, Math.PI * 2); c.fillStyle = '#4a3226'; c.fill();
f(11, 5, 3, 4, '#d8a050'); f(19, 6, 3, 4, '#c03434');
break;
case 'sinkRow':
f(0, 0, 96, 4, '#5c6c72');
for (const x of [8, 40, 72]) { f(x, 6, 16, 8, '#8a9aa2'); f(x + 5, 8, 6, 4, '#22303a'); }
break;
case 'buttBin':
f(5, 3, 6, 12, '#4c4c56'); f(4, 2, 8, 3, '#6a6a76'); f(7, 0, 2, 3, '#d8a050');
break;
case 'crate':
f(2, 4, 12, 10, '#2c4a6a'); f(3, 5, 10, 2, '#16283c'); f(3, 9, 10, 2, '#16283c');
break;
case 'poster1':
f(1, 1, 30, 14, '#c8b890'); f(4, 3, 24, 4, '#c03434'); f(4, 9, 18, 3, '#22222c');
break;
case 'poster2':
f(1, 1, 30, 14, '#20303c'); f(4, 4, 24, 8, '#d03470'); f(8, 6, 14, 3, '#e8e8f2');
break;
case 'poster3':
f(1, 1, 30, 14, '#d8d0b8'); f(5, 3, 8, 8, '#6d4726'); f(16, 4, 12, 2, '#22222c'); f(16, 8, 10, 2, '#22222c');
break;
case 'cloak':
f(0, 0, 16, 48, '#2a2230');
for (const y of [6, 16, 26, 36]) { f(3, y, 10, 6, '#5a4a6a'); f(7, y - 2, 2, 3, '#8a8a94'); }
break;
case 'stampPodium':
f(3, 2, 10, 12, '#3c3040'); f(5, 4, 6, 4, '#7a4fc0');
break;
case 'arcade':
f(3, 1, 26, 30, '#1c2a3c'); f(7, 5, 18, 12, '#40c0d0'); f(9, 20, 6, 4, '#c03434'); f(19, 20, 6, 4, '#d8c23a');
break;
}
canvas.refresh();
return key;
}

View File

@ -7,7 +7,8 @@ export const MAP_H = 45; // tiles
export type TileKind =
| 'void' | 'floor' | 'wall' | 'bar' | 'stool' | 'dance'
| 'booth' | 'stall' | 'stallDoor' | 'exit' | 'smokeDoor' | 'neon';
| 'booth' | 'stall' | 'stallDoor' | 'exit' | 'smokeDoor' | 'neon'
| 'djbooth' | 'djfloor' | 'yard' | 'fence' | 'sink';
export type AnchorKind = 'bar' | 'dance' | 'booth' | 'toilet' | 'smoke' | 'entry' | 'exit';
@ -33,7 +34,7 @@ export interface VenueMap {
// targets; send a patron inside via StallDef.inside instead. Everything else
// walkable is mutually reachable (see tests/floor/venueMap.test.ts).
const WALKABLE: ReadonlySet<TileKind> = new Set<TileKind>([
'floor', 'dance', 'exit', 'smokeDoor', 'stall',
'floor', 'dance', 'exit', 'smokeDoor', 'stall', 'yard',
]);
// Tuned UNDER the renderer's darkness sheet, not in isolation — at the original
@ -52,6 +53,11 @@ const COLOURS: Record<TileKind, number> = {
exit: 0x2c5636,
smokeDoor: 0x3d4d5c,
neon: 0xd8409a,
djbooth: 0x2c3550,
djfloor: 0x232a42,
yard: 0x28303a,
fence: 0x46505a,
sink: 0x4a5a5c,
};
// Cubicle west edges. Each cubicle is a 4x4 walled box; the last one shares the
@ -89,10 +95,25 @@ function buildTiles(): TileKind[] {
rect(26, 16, 50, 30, 'dance');
// Booths: south wall then east wall.
for (const tx of [22, 30, 38, 46]) rect(tx, 41, tx + 1, 42, 'booth');
// Booths: south wall (east of the smoking yard) then east wall.
for (const tx of [25, 32, 39, 46]) rect(tx, 41, tx + 1, 42, 'booth');
for (const ty of [22, 27, 32]) rect(76, ty, 77, ty + 1, 'booth');
// DJ booth: sealed island east of the dance floor, gear facing it across the
// west counter. Nobody paths inside — like the cubicles, the interior is a
// stage, not a room (the DJ is set dressing until the DJ role lands).
rect(52, 19, 58, 25, 'djbooth');
rect(53, 20, 57, 24, 'djfloor');
// Smoking yard: fenced open-sky pocket in the south-west. One door in from
// the floor; rain falls INSIDE the fence (the renderer keys off 'yard').
rect(14, 38, 22, 43, 'fence');
rect(15, 39, 21, 42, 'yard');
set(18, 38, 'smokeDoor');
// Dunnies get plumbing: sink row along the south interior wall.
rect(66, 15, 71, 15, 'sink');
// Dunnies: walled room in the north-east, one doorway on its south face.
box(60, 3, 76, 16, 'wall');
rect(62, 16, 63, 16, 'floor');
@ -102,8 +123,6 @@ function buildTiles(): TileKind[] {
set(x + 1, CUBICLE_Y + 3, 'stallDoor');
}
set(18, MAP_H - 1, 'smokeDoor');
// Accent strips the renderer hangs point lights off.
for (const tx of [20, 30, 40, 50, 60]) set(tx, 0, 'neon');
for (const ty of [10, 20, 30]) set(MAP_W - 1, ty, 'neon');
@ -136,17 +155,126 @@ function buildAnchors(): Record<AnchorKind, readonly WorldPoint[]> {
bar: [24, 29, 34, 39, 44, 49].map((tx) => tileToWorld(tx, 7)),
dance,
booth: [
...[22, 30, 38, 46].map((tx) => tileToWorld(tx, 40)),
...[25, 32, 39, 46].map((tx) => tileToWorld(tx, 40)),
tileToWorld(75, 22),
tileToWorld(75, 27),
],
toilet: CUBICLE_X.map((x) => tileToWorld(x + 1, CUBICLE_Y + 4)),
smoke: [tileToWorld(18, MAP_H - 1)],
smoke: [tileToWorld(17, 41), tileToWorld(19, 40)],
entry: [tileToWorld(4, 22), tileToWorld(9, 22)],
exit: [tileToWorld(1, 22)],
};
}
// ---- props & lights (docs/SPACES.md) -----------------------------------------
//
// Props are DATA: the renderer prefers a generated texture ('gen:prop:<kind>',
// dropped in public/props/ + manifest — see tools/props_import.py) and falls
// back to a baked placeholder. Footprints are tiles; props never affect
// walkability — anything that should block stands on non-walkable tiles.
export type PropKind =
| 'deck' | 'mixer' | 'monitor' | 'boothLamp'
| 'speaker' | 'discoball'
| 'barShelf' | 'taps' | 'till' | 'glassStack' | 'glassRack'
| 'boothTable' | 'sinkRow'
| 'buttBin' | 'crate'
| 'poster1' | 'poster2' | 'poster3' | 'cloak' | 'stampPodium' | 'arcade';
export interface PropEmissive {
colour: number;
radius: number;
pulse?: 'beat' | 'flicker';
}
export interface PropDef {
id: string;
kind: PropKind;
tx: number;
ty: number;
tw: number;
th: number;
emissive?: PropEmissive;
}
export interface ZoneLight {
id: string;
tx: number;
ty: number;
colour: number;
radius: number;
pulse?: 'beat' | 'flicker';
}
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 });
export const PROPS: readonly PropDef[] = Object.freeze([
// DJ booth — the fleet's own gear, facing the dance floor
P('deckA', 'deck', 53, 20, 2, 2),
P('mixer', 'mixer', 53, 22, 2, 1),
P('deckB', 'deck', 53, 23, 2, 2),
P('monitor', 'monitor', 56, 22, 1, 1),
P('boothLamp', 'boothLamp', 57, 20, 1, 1, { colour: 0x3060c0, radius: 40, pulse: 'flicker' }),
// dance floor
P('spkNW', 'speaker', 25, 14, 1, 2),
P('spkNE', 'speaker', 50, 14, 1, 2),
P('spkSW', 'speaker', 25, 31, 1, 2),
P('spkSE', 'speaker', 50, 31, 1, 2),
P('ball', 'discoball', 37, 22, 2, 2, { colour: 0xe8c8e0, radius: 22 }),
// bar
P('shelf', 'barShelf', 22, 3, 31, 1),
P('glassStack', 'glassStack', 24, 4, 2, 1),
P('taps', 'taps', 32, 4, 3, 1),
P('till', 'till', 46, 4, 2, 1),
// glassie station (future role home — design §6)
P('rackA', 'glassRack', 16, 4, 2, 1),
P('rackB', 'glassRack', 16, 6, 2, 1),
// booths
P('tblS1', 'boothTable', 25, 40, 2, 1, { colour: 0xd8a050, radius: 12 }),
P('tblS2', 'boothTable', 32, 40, 2, 1, { colour: 0xd8a050, radius: 12 }),
P('tblS3', 'boothTable', 39, 40, 2, 1, { colour: 0xd8a050, radius: 12 }),
P('tblS4', 'boothTable', 46, 40, 2, 1, { colour: 0xd8a050, radius: 12 }),
P('tblE1', 'boothTable', 75, 23, 1, 1, { colour: 0xd8a050, radius: 12 }),
P('tblE2', 'boothTable', 75, 28, 1, 1, { colour: 0xd8a050, radius: 12 }),
// dunnies
P('sinks', 'sinkRow', 66, 15, 6, 1),
// smoking yard
P('bin', 'buttBin', 16, 39, 1, 1),
P('crateA', 'crate', 19, 42, 1, 1),
P('crateB', 'crate', 20, 40, 1, 1),
// entry corridor
P('poster1', 'poster1', 3, 20, 2, 1),
P('poster2', 'poster2', 7, 20, 2, 1),
P('poster3', 'poster3', 11, 20, 2, 1),
P('cloak', 'cloak', 14, 18, 1, 3),
P('podium', 'stampPodium', 12, 22, 1, 1),
// one arcade cabinet, because it is a venue in Australia
P('arcade', 'arcade', 15, 34, 2, 2, { colour: 0x40c0d0, radius: 18, pulse: 'flicker' }),
]);
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): navigate by glow colour.
export const LIGHTS: readonly ZoneLight[] = Object.freeze([
// bar ambers along the shelf
...[24, 30, 36, 42, 48].map((tx, i) => L(`bar${i}`, tx, 4, 0xd8a020, 40)),
// dance grid — pulses on the ACTUAL beat via FloorView.onBeat
...[30, 37, 44].flatMap((tx) =>
[19, 23, 27].map((ty) => L(`dg${tx}x${ty}`, tx, ty, 0xd03470, 26, 'beat')),
),
L('dj', 55, 22, 0x3060c0, 46),
L('loo', 68, 10, 0x50c060, 55, 'flicker'),
L('yard', 18, 40, 0x6080a0, 44),
L('entry', 2, 22, 0xd03470, 40),
L('glassie', 17, 5, 0xd8a020, 28),
]);
export const VENUE: VenueMap = Object.freeze({
width: MAP_W,
height: MAP_H,

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import {
import { LIGHTS, PROPS,
MAP_H, MAP_W, TILE, VENUE,
isWalkable, isWalkableWorld, tileAt, tileColour, tileToWorld, worldToTile,
} from '../../src/scenes/floor/venueMap';
@ -45,6 +45,7 @@ describe('venueMap dimensions', () => {
const known = new Set<TileKind>([
'void', 'floor', 'wall', 'bar', 'stool', 'dance',
'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon',
'djbooth', 'djfloor', 'yard', 'fence', 'sink',
]);
for (const t of VENUE.tiles) expect(known.has(t)).toBe(true);
});
@ -61,7 +62,7 @@ describe('venueMap dimensions', () => {
it('contains every authored tile kind', () => {
const seen = new Set(VENUE.tiles);
for (const k of ['floor', 'wall', 'bar', 'stool', 'dance', 'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon'] as const) {
for (const k of ['floor', 'wall', 'bar', 'stool', 'dance', 'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon', 'djbooth', 'djfloor', 'yard', 'fence', 'sink'] as const) {
expect(seen.has(k)).toBe(true);
}
});
@ -76,8 +77,8 @@ describe('tileAt / walkability', () => {
expect(isWalkable(VENUE, -5, -5)).toBe(false);
});
it('walks floor/dance/exit/smokeDoor/stall and nothing else', () => {
const walkable = new Set<TileKind>(['floor', 'dance', 'exit', 'smokeDoor', 'stall']);
it('walks floor/dance/exit/smokeDoor/stall/yard and nothing else', () => {
const walkable = new Set<TileKind>(['floor', 'dance', 'exit', 'smokeDoor', 'stall', 'yard']);
for (let ty = 0; ty < MAP_H; ty++) {
for (let tx = 0; tx < MAP_W; tx++) {
expect(isWalkable(VENUE, tx, ty)).toBe(walkable.has(tileAt(VENUE, tx, ty)));
@ -226,3 +227,42 @@ describe('tileColour', () => {
expect(new Set(colours).size).toBe(kinds.length);
});
});
describe('props & lights (docs/SPACES.md)', () => {
it('every prop sits in bounds with a positive footprint', () => {
for (const p of PROPS) {
expect(p.tw, p.id).toBeGreaterThan(0);
expect(p.th, p.id).toBeGreaterThan(0);
expect(p.tx, p.id).toBeGreaterThanOrEqual(0);
expect(p.ty, p.id).toBeGreaterThanOrEqual(0);
expect(p.tx + p.tw, p.id).toBeLessThanOrEqual(MAP_W);
expect(p.ty + p.th, p.id).toBeLessThanOrEqual(MAP_H);
}
});
it('prop ids are unique', () => {
expect(new Set(PROPS.map((p) => p.id)).size).toBe(PROPS.length);
});
it('every zone light sits in bounds with a sane radius', () => {
for (const l of LIGHTS) {
expect(l.tx, l.id).toBeGreaterThanOrEqual(0);
expect(l.tx, l.id).toBeLessThan(MAP_W);
expect(l.ty, l.id).toBeGreaterThanOrEqual(0);
expect(l.ty, l.id).toBeLessThan(MAP_H);
expect(l.radius, l.id).toBeGreaterThan(4);
expect(l.radius, l.id).toBeLessThan(120);
}
});
it('the DJ booth interior is sealed — a stage, not a room', () => {
for (let ty = 20; ty <= 24; ty++) {
for (let tx = 53; tx <= 57; tx++) expect(isWalkable(VENUE, tx, ty)).toBe(false);
}
});
it('the smoking yard is walkable and entered through its door', () => {
expect(tileAt(VENUE, 18, 38)).toBe('smokeDoor');
expect(isWalkable(VENUE, 18, 40)).toBe(true);
});
});

74
tools/props_import.py Normal file
View File

@ -0,0 +1,74 @@
#!/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]
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.
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.
"""
import json
import sys
from pathlib import Path
from PIL import Image
TARGETS = {
"deck": (32, 32), "mixer": (32, 16), "monitor": (16, 16), "boothLamp": (16, 16),
"speaker": (16, 32), "discoball": (32, 32),
"barShelf": (496, 16), "taps": (48, 16), "till": (32, 16), "glassStack": (32, 16),
"glassRack": (32, 16), "boothTable": (32, 16), "sinkRow": (96, 16),
"buttBin": (16, 16), "crate": (16, 16),
"poster1": (32, 16), "poster2": (32, 16), "poster3": (32, 16),
"cloak": (16, 48), "stampPodium": (16, 16), "arcade": (32, 32),
# the door-scene backdrop plate is special-cased: full size, no quantise
"street:backdrop": (640, 176),
}
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
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)
w, h = TARGETS[kind]
img = img.resize((w, h), Image.NEAREST)
if quantise and kind != "street:backdrop":
alpha = img.getchannel("A")
img = img.convert("RGB").quantize(16).convert("RGBA")
img.putalpha(alpha)
out_name = kind.replace(":", "_")
root = Path(__file__).resolve().parent.parent
out = root / "public" / "props" / f"{out_name}.png"
out.parent.mkdir(parents=True, exist_ok=True)
img.save(out)
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")
print(f"wrote {out} and updated manifest ({len(man['props'])} generated props live)")
if __name__ == "__main__":
main()