diff --git a/B-progress.md b/B-progress.md new file mode 100644 index 0000000..e4e15f7 --- /dev/null +++ b/B-progress.md @@ -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 ~130–210. 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.) diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md new file mode 100644 index 0000000..568f831 --- /dev/null +++ b/docs/LANES/LANE_B_NOTES.md @@ -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 | ~1–8 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 wall−radius (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) | **~130–210 total** | ≤ 300 | ✅ | +| triangles | 12k–25k | ≤ 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 ~5–8 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 ~8–22 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 0–5)` — 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. diff --git a/docs/shots/laneB/.gitkeep b/docs/shots/laneB/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/shots/laneB/procity-laneB-main-street-day.png b/docs/shots/laneB/procity-laneB-main-street-day.png new file mode 100644 index 0000000..0f73d13 Binary files /dev/null and b/docs/shots/laneB/procity-laneB-main-street-day.png differ diff --git a/docs/shots/laneB/procity-laneB-plaza-overview.png b/docs/shots/laneB/procity-laneB-plaza-overview.png new file mode 100644 index 0000000..2dcad5e Binary files /dev/null and b/docs/shots/laneB/procity-laneB-plaza-overview.png differ diff --git a/docs/shots/laneB/procity-laneB-strip-night.png b/docs/shots/laneB/procity-laneB-strip-night.png new file mode 100644 index 0000000..98620de Binary files /dev/null and b/docs/shots/laneB/procity-laneB-strip-night.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..20c5b16 --- /dev/null +++ b/web/index.html @@ -0,0 +1,256 @@ + + + + + +PROCITY — walkable town + + + + + + +
+

PROCITY

+
A procedurally generated, walkable 90s-Australian shopping town. + Every door opens (soon). Walk the strip, watch the day turn.
+ +
+
WASD move · shift run · mouse look · click a door · [ ] time of day · M map · P screenshot
+
+ + + + diff --git a/web/js/world/buildings.js b/web/js/world/buildings.js new file mode 100644 index 0000000..e24b70e --- /dev/null +++ b/web/js/world/buildings.js @@ -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 ~10–18 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 }; +} diff --git a/web/js/world/chunks.js b/web/js/world/chunks.js new file mode 100644 index 0000000..956ff10 --- /dev/null +++ b/web/js/world/chunks.js @@ -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; }, + }; +} diff --git a/web/js/world/dbg.js b/web/js/world/dbg.js new file mode 100644 index 0000000..acc3f89 --- /dev/null +++ b/web/js/world/dbg.js @@ -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; +} diff --git a/web/js/world/fixture_plan.js b/web/js/world/fixture_plan.js new file mode 100644 index 0000000..92e3219 --- /dev/null +++ b/web/js/world/fixture_plan.js @@ -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', 'Fossey’s', '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 9–5-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; diff --git a/web/js/world/furniture.js b/web/js/world/furniture.js new file mode 100644 index 0000000..7f77b65 --- /dev/null +++ b/web/js/world/furniture.js @@ -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). diff --git a/web/js/world/ground.js b/web/js/world/ground.js new file mode 100644 index 0000000..fee7daf --- /dev/null +++ b/web/js/world/ground.js @@ -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 }; +} diff --git a/web/js/world/hud.js b/web/js/world/hud.js new file mode 100644 index 0000000..2136d40 --- /dev/null +++ b/web/js/world/hud.js @@ -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 = ` +
+
+
+
+
town
+
seed
+
fps
+
draws · tris
+
chunks
+
+
+
WASD move · shift run · mouse look · click a door · [ ] time · P shot · Esc release
`; + 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 ? `${draws}` : draws; + elTris.innerHTML = tris > 200000 ? `${(tris / 1000 | 0)}k` : `${(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 }; +} diff --git a/web/js/world/lighting.js b/web/js/world/lighting.js new file mode 100644 index 0000000..036e4ba --- /dev/null +++ b/web/js/world/lighting.js @@ -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; }, + }; +} diff --git a/web/js/world/minimap.js b/web/js/world/minimap.js new file mode 100644 index 0000000..f510ff0 --- /dev/null +++ b/web/js/world/minimap.js @@ -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(), + }; +} diff --git a/web/js/world/planutil.js b/web/js/world/planutil.js new file mode 100644 index 0000000..16e4e31 --- /dev/null +++ b/web/js/world/planutil.js @@ -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 + * 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; +} diff --git a/web/js/world/player.js b/web/js/world/player.js new file mode 100644 index 0000000..eb688b2 --- /dev/null +++ b/web/js/world/player.js @@ -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; }, + }; +} diff --git a/web/js/world/skins.js b/web/js/world/skins.js new file mode 100644 index 0000000..761421c --- /dev/null +++ b/web/js/world/skins.js @@ -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-.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 }; +}