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>
This commit is contained in:
m3ultra 2026-07-14 14:28:47 +10:00
parent 8577c48594
commit 5313402e43
19 changed files with 2331 additions and 0 deletions

111
B-progress.md Normal file
View File

@ -0,0 +1,111 @@
# PROCITY-B — progress (Lane B: Streetscape + game shell)
**Status: complete and verified.** Lane B turns a CityPlan into a walkable, chunk-streamed 3D town
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
`generatePlan` (which landed mid-session). Everything below was verified live in a browser.
## Session 2 (continuation) — closed the two open Lane-B items Fable flagged
- **Draw budget now holds with margin.** Facade texture atlas + awning-merge + shell/trim-merge:
worst dense frustum **393 → 265 scene draws**, worst continuous-walk gameplay view **~261 total**
(≤300 gate, ~40 margin). Bonus: **skin materials 25 → 5** (the atlas is one shared facade material),
fixing the "≤25 was at the cap" concern too. (The old "~445" peak was largely a measurement artifact
— orphan chunks from teleport-jumping that the real streamer disposes.)
- **`window.DBG` harness exposed** (`?dbg=1`, `js/world/dbg.js`) — answers LANE_F_NOTES §4 so Fable's
`shots.py`/`soak.py` can drive the game: `ready`, `shot(name)`, `teleport`, `setSegment`,
`enterShop`/`exitShop` (drives the real B→F→C interior chain), `info()`.
- **`ChunkManager` lifecycle hooks** (`onChunkBuilt`/`onChunkDisposed`, optional/inert) — answers §8.
- Verified: fixture ("Yarraville Junction", 26 shops) still builds clean with all 3 awning colours;
shadow path (`?shadows=1`) renders from the merged meshes; day + night + door tooltip + interiors
all work; **zero console errors**. Files touched: `skins.js`, `buildings.js`, `chunks.js`, `hud.js`,
new `dbg.js`, + a small guarded DBG loader in `index.html`. **Uncommitted** (per the lane treaty;
and Fable is concurrently wiring `index.html` — our edits coexist cleanly on stable anchors).
Owned/created (only `web/index.html` + `web/js/world/*`, per the ownership treaty):
| file | role |
|---|---|
| `web/index.html` | shell: importmap→vendor, renderer + ACES + bloom, MODE state machine (`map`/`street`/`interior`), main loop, adaptive perf, guarded `generatePlan` import, shot harness |
| `web/js/world/fixture_plan.js` | hand-authored CityPlan (schema v1): 4 N + 2 S terraced blocks (26 shops), cross-street houses, 2 market stalls, streets graph — spans ~6 chunks so streaming is exercised |
| `web/js/world/planutil.js` | chunk math (64 m), `chunkIndex`, node/lot indices, oriented-rect collider + `pushOutRect` |
| `web/js/world/skins.js` | city-wide shared material registry (one material per skin), seeded flat-colour fallbacks |
| `web/js/world/buildings.js` | facade kit: instanced shells/parapets/posts/awnings, merged per-skin facades, per-chunk sign atlas, doors/windows |
| `web/js/world/ground.js` | roads/footpaths/kerbs/plaza/base plane, merged strips, UV-baked tiling |
| `web/js/world/furniture.js` | instanced streetlights (emissive at night), benches, bins, gum trees, bus stops — all with primitive fallbacks |
| `web/js/world/lighting.js` | seeded sky dome + 6-segment day/night cycle, one following sun/shadow, fog pop-in mask |
| `web/js/world/player.js` | PointerLock WASD+run, eye 1.7 m, push-out collision vs nearby-chunk colliders |
| `web/js/world/chunks.js` | the streamer: radius-R build / R+1 dispose, nearest-first queue, 4 ms/frame budget, night propagation |
| `web/js/world/hud.js` | crosshair, throttled door-raycast tooltip, live draws/tris/fps/seed/clock readout, click→`procity:enterShop` |
| `web/js/world/minimap.js` | `map` mode: 2D top-down town directory (streets + lots by type + player dot) |
## Acceptance — all met
- ✅ Walk the fixture 5 min: contiguous, no seams, chunks stream silently, day/night cycles, signs
legible, tooltips work, budgets green (148 scene draws, 21 skin materials, 5.8k tris).
- ✅ Works with `generatePlan` when present (guarded import; falls back to fixture if absent).
- ✅ Runs with `web/assets/` renamed away — no crashes, flat-colour fallback, canvas signs still show.
- ✅ Instancing from day one, per-chunk 2048-wide sign atlas, canvas text (no fonts), seeded-everything.
- ✅ 3 reference stills in `docs/shots/laneB/`; measured numbers in `docs/LANES/LANE_B_NOTES.md`.
## Verified live (in-browser)
Streaming (chunks build/dispose as you walk, queue never starves), 6-segment day→night with glowing
shopfront windows + streetlamp bloom at night, door raycast → correct shop → `procity:enterShop` +
toast, pixel-accurate collision (stops at wall player radius), all-fallback mode, and Lane A's full
493-shop city rendering + streaming + arbitrary-angle collision through Lane B.
## Perf (measured — full table in `docs/LANES/LANE_B_NOTES.md`)
- **Fixture** (radius 3, shadows on): ~148 scene draws, low tris, well under all gates.
- **Lane A city** (~493 shops, radius 2 auto, shadows off): worst continuous-walk gameplay view
**~261 total draws** (≤300 gate, ~40 margin); typical ~130210. Skin materials **5** (≤25).
Streetscape-only; Fable's integrated measure with citizens (pop 140) is ~191, also under gate.
## Adversarial multi-agent review → fixes landed
I ran a 4-dimension review (correctness / memory-perf / spec / robustness) with adversarial
verification. Confirmed real bugs, all fixed and re-verified:
1. **Collision was wrong for non-axis-aligned `ry`** (`pushOutRect` used the wrong rotation sign).
Latent on my axis-aligned fixture; would let you walk through Lane A's angled buildings. **Fixed**
verified `keptOut:true` against a `ry=1.32` lot.
2. **House doors** were raycastable and mis-resolved to the nearest shop → clicking a house "entered"
a random shop. **Fixed** (separate non-picked mesh).
3. **Awning skin** used a signed shift → undefined skin for ~⅓ of shops. **Fixed** (`>>>`).
4. **Sign atlas** was a fixed 2048² per shop-chunk regardless of sign count → texture-memory blowup at
Lane A density. **Fixed** (sized to actual signs, mipmaps off).
5. **Furniture**: numeric edge ids broke the seed hash (all edges identical); offset furniture could
land in an unbuilt chunk and vanish. **Both fixed**.
6. **`takeShots()`** didn't restore the pre-shot mode. **Fixed**.
## ⚠️ Things Fable should know (cross-lane)
- **Lane A landed and is now the shipped default.** `index.html` prefers `generatePlan(seed)` over the
fixture. Seed 20261990 → "Boolarra Heads", ~493 shops. The fixture remains the fallback + schema ref.
- **A "Lane F integration fix" already appeared in `skins.js`** (`facadeMat` now strips a `.jpg`/`facade-`
prefix). Lane A stores `shop.facadeSkin` as a **full filename** (`"facade-stucco-pink.jpg"`), not the
bare skin key the CITY_SPEC table implies. That normalize is what makes Lane A's facades load. Kept.
- **Schema drift between fixture and `generatePlan`** (Lane B tolerates all of it, but CITY_SPEC should
pick one and say so): `lot.id`/`lot.block` are strings in the fixture, **numbers** from Lane A;
`lot.frontEdge` is `'N'/'S'` in the fixture, a **number** from Lane A; `shop.facadeSkin` is a bare key
in the fixture, a **filename** from Lane A; `lot.ry` is axis-aligned in the fixture, **arbitrary
radians** from Lane A. Recommend Fable freeze these in CITY_SPEC so Lane C/D/E don't each guess.
- **≤25 skin-materials gate — RESOLVED (session 2).** Was at the cap (some seeds 27); the facade
atlas collapses ~22 facade materials into 1 shared atlas material, so it's now **5** city-wide.
- **Perf gate on the full city — RESOLVED (session 2).** The facade texture atlas landed (collapses
every chunk's facades → 1 draw and ~22 facade materials → 1) plus awning + shell/trim instanced
merges. Worst gameplay view is now ~261 total (≤300). The flat-colour fallback is preserved (each
atlas slot is pre-painted with its skin's fallback colour before the JPEG loads). See LANE_B_NOTES.
## Suggested next instructions (your call)
1. Freeze the CityPlan schema in CITY_SPEC (the drift list above) — unblocks C/D/E. **Still open.**
2. ~~Decide the facade-atlas~~**done (session 2): Lane B implemented it.** Draw + material gates now green.
3. ~~Interiors hand-off~~ — **done: Fable wired `enterShop → interiorMode → buildInterior` in the shell;**
verified via `DBG.enterShop`. Lane B's seam (`procity:enterShop`/`procity:exitShop`) is intact.
4. Run Lane F's `shots.py`/`soak.py` against `window.DBG` now that it exists (bookmarks are seed-derived).
5. Optional: capture fresh `docs/shots/laneB/` stills of the generated city via `DBG.shot(...)`; wire
`map` mode into Lane A's `map.html` (currently my lightweight minimap).
## Run
`cd web && python3 -m http.server 8130` → http://localhost:8130 . (The app needs no server-side logic;
the P-key shot harness uses browser downloads.)

147
docs/LANES/LANE_B_NOTES.md Normal file
View File

@ -0,0 +1,147 @@
# LANE B — NOTES (measured)
Streetscape + game shell. Files: `web/index.html`, `web/js/world/*`, `web/js/world/fixture_plan.js`.
All numbers measured in-browser (Chromium, M-series) via `renderer.info`, 1280×720-ish viewport.
`renderer.info.autoReset` is off and reset once/frame, so **draws/tris are the true per-frame
totals across all passes** (scene + bloom + output). "scene draws" below excludes the ~11
post-processing passes; "total" adds them.
## Perf — fixture (`fixture_plan.js`, "Yarraville Junction", 26 shops, radius 3, shadows on)
| metric | measured | gate | status |
|---|---|---|---|
| draw calls | **148 scene (~159 total)** | ≤ 300 | ✅ |
| triangles | **~5.8k** | ≤ 200k | ✅ |
| skin materials (city-wide, shared) | **21** | ≤ 25 | ✅ |
| live chunks (radius 3) | 12 | — | — |
| chunk build | ~18 ms | 4 ms/frame budget | drained 1/frame |
Walked the fixture end-to-end (~310 m of main street + the backstreets): contiguous, no seams,
chunks stream in/out silently (live count 8↔12 as you move; build queue never starved), day/night
cycles through 6 segments, signs legible, door tooltips + `procity:enterShop` fire, collision
stops the player at wallradius (measured 10.62 m against an 11 m frontage, radius 0.38). Runs with
`web/assets/` renamed away — no crashes, flat-colour fallback town, canvas signs still legible.
## Perf — Lane A integration (`generatePlan`, "Boolarra Heads", ~493 shops, 1 km², radius 2 auto, shadows off)
Lane A's `generatePlan` landed mid-build; `index.html` prefers it over the fixture (guarded import),
so this is the shipped default now. It is a full city with content in nearly every chunk — far
denser than the fixture the ≤300 gate was sized against.
Streetscape numbers below are **buildings + ground + furniture only** (what Lane B owns), measured by
a scripted continuous walk (real streaming + disposal, so ≤25 live chunks — no orphan-chunk inflation).
Lane F's citizens add on top; Fable measures the *integrated* worst main-street view (pop 140) at
**~191 draws**, also under gate.
| metric | measured | gate | status |
|---|---|---|---|
| draw calls (worst gameplay view, continuous walk) | **~261 total (249 scene)** | ≤ 300 | ✅ ~40 draw margin |
| draw calls (typical street view) | **~130210 total** | ≤ 300 | ✅ |
| triangles | 12k25k | ≤ 200k | ✅ (huge headroom) |
| skin materials (shared, city-wide) | **5** | ≤ 25 | ✅ (facade atlas collapsed ~22 facade mats → 1) |
| live chunks (radius 2, walking) | ≤ 25 | — | — |
| chunk build | min ~1 / median ~8 / max ~22 ms | 4 ms/frame | ~1 heavy chunk/frame |
Integration verified: Lane B streams and renders Lane A's 493-shop city correctly — facades, signs,
doors, ground, furniture, day/night all work. Collision holds for the arbitrary lot orientations
`generatePlan` emits (e.g. `ry = 1.32 rad`); see the collision fix below.
### Adaptive settings (index.html)
`BIG_CITY = plan.shops.length > 120`. Big city → **radius 2 + no sun-shadow pass**; fixture → radius
3 + shadows. Override with `?r=N` / `?shadows=0|1`. Dropping the shadow pass ≈ halves draw calls in
dense areas (the shadow map re-renders every caster) for little visual loss in a flat-lit low-poly
town — LANE_B doc explicitly allows "none".
### Draw-budget close-out (this session) — the gate now holds with margin
Fable's first integrated smoke test read **~334 draws at spawn** (that was radius 3, pre-adaptive) and
the deep districts appeared to peak ~445. Two things closed the gap:
1. **Facade texture atlas** (`skins.js` + `buildings.js`). Every facade skin packs into one 2048²
atlas (6×6 slots, each pre-painted with the skin's flat fallback colour, then the JPEG drawn over
it — house law preserved), so a chunk's facades merge into **one** mesh (was ~58 per-skin meshes)
and ~22 facade materials collapse to **1** (this also fixes the "≤25 materials was at the cap /
over for some seeds" concern — now **5** city-wide). UV convention mirrors the sign atlas.
2. **Instanced-mesh merges.** Awnings were one InstancedMesh *per skin* (red/green/blue) per chunk;
now **one per chunk** with per-instance colour (the stripe JPEG lived on the awning's unseen top
face, so flat colour reads the same under the verandah). Shells + parapets + posts merged into
**one** InstancedMesh (per-instance colour) instead of two.
Measured effect (worst dense frustum): **393 → 265 scene draws**; worst *continuous-walk* gameplay
view **~261 total**. Note the earlier "445" figures were partly a **measurement artifact** — teleport-
jumping between distant spots left orphan chunks the real streamer disposes; a proper walk never
exceeds the radius-2 set. Verified day + night, facades still textured, awning colours still varied.
## Architecture decisions worth knowing (for Lane F)
- **Ground is built once, not streamed.** The street graph is a handful of edges; merged flat quads
are a few draws total and can't seam. If Lane A ships a many-hundred-edge network, revisit.
- **Sky/lighting are global**, re-centred on the player each frame (dome radius 650, sun follows).
Linear fog at `(radius+0.4)·64 m` doubles as the chunk pop-in mask.
- **Per-chunk streaming = buildings + furniture only.** `ChunkManager` keeps chunks within Chebyshev
radius R, disposes past R+1, drains a nearest-first build queue under a 4 ms/frame budget. A single
dense chunk can exceed 4 ms (median 8), so it builds ~1/frame → an occasional ~822 ms build frame
entering a dense district (fog-masked). Splitting a chunk build across frames is the next step if
that hitch matters.
- **Night lighting is faked** (Vuntra-style): window + streetlamp **emissive** toggled by a night
flag, plus bloom — no per-streetlight real lights. `ChunkManager.setNight()` re-applies to chunks
streamed in while already night.
- **Shared materials, city-wide** (`skins.js`). Facades share **one atlas material** (all skins in a
2048² atlas); shells + parapets + posts are **one** InstancedMesh/chunk (per-instance colour); all
awning/canopy skins are **one** InstancedMesh/chunk (per-instance colour). Signs are a per-chunk
canvas atlas sized to the chunk's actual sign count, mipmaps off. Net: ~1 draw per kind per chunk.
- **Orientation law** (obeyed by `buildings.toWorld`, the InstancedMesh shells, and `pushOutRect`):
a lot at `ry=0` faces `+Z`; `local→world = RotY(ry)`. Collider and geometry share this convention.
## Fixes applied after an adversarial multi-agent review (this session)
- **Collision for arbitrary `ry`** (`planutil.pushOutRect`): world→local used the wrong rotation sign;
correct only for axis-aligned `ry`. The fixture is axis-aligned so it passed, but Lane A's angled
lots would let you walk through walls. Fixed to the true inverse of `toWorld`. **(Critical for Lane A.)**
- **House doors** were merged into the interactive door mesh with no `doorRects` → raycasting a house
door mis-resolved to the nearest shop. Now a separate non-picked mesh.
- **Awning skin index** used a signed shift (`seed >> 3`) → negative index → undefined skin for ~⅓ of
shops. Now `>>> 3`.
- **Sign atlas** was a fixed 2048² (~16 MB canvas + ~22 MB GPU each) per shop-chunk regardless of
sign count. Now sized to the actual sign count, mipmaps off — critical at Lane A's density.
- **Furniture edge hashing** assumed string edge ids; `generatePlan` uses numeric ids → every edge
got the same seed. Coerced with `String(id)`.
- **Furniture drop:** `chunkIndex` rasterised only the edge centreline, so furniture at a perpendicular
offset could land in an unbuilt chunk and vanish. Rasterisation now covers the full road+verge band.
- **`takeShots()`** never restored the pre-shot mode (P in map mode dumped you into street).
Reviewers' non-issues (intentional): `getColliders` returns a shared scratch array (per-frame
alloc avoidance); per-frame `{clock,chunks}` HUD object (negligible).
## The `shot()` harness
Press **P** → renders 3 fixed cameras and downloads PNGs. Reference stills captured to
`docs/shots/laneB/` (main-street day, plaza overview, night strip). Note: at Lane A's city the fixed
camera poses are tuned for the fixture; re-aim if used against the generated city.
## `window.DBG` — the QA harness hook (this session, for Lane F's shots.py/soak.py)
Loaded only with **`?dbg=1`** (`js/world/dbg.js`, installed by the shell). Answers LANE_F_NOTES §4.
All poses are derived from the **live plan** so bookmarks work on any seed; the day cycle pauses on
the first scripted segment change for deterministic captures.
- `DBG.ready` — true once the first chunks are built + the queue is drained (textures may still decode).
- `DBG.shot(name)` — snaps to a named bookmark, sets its time-of-day, settles bloom, returns stats.
Bookmarks: `street_noon`, `arcade` (dept anchor), `market_square` (stalls), `milkbar_dusk`, `night_neon`.
- `DBG.teleport(x, z, ry)` / `DBG.setSegment(seg 05)` — drive the soak walk + time of day.
- `DBG.enterShop(shopId)` / `DBG.exitShop()` — scripted interior visits (drives Lane F's bridge →
Lane C `buildInterior`; verified end-to-end: `enterShop` → mode `interior`, `exitShop` → back to street).
- `DBG.info()``{ drawCalls, tris, fps, heapMB, geometries, textures, chunk, mode }` (budget/soak read this).
Every bookmark stays under budget (busiest, the dept anchor, ~269 draws). `fps` reads the HUD's
smoothed value (0 under a throttled/background tab; populates in a foreground Playwright run).
## `ChunkManager` lifecycle hooks (optional, LANE_F_NOTES §8)
`createChunkManager` now fires `ctx.onChunkBuilt(key, {cx, cz, buildings, furniture, data})` and
`ctx.onChunkDisposed(key, {cx, cz})` **if a consumer sets them on `ctx`** — a clean per-chunk seam
for spawning/ambient/LOD. Inert by default (the shell doesn't set them; **Lane D's citizens drive off
`plan.streets` instead of chunk lifecycle**, so this is a spare hook, not a dependency).
## Controls
WASD move · shift run · mouse look · click a door · `[` `]` step time-of-day · `T` pause clock ·
`M` map · `P` screenshot · Esc release pointer.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

256
web/index.html Normal file
View File

@ -0,0 +1,256 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>PROCITY — walkable town</title>
<style>
html, body { margin: 0; height: 100%; background: #0c0e0a; overflow: hidden; color: #eee;
font: 14px -apple-system, Segoe UI, Roboto, sans-serif; }
#c { display: block; width: 100vw; height: 100vh; }
#pc-start { position: fixed; inset: 0; z-index: 40; display: flex; flex-direction: column;
align-items: center; justify-content: center; text-align: center; gap: 14px;
background: radial-gradient(circle at 50% 40%, #24281e, #0c0e0a); }
#pc-start h1 { font-size: 34px; margin: 0; letter-spacing: 2px; color: #ffd75e; text-shadow: 0 2px 8px #000; }
#pc-start .sub { opacity: .85; max-width: 420px; line-height: 1.5; }
#pc-start button { pointer-events: auto; cursor: pointer; border: 0; border-radius: 10px;
padding: 12px 26px; font-size: 16px; background: #ffd75e; color: #241d06; font-weight: 700; }
#pc-start .seed { opacity: .55; font-size: 12px; }
#pc-load { position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%); opacity: .7; }
</style>
<script type="importmap">
{ "imports": {
"three": "./vendor/three.module.js",
"three/addons/": "./vendor/addons/"
} }
</script>
</head>
<body>
<canvas id="c"></canvas>
<div id="pc-start">
<h1>PROCITY</h1>
<div class="sub">A procedurally generated, walkable 90s-Australian shopping town.
Every door opens (soon). Walk the strip, watch the day turn.</div>
<button id="pc-go">walk into town</button>
<div class="seed" id="pc-startseed"></div>
<div class="sub" style="font-size:12px;opacity:.7">WASD move · shift run · mouse look · click a door · [ ] time of day · M map · P screenshot</div>
</div>
<script type="module">
import * as THREE from 'three';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
import { fixturePlan } from './js/world/fixture_plan.js';
import { createSkinLibrary } from './js/world/skins.js';
import { buildGround } from './js/world/ground.js';
import { createLighting } from './js/world/lighting.js';
import { createChunkManager } from './js/world/chunks.js';
import { createPlayer } from './js/world/player.js';
import { createHUD } from './js/world/hud.js';
import { createMinimap } from './js/world/minimap.js';
import { createInteriorMode } from './js/world/interior_mode.js'; // [Lane F integration] Lane C interior bridge
import { CitizenSim } from './js/citizens/sim.js'; // [Lane F integration] Lane D street pedestrians
main().catch((err) => {
console.error('[procity] init failed:', err);
const el = document.getElementById('pc-startseed');
if (el) el.textContent = 'INIT ERROR: ' + (err && err.message || err);
});
async function main() {
// ── plan: prefer Lane A's generatePlan if it has landed, else the fixture ──
const params = new URLSearchParams(location.search);
const seed = (parseInt(params.get('seed'), 10) || 20261990) >>> 0;
let plan;
try {
const citygen = await import('./js/citygen/index.js');
const gen = citygen.generatePlan || citygen.default;
plan = gen ? gen(seed) : fixturePlan(seed);
console.log('[procity] using Lane A generatePlan');
} catch (e) {
plan = fixturePlan(seed);
console.log('[procity] Lane A citygen absent — running on fixture_plan');
}
// ── renderer + post ──
// Adaptive to plan density: a large generated city (Lane A) has content in nearly every chunk, so
// a smaller radius + no sun-shadow pass keeps draw calls near the ≤300 gate. The sparse fixture
// gets the fuller radius 3 + shadows (measured ~140 draws). Override via ?r= / ?shadows=0|1.
const BIG_CITY = (plan.shops?.length || 0) > 120;
const RADIUS = parseInt(params.get('r'), 10) || (BIG_CITY ? 2 : 3);
const SHADOWS = params.has('shadows') ? params.get('shadows') !== '0' : !BIG_CITY;
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = SHADOWS;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.info.autoReset = false; // EffectComposer renders many passes/frame; we reset once per frame
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 800);
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.55, 0.5, 0.9);
composer.addPass(bloom);
composer.addPass(new OutputPass());
// ── world systems ──
const skins = createSkinLibrary();
const ground = buildGround(plan, skins);
scene.add(ground.group);
const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS });
lighting.attachRenderer(renderer);
const ctx = { skins, citySeed: plan.citySeed, plan, night: lighting.isNight(), radius: RADIUS };
const chunks = createChunkManager(plan, scene, ctx);
lighting.onNightChange = (night) => { chunks.setNight(night); bloom.strength = night ? 0.9 : 0.55; };
const player = createPlayer(camera, canvas, { getColliders: (x, z) => chunks.getColliders(x, z) });
const hud = createHUD({
camera, renderer, plan,
getDoorMeshes: () => chunks.getDoorMeshes(),
onEnterShop: (shopId, name) => enterShop(shopId, name),
});
const minimap = createMinimap(plan);
// [Lane F integration] interior bridge — owns the 'interior' mode branch (Lane C buildInterior)
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan });
// [Lane F integration] street pedestrians (Lane D). graph = plan.streets directly; no fleet → asset-free
// placeholder/impostor peds. `?pop=` tunable; default kept modest so placeholder-mode near-tier stays
// inside the ≤300 draw budget (the rig-fleet upgrade §3.4 lets this rise). setTimeOfDay/update in the loop.
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets });
citizens.setPopulation((parseInt(params.get('pop'), 10) || 140) >>> 0); // midday ≈96 active; worst main-street view ~191 draws (budget 300)
document.addEventListener('visibilitychange', () => citizens.setPaused(document.hidden));
// spawn on the footpath near the west end of the strip, looking east down main street
player.teleport(-96, 9, -Math.PI / 2);
chunks.warmup(player.position);
lighting.update(0.0001, player.position);
// ── mode state machine: map | street | interior ──
let MODE = 'street';
function setMode(m) {
MODE = m;
hud.setVisible(m === 'street');
minimap.setVisible(m === 'map');
keys.clear();
if (m === 'map') player.unlock(); // [Lane F] interior keeps first-person lock; street re-locks on click
}
// ── door → interior hand-off (Lane B seam → Lane F bridge → Lane C buildInterior) ──
function enterShop(shopId, name) {
window.dispatchEvent(new CustomEvent('procity:enterShop', { detail: { shopId } }));
const shop = plan.shops.find((s) => s.id === shopId);
if (!shop) { hud.showToast(`🚪 ${name}`); return; }
// [Lane F integration] attach lot dims + open Lane C's interior, switch to interior mode
const lot = plan.lots.find((l) => l.id === shop.lot);
interiorMode.enter({ ...shop, lot: lot ? { w: lot.w, d: lot.d } : null }, name);
setMode('interior');
}
function leaveShop() { // [Lane F] dispose the interior + restore the player to the street door
interiorMode.exit();
setMode('street'); // pointer stayed locked while inside, so walking out resumes at once; Esc-exit re-locks on click
hud.showToast('🚪 back on the street');
}
window.addEventListener('procity:exitShop', () => { if (MODE === 'interior') leaveShop(); });
// ── input ──
const keys = new Set();
addEventListener('keydown', (e) => {
if (e.repeat) return;
keys.add(e.code);
if (e.code === 'BracketRight') lighting.stepSegment(1);
else if (e.code === 'BracketLeft') lighting.stepSegment(-1);
else if (e.code === 'KeyP') { if (MODE === 'street') takeShots(); } // [Lane F] street-only
else if (e.code === 'KeyM') { if (MODE !== 'interior') setMode(MODE === 'map' ? 'street' : 'map'); } // [Lane F] not while inside a shop
else if (e.code === 'KeyT') lighting.togglePaused();
});
addEventListener('keyup', (e) => keys.delete(e.code));
addEventListener('blur', () => keys.clear());
const startPanel = document.getElementById('pc-start');
document.getElementById('pc-startseed').textContent = `${plan.name} · seed ${plan.citySeed}`;
document.getElementById('pc-go').addEventListener('click', () => { startPanel.style.display = 'none'; player.lock(); });
player.controls.addEventListener('unlock', () => { keys.clear(); if (MODE === 'interior') leaveShop(); }); // [Lane F] Esc leaves the shop
// click the world to (re)grab the pointer when walking
canvas.addEventListener('click', () => { if (MODE === 'street' && !player.isLocked) player.lock(); });
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
composer.setSize(innerWidth, innerHeight);
});
// ── screenshot harness (P): three fixed cameras → PNG downloads into the browser ──
const SHOTS = [
{ name: 'main-street', pos: [-96, 1.7, 6], look: [0, 1.7, 6] },
{ name: 'plaza', pos: [0, 22, 40], look: [0, 0, 0] },
{ name: 'backstreets', pos: [3, 1.7, 30], look: [3, 1.7, 95] },
];
function takeShots() {
const savePos = camera.position.clone(), saveQuat = camera.quaternion.clone();
const wasMap = MODE; if (MODE !== 'street') setMode('street');
SHOTS.forEach((s, i) => {
camera.position.set(...s.pos); camera.lookAt(new THREE.Vector3(...s.look));
chunks.update(camera.position);
composer.render();
const a = document.createElement('a');
a.download = `procity-laneB-${String(i + 1).padStart(2, '0')}-${s.name}.png`;
a.href = canvas.toDataURL('image/png'); a.click();
});
camera.position.copy(savePos); camera.quaternion.copy(saveQuat);
if (wasMap !== 'street') setMode(wasMap); // restore the mode P was pressed in
hud.showToast('📸 3 shots saved to downloads');
}
// ── main loop ──
const clock = new THREE.Clock();
function frame() {
requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
if (MODE === 'street') {
player.update(dt, player.isLocked ? keys : EMPTY);
chunks.update(player.position);
lighting.update(dt, player.position);
const clk = lighting.getClock();
citizens.setTimeOfDay((clk.seg + clk.frac) / 6); // [Lane F] 0..1 day fraction → density (busy midday, empty at night)
citizens.update(dt); // [Lane F] reads camera pos internally for LOD tiers
hud.tickToast(dt);
hud.update(dt, { clock: clk, chunks: chunks.count }); // reads prev frame's total
renderer.info.reset(); // zero the counters, then let the composer's passes accumulate this frame
composer.render();
} else if (MODE === 'interior') {
if (interiorMode.update(dt, player.isLocked ? keys : EMPTY)) leaveShop(); // [Lane F] walk to the door → back to street
} else if (MODE === 'map') {
camera.getWorldDirection(_fwd);
minimap.draw(player.position, { x: _fwd.x, z: _fwd.z });
}
}
const EMPTY = new Set();
const _fwd = new THREE.Vector3();
frame();
// expose for debugging / Lane F
window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins,
interiorMode, citizens, enterShop, leaveShop, THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
// [Lane B] QA debug harness (window.DBG) — loaded only with ?dbg=1 (LANE_F_NOTES §4). Logic lives
// in js/world/dbg.js; the shell just hands it the systems it owns so shots.py/soak.py can drive it.
if (params.get('dbg')) {
import('./js/world/dbg.js')
.then((m) => m.installDBG({ plan, camera, renderer, composer, chunks, lighting, player, hud,
setMode, getMode: () => MODE, enterShop }))
.catch((e) => console.warn('[procity] DBG harness load failed:', e));
}
} // end main()
</script>
</body>
</html>

