The club gets dressed: full MODELBEAST art drop (20 sprites + street backdrop), scan-beam light show, colour-cycling dance grid, pool corner + furniture, present-race fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 17:19:46 +10:00
parent f38ea0743c
commit 7803873825
48 changed files with 186 additions and 10 deletions

View File

@ -1114,3 +1114,46 @@ queue (SPACES §5), the two human passes (ear, economy feel) still open.
non-code items. Code-side: regular step-up lines, Elevate VIP guest-list non-code items. Code-side: regular step-up lines, Elevate VIP guest-list
density (claims are archetype-driven so the mix already lifts them), and the density (claims are archetype-driven so the mix already lifts them), and the
smoking-door slip-in (FLOOR2 §2's second noStamp source) are the next nibbles. smoking-door slip-in (FLOOR2 §2's second noStamp source) are the next nibbles.
---
### SESSION — THE CLUB GETS DRESSED (Fable, solo) — 2026-07-20 22:15
**Branch/commits:** main; deployed live with the full art drop.
**Done:**
- **Ran the entire SPACES §5 generation queue myself on MODELBEAST** — 21
flux_local jobs (all prop sprites + 3 posters + the street backdrop),
processed through tools/props_import.py on ultra (PIL), 20 sprites now live
in public/props/ + manifest. The drop-in contract worked exactly as designed:
zero code changes for the floor props.
- **Two new consumers wired**: NightScene now preloads the manifest art BEFORE
launching Door/Floor (textures are game-global), and DoorScene swaps its
flat wall-of-night rect for the generated STREET BACKDROP plate — brick,
layered gig posters, graffiti, rain streaks. The door scene went from
placeholder rects to a real street in one image.
- **Dance floor light show**: two scan beams pivoting on the disco ball
(baked wedge texture, additive, opposite rotations, beat-kicked brightness)
and the grid cells now COLOUR-CYCLE by bar (pink/blue/amber, phase-offset
per cell so the floor ripples instead of blinking).
- **Furniture pass**: pool table with low green cone (the Australian venue
underneath the techno), plants, wet-floor sign, standing tables by the dance
floor, third east booth table. New kinds registered end-to-end (map → sizes
→ placeholders → importer targets).
- **Bug fixed (found live)**: stepping inside during the floor's prop-load
window meant the scene missed night:phaseChange and never learned the player
was present (camera dead, input ignored). `present` now initialises from
NightState.location at bind time.
- **Farm note**: first queue run lost all 20 downloads to a shell-quoting
mangle (ssh single-quote stripping around ['id'] — same trap as the cover
art). The renders survived on the farm; patched and refetched. If you script
MB over ssh: heredoc with QUOTED delimiter, double-quoted keys, always.
**Tests: 646. Gate clean. Deployed; game + backdrop both 200 live.**
**Broke / known-wonky:**
- Generated sprites are first-pass flux at 16-48px — most read beautifully
(decks/ball/backdrop/posters are transformative), a few are just fine
(glassRack, stampPodium). Re-rolls are one importer command away; judge on
John's screen, not mine.
- The GLB prerender path (real 90sDJsim gear via Blender) remains unused —
flux versions landed well enough that it's now an upgrade, not a need.
- Speaker sprite is slight-angle rather than pure top-down; reads good in situ.
**Next:** John plays it (economy feel + ear pass, M works live); re-roll any
sprite that bugs him; regular step-up lines and the smoking-door slip-in stay
the next code nibbles.

BIN
art_incoming/arcade.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
art_incoming/barShelf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
art_incoming/boothTable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
art_incoming/buttBin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
art_incoming/cloak.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
art_incoming/crate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
art_incoming/deck.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

BIN
art_incoming/discoball.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

BIN
art_incoming/glassRack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
art_incoming/glassStack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
art_incoming/mixer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
art_incoming/poster1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 KiB

BIN
art_incoming/poster2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

BIN
art_incoming/poster3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

BIN
art_incoming/sinkRow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
art_incoming/speaker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

BIN
art_incoming/taps.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
art_incoming/till.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
public/props/arcade.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

BIN
public/props/barShelf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
public/props/boothTable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

BIN
public/props/buttBin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

BIN
public/props/cloak.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

BIN
public/props/crate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 B

BIN
public/props/deck.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/props/discoball.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/props/glassRack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/props/glassStack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1 +1,24 @@
{ "props": [] } {
"props": [
"arcade",
"barShelf",
"boothTable",
"buttBin",
"cloak",
"crate",
"deck",
"discoball",
"glassRack",
"glassStack",
"mixer",
"poster1",
"poster2",
"poster3",
"sinkRow",
"speaker",
"stampPodium",
"street_backdrop",
"taps",
"till"
]
}

BIN
public/props/mixer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

