Lane B (Streetscape): chunk-streamed walkable town shell
First-person shell: chunk streaming, instanced buildings + facade texture atlas (22 facade materials -> 1; 5 skin materials city-wide), signs, awnings, furniture, day/night lighting, minimap, pixel-accurate collision (incl. arbitrary-angle lots), door raycast -> procity:enterShop, DBG harness, all-fallback mode. Renders Lane A's full generated town (seed 20261990 'Boolarra Heads', 493 shops); worst continuous- walk view ~261 draws (<=300 gate). 6 adversarial-review bugs fixed (collision sign, house doors, awning skin, sign atlas, furniture seed hash, shot-mode restore). index.html and skins.js include Lane F's inline integration seam edits (marked '[Lane F integration]'): interior/keeper/citizen wiring + facade filename fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8577c48594
commit
5313402e43
111
B-progress.md
Normal file
111
B-progress.md
Normal file
@ -0,0 +1,111 @@
|
||||
# PROCITY-B — progress (Lane B: Streetscape + game shell)
|
||||
|
||||
**Status: complete and verified.** Lane B turns a CityPlan into a walkable, chunk-streamed 3D town
|
||||
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
|
||||
`generatePlan` (which landed mid-session). Everything below was verified live in a browser.
|
||||
|
||||
## Session 2 (continuation) — closed the two open Lane-B items Fable flagged
|
||||
- **Draw budget now holds with margin.** Facade texture atlas + awning-merge + shell/trim-merge:
|
||||
worst dense frustum **393 → 265 scene draws**, worst continuous-walk gameplay view **~261 total**
|
||||
(≤300 gate, ~40 margin). Bonus: **skin materials 25 → 5** (the atlas is one shared facade material),
|
||||
fixing the "≤25 was at the cap" concern too. (The old "~445" peak was largely a measurement artifact
|
||||
— orphan chunks from teleport-jumping that the real streamer disposes.)
|
||||
- **`window.DBG` harness exposed** (`?dbg=1`, `js/world/dbg.js`) — answers LANE_F_NOTES §4 so Fable's
|
||||
`shots.py`/`soak.py` can drive the game: `ready`, `shot(name)`, `teleport`, `setSegment`,
|
||||
`enterShop`/`exitShop` (drives the real B→F→C interior chain), `info()`.
|
||||
- **`ChunkManager` lifecycle hooks** (`onChunkBuilt`/`onChunkDisposed`, optional/inert) — answers §8.
|
||||
- Verified: fixture ("Yarraville Junction", 26 shops) still builds clean with all 3 awning colours;
|
||||
shadow path (`?shadows=1`) renders from the merged meshes; day + night + door tooltip + interiors
|
||||
all work; **zero console errors**. Files touched: `skins.js`, `buildings.js`, `chunks.js`, `hud.js`,
|
||||
new `dbg.js`, + a small guarded DBG loader in `index.html`. **Uncommitted** (per the lane treaty;
|
||||
and Fable is concurrently wiring `index.html` — our edits coexist cleanly on stable anchors).
|
||||
|
||||
Owned/created (only `web/index.html` + `web/js/world/*`, per the ownership treaty):
|
||||
|
||||
| file | role |
|
||||
|---|---|
|
||||
| `web/index.html` | shell: importmap→vendor, renderer + ACES + bloom, MODE state machine (`map`/`street`/`interior`), main loop, adaptive perf, guarded `generatePlan` import, shot harness |
|
||||
| `web/js/world/fixture_plan.js` | hand-authored CityPlan (schema v1): 4 N + 2 S terraced blocks (26 shops), cross-street houses, 2 market stalls, streets graph — spans ~6 chunks so streaming is exercised |
|
||||
| `web/js/world/planutil.js` | chunk math (64 m), `chunkIndex`, node/lot indices, oriented-rect collider + `pushOutRect` |
|
||||
| `web/js/world/skins.js` | city-wide shared material registry (one material per skin), seeded flat-colour fallbacks |
|
||||
| `web/js/world/buildings.js` | facade kit: instanced shells/parapets/posts/awnings, merged per-skin facades, per-chunk sign atlas, doors/windows |
|
||||
| `web/js/world/ground.js` | roads/footpaths/kerbs/plaza/base plane, merged strips, UV-baked tiling |
|
||||
| `web/js/world/furniture.js` | instanced streetlights (emissive at night), benches, bins, gum trees, bus stops — all with primitive fallbacks |
|
||||
| `web/js/world/lighting.js` | seeded sky dome + 6-segment day/night cycle, one following sun/shadow, fog pop-in mask |
|
||||
| `web/js/world/player.js` | PointerLock WASD+run, eye 1.7 m, push-out collision vs nearby-chunk colliders |
|
||||
| `web/js/world/chunks.js` | the streamer: radius-R build / R+1 dispose, nearest-first queue, 4 ms/frame budget, night propagation |
|
||||
| `web/js/world/hud.js` | crosshair, throttled door-raycast tooltip, live draws/tris/fps/seed/clock readout, click→`procity:enterShop` |
|
||||
| `web/js/world/minimap.js` | `map` mode: 2D top-down town directory (streets + lots by type + player dot) |
|
||||
|
||||
## Acceptance — all met
|
||||
|
||||
- ✅ Walk the fixture 5 min: contiguous, no seams, chunks stream silently, day/night cycles, signs
|
||||
legible, tooltips work, budgets green (148 scene draws, 21 skin materials, 5.8k tris).
|
||||
- ✅ Works with `generatePlan` when present (guarded import; falls back to fixture if absent).
|
||||
- ✅ Runs with `web/assets/` renamed away — no crashes, flat-colour fallback, canvas signs still show.
|
||||
- ✅ Instancing from day one, per-chunk 2048-wide sign atlas, canvas text (no fonts), seeded-everything.
|
||||
- ✅ 3 reference stills in `docs/shots/laneB/`; measured numbers in `docs/LANES/LANE_B_NOTES.md`.
|
||||
|
||||
## Verified live (in-browser)
|
||||
|
||||
Streaming (chunks build/dispose as you walk, queue never starves), 6-segment day→night with glowing
|
||||
shopfront windows + streetlamp bloom at night, door raycast → correct shop → `procity:enterShop` +
|
||||
toast, pixel-accurate collision (stops at wall − player radius), all-fallback mode, and Lane A's full
|
||||
493-shop city rendering + streaming + arbitrary-angle collision through Lane B.
|
||||
|
||||
## Perf (measured — full table in `docs/LANES/LANE_B_NOTES.md`)
|
||||
|
||||
- **Fixture** (radius 3, shadows on): ~148 scene draws, low tris, well under all gates.
|
||||
- **Lane A city** (~493 shops, radius 2 auto, shadows off): worst continuous-walk gameplay view
|
||||
**~261 total draws** (≤300 gate, ~40 margin); typical ~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.)
|
||||
147
docs/LANES/LANE_B_NOTES.md
Normal file
147
docs/LANES/LANE_B_NOTES.md
Normal file
@ -0,0 +1,147 @@
|
||||
# LANE B — NOTES (measured)
|
||||
|
||||
Streetscape + game shell. Files: `web/index.html`, `web/js/world/*`, `web/js/world/fixture_plan.js`.
|
||||
All numbers measured in-browser (Chromium, M-series) via `renderer.info`, 1280×720-ish viewport.
|
||||
`renderer.info.autoReset` is off and reset once/frame, so **draws/tris are the true per-frame
|
||||
totals across all passes** (scene + bloom + output). "scene draws" below excludes the ~11
|
||||
post-processing passes; "total" adds them.
|
||||
|
||||
## Perf — fixture (`fixture_plan.js`, "Yarraville Junction", 26 shops, radius 3, shadows on)
|
||||
|
||||
| metric | measured | gate | status |
|
||||
|---|---|---|---|
|
||||
| draw calls | **148 scene (~159 total)** | ≤ 300 | ✅ |
|
||||
| triangles | **~5.8k** | ≤ 200k | ✅ |
|
||||
| skin materials (city-wide, shared) | **21** | ≤ 25 | ✅ |
|
||||
| live chunks (radius 3) | 12 | — | — |
|
||||
| chunk build | ~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.
|
||||
0
docs/shots/laneB/.gitkeep
Normal file
0
docs/shots/laneB/.gitkeep
Normal file
BIN
docs/shots/laneB/procity-laneB-main-street-day.png
Normal file
BIN
docs/shots/laneB/procity-laneB-main-street-day.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
BIN
docs/shots/laneB/procity-laneB-plaza-overview.png
Normal file
BIN
docs/shots/laneB/procity-laneB-plaza-overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 MiB |
BIN
docs/shots/laneB/procity-laneB-strip-night.png
Normal file
BIN
docs/shots/laneB/procity-laneB-strip-night.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
256
web/index.html
Normal file
256
web/index.html
Normal file
@ -0,0 +1,256 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>PROCITY — walkable town</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #0c0e0a; overflow: hidden; color: #eee;
|
||||
font: 14px -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
#c { display: block; width: 100vw; height: 100vh; }
|
||||
#pc-start { position: fixed; inset: 0; z-index: 40; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; text-align: center; gap: 14px;
|
||||
background: radial-gradient(circle at 50% 40%, #24281e, #0c0e0a); }
|
||||
#pc-start h1 { font-size: 34px; margin: 0; letter-spacing: 2px; color: #ffd75e; text-shadow: 0 2px 8px #000; }
|
||||
#pc-start .sub { opacity: .85; max-width: 420px; line-height: 1.5; }
|
||||
#pc-start button { pointer-events: auto; cursor: pointer; border: 0; border-radius: 10px;
|
||||
padding: 12px 26px; font-size: 16px; background: #ffd75e; color: #241d06; font-weight: 700; }
|
||||
#pc-start .seed { opacity: .55; font-size: 12px; }
|
||||
#pc-load { position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%); opacity: .7; }
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
"three": "./vendor/three.module.js",
|
||||
"three/addons/": "./vendor/addons/"
|
||||
} }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
|
||||
<div id="pc-start">
|
||||
<h1>PROCITY</h1>
|
||||
<div class="sub">A procedurally generated, walkable 90s-Australian shopping town.
|
||||
Every door opens (soon). Walk the strip, watch the day turn.</div>
|
||||
<button id="pc-go">walk into town</button>
|
||||
<div class="seed" id="pc-startseed"></div>
|
||||
<div class="sub" style="font-size:12px;opacity:.7">WASD move · shift run · mouse look · click a door · [ ] time of day · M map · P screenshot</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||||
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||||
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||||
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
|
||||
|
||||
import { fixturePlan } from './js/world/fixture_plan.js';
|
||||
import { createSkinLibrary } from './js/world/skins.js';
|
||||
import { buildGround } from './js/world/ground.js';
|
||||
import { createLighting } from './js/world/lighting.js';
|
||||
import { createChunkManager } from './js/world/chunks.js';
|
||||
import { createPlayer } from './js/world/player.js';
|
||||
import { createHUD } from './js/world/hud.js';
|
||||
import { createMinimap } from './js/world/minimap.js';
|
||||
import { createInteriorMode } from './js/world/interior_mode.js'; // [Lane F integration] Lane C interior bridge
|
||||
import { CitizenSim } from './js/citizens/sim.js'; // [Lane F integration] Lane D street pedestrians
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[procity] init failed:', err);
|
||||
const el = document.getElementById('pc-startseed');
|
||||
if (el) el.textContent = 'INIT ERROR: ' + (err && err.message || err);
|
||||
});
|
||||
|
||||
async function main() {
|
||||
// ── plan: prefer Lane A's generatePlan if it has landed, else the fixture ──
|
||||
const params = new URLSearchParams(location.search);
|
||||
const seed = (parseInt(params.get('seed'), 10) || 20261990) >>> 0;
|
||||
let plan;
|
||||
try {
|
||||
const citygen = await import('./js/citygen/index.js');
|
||||
const gen = citygen.generatePlan || citygen.default;
|
||||
plan = gen ? gen(seed) : fixturePlan(seed);
|
||||
console.log('[procity] using Lane A generatePlan');
|
||||
} catch (e) {
|
||||
plan = fixturePlan(seed);
|
||||
console.log('[procity] Lane A citygen absent — running on fixture_plan');
|
||||
}
|
||||
|
||||
// ── renderer + post ──
|
||||
// Adaptive to plan density: a large generated city (Lane A) has content in nearly every chunk, so
|
||||
// a smaller radius + no sun-shadow pass keeps draw calls near the ≤300 gate. The sparse fixture
|
||||
// gets the fuller radius 3 + shadows (measured ~140 draws). Override via ?r= / ?shadows=0|1.
|
||||
const BIG_CITY = (plan.shops?.length || 0) > 120;
|
||||
const RADIUS = parseInt(params.get('r'), 10) || (BIG_CITY ? 2 : 3);
|
||||
const SHADOWS = params.has('shadows') ? params.get('shadows') !== '0' : !BIG_CITY;
|
||||
const canvas = document.getElementById('c');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
renderer.shadowMap.enabled = SHADOWS;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.info.autoReset = false; // EffectComposer renders many passes/frame; we reset once per frame
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 800);
|
||||
|
||||
const composer = new EffectComposer(renderer);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.55, 0.5, 0.9);
|
||||
composer.addPass(bloom);
|
||||
composer.addPass(new OutputPass());
|
||||
|
||||
// ── world systems ──
|
||||
const skins = createSkinLibrary();
|
||||
const ground = buildGround(plan, skins);
|
||||
scene.add(ground.group);
|
||||
|
||||
const lighting = createLighting(scene, plan, skins, { radius: RADIUS, shadows: SHADOWS });
|
||||
lighting.attachRenderer(renderer);
|
||||
|
||||
const ctx = { skins, citySeed: plan.citySeed, plan, night: lighting.isNight(), radius: RADIUS };
|
||||
const chunks = createChunkManager(plan, scene, ctx);
|
||||
lighting.onNightChange = (night) => { chunks.setNight(night); bloom.strength = night ? 0.9 : 0.55; };
|
||||
|
||||
const player = createPlayer(camera, canvas, { getColliders: (x, z) => chunks.getColliders(x, z) });
|
||||
|
||||
const hud = createHUD({
|
||||
camera, renderer, plan,
|
||||
getDoorMeshes: () => chunks.getDoorMeshes(),
|
||||
onEnterShop: (shopId, name) => enterShop(shopId, name),
|
||||
});
|
||||
const minimap = createMinimap(plan);
|
||||
// [Lane F integration] interior bridge — owns the 'interior' mode branch (Lane C buildInterior)
|
||||
const interiorMode = createInteriorMode({ THREE, renderer, camera, plan });
|
||||
// [Lane F integration] street pedestrians (Lane D). graph = plan.streets directly; no fleet → asset-free
|
||||
// placeholder/impostor peds. `?pop=` tunable; default kept modest so placeholder-mode near-tier stays
|
||||
// inside the ≤300 draw budget (the rig-fleet upgrade §3.4 lets this rise). setTimeOfDay/update in the loop.
|
||||
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets });
|
||||
citizens.setPopulation((parseInt(params.get('pop'), 10) || 140) >>> 0); // midday ≈96 active; worst main-street view ~191 draws (budget 300)
|
||||
document.addEventListener('visibilitychange', () => citizens.setPaused(document.hidden));
|
||||
|
||||
// spawn on the footpath near the west end of the strip, looking east down main street
|
||||
player.teleport(-96, 9, -Math.PI / 2);
|
||||
chunks.warmup(player.position);
|
||||
lighting.update(0.0001, player.position);
|
||||
|
||||
// ── mode state machine: map | street | interior ──
|
||||
let MODE = 'street';
|
||||
function setMode(m) {
|
||||
MODE = m;
|
||||
hud.setVisible(m === 'street');
|
||||
minimap.setVisible(m === 'map');
|
||||
keys.clear();
|
||||
if (m === 'map') player.unlock(); // [Lane F] interior keeps first-person lock; street re-locks on click
|
||||
}
|
||||
|
||||
// ── door → interior hand-off (Lane B seam → Lane F bridge → Lane C buildInterior) ──
|
||||
function enterShop(shopId, name) {
|
||||
window.dispatchEvent(new CustomEvent('procity:enterShop', { detail: { shopId } }));
|
||||
const shop = plan.shops.find((s) => s.id === shopId);
|
||||
if (!shop) { hud.showToast(`🚪 ${name}`); return; }
|
||||
// [Lane F integration] attach lot dims + open Lane C's interior, switch to interior mode
|
||||
const lot = plan.lots.find((l) => l.id === shop.lot);
|
||||
interiorMode.enter({ ...shop, lot: lot ? { w: lot.w, d: lot.d } : null }, name);
|
||||
setMode('interior');
|
||||
}
|
||||
function leaveShop() { // [Lane F] dispose the interior + restore the player to the street door
|
||||
interiorMode.exit();
|
||||
setMode('street'); // pointer stayed locked while inside, so walking out resumes at once; Esc-exit re-locks on click
|
||||
hud.showToast('🚪 back on the street');
|
||||
}
|
||||
window.addEventListener('procity:exitShop', () => { if (MODE === 'interior') leaveShop(); });
|
||||
|
||||
// ── input ──
|
||||
const keys = new Set();
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.repeat) return;
|
||||
keys.add(e.code);
|
||||
if (e.code === 'BracketRight') lighting.stepSegment(1);
|
||||
else if (e.code === 'BracketLeft') lighting.stepSegment(-1);
|
||||
else if (e.code === 'KeyP') { if (MODE === 'street') takeShots(); } // [Lane F] street-only
|
||||
else if (e.code === 'KeyM') { if (MODE !== 'interior') setMode(MODE === 'map' ? 'street' : 'map'); } // [Lane F] not while inside a shop
|
||||
else if (e.code === 'KeyT') lighting.togglePaused();
|
||||
});
|
||||
addEventListener('keyup', (e) => keys.delete(e.code));
|
||||
addEventListener('blur', () => keys.clear());
|
||||
|
||||
const startPanel = document.getElementById('pc-start');
|
||||
document.getElementById('pc-startseed').textContent = `${plan.name} · seed ${plan.citySeed}`;
|
||||
document.getElementById('pc-go').addEventListener('click', () => { startPanel.style.display = 'none'; player.lock(); });
|
||||
player.controls.addEventListener('unlock', () => { keys.clear(); if (MODE === 'interior') leaveShop(); }); // [Lane F] Esc leaves the shop
|
||||
// click the world to (re)grab the pointer when walking
|
||||
canvas.addEventListener('click', () => { if (MODE === 'street' && !player.isLocked) player.lock(); });
|
||||
|
||||
addEventListener('resize', () => {
|
||||
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
composer.setSize(innerWidth, innerHeight);
|
||||
});
|
||||
|
||||
// ── screenshot harness (P): three fixed cameras → PNG downloads into the browser ──
|
||||
const SHOTS = [
|
||||
{ name: 'main-street', pos: [-96, 1.7, 6], look: [0, 1.7, 6] },
|
||||
{ name: 'plaza', pos: [0, 22, 40], look: [0, 0, 0] },
|
||||
{ name: 'backstreets', pos: [3, 1.7, 30], look: [3, 1.7, 95] },
|
||||
];
|
||||
function takeShots() {
|
||||
const savePos = camera.position.clone(), saveQuat = camera.quaternion.clone();
|
||||
const wasMap = MODE; if (MODE !== 'street') setMode('street');
|
||||
SHOTS.forEach((s, i) => {
|
||||
camera.position.set(...s.pos); camera.lookAt(new THREE.Vector3(...s.look));
|
||||
chunks.update(camera.position);
|
||||
composer.render();
|
||||
const a = document.createElement('a');
|
||||
a.download = `procity-laneB-${String(i + 1).padStart(2, '0')}-${s.name}.png`;
|
||||
a.href = canvas.toDataURL('image/png'); a.click();
|
||||
});
|
||||
camera.position.copy(savePos); camera.quaternion.copy(saveQuat);
|
||||
if (wasMap !== 'street') setMode(wasMap); // restore the mode P was pressed in
|
||||
hud.showToast('📸 3 shots saved to downloads');
|
||||
}
|
||||
|
||||
// ── main loop ──
|
||||
const clock = new THREE.Clock();
|
||||
function frame() {
|
||||
requestAnimationFrame(frame);
|
||||
const dt = Math.min(clock.getDelta(), 0.05);
|
||||
|
||||
if (MODE === 'street') {
|
||||
player.update(dt, player.isLocked ? keys : EMPTY);
|
||||
chunks.update(player.position);
|
||||
lighting.update(dt, player.position);
|
||||
const clk = lighting.getClock();
|
||||
citizens.setTimeOfDay((clk.seg + clk.frac) / 6); // [Lane F] 0..1 day fraction → density (busy midday, empty at night)
|
||||
citizens.update(dt); // [Lane F] reads camera pos internally for LOD tiers
|
||||
hud.tickToast(dt);
|
||||
hud.update(dt, { clock: clk, chunks: chunks.count }); // reads prev frame's total
|
||||
renderer.info.reset(); // zero the counters, then let the composer's passes accumulate this frame
|
||||
composer.render();
|
||||
} else if (MODE === 'interior') {
|
||||
if (interiorMode.update(dt, player.isLocked ? keys : EMPTY)) leaveShop(); // [Lane F] walk to the door → back to street
|
||||
} else if (MODE === 'map') {
|
||||
camera.getWorldDirection(_fwd);
|
||||
minimap.draw(player.position, { x: _fwd.x, z: _fwd.z });
|
||||
}
|
||||
}
|
||||
const EMPTY = new Set();
|
||||
const _fwd = new THREE.Vector3();
|
||||
frame();
|
||||
|
||||
// expose for debugging / Lane F
|
||||
window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skins,
|
||||
interiorMode, citizens, enterShop, leaveShop, THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
|
||||
// [Lane B] QA debug harness (window.DBG) — loaded only with ?dbg=1 (LANE_F_NOTES §4). Logic lives
|
||||
// in js/world/dbg.js; the shell just hands it the systems it owns so shots.py/soak.py can drive it.
|
||||
if (params.get('dbg')) {
|
||||
import('./js/world/dbg.js')
|
||||
.then((m) => m.installDBG({ plan, camera, renderer, composer, chunks, lighting, player, hud,
|
||||
setMode, getMode: () => MODE, enterShop }))
|
||||
.catch((e) => console.warn('[procity] DBG harness load failed:', e));
|
||||
}
|
||||
} // end main()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
362
web/js/world/buildings.js
Normal file
362
web/js/world/buildings.js
Normal file
@ -0,0 +1,362 @@
|
||||
// PROCITY Lane B — buildings.js
|
||||
// The facade kit. Builds ONE chunk's buildings into a small, disposable set of draw calls:
|
||||
// • InstancedMesh shells + parapets + verandah posts + awning boxes (per-instance matrix/colour)
|
||||
// • merged facade planes, grouped per skin → one mesh per skin, sharing a city-wide material
|
||||
// • one 2048² sign atlas per chunk (every sign drawn once, planes UV-mapped into it)
|
||||
// • merged door / window meshes (windows go emissive at night via applyNight)
|
||||
// Everything is built in world space and collected, so a whole chunk is ~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 };
|
||||
}
|
||||
132
web/js/world/chunks.js
Normal file
132
web/js/world/chunks.js
Normal file
@ -0,0 +1,132 @@
|
||||
// PROCITY Lane B — chunks.js
|
||||
// The streamer. Around the player it keeps chunks within Chebyshev radius R built and disposes
|
||||
// anything past R+1, so the world is never rebuilt wholesale. Chunk builds are queued nearest-first
|
||||
// and drained under a 4ms/frame budget so streaming never hitches. Each chunk aggregates its
|
||||
// buildings (buildings.js) + furniture (furniture.js); ground/sky/lighting are global.
|
||||
|
||||
import { chunkIndex, chunkCoord, chunkKey, parseKey } from './planutil.js';
|
||||
import { buildChunkBuildings } from './buildings.js';
|
||||
import { buildChunkFurniture } from './furniture.js';
|
||||
|
||||
const BUILD_BUDGET_MS = 4;
|
||||
|
||||
export function createChunkManager(plan, scene, ctx) {
|
||||
const R = ctx.radius ?? 3;
|
||||
const DISPOSE_R = R + 1;
|
||||
const index = chunkIndex(plan);
|
||||
|
||||
const live = new Map(); // key → { buildings, furniture, colliders, cx, cz }
|
||||
const doorMeshes = []; // live door meshes (for HUD raycast)
|
||||
let queue = []; // keys pending build, nearest-first
|
||||
let queued = new Set();
|
||||
let lastCx = null, lastCz = null;
|
||||
let lastBuildMs = 0; // rolling measure for the HUD/notes
|
||||
|
||||
const _colliders = []; // scratch, refilled each getColliders()
|
||||
|
||||
function buildChunk(key) {
|
||||
if (live.has(key)) return;
|
||||
const data = index.get(key);
|
||||
if (!data) return;
|
||||
const { cx, cz } = parseKey(key);
|
||||
const t0 = performance.now();
|
||||
const buildings = buildChunkBuildings(data, ctx);
|
||||
const furniture = buildChunkFurniture(data, ctx, cx, cz);
|
||||
lastBuildMs = performance.now() - t0;
|
||||
scene.add(buildings.group, furniture.group);
|
||||
if (buildings.doorMesh) doorMeshes.push(buildings.doorMesh);
|
||||
live.set(key, { buildings, furniture, colliders: buildings.colliders, cx, cz });
|
||||
// optional lifecycle hook (Lane D/F per-chunk spawning, ambient, LOD — LANE_F_NOTES §8).
|
||||
// Inert unless a consumer sets ctx.onChunkBuilt; citizens currently drive off plan.streets instead.
|
||||
if (ctx.onChunkBuilt) ctx.onChunkBuilt(key, { cx, cz, buildings: buildings.group, furniture: furniture.group, data });
|
||||
}
|
||||
|
||||
function disposeChunk(key) {
|
||||
const b = live.get(key);
|
||||
if (!b) return;
|
||||
if (ctx.onChunkDisposed) ctx.onChunkDisposed(key, { cx: b.cx, cz: b.cz }); // before the groups are freed
|
||||
scene.remove(b.buildings.group, b.furniture.group);
|
||||
if (b.buildings.doorMesh) {
|
||||
const i = doorMeshes.indexOf(b.buildings.doorMesh);
|
||||
if (i >= 0) doorMeshes.splice(i, 1);
|
||||
}
|
||||
b.buildings.dispose();
|
||||
b.furniture.dispose();
|
||||
live.delete(key);
|
||||
}
|
||||
|
||||
function recompute(pcx, pcz) {
|
||||
// dispose anything beyond R+1
|
||||
for (const key of [...live.keys()]) {
|
||||
const { cx, cz } = parseKey(key);
|
||||
if (Math.max(Math.abs(cx - pcx), Math.abs(cz - pcz)) > DISPOSE_R) disposeChunk(key);
|
||||
}
|
||||
// enqueue desired-but-missing, nearest first
|
||||
const want = [];
|
||||
for (let dz = -R; dz <= R; dz++) {
|
||||
for (let dx = -R; dx <= R; dx++) {
|
||||
const key = chunkKey(pcx + dx, pcz + dz);
|
||||
if (index.has(key) && !live.has(key)) want.push({ key, d: dx * dx + dz * dz });
|
||||
}
|
||||
}
|
||||
want.sort((a, b) => a.d - b.d);
|
||||
queue = want.map((w) => w.key);
|
||||
queued = new Set(queue);
|
||||
}
|
||||
|
||||
function drainQueue() {
|
||||
if (!queue.length) return;
|
||||
const t0 = performance.now();
|
||||
while (queue.length && performance.now() - t0 < BUILD_BUDGET_MS) {
|
||||
const key = queue.shift();
|
||||
queued.delete(key);
|
||||
buildChunk(key);
|
||||
}
|
||||
}
|
||||
|
||||
function update(playerPos) {
|
||||
const pcx = chunkCoord(playerPos.x), pcz = chunkCoord(playerPos.z);
|
||||
if (pcx !== lastCx || pcz !== lastCz) { lastCx = pcx; lastCz = pcz; recompute(pcx, pcz); }
|
||||
drainQueue();
|
||||
}
|
||||
|
||||
// Prebuild everything within radius synchronously (used once at spawn so the player never
|
||||
// starts inside an unbuilt void).
|
||||
function warmup(playerPos) {
|
||||
const pcx = chunkCoord(playerPos.x), pcz = chunkCoord(playerPos.z);
|
||||
lastCx = pcx; lastCz = pcz;
|
||||
recompute(pcx, pcz);
|
||||
while (queue.length) { const key = queue.shift(); queued.delete(key); buildChunk(key); }
|
||||
}
|
||||
|
||||
// Colliders from the player's chunk + its 8 neighbours (refills a scratch array; no per-frame alloc).
|
||||
function getColliders(x, z) {
|
||||
const pcx = chunkCoord(x), pcz = chunkCoord(z);
|
||||
_colliders.length = 0;
|
||||
for (let dz = -1; dz <= 1; dz++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const b = live.get(chunkKey(pcx + dx, pcz + dz));
|
||||
if (b) for (let i = 0; i < b.colliders.length; i++) _colliders.push(b.colliders[i]);
|
||||
}
|
||||
}
|
||||
return _colliders;
|
||||
}
|
||||
|
||||
function setNight(night) {
|
||||
ctx.night = night;
|
||||
for (const b of live.values()) { b.buildings.applyNight(night); b.furniture.applyNight(night); }
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
for (const key of [...live.keys()]) disposeChunk(key);
|
||||
queue = []; queued.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
update, warmup, getColliders, setNight, dispose,
|
||||
getDoorMeshes: () => doorMeshes,
|
||||
get count() { return live.size; },
|
||||
get pending() { return queue.length; },
|
||||
get lastBuildMs() { return lastBuildMs; },
|
||||
};
|
||||
}
|
||||
114
web/js/world/dbg.js
Normal file
114
web/js/world/dbg.js
Normal file
@ -0,0 +1,114 @@
|
||||
// PROCITY Lane B — dbg.js
|
||||
// The QA debug harness hook (window.DBG), installed by the shell only with ?dbg=1. It drives Lane
|
||||
// F's tools/shots.py + tools/soak.py (LANE_F_NOTES §4) and mirrors 90sDJsim's window.DBG.shot()
|
||||
// contract: named camera bookmarks, teleport, time-of-day, scripted shop entry, and a perf/heap
|
||||
// readout. Every pose is derived from the LIVE plan so bookmarks work on any seed; the day/night
|
||||
// cycle is paused on the first scripted segment change so captures are deterministic.
|
||||
//
|
||||
// Lives in its own module (not the shell) so it can grow without touching index.html — the shell
|
||||
// just calls installDBG(deps) with the systems it owns when ?dbg=1 is present.
|
||||
|
||||
export function installDBG(deps) {
|
||||
const { plan, camera, renderer, composer, chunks, lighting, player, hud,
|
||||
setMode, getMode, enterShop } = deps;
|
||||
const CS = 64;
|
||||
|
||||
// reset the perf counters, then render one full composited frame (bloom + output) so the harness
|
||||
// captures exactly this frame and renderer.info reflects it — robust even when rAF is throttled.
|
||||
const render = () => { renderer.info.reset(); composer.render(); };
|
||||
|
||||
const info = () => {
|
||||
const r = renderer.info;
|
||||
return {
|
||||
drawCalls: r.render.calls, tris: r.render.triangles,
|
||||
fps: hud && hud.getFps ? hud.getFps() : null,
|
||||
heapMB: (performance.memory ? +(performance.memory.usedJSHeapSize / 1048576).toFixed(1) : null),
|
||||
geometries: r.memory.geometries, textures: r.memory.textures,
|
||||
chunk: `${Math.round(player.position.x / CS)},${Math.round(player.position.z / CS)}`,
|
||||
chunks: chunks.count, mode: getMode(),
|
||||
};
|
||||
};
|
||||
|
||||
// ── camera pose facing a shop's front ──
|
||||
// Front normal = local +Z rotated by lot.ry; the shopfront sits d/2 out from the lot centre, so we
|
||||
// stand `dist` m IN FRONT OF THE SHOPFRONT (not the centre — deep lots like the dept anchor would
|
||||
// otherwise put the camera inside the facade), offset slightly for a 3/4 angle, looking at it.
|
||||
const lotOf = (shop) => plan.lots?.find((l) => l.id === shop.lot);
|
||||
const poseForShop = (shop, dist = 10) => {
|
||||
const lot = shop && lotOf(shop); if (!lot) return null;
|
||||
const ry = lot.ry || 0, fx = Math.sin(ry), fz = Math.cos(ry); // front normal
|
||||
const rx = Math.cos(ry), rz = -Math.sin(ry); // right vector
|
||||
const half = (lot.d || 8) / 2, off = 1.0; // near dead-on (terraced neighbours flank the frame)
|
||||
const frontX = lot.x + fx * half, frontZ = lot.z + fz * half; // shopfront centre
|
||||
return {
|
||||
pos: [frontX + fx * dist + rx * off, 2.0, frontZ + fz * dist + rz * off],
|
||||
look: [frontX, 2.2, frontZ],
|
||||
};
|
||||
};
|
||||
const byDist = () => [...(plan.shops || [])].sort((a, b) => {
|
||||
const la = lotOf(a), lb = lotOf(b);
|
||||
return Math.hypot(la?.x || 0, la?.z || 0) - Math.hypot(lb?.x || 0, lb?.z || 0);
|
||||
});
|
||||
// nearest shop matching pred, else nearest shop overall → a pose is always produced
|
||||
const pickShop = (pred) => { const s = byDist(); return poseForShop(s.find(pred) || s[0]); };
|
||||
|
||||
// Named bookmarks (LANE_F_NOTES §4). seg = day segment index (0 DAWN … 5 NIGHT).
|
||||
// Predicates cover both Lane A's taxonomy (dept/stall/record/…) and the fixture's; each falls back
|
||||
// to the nearest shop, so every bookmark always yields a valid street pose.
|
||||
const BOOKMARKS = {
|
||||
street_noon: { seg: 2, resolve: () => pickShop(() => true) },
|
||||
arcade: { seg: 3, resolve: () => pickShop((s) => s.type === 'dept' || /arcade/.test(s.type)) },
|
||||
market_square: { seg: 1, resolve: () => pickShop((s) => s.type === 'stall' || s.type === 'market') },
|
||||
milkbar_dusk: { seg: 4, resolve: () => pickShop((s) => s.type === 'milkbar') },
|
||||
night_neon: { seg: 5, resolve: () => pickShop((s) => s.type === 'record' || s.type === 'video') },
|
||||
};
|
||||
|
||||
const DBG = {
|
||||
// true once the initial chunks are built and the queue is drained (textures may still decode)
|
||||
get ready() { return chunks.count > 0 && chunks.pending === 0; },
|
||||
bookmarks: Object.keys(BOOKMARKS),
|
||||
info,
|
||||
|
||||
// time-of-day (hands the day cycle to the harness so captures are deterministic)
|
||||
setSegment(seg) { lighting.setPaused(true); lighting.setSegment(seg | 0); return lighting.getClock(); },
|
||||
|
||||
// drive the soak walk: place the player and stream the destination in before the next step/shot
|
||||
teleport(x, z, ry = 0) {
|
||||
if (getMode() !== 'street') setMode('street');
|
||||
player.teleport(x, z, ry);
|
||||
chunks.warmup(player.position);
|
||||
render();
|
||||
return info();
|
||||
},
|
||||
|
||||
// snap to a named bookmark, settle, and return the frame's stats
|
||||
shot(name) {
|
||||
const bm = BOOKMARKS[name] || BOOKMARKS.street_noon;
|
||||
const pose = bm.resolve();
|
||||
if (getMode() !== 'street') setMode('street');
|
||||
if (bm.seg != null) { lighting.setPaused(true); lighting.setSegment(bm.seg); }
|
||||
if (pose) {
|
||||
player.teleport(pose.pos[0], pose.pos[2], 0);
|
||||
camera.position.set(pose.pos[0], pose.pos[1], pose.pos[2]);
|
||||
chunks.warmup(camera.position);
|
||||
camera.lookAt(pose.look[0], pose.look[1], pose.look[2]);
|
||||
camera.updateMatrixWorld();
|
||||
}
|
||||
render(); render(); // two frames so bloom + the segment change settle before capture
|
||||
return { name, ...info() };
|
||||
},
|
||||
|
||||
// scripted interior visit (drives the record_interior shot + the soak memory-baseline gate)
|
||||
enterShop(shopId) {
|
||||
const s = (plan.shops || []).find((x) => x.id === shopId);
|
||||
if (!s) return { error: `no shop ${shopId}` };
|
||||
enterShop(s.id, s.name);
|
||||
return { entered: s.id, name: s.name };
|
||||
},
|
||||
exitShop() { window.dispatchEvent(new CustomEvent('procity:exitShop')); return { exited: true }; },
|
||||
};
|
||||
|
||||
window.DBG = DBG;
|
||||
console.log('[procity] DBG harness ready (?dbg=1) — shot/teleport/setSegment/enterShop/exitShop/info/ready');
|
||||
return DBG;
|
||||
}
|
||||
211
web/js/world/fixture_plan.js
Normal file
211
web/js/world/fixture_plan.js
Normal file
@ -0,0 +1,211 @@
|
||||
// PROCITY Lane B — fixture_plan.js
|
||||
// A hand-authored CityPlan (CITY_SPEC schema v1) so the streetscape is buildable *before*
|
||||
// Lane A ships generatePlan(). Everything here is deterministic layout math (no Math.random,
|
||||
// no THREE) — pure JSON-serialisable data. When Lane A lands, index.html swaps in
|
||||
// generatePlan(seed) and this file becomes the fallback + schema reference.
|
||||
//
|
||||
// LAYOUT (metres, +Y up, ground = XZ, origin = centre of the main square):
|
||||
//
|
||||
// north (backstreets: houses up the cross street)
|
||||
// │ cross st (X=0)
|
||||
// NW shops (Z=+11, face −Z) ──────┼────── NE shops
|
||||
// ═══════════════ MAIN STREET (Z=0, east-west) ═══════════════
|
||||
// SW shops (Z=−11, face +Z) ──────┼────── SE shops
|
||||
//
|
||||
// Orientation convention (documented once, obeyed by buildings.js):
|
||||
// A lot is an oriented rectangle centred (x,z), footprint w (frontage) × d (depth).
|
||||
// `ry` rotates the building about +Y. At ry=0 the shopfront outward-normal is +Z.
|
||||
// Rotation by ry maps +Z → (sin ry, 0, cos ry), so:
|
||||
// front faces +Z → ry = 0 (south side of an E-W street)
|
||||
// front faces −Z → ry = π (north side of an E-W street)
|
||||
// front faces +X → ry = +π/2 (west side of a N-S street)
|
||||
// front faces −X → ry = −π/2 (east side of a N-S street)
|
||||
|
||||
import { rng, pick, irange } from '../core/prng.js';
|
||||
|
||||
// ── Shop-type registry (Lane A owns web/js/core/registry.js; it hasn't landed yet, so Lane B
|
||||
// carries the slice it needs. facade pools are the CITY_SPEC table. Lane F reconciles.) ──
|
||||
export const SHOP_REGISTRY = {
|
||||
record: { facades: ['timber-teal', 'arcade-tile', 'djsim-record'], signBg: '#1f2e2b', signFg: '#e8d9a0' },
|
||||
opshop: { facades: ['weatherboard', 'fibro-blue', 'djsim-opshop'], signBg: '#2c2820', signFg: '#f0e2c0' },
|
||||
toy: { facades: ['stucco-pink', 'deco-pastel'], signBg: '#7a1f5a', signFg: '#ffe6f2' },
|
||||
book: { facades: ['federation', 'sandstone'], signBg: '#2a231a', signFg: '#f3e6c4' },
|
||||
video: { facades: ['stripmall', 'djsim-video'], signBg: '#141a2c', signFg: '#8fb7ff' },
|
||||
pawn: { facades: ['besser', 'grimy', 'djsim-pawn'], signBg: '#f5c400', signFg: '#c1121f' },
|
||||
milkbar: { facades: ['djsim-milkbar', 'brickveneer'], signBg: '#b21f1f', signFg: '#fff3d6' },
|
||||
dept: { facades: ['terrazzo', 'djsim-dept'], signBg: '#20313a', signFg: '#ffd75e' },
|
||||
stall: { facades: ['market'], signBg: '#2c2820', signFg: '#f0e2c0' },
|
||||
};
|
||||
export const SHOP_TYPES = Object.keys(SHOP_REGISTRY);
|
||||
|
||||
// Parody-name word banks (kept small; deterministic pick per shop). 90s-AU flavour.
|
||||
const NAME_BANK = {
|
||||
record: { a: ['Groove', 'Wax', 'Vinyl', 'Spin', 'Needle', 'Rotary'], b: ['Junction', 'Merchants', 'Traders', 'Exchange', 'Bin'] },
|
||||
opshop: { a: ['Vinnies', 'Salvos', 'Second', 'Thrifty', 'Endeavour', 'Lifeline'], b: ['Chance', 'Op Shop', 'Finds', 'Bazaar', 'Nook'] },
|
||||
toy: { a: ['Toyworld', 'Kiddie', 'Wonder', 'Bright', 'Jolly'], b: ['Toys', 'Playtime', 'Corner', 'Emporium'] },
|
||||
book: { a: ['Dog-Eared', 'Second Story', 'Turning', 'Foxed', 'Marginalia'], b: ['Books', 'Book Barn', 'Reads', 'Pages'] },
|
||||
video: { a: ['Blockheads', 'Video', 'Reel', 'Nightowl', 'Civic'], b: ['Video', 'Rental', 'Ezy Hire', 'Movies'] },
|
||||
pawn: { a: ['Cash', 'Quick', 'Second Chance', 'City', 'Honest'], b: ['Converters', 'Pawn', 'Loans', 'Traders'] },
|
||||
milkbar: { a: ['Corner', 'Lucky', 'Rio', 'Astoria', 'Sunny'], b: ['Milk Bar', 'Deli', 'Cafe', 'Store'] },
|
||||
dept: { a: ['Coles', 'Waltons', 'Grace Bros', '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;
|
||||
192
web/js/world/furniture.js
Normal file
192
web/js/world/furniture.js
Normal file
@ -0,0 +1,192 @@
|
||||
// PROCITY Lane B — furniture.js
|
||||
// Instanced street furniture along the footpaths: streetlights (emissive lamp at night), benches,
|
||||
// bins, gum trees (crossed-plane billboards), bus stops. Seeded placement, one InstancedMesh per
|
||||
// type per chunk so a chunk's furniture is ~6 draw calls and disposes cleanly with the chunk.
|
||||
//
|
||||
// Every type has a PRIMITIVE fallback (house law) — the town is fully furnished with zero GLBs.
|
||||
// A 3GOD-depot upgrade path (promise-cached loadGLB → hot-swap) is documented at the bottom but
|
||||
// left OFF for v1 so async loads can never write into an unloaded chunk.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
||||
import { rng, frange } from '../core/prng.js';
|
||||
import { chunkOf, resolveEdges } from './planutil.js';
|
||||
|
||||
const FOOT = 3.5;
|
||||
|
||||
// ── shared primitive templates (built once, reused by every chunk; never disposed per-chunk) ──
|
||||
let _tpl = null;
|
||||
function templates() {
|
||||
if (_tpl) return _tpl;
|
||||
const box = (w, h, d, x = 0, y = 0, z = 0) => {
|
||||
const g = new THREE.BoxGeometry(w, h, d); g.translate(x, y, z); return g;
|
||||
};
|
||||
// streetlight pole + arm (dark). Lamp head is separate (emissive).
|
||||
const pole = mergeGeometries([
|
||||
box(0.12, 4.2, 0.12, 0, 2.1, 0),
|
||||
box(0.09, 0.09, 1.1, 0, 4.1, 0.55),
|
||||
], false);
|
||||
const lamp = box(0.34, 0.18, 0.34, 0, 4.05, 1.05);
|
||||
// bench: seat + back + two legs
|
||||
const bench = mergeGeometries([
|
||||
box(1.6, 0.08, 0.45, 0, 0.45, 0),
|
||||
box(1.6, 0.5, 0.06, 0, 0.72, -0.2),
|
||||
box(0.08, 0.45, 0.4, -0.7, 0.22, 0),
|
||||
box(0.08, 0.45, 0.4, 0.7, 0.22, 0),
|
||||
], false);
|
||||
// bin: short cylinder
|
||||
const bin = new THREE.CylinderGeometry(0.28, 0.24, 0.9, 10); bin.translate(0, 0.45, 0);
|
||||
// bus stop shelter: two posts + flat roof + a small bench
|
||||
const busstop = mergeGeometries([
|
||||
box(0.1, 2.3, 0.1, -1.3, 1.15, -0.5), box(0.1, 2.3, 0.1, 1.3, 1.15, -0.5),
|
||||
box(3.0, 0.12, 1.4, 0, 2.35, 0),
|
||||
box(2.4, 0.08, 0.4, 0, 0.5, -0.4),
|
||||
], false);
|
||||
// gum tree: crossed alpha-textured planes
|
||||
const treeGeo = mergeGeometries([
|
||||
new THREE.PlaneGeometry(3.2, 4.4).translate(0, 2.2, 0),
|
||||
new THREE.PlaneGeometry(3.2, 4.4).rotateY(Math.PI / 2).translate(0, 2.2, 0),
|
||||
], false);
|
||||
_tpl = { pole, lamp, bench, bin, busstop, treeGeo };
|
||||
return _tpl;
|
||||
}
|
||||
|
||||
// tree billboard texture (canvas — no font files, house law)
|
||||
let _treeTex = null;
|
||||
function treeTexture() {
|
||||
if (_treeTex) return _treeTex;
|
||||
const c = document.createElement('canvas'); c.width = c.height = 128;
|
||||
const x = c.getContext('2d');
|
||||
x.clearRect(0, 0, 128, 128);
|
||||
x.fillStyle = '#6a5a44'; x.fillRect(58, 74, 12, 54); // trunk
|
||||
const blobs = [[64, 44, 34], [44, 56, 24], [84, 56, 24], [64, 30, 22]];
|
||||
for (const [bx, by, br] of blobs) {
|
||||
x.fillStyle = ['#5c7a3e', '#6b8a48', '#4f6d38'][(bx + by) % 3];
|
||||
x.beginPath(); x.arc(bx, by, br, 0, Math.PI * 2); x.fill();
|
||||
}
|
||||
_treeTex = new THREE.CanvasTexture(c); _treeTex.colorSpace = THREE.SRGBColorSpace;
|
||||
return _treeTex;
|
||||
}
|
||||
|
||||
// ── shared materials (module-level; not disposed per chunk) ──
|
||||
let _mats = null;
|
||||
function materials() {
|
||||
if (_mats) return _mats;
|
||||
_mats = {
|
||||
dark: new THREE.MeshStandardMaterial({ color: 0x33322f, roughness: 0.7, metalness: 0.2 }),
|
||||
lamp: new THREE.MeshStandardMaterial({ color: 0x2a2a26, roughness: 0.5, emissive: new THREE.Color(0xffd9a0), emissiveIntensity: 0 }),
|
||||
wood: new THREE.MeshStandardMaterial({ color: 0x6b4a30, roughness: 0.8 }),
|
||||
bin: new THREE.MeshStandardMaterial({ color: 0x2f4a3a, roughness: 0.8 }),
|
||||
shelter: new THREE.MeshStandardMaterial({ color: 0x8a8f96, roughness: 0.7, metalness: 0.2 }),
|
||||
tree: new THREE.MeshBasicMaterial({ map: treeTexture(), transparent: true, alphaTest: 0.5, side: THREE.DoubleSide }),
|
||||
};
|
||||
return _mats;
|
||||
}
|
||||
|
||||
const _m = new THREE.Matrix4();
|
||||
const _p = new THREE.Vector3();
|
||||
const _q = new THREE.Quaternion();
|
||||
const _s = new THREE.Vector3(1, 1, 1);
|
||||
const _up = new THREE.Vector3(0, 1, 0);
|
||||
|
||||
/**
|
||||
* buildChunkFurniture(chunkData, ctx, cx, cz) → { group, applyNight, dispose }
|
||||
* ctx: { plan, citySeed, night }
|
||||
* Places furniture along every street edge, keeping only items whose position lands in this chunk.
|
||||
*/
|
||||
export function buildChunkFurniture(chunkData, ctx, cx, cz) {
|
||||
const group = new THREE.Group();
|
||||
group.name = 'chunk-furniture';
|
||||
const t = templates(), mat = materials();
|
||||
const edges = ctx._edges || (ctx._edges = resolveEdges(ctx.plan));
|
||||
|
||||
const lists = { pole: [], lamp: [], bench: [], bin: [], busstop: [], tree: [] };
|
||||
const here = (x, z) => { const c = chunkOf(x, z); return c.cx === cx && c.cz === cz; };
|
||||
|
||||
const pushYaw = (list, x, y, z, yaw, sx = 1, sy = 1, sz = 1) => {
|
||||
_p.set(x, y, z); _q.setFromAxisAngle(_up, yaw); _s.set(sx, sy, sz);
|
||||
list.push(_m.clone().compose(_p, _q, _s));
|
||||
};
|
||||
|
||||
for (const e of edges) {
|
||||
const dx = e.bx - e.ax, dz = e.bz - e.az;
|
||||
const len = Math.hypot(dx, dz) || 1;
|
||||
const ux = dx / len, uz = dz / len; // along
|
||||
const px = -uz, pz = ux; // perp (unit)
|
||||
const yaw = Math.atan2(ux, uz); // face along +Z→dir
|
||||
const r = rng(ctx.citySeed, 'furn', hashEdge(e.id));
|
||||
const halfRoad = e.width / 2;
|
||||
|
||||
// streetlights every ~18m, alternating sides, near the kerb
|
||||
let side = 1;
|
||||
for (let s = 8; s < len - 4; s += 18) {
|
||||
const off = (halfRoad + 0.7) * side;
|
||||
const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
|
||||
if (here(x, z)) {
|
||||
const armYaw = side > 0 ? yaw + Math.PI : yaw; // arm reaches over the road
|
||||
pushYaw(lists.pole, x, 0, z, armYaw);
|
||||
pushYaw(lists.lamp, x, 0, z, armYaw);
|
||||
}
|
||||
side = -side;
|
||||
}
|
||||
// benches + bins every ~26m on the building side of the footpath, facing the road
|
||||
for (let s = 14; s < len - 6; s += 26) {
|
||||
const bside = (Math.floor(s / 26) % 2) ? 1 : -1;
|
||||
const off = (halfRoad + FOOT - 0.8) * bside;
|
||||
const bx = e.ax + ux * s + px * off, bz = e.az + uz * s + pz * off;
|
||||
if (here(bx, bz)) pushYaw(lists.bench, bx, 0, bz, yaw + (bside > 0 ? Math.PI : 0));
|
||||
const nx = bx + ux * 1.4, nz = bz + uz * 1.4;
|
||||
if (here(nx, nz)) pushYaw(lists.bin, nx, 0, nz, 0);
|
||||
}
|
||||
// gum trees only along side/lane verges (main street has no verge room)
|
||||
if (e.kind === 'side' || e.kind === 'lane') {
|
||||
for (let s = 6; s < len - 4; s += 22) {
|
||||
for (const vs of [1, -1]) {
|
||||
const off = (halfRoad + FOOT + 1.2) * vs;
|
||||
const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
|
||||
if (here(x, z)) pushYaw(lists.tree, x, 0, z, frange(r, 0, Math.PI), 1, 1 + r() * 0.3, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// one bus stop near each main-street edge's near end
|
||||
if (e.kind === 'main') {
|
||||
const s = 10, off = (halfRoad + FOOT - 0.9);
|
||||
const x = e.ax + ux * s + px * off, z = e.az + uz * s + pz * off;
|
||||
if (here(x, z)) pushYaw(lists.busstop, x, 0, z, yaw + Math.PI);
|
||||
}
|
||||
}
|
||||
|
||||
const ims = [];
|
||||
const emit = (geo, material, list, cast = true) => {
|
||||
if (!list.length) return;
|
||||
const im = new THREE.InstancedMesh(geo, material, list.length);
|
||||
for (let i = 0; i < list.length; i++) im.setMatrixAt(i, list[i]);
|
||||
im.instanceMatrix.needsUpdate = true;
|
||||
im.castShadow = cast; im.receiveShadow = false; im.frustumCulled = true;
|
||||
group.add(im); ims.push(im);
|
||||
};
|
||||
emit(t.pole, mat.dark, lists.pole);
|
||||
emit(t.lamp, mat.lamp, lists.lamp, false);
|
||||
emit(t.bench, mat.wood, lists.bench);
|
||||
emit(t.bin, mat.bin, lists.bin);
|
||||
emit(t.busstop, mat.shelter, lists.busstop);
|
||||
emit(t.treeGeo, mat.tree, lists.tree, false);
|
||||
|
||||
function applyNight(night) { mat.lamp.emissiveIntensity = night ? 1.4 : 0; }
|
||||
applyNight(!!ctx.night);
|
||||
|
||||
function dispose() {
|
||||
for (const im of ims) { im.dispose && im.dispose(); group.remove(im); }
|
||||
// shared templates/materials intentionally NOT disposed (reused by other chunks)
|
||||
group.clear();
|
||||
}
|
||||
return { group, applyNight, dispose };
|
||||
}
|
||||
|
||||
function hashEdge(id) { const s = String(id); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h >>> 0; } // String(): generatePlan uses numeric edge ids
|
||||
|
||||
// ── OPTIONAL 3GOD-depot upgrade (documented, off for v1) ──────────────────────────────────────
|
||||
// import { loadGLB } from '../core/loaders.js';
|
||||
// To swap a primitive for a real GLB prop: loadGLB('depot:bench.glb').then(g => { if(!g) return;
|
||||
// ... build an InstancedMesh from g.scene's merged geometry and replace the primitive one ... });
|
||||
// Guard against the chunk having been disposed before the promise resolves (check a live flag).
|
||||
104
web/js/world/ground.js
Normal file
104
web/js/world/ground.js
Normal file
@ -0,0 +1,104 @@
|
||||
// PROCITY Lane B — ground.js
|
||||
// Roads, footpaths, kerbs, the market plaza, and a base ground plane, built ONCE as merged strips
|
||||
// per material (not per-chunk streamed). Rationale: the street graph is a handful of edges; merged
|
||||
// flat quads are a few draw calls total and — unlike per-chunk road clipping — can never seam.
|
||||
// UV tiling is baked into the geometry so one shared ground material serves strips of any length.
|
||||
// (If Lane A ships a many-hundred-edge city, Lane F revisits streaming the ground.)
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
||||
import { resolveEdges } from './planutil.js';
|
||||
|
||||
const FOOT = 3.5; // footpath width
|
||||
const TILE = 5; // metres per texture tile
|
||||
const OVERLAP = 1.5; // road segments overrun their nodes so intersections fill
|
||||
|
||||
// Horizontal quad in XZ centred at (cx,cz), aligned to unit dir/perp, size len×wid, at height y.
|
||||
// UVs baked to tile every TILE metres.
|
||||
function hQuad(cx, cz, dir, perp, len, wid, y) {
|
||||
const hl = len / 2, hw = wid / 2;
|
||||
const ax = dir.x * hl, az = dir.z * hl; // along
|
||||
const bx = perp.x * hw, bz = perp.z * hw; // across
|
||||
const P = (sa, sb) => [cx + sa * ax + sb * bx, y, cz + sa * az + sb * bz];
|
||||
const c0 = P(-1, -1), c1 = P(1, -1), c2 = P(1, 1), c3 = P(-1, 1);
|
||||
const repU = Math.max(1, len / TILE), repV = Math.max(1, wid / TILE);
|
||||
const g = new THREE.BufferGeometry();
|
||||
// winding c0,c2,c1 / c0,c3,c2 so the geometric normal points +Y (up) — FrontSide stays visible.
|
||||
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array([
|
||||
...c0, ...c2, ...c1, ...c0, ...c3, ...c2]), 3));
|
||||
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([
|
||||
0, 0, repU, repV, repU, 0, 0, 0, 0, repV, repU, repV]), 2));
|
||||
g.setAttribute('normal', new THREE.BufferAttribute(new Float32Array([
|
||||
0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]), 3));
|
||||
return g;
|
||||
}
|
||||
|
||||
// Axis-aligned rectangle helper (plaza, base plane).
|
||||
function rectGeo(cx, cz, w, d, y, tile = TILE) {
|
||||
return hQuad(cx, cz, new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 1), w, d, y);
|
||||
}
|
||||
|
||||
export function buildGround(plan, skins) {
|
||||
const group = new THREE.Group();
|
||||
group.name = 'ground';
|
||||
const edges = resolveEdges(plan);
|
||||
|
||||
const roadGeos = [], footGeos = [], kerbGeos = [];
|
||||
const dir = new THREE.Vector3(), perp = new THREE.Vector3();
|
||||
|
||||
for (const e of edges) {
|
||||
const dx = e.bx - e.ax, dz = e.bz - e.az;
|
||||
const len = Math.hypot(dx, dz) || 1;
|
||||
dir.set(dx / len, 0, dz / len);
|
||||
perp.set(-dir.z, 0, dir.x);
|
||||
const cx = (e.ax + e.bx) / 2, cz = (e.az + e.bz) / 2;
|
||||
const roadLen = len + OVERLAP * 2;
|
||||
roadGeos.push(hQuad(cx, cz, dir, perp, roadLen, e.width, 0.0));
|
||||
// footpaths flank the road
|
||||
const off = e.width / 2 + FOOT / 2;
|
||||
footGeos.push(hQuad(cx + perp.x * off, cz + perp.z * off, dir, perp, roadLen, FOOT, 0.02));
|
||||
footGeos.push(hQuad(cx - perp.x * off, cz - perp.z * off, dir, perp, roadLen, FOOT, 0.02));
|
||||
// low kerbs between road and footpath
|
||||
const koff = e.width / 2 + 0.12;
|
||||
kerbGeos.push(hQuad(cx + perp.x * koff, cz + perp.z * koff, dir, perp, roadLen, 0.24, 0.06));
|
||||
kerbGeos.push(hQuad(cx - perp.x * koff, cz - perp.z * koff, dir, perp, roadLen, 0.24, 0.06));
|
||||
}
|
||||
|
||||
// market plaza (brickpave) over the central square
|
||||
const sq = (plan.districts || []).find((d) => d.kind === 'market');
|
||||
const plazaGeos = [];
|
||||
if (sq) plazaGeos.push(rectGeo(0, 0, 46, 46, 0.015));
|
||||
|
||||
// base ground plane under the whole town (grass/dirt), sized to the plan extent
|
||||
let minX = -60, maxX = 60, minZ = -60, maxZ = 60;
|
||||
for (const n of plan.streets.nodes) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minZ = Math.min(minZ, n.z); maxZ = Math.max(maxZ, n.z); }
|
||||
for (const l of plan.lots) { minX = Math.min(minX, l.x); maxX = Math.max(maxX, l.x); minZ = Math.min(minZ, l.z); maxZ = Math.max(maxZ, l.z); }
|
||||
const bw = (maxX - minX) + 120, bd = (maxZ - minZ) + 120;
|
||||
const bcx = (minX + maxX) / 2, bcz = (minZ + maxZ) / 2;
|
||||
const baseGeo = rectGeo(bcx, bcz, bw, bd, -0.03);
|
||||
|
||||
const meshes = [];
|
||||
const add = (geos, material, receive = true) => {
|
||||
if (!geos.length) return;
|
||||
const merged = mergeGeometries(geos, false);
|
||||
geos.forEach((g) => g.dispose());
|
||||
const m = new THREE.Mesh(merged, material);
|
||||
m.receiveShadow = receive; m.castShadow = false;
|
||||
group.add(m); meshes.push(m);
|
||||
};
|
||||
|
||||
const roadSkin = 'djsim-road', footSkin = 'djsim-footpath', grassSkin = 'grass', plazaSkin = 'brickpave';
|
||||
add([baseGeo], skins.groundMat(grassSkin));
|
||||
add(roadGeos, skins.groundMat(roadSkin));
|
||||
add(footGeos, skins.groundMat(footSkin));
|
||||
add(plazaGeos, skins.groundMat(plazaSkin));
|
||||
const kerbMat = new THREE.MeshStandardMaterial({ color: 0x9a948c, roughness: 0.9 });
|
||||
add(kerbGeos, kerbMat);
|
||||
|
||||
function dispose() {
|
||||
for (const m of meshes) m.geometry.dispose();
|
||||
kerbMat.dispose();
|
||||
group.clear();
|
||||
}
|
||||
return { group, dispose };
|
||||
}
|
||||
120
web/js/world/hud.js
Normal file
120
web/js/world/hud.js
Normal file
@ -0,0 +1,120 @@
|
||||
// PROCITY Lane B — hud.js
|
||||
// The street HUD: crosshair, shop-name tooltip (throttled centre-screen raycast, far = 6m),
|
||||
// time-of-day + seed + live perf readout (renderer.info: draw calls & triangles), and the
|
||||
// door-click → `procity:enterShop` hand-off. Creates its own DOM overlay so index.html stays thin.
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
const CSS = `
|
||||
#pc-hud{position:fixed;inset:0;pointer-events:none;font:13px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#f4efe6;z-index:10}
|
||||
#pc-cross{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px 0 0 -3px;border-radius:50%;background:#fff;opacity:.7;box-shadow:0 0 3px #000}
|
||||
#pc-tip{position:absolute;left:50%;top:calc(50% + 22px);transform:translateX(-50%);background:rgba(20,16,10,.82);padding:6px 12px;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity .12s;text-shadow:0 1px 2px #000}
|
||||
#pc-dbg{position:absolute;right:10px;top:10px;text-align:right;background:rgba(20,16,10,.55);padding:8px 11px;border-radius:8px;min-width:150px}
|
||||
#pc-dbg b{color:#ffd75e;font-weight:600}
|
||||
#pc-dbg .warn{color:#ff9a6a}
|
||||
#pc-clock{font-size:15px;margin-bottom:3px}
|
||||
#pc-toast{position:absolute;left:50%;bottom:64px;transform:translateX(-50%);background:rgba(20,16,10,.9);padding:10px 18px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;text-shadow:0 1px 2px #000}
|
||||
#pc-help{position:absolute;left:12px;bottom:12px;background:rgba(20,16,10,.5);padding:7px 11px;border-radius:8px;opacity:.85}
|
||||
`;
|
||||
|
||||
export function createHUD({ camera, renderer, plan, getDoorMeshes, onEnterShop }) {
|
||||
if (!document.getElementById('pc-hud-style')) {
|
||||
const st = document.createElement('style'); st.id = 'pc-hud-style'; st.textContent = CSS;
|
||||
document.head.appendChild(st);
|
||||
}
|
||||
const root = document.createElement('div'); root.id = 'pc-hud';
|
||||
root.innerHTML = `
|
||||
<div id="pc-cross"></div>
|
||||
<div id="pc-tip"></div>
|
||||
<div id="pc-dbg">
|
||||
<div id="pc-clock">—</div>
|
||||
<div>town <b id="pc-town">—</b></div>
|
||||
<div>seed <b id="pc-seed">—</b></div>
|
||||
<div>fps <b id="pc-fps">—</b></div>
|
||||
<div>draws <b id="pc-draws">—</b> · tris <b id="pc-tris">—</b></div>
|
||||
<div>chunks <b id="pc-chunks">—</b></div>
|
||||
</div>
|
||||
<div id="pc-toast"></div>
|
||||
<div id="pc-help">WASD move · shift run · mouse look · click a door · [ ] time · P shot · Esc release</div>`;
|
||||
document.body.appendChild(root);
|
||||
const $ = (id) => root.querySelector('#' + id);
|
||||
const elTip = $('pc-tip'), elClock = $('pc-clock'), elFps = $('pc-fps'), elDraws = $('pc-draws'),
|
||||
elTris = $('pc-tris'), elChunks = $('pc-chunks'), elToast = $('pc-toast');
|
||||
$('pc-town').textContent = plan.name || '—';
|
||||
$('pc-seed').textContent = plan.citySeed;
|
||||
|
||||
const ray = new THREE.Raycaster();
|
||||
ray.far = 6;
|
||||
const centre = new THREE.Vector2(0, 0);
|
||||
let hovered = null; // { shopId, name }
|
||||
let frame = 0, fpsAcc = 0, fpsCount = 0, fpsShown = 0;
|
||||
|
||||
function nearestDoor(rects, point) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const r of rects) {
|
||||
const d = (r.x - point.x) ** 2 + (r.z - point.z) ** 2;
|
||||
if (d < bd) { bd = d; best = r; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function raycastDoor() {
|
||||
const meshes = getDoorMeshes ? getDoorMeshes() : [];
|
||||
if (!meshes.length) { hovered = null; return; }
|
||||
ray.setFromCamera(centre, camera);
|
||||
const hits = ray.intersectObjects(meshes, false);
|
||||
if (hits.length) {
|
||||
const h = hits[0];
|
||||
const rects = h.object.userData.doorRects || [];
|
||||
const door = nearestDoor(rects, h.point);
|
||||
hovered = door ? { shopId: door.shopId, name: door.name } : null;
|
||||
} else {
|
||||
hovered = null;
|
||||
}
|
||||
}
|
||||
|
||||
function update(dt, info) {
|
||||
// fps (smoothed over ~0.4s)
|
||||
fpsAcc += dt; fpsCount++;
|
||||
if (fpsAcc >= 0.4) { fpsShown = Math.round(fpsCount / fpsAcc); fpsAcc = 0; fpsCount = 0; }
|
||||
|
||||
frame++;
|
||||
if (frame % 6 === 0) raycastDoor();
|
||||
|
||||
// tooltip
|
||||
if (hovered) { elTip.textContent = `🚪 ${hovered.name} — click to enter`; elTip.style.opacity = 1; }
|
||||
else elTip.style.opacity = 0;
|
||||
|
||||
// debug readout
|
||||
if (info) {
|
||||
if (info.clock) elClock.textContent = `${info.clock.label} ${info.clock.hour}${info.clock.night ? ' 🌙' : ''}`;
|
||||
elFps.textContent = fpsShown;
|
||||
const draws = renderer.info.render.calls, tris = renderer.info.render.triangles;
|
||||
elDraws.innerHTML = draws > 300 ? `<span class="warn">${draws}</span>` : draws;
|
||||
elTris.innerHTML = tris > 200000 ? `<span class="warn">${(tris / 1000 | 0)}k</span>` : `${(tris / 1000 | 0)}k`;
|
||||
if (info.chunks != null) elChunks.textContent = info.chunks;
|
||||
}
|
||||
}
|
||||
|
||||
let toastT = 0;
|
||||
function showToast(msg, secs = 2.6) {
|
||||
elToast.textContent = msg; elToast.style.opacity = 1; toastT = secs;
|
||||
}
|
||||
function tickToast(dt) { if (toastT > 0) { toastT -= dt; if (toastT <= 0) elToast.style.opacity = 0; } }
|
||||
|
||||
// door click → enter shop (only when hovering one)
|
||||
function onClick() {
|
||||
if (hovered && onEnterShop) onEnterShop(hovered.shopId, hovered.name);
|
||||
}
|
||||
renderer.domElement.addEventListener('click', onClick);
|
||||
|
||||
function setVisible(v) { root.style.display = v ? 'block' : 'none'; }
|
||||
|
||||
function dispose() {
|
||||
renderer.domElement.removeEventListener('click', onClick);
|
||||
root.remove();
|
||||
}
|
||||
|
||||
return { update, tickToast, showToast, setVisible, dispose, getHovered: () => hovered,
|
||||
getFps: () => fpsShown };
|
||||
}
|
||||
113
web/js/world/lighting.js
Normal file
113
web/js/world/lighting.js
Normal file
@ -0,0 +1,113 @@
|
||||
// PROCITY Lane B — lighting.js
|
||||
// Sky dome (seeded sky-*.jpg on a BackSide sphere) + a 6-segment day/night cycle ported from
|
||||
// 90sDJsim's applyLighting: one hemisphere ambient + one directional sun (single shadow map that
|
||||
// follows the player) + a night flag that drives window/lamp emissives (via a callback into the
|
||||
// ChunkManager). Linear fog tuned to the streaming radius doubles as the pop-in mask.
|
||||
//
|
||||
// Global, not chunked. The sky dome and sun re-centre on the player each frame so the world
|
||||
// never runs out from under you.
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
// [name, sunI, sunColor, hemiSky, hemiGround, hemiI, skyTint, fogColor, exposure, night, sunOffset]
|
||||
const SEG = [
|
||||
['DAWN', 0.75, 0xffd9b0, 0xbcd0e8, 0x8a7a60, 0.75, 0xdcdce8, 0xcfc6ba, 1.05, false, [80, 26, 14]],
|
||||
['MORNING', 1.20, 0xffe8c8, 0xcfe0f2, 0x9a8a6c, 0.95, 0xffffff, 0xd6d6cc, 1.00, false, [50, 46, 22]],
|
||||
['MIDDAY', 1.55, 0xffffff, 0xd8e8ff, 0xa89878, 1.10, 0xffffff, 0xe0e2da, 0.98, false, [12, 72, 12]],
|
||||
['ARVO', 1.15, 0xffdca0, 0xc8d4e8, 0xa08858, 1.00, 0xf2e8d8, 0xe0cdb0, 1.02, false, [-42, 46, 20]],
|
||||
['DUSK', 0.55, 0xff9860, 0x9a86b0, 0x6a4a3a, 0.72, 0xb89a8a, 0xa07860, 1.10, false, [-74, 16, 10]],
|
||||
['NIGHT', 0.10, 0x8faaff, 0x2a3050, 0x14100c, 0.38, 0x1b2038, 0x0a0c18, 1.18, true, [-40, 66, 22]],
|
||||
];
|
||||
const HOUR = ['06:30', '09:00', '12:30', '15:30', '18:30', '22:00'];
|
||||
|
||||
export function createLighting(scene, plan, skins, { radius = 3, shadows = true } = {}) {
|
||||
const group = new THREE.Group();
|
||||
group.name = 'lighting';
|
||||
scene.add(group);
|
||||
|
||||
// sky dome — seeded photo skin, darkened at night via material colour
|
||||
const skyName = plan.sky || 'golden-arvo';
|
||||
const skyMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.BackSide, depthWrite: false, fog: false });
|
||||
skins.skyTex(skyName, (t) => { skyMat.map = t; skyMat.needsUpdate = true; });
|
||||
const dome = new THREE.Mesh(new THREE.SphereGeometry(650, 24, 14), skyMat);
|
||||
group.add(dome);
|
||||
|
||||
const hemi = new THREE.HemisphereLight(0xd8e8ff, 0xa89878, 1.0);
|
||||
group.add(hemi);
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
|
||||
sun.castShadow = shadows; // dense cities skip the shadow pass (≈halves draw calls; doc allows "none")
|
||||
sun.shadow.mapSize.set(2048, 2048);
|
||||
sun.shadow.bias = -0.0004;
|
||||
const SH = 60; // half-extent of the shadow frustum around the player
|
||||
Object.assign(sun.shadow.camera, { left: -SH, right: SH, top: SH, bottom: -SH, near: 1, far: 260 });
|
||||
sun.shadow.camera.updateProjectionMatrix();
|
||||
group.add(sun, sun.target);
|
||||
|
||||
// fog fades distant chunks out just before the streaming radius, hiding pop-in
|
||||
const fog = new THREE.Fog(0xd6d6cc, 40, (radius + 0.4) * 64);
|
||||
scene.fog = fog;
|
||||
|
||||
const _skyTint = new THREE.Color();
|
||||
const _fogTint = new THREE.Color();
|
||||
let seg = 2, night = false;
|
||||
let clock = seg + 0.001, paused = false;
|
||||
const DAY_SECONDS = 240; // one full cycle
|
||||
let onNightChange = null;
|
||||
let renderer = null;
|
||||
|
||||
function apply(i) {
|
||||
const [, sunI, sunC, hSky, hGnd, hI, skyTint, fogC, exposure, isNight] = SEG[i];
|
||||
sun.intensity = sunI; sun.color.set(sunC);
|
||||
hemi.intensity = hI; hemi.color.set(hSky); hemi.groundColor.set(hGnd);
|
||||
_skyTint.set(skyTint); skyMat.color.copy(_skyTint);
|
||||
_fogTint.set(fogC); fog.color.copy(_fogTint);
|
||||
scene.background = _fogTint.clone(); // horizon colour behind the (finite) dome
|
||||
if (renderer) renderer.toneMappingExposure = exposure;
|
||||
const was = night; night = isNight; seg = i;
|
||||
if (isNight !== was && onNightChange) onNightChange(isNight);
|
||||
}
|
||||
|
||||
function setSegment(i) { clock = ((i % SEG.length) + SEG.length) % SEG.length + 0.001; apply(i); }
|
||||
function stepSegment(d) { setSegment((seg + d + SEG.length) % SEG.length); }
|
||||
|
||||
const _sunOff = new THREE.Vector3();
|
||||
function update(dt, playerPos) {
|
||||
if (!paused) {
|
||||
clock = (clock + (dt / DAY_SECONDS) * SEG.length) % SEG.length;
|
||||
const i = Math.floor(clock);
|
||||
if (i !== seg) apply(i);
|
||||
}
|
||||
// re-centre dome + sun on the player
|
||||
dome.position.copy(playerPos);
|
||||
const off = SEG[seg][10];
|
||||
_sunOff.set(off[0], off[1], off[2]).multiplyScalar(0.8);
|
||||
sun.position.copy(playerPos).add(_sunOff);
|
||||
sun.target.position.copy(playerPos);
|
||||
sun.target.updateMatrixWorld();
|
||||
}
|
||||
|
||||
function getClock() {
|
||||
const frac = clock - Math.floor(clock);
|
||||
return { seg, label: SEG[seg][0], hour: HOUR[seg], night, frac };
|
||||
}
|
||||
|
||||
function attachRenderer(r) { renderer = r; r.toneMappingExposure = SEG[seg][8]; }
|
||||
|
||||
function dispose() {
|
||||
scene.fog = null;
|
||||
dome.geometry.dispose(); skyMat.dispose();
|
||||
scene.remove(group);
|
||||
}
|
||||
|
||||
apply(seg);
|
||||
return {
|
||||
group, sun, hemi,
|
||||
update, setSegment, stepSegment, getClock, attachRenderer, dispose,
|
||||
setPaused: (p) => { paused = p; },
|
||||
togglePaused: () => (paused = !paused),
|
||||
isNight: () => night,
|
||||
get segCount() { return SEG.length; },
|
||||
set onNightChange(fn) { onNightChange = fn; },
|
||||
get onNightChange() { return onNightChange; },
|
||||
};
|
||||
}
|
||||
82
web/js/world/minimap.js
Normal file
82
web/js/world/minimap.js
Normal file
@ -0,0 +1,82 @@
|
||||
// PROCITY Lane B — minimap.js
|
||||
// The `map` mode: a 2D top-down town directory drawn from the CityPlan (streets + lots coloured by
|
||||
// shop type + a live player dot/heading). This is the shell's own lightweight map; Lane A's
|
||||
// map.html debug view grows into the richer directory later (Lane F reconciles).
|
||||
|
||||
const TYPE_COLOR = {
|
||||
record: '#8a5aa8', opshop: '#8a7a5a', toy: '#c86aa0', book: '#7a6a3a', video: '#4a6ab0',
|
||||
pawn: '#c4a028', milkbar: '#c4863a', dept: '#5a6a7a', stall: '#5a8a6a',
|
||||
house: '#6b5a48', anchor: '#5a6a7a',
|
||||
};
|
||||
|
||||
export function createMinimap(plan) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.id = 'pc-map';
|
||||
wrap.style.cssText = 'position:fixed;inset:0;z-index:20;display:none;background:#141712;'
|
||||
+ 'align-items:center;justify-content:center;flex-direction:column;font:13px -apple-system,sans-serif;color:#e8e0d0';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 720; canvas.height = 720;
|
||||
canvas.style.cssText = 'background:#1b1e17;border-radius:10px;max-width:92vw;max-height:80vh;box-shadow:0 8px 40px #000';
|
||||
const cap = document.createElement('div');
|
||||
cap.style.cssText = 'margin-top:12px;opacity:.8';
|
||||
cap.textContent = `${plan.name} — seed ${plan.citySeed} · press M to close`;
|
||||
wrap.append(canvas, cap);
|
||||
document.body.appendChild(wrap);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// world→canvas transform (fit plan extent with margin)
|
||||
let minX = -60, maxX = 60, minZ = -60, maxZ = 60;
|
||||
for (const n of plan.streets.nodes) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minZ = Math.min(minZ, n.z); maxZ = Math.max(maxZ, n.z); }
|
||||
for (const l of plan.lots) { minX = Math.min(minX, l.x); maxX = Math.max(maxX, l.x); minZ = Math.min(minZ, l.z); maxZ = Math.max(maxZ, l.z); }
|
||||
const pad = 24, W = canvas.width, H = canvas.height;
|
||||
const span = Math.max(maxX - minX, maxZ - minZ) + 40;
|
||||
const cxw = (minX + maxX) / 2, czw = (minZ + maxZ) / 2;
|
||||
const sc = (W - pad * 2) / span;
|
||||
const tx = (x) => W / 2 + (x - cxw) * sc;
|
||||
const tz = (z) => H / 2 + (z - czw) * sc;
|
||||
|
||||
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
|
||||
const shopByLot = new Map(plan.shops.map((s) => [s.lot, s]));
|
||||
|
||||
function drawStatic() {
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.fillStyle = '#1b1e17'; ctx.fillRect(0, 0, W, H);
|
||||
// streets
|
||||
for (const e of plan.streets.edges) {
|
||||
const a = nodes.get(e.a), b = nodes.get(e.b);
|
||||
ctx.strokeStyle = '#3a3f34'; ctx.lineWidth = Math.max(3, e.width * sc);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath(); ctx.moveTo(tx(a.x), tz(a.z)); ctx.lineTo(tx(b.x), tz(b.z)); ctx.stroke();
|
||||
}
|
||||
// lots
|
||||
for (const l of plan.lots) {
|
||||
const s = shopByLot.get(l.id);
|
||||
const col = TYPE_COLOR[s ? s.type : l.use] || '#666';
|
||||
ctx.save();
|
||||
ctx.translate(tx(l.x), tz(l.z)); ctx.rotate(-(l.ry || 0));
|
||||
ctx.fillStyle = col;
|
||||
ctx.fillRect(-l.w * sc / 2, -l.d * sc / 2, l.w * sc, l.d * sc);
|
||||
// front-edge tick (toward the street)
|
||||
ctx.fillStyle = 'rgba(255,255,255,.5)';
|
||||
ctx.fillRect(-l.w * sc / 2, l.d * sc / 2 - 2, l.w * sc, 2);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function draw(playerPos, fwd) {
|
||||
drawStatic();
|
||||
const x = tx(playerPos.x), z = tz(playerPos.z);
|
||||
// heading (world +X→right, +Z→down on the map)
|
||||
ctx.strokeStyle = '#ffd75e'; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(x, z);
|
||||
ctx.lineTo(x + fwd.x * 16, z + fwd.z * 16); ctx.stroke();
|
||||
ctx.fillStyle = '#ffd75e';
|
||||
ctx.beginPath(); ctx.arc(x, z, 6, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
|
||||
return {
|
||||
draw,
|
||||
setVisible: (v) => { wrap.style.display = v ? 'flex' : 'none'; },
|
||||
dispose: () => wrap.remove(),
|
||||
};
|
||||
}
|
||||
116
web/js/world/planutil.js
Normal file
116
web/js/world/planutil.js
Normal file
@ -0,0 +1,116 @@
|
||||
// PROCITY Lane B — planutil.js
|
||||
// Pure plan/geometry helpers over a CityPlan (no THREE). Chunk math (CITY_SPEC: 64m cells),
|
||||
// node/lot/shop indices, edge resolution, and the oriented-rectangle collider for a lot.
|
||||
// If Lane A later ships an official chunkIndex(plan) we swap the import; the shapes match.
|
||||
|
||||
export const CHUNK = 64; // metres (CITY_SPEC law)
|
||||
|
||||
export const chunkCoord = (v) => Math.floor(v / CHUNK);
|
||||
export const chunkKey = (cx, cz) => `${cx},${cz}`;
|
||||
export const chunkOf = (x, z) => ({ cx: chunkCoord(x), cz: chunkCoord(z) });
|
||||
export const parseKey = (k) => { const [cx, cz] = k.split(',').map(Number); return { cx, cz }; };
|
||||
|
||||
// Fast lookup maps built once per plan.
|
||||
export function indexPlan(plan) {
|
||||
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
|
||||
const lots = new Map(plan.lots.map((l) => [l.id, l]));
|
||||
const shopByLot = new Map(plan.shops.map((s) => [s.lot, s]));
|
||||
const shopsById = new Map(plan.shops.map((s) => [s.id, s]));
|
||||
return { nodes, lots, shopByLot, shopsById };
|
||||
}
|
||||
|
||||
// Resolve street edges to world segments with widths.
|
||||
export function resolveEdges(plan) {
|
||||
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
|
||||
return plan.streets.edges.map((e) => {
|
||||
const a = nodes.get(e.a), b = nodes.get(e.b);
|
||||
return { id: e.id, kind: e.kind, width: e.width, ax: a.x, az: a.z, bx: b.x, bz: b.z };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* chunkIndex(plan) → Map<chunkKey, { lots:[lot], shops:[shop], edges:[edgeId] }>
|
||||
* A lot belongs to the chunk containing its centre. An edge is registered to every chunk its
|
||||
* segment passes through (rasterised at CHUNK/4 steps) so the streamer can draw partial roads
|
||||
* if it ever needs to. Shops ride along with their lot.
|
||||
*/
|
||||
export function chunkIndex(plan) {
|
||||
const { shopByLot } = indexPlan(plan);
|
||||
const map = new Map();
|
||||
const cell = (cx, cz) => {
|
||||
const k = chunkKey(cx, cz);
|
||||
let c = map.get(k);
|
||||
if (!c) { c = { lots: [], shops: [], edges: [] }; map.set(k, c); }
|
||||
return c;
|
||||
};
|
||||
for (const lot of plan.lots) {
|
||||
const { cx, cz } = chunkOf(lot.x, lot.z);
|
||||
const c = cell(cx, cz);
|
||||
c.lots.push(lot);
|
||||
const s = shopByLot.get(lot.id);
|
||||
if (s) c.shops.push(s);
|
||||
}
|
||||
const nodes = new Map(plan.streets.nodes.map((n) => [n.id, n]));
|
||||
for (const e of plan.streets.edges) {
|
||||
const a = nodes.get(e.a), b = nodes.get(e.b);
|
||||
const dx = b.x - a.x, dz = b.z - a.z;
|
||||
const len = Math.hypot(dx, dz) || 1;
|
||||
const ux = dx / len, uz = dz / len, px = -uz, pz = ux; // along + perpendicular units
|
||||
// Register every chunk the road BAND touches — not just the centreline — so furniture placed
|
||||
// at a perpendicular offset (footpath/verge) can never fall in an unbuilt chunk and vanish.
|
||||
const band = e.width / 2 + 10;
|
||||
const along = Math.max(1, Math.ceil(len / (CHUNK / 4)));
|
||||
const across = Math.max(1, Math.ceil((band * 2) / (CHUNK / 4)));
|
||||
const seen = new Set();
|
||||
for (let i = 0; i <= along; i++) {
|
||||
const sx = a.x + dx * (i / along), sz = a.z + dz * (i / along);
|
||||
for (let j = 0; j <= across; j++) {
|
||||
const o = -band + (2 * band) * (j / across);
|
||||
const { cx, cz } = chunkOf(sx + px * o, sz + pz * o);
|
||||
const k = chunkKey(cx, cz);
|
||||
if (!seen.has(k)) { seen.add(k); cell(cx, cz).edges.push(e.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Oriented-rectangle collider for a lot (local X = frontage w, local Z = depth d, rotated ry).
|
||||
// Yards/houses get a fatter keep-out so you can't walk through private front gardens.
|
||||
export function lotCollider(lot) {
|
||||
const yard = lot.use === 'house' || lot.use === 'yard';
|
||||
const pad = yard ? 2.5 : 0; // private-yard buffer in front of houses
|
||||
return {
|
||||
x: lot.x, z: lot.z, ry: lot.ry || 0,
|
||||
hw: lot.w / 2, hd: lot.d / 2 + pad / 2,
|
||||
use: lot.use,
|
||||
};
|
||||
}
|
||||
|
||||
// Test/point-push a circle of radius r at (px,pz) out of an oriented rect collider.
|
||||
// Returns [nx, nz] corrected position (or the same point if no overlap). Reused every frame —
|
||||
// pass scratch out-array to avoid allocation.
|
||||
export function pushOutRect(px, pz, r, col, out) {
|
||||
// Convention must match buildings.toWorld (local→world = RotY(ry)): dx = lx·cos + lz·sin,
|
||||
// dz = −lx·sin + lz·cos. So world→local is the inverse (RotY(−ry)) below, and the push-out
|
||||
// back-transform is RotY(ry). (Earlier this used the wrong sign and only happened to work for
|
||||
// axis-aligned ry ∈ {0,±π/2,π}; arbitrary ry from generatePlan needs the correct inverse.)
|
||||
const cos = Math.cos(col.ry), sin = Math.sin(col.ry);
|
||||
const dx = px - col.x, dz = pz - col.z;
|
||||
const lx = dx * cos - dz * sin; // world→local (RotY(−ry))
|
||||
const lz = dx * sin + dz * cos;
|
||||
const ex = col.hw + r, ez = col.hd + r;
|
||||
if (lx > -ex && lx < ex && lz > -ez && lz < ez) {
|
||||
// overlapping — push along axis of least penetration
|
||||
const penX = ex - Math.abs(lx);
|
||||
const penZ = ez - Math.abs(lz);
|
||||
let nlx = lx, nlz = lz;
|
||||
if (penX < penZ) nlx = lx < 0 ? -ex : ex;
|
||||
else nlz = lz < 0 ? -ez : ez;
|
||||
out[0] = col.x + (nlx * cos + nlz * sin); // local→world (RotY(ry)), inverse of the above
|
||||
out[1] = col.z + (-nlx * sin + nlz * cos);
|
||||
return true;
|
||||
}
|
||||
out[0] = px; out[1] = pz;
|
||||
return false;
|
||||
}
|
||||
82
web/js/world/player.js
Normal file
82
web/js/world/player.js
Normal file
@ -0,0 +1,82 @@
|
||||
// PROCITY Lane B — player.js
|
||||
// PointerLockControls first-person walk (WASD + shift-run), eye at 1.7m, with push-out collision
|
||||
// against the oriented-rectangle colliders of the nearby chunks (buildings + private yards).
|
||||
// No navmesh — the town is rectangles, so a couple of push-out passes is plenty. All scratch
|
||||
// vectors are reused; the update loop allocates nothing.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
|
||||
import { pushOutRect } from './planutil.js';
|
||||
|
||||
const EYE = 1.7;
|
||||
const RADIUS = 0.38; // player collision radius
|
||||
const WALK = 4.6, RUN = 8.8;
|
||||
|
||||
export function createPlayer(camera, domElement, { getColliders }) {
|
||||
camera.position.y = EYE;
|
||||
const controls = new PointerLockControls(camera, domElement);
|
||||
|
||||
const _dir = new THREE.Vector3();
|
||||
const _fwd = new THREE.Vector3();
|
||||
const _right = new THREE.Vector3();
|
||||
const _move = new THREE.Vector3();
|
||||
const _out = [0, 0];
|
||||
let enabled = true;
|
||||
|
||||
// keys: a live Set of held key codes (index.html fills it)
|
||||
function update(dt, keys) {
|
||||
if (!enabled) return;
|
||||
// horizontal forward/right from camera yaw
|
||||
camera.getWorldDirection(_dir);
|
||||
_fwd.set(_dir.x, 0, _dir.z);
|
||||
if (_fwd.lengthSq() < 1e-6) _fwd.set(0, 0, -1);
|
||||
_fwd.normalize();
|
||||
_right.set(-_fwd.z, 0, _fwd.x); // camera-right = normalize(cross(forward, up)) on XZ
|
||||
|
||||
let f = 0, s = 0;
|
||||
if (keys.has('KeyW') || keys.has('ArrowUp')) f += 1;
|
||||
if (keys.has('KeyS') || keys.has('ArrowDown')) f -= 1;
|
||||
if (keys.has('KeyD') || keys.has('ArrowRight')) s += 1;
|
||||
if (keys.has('KeyA') || keys.has('ArrowLeft')) s -= 1;
|
||||
|
||||
_move.set(0, 0, 0);
|
||||
if (f || s) {
|
||||
_move.addScaledVector(_fwd, f).addScaledVector(_right, s);
|
||||
if (_move.lengthSq() > 1e-6) _move.normalize();
|
||||
const speed = (keys.has('ShiftLeft') || keys.has('ShiftRight')) ? RUN : WALK;
|
||||
_move.multiplyScalar(speed * dt);
|
||||
}
|
||||
|
||||
let px = camera.position.x + _move.x;
|
||||
let pz = camera.position.z + _move.z;
|
||||
|
||||
// collision: two push-out passes so inner corners resolve
|
||||
const cols = getColliders ? getColliders(px, pz) : null;
|
||||
if (cols && cols.length) {
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
if (pushOutRect(px, pz, RADIUS, cols[i], _out)) { px = _out[0]; pz = _out[1]; }
|
||||
}
|
||||
}
|
||||
}
|
||||
camera.position.x = px;
|
||||
camera.position.z = pz;
|
||||
camera.position.y = EYE;
|
||||
}
|
||||
|
||||
function teleport(x, z, yaw = 0) {
|
||||
camera.position.set(x, EYE, z);
|
||||
camera.rotation.set(0, yaw, 0, 'YXZ');
|
||||
}
|
||||
|
||||
return {
|
||||
controls,
|
||||
update,
|
||||
teleport,
|
||||
get position() { return camera.position; },
|
||||
setEnabled: (v) => { enabled = v; },
|
||||
lock: () => controls.lock(),
|
||||
unlock: () => controls.unlock(),
|
||||
get isLocked() { return controls.isLocked; },
|
||||
};
|
||||
}
|
||||
189
web/js/world/skins.js
Normal file
189
web/js/world/skins.js
Normal file
@ -0,0 +1,189 @@
|
||||
// PROCITY Lane B — skins.js
|
||||
// The city-wide shared-material registry. Every facade/ground/awning skin resolves to ONE
|
||||
// cached material used by every chunk (CITY_SPEC: ≤25 skin materials city-wide, share by skin).
|
||||
// House law: seeded flat-colour fallback UNDER every texture — the town looks right with zero
|
||||
// assets. We use a small onLoad-capable texture cache (not core/loaders.loadTex) precisely so we
|
||||
// can keep the flat fallback visible until the JPEG arrives, then swap the map in. GLB depot
|
||||
// loading stays on core/loaders (furniture.js).
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { xmur3 } from '../core/prng.js';
|
||||
|
||||
const ASSET_BASE = 'assets/gen/';
|
||||
|
||||
// ── muted 90s-AU fallback palettes (used until — or instead of — the JPEG skin) ──
|
||||
const FACADE_FALLBACK = ['#c9b8a0', '#b0b8ab', '#c4b0b0', '#a8b4c0', '#c4bc9c', '#cab89e', '#b8a890', '#bfae96'];
|
||||
const GROUND_FALLBACK = [
|
||||
[/asphalt|road/, '#4a4a46'], [/footpath|djsim-footpath/, '#8b857a'], [/grass/, '#5d6b45'],
|
||||
[/brickpave/, '#8a6a58'], [/gravel/, '#9a948a'], [/reddust|dust/, '#a8664a'],
|
||||
];
|
||||
const AWNING_FALLBACK = [[/red/, '#a23b32'], [/green/, '#3a6b45'], [/blue/, '#3a5b8b']];
|
||||
|
||||
const hashName = (s) => xmur3(s)();
|
||||
const fallbackFacade = (name) => FACADE_FALLBACK[hashName(name) % FACADE_FALLBACK.length];
|
||||
const keyword = (name, table, dflt) => (table.find(([re]) => re.test(name)) || [null, dflt])[1];
|
||||
|
||||
/**
|
||||
* createSkinLibrary(opts) — one per game session. Materials cached by skin name and shared by
|
||||
* every chunk; dispose() frees textures/materials at teardown.
|
||||
*/
|
||||
export function createSkinLibrary({ assetBase = ASSET_BASE } = {}) {
|
||||
const texCache = new Map(); // url → THREE.Texture (with fallback colour on its owning material)
|
||||
const matCache = new Map(); // `kind:name` → material
|
||||
const disposables = [];
|
||||
|
||||
// onLoad-capable, cached texture loader. Applies to a material via a swap callback so the
|
||||
// flat fallback colour shows until the image is decoded, then map pops in.
|
||||
const texLoader = new THREE.TextureLoader();
|
||||
function skinTex(url, { repeat, srgb = true, onReady } = {}) {
|
||||
let tex = texCache.get(url);
|
||||
if (tex) { if (onReady && tex.image) onReady(tex); return tex; }
|
||||
tex = texLoader.load(
|
||||
url,
|
||||
(t) => { t.needsUpdate = true; onReady && onReady(t); },
|
||||
undefined,
|
||||
() => console.warn('[skins] tex missing, flat fallback stays:', url),
|
||||
);
|
||||
if (srgb) tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.anisotropy = 4;
|
||||
if (repeat) { tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(...repeat); }
|
||||
texCache.set(url, tex);
|
||||
disposables.push(tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
function facadeMat(name) {
|
||||
// [Lane F integration fix] Lane A's registry/plan store facadeSkin as a full filename
|
||||
// ("facade-fibro-blue.jpg"); this fn builds `assets/gen/facade-<name>.jpg`, so a raw filename
|
||||
// double-wraps to `facade-facade-…jpg.jpg` (every facade 404 → flat colour). Normalize to the
|
||||
// bare key — idempotent for a value that is already bare (e.g. the 'weatherboard' fallback).
|
||||
name = String(name).replace(/\.(jpe?g|png|webp)$/i, '').replace(/^facade-/, '');
|
||||
const key = `facade:${name}`;
|
||||
let m = matCache.get(key);
|
||||
if (m) return m;
|
||||
m = new THREE.MeshStandardMaterial({ color: new THREE.Color(fallbackFacade(name)), roughness: 0.92, metalness: 0 });
|
||||
skinTex(`${assetBase}facade-${name}.jpg`, { onReady: (t) => { m.map = t; m.color.set(0xffffff); m.needsUpdate = true; } });
|
||||
matCache.set(key, m); disposables.push(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
// ── facade atlas ────────────────────────────────────────────────────────────────────────────
|
||||
// ONE texture + ONE material for EVERY facade skin, so a chunk's facades merge into a single draw
|
||||
// (vs one mesh per distinct skin — the biggest draw-call term in dense districts) AND ~25 facade
|
||||
// materials collapse to 1. Each slot is pre-painted with the skin's flat fallback colour (house
|
||||
// law: the town is right with zero JPEGs), then the decoded JPEG is drawn over it. UV convention
|
||||
// mirrors the per-chunk sign atlas (CanvasTexture flipY ⇒ v0 = bottom of slot). The stretch of a
|
||||
// square skin across a wide-short shopfront matches the pre-atlas [0,1]² mapping — no regression.
|
||||
const ATLAS_DIM = 2048, ATLAS_COLS = 6, ATLAS_ROWS = 6, SLOT = Math.floor(ATLAS_DIM / ATLAS_COLS);
|
||||
let atlasCanvas = null, atlasCtx = null, atlasTex = null, atlasMat = null, atlasNext = 0;
|
||||
const atlasSlot = new Map(); // bare skin name → [u0,v0,u1,v1]
|
||||
|
||||
function ensureFacadeAtlas() {
|
||||
if (atlasMat) return;
|
||||
atlasCanvas = document.createElement('canvas');
|
||||
atlasCanvas.width = atlasCanvas.height = ATLAS_DIM;
|
||||
atlasCtx = atlasCanvas.getContext('2d');
|
||||
atlasCtx.fillStyle = '#8a7f6f'; atlasCtx.fillRect(0, 0, ATLAS_DIM, ATLAS_DIM);
|
||||
atlasTex = new THREE.CanvasTexture(atlasCanvas);
|
||||
atlasTex.colorSpace = THREE.SRGBColorSpace; atlasTex.anisotropy = 4;
|
||||
atlasTex.generateMipmaps = false; atlasTex.minFilter = THREE.LinearFilter; // NPOT-safe, no cross-slot mip bleed
|
||||
atlasMat = new THREE.MeshStandardMaterial({ map: atlasTex, roughness: 0.92, metalness: 0 });
|
||||
disposables.push(atlasTex, atlasMat);
|
||||
}
|
||||
|
||||
function facadeAtlasMaterial() { ensureFacadeAtlas(); return atlasMat; }
|
||||
|
||||
// UV sub-rect for a skin; assigns + paints a slot on first request (fallback colour now, JPEG on load).
|
||||
function facadeAtlasUV(name) {
|
||||
ensureFacadeAtlas();
|
||||
name = String(name).replace(/\.(jpe?g|png|webp)$/i, '').replace(/^facade-/, ''); // accept bare key OR full filename
|
||||
let uv = atlasSlot.get(name);
|
||||
if (uv) return uv;
|
||||
if (atlasNext >= ATLAS_COLS * ATLAS_ROWS) console.warn('[skins] facade atlas full (>36 skins), reusing a slot for', name);
|
||||
const idx = atlasNext++ % (ATLAS_COLS * ATLAS_ROWS);
|
||||
const col = idx % ATLAS_COLS, row = (idx / ATLAS_COLS) | 0;
|
||||
const px = col * SLOT, py = row * SLOT;
|
||||
atlasCtx.fillStyle = fallbackFacade(name); atlasCtx.fillRect(px, py, SLOT, SLOT);
|
||||
atlasTex.needsUpdate = true;
|
||||
const img = new Image(); // same-origin JPEG → canvas never tainted
|
||||
img.onload = () => { atlasCtx.drawImage(img, px, py, SLOT, SLOT); atlasTex.needsUpdate = true; };
|
||||
img.onerror = () => {}; // fallback colour already painted — house law holds
|
||||
img.src = `${assetBase}facade-${name}.jpg`;
|
||||
const g = 0.5 / ATLAS_DIM; // ½px guard against sampling the neighbouring slot
|
||||
uv = [px / ATLAS_DIM + g, 1 - (py + SLOT) / ATLAS_DIM + g, (px + SLOT) / ATLAS_DIM - g, 1 - py / ATLAS_DIM - g];
|
||||
atlasSlot.set(name, uv);
|
||||
return uv;
|
||||
}
|
||||
|
||||
// Ground materials tile via RepeatWrapping; ground.js bakes the tile scale into the mesh UVs,
|
||||
// so one shared material serves strips of any length.
|
||||
function groundMat(name) {
|
||||
const key = `ground:${name}`;
|
||||
let m = matCache.get(key);
|
||||
if (m) return m;
|
||||
m = new THREE.MeshStandardMaterial({ color: new THREE.Color(keyword(name, GROUND_FALLBACK, '#6b6b64')), roughness: 0.97, metalness: 0 });
|
||||
const t = skinTex(`${assetBase}ground-${name}.jpg`, { repeat: [1, 1], onReady: (tx) => { m.map = tx; m.color.set(0xffffff); m.needsUpdate = true; } });
|
||||
t.wrapS = t.wrapT = THREE.RepeatWrapping;
|
||||
matCache.set(key, m); disposables.push(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
function awningMat(name) {
|
||||
const key = `awning:${name}`;
|
||||
let m = matCache.get(key);
|
||||
if (m) return m;
|
||||
m = new THREE.MeshStandardMaterial({ color: new THREE.Color(keyword(name, AWNING_FALLBACK, '#8a4b3a')), roughness: 0.85, metalness: 0, side: THREE.DoubleSide });
|
||||
skinTex(`${assetBase}tex-awning-${name}.jpg`, { repeat: [3, 1], onReady: (t) => { m.map = t; m.color.set(0xffffff); m.needsUpdate = true; } });
|
||||
matCache.set(key, m); disposables.push(m);
|
||||
return m;
|
||||
}
|
||||
// Shared awning material for a single instanced mesh per chunk (per-instance colour), so all three
|
||||
// awning skins draw in one call instead of one-per-skin. The stripe JPEG sits on the awning's top
|
||||
// face (unseen from under the verandah), so the flat instance colour reads the same at street level.
|
||||
let _awn = null;
|
||||
function awningMaterial() {
|
||||
if (!_awn) { _awn = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0, side: THREE.DoubleSide }); disposables.push(_awn); }
|
||||
return _awn;
|
||||
}
|
||||
function awningColor(name) { return keyword(String(name), AWNING_FALLBACK, '#8a4b3a'); }
|
||||
|
||||
// Generic wallpaper / interior surface (used lightly for house facades until Lane C).
|
||||
function texMat(name, { repeat = [1, 1], rough = 0.9 } = {}) {
|
||||
const key = `tex:${name}:${repeat.join('x')}`;
|
||||
let m = matCache.get(key);
|
||||
if (m) return m;
|
||||
m = new THREE.MeshStandardMaterial({ color: 0xbdb2a2, roughness: rough });
|
||||
skinTex(`${assetBase}tex-${name}.jpg`, { repeat, onReady: (t) => { m.map = t; m.color.set(0xffffff); m.needsUpdate = true; } });
|
||||
matCache.set(key, m); disposables.push(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
// Sky dome texture for lighting.js (own material there; we just supply the cached texture).
|
||||
function skyTex(name, onReady) {
|
||||
return skinTex(`${assetBase}sky-${name}.jpg`, { onReady });
|
||||
}
|
||||
|
||||
// Shared shell material for InstancedMesh building boxes — per-instance colour via instanceColor,
|
||||
// so ONE material draws every shell in a chunk. Not cached per-name (there is only one).
|
||||
let _shell = null;
|
||||
function shellMaterial() {
|
||||
if (!_shell) { _shell = new THREE.MeshStandardMaterial({ roughness: 0.9, metalness: 0 }); disposables.push(_shell); }
|
||||
return _shell;
|
||||
}
|
||||
// Shared trim material (parapets, posts, kerbs) — plain instanced, tinted per instance.
|
||||
let _trim = null;
|
||||
function trimMaterial() {
|
||||
if (!_trim) { _trim = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0 }); disposables.push(_trim); }
|
||||
return _trim;
|
||||
}
|
||||
|
||||
function count() { return matCache.size + (atlasMat ? 1 : 0); } // the atlas is the single shared facade material
|
||||
|
||||
function dispose() {
|
||||
for (const d of disposables) d.dispose && d.dispose();
|
||||
texCache.clear(); matCache.clear(); disposables.length = 0;
|
||||
}
|
||||
|
||||
return { facadeMat, facadeAtlasMaterial, facadeAtlasUV, groundMat, awningMat, awningMaterial, awningColor,
|
||||
texMat, skyTex, shellMaterial, trimMaterial, skinTex, count, dispose, fallbackFacadeColor: fallbackFacade };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user