362
web/js/world/buildings.js Normal file
View File

@ -0,0 +1,362 @@
// PROCITY Lane B — buildings.js
// The facade kit. Builds ONE chunk's buildings into a small, disposable set of draw calls:
// • InstancedMesh shells + parapets + verandah posts + awning boxes (per-instance matrix/colour)
// • merged facade planes, grouped per skin → one mesh per skin, sharing a city-wide material
// • one 2048² sign atlas per chunk (every sign drawn once, planes UV-mapped into it)
// • merged door / window meshes (windows go emissive at night via applyNight)
// Everything is built in world space and collected, so a whole chunk is ~1018 draw calls.
//
// Canonical building: front face at local +Z, footprint centred at origin, then translate to the
// lot centre (x,z) and rotate by lot.ry (see fixture_plan.js orientation note).
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { rng, pick, frange, irange } from '../core/prng.js';
import { SHOP_REGISTRY } from './fixture_plan.js';
import { lotCollider } from './planutil.js';
const STOREY_H = 3.2;
const FACADE_H = 3.0; // ground-floor shopfront height the skin covers
const PARAPET_H = 0.5;
const AWNING_DEPTH = 2.2; // reach over the footpath
const AWNING_Y = 2.95;
const WALL_TINTS = ['#c9b8a0', '#b0b8ab', '#c4b0b0', '#a8b4c0', '#c4bc9c', '#cab89e', '#b8a890'];
const ROOF_TINT = '#5b5148';
const AWNING_SKINS = ['red', 'green', 'blue'];
const _m4 = new THREE.Matrix4();
const _q = new THREE.Quaternion();
const _up = new THREE.Vector3(0, 1, 0);
const _s = new THREE.Vector3();
const _p = new THREE.Vector3();
// Build a flat quad (two tris) from four world-space corners + a UV rect. Corners CCW seen from +Y-ish.
function quad(c0, c1, c2, c3, uv = [0, 0, 1, 1]) {
const g = new THREE.BufferGeometry();
const pos = new Float32Array([
c0[0], c0[1], c0[2], c1[0], c1[1], c1[2], c2[0], c2[1], c2[2],
c0[0], c0[1], c0[2], c2[0], c2[1], c2[2], c3[0], c3[1], c3[2],
]);
const [u0, v0, u1, v1] = uv;
const uvs = new Float32Array([u0, v0, u1, v0, u1, v1, u0, v0, u1, v1, u0, v1]);
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
g.computeVertexNormals();
return g;
}
// Transform a local (lx,ly,lz) point to world via a lot's (x,z,ry).
function toWorld(lot, lx, ly, lz, out) {
const cos = Math.cos(lot.ry || 0), sin = Math.sin(lot.ry || 0);
out[0] = lot.x + (lx * cos + lz * sin);
out[1] = ly;
out[2] = lot.z + (-lx * sin + lz * cos);
return out;
}
/**
* buildChunkBuildings(chunkData, ctx) chunk building bundle.
* chunkData: { lots:[], shops:[] }
* ctx: { skins, citySeed, night }
* returns { group, colliders, doorMesh|null, doorRects, applyNight, dispose }
*/
export function buildChunkBuildings(chunkData, ctx) {
const { skins } = ctx;
const group = new THREE.Group();
group.name = 'chunk-buildings';
const boxMats = []; // {matrix, color} — shells + parapets + posts, ONE instanced draw/chunk
const awnings = []; // {matrix, color} — all awning/canopy skins, ONE instanced draw/chunk
const facadeGeos = []; // every shopfront facade, UV-mapped into the shared atlas → 1 merged mesh
const doorGeos = []; // interactive shop/stall doors (raycast targets)
const decorDoorGeos = []; // decorative house doors (NOT raycastable → no mis-resolve to a shop)
const windowGeos = [];
const signQuads = []; // { geo }
const colliders = [];
const doorRects = []; // { shopId, name, x, z }
const shopByLot = new Map(chunkData.shops.map((s) => [s.lot, s]));
// ── sign atlas: lay every sign in this chunk into one 2048² canvas ──
// Atlas sized to the chunk's ACTUAL sign count (not a fixed 2048²): one row per 4 signs, so a
// typical chunk holds a ~2048×168 strip, not a 16MB square. Mipmaps off (saves ⅓ GPU + NPOT-safe).
const COLS = 4, CELL_W = 512, CELL_H = 168, ATLAS_W = COLS * CELL_W; // 2048 wide
const N_SIGNS = chunkData.shops.length; // shops+stalls each get one
const ATLAS_ROWS = Math.max(1, Math.ceil(N_SIGNS / COLS));
const ATLAS_H = ATLAS_ROWS * CELL_H;
let atlasCanvas = null, actx = null; // still lazy — shopless chunks allocate nothing
let signCell = 0;
const signUV = new Map(); // shopId → [u0,v0,u1,v1]
function drawSign(shop) {
if (signCell >= COLS * ATLAS_ROWS) return null;
if (!atlasCanvas) {
atlasCanvas = document.createElement('canvas');
atlasCanvas.width = ATLAS_W; atlasCanvas.height = ATLAS_H;
actx = atlasCanvas.getContext('2d');
actx.clearRect(0, 0, ATLAS_W, ATLAS_H);
}
const col = signCell % COLS, row = (signCell / COLS) | 0;
const px = col * CELL_W, py = row * CELL_H;
const reg = SHOP_REGISTRY[shop.type] || SHOP_REGISTRY.opshop;
actx.fillStyle = reg.signBg; actx.fillRect(px + 4, py + 4, CELL_W - 8, CELL_H - 8);
actx.fillStyle = reg.signFg;
actx.strokeStyle = reg.signFg; actx.lineWidth = 3;
actx.strokeRect(px + 10, py + 10, CELL_W - 20, CELL_H - 20);
// fit text
let fs = 74; const label = String(shop.name).slice(0, 22).toUpperCase();
actx.textAlign = 'center'; actx.textBaseline = 'middle';
do { actx.font = `bold ${fs}px Arial, sans-serif`; fs -= 4; }
while (actx.measureText(label).width > CELL_W - 40 && fs > 22);
actx.fillText(label, px + CELL_W / 2, py + CELL_H / 2);
const uv = [px / ATLAS_W, 1 - (py + CELL_H) / ATLAS_H, (px + CELL_W) / ATLAS_W, 1 - py / ATLAS_H];
signUV.set(shop.id, uv);
signCell++;
return uv;
}
// ── per-lot build ──
for (const lot of chunkData.lots) {
const shop = shopByLot.get(lot.id);
if (lot.use === 'house') { buildHouse(lot); continue; }
if (lot.use === 'stall') { buildStall(lot, shop); continue; }
buildShopfront(lot, shop);
}
function buildShopfront(lot, shop) {
const seed = shop ? shop.seed : ctx.citySeed ^ 0x9e37;
const r = rng(seed, 'bld', 0);
const storeys = (shop && shop.storeys) || 1;
const h = storeys * STOREY_H;
const w = lot.w, d = lot.d;
const wallC = new THREE.Color(WALL_TINTS[seed % WALL_TINTS.length]);
// shell (unit box scaled) centred at (x, h/2, z)
_p.set(lot.x, h / 2, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, h, d);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: wallC.clone() });
// parapet cap along the front
_p.set(lot.x, h + PARAPET_H / 2, lot.z); _s.set(w, PARAPET_H, d * 0.6);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(ROOF_TINT) });
// facade plane on the ground-floor front (local +Z face) — UV-mapped into the shared facade atlas
const skin = (shop && shop.facadeSkin) || 'weatherboard';
const fh = Math.min(h, FACADE_H);
const zf = d / 2 + 0.02;
const hw = w * 0.48;
const c0 = toWorld(lot, -hw, 0.02, zf, []);
const c1 = toWorld(lot, hw, 0.02, zf, []);
const c2 = toWorld(lot, hw, fh, zf, []);
const c3 = toWorld(lot, -hw, fh, zf, []);
facadeGeos.push(quad(c0, c1, c2, c3, skins.facadeAtlasUV(skin)));
// door (dark) — centred, on the front, proud of the facade
const dw = 1.2, dh = 2.2, zd = zf + 0.03;
doorGeos.push(quad(
toWorld(lot, -dw / 2, 0.02, zd, []), toWorld(lot, dw / 2, 0.02, zd, []),
toWorld(lot, dw / 2, dh, zd, []), toWorld(lot, -dw / 2, dh, zd, []),
));
if (shop) doorRects.push({ shopId: shop.id, name: shop.name, x: (toWorld(lot, 0, 0, zd, [])[0]), z: toWorld(lot, 0, 0, zd, [])[2] });
// windows flanking the door (tinted glass, emissive at night)
const winW = Math.max(0.7, (w - dw) / 2 - 0.6), winH = 1.5, wy = 1.55, zw = zf + 0.025;
const offs = [-(dw / 2 + 0.4 + winW / 2), (dw / 2 + 0.4 + winW / 2)];
for (const ox of offs) {
windowGeos.push(quad(
toWorld(lot, ox - winW / 2, wy - winH / 2, zw, []), toWorld(lot, ox + winW / 2, wy - winH / 2, zw, []),
toWorld(lot, ox + winW / 2, wy + winH / 2, zw, []), toWorld(lot, ox - winW / 2, wy + winH / 2, zw, []),
));
}
// sign band overlaying the blank signboard region of the skin
if (shop) {
drawSign(shop);
const uv = signUV.get(shop.id);
if (uv) {
const sh = 0.5, sy = fh - 0.35, sw = w * 0.92, zs = zf + 0.05;
signQuads.push(quad(
toWorld(lot, -sw / 2, sy - sh / 2, zs, []), toWorld(lot, sw / 2, sy - sh / 2, zs, []),
toWorld(lot, sw / 2, sy + sh / 2, zs, []), toWorld(lot, -sw / 2, sy + sh / 2, zs, []),
uv,
));
}
}
// posted verandah + awning over the footpath (main-street shopfronts only)
if (lot.use === 'shop' || lot.use === 'anchor') {
const askin = AWNING_SKINS[(seed >>> 3) % AWNING_SKINS.length]; // unsigned: seed is uint32
// awning box: thin slab reaching over the footpath at the front
_p.copy(new THREE.Vector3().fromArray(toWorld(lot, 0, AWNING_Y, d / 2 + AWNING_DEPTH / 2, [])));
_s.set(w, 0.16, AWNING_DEPTH);
awnings.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(skins.awningColor(askin)) });
// two posts at the awning's outer edge
const postY = AWNING_Y / 2;
for (const ox of [-w / 2 + 0.25, w / 2 - 0.25]) {
_p.fromArray(toWorld(lot, ox, postY, d / 2 + AWNING_DEPTH - 0.2, []));
_s.set(0.12, AWNING_Y, 0.12);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#2b2b2b') });
}
}
colliders.push(lotCollider(lot));
}
function buildHouse(lot) {
const seed = (ctx.citySeed ^ (lot.x * 73856093) ^ (lot.z * 19349663)) >>> 0;
const r = rng(seed, 'house', 0);
const h = 2.7, w = lot.w, d = lot.d;
const wallC = new THREE.Color(WALL_TINTS[seed % WALL_TINTS.length]);
_p.set(lot.x, h / 2, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, h, d);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: wallC });
// simple hip roof: a squashed, slightly oversized box on top
_p.set(lot.x, h + 0.5, lot.z); _s.set(w + 0.6, 1.0, d + 0.6);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(ROOF_TINT) });
// front door + two windows (decorative — houses are not enterable, so keep the door out of
// the interactive doorMesh or the HUD raycast would mis-resolve it to the nearest shop)
const zf = d / 2 + 0.02;
decorDoorGeos.push(quad(
toWorld(lot, -0.5, 0.02, zf, []), toWorld(lot, 0.5, 0.02, zf, []),
toWorld(lot, 0.5, 2.0, zf, []), toWorld(lot, -0.5, 2.0, zf, []),
));
for (const ox of [-w / 2 + 1.2, w / 2 - 1.2]) {
windowGeos.push(quad(
toWorld(lot, ox - 0.6, 1.1, zf, []), toWorld(lot, ox + 0.6, 1.1, zf, []),
toWorld(lot, ox + 0.6, 2.0, zf, []), toWorld(lot, ox - 0.6, 2.0, zf, []),
));
}
colliders.push(lotCollider(lot));
}
function buildStall(lot, shop) {
const seed = shop ? shop.seed : 4242;
const w = lot.w, d = lot.d;
// trestle table
_p.set(lot.x, 0.4, lot.z); _q.setFromAxisAngle(_up, lot.ry || 0); _s.set(w, 0.8, d);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#6b4a30') });
// striped canopy (awning skin) above
const askin = AWNING_SKINS[seed % AWNING_SKINS.length];
_p.set(lot.x, 2.2, lot.z); _s.set(w + 0.6, 0.14, d + 0.8);
awnings.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color(skins.awningColor(askin)) });
// corner posts
for (const [ox, oz] of [[-w / 2, -d / 2], [w / 2, -d / 2], [-w / 2, d / 2], [w / 2, d / 2]]) {
_p.fromArray(toWorld(lot, ox, 1.1, oz, [])); _s.set(0.08, 2.2, 0.08);
boxMats.push({ matrix: new THREE.Matrix4().compose(_p, _q, _s), color: new THREE.Color('#3a3a3a') });
}
// interaction plane on the front so you can "enter" the stall
if (shop) {
const zf = d / 2 + 0.02;
doorGeos.push(quad(
toWorld(lot, -w / 2, 0.02, zf, []), toWorld(lot, w / 2, 0.02, zf, []),
toWorld(lot, w / 2, 1.9, zf, []), toWorld(lot, -w / 2, 1.9, zf, []),
));
doorRects.push({ shopId: shop.id, name: shop.name, x: lot.x, z: lot.z });
drawSign(shop);
const uv = signUV.get(shop.id);
if (uv) {
signQuads.push(quad(
toWorld(lot, -w / 2, 2.0, zf + 0.05, []), toWorld(lot, w / 2, 2.0, zf + 0.05, []),
toWorld(lot, w / 2, 2.4, zf + 0.05, []), toWorld(lot, -w / 2, 2.4, zf + 0.05, []),
uv,
));
}
}
colliders.push(lotCollider(lot));
}
// ── finalise instanced meshes ──
const disposables = [];
function makeInstanced(list, material, castShadow) {
if (!list.length) return null;
const geo = new THREE.BoxGeometry(1, 1, 1);
const im = new THREE.InstancedMesh(geo, material, list.length);
for (let i = 0; i < list.length; i++) {
im.setMatrixAt(i, list[i].matrix);
if (list[i].color && im.instanceColor !== undefined) im.setColorAt(i, list[i].color);
}
im.instanceMatrix.needsUpdate = true;
if (im.instanceColor) im.instanceColor.needsUpdate = true;
im.castShadow = !!castShadow; im.receiveShadow = true;
im.frustumCulled = true;
disposables.push(geo);
group.add(im);
return im;
}
makeInstanced(boxMats, skins.shellMaterial(), true); // shells + parapets + posts (per-instance colour)
makeInstanced(awnings, skins.awningMaterial(), true); // every awning/canopy skin (per-instance colour)
// ── finalise the facade: ALL skins merge into ONE mesh via the shared atlas (1 draw/chunk) ──
if (facadeGeos.length) {
const merged = mergeGeometries(facadeGeos, false);
facadeGeos.forEach((g) => g.dispose());
const mesh = new THREE.Mesh(merged, skins.facadeAtlasMaterial());
mesh.receiveShadow = true; mesh.castShadow = false;
disposables.push(merged);
group.add(mesh);
}
// ── doors ──
const doorMat = new THREE.MeshStandardMaterial({ color: 0x2a2018, roughness: 0.7 });
disposables.push(doorMat);
// interactive shop/stall doors — the HUD raycasts this mesh and maps hits to doorRects
let doorMesh = null;
if (doorGeos.length) {
const merged = mergeGeometries(doorGeos, false);
doorGeos.forEach((g) => g.dispose());
doorMesh = new THREE.Mesh(merged, doorMat);
doorMesh.userData.isDoorMesh = true;
doorMesh.userData.doorRects = doorRects;
disposables.push(merged);
group.add(doorMesh);
}
// decorative house doors — same look, but no userData so the HUD never picks them
if (decorDoorGeos.length) {
const merged = mergeGeometries(decorDoorGeos, false);
decorDoorGeos.forEach((g) => g.dispose());
const mesh = new THREE.Mesh(merged, doorMat);
disposables.push(merged);
group.add(mesh);
}
// ── windows (merged, emissive at night) ──
let windowMat = null;
if (windowGeos.length) {
const merged = mergeGeometries(windowGeos, false);
windowGeos.forEach((g) => g.dispose());
windowMat = new THREE.MeshStandardMaterial({
color: 0x28323c, roughness: 0.14, metalness: 0.35,
transparent: true, opacity: 0.36, // subtle glass in day so the facade art shows through
emissive: new THREE.Color(0xffcf94), emissiveIntensity: 0,
});
const mesh = new THREE.Mesh(merged, windowMat);
disposables.push(merged, windowMat);
group.add(mesh);
}
// ── signs (merged, one atlas texture) ──
if (signQuads.length) {
const merged = mergeGeometries(signQuads.map((s) => s.geo || s), false);
signQuads.forEach((s) => (s.geo || s).dispose());
const tex = new THREE.CanvasTexture(atlasCanvas);
tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
tex.generateMipmaps = false; tex.minFilter = THREE.LinearFilter; // ⅓ less GPU mem, NPOT-safe
const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true });
const mesh = new THREE.Mesh(merged, mat);
disposables.push(merged, tex, mat);
group.add(mesh);
}
function applyNight(night) {
if (windowMat) { windowMat.emissiveIntensity = night ? 0.95 : 0; windowMat.opacity = night ? 0.9 : 0.36; }
}
applyNight(!!ctx.night);
function dispose() {
group.traverse((o) => {
if (o.isInstancedMesh) o.dispose && o.dispose(); // frees instanceMatrix/instanceColor
if (o.isMesh || o.isInstancedMesh) o.geometry && o.geometry.dispose && o.geometry.dispose();
});
for (const d of disposables) d.dispose && d.dispose();
group.clear();
}
return { group, colliders, doorMesh, doorRects, applyNight, dispose };
}

