PROCITY/web/js/world/fixture_plan.js
m3ultra 5313402e43 Lane B (Streetscape): chunk-streamed walkable town shell
First-person shell: chunk streaming, instanced buildings + facade texture atlas
(22 facade materials -> 1; 5 skin materials city-wide), signs, awnings, furniture,
day/night lighting, minimap, pixel-accurate collision (incl. arbitrary-angle lots),
door raycast -> procity:enterShop, DBG harness, all-fallback mode. Renders Lane A's
full generated town (seed 20261990 'Boolarra Heads', 493 shops); worst continuous-
walk view ~261 draws (<=300 gate). 6 adversarial-review bugs fixed (collision sign,
house doors, awning skin, sign atlas, furniture seed hash, shot-mode restore).

index.html and skins.js include Lane F's inline integration seam edits (marked
'[Lane F integration]'): interior/keeper/citizen wiring + facade filename fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:28:47 +10:00

212 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY Lane B — fixture_plan.js
// A hand-authored CityPlan (CITY_SPEC schema v1) so the streetscape is buildable *before*
// Lane A ships generatePlan(). Everything here is deterministic layout math (no Math.random,
// no THREE) — pure JSON-serialisable data. When Lane A lands, index.html swaps in
// generatePlan(seed) and this file becomes the fallback + schema reference.
//
// LAYOUT (metres, +Y up, ground = XZ, origin = centre of the main square):
//
// north (backstreets: houses up the cross street)
// │ cross st (X=0)
// NW shops (Z=+11, face Z) ──────┼────── NE shops
// ═══════════════ MAIN STREET (Z=0, east-west) ═══════════════
// SW shops (Z=11, face +Z) ──────┼────── SE shops
//
// Orientation convention (documented once, obeyed by buildings.js):
// A lot is an oriented rectangle centred (x,z), footprint w (frontage) × d (depth).
// `ry` rotates the building about +Y. At ry=0 the shopfront outward-normal is +Z.
// Rotation by ry maps +Z → (sin ry, 0, cos ry), so:
// front faces +Z → ry = 0 (south side of an E-W street)
// front faces Z → ry = π (north side of an E-W street)
// front faces +X → ry = +π/2 (west side of a N-S street)
// front faces X → ry = −π/2 (east side of a N-S street)
import { rng, pick, irange } from '../core/prng.js';
// ── Shop-type registry (Lane A owns web/js/core/registry.js; it hasn't landed yet, so Lane B
// carries the slice it needs. facade pools are the CITY_SPEC table. Lane F reconciles.) ──
export const SHOP_REGISTRY = {
record: { facades: ['timber-teal', 'arcade-tile', 'djsim-record'], signBg: '#1f2e2b', signFg: '#e8d9a0' },
opshop: { facades: ['weatherboard', 'fibro-blue', 'djsim-opshop'], signBg: '#2c2820', signFg: '#f0e2c0' },
toy: { facades: ['stucco-pink', 'deco-pastel'], signBg: '#7a1f5a', signFg: '#ffe6f2' },
book: { facades: ['federation', 'sandstone'], signBg: '#2a231a', signFg: '#f3e6c4' },
video: { facades: ['stripmall', 'djsim-video'], signBg: '#141a2c', signFg: '#8fb7ff' },
pawn: { facades: ['besser', 'grimy', 'djsim-pawn'], signBg: '#f5c400', signFg: '#c1121f' },
milkbar: { facades: ['djsim-milkbar', 'brickveneer'], signBg: '#b21f1f', signFg: '#fff3d6' },
dept: { facades: ['terrazzo', 'djsim-dept'], signBg: '#20313a', signFg: '#ffd75e' },
stall: { facades: ['market'], signBg: '#2c2820', signFg: '#f0e2c0' },
};
export const SHOP_TYPES = Object.keys(SHOP_REGISTRY);
// Parody-name word banks (kept small; deterministic pick per shop). 90s-AU flavour.
const NAME_BANK = {
record: { a: ['Groove', 'Wax', 'Vinyl', 'Spin', 'Needle', 'Rotary'], b: ['Junction', 'Merchants', 'Traders', 'Exchange', 'Bin'] },
opshop: { a: ['Vinnies', 'Salvos', 'Second', 'Thrifty', 'Endeavour', 'Lifeline'], b: ['Chance', 'Op Shop', 'Finds', 'Bazaar', 'Nook'] },
toy: { a: ['Toyworld', 'Kiddie', 'Wonder', 'Bright', 'Jolly'], b: ['Toys', 'Playtime', 'Corner', 'Emporium'] },
book: { a: ['Dog-Eared', 'Second Story', 'Turning', 'Foxed', 'Marginalia'], b: ['Books', 'Book Barn', 'Reads', 'Pages'] },
video: { a: ['Blockheads', 'Video', 'Reel', 'Nightowl', 'Civic'], b: ['Video', 'Rental', 'Ezy Hire', 'Movies'] },
pawn: { a: ['Cash', 'Quick', 'Second Chance', 'City', 'Honest'], b: ['Converters', 'Pawn', 'Loans', 'Traders'] },
milkbar: { a: ['Corner', 'Lucky', 'Rio', 'Astoria', 'Sunny'], b: ['Milk Bar', 'Deli', 'Cafe', 'Store'] },
dept: { a: ['Coles', 'Waltons', 'Grace Bros', 'Fosseys', 'Venture'], b: ['Variety', 'Department', 'Emporium', ''] },
stall: { a: ['Trash & Treasure', 'Sunday', 'Boot', 'Market'], b: ['Stall', 'Table', 'Trestle'] },
};
function makeName(seed, type) {
const r = rng(seed, 'name', 0);
const bank = NAME_BANK[type] || NAME_BANK.opshop;
const a = pick(r, bank.a), b = pick(r, bank.b);
return b ? `${a} ${b}`.trim() : a;
}
// Deterministic hours: most open 95-ish, some late, a few closed-feeling.
function makeHours(seed) {
const r = rng(seed, 'hours', 0);
const open = irange(r, 7, 10);
const close = irange(r, 16, 21);
return [open, close];
}
// Facade skin for a shop, seeded from its type pool.
function pickFacade(seed, type) {
const r = rng(seed, 'facade', 0);
return pick(r, (SHOP_REGISTRY[type] || SHOP_REGISTRY.opshop).facades);
}
// ── Geometry constants for the fixture town ──
const W = 8; // terraced shop frontage width (m)
const D = 8; // shop depth (m)
const N_FRONT = 11; // north-side frontage plane Z (fronts face Z)
const S_FRONT = -11; // south-side frontage plane Z (fronts face +Z)
const CROSS_W = -11; // cross-street west frontage plane X (fronts face +X)
const CROSS_E = 11; // cross-street east frontage plane X (fronts face X)
// The terraced blocks. Four along the north frontage + two on the south near the plaza, spread so
// the main street spans ~6 chunks (streaming visibly kicks in walking end-to-end). Every archetype
// shows up. 8m shops; the plaza gap sits around the origin.
const BLOCKS = [
{ key: 'NW2', side: 'N', xs: [-140, -132, -124, -116], types: ['record', 'opshop', 'book', 'milkbar'] },
{ key: 'NW1', side: 'N', xs: [-52, -44, -36, -28], types: ['video', 'toy', 'pawn', 'record'] },
{ key: 'NE1', side: 'N', xs: [28, 36, 44, 52], types: ['opshop', 'book', 'milkbar', 'video'] },
{ key: 'NE2', side: 'N', xs: [116, 124, 132, 140], types: ['dept', 'toy', 'pawn', 'opshop'] },
{ key: 'SW', side: 'S', xs: [-52, -44, -36, -28], types: ['milkbar', 'record', 'opshop', 'book'] },
{ key: 'SE', side: 'S', xs: [28, 36, 44, 52], types: ['toy', 'video', 'pawn', 'record'] },
];
let _uid = 0;
const uid = (p) => `${p}${_uid++}`;
function buildShopLots(citySeed, lots, shops, blocks) {
_uid = 0;
for (const bd of BLOCKS) {
const north = bd.side === 'N';
const z = north ? N_FRONT + D / 2 : S_FRONT - D / 2;
const ry = north ? Math.PI : 0;
const frontEdge = north ? 'S' : 'N';
const xs = bd.xs, types = bd.types;
const minX = Math.min(...xs) - W / 2, maxX = Math.max(...xs) + W / 2;
const zNear = north ? N_FRONT : S_FRONT;
const zFar = north ? N_FRONT + D : S_FRONT - D;
const blockId = `block-${bd.key}`;
blocks.push({
id: blockId, district: 'main',
poly: [[minX, Math.min(zNear, zFar)], [maxX, Math.min(zNear, zFar)],
[maxX, Math.max(zNear, zFar)], [minX, Math.max(zNear, zFar)]],
});
xs.forEach((x, i) => {
const type = types[i % types.length];
const lotId = uid('lot-');
const isAnchor = type === 'dept';
const storeys = isAnchor ? 3 : (i % 3 === 0 ? 2 : 1);
lots.push({
id: lotId, block: blockId, x, z, w: W, d: D, ry,
frontEdge, use: isAnchor ? 'anchor' : 'shop',
});
const seed = citySeed + _uid * 2654435761 % 0xffffffff;
const s = (seed >>> 0);
shops.push({
id: uid('shop-'), lot: lotId, type,
name: makeName(s, type), sign: makeName(s, type),
seed: s, facadeSkin: pickFacade(s, type), storeys, hours: makeHours(s),
});
});
}
}
function buildHouses(citySeed, lots) {
// Backstreets: houses lining the cross street north of the square.
const specs = [
{ x: CROSS_W - 5, z: 36, ry: Math.PI / 2, frontEdge: 'E' },
{ x: CROSS_W - 5, z: 60, ry: Math.PI / 2, frontEdge: 'E' },
{ x: CROSS_E + 5, z: 48, ry: -Math.PI / 2, frontEdge: 'W' },
{ x: CROSS_E + 5, z: 72, ry: -Math.PI / 2, frontEdge: 'W' },
];
specs.forEach((sp, i) => {
lots.push({
id: `lot-house-${i}`, block: 'block-back', x: sp.x, z: sp.z,
w: 9, d: 10, ry: sp.ry, frontEdge: sp.frontEdge, use: 'house',
});
});
}
function buildStalls(citySeed, lots, shops) {
// Two market stalls in the square (brickpave plaza around the origin).
const specs = [{ x: -9, z: 6, ry: 0 }, { x: 9, z: -6, ry: Math.PI }];
specs.forEach((sp, i) => {
const lotId = `lot-stall-${i}`;
lots.push({ id: lotId, block: 'block-square', x: sp.x, z: sp.z, w: 4, d: 3, ry: sp.ry, use: 'stall', frontEdge: 'S' });
const s = (citySeed + 7777 + i * 131) >>> 0;
shops.push({
id: `shop-stall-${i}`, lot: lotId, type: 'stall',
name: makeName(s, 'stall'), sign: makeName(s, 'stall'),
seed: s, facadeSkin: 'market', storeys: 1, hours: [8, 14],
});
});
}
/**
* fixturePlan(citySeed) → CityPlan (schema v1). Deterministic in citySeed.
*/
export function fixturePlan(citySeed = 20261990) {
citySeed = citySeed >>> 0;
const lots = [], shops = [], blocks = [];
buildShopLots(citySeed, lots, shops, blocks);
buildHouses(citySeed, lots);
buildStalls(citySeed, lots, shops);
const r = rng(citySeed, 'city', 0);
const plan = {
version: 1,
citySeed,
name: pick(r, ['Wollamby', 'Dunroamin', 'Barretta', 'Coolabah', 'Mungindi', 'Yarraville Junction']),
size: { w: 1024, d: 1024 },
districts: [
{ id: 'main', kind: 'mainstreet', cx: 0, cz: 0 },
{ id: 'square', kind: 'market', cx: 0, cz: 0 },
{ id: 'back', kind: 'backstreets', cx: 0, cz: 55 },
],
streets: {
nodes: [
{ id: 'n0', x: -155, z: 0 }, // west end, main
{ id: 'n1', x: 0, z: 0 }, // central intersection / square
{ id: 'n2', x: 155, z: 0 }, // east end, main
{ id: 'n3', x: 0, z: 95 }, // north end, cross street
],
edges: [
{ id: 'e0', a: 'n0', b: 'n1', width: 14, kind: 'main' },
{ id: 'e1', a: 'n1', b: 'n2', width: 14, kind: 'main' },
{ id: 'e2', a: 'n1', b: 'n3', width: 8, kind: 'side' },
],
},
blocks,
lots,
shops,
// Which sky this town gets (Lane B lighting reads this; seeded so a town's weather is stable).
sky: pick(r, ['golden-arvo', 'spring-puffy', 'endless-blue', 'high-cirrus', 'summer-storm', 'outback-dusk']),
};
return plan;
}
// Default fixture used by index.html when citygen is absent.
export default fixturePlan;