diff --git a/CLAUDE.md b/CLAUDE.md index d907804..0cca318 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index d1aa15f..b88e2b0 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -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:` 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. diff --git a/art_incoming/README.txt b/art_incoming/README.txt new file mode 100644 index 0000000..49bbf08 --- /dev/null +++ b/art_incoming/README.txt @@ -0,0 +1 @@ +raw drops go here; process with tools/props_import.py diff --git a/docs/SPACES.md b/docs/SPACES.md new file mode 100644 index 0000000..d04c2bc --- /dev/null +++ b/docs/SPACES.md @@ -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:`) 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 `.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:`). 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 48–64 px, furniture + 24–32 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.)* diff --git a/public/props/manifest.json b/public/props/manifest.json new file mode 100644 index 0000000..e08a06c --- /dev/null +++ b/public/props/manifest.json @@ -0,0 +1 @@ +{ "props": [] } diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index d4d88f9..2e35ab6 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -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); diff --git a/src/scenes/floor/FloorView.ts b/src/scenes/floor/FloorView.ts index 1263ffe..169883a 100644 --- a/src/scenes/floor/FloorView.ts +++ b/src/scenes/floor/FloorView.ts @@ -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(); + 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:', 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/.png via tools/props_import.py) replaces +// these by texture key without touching any of this. + +const PROP_SIZES: Record = { + 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; +} + diff --git a/src/scenes/floor/venueMap.ts b/src/scenes/floor/venueMap.ts index 2cd2e7d..d74575f 100644 --- a/src/scenes/floor/venueMap.ts +++ b/src/scenes/floor/venueMap.ts @@ -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 = new Set([ - '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 = { 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 { 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:', +// 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, diff --git a/tests/floor/venueMap.test.ts b/tests/floor/venueMap.test.ts index fd7f8aa..7d0f6e7 100644 --- a/tests/floor/venueMap.test.ts +++ b/tests/floor/venueMap.test.ts @@ -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([ '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(['floor', 'dance', 'exit', 'smokeDoor', 'stall']); + it('walks floor/dance/exit/smokeDoor/stall/yard and nothing else', () => { + const walkable = new Set(['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); + }); +}); diff --git a/tools/props_import.py b/tools/props_import.py new file mode 100644 index 0000000..32ad574 --- /dev/null +++ b/tools/props_import.py @@ -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 [--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/.png and add 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()