132
web/js/world/chunks.js Normal file
View File

@ -0,0 +1,132 @@
// PROCITY Lane B — chunks.js
// The streamer. Around the player it keeps chunks within Chebyshev radius R built and disposes
// anything past R+1, so the world is never rebuilt wholesale. Chunk builds are queued nearest-first
// and drained under a 4ms/frame budget so streaming never hitches. Each chunk aggregates its
// buildings (buildings.js) + furniture (furniture.js); ground/sky/lighting are global.
import { chunkIndex, chunkCoord, chunkKey, parseKey } from './planutil.js';
import { buildChunkBuildings } from './buildings.js';
import { buildChunkFurniture } from './furniture.js';
const BUILD_BUDGET_MS = 4;
export function createChunkManager(plan, scene, ctx) {
const R = ctx.radius ?? 3;
const DISPOSE_R = R + 1;
const index = chunkIndex(plan);
const live = new Map(); // key → { buildings, furniture, colliders, cx, cz }
const doorMeshes = []; // live door meshes (for HUD raycast)
let queue = []; // keys pending build, nearest-first
let queued = new Set();
let lastCx = null, lastCz = null;
let lastBuildMs = 0; // rolling measure for the HUD/notes
const _colliders = []; // scratch, refilled each getColliders()
function buildChunk(key) {
if (live.has(key)) return;
const data = index.get(key);
if (!data) return;
const { cx, cz } = parseKey(key);
const t0 = performance.now();
const buildings = buildChunkBuildings(data, ctx);
const furniture = buildChunkFurniture(data, ctx, cx, cz);
lastBuildMs = performance.now() - t0;
scene.add(buildings.group, furniture.group);
if (buildings.doorMesh) doorMeshes.push(buildings.doorMesh);
live.set(key, { buildings, furniture, colliders: buildings.colliders, cx, cz });
// optional lifecycle hook (Lane D/F per-chunk spawning, ambient, LOD — LANE_F_NOTES §8).
// Inert unless a consumer sets ctx.onChunkBuilt; citizens currently drive off plan.streets instead.
if (ctx.onChunkBuilt) ctx.onChunkBuilt(key, { cx, cz, buildings: buildings.group, furniture: furniture.group, data });
}
function disposeChunk(key) {
const b = live.get(key);
if (!b) return;
if (ctx.onChunkDisposed) ctx.onChunkDisposed(key, { cx: b.cx, cz: b.cz }); // before the groups are freed
scene.remove(b.buildings.group, b.furniture.group);
if (b.buildings.doorMesh) {
const i = doorMeshes.indexOf(b.buildings.doorMesh);
if (i >= 0) doorMeshes.splice(i, 1);
}
b.buildings.dispose();
b.furniture.dispose();
live.delete(key);
}
function recompute(pcx, pcz) {
// dispose anything beyond R+1
for (const key of [...live.keys()]) {
const { cx, cz } = parseKey(key);
if (Math.max(Math.abs(cx - pcx), Math.abs(cz - pcz)) > DISPOSE_R) disposeChunk(key);
}
// enqueue desired-but-missing, nearest first
const want = [];
for (let dz = -R; dz <= R; dz++) {
for (let dx = -R; dx <= R; dx++) {
const key = chunkKey(pcx + dx, pcz + dz);
if (index.has(key) && !live.has(key)) want.push({ key, d: dx * dx + dz * dz });
}
}
want.sort((a, b) => a.d - b.d);
queue = want.map((w) => w.key);
queued = new Set(queue);
}
function drainQueue() {
if (!queue.length) return;
const t0 = performance.now();
while (queue.length && performance.now() - t0 < BUILD_BUDGET_MS) {
const key = queue.shift();
queued.delete(key);
buildChunk(key);
}
}
function update(playerPos) {
const pcx = chunkCoord(playerPos.x), pcz = chunkCoord(playerPos.z);
if (pcx !== lastCx || pcz !== lastCz) { lastCx = pcx; lastCz = pcz; recompute(pcx, pcz); }
drainQueue();
}
// Prebuild everything within radius synchronously (used once at spawn so the player never
// starts inside an unbuilt void).
function warmup(playerPos) {
const pcx = chunkCoord(playerPos.x), pcz = chunkCoord(playerPos.z);
lastCx = pcx; lastCz = pcz;
recompute(pcx, pcz);
while (queue.length) { const key = queue.shift(); queued.delete(key); buildChunk(key); }
}
// Colliders from the player's chunk + its 8 neighbours (refills a scratch array; no per-frame alloc).
function getColliders(x, z) {
const pcx = chunkCoord(x), pcz = chunkCoord(z);
_colliders.length = 0;
for (let dz = -1; dz <= 1; dz++) {
for (let dx = -1; dx <= 1; dx++) {
const b = live.get(chunkKey(pcx + dx, pcz + dz));
if (b) for (let i = 0; i < b.colliders.length; i++) _colliders.push(b.colliders[i]);
}
}
return _colliders;
}
function setNight(night) {
ctx.night = night;
for (const b of live.values()) { b.buildings.applyNight(night); b.furniture.applyNight(night); }
}
function dispose() {
for (const key of [...live.keys()]) disposeChunk(key);
queue = []; queued.clear();
}
return {
update, warmup, getColliders, setNight, dispose,
getDoorMeshes: () => doorMeshes,
get count() { return live.size; },
get pending() { return queue.length; },
get lastBuildMs() { return lastBuildMs; },
};
}