BIN
public/props/poster1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/props/poster2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

BIN
public/props/poster3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 B

BIN
public/props/sinkRow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/props/speaker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

BIN
public/props/taps.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

BIN
public/props/till.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

View File

@ -165,7 +165,13 @@ export class DoorScene extends Phaser.Scene {
// ---------- construction ---------- // ---------- construction ----------
private buildStreet(): void { private buildStreet(): void {
this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night 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);
} else {
this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night
}
this.add.rectangle(W / 2, 212, W, 76, 0x1a1a24); // footpath this.add.rectangle(W / 2, 212, W, 76, 0x1a1a24); // footpath
this.add.rectangle(W / 2, 176, W, 2, 0x2a2a38); this.add.rectangle(W / 2, 176, W, 2, 0x2a2a38);

View File

@ -267,11 +267,27 @@ export class NightScene extends Phaser.Scene {
this.events.once('shutdown', () => this.teardown()); this.events.once('shutdown', () => this.teardown());
this.clock.start(); this.clock.start();
this.scene.launch('Door', { night: this.ctx });
this.scene.launch('Floor', { night: this.ctx }); // Generated art loads BEFORE the scenes that consume it (textures are
// Door and Floor BOTH run all night; the flags below decide which one the // game-global): the door's street backdrop and the floor's props both ride
// player is inhabiting. Floor starts hidden. // public/props + manifest. Missing manifest = placeholders, zero waiting.
this.time.delayedCall(0, () => this.applyLocation('door')); this.load.json('propManifest', 'props/manifest.json');
this.load.once(Phaser.Loader.Events.COMPLETE, () => {
const manifest = this.cache.json.get('propManifest') as { props?: string[] } | undefined;
const missing = (manifest?.props ?? []).filter((k) => !this.textures.exists(`gen:prop:${k}`));
const launch = (): void => {
this.scene.launch('Door', { night: this.ctx });
this.scene.launch('Floor', { night: this.ctx });
// Door and Floor BOTH run all night; the flags below decide which one
// the player is inhabiting. Floor starts hidden.
this.time.delayedCall(0, () => this.applyLocation('door'));
};
if (missing.length === 0) return launch();
for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`);
this.load.once(Phaser.Loader.Events.COMPLETE, launch);
this.load.start();
});
this.load.start();
} }
// ---- location ---------------------------------------------------------- // ---- location ----------------------------------------------------------

View File

@ -176,7 +176,10 @@ export class FloorDemoScene extends Phaser.Scene {
this.target = null; this.target = null;
this.liveFight = null; this.liveFight = null;
this.uvMode = false; this.uvMode = false;
this.present = false; // the night starts at the rope // From STATE, not an assumption: if the player stepped inside during the
// prop-manifest load window, the phaseChange event fired before our
// listener existed and 'false' would stick forever.
this.present = ctx.state.location === 'floor';
this.elapsedMs = 0; this.elapsedMs = 0;
this.pendingSpawns = []; this.pendingSpawns = [];
this.maggotSeen.clear(); this.maggotSeen.clear();

View File

@ -87,6 +87,8 @@ export class FloorView {
private readonly flickerLights: Array<{ img: Phaser.GameObjects.Image; base: number; phase: number }> = []; private readonly flickerLights: Array<{ img: Phaser.GameObjects.Image; base: number; phase: number }> = [];
private beatKick = 0; private beatKick = 0;
private yardRain: Phaser.GameObjects.Particles.ParticleEmitter | null = null; private yardRain: Phaser.GameObjects.Particles.ParticleEmitter | null = null;
private readonly beams: Phaser.GameObjects.Image[] = [];
private beamAngle = 0;
private dj: Phaser.GameObjects.Image | null = null; private dj: Phaser.GameObjects.Image | null = null;
private djBob = 0; private djBob = 0;
// Render-only shove displacement by patron id. The sim owns agent.x/agent.y; // Render-only shove displacement by patron id. The sim owns agent.x/agent.y;
@ -147,6 +149,7 @@ export class FloorView {
this.buildLights(); this.buildLights();
this.buildYardRain(); this.buildYardRain();
this.buildDj(); this.buildDj();
this.buildScanBeams();
this.maskG = scene.make.graphics({}, false); this.maskG = scene.make.graphics({}, false);
this.mask = this.maskG.createGeometryMask(); this.mask = this.maskG.createGeometryMask();
@ -219,6 +222,43 @@ export class FloorView {
} }
} }
/**
* The light rig: two scan beams pivoting on the disco ball, sweeping the
* dance floor in opposite directions. Baked wedge texture, additive blend
* all show, no cost.
*/
private buildScanBeams(): void {
const key = 'floor:beam';
if (!this.scene.textures.exists(key)) {
const canvas = this.scene.textures.createCanvas(key, 180, 36);
if (canvas) {
const c = canvas.getContext();
const grad = c.createLinearGradient(0, 0, 180, 0);
grad.addColorStop(0, 'rgba(255,255,255,0.55)');
grad.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = grad;
c.beginPath();
c.moveTo(0, 16); c.lineTo(180, 0); c.lineTo(180, 36); c.lineTo(0, 20);
c.closePath(); c.fill();
canvas.refresh();
}
}
// Pivot on the disco ball (PROPS: 'ball' at 37,22 with a 2x2 footprint).
const cx = 38 * TILE;
const cy = 23 * TILE;
for (let i = 0; i < 2; i++) {
this.beams.push(
this.scene.add
.image(cx, cy, key)
.setOrigin(0, 0.5)
.setDepth(D_NEON)
.setBlendMode(Phaser.BlendModes.ADD)
.setAlpha(0.16)
.setRotation(i * Math.PI),
);
}
}
/** Rain falls INSIDE the smoking yard — it is the one open-sky pocket. */ /** Rain falls INSIDE the smoking yard — it is the one open-sky pocket. */
private buildYardRain(): void { private buildYardRain(): void {
const yard = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity }; const yard = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
@ -272,6 +312,13 @@ export class FloorView {
onBeat(beatIndex: number): void { onBeat(beatIndex: number): void {
this.beatKick = beatIndex % 4 === 0 ? 1 : 0.6; this.beatKick = beatIndex % 4 === 0 ? 1 : 0.6;
this.djBob = 1; this.djBob = 1;
// The dance grid cycles colour by bar — each cell offset so the floor
// ripples instead of blinking as one.
const bar = Math.floor(beatIndex / 4);
const PALETTE = [0xd03470, 0x3060c0, 0xd8a020];
this.beatLights.forEach((img, i) => {
img.setTint(PALETTE[(bar + i) % PALETTE.length]!);
});
} }
/** Per-frame light life: beat decay + fluoro flicker. */ /** Per-frame light life: beat decay + fluoro flicker. */
@ -280,6 +327,11 @@ export class FloorView {
this.beatKick = Math.max(0, this.beatKick - dtMs / 260); this.beatKick = Math.max(0, this.beatKick - dtMs / 260);
for (const img of this.beatLights) img.setAlpha(0.22 + 0.3 * this.beatKick); for (const img of this.beatLights) img.setAlpha(0.22 + 0.3 * this.beatKick);
} }
this.beamAngle += dtMs * 0.00035 * (1 + this.beatKick * 0.8);
this.beams.forEach((b, i) => {
b.setRotation(this.beamAngle * (i === 0 ? 1 : -1) + i * Math.PI);
b.setAlpha(0.1 + 0.14 * this.beatKick);
});
if (this.dj && this.djBob > 0) { if (this.dj && this.djBob > 0) {
this.djBob = Math.max(0, this.djBob - dtMs / 200); this.djBob = Math.max(0, this.djBob - dtMs / 200);
this.dj.setY(22.5 * TILE - 2 * this.djBob); this.dj.setY(22.5 * TILE - 2 * this.djBob);
@ -441,6 +493,8 @@ export class FloorView {
for (const n of this.neon) n.destroy(); for (const n of this.neon) n.destroy();
this.yardRain?.destroy(); this.yardRain?.destroy();
this.yardRain = null; this.yardRain = null;
for (const b of this.beams) b.destroy();
this.beams.length = 0;
this.dj?.destroy(); this.dj?.destroy();
this.dj = null; this.dj = null;
for (const pr of this.propImgs) pr.destroy(); for (const pr of this.propImgs) pr.destroy();
@ -564,6 +618,7 @@ const PROP_SIZES: Record<PropKind, [number, number]> = {
buttBin: [16, 16], crate: [16, 16], buttBin: [16, 16], crate: [16, 16],
poster1: [32, 16], poster2: [32, 16], poster3: [32, 16], poster1: [32, 16], poster2: [32, 16], poster3: [32, 16],
cloak: [16, 48], stampPodium: [16, 16], arcade: [32, 32], cloak: [16, 48], stampPodium: [16, 16], arcade: [32, 32],
highTable: [16, 16], plant: [16, 16], wetFloor: [16, 16], poolTable: [48, 32],
}; };
function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string { function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
@ -658,6 +713,24 @@ function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
case 'arcade': 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'); 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; break;
case 'highTable':
c.beginPath(); c.arc(8, 8, 6, 0, Math.PI * 2); c.fillStyle = '#3c2e22'; c.fill();
f(5, 5, 3, 3, '#d8a050'); f(9, 7, 3, 3, '#9fb2c8');
break;
case 'plant':
f(6, 10, 4, 5, '#6e4a2e');
f(4, 3, 3, 6, '#2e7d46'); f(9, 2, 3, 7, '#2e7d46'); f(6, 1, 3, 8, '#3a9456');
break;
case 'wetFloor':
f(5, 2, 6, 12, '#d8c23a'); f(6, 4, 4, 4, '#22222c');
break;
case 'poolTable':
f(2, 2, 44, 28, '#4a3226'); f(5, 5, 38, 22, '#1e6e3c');
for (const [px, py] of [[6, 6], [42, 6], [6, 25], [42, 25], [24, 5], [24, 26]] as const) {
c.beginPath(); c.arc(px, py, 2, 0, Math.PI * 2); c.fillStyle = '#0c0c12'; c.fill();
}
f(20, 14, 3, 3, '#f2f2f2'); f(28, 12, 3, 3, '#c03434'); f(31, 18, 3, 3, '#d8c23a');
break;
} }
canvas.refresh(); canvas.refresh();
return key; return key;

View File

@ -177,8 +177,8 @@ export type PropKind =
| 'deck' | 'mixer' | 'monitor' | 'boothLamp' | 'deck' | 'mixer' | 'monitor' | 'boothLamp'
| 'speaker' | 'discoball' | 'speaker' | 'discoball'
| 'barShelf' | 'taps' | 'till' | 'glassStack' | 'glassRack' | 'barShelf' | 'taps' | 'till' | 'glassStack' | 'glassRack'
| 'boothTable' | 'sinkRow' | 'boothTable' | 'highTable' | 'sinkRow'
| 'buttBin' | 'crate' | 'buttBin' | 'crate' | 'plant' | 'wetFloor' | 'poolTable'
| 'poster1' | 'poster2' | 'poster3' | 'cloak' | 'stampPodium' | 'arcade'; | 'poster1' | 'poster2' | 'poster3' | 'cloak' | 'stampPodium' | 'arcade';
export interface PropEmissive { export interface PropEmissive {
@ -253,6 +253,15 @@ export const PROPS: readonly PropDef[] = Object.freeze([
P('podium', 'stampPodium', 12, 22, 1, 1), P('podium', 'stampPodium', 12, 22, 1, 1),
// one arcade cabinet, because it is a venue in Australia // one arcade cabinet, because it is a venue in Australia
P('arcade', 'arcade', 15, 34, 2, 2, { colour: 0x40c0d0, radius: 18, pulse: 'flicker' }), P('arcade', 'arcade', 15, 34, 2, 2, { colour: 0x40c0d0, radius: 18, pulse: 'flicker' }),
// filling out the room (2026-07-20): the club stops being furniture-poor
P('tblE3', 'boothTable', 75, 33, 1, 1, { colour: 0xd8a050, radius: 12 }),
P('high1', 'highTable', 28, 34, 1, 1),
P('high2', 'highTable', 46, 34, 1, 1),
P('plant1', 'plant', 14, 25, 1, 1),
P('plant2', 'plant', 21, 36, 1, 1),
P('wet', 'wetFloor', 35, 8, 1, 1),
// the pool corner — it is still an Australian venue underneath the techno
P('pool', 'poolTable', 15, 10, 3, 2),
]); ]);
const L = ( const L = (
@ -273,6 +282,8 @@ export const LIGHTS: readonly ZoneLight[] = Object.freeze([
L('yard', 18, 40, 0x6080a0, 44), L('yard', 18, 40, 0x6080a0, 44),
L('entry', 2, 22, 0xd03470, 40), L('entry', 2, 22, 0xd03470, 40),
L('glassie', 17, 5, 0xd8a020, 28), L('glassie', 17, 5, 0xd8a020, 28),
// low green cone over the pool table, like every pool table ever
L('pool', 16, 10, 0x30a050, 34),
]); ]);
export const VENUE: VenueMap = Object.freeze({ export const VENUE: VenueMap = Object.freeze({

View File

@ -25,6 +25,7 @@ TARGETS = {
"buttBin": (16, 16), "crate": (16, 16), "buttBin": (16, 16), "crate": (16, 16),
"poster1": (32, 16), "poster2": (32, 16), "poster3": (32, 16), "poster1": (32, 16), "poster2": (32, 16), "poster3": (32, 16),
"cloak": (16, 48), "stampPodium": (16, 16), "arcade": (32, 32), "cloak": (16, 48), "stampPodium": (16, 16), "arcade": (32, 32),
"highTable": (16, 16), "plant": (16, 16), "wetFloor": (16, 16), "poolTable": (48, 32),
# the door-scene backdrop plate is special-cased: full size, no quantise # the door-scene backdrop plate is special-cased: full size, no quantise
"street:backdrop": (640, 176), "street:backdrop": (640, 176),
} }