The venue ladder has always listed four venues in data/venues.ts, but they shared ONE floor map and differed only by difficulty knobs — so a promotion you survived a whole week for looked exactly like the room you had just left. - venueMap.ts grows a FloorLayout contract (map + props + lights + posts + probes + palette) and the newGrid() primitives every room is painted with. Voltage stays IN venueMap rather than moving to layouts/, so the new rooms can import primitives from it without closing an import cycle. - Three new rooms in src/scenes/floor/layouts/: The Royal (horseshoe public bar, pool room, pokies corner, trough + two cubicles, a beer garden that is plainly the nicest room in the pub, and a DJ "corner" that is a folding table because this pub never built a booth), Elevate (open-air rooftop — most of the grid is sky, the DJ is on an unwalled plinth, you arrive by lift), ROOM (concrete warehouse, pillars you path around, central booth, loading-dock smoking area, twelve lights in the whole venue). - Nothing about a room is a module singleton any more. FloorView, sweep and FloorDemoScene take the layout; the door picks its street plate by venue id. - FloorDemoScene's six hardcoded station spots (TAPS_SPOT, DECKS_SPOT, ...) were Voltage's tile coordinates. At four venues a hardcoded (47,3) puts the bar shift inside The Royal's pool room, so each layout now names its own stations and the scene reads them from there. - tests/floor/layoutInvariants.ts is an executable rulebook: perimeter holds, every walkable tile reachable from the entry, anchors on walkable ground, posts not sealed (a post on a sealed tile freezes the player for the night — that has shipped twice), probes reachable, props on-grid. Proved against Voltage BEFORE the new rooms were authored against it. Dev route #floor:<venueId> boots the floor straight into one room, because otherwise seeing ROOM means surviving three weeks of the ladder. Gate: lint clean, build clean, 821 tests passing (was 784). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
357 lines
15 KiB
TypeScript
357 lines
15 KiB
TypeScript
// Elevate — the rooftop. Licensed 70, and every second person is a VIP
|
|
// apparently (docs/VENUES.md §4).
|
|
//
|
|
// Two things about this room will look like bugs to whoever reads it next, and
|
|
// both are deliberate:
|
|
//
|
|
// 1. THE FLOOR IS 'yard', NOT 'floor'. The whole venue is outdoors, so the
|
|
// terrace is painted with the tile kind the renderer falls rain on
|
|
// (FloorView keys its rain emitter off 'yard'). Getting rained on at the
|
|
// cocktail bar is the joke and the weather is the venue's character. Only
|
|
// the lift lobby and the dunnies — the two roofed pockets — are 'floor'.
|
|
// Reskinning the terrace to 'floor' would silently switch the rain off.
|
|
//
|
|
// 2. THE DJ PLINTH HAS NO WALLS. Voltage's DJ sits in a sealed `djbooth` ring
|
|
// with `djfloor` inside it; here there is no ring at all, just a raised
|
|
// `djfloor` island the crowd walks around. Elevate's DJ is on display, which
|
|
// is the whole point of a plinth, so the `decks` post stands the player on
|
|
// the open terrace tile behind the gear rather than inside anything.
|
|
//
|
|
// Everything else follows from "small": more than half the grid is 'void' —
|
|
// open sky past the roof edge — because the grid is fixed at 80x45 for every
|
|
// venue (VENUES.md §1) and a small room reads small by walling the rest away.
|
|
import {
|
|
MAP_H, MAP_W, TILE, newGrid, tileToWorld,
|
|
} from '../venueMap';
|
|
import type {
|
|
AnchorKind, FloorLayout, PropDef, PropEmissive, PropKind, StallDef, TileKind,
|
|
TilePoint, VenueMap, WorldPoint, ZoneLight,
|
|
} from '../venueMap';
|
|
|
|
// The roof deck's outer ring. North and west are solid parapet/lift-core wall;
|
|
// south and east are the glass balustrade, which is why they are 'fence' — the
|
|
// city is visible through them and the skyline cards sit out in the void beyond.
|
|
const ROOF_X0 = 14;
|
|
const ROOF_Y0 = 7;
|
|
const ROOF_X1 = 58;
|
|
const ROOF_Y1 = 37;
|
|
|
|
// The lift lobby hangs off the west side, outside the roof ring: you arrive in
|
|
// the building's core and step OUT onto the terrace, rather than off a street.
|
|
const LOBBY_X1 = ROOF_X0;
|
|
const LOBBY_Y0 = 18;
|
|
const LOBBY_Y1 = 28;
|
|
/** Lift doors on column 0 — 'exit' here is the lift back down to the door scene. */
|
|
const LIFT_Y = [22, 23, 24] as const;
|
|
|
|
// Two unisex cubicles, per the brief — no urinals, no third door. Each is the
|
|
// standard 4x4 walled box; the west one shares no wall with the dunny room
|
|
// (tx 46 is the room wall) so both boxes sit clear of it.
|
|
const CUBICLE_X = [47, 51] as const;
|
|
const CUBICLE_Y = 8;
|
|
|
|
// Banquette pairs down the east side, behind their velvet rope.
|
|
const BOOTH_Y = [21, 25, 29, 33] as const;
|
|
|
|
const BAR_X0 = 18;
|
|
const BAR_X1 = 32;
|
|
const BAR_Y = 10;
|
|
/** Two tiles of service lane behind the bar, not one — the bartender has to fit. */
|
|
const BAR_LANE_Y = 9;
|
|
|
|
function buildTiles(): TileKind[] {
|
|
const { tiles, set, rect, box } = newGrid('void');
|
|
|
|
// --- the roof deck ---------------------------------------------------------
|
|
rect(ROOF_X0, ROOF_Y0, ROOF_X1, ROOF_Y0, 'wall'); // north parapet
|
|
rect(ROOF_X0, ROOF_Y0, ROOF_X0, ROOF_Y1, 'wall'); // lift-core wall
|
|
rect(ROOF_X0, ROOF_Y1, ROOF_X1, ROOF_Y1, 'fence'); // south glass balustrade
|
|
rect(ROOF_X1, ROOF_Y0, ROOF_X1, ROOF_Y1, 'fence'); // east glass balustrade
|
|
rect(ROOF_X0 + 1, ROOF_Y0 + 1, ROOF_X1 - 1, ROOF_Y1 - 1, 'yard');
|
|
|
|
// The venue's name, in pink, facing nobody but the building opposite.
|
|
for (const tx of [22, 28, 34]) set(tx, ROOF_Y0, 'neon');
|
|
|
|
// --- lift lobby ------------------------------------------------------------
|
|
box(0, LOBBY_Y0, LOBBY_X1, LOBBY_Y1, 'wall');
|
|
rect(1, LOBBY_Y0 + 1, LOBBY_X1 - 1, LOBBY_Y1 - 1, 'floor');
|
|
for (const ty of LIFT_Y) set(0, ty, 'exit');
|
|
// Threshold out of the core onto the terrace, carved after both walls exist.
|
|
for (const ty of LIFT_Y) set(LOBBY_X1, ty, 'floor');
|
|
|
|
// --- cocktail bar (north) --------------------------------------------------
|
|
// Short counter, no taps anywhere in this venue — bottles and a bucket.
|
|
rect(BAR_X0, BAR_Y, BAR_X1, BAR_Y + 1, 'bar');
|
|
// Loose stools rather than Voltage's solid run: patrons filter between them,
|
|
// which keeps the bar apron from behaving like a second wall.
|
|
for (const tx of [20, 23, 26, 29, 32]) set(tx, BAR_Y + 2, 'stool');
|
|
|
|
// --- DJ plinth (open, raised, unenclosed — see the header) ------------------
|
|
rect(29, 16, 35, 17, 'djfloor');
|
|
|
|
// --- dance floor -----------------------------------------------------------
|
|
// It is a terrace with a dance floor on it, not a club, so this is small.
|
|
rect(26, 21, 37, 29, 'dance');
|
|
|
|
// --- VIP booths (east) -----------------------------------------------------
|
|
for (const ty of BOOTH_Y) rect(54, ty, 55, ty + 1, 'booth');
|
|
|
|
// --- toilets (north-east) --------------------------------------------------
|
|
box(46, ROOF_Y0, 56, 18, 'wall');
|
|
rect(47, ROOF_Y0 + 1, 55, 17, 'floor');
|
|
rect(50, 18, 51, 18, 'floor'); // the only way in
|
|
rect(48, 15, 52, 15, 'sink'); // the marble basin run
|
|
for (const x of CUBICLE_X) {
|
|
box(x, CUBICLE_Y, x + 3, CUBICLE_Y + 3, 'wall');
|
|
rect(x + 1, CUBICLE_Y + 1, x + 2, CUBICLE_Y + 2, 'stall');
|
|
set(x + 1, CUBICLE_Y + 3, 'stallDoor');
|
|
}
|
|
|
|
return tiles;
|
|
}
|
|
|
|
function buildStalls(): StallDef[] {
|
|
return CUBICLE_X.map((x, i) => ({
|
|
id: `s${i + 1}`,
|
|
door: tileToWorld(x + 1, CUBICLE_Y + 4),
|
|
// Centre of the 2x2 interior, which lands on a tile CORNER, not a centre.
|
|
inside: { x: (x + 2) * TILE, y: (CUBICLE_Y + 2) * TILE },
|
|
}));
|
|
}
|
|
|
|
function buildAnchors(): Record<AnchorKind, readonly WorldPoint[]> {
|
|
const dance: WorldPoint[] = [];
|
|
for (const ty of [23, 27]) for (const tx of [28, 32, 36]) dance.push(tileToWorld(tx, ty));
|
|
return {
|
|
// Punter side of the counter, one tile clear of the stools.
|
|
bar: [19, 22, 25, 28, 31].map((tx) => tileToWorld(tx, BAR_Y + 3)),
|
|
dance,
|
|
// Standing room in front of each banquette, outside the rope.
|
|
booth: [
|
|
...BOOTH_Y.map((ty) => tileToWorld(52, ty)),
|
|
tileToWorld(52, BOOTH_Y[0] + 1),
|
|
tileToWorld(52, BOOTH_Y[2] + 1),
|
|
],
|
|
toilet: CUBICLE_X.map((x) => tileToWorld(x + 1, CUBICLE_Y + 4)),
|
|
// No smoking ROOM and no smokeDoor: the whole roof is open air, so the
|
|
// smokers get a designated corner of the terrace instead of a pocket.
|
|
smoke: [tileToWorld(18, 33), tileToWorld(21, 33), tileToWorld(19, 35)],
|
|
entry: [tileToWorld(3, 23), tileToWorld(7, 23), tileToWorld(11, 23)],
|
|
exit: [tileToWorld(0, 23)],
|
|
};
|
|
}
|
|
|
|
const P = (
|
|
id: string, kind: PropKind, tx: number, ty: number, tw: number, th: number,
|
|
emissive?: PropEmissive,
|
|
): PropDef => (emissive ? { id, kind, tx, ty, tw, th, emissive } : { id, kind, tx, ty, tw, th });
|
|
|
|
/** The skyline gold every card throws back over the balustrade. */
|
|
const SKYLINE: PropEmissive = { colour: 0xd8a848, radius: 56 };
|
|
|
|
const PROPS: readonly PropDef[] = Object.freeze([
|
|
// --- lift lobby ------------------------------------------------------------
|
|
P('liftDoors', 'liftDoor', 0, 22, 1, 3),
|
|
// No 'mirror' PropKind exists and the union is frozen after Phase 0, so both
|
|
// of this venue's mirrors are flat wall plates borrowed from the poster slot.
|
|
// TODO(contract): a `mirror` kind would let these stop lying.
|
|
P('lobbyMirror', 'poster3', 1, 19, 2, 1),
|
|
P('lobbyArt', 'poster1', 1, 26, 2, 1),
|
|
P('podium', 'stampPodium', 11, 23, 1, 1),
|
|
P('ropeL1', 'ropePost', 5, 21, 1, 1),
|
|
P('ropeL2', 'ropePost', 8, 21, 1, 1),
|
|
P('ropeL3', 'ropePost', 5, 25, 1, 1),
|
|
P('ropeL4', 'ropePost', 8, 25, 1, 1),
|
|
P('cloak', 'cloak', 13, 19, 1, 3),
|
|
P('lobbyPlanter', 'planter', 2, 22, 1, 1),
|
|
P('doorStaff', 'staffSecurity', 12, 26, 1, 1),
|
|
|
|
// --- cocktail bar ----------------------------------------------------------
|
|
P('backbar', 'barShelf', BAR_X0, 8, 15, 1, { colour: 0xd8a020, radius: 44 }),
|
|
P('counter', 'cocktailBar', BAR_X0, BAR_Y, 15, 2),
|
|
P('glassStack', 'glassStack', 21, BAR_Y, 2, 1),
|
|
P('champA', 'champBucket', 24, BAR_Y, 1, 1),
|
|
P('iceWell', 'iceWell', 27, BAR_Y, 2, 1),
|
|
P('champB', 'champBucket', 30, BAR_Y, 1, 1),
|
|
P('till', 'till', 31, BAR_Y, 1, 1),
|
|
P('fridge', 'barFridge', 33, 8, 2, 2, { colour: 0x60c0e0, radius: 16 }),
|
|
P('dish', 'dishwasher', 20, 8, 2, 1, { colour: 0x50c060, radius: 20 }),
|
|
P('rack', 'glassRack', 36, 9, 2, 1),
|
|
P('bartender', 'staffBartender', 25, BAR_LANE_Y, 1, 1),
|
|
|
|
// --- DJ plinth -------------------------------------------------------------
|
|
P('plinth', 'djPlinth', 29, 16, 7, 2),
|
|
P('deckA', 'deck', 30, 16, 2, 2),
|
|
P('mixer', 'mixer', 32, 16, 1, 2),
|
|
P('deckB', 'deck', 33, 16, 2, 2),
|
|
P('monitor', 'monitor', 29, 17, 1, 1),
|
|
P('djLamp', 'boothLamp', 35, 16, 1, 1, { colour: 0x3060c0, radius: 38, pulse: 'flicker' }),
|
|
P('theDj', 'staffDj', 34, 15, 1, 1),
|
|
|
|
// --- dance floor -----------------------------------------------------------
|
|
P('spkNW', 'speaker', 25, 20, 1, 2),
|
|
P('spkNE', 'speaker', 38, 20, 1, 2),
|
|
P('spkSW', 'speaker', 25, 28, 1, 2),
|
|
P('spkSE', 'speaker', 38, 28, 1, 2),
|
|
P('trussN', 'truss', 30, 20, 3, 1),
|
|
P('trussS', 'truss', 30, 30, 3, 1),
|
|
P('ball', 'discoball', 31, 24, 2, 2, { colour: 0xe8c8e0, radius: 22 }),
|
|
|
|
// --- VIP booths ------------------------------------------------------------
|
|
...BOOTH_Y.flatMap((ty, i) => {
|
|
const tag = String.fromCharCode(65 + i);
|
|
return [
|
|
P(`banq${tag}`, 'banquette', 54, ty, 2, 2),
|
|
P(`vipTbl${tag}`, 'boothTable', 53, ty, 1, 1, { colour: 0xd8a050, radius: 12 }),
|
|
P(`vipRope${tag}`, 'ropePost', 52, ty - 1, 1, 1),
|
|
];
|
|
}),
|
|
P('vipChamp', 'champBucket', 53, 26, 1, 1),
|
|
P('vipLounge', 'loungeChair', 51, 35, 2, 1),
|
|
|
|
// --- toilets ---------------------------------------------------------------
|
|
P('basin', 'marbleSink', 48, 15, 5, 1),
|
|
P('looMirror', 'poster3', 48, 14, 5, 1),
|
|
P('cubDoorA', 'cubicleDoor', 48, 11, 1, 1),
|
|
P('cubDoorB', 'cubicleDoor', 52, 11, 1, 1),
|
|
P('dryer', 'handDryer', 55, 13, 1, 1),
|
|
P('mop', 'mopBucket', 47, 17, 1, 1),
|
|
P('looPlanter', 'planter', 54, 16, 1, 1),
|
|
P('wet', 'wetFloor', 50, 19, 1, 1),
|
|
|
|
// --- terrace edge ----------------------------------------------------------
|
|
P('balS1', 'balustrade', 16, ROOF_Y1, 10, 1),
|
|
P('balS2', 'balustrade', 27, ROOF_Y1, 10, 1),
|
|
P('balS3', 'balustrade', 38, ROOF_Y1, 10, 1),
|
|
P('balS4', 'balustrade', 49, ROOF_Y1, 9, 1),
|
|
P('balE1', 'balustrade', ROOF_X1, 9, 1, 9),
|
|
P('balE2', 'balustrade', ROOF_X1, 19, 1, 9),
|
|
P('balE3', 'balustrade', ROOF_X1, 29, 1, 8),
|
|
P('planterA', 'planter', 26, 36, 1, 1),
|
|
P('planterB', 'planter', 33, 36, 1, 1),
|
|
P('planterC', 'planter', 40, 36, 1, 1),
|
|
P('planterD', 'planter', 47, 36, 1, 1),
|
|
P('planterE', 'planter', 57, 12, 1, 1),
|
|
P('planterF', 'planter', 57, 22, 1, 1),
|
|
P('planterG', 'planter', 57, 32, 1, 1),
|
|
// The city itself, out in the void past the glass. These are the only props
|
|
// deliberately placed OFF the deck — they are scenery, nobody walks to them.
|
|
P('skyS1', 'skylineCard', 16, 39, 14, 5, SKYLINE),
|
|
P('skyS2', 'skylineCard', 32, 39, 14, 5, SKYLINE),
|
|
P('skyS3', 'skylineCard', 48, 39, 12, 5, SKYLINE),
|
|
P('skyE1', 'skylineCard', 60, 8, 14, 12, SKYLINE),
|
|
P('skyE2', 'skylineCard', 60, 22, 16, 14, SKYLINE),
|
|
|
|
// --- the smoking corner (south-west of the deck) ---------------------------
|
|
P('heaterA', 'patioHeater', 17, 31, 1, 2, { colour: 0xe07030, radius: 34, pulse: 'flicker' }),
|
|
P('heaterB', 'patioHeater', 23, 31, 1, 2, { colour: 0xe07030, radius: 34, pulse: 'flicker' }),
|
|
P('ashUrnA', 'ashUrn', 18, 32, 1, 1),
|
|
P('ashUrnB', 'ashUrn', 22, 34, 1, 1),
|
|
P('loungeA', 'loungeChair', 19, 34, 2, 1),
|
|
P('loungeB', 'loungeChair', 16, 35, 2, 1),
|
|
P('smokePlanter', 'planter', 24, 33, 1, 1),
|
|
|
|
// --- terrace dressing ------------------------------------------------------
|
|
P('highA', 'highTable', 21, 17, 1, 1),
|
|
P('highB', 'highTable', 40, 14, 1, 1),
|
|
P('highC', 'highTable', 43, 24, 1, 1),
|
|
P('highD', 'highTable', 20, 24, 1, 1),
|
|
P('loungeC', 'loungeChair', 41, 32, 2, 1),
|
|
P('terraceArt', 'poster2', 15, 14, 1, 2),
|
|
P('glassie', 'staffGlassie', 44, 20, 1, 1),
|
|
]);
|
|
|
|
const L = (
|
|
id: string, tx: number, ty: number, colour: number, radius: number,
|
|
pulse?: 'beat' | 'flicker',
|
|
): ZoneLight => (pulse ? { id, tx, ty, colour, radius, pulse } : { id, tx, ty, colour, radius });
|
|
|
|
// Zone light signatures (docs/SPACES.md §1) — you navigate by glow colour, so
|
|
// the house colours are fixed and a venue gets exactly ONE of its own. Elevate's
|
|
// is SKYLINE GOLD, thrown back off the city beyond the glass; that is why the
|
|
// terrace edge is lit and the middle of the deck largely is not.
|
|
const TERRACE_BLUE = 0x7090a8;
|
|
const SKYLINE_GOLD = 0xd8a848;
|
|
|
|
const LIGHTS: readonly ZoneLight[] = Object.freeze([
|
|
...[20, 25, 30].map((tx, i) => L(`bar${i}`, tx, BAR_LANE_Y, 0xd8a020, 40)),
|
|
...[28, 32, 36].flatMap((tx) =>
|
|
[22, 25, 28].map((ty) => L(`dg${tx}x${ty}`, tx, ty, 0xd03470, 26, 'beat')),
|
|
),
|
|
L('dj', 32, 17, 0x3060c0, 44),
|
|
L('loo', 51, 13, 0x50c060, 50, 'flicker'),
|
|
L('smoke', 20, 33, TERRACE_BLUE, 44),
|
|
L('terraceS', 38, 34, TERRACE_BLUE, 40),
|
|
L('terraceE', 56, 26, TERRACE_BLUE, 40),
|
|
L('terraceW', 16, 20, TERRACE_BLUE, 36),
|
|
L('entry', 2, 23, 0xd03470, 40),
|
|
L('lobby', 10, 23, 0xd03470, 32),
|
|
L('gold0', ROOF_X1, 14, SKYLINE_GOLD, 54),
|
|
L('gold1', ROOF_X1, 26, SKYLINE_GOLD, 54),
|
|
L('gold2', ROOF_X1, 34, SKYLINE_GOLD, 54),
|
|
L('gold3', 24, ROOF_Y1, SKYLINE_GOLD, 54),
|
|
L('gold4', 38, ROOF_Y1, SKYLINE_GOLD, 54),
|
|
L('gold5', 50, ROOF_Y1, SKYLINE_GOLD, 54),
|
|
]);
|
|
|
|
const MAP: VenueMap = Object.freeze({
|
|
width: MAP_W,
|
|
height: MAP_H,
|
|
tiles: Object.freeze(buildTiles()),
|
|
anchors: Object.freeze(buildAnchors()),
|
|
stalls: Object.freeze(buildStalls()),
|
|
});
|
|
|
|
/**
|
|
* Where a ROLE plants the player. Both must be walkable and reachable — a post
|
|
* on a sealed tile is a night spent frozen in place, and that has shipped twice.
|
|
* Elevate has no beer taps at all, so 'taps' is the cocktail bar's serving side.
|
|
*/
|
|
const POSTS: Readonly<Record<string, TilePoint>> = Object.freeze({
|
|
taps: { tx: 25, ty: BAR_LANE_Y },
|
|
/** Behind the plinth, facing the crowd across the gear. Open terrace, no box. */
|
|
decks: { tx: 31, ty: 15 },
|
|
});
|
|
|
|
/** Interaction anchors the player walks UP TO; these may sit inside furniture. */
|
|
const PROBES: Readonly<Record<string, TilePoint>> = Object.freeze({
|
|
tapsFront: { tx: 25, ty: BAR_Y + 3 },
|
|
rack: { tx: 36, ty: 9 },
|
|
dishwasher: { tx: 20, ty: BAR_LANE_Y },
|
|
/** RSA water duty runs off the marble basin, the only plumbing on the roof. */
|
|
water: { tx: 50, ty: 15 },
|
|
});
|
|
|
|
/**
|
|
* Poured concrete and pale decking, cooler and a stop lighter than Voltage's
|
|
* sticky purple — but only a stop. These values are read UNDER the renderer's
|
|
* darkness sheet, which multiplies everything down; a palette picked to look
|
|
* right in isolation renders as black in the room.
|
|
*/
|
|
const PALETTE: Readonly<Partial<Record<TileKind, number>>> = Object.freeze({
|
|
void: 0x080b16, // city night, not a hole in the world
|
|
yard: 0x323b42, // the deck itself, and most of the venue
|
|
floor: 0x2c3238, // lift lobby + dunnies, the two roofed pockets
|
|
wall: 0x3f4750,
|
|
fence: 0x5a6a76, // glass balustrade — catches every light on the roof
|
|
dance: 0x38344c,
|
|
booth: 0x4a3040, // velvet
|
|
bar: 0x554e46, // pale stone counter, not Voltage's timber
|
|
stool: 0x6a5c48,
|
|
djfloor: 0x2e3646,
|
|
sink: 0x6a6f74, // marble
|
|
stall: 0x353d40,
|
|
stallDoor: 0x55605e,
|
|
exit: 0x36624a,
|
|
});
|
|
|
|
export const ELEVATE: FloorLayout = Object.freeze({
|
|
id: 'elevate',
|
|
map: MAP,
|
|
props: PROPS,
|
|
lights: LIGHTS,
|
|
posts: POSTS,
|
|
probes: PROBES,
|
|
palette: PALETTE,
|
|
});
|