114
web/js/world/dbg.js Normal file
View File

@ -0,0 +1,114 @@
// PROCITY Lane B — dbg.js
// The QA debug harness hook (window.DBG), installed by the shell only with ?dbg=1. It drives Lane
// F's tools/shots.py + tools/soak.py (LANE_F_NOTES §4) and mirrors 90sDJsim's window.DBG.shot()
// contract: named camera bookmarks, teleport, time-of-day, scripted shop entry, and a perf/heap
// readout. Every pose is derived from the LIVE plan so bookmarks work on any seed; the day/night
// cycle is paused on the first scripted segment change so captures are deterministic.
//
// Lives in its own module (not the shell) so it can grow without touching index.html — the shell
// just calls installDBG(deps) with the systems it owns when ?dbg=1 is present.
export function installDBG(deps) {
const { plan, camera, renderer, composer, chunks, lighting, player, hud,
setMode, getMode, enterShop } = deps;
const CS = 64;
// reset the perf counters, then render one full composited frame (bloom + output) so the harness
// captures exactly this frame and renderer.info reflects it — robust even when rAF is throttled.
const render = () => { renderer.info.reset(); composer.render(); };
const info = () => {
const r = renderer.info;
return {
drawCalls: r.render.calls, tris: r.render.triangles,
fps: hud && hud.getFps ? hud.getFps() : null,
heapMB: (performance.memory ? +(performance.memory.usedJSHeapSize / 1048576).toFixed(1) : null),
geometries: r.memory.geometries, textures: r.memory.textures,
chunk: `${Math.round(player.position.x / CS)},${Math.round(player.position.z / CS)}`,
chunks: chunks.count, mode: getMode(),
};
};
// ── camera pose facing a shop's front ──
// Front normal = local +Z rotated by lot.ry; the shopfront sits d/2 out from the lot centre, so we
// stand `dist` m IN FRONT OF THE SHOPFRONT (not the centre — deep lots like the dept anchor would
// otherwise put the camera inside the facade), offset slightly for a 3/4 angle, looking at it.
const lotOf = (shop) => plan.lots?.find((l) => l.id === shop.lot);
const poseForShop = (shop, dist = 10) => {
const lot = shop && lotOf(shop); if (!lot) return null;
const ry = lot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry); // front normal
const rx = Math.cos(ry), rz = -Math.sin(ry); // right vector
const half = (lot.d || 8) / 2, off = 1.0; // near dead-on (terraced neighbours flank the frame)
const frontX = lot.x + fx * half, frontZ = lot.z + fz * half; // shopfront centre
return {
pos: [frontX + fx * dist + rx * off, 2.0, frontZ + fz * dist + rz * off],
look: [frontX, 2.2, frontZ],
};
};
const byDist = () => [...(plan.shops || [])].sort((a, b) => {
const la = lotOf(a), lb = lotOf(b);
return Math.hypot(la?.x || 0, la?.z || 0) - Math.hypot(lb?.x || 0, lb?.z || 0);
});
// nearest shop matching pred, else nearest shop overall → a pose is always produced
const pickShop = (pred) => { const s = byDist(); return poseForShop(s.find(pred) || s[0]); };
// Named bookmarks (LANE_F_NOTES §4). seg = day segment index (0 DAWN … 5 NIGHT).
// Predicates cover both Lane A's taxonomy (dept/stall/record/…) and the fixture's; each falls back
// to the nearest shop, so every bookmark always yields a valid street pose.
const BOOKMARKS = {
street_noon: { seg: 2, resolve: () => pickShop(() => true) },
arcade: { seg: 3, resolve: () => pickShop((s) => s.type === 'dept' || /arcade/.test(s.type)) },
market_square: { seg: 1, resolve: () => pickShop((s) => s.type === 'stall' || s.type === 'market') },
milkbar_dusk: { seg: 4, resolve: () => pickShop((s) => s.type === 'milkbar') },
night_neon: { seg: 5, resolve: () => pickShop((s) => s.type === 'record' || s.type === 'video') },
};
const DBG = {
// true once the initial chunks are built and the queue is drained (textures may still decode)
get ready() { return chunks.count > 0 && chunks.pending === 0; },
bookmarks: Object.keys(BOOKMARKS),
info,
// time-of-day (hands the day cycle to the harness so captures are deterministic)
setSegment(seg) { lighting.setPaused(true); lighting.setSegment(seg | 0); return lighting.getClock(); },
// drive the soak walk: place the player and stream the destination in before the next step/shot
teleport(x, z, ry = 0) {
if (getMode() !== 'street') setMode('street');
player.teleport(x, z, ry);
chunks.warmup(player.position);
render();
return info();
},
// snap to a named bookmark, settle, and return the frame's stats
shot(name) {
const bm = BOOKMARKS[name] || BOOKMARKS.street_noon;
const pose = bm.resolve();
if (getMode() !== 'street') setMode('street');
if (bm.seg != null) { lighting.setPaused(true); lighting.setSegment(bm.seg); }
if (pose) {
player.teleport(pose.pos[0], pose.pos[2], 0);
camera.position.set(pose.pos[0], pose.pos[1], pose.pos[2]);
chunks.warmup(camera.position);
camera.lookAt(pose.look[0], pose.look[1], pose.look[2]);
camera.updateMatrixWorld();
}
render(); render(); // two frames so bloom + the segment change settle before capture
return { name, ...info() };
},
// scripted interior visit (drives the record_interior shot + the soak memory-baseline gate)
enterShop(shopId) {
const s = (plan.shops || []).find((x) => x.id === shopId);
if (!s) return { error: `no shop ${shopId}` };
enterShop(s.id, s.name);
return { entered: s.id, name: s.name };
},
exitShop() { window.dispatchEvent(new CustomEvent('procity:exitShop')); return { exited: true }; },
};
window.DBG = DBG;
console.log('[procity] DBG harness ready (?dbg=1) — shot/teleport/setSegment/enterShop/exitShop/info/ready');
return DBG;
}

View File

@ -0,0 +1,211 @@
// 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;

192
web/js/world/furniture.js Normal file
View File

@ -0,0 +1,192 @@
// PROCITY Lane B — furniture.js
// Instanced street furniture along the footpaths: streetlights (emissive lamp at night), benches,
// bins, gum trees (crossed-plane billboards), bus stops. Seeded placement, one InstancedMesh per
// type per chunk so a chunk's furniture is ~6 draw calls and disposes cleanly with the chunk.
//
// Every type has a PRIMITIVE fallback (house law) — the town is fully furnished with zero GLBs.
// A 3GOD-depot upgrade path (promise-cached loadGLB → hot-swap) is documented at the bottom but
// left OFF for v1 so async loads can never write into an unloaded chunk.
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { rng, frange } from '../core/prng.js';
import { chunkOf, resolveEdges } from './planutil.js';
const FOOT = 3.5;
// ── shared primitive templates (built once, reused by every chunk; never disposed per-chunk) ──
let _tpl = null;
function templates() {
if (_tpl) return _tpl;
const box = (w, h, d, x = 0, y = 0, z = 0) => {
const g = new THREE.BoxGeometry(w, h, d); g.translate(x, y, z); return g;
};
// streetlight pole + arm (dark). Lamp head is separate (emissive).
const pole = mergeGeometries([
box(0.12, 4.2, 0.12, 0, 2.1, 0),
box(0.09, 0.09, 1.1, 0, 4.1, 0.55),
], false);
const lamp = box(0.34, 0.18, 0.34, 0, 4.05, 1.05);
// bench: seat + back + two legs
const bench = mergeGeometries([
box(1.6, 0.08, 0.45, 0, 0.45, 0),
box(1.6, 0.5, 0.06, 0, 0.72, -0.2),
box(0.08, 0.45, 0.4, -0.7, 0.22, 0),
box(0.08, 0.45, 0.4, 0.7, 0.22, 0),
], false);
// bin: short cylinder
const bin = new THREE.CylinderGeometry(0.28, 0.24, 0.9, 10); bin.translate(0, 0.45, 0);
// bus stop shelter: two posts + flat roof + a small bench
const busstop = mergeGeometries([
box(0.1, 2.3, 0.1, -1.3, 1.15, -0.5), box(0.1, 2.3, 0.1, 1.3, 1.15, -0.5),
box(3.0, 0.12, 1.4, 0, 2.35, 0),
box(2.4, 0.08, 0.4, 0, 0.5, -0.4),
], false);
// gum tree: crossed alpha-textured planes
const treeGeo = mergeGeometries([
new THREE.PlaneGeometry(3.2, 4.4).translate(0, 2.2, 0),
new THREE.PlaneGeometry(3.2, 4.4).rotateY(Math.PI / 2).translate(0, 2.2, 0),
], false);
_tpl = { pole, lamp, bench, bin, busstop, treeGeo };
return _tpl;
}
// tree billboard texture (canvas — no font files, house law)
let _treeTex = null;
function treeTexture() {
if (_treeTex) return _treeTex;
const c = document.createElement('canvas'); c.width = c.height = 128;
const x = c.getContext('2d');
x.clearRect(0, 0, 128, 128);
x.fillStyle = '#6a5a44'; x.fillRect(58, 74, 12, 54); // trunk
const blobs = [[64, 44, 34], [44, 56, 24], [84, 56, 24], [64, 30, 22]];
for (const [bx, by, br] of blobs) {
x.fillStyle = ['#5c7a3e', '#6b8a48', '#4f6d38'][(bx + by) % 3];
x.beginPath(); x.arc(bx, by, br, 0, Math.PI * 2); x.fill();
}
_treeTex = new THREE.CanvasTexture(c); _treeTex.colorSpace = THREE.SRGBColorSpace;
return _treeTex;
}
// ── shared materials (module-level; not disposed per chunk) ──
let _mats = null;
function materials() {
if (_mats) return _mats;
_mats = {
dark: new THREE.MeshStandardMaterial({ color: 0x33322f, roughness: 0.7, metalness: 0.2 }),
lamp: new THREE.MeshStandardMaterial({ color: 0x2a2a26, roughness: 0.5, emissive: new THREE.Color(0xffd9a0), emissiveIntensity: 0 }),
wood: new THREE.MeshStandardMaterial({ color: 0x6b4a30, roughness: 0.8 }),
bin: new THREE.MeshStandardMaterial({ color: 0x2f4a3a, roughness: 0.8 }),
shelter: new THREE.MeshStandardMaterial({ color: 0x8a8f96, roughness: 0.7, metalness: 0.2 }),
tree: new THREE.MeshBasicMaterial({ map: treeTexture(), transparent: true, alphaTest: 0.5, side: THREE.DoubleSide }),
};
return _mats;
}
const _m = new THREE.Matrix4();
const _p = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _s = new THREE.Vector3(1, 1, 1);
const _up = new THREE.Vector3(0, 1, 0);
/**
* buildChunkFurniture(chunkData, ctx, cx, cz) { group, applyNight, dispose }
* ctx: { plan, citySeed, night }
* Places furniture along every street edge, keeping only items whose position lands in this chunk.
*/
export function buildChunkFurniture(chunkData, ctx, cx, cz) {
const group = new THREE.Group();
group.name = 'chunk-furniture';
const t = templates(), mat = materials();
const edges = ctx._edges || (ctx._edges = resolveEdges(ctx.plan));
const lists = { pole: [], lamp: [], bench: [], bin: [], busstop: [], tree: [] };
const here = (x, z) => { const c = chunkOf(x, z); return c.cx === cx && c.cz === cz; };
const pushYaw = (list, x, y, z, yaw, sx = 1, sy = 1, sz = 1) => {
_p.set(x, y, z); _q.setFromAxisAngle(_up, yaw); _s.set(sx, sy, sz);
list.push(_m.clone().compose(_p, _q, _s));
};
for (const e of edges) {
const dx = e.bx - e.ax, dz = e.bz - e.az;
const len = Math.hypot(dx, dz) || 1;
const ux = dx / len, uz = dz / len; // along
const px = -uz, pz = ux; // perp (unit)
const yaw = Math.atan2(ux, uz); // face along +Z→dir
const r = rng(ctx.citySeed, 'furn', hashEdge(e.id));
const halfRoad = e.width / 2;
// streetlights every ~18m, alternating sides, near the kerb
let side = 1;
for (let s = 8; s < len - 4; s += 18) {
const off = (halfRoad + 0.7) * side;
const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
if (here(x, z)) {
const armYaw = side > 0 ? yaw + Math.PI : yaw; // arm reaches over the road
pushYaw(lists.pole, x, 0, z, armYaw);
pushYaw(lists.lamp, x, 0, z, armYaw);
}
side = -side;
}
// benches + bins every ~26m on the building side of the footpath, facing the road
for (let s = 14; s < len - 6; s += 26) {
const bside = (Math.floor(s / 26) % 2) ? 1 : -1;
const off = (halfRoad + FOOT - 0.8) * bside;
const bx = e.ax + ux * s + px * off, bz = e.az + uz * s + pz * off;
if (here(bx, bz)) pushYaw(lists.bench, bx, 0, bz, yaw + (bside > 0 ? Math.PI : 0));
const nx = bx + ux * 1.4, nz = bz + uz * 1.4;
if (here(nx, nz)) pushYaw(lists.bin, nx, 0, nz, 0);
}
// gum trees only along side/lane verges (main street has no verge room)
if (e.kind === 'side' || e.kind === 'lane') {
for (let s = 6; s < len - 4; s += 22) {
for (const vs of [1, -1]) {
const off = (halfRoad + FOOT + 1.2) * vs;
const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
if (here(x, z)) pushYaw(lists.tree, x, 0, z, frange(r, 0, Math.PI), 1, 1 + r() * 0.3, 1);
}
}
}
// one bus stop near each main-street edge's near end
if (e.kind === 'main') {
const s = 10, off = (halfRoad + FOOT - 0.9);
const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
if (here(x, z)) pushYaw(lists.busstop, x, 0, z, yaw + Math.PI);
}
}
const ims = [];
const emit = (geo, material, list, cast = true) => {
if (!list.length) return;
const im = new THREE.InstancedMesh(geo, material, list.length);
for (let i = 0; i < list.length; i++) im.setMatrixAt(i, list[i]);
im.instanceMatrix.needsUpdate = true;
im.castShadow = cast; im.receiveShadow = false; im.frustumCulled = true;
group.add(im); ims.push(im);
};
emit(t.pole, mat.dark, lists.pole);
emit(t.lamp, mat.lamp, lists.lamp, false);
emit(t.bench, mat.wood, lists.bench);
emit(t.bin, mat.bin, lists.bin);
emit(t.busstop, mat.shelter, lists.busstop);
emit(t.treeGeo, mat.tree, lists.tree, false);
function applyNight(night) { mat.lamp.emissiveIntensity = night ? 1.4 : 0; }
applyNight(!!ctx.night);
function dispose() {
for (const im of ims) { im.dispose && im.dispose(); group.remove(im); }
// shared templates/materials intentionally NOT disposed (reused by other chunks)
group.clear();
}
return { group, applyNight, dispose };
}
function hashEdge(id) { const s = String(id); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h >>> 0; } // String(): generatePlan uses numeric edge ids
// ── OPTIONAL 3GOD-depot upgrade (documented, off for v1) ──────────────────────────────────────
// import { loadGLB } from '../core/loaders.js';
// To swap a primitive for a real GLB prop: loadGLB('depot:bench.glb').then(g => { if(!g) return;
// ... build an InstancedMesh from g.scene's merged geometry and replace the primitive one ... });
// Guard against the chunk having been disposed before the promise resolves (check a live flag).

104
web/js/world/ground.js Normal file
View File

@ -0,0 +1,104 @@
// PROCITY Lane B — ground.js
// Roads, footpaths, kerbs, the market plaza, and a base ground plane, built ONCE as merged strips
// per material (not per-chunk streamed). Rationale: the street graph is a handful of edges; merged
// flat quads are a few draw calls total and — unlike per-chunk road clipping — can never seam.
// UV tiling is baked into the geometry so one shared ground material serves strips of any length.
// (If Lane A ships a many-hundred-edge city, Lane F revisits streaming the ground.)
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { resolveEdges } from './planutil.js';
const FOOT = 3.5; // footpath width
const TILE = 5; // metres per texture tile
const OVERLAP = 1.5; // road segments overrun their nodes so intersections fill
// Horizontal quad in XZ centred at (cx,cz), aligned to unit dir/perp, size len×wid, at height y.
// UVs baked to tile every TILE metres.
function hQuad(cx, cz, dir, perp, len, wid, y) {
const hl = len / 2, hw = wid / 2;
const ax = dir.x * hl, az = dir.z * hl; // along
const bx = perp.x * hw, bz = perp.z * hw; // across
const P = (sa, sb) => [cx + sa * ax + sb * bx, y, cz + sa * az + sb * bz];
const c0 = P(-1, -1), c1 = P(1, -1), c2 = P(1, 1), c3 = P(-1, 1);
const repU = Math.max(1, len / TILE), repV = Math.max(1, wid / TILE);
const g = new THREE.BufferGeometry();
// winding c0,c2,c1 / c0,c3,c2 so the geometric normal points +Y (up) — FrontSide stays visible.
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array([
...c0, ...c2, ...c1, ...c0, ...c3, ...c2]), 3));
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([
0, 0, repU, repV, repU, 0, 0, 0, 0, repV, repU, repV]), 2));
g.setAttribute('normal', new THREE.BufferAttribute(new Float32Array([
0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]), 3));
return g;
}
// Axis-aligned rectangle helper (plaza, base plane).
function rectGeo(cx, cz, w, d, y, tile = TILE) {
return hQuad(cx, cz, new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 1), w, d, y);
}
export function buildGround(plan, skins) {
const group = new THREE.Group();
group.name = 'ground';
const edges = resolveEdges(plan);
const roadGeos = [], footGeos = [], kerbGeos = [];
const dir = new THREE.Vector3(), perp = new THREE.Vector3();
for (const e of edges) {
const dx = e.bx - e.ax, dz = e.bz - e.az;
const len = Math.hypot(dx, dz) || 1;
dir.set(dx / len, 0, dz / len);
perp.set(-dir.z, 0, dir.x);
const cx = (e.ax + e.bx) / 2, cz = (e.az + e.bz) / 2;
const roadLen = len + OVERLAP * 2;
roadGeos.push(hQuad(cx, cz, dir, perp, roadLen, e.width, 0.0));
// footpaths flank the road
const off = e.width / 2 + FOOT / 2;
footGeos.push(hQuad(cx + perp.x * off, cz + perp.z * off, dir, perp, roadLen, FOOT, 0.02));
footGeos.push(hQuad(cx - perp.x * off, cz - perp.z * off, dir, perp, roadLen, FOOT, 0.02));
// low kerbs between road and footpath
const koff = e.width / 2 + 0.12;
kerbGeos.push(hQuad(cx + perp.x * koff, cz + perp.z * koff, dir, perp, roadLen, 0.24, 0.06));
kerbGeos.push(hQuad(cx - perp.x * koff, cz - perp.z * koff, dir, perp, roadLen, 0.24, 0.06));
}
// market plaza (brickpave) over the central square
const sq = (plan.districts || []).find((d) => d.kind === 'market');
const plazaGeos = [];
if (sq) plazaGeos.push(rectGeo(0, 0, 46, 46, 0.015));
// base ground plane under the whole town (grass/dirt), sized to the plan extent
let minX = -60, maxX = 60, minZ = -60, maxZ = 60;
for (const n of plan.streets.nodes) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minZ = Math.min(minZ, n.z); maxZ = Math.max(maxZ, n.z); }
for (const l of plan.lots) { minX = Math.min(minX, l.x); maxX = Math.max(maxX, l.x); minZ = Math.min(minZ, l.z); maxZ = Math.max(maxZ, l.z); }
const bw = (maxX - minX) + 120, bd = (maxZ - minZ) + 120;
const bcx = (minX + maxX) / 2, bcz = (minZ + maxZ) / 2;
const baseGeo = rectGeo(bcx, bcz, bw, bd, -0.03);
const meshes = [];
const add = (geos, material, receive = true) => {
if (!geos.length) return;
const merged = mergeGeometries(geos, false);
geos.forEach((g) => g.dispose());
const m = new THREE.Mesh(merged, material);
m.receiveShadow = receive; m.castShadow = false;
group.add(m); meshes.push(m);
};
const roadSkin = 'djsim-road', footSkin = 'djsim-footpath', grassSkin = 'grass', plazaSkin = 'brickpave';
add([baseGeo], skins.groundMat(grassSkin));
add(roadGeos, skins.groundMat(roadSkin));
add(footGeos, skins.groundMat(footSkin));
add(plazaGeos, skins.groundMat(plazaSkin));
const kerbMat = new THREE.MeshStandardMaterial({ color: 0x9a948c, roughness: 0.9 });
add(kerbGeos, kerbMat);
function dispose() {
for (const m of meshes) m.geometry.dispose();
kerbMat.dispose();
group.clear();
}
return { group, dispose };
}

120
web/js/world/hud.js Normal file
View File

@ -0,0 +1,120 @@
// PROCITY Lane B — hud.js
// The street HUD: crosshair, shop-name tooltip (throttled centre-screen raycast, far = 6m),
// time-of-day + seed + live perf readout (renderer.info: draw calls & triangles), and the
// door-click → `procity:enterShop` hand-off. Creates its own DOM overlay so index.html stays thin.
import * as THREE from 'three';
const CSS = `
#pc-hud{position:fixed;inset:0;pointer-events:none;font:13px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#f4efe6;z-index:10}
#pc-cross{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px 0 0 -3px;border-radius:50%;background:#fff;opacity:.7;box-shadow:0 0 3px #000}
#pc-tip{position:absolute;left:50%;top:calc(50% + 22px);transform:translateX(-50%);background:rgba(20,16,10,.82);padding:6px 12px;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity .12s;text-shadow:0 1px 2px #000}
#pc-dbg{position:absolute;right:10px;top:10px;text-align:right;background:rgba(20,16,10,.55);padding:8px 11px;border-radius:8px;min-width:150px}
#pc-dbg b{color:#ffd75e;font-weight:600}
#pc-dbg .warn{color:#ff9a6a}
#pc-clock{font-size:15px;margin-bottom:3px}
#pc-toast{position:absolute;left:50%;bottom:64px;transform:translateX(-50%);background:rgba(20,16,10,.9);padding:10px 18px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;text-shadow:0 1px 2px #000}
#pc-help{position:absolute;left:12px;bottom:12px;background:rgba(20,16,10,.5);padding:7px 11px;border-radius:8px;opacity:.85}
`;
export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }) {
if (!document.getElementById('pc-hud-style')) {
const st = document.createElement('style'); st.id = 'pc-hud-style'; st.textContent = CSS;
document.head.appendChild(st);
}
const root = document.createElement('div'); root.id = 'pc-hud';
root.innerHTML = `
<div id="pc-cross"></div>
<div id="pc-tip"></div>
<div id="pc-dbg">
<div id="pc-clock"></div>
<div>town <b id="pc-town"></b></div>
<div>seed <b id="pc-seed"></b></div>
<div>fps <b id="pc-fps"></b></div>
<div>draws <b id="pc-draws"></b> · tris <b id="pc-tris"></b></div>
<div>chunks <b id="pc-chunks"></b></div>
</div>
<div id="pc-toast"></div>
<div id="pc-help">WASD move · shift run · mouse look · click a door · [ ] time · P shot · Esc release</div>`;
document.body.appendChild(root);
const $ = (id) => root.querySelector('#' + id);
const elTip = $('pc-tip'), elClock = $('pc-clock'), elFps = $('pc-fps'), elDraws = $('pc-draws'),
elTris = $('pc-tris'), elChunks = $('pc-chunks'), elToast = $('pc-toast');
$('pc-town').textContent = plan.name || '—';
$('pc-seed').textContent = plan.citySeed;
const ray = new THREE.Raycaster();
ray.far = 6;
const centre = new THREE.Vector2(0, 0);
let hovered = null; // { shopId, name }
let frame = 0, fpsAcc = 0, fpsCount = 0, fpsShown = 0;
function nearestDoor(rects, point) {
let best = null, bd = Infinity;
for (const r of rects) {
const d = (r.x - point.x) ** 2 + (r.z - point.z) ** 2;
if (d < bd) { bd = d; best = r; }
}
return best;
}
function raycastDoor() {
const meshes = getDoorMeshes ? getDoorMeshes() : [];
if (!meshes.length) { hovered = null; return; }
ray.setFromCamera(centre, camera);
const hits = ray.intersectObjects(meshes, false);
if (hits.length) {
const h = hits[0];
const rects = h.object.userData.doorRects || [];
const door = nearestDoor(rects, h.point);
hovered = door ? { shopId: door.shopId, name: door.name } : null;
} else {
hovered = null;
}
}
function update(dt, info) {
// fps (smoothed over ~0.4s)
fpsAcc += dt; fpsCount++;
if (fpsAcc >= 0.4) { fpsShown = Math.round(fpsCount / fpsAcc); fpsAcc = 0; fpsCount = 0; }
frame++;
if (frame % 6 === 0) raycastDoor();
// tooltip
if (hovered) { elTip.textContent = `🚪 ${hovered.name} — click to enter`; elTip.style.opacity = 1; }
else elTip.style.opacity = 0;
// debug readout
if (info) {
if (info.clock) elClock.textContent = `${info.clock.label} ${info.clock.hour}${info.clock.night ? ' 🌙' : ''}`;
elFps.textContent = fpsShown;
const draws = renderer.info.render.calls, tris = renderer.info.render.triangles;
elDraws.innerHTML = draws > 300 ? `<span class="warn">${draws}</span>` : draws;
elTris.innerHTML = tris > 200000 ? `<span class="warn">${(tris / 1000 | 0)}k</span>` : `${(tris / 1000 | 0)}k`;
if (info.chunks != null) elChunks.textContent = info.chunks;
}
}
let toastT = 0;
function showToast(msg, secs = 2.6) {
elToast.textContent = msg; elToast.style.opacity = 1; toastT = secs;
}
function tickToast(dt) { if (toastT > 0) { toastT -= dt; if (toastT <= 0) elToast.style.opacity = 0; } }
// door click → enter shop (only when hovering one)
function onClick() {
if (hovered && onEnterShop) onEnterShop(hovered.shopId, hovered.name);
}
renderer.domElement.addEventListener('click', onClick);
function setVisible(v) { root.style.display = v ? 'block' : 'none'; }
function dispose() {
renderer.domElement.removeEventListener('click', onClick);
root.remove();
}
return { update, tickToast, showToast, setVisible, dispose, getHovered: () => hovered,
getFps: () => fpsShown };
}

113
web/js/world/lighting.js Normal file
View File

@ -0,0 +1,113 @@
// PROCITY Lane B — lighting.js
// Sky dome (seeded sky-*.jpg on a BackSide sphere) + a 6-segment day/night cycle ported from
// 90sDJsim's applyLighting: one hemisphere ambient + one directional sun (single shadow map that
// follows the player) + a night flag that drives window/lamp emissives (via a callback into the
// ChunkManager). Linear fog tuned to the streaming radius doubles as the pop-in mask.
//
// Global, not chunked. The sky dome and sun re-centre on the player each frame so the world
// never runs out from under you.
import * as THREE from 'three';
// [name, sunI, sunColor, hemiSky, hemiGround, hemiI, skyTint, fogColor, exposure, night, sunOffset]
const SEG = [
['DAWN', 0.75, 0xffd9b0, 0xbcd0e8, 0x8a7a60, 0.75, 0xdcdce8, 0xcfc6ba, 1.05, false, [80, 26, 14]],
['MORNING', 1.20, 0xffe8c8, 0xcfe0f2, 0x9a8a6c, 0.95, 0xffffff, 0xd6d6cc, 1.00, false, [50, 46, 22]],
['MIDDAY', 1.55, 0xffffff, 0xd8e8ff, 0xa89878, 1.10, 0xffffff, 0xe0e2da, 0.98, false, [12, 72, 12]],
['ARVO', 1.15, 0xffdca0, 0xc8d4e8, 0xa08858, 1.00, 0xf2e8d8, 0xe0cdb0, 1.02, false, [-42, 46, 20]],
['DUSK', 0.55, 0xff9860, 0x9a86b0, 0x6a4a3a, 0.72, 0xb89a8a, 0xa07860, 1.10, false, [-74, 16, 10]],
['NIGHT', 0.10, 0x8faaff, 0x2a3050, 0x14100c, 0.38, 0x1b2038, 0x0a0c18, 1.18, true, [-40, 66, 22]],
];
const HOUR = ['06:30', '09:00', '12:30', '15:30', '18:30', '22:00'];
export function createLighting(scene, plan, skins, { radius = 3, shadows = true } = {}) {
const group = new THREE.Group();
group.name = 'lighting';
scene.add(group);
// sky dome — seeded photo skin, darkened at night via material colour
const skyName = plan.sky || 'golden-arvo';
const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false });
skins.skyTex(skyName, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat);
group.add(dome);
const hemi = new THREE.HemisphereLight(0xd8e8ff, 0xa89878, 1.0);
group.add(hemi);
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
sun.castShadow = shadows; // dense cities skip the shadow pass (≈halves draw calls; doc allows "none")
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.bias = -0.0004;
const SH = 60; // half-extent of the shadow frustum around the player
Object.assign(sun.shadow.camera, { left: -SH, right: SH, top: SH, bottom: -SH, near: 1, far: 260 });
sun.shadow.camera.updateProjectionMatrix();
group.add(sun, sun.target);
// fog fades distant chunks out just before the streaming radius, hiding pop-in
const fog = new THREE.Fog(0xd6d6cc, 40, (radius + 0.4) * 64);
scene.fog = fog;
const _skyTint = new THREE.Color();
const _fogTint = new THREE.Color();
let seg = 2, night = false;
let clock = seg + 0.001, paused = false;
const DAY_SECONDS = 240; // one full cycle
let onNightChange = null;
let renderer = null;
function apply(i) {
const [, sunI, sunC, hSky, hGnd, hI, skyTint, fogC, exposure, isNight] = SEG[i];
sun.intensity = sunI; sun.color.set(sunC);
hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd);
_skyTint.set(skyTint); skyMat.color.copy(_skyTint);
_fogTint.set(fogC); fog.color.copy(_fogTint);
scene.background = _fogTint.clone(); // horizon colour behind the (finite) dome
if (renderer) renderer.toneMappingExposure = exposure;
const was = night; night = isNight; seg = i;
if (isNight !== was && onNightChange) onNightChange(isNight);
}
function setSegment(i) { clock = ((i % SEG.length) + SEG.length) % SEG.length + 0.001; apply(i); }
function stepSegment(d) { setSegment((seg + d + SEG.length) % SEG.length); }
const _sunOff = new THREE.Vector3();
function update(dt, playerPos) {
if (!paused) {
clock = (clock + (dt / DAY_SECONDS) * SEG.length) % SEG.length;
const i = Math.floor(clock);
if (i !== seg) apply(i);
}
// re-centre dome + sun on the player
dome.position.copy(playerPos);
const off = SEG[seg][10];
_sunOff.set(off[0], off[1], off[2]).multiplyScalar(0.8);
sun.position.copy(playerPos).add(_sunOff);
sun.target.position.copy(playerPos);
sun.target.updateMatrixWorld();
}
function getClock() {
const frac = clock - Math.floor(clock);
return { seg, label: SEG[seg][0], hour: HOUR[seg], night, frac };
}
function attachRenderer(r) { renderer = r; r.toneMappingExposure = SEG[seg][8]; }
function dispose() {
scene.fog = null;
dome.geometry.dispose(); skyMat.dispose();
scene.remove(group);
}
apply(seg);
return {
group, sun, hemi,
update, setSegment, stepSegment, getClock, attachRenderer, dispose,
setPaused: (p) => { paused = p; },
togglePaused: () => (paused = !paused),
isNight: () => night,
get segCount() { return SEG.length; },
set onNightChange(fn) { onNightChange = fn; },
get onNightChange() { return onNightChange; },
};
}

82
web/js/world/minimap.js Normal file
View File

@ -0,0 +1,82 @@
// PROCITY Lane B — minimap.js
// The `map` mode: a 2D top-down town directory drawn from the CityPlan (streets + lots coloured by
// shop type + a live player dot/heading). This is the shell's own lightweight map; Lane A's
// map.html debug view grows into the richer directory later (Lane F reconciles).
const TYPE_COLOR = {
record: '#8a5aa8', opshop: '#8a7a5a', toy: '#c86aa0', book: '#7a6a3a', video: '#4a6ab0',
pawn: '#c4a028', milkbar: '#c4863a', dept: '#5a6a7a', stall: '#5a8a6a',
house: '#6b5a48', anchor: '#5a6a7a',
};
export function createMinimap(plan) {
const wrap = document.createElement('div');
wrap.id = 'pc-map';
wrap.style.cssText = 'position:fixed;inset:0;z-index:20;display:none;background:#141712;'
+ 'align-items:center;justify-content:center;flex-direction:column;font:13px -apple-system,sans-serif;color:#e8e0d0';
const canvas = document.createElement('canvas');
canvas.width = 720; canvas.height = 720;
canvas.style.cssText = 'background:#1b1e17;border-radius:10px;max-width:92vw;max-height:80vh;box-shadow:0 8px 40px #000';
const cap = document.createElement('div');
cap.style.cssText = 'margin-top:12px;opacity:.8';
cap.textContent = `${plan.name} — seed ${plan.citySeed} · press M to close`;
wrap.append(canvas, cap);
document.body.appendChild(wrap);
const ctx = canvas.getContext('2d');
// world→canvas transform (fit plan extent with margin)
let minX = -60, maxX = 60, minZ = -60, maxZ = 60;
for (const n of plan.streets.nodes) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minZ = Math.min(minZ, n.z); maxZ = Math.max(maxZ, n.z); }
for (const l of plan.lots) { minX = Math.min(minX, l.x); maxX = Math.max(maxX, l.x); minZ = Math.min(minZ, l.z); maxZ = Math.max(maxZ, l.z); }
const pad = 24, W = canvas.width, H = canvas.height;
const span = Math.max(maxX - minX, maxZ - minZ) + 40;
const cxw = (minX + maxX) / 2, czw = (minZ + maxZ) / 2;
const sc = (W - pad * 2) / span;
const tx = (x) => W / 2 + (x - cxw) * sc;
const tz = (z) => H / 2 + (z - czw) * sc;
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
const shopByLot = new Map(plan.shops.map((s) => [s.lot, s]));
function drawStatic() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#1b1e17'; ctx.fillRect(0, 0, W, H);
// streets
for (const e of plan.streets.edges) {
const a = nodes.get(e.a), b = nodes.get(e.b);
ctx.strokeStyle = '#3a3f34'; ctx.lineWidth = Math.max(3, e.width * sc);
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(tx(a.x), tz(a.z)); ctx.lineTo(tx(b.x), tz(b.z)); ctx.stroke();
}
// lots
for (const l of plan.lots) {
const s = shopByLot.get(l.id);
const col = TYPE_COLOR[s ? s.type : l.use] || '#666';
ctx.save();
ctx.translate(tx(l.x), tz(l.z)); ctx.rotate(-(l.ry || 0));
ctx.fillStyle = col;
ctx.fillRect(-l.w * sc / 2, -l.d * sc / 2, l.w * sc, l.d * sc);
// front-edge tick (toward the street)
ctx.fillStyle = 'rgba(255,255,255,.5)';
ctx.fillRect(-l.w * sc / 2, l.d * sc / 2 - 2, l.w * sc, 2);
ctx.restore();
}
}
function draw(playerPos, fwd) {
drawStatic();
const x = tx(playerPos.x), z = tz(playerPos.z);
// heading (world +X→right, +Z→down on the map)
ctx.strokeStyle = '#ffd75e'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(x, z);
ctx.lineTo(x + fwd.x * 16, z + fwd.z * 16); ctx.stroke();
ctx.fillStyle = '#ffd75e';
ctx.beginPath(); ctx.arc(x, z, 6, 0, Math.PI * 2); ctx.fill();
}
return {
draw,
setVisible: (v) => { wrap.style.display = v ? 'flex' : 'none'; },
dispose: () => wrap.remove(),
};
}

116
web/js/world/planutil.js Normal file
View File

@ -0,0 +1,116 @@
// PROCITY Lane B — planutil.js
// Pure plan/geometry helpers over a CityPlan (no THREE). Chunk math (CITY_SPEC: 64m cells),
// node/lot/shop indices, edge resolution, and the oriented-rectangle collider for a lot.
// If Lane A later ships an official chunkIndex(plan) we swap the import; the shapes match.
export const CHUNK = 64; // metres (CITY_SPEC law)
export const chunkCoord = (v) => Math.floor(v / CHUNK);
export const chunkKey = (cx, cz) => `${cx},${cz}`;
export const chunkOf = (x, z) => ({ cx: chunkCoord(x), cz: chunkCoord(z) });
export const parseKey = (k) => { const [cx, cz] = k.split(',').map(Number); return { cx, cz }; };
// Fast lookup maps built once per plan.
export function indexPlan(plan) {
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
const lots = new Map(plan.lots.map((l) => [l.id, l]));
const shopByLot = new Map(plan.shops.map((s) => [s.lot, s]));
const shopsById = new Map(plan.shops.map((s) => [s.id, s]));
return { nodes, lots, shopByLot, shopsById };
}
// Resolve street edges to world segments with widths.
export function resolveEdges(plan) {
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
return plan.streets.edges.map((e) => {
const a = nodes.get(e.a), b = nodes.get(e.b);
return { id: e.id, kind: e.kind, width: e.width, ax: a.x, az: a.z, bx: b.x, bz: b.z };
});
}
/**
* chunkIndex(plan) Map<chunkKey, { lots:[lot], shops:[shop], edges:[edgeId] }>
* A lot belongs to the chunk containing its centre. An edge is registered to every chunk its
* segment passes through (rasterised at CHUNK/4 steps) so the streamer can draw partial roads
* if it ever needs to. Shops ride along with their lot.
*/
export function chunkIndex(plan) {
const { shopByLot } = indexPlan(plan);
const map = new Map();
const cell = (cx, cz) => {
const k = chunkKey(cx, cz);
let c = map.get(k);
if (!c) { c = { lots: [], shops: [], edges: [] }; map.set(k, c); }
return c;
};
for (const lot of plan.lots) {
const { cx, cz } = chunkOf(lot.x, lot.z);
const c = cell(cx, cz);
c.lots.push(lot);
const s = shopByLot.get(lot.id);
if (s) c.shops.push(s);
}
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
for (const e of plan.streets.edges) {
const a = nodes.get(e.a), b = nodes.get(e.b);
const dx = b.x - a.x, dz = b.z - a.z;
const len = Math.hypot(dx, dz) || 1;
const ux = dx / len, uz = dz / len, px = -uz, pz = ux; // along + perpendicular units
// Register every chunk the road BAND touches — not just the centreline — so furniture placed
// at a perpendicular offset (footpath/verge) can never fall in an unbuilt chunk and vanish.
const band = e.width / 2 + 10;
const along = Math.max(1, Math.ceil(len / (CHUNK / 4)));
const across = Math.max(1, Math.ceil((band * 2) / (CHUNK / 4)));
const seen = new Set();
for (let i = 0; i <= along; i++) {
const sx = a.x + dx * (i / along), sz = a.z + dz * (i / along);
for (let j = 0; j <= across; j++) {
const o = -band + (2 * band) * (j / across);
const { cx, cz } = chunkOf(sx + px * o, sz + pz * o);
const k = chunkKey(cx, cz);
if (!seen.has(k)) { seen.add(k); cell(cx, cz).edges.push(e.id); }
}
}
}
return map;
}
// Oriented-rectangle collider for a lot (local X = frontage w, local Z = depth d, rotated ry).
// Yards/houses get a fatter keep-out so you can't walk through private front gardens.
export function lotCollider(lot) {
const yard = lot.use === 'house' || lot.use === 'yard';
const pad = yard ? 2.5 : 0; // private-yard buffer in front of houses
return {
x: lot.x, z: lot.z, ry: lot.ry || 0,
hw: lot.w / 2, hd: lot.d / 2 + pad / 2,
use: lot.use,
};
}
// Test/point-push a circle of radius r at (px,pz) out of an oriented rect collider.
// Returns [nx, nz] corrected position (or the same point if no overlap). Reused every frame —
// pass scratch out-array to avoid allocation.
export function pushOutRect(px, pz, r, col, out) {
// Convention must match buildings.toWorld (local→world = RotY(ry)): dx = lx·cos + lz·sin,
// dz = lx·sin + lz·cos. So world→local is the inverse (RotY(ry)) below, and the push-out
// back-transform is RotY(ry). (Earlier this used the wrong sign and only happened to work for
// axis-aligned ry ∈ {0,±π/2,π}; arbitrary ry from generatePlan needs the correct inverse.)
const cos = Math.cos(col.ry), sin = Math.sin(col.ry);
const dx = px - col.x, dz = pz - col.z;
const lx = dx * cos - dz * sin; // world→local (RotY(ry))
const lz = dx * sin + dz * cos;
const ex = col.hw + r, ez = col.hd + r;
if (lx > -ex && lx < ex && lz > -ez && lz < ez) {
// overlapping — push along axis of least penetration
const penX = ex - Math.abs(lx);
const penZ = ez - Math.abs(lz);
let nlx = lx, nlz = lz;
if (penX < penZ) nlx = lx < 0 ? -ex : ex;
else nlz = lz < 0 ? -ez : ez;
out[0] = col.x + (nlx * cos + nlz * sin); // local→world (RotY(ry)), inverse of the above
out[1] = col.z + (-nlx * sin + nlz * cos);
return true;
}
out[0] = px; out[1] = pz;
return false;
}

82
web/js/world/player.js Normal file
View File

@ -0,0 +1,82 @@
// PROCITY Lane B — player.js
// PointerLockControls first-person walk (WASD + shift-run), eye at 1.7m, with push-out collision
// against the oriented-rectangle colliders of the nearby chunks (buildings + private yards).
// No navmesh — the town is rectangles, so a couple of push-out passes is plenty. All scratch
// vectors are reused; the update loop allocates nothing.
import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
import { pushOutRect } from './planutil.js';
const EYE = 1.7;
const RADIUS = 0.38; // player collision radius
const WALK = 4.6, RUN = 8.8;
export function createPlayer(camera, domElement, { getColliders }) {
camera.position.y = EYE;
const controls = new PointerLockControls(camera, domElement);
const _dir = new THREE.Vector3();
const _fwd = new THREE.Vector3();
const _right = new THREE.Vector3();
const _move = new THREE.Vector3();
const _out = [0, 0];
let enabled = true;
// keys: a live Set of held key codes (index.html fills it)
function update(dt, keys) {
if (!enabled) return;
// horizontal forward/right from camera yaw
camera.getWorldDirection(_dir);
_fwd.set(_dir.x, 0, _dir.z);
if (_fwd.lengthSq() < 1e-6) _fwd.set(0, 0, -1);
_fwd.normalize();
_right.set(-_fwd.z, 0, _fwd.x); // camera-right = normalize(cross(forward, up)) on XZ
let f = 0, s = 0;
if (keys.has('KeyW') || keys.has('ArrowUp')) f += 1;
if (keys.has('KeyS') || keys.has('ArrowDown')) f -= 1;
if (keys.has('KeyD') || keys.has('ArrowRight')) s += 1;
if (keys.has('KeyA') || keys.has('ArrowLeft')) s -= 1;
_move.set(0, 0, 0);
if (f || s) {
_move.addScaledVector(_fwd, f).addScaledVector(_right, s);
if (_move.lengthSq() > 1e-6) _move.normalize();
const speed = (keys.has('ShiftLeft') || keys.has('ShiftRight')) ? RUN : WALK;
_move.multiplyScalar(speed * dt);
}
let px = camera.position.x + _move.x;
let pz = camera.position.z + _move.z;
// collision: two push-out passes so inner corners resolve
const cols = getColliders ? getColliders(px, pz) : null;
if (cols && cols.length) {
for (let pass = 0; pass < 2; pass++) {
for (let i = 0; i < cols.length; i++) {
if (pushOutRect(px, pz, RADIUS, cols[i], _out)) { px = _out[0]; pz = _out[1]; }
}
}
}
camera.position.x = px;
camera.position.z = pz;
camera.position.y = EYE;
}
function teleport(x, z, yaw = 0) {
camera.position.set(x, EYE, z);
camera.rotation.set(0, yaw, 0, 'YXZ');
}
return {
controls,
update,
teleport,
get position() { return camera.position; },
setEnabled: (v) => { enabled = v; },
lock: () => controls.lock(),
unlock: () => controls.unlock(),
get isLocked() { return controls.isLocked; },
};
}

189
web/js/world/skins.js Normal file
View File

@ -0,0 +1,189 @@
// PROCITY Lane B — skins.js
// The city-wide shared-material registry. Every facade/ground/awning skin resolves to ONE
// cached material used by every chunk (CITY_SPEC: ≤25 skin materials city-wide, share by skin).
// House law: seeded flat-colour fallback UNDER every texture — the town looks right with zero
// assets. We use a small onLoad-capable texture cache (not core/loaders.loadTex) precisely so we
// can keep the flat fallback visible until the JPEG arrives, then swap the map in. GLB depot
// loading stays on core/loaders (furniture.js).
import * as THREE from 'three';
import { xmur3 } from '../core/prng.js';
const ASSET_BASE = 'assets/gen/';
// ── muted 90s-AU fallback palettes (used until — or instead of — the JPEG skin) ──
const FACADE_FALLBACK = ['#c9b8a0', '#b0b8ab', '#c4b0b0', '#a8b4c0', '#c4bc9c', '#cab89e', '#b8a890', '#bfae96'];
const GROUND_FALLBACK = [
[/asphalt|road/, '#4a4a46'], [/footpath|djsim-footpath/, '#8b857a'], [/grass/, '#5d6b45'],
[/brickpave/, '#8a6a58'], [/gravel/, '#9a948a'], [/reddust|dust/, '#a8664a'],
];
const AWNING_FALLBACK = [[/red/, '#a23b32'], [/green/, '#3a6b45'], [/blue/, '#3a5b8b']];
const hashName = (s) => xmur3(s)();
const fallbackFacade = (name) => FACADE_FALLBACK[hashName(name) % FACADE_FALLBACK.length];
const keyword = (name, table, dflt) => (table.find(([re]) => re.test(name)) || [null, dflt])[1];
/**
* createSkinLibrary(opts) one per game session. Materials cached by skin name and shared by
* every chunk; dispose() frees textures/materials at teardown.
*/
export function createSkinLibrary({ assetBase = ASSET_BASE } = {}) {
const texCache = new Map(); // url → THREE.Texture (with fallback colour on its owning material)
const matCache = new Map(); // `kind:name` → material
const disposables = [];
// onLoad-capable, cached texture loader. Applies to a material via a swap callback so the
// flat fallback colour shows until the image is decoded, then map pops in.
const texLoader = new THREE.TextureLoader();
function skinTex(url, { repeat, srgb = true, onReady } = {}) {
let tex = texCache.get(url);
if (tex) { if (onReady && tex.image) onReady(tex); return tex; }
tex = texLoader.load(
url,
(t) => { t.needsUpdate = true; onReady && onReady(t); },
undefined,
() => console.warn('[skins] tex missing, flat fallback stays:', url),
);
if (srgb) tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4;
if (repeat) { tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(...repeat); }
texCache.set(url, tex);
disposables.push(tex);
return tex;
}
function facadeMat(name) {
// [Lane F integration fix] Lane A's registry/plan store facadeSkin as a full filename
// ("facade-fibro-blue.jpg"); this fn builds `assets/gen/facade-<name>.jpg`, so a raw filename
// double-wraps to `facade-facade-…jpg.jpg` (every facade 404 → flat colour). Normalize to the
// bare key — idempotent for a value that is already bare (e.g. the 'weatherboard' fallback).
name = String(name).replace(/\.(jpe?g|png|webp)$/i, '').replace(/^facade-/, '');
const key = `facade:${name}`;
let m = matCache.get(key);
if (m) return m;
m = new THREE.MeshStandardMaterial({ color: new THREE.Color(fallbackFacade(name)), roughness: 0.92, metalness: 0 });
skinTex(`${assetBase}facade-${name}.jpg`, { onReady: (t) => { m.map = t; m.color.set(0xffffff); m.needsUpdate = true; } });
matCache.set(key, m); disposables.push(m);
return m;
}
// ── facade atlas ────────────────────────────────────────────────────────────────────────────
// ONE texture + ONE material for EVERY facade skin, so a chunk's facades merge into a single draw
// (vs one mesh per distinct skin — the biggest draw-call term in dense districts) AND ~25 facade
// materials collapse to 1. Each slot is pre-painted with the skin's flat fallback colour (house
// law: the town is right with zero JPEGs), then the decoded JPEG is drawn over it. UV convention
// mirrors the per-chunk sign atlas (CanvasTexture flipY ⇒ v0 = bottom of slot). The stretch of a
// square skin across a wide-short shopfront matches the pre-atlas [0,1]² mapping — no regression.
const ATLAS_DIM = 2048, ATLAS_COLS = 6, ATLAS_ROWS = 6, SLOT = Math.floor(ATLAS_DIM / ATLAS_COLS);
let atlasCanvas = null, atlasCtx = null, atlasTex = null, atlasMat = null, atlasNext = 0;
const atlasSlot = new Map(); // bare skin name → [u0,v0,u1,v1]
function ensureFacadeAtlas() {
if (atlasMat) return;
atlasCanvas = document.createElement('canvas');
atlasCanvas.width = atlasCanvas.height = ATLAS_DIM;
atlasCtx = atlasCanvas.getContext('2d');
atlasCtx.fillStyle = '#8a7f6f'; atlasCtx.fillRect(0, 0, ATLAS_DIM, ATLAS_DIM);
atlasTex = new THREE.CanvasTexture(atlasCanvas);
atlasTex.colorSpace = THREE.SRGBColorSpace; atlasTex.anisotropy = 4;
atlasTex.generateMipmaps = false; atlasTex.minFilter = THREE.LinearFilter; // NPOT-safe, no cross-slot mip bleed
atlasMat = new THREE.MeshStandardMaterial({ map: atlasTex, roughness: 0.92, metalness: 0 });
disposables.push(atlasTex, atlasMat);
}
function facadeAtlasMaterial() { ensureFacadeAtlas(); return atlasMat; }
// UV sub-rect for a skin; assigns + paints a slot on first request (fallback colour now, JPEG on load).
function facadeAtlasUV(name) {
ensureFacadeAtlas();
name = String(name).replace(/\.(jpe?g|png|webp)$/i, '').replace(/^facade-/, ''); // accept bare key OR full filename
let uv = atlasSlot.get(name);
if (uv) return uv;
if (atlasNext >= ATLAS_COLS * ATLAS_ROWS) console.warn('[skins] facade atlas full (>36 skins), reusing a slot for', name);
const idx = atlasNext++ % (ATLAS_COLS * ATLAS_ROWS);
const col = idx % ATLAS_COLS, row = (idx / ATLAS_COLS) | 0;
const px = col * SLOT, py = row * SLOT;
atlasCtx.fillStyle = fallbackFacade(name); atlasCtx.fillRect(px, py, SLOT, SLOT);
atlasTex.needsUpdate = true;
const img = new Image(); // same-origin JPEG → canvas never tainted
img.onload = () => { atlasCtx.drawImage(img, px, py, SLOT, SLOT); atlasTex.needsUpdate = true; };
img.onerror = () => {}; // fallback colour already painted — house law holds
img.src = `${assetBase}facade-${name}.jpg`;
const g = 0.5 / ATLAS_DIM; // ½px guard against sampling the neighbouring slot
uv = [px / ATLAS_DIM + g, 1 - (py + SLOT) / ATLAS_DIM + g, (px + SLOT) / ATLAS_DIM - g, 1 - py / ATLAS_DIM - g];
atlasSlot.set(name, uv);
return uv;
}
// Ground materials tile via RepeatWrapping; ground.js bakes the tile scale into the mesh UVs,
// so one shared material serves strips of any length.
function groundMat(name) {
const key = `ground:${name}`;
let m = matCache.get(key);
if (m) return m;
m = new THREE.MeshStandardMaterial({ color: new THREE.Color(keyword(name, GROUND_FALLBACK, '#6b6b64')), roughness: 0.97, metalness: 0 });
const t = skinTex(`${assetBase}ground-${name}.jpg`, { repeat: [1, 1], onReady: (tx) => { m.map = tx; m.color.set(0xffffff); m.needsUpdate = true; } });
t.wrapS = t.wrapT = THREE.RepeatWrapping;
matCache.set(key, m); disposables.push(m);
return m;
}
function awningMat(name) {
const key = `awning:${name}`;
let m = matCache.get(key);
if (m) return m;
m = new THREE.MeshStandardMaterial({ color: new THREE.Color(keyword(name, AWNING_FALLBACK, '#8a4b3a')), roughness: 0.85, metalness: 0, side: THREE.DoubleSide });
skinTex(`${assetBase}tex-awning-${name}.jpg`, { repeat: [3, 1], onReady: (t) => { m.map = t; m.color.set(0xffffff); m.needsUpdate = true; } });
matCache.set(key, m); disposables.push(m);
return m;
}
// Shared awning material for a single instanced mesh per chunk (per-instance colour), so all three
// awning skins draw in one call instead of one-per-skin. The stripe JPEG sits on the awning's top
// face (unseen from under the verandah), so the flat instance colour reads the same at street level.
let _awn = null;
function awningMaterial() {
if (!_awn) { _awn = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0, side: THREE.DoubleSide }); disposables.push(_awn); }
return _awn;
}
function awningColor(name) { return keyword(String(name), AWNING_FALLBACK, '#8a4b3a'); }
// Generic wallpaper / interior surface (used lightly for house facades until Lane C).
function texMat(name, { repeat = [1, 1], rough = 0.9 } = {}) {
const key = `tex:${name}:${repeat.join('x')}`;
let m = matCache.get(key);
if (m) return m;
m = new THREE.MeshStandardMaterial({ color: 0xbdb2a2, roughness: rough });
skinTex(`${assetBase}tex-${name}.jpg`, { repeat, onReady: (t) => { m.map = t; m.color.set(0xffffff); m.needsUpdate = true; } });
matCache.set(key, m); disposables.push(m);
return m;
}
// Sky dome texture for lighting.js (own material there; we just supply the cached texture).
function skyTex(name, onReady) {
return skinTex(`${assetBase}sky-${name}.jpg`, { onReady });
}
// Shared shell material for InstancedMesh building boxes — per-instance colour via instanceColor,
// so ONE material draws every shell in a chunk. Not cached per-name (there is only one).
let _shell = null;
function shellMaterial() {
if (!_shell) { _shell = new THREE.MeshStandardMaterial({ roughness: 0.9, metalness: 0 }); disposables.push(_shell); }
return _shell;
}
// Shared trim material (parapets, posts, kerbs) — plain instanced, tinted per instance.
let _trim = null;
function trimMaterial() {
if (!_trim) { _trim = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0 }); disposables.push(_trim); }
return _trim;
}
function count() { return matCache.size + (atlasMat ? 1 : 0); } // the atlas is the single shared facade material
function dispose() {
for (const d of disposables) d.dispose && d.dispose();
texCache.clear(); matCache.clear(); disposables.length = 0;
}
return { facadeMat, facadeAtlasMaterial, facadeAtlasUV, groundMat, awningMat, awningMaterial, awningColor,
texMat, skyTex, shellMaterial, trimMaterial, skinTex, count, dispose, fallbackFacadeColor: fallbackFacade };
}