Lane A (CityGen): deterministic town generator, registry, names, 2D map, self-check
Layer-1 CityPlan generator per CITY_SPEC schema v1 — pure data, no THREE, all randomness
through core/prng, deterministic + JSON-serializable + <1ms.
- web/js/citygen/plan.js: generatePlan(citySeed) → CityPlan (spine, cross-streets, market
square + dept anchor, arcade, warehouse fringe, residential collar w/ corner milkbars,
laneways) + chunkIndex (Amanatides–Woo edge supercover) + corner overlap-resolution pass.
- web/js/core/registry.js: SHOP_TYPES (all 9) + district weights + enums (shared vocabulary).
- web/js/citygen/names.js + wordlists.js: seeded 90s-AU shop/town names, 50+ patterns.
- web/js/citygen/index.js: canonical barrel entry (adopts Lane F's integration shim).
- web/map.html: standalone Canvas-2D plan debugger (pan/zoom/hover, layers, export JSON).
- web/js/citygen/selfcheck.js: node acceptance harness — determinism, golden fingerprint,
facing, within- AND cross-block no-overlap, chunk+edge coverage, finiteness, assets. ALL GREEN
(1271/1271) over 6 seeds. web/package.json ({"type":"module"}) lets node run it.
- docs/shots/laneA/: 5 seeds screenshotted, each reads as a town.
Fixes from an adversarial multi-agent review (15 confirmed defects): market facades faced the
wrong way; cross-block corner overlaps; chunkIndex skipped chunks incl. the origin/spine;
stall frontEdge z-band; doubled-possessive names; unparseable map road colour; +others.
CITY_SPEC amendment (treaty, same commit): shop-types table aligned to the registry lanes
import; documented lot.ry/frontEdge, block.kind/district-id, and web/package.json.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
a702a69f1e
commit
8b7ca9aae4
126
docs/A-progress.md
Normal file
126
docs/A-progress.md
Normal file
@ -0,0 +1,126 @@
|
||||
# LANE A — CITYGEN · progress (PROCITY-A)
|
||||
|
||||
*Status: **all deliverables landed & self-check all-green.** Awaiting Fable review.*
|
||||
*Date: 2026-07-14 · owner: PROCITY-A (Opus 4.8)*
|
||||
|
||||
## TL;DR
|
||||
|
||||
`generatePlan(citySeed) → CityPlan` is done: a deterministic, JSON-serializable, <1ms pure-data
|
||||
town generator matching CITY_SPEC schema v1, plus `chunkIndex`, the shared shop-type registry, a
|
||||
seeded 90s-AU name generator, a Canvas-2D map debugger, and a node self-check that asserts the
|
||||
whole acceptance contract. `node web/js/citygen/selfcheck.js` prints **ALL GREEN (1082/1082)**.
|
||||
Five seeds render as recognisable towns (screenshots in `docs/shots/laneA/`).
|
||||
|
||||
## Deliverables (all present)
|
||||
|
||||
| # | file | what |
|
||||
|---|---|---|
|
||||
| 1 | [web/js/citygen/plan.js](../web/js/citygen/plan.js) | `generatePlan(citySeed)`, `chunkIndex(plan)`, `CHUNK`, `chunkKey` |
|
||||
| 2 | [web/js/citygen/names.js](../web/js/citygen/names.js) + [wordlists.js](../web/js/citygen/wordlists.js) | seeded shop+town names, 50+ patterns, short signboard forms |
|
||||
| 3 | [web/js/core/registry.js](../web/js/core/registry.js) | `SHOP_TYPES` (all 9), district weights, enums, pure helpers |
|
||||
| 4 | [web/map.html](../web/map.html) | standalone Canvas-2D plan viewer (pan/zoom/hover, layer toggles, seed box, regen, export JSON, chunk grid) |
|
||||
| 5 | [web/js/citygen/selfcheck.js](../web/js/citygen/selfcheck.js) | `node web/js/citygen/selfcheck.js` — full acceptance harness |
|
||||
| + | [web/package.json](../web/package.json) | `{"type":"module"}` so `node` runs the self-check as ESM (see *Treaty notes*) |
|
||||
| + | `docs/shots/laneA/seed-*.png` | 5 seeds screenshotted (20261990, 1, 42, 777, 8675309) |
|
||||
|
||||
## What the generator produces (matches the CITY_SPEC design brief)
|
||||
|
||||
- **Main-street spine** — 7 stations S→N through the origin, x-jitter ±30m, 28m corridor, `kind:'main'`.
|
||||
Continuous narrow-frontage (6–9m) retail both sides.
|
||||
- **Cross streets** at 4 stations; the two central ones are wider "second high streets", the outer
|
||||
two are grittier `backstreets`. All `kind:'side'`, ±8° jitter.
|
||||
- **Market square** west of the origin — a tidy 5×8 grid of `stall` lots + the `dept` anchor
|
||||
fronting the spine.
|
||||
- **Arcade** — a `kind:'arcade'` (width 5) pedestrian lane cutting east through a mid-spine block,
|
||||
lined both sides with tiny 3–5m record-heavy shops.
|
||||
- **Warehouse fringe** — sparse big lots beyond one spine end (N or S, seeded), mostly `infill`
|
||||
with the odd pawn shop.
|
||||
- **Residential collar** — a loose ring road of `house` lots with **2–4 corner milk bars** embedded.
|
||||
- **Laneways** (`kind:'lane'`) behind the central blocks, with `yard` lots for back-door flavour.
|
||||
|
||||
Sample (seed 20261990 → "Boolarra Heads"): 27 nodes, 22 edges, 35 blocks, 711 lots, 523 shops, 152 chunks.
|
||||
|
||||
## Self-check coverage (`node web/js/citygen/selfcheck.js`)
|
||||
|
||||
Determinism (two runs byte-identical) · <100ms (actually <1ms) · every edge→real nodes · every block→real
|
||||
district · every lot→real block + **valid frontEdge** + positive size · every shop→real lot + known type +
|
||||
**facade skin exists on disk** + sane hours + named · one-shop-per-lot · **no overlapping lots within a
|
||||
block** (rotated-rect OBB / SAT test) · **chunkIndex covers every lot** & buckets reference real ids ·
|
||||
JSON round-trip lossless · design-brief presence (spine, arcade, market, stalls, dept anchor, 2–4 milkbars) ·
|
||||
all registry + used facades exist in `web/assets/gen/`. Run over 6 seeds → **1082/1082**.
|
||||
|
||||
## House-law compliance
|
||||
|
||||
- **Zero `Math.random` and zero THREE imports in `js/citygen/*` and `registry.js`** (grep-clean).
|
||||
All randomness flows through `web/js/core/prng.js`. (map.html uses `Math.random` only to pick a
|
||||
*random seed to view* — the generation it then runs is fully seeded; the map is pure Canvas 2D,
|
||||
no THREE.)
|
||||
- JSON-serializable, deterministic, <100ms — verified.
|
||||
- Registry is flat data every lane imports; facade pools are real files in `web/assets/gen/`.
|
||||
|
||||
## Treaty / cross-lane notes for Fable
|
||||
|
||||
1. **`web/package.json` added.** Needed so `node web/js/citygen/selfcheck.js` runs the ES modules
|
||||
(browsers use `<script type=module>`; node needs `"type":"module"`). It's additive infra —
|
||||
python `http.server` and browsers ignore it, no deps, no build. Not in the ownership table;
|
||||
flagging for your blessing. Other lanes can now node-test their pure modules too.
|
||||
2. **Schema extensions (non-breaking).** Added `kind` to each `block` (its district-kind string,
|
||||
alongside the spec's `district` **id**) so the map & Lane B can theme a block without a district
|
||||
lookup. `block.district` is the **district id**; `districts[id].kind` is the kind. Everything
|
||||
else matches schema v1 exactly.
|
||||
3. **`ry` convention (Lane B please read):** a lot's `ry` is the Y-rotation so a GLB modelled
|
||||
facing **−Z** ends up with its facade's outward normal pointing at its `frontEdge` street.
|
||||
Derivation + a worked example are in `plan.js` (`marchStrip`). `frontEdge` is a valid edge id
|
||||
for every lot (stalls/dept front the nearest spine edge).
|
||||
4. **`pipeline/gen_names.py` intentionally NOT shipped.** The Lane A brief offered an *optional*
|
||||
Ollama word-expansion script, but `pipeline/*` is **Lane E's** owned dir per the CITY_SPEC
|
||||
ownership table. Wordlists are hand-authored and checked in; runtime does zero network calls.
|
||||
If you want the Ollama expansion, it should live in Lane E's pipeline or be re-assigned.
|
||||
|
||||
## How to eyeball it
|
||||
|
||||
```
|
||||
cd web && python3 -m http.server 8130
|
||||
# http://localhost:8130/map.html (default seed)
|
||||
# http://localhost:8130/map.html?seed=42 (any uint32 seed; ?seed= drives the screenshot harness)
|
||||
node web/js/citygen/selfcheck.js # acceptance harness, prints ALL GREEN
|
||||
```
|
||||
|
||||
## Open questions for Fable
|
||||
|
||||
- Retail density: a core seed yields ~500 shops (continuous frontage, as briefed). Happy with that,
|
||||
or dial down for Lane B's draw-call budget? (It's one constant per band; trivial to tune.)
|
||||
- Want the residential collar to be a true closed ring vs the current 4-edge loop? (Current reads
|
||||
fine on the map; a fuller street graph is a later enhancement.)
|
||||
|
||||
## Adversarial review + fixes (round 1)
|
||||
|
||||
Ran a 6-dimension multi-agent review of the whole lane (27 agents: determinism · schema/spec ·
|
||||
geometry · chunk/JSON/perf · cross-lane contract · code quality), each finding independently
|
||||
verified by a skeptic agent that ran the real generator to confirm or refute. **21 findings raised,
|
||||
15 confirmed real, 6 refuted.** All confirmed defects are now **fixed and re-verified**; the
|
||||
self-check grew new assertions so none of these classes can regress silently.
|
||||
|
||||
| # | defect (confirmed) | sev | fix |
|
||||
|---|---|---|---|
|
||||
| D1 | market stalls + dept anchor faced **backwards** (`ryEast` sign inverted → facades pointed west, away from the spine they front) | HIGH | `ryEast = atan2(-1,0)`; self-check now asserts every lot faces its `frontEdge` |
|
||||
| D2 | **cross-block lot overlaps** at spine×cross-street corners (≈45 building-lot pairs/seed, ≤8.7m) — self-check only tested *within* a block | HIGH | reserve corner (rung near-spine inset 14→34m) + a deterministic overlap-resolution pass (demote later lot to `infill`); self-check now runs **global** cross-block OBB/SAT over building lots |
|
||||
| D3 | `chunkIndex` **skipped chunks** (32m point-sampling on a 64m grid); the origin chunk under the spine was omitted; road width ignored | HIGH | Amanatides–Woo grid supercover + kerb rails (centreline ± half-width); self-check now asserts edge coverage vs a dense supercover |
|
||||
| D4 | market stalls' `frontEdge` pointed at the wrong spine segment (z-band mismatch, up to 122m off) | MED | stalls → `spineEdges[2]`, dept → `spineEdges[3]` |
|
||||
| D5 | name generator emitted **doubled possessives** (`Macca’s’s`) — `{First}*’s` fired both the `*` and the literal `’s` | HIGH | dropped the redundant `*`; simplified `fill()`; self-check rejects unresolved tokens |
|
||||
| D6 | map.html drew the main road with `var(--roadmain)`, which canvas can't parse → the spine rendered near-invisible | MED | pass the bare custom-property name to `getcss()` (now a proper brown spine) |
|
||||
| D7 | registry facades/fittings drifted from the CITY_SPEC table | LOW | **amended CITY_SPEC's shop-types table** (in this commit, flagged) to match the registry lanes import |
|
||||
| + | `_sbl` Map cache was memoized onto the plan → polluted exported JSON with `"_sbl":{}` | LOW | moved the cache to a module-level var, reset per regen |
|
||||
| + | `cornerBoost` could push single-storey types (video/milkbar) above their registry max | LOW | gated to types with registry max ≥ 2 |
|
||||
| + | no golden-fingerprint / cross-revision drift guard; lot coords never checked finite | — | added a committed golden hash for seed 20261990 + finiteness assertions |
|
||||
| + | undocumented `block.kind` / `block.district`-is-id; dead stall guard | — | documented the schema in CITY_SPEC; removed dead code |
|
||||
|
||||
**Refuted (6)** — investigated and dismissed with reasons, e.g. "the determinism test is a
|
||||
tautology" (true that it only catches in-process nondeterminism — so I *added* the golden fingerprint
|
||||
for real drift detection) and "`package.json` isn't in the ownership table" (correct, but it's
|
||||
required infra — now added to the table).
|
||||
|
||||
**Post-fix self-check:** `✓ ALL GREEN — 1271/1271` (was 1082; +189 checks are the new
|
||||
facing/cross-block-overlap/edge-coverage/finiteness/fingerprint assertions). Seed 20261990 →
|
||||
"Boolarra Heads": 27 nodes, 22 edges, 35 blocks, 681 lots, 493 shops, 174 chunks. All 5 seed
|
||||
screenshots re-captured. Golden fingerprint `0xb5d5cc13`.
|
||||
@ -63,14 +63,26 @@ Schema v1 (Lane A owns it; extend, don't break):
|
||||
nodes: [ { id, x, z } ],
|
||||
edges: [ { id, a, b, width, kind } ] // kind: 'main' | 'side' | 'lane' | 'arcade'
|
||||
},
|
||||
blocks: [ { id, district, poly: [[x,z],…] } ],
|
||||
blocks: [ { id, district, kind, poly: [[x,z],…] } ],
|
||||
// district = district ID (index into districts[]); kind = that district's kind string, denormalized
|
||||
// onto the block so consumers can theme it without a lookup (Lane A extension, non-breaking).
|
||||
lots: [ { id, block, x, z, w, d, ry, frontEdge, use } ],
|
||||
// use: 'shop' | 'anchor' | 'house' | 'yard' | 'stall' | 'infill'
|
||||
// ry: Y-rotation (radians) so a GLB modelled facing −Z ends up with its facade's OUTWARD normal
|
||||
// pointing at frontEdge. World facing = (−sin ry, −cos ry). Lane B rotates the shell by ry.
|
||||
// frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent).
|
||||
shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close] } ]
|
||||
// type: see SHOP TYPES below
|
||||
// type: see SHOP TYPES below. hours: [open, close] with 0 ≤ open < close ≤ 23.
|
||||
}
|
||||
```
|
||||
|
||||
> **Layer-1 invariants Lane A guarantees** (enforced by `web/js/citygen/selfcheck.js`): fully
|
||||
> deterministic per `citySeed` (with a committed golden fingerprint guarding against drift); every
|
||||
> `frontEdge`/`block`/`district`/`lot` reference resolves; every lot faces its `frontEdge`; **no two
|
||||
> building lots (`shop`/`anchor`/`house`/`stall`) overlap, within OR across blocks**; `chunkIndex`
|
||||
> covers every lot and lists every edge in every chunk its road crosses; strictly JSON round-trippable
|
||||
> (all finite numbers, no stray keys).
|
||||
|
||||
Chunk key: `cx = floor(x/64)`, `cz = floor(z/64)`. **Chunk size 64m.** Lane A ships a
|
||||
`chunkIndex(plan)` helper: chunk key → { lots, shops, edges } touching it.
|
||||
|
||||
@ -97,16 +109,23 @@ night in v1; interior-mapping shader is a stretch goal).
|
||||
|
||||
## Shop types v1 (registry lives in `web/js/core/registry.js`, Lane A writes it, all lanes read)
|
||||
|
||||
> **`web/js/core/registry.js` is the machine-readable source of truth** — import `SHOP_TYPES` for
|
||||
> the exact facade pools, sign hints, interior archetype, fittings, storeys, hours and district
|
||||
> weights. This table mirrors it. *(Lane A amendment 2026-07-14: added a variety facade to
|
||||
> `toy`/`book`/`pawn` and rounded out the fittings mixes — a `counter` to shops that sell over one,
|
||||
> a `freezer` to milkbar, a `glass_case`+`counter` to dept — so this table matches the registry
|
||||
> lanes actually consume. Non-breaking; all facades exist in `web/assets/gen/`.)*
|
||||
|
||||
| type | facade pool | interior archetype | fittings mix |
|
||||
|---|---|---|---|
|
||||
| `record` | timber-teal, arcade-tile, djsim-record | crates + bins | record bins, crates, counter, listening corner |
|
||||
| `opshop` | weatherboard, fibro-blue, djsim-opshop | racks + shelves | clothes racks, bric-a-brac shelving, book wall |
|
||||
| `toy` | stucco-pink, deco-pastel | shelves + glass case | cube shelves, display tables, glass case |
|
||||
| `book` | federation, sandstone | halls of shelves | bookshelves, spinner racks, armchair |
|
||||
| `video` | stripmall, djsim-video | aisle shelves | VHS shelving, returns slot, poster wall |
|
||||
| `pawn` | besser, grimy, djsim-pawn | counter-forward | glass cabinets, wall hooks, barred window |
|
||||
| `milkbar` | djsim-milkbar, brickveneer | counter + freezer | counter, fridge, magazine rack |
|
||||
| `dept` (anchor) | terrazzo, djsim-dept | grand hall | mixed sections, escalator prop |
|
||||
| `opshop` | weatherboard, fibro-blue, djsim-opshop | racks + shelves | clothes racks, bric-a-brac shelving, book wall, counter |
|
||||
| `toy` | stucco-pink, deco-pastel, boutique | shelves + glass case | cube shelves, display tables, glass case, counter |
|
||||
| `book` | federation, sandstone, redbrick | halls of shelves | bookshelves, spinner racks, armchair, counter |
|
||||
| `video` | stripmall, djsim-video | aisle shelves | VHS shelving, returns slot, poster wall, counter |
|
||||
| `pawn` | besser, grimy, djsim-pawn, corrugated | counter-forward | glass cabinets, wall hooks, barred window, counter |
|
||||
| `milkbar` | djsim-milkbar, brickveneer | counter + freezer | counter, fridge, magazine rack, freezer |
|
||||
| `dept` (anchor) | terrazzo, djsim-dept | grand hall | mixed sections, escalator prop, glass case, counter |
|
||||
| `stall` (market) | facade-market | open stall | trestle tables, crates |
|
||||
|
||||
## NPC contract (Lane D)
|
||||
@ -144,11 +163,18 @@ night in v1; interior-mapping shader is a stretch goal).
|
||||
| `web/js/interiors/*`, `web/interior_test.html` | Lane C |
|
||||
| `web/js/citizens/*`, `web/citizens_test.html`, `web/models/*` | Lane D |
|
||||
| `pipeline/*`, `web/assets/*` + `web/assets/manifest.json` | Lane E |
|
||||
| `web/package.json` | scaffold/shared — `{"type":"module"}` only, added by Lane A |
|
||||
| `docs/*` | everyone, additively |
|
||||
|
||||
Each lane also ships its own standalone test page so it can be verified without the others.
|
||||
**Never edit another lane's files.** Integration (Lane F) happens after A–E land.
|
||||
|
||||
> `web/package.json` (added by Lane A) contains only `{"type":"module"}` so `node` runs the pure
|
||||
> `web/js/**` ES modules directly (e.g. `node web/js/citygen/selfcheck.js`, an acceptance criterion).
|
||||
> Browsers and `python3 -m http.server` ignore it; no deps, no build step. It changes Node's module
|
||||
> interpretation for every lane's `web/js/**` — which is what we want (all lanes can node-test their
|
||||
> pure modules) — so it's flagged here as shared scope, not silently added.
|
||||
|
||||
## Infrastructure map
|
||||
|
||||
- **This mac** — dev box, Blender + Unreal installed, Apple Silicon (no CUDA — all gen is
|
||||
|
||||
0
docs/shots/laneA/.gitkeep
Normal file
0
docs/shots/laneA/.gitkeep
Normal file
BIN
docs/shots/laneA/seed-1.png
Normal file
BIN
docs/shots/laneA/seed-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 199 KiB |
BIN
docs/shots/laneA/seed-20261990.png
Normal file
BIN
docs/shots/laneA/seed-20261990.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
BIN
docs/shots/laneA/seed-42.png
Normal file
BIN
docs/shots/laneA/seed-42.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
BIN
docs/shots/laneA/seed-777.png
Normal file
BIN
docs/shots/laneA/seed-777.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
BIN
docs/shots/laneA/seed-8675309.png
Normal file
BIN
docs/shots/laneA/seed-8675309.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
14
web/js/citygen/index.js
Normal file
14
web/js/citygen/index.js
Normal file
@ -0,0 +1,14 @@
|
||||
// PROCITY citygen — canonical Lane A public entry point (barrel).
|
||||
//
|
||||
// Consumers import the whole CityGen API from here (not from plan.js directly), e.g. Lane B's shell:
|
||||
// const citygen = await import('./js/citygen/index.js'); citygen.generatePlan(seed) → CityPlan
|
||||
// Keep this the stable import surface for the lane; internals may move between files behind it.
|
||||
//
|
||||
// (Originated as a Lane F integration shim because the shell imports ./js/citygen/index.js while the
|
||||
// generator ships as plan.js; adopted here as the real Lane A entry point, per the shim's own note.)
|
||||
|
||||
export { generatePlan, chunkIndex, chunkKey, CHUNK, lotCorners, obbOverlap } from './plan.js';
|
||||
export { shopName, townName } from './names.js';
|
||||
|
||||
// Convenience default so `citygen.default` also resolves to the generator.
|
||||
export { generatePlan as default } from './plan.js';
|
||||
114
web/js/citygen/names.js
Normal file
114
web/js/citygen/names.js
Normal file
@ -0,0 +1,114 @@
|
||||
// PROCITY shop & town name generator — seeded, deterministic, 90s-Australian flavour.
|
||||
// Given a shop's stable seed, produces the same {name, sign} forever. `sign` is the short form
|
||||
// that fits a signboard (the game renders it big; `name` is the full directory/label string).
|
||||
//
|
||||
// Pure: only imports prng (mulberry32) + wordlists. No THREE, no network, no Math.random.
|
||||
|
||||
import { mulberry32 } from '../core/prng.js';
|
||||
import * as W from './wordlists.js';
|
||||
|
||||
const pick = (r, arr) => arr[(r() * arr.length) | 0];
|
||||
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
const initials = (r) => pick(r, ['A', 'B', 'C', 'D', 'E', 'G', 'J', 'K', 'M', 'P', 'R', 'S', 'T', 'V'])
|
||||
+ ' & ' + pick(r, ['A', 'B', 'C', 'D', 'E', 'G', 'J', 'K', 'M', 'P', 'R', 'S', 'T', 'V']);
|
||||
|
||||
// Token → wordlist resolver. Each token draws from the bank with the given stream.
|
||||
function tok(r, t) {
|
||||
switch (t) {
|
||||
case 'Word': return pick(r, W.WORDS);
|
||||
case 'Adj': return pick(r, W.ADJ);
|
||||
case 'Noun': return pick(r, W.NOUN);
|
||||
case 'Suburb': return pick(r, W.SUBURB);
|
||||
case 'Family': return pick(r, W.FAMILY);
|
||||
case 'Saint': return pick(r, W.SAINT);
|
||||
case 'Charity': return pick(r, W.CHARITY);
|
||||
case 'First': return pick(r, W.FIRSTNAME);
|
||||
case 'Animal': return pick(r, W.ANIMAL);
|
||||
case 'Init': return initials(r);
|
||||
default: return t;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill "{Adj} {Noun}" style token patterns. Possessives are written with a literal ’s in the
|
||||
// pattern itself (e.g. "{First}’s Records", "{Saint}’s Charity Barn") — one mechanism, no magic.
|
||||
function fill(r, pattern) {
|
||||
return pattern.replace(/\{(\w+)\}/g, (_, t) => tok(r, t));
|
||||
}
|
||||
|
||||
// ── name patterns per shop type (≥40 across the board) ─────────────────────────────
|
||||
const PATTERNS = {
|
||||
record: [
|
||||
'{Word} Records', '{Suburb} Sound Exchange', '{Adj} Groove', '{Word} & Wax',
|
||||
'Rotation', '{First}’s Records', 'The {Animal} Sound Co.', '{Suburb} Vinyl',
|
||||
],
|
||||
opshop: [
|
||||
'St {Saint}’s Opportunity Shop', '{Charity} Op Shop', 'The {Adj} Op Shop',
|
||||
'{Suburb} Recycled', '{Saint}’s Charity Barn', 'Second Time {Suburb}', 'The Op Shop',
|
||||
],
|
||||
toy: [
|
||||
'{First}’s Toys', 'The {Adj} Toy Box', '{Word} Toys & Games', '{Animal} Playthings',
|
||||
'{Suburb} Toyworld-ish', 'Marbles & Kites', 'The Toy Nook',
|
||||
],
|
||||
book: [
|
||||
'The {Adj} Book Barn', '{Suburb} Books', '{Word} & Sons Booksellers', 'The {Animal} Bookshop',
|
||||
'Dusty Pages', '{Init} Books', 'Second-Hand Reads', 'The Book Cellar',
|
||||
],
|
||||
video: [
|
||||
'{Suburb} Video', '{Adj} Video', 'Video {Word}', '{First}’s Video',
|
||||
'The Picture Show', 'Late Nite Video', 'Video Barn',
|
||||
],
|
||||
pawn: [
|
||||
'{Suburb} Cash Converters-ish', '{First}’s Pawn & Loan', '{Adj} Cash', 'The Cash Corner',
|
||||
'{Init} Trading Co.', 'Quick Cash Pawn', 'Second Chance Loans',
|
||||
],
|
||||
milkbar: [
|
||||
'{Family} Milk Bar', '{Suburb} Milk Bar', 'The {Adj} Corner Store', '{First}’s Milk Bar',
|
||||
'{Family}’s Deli & Milk Bar', 'Corner Store', 'The Milk Bar',
|
||||
],
|
||||
dept: [
|
||||
'{Suburb} Emporium', '{Family} & Co.', 'The Grand {Word}', '{Adj} Department Store',
|
||||
'{Suburb} Trading Co.', 'Palais {Word}',
|
||||
],
|
||||
stall: [
|
||||
'{First}’s Stall', '{Word} Stall', '{Adj} Bits', 'Trash & Treasure', '{Animal} Wares',
|
||||
'Bric-a-Brac', 'Sunday Stall',
|
||||
],
|
||||
};
|
||||
|
||||
// ── short signboard forms (what actually fits above the door) ──────────────────────
|
||||
const SIGN_PATTERNS = {
|
||||
record: ['{Word}', 'RECORDS', 'VINYL', '{Suburb} SOUND'],
|
||||
opshop: ['OP SHOP', 'ST {Saint}’S', '{Charity}', 'RECYCLED'],
|
||||
toy: ['TOYS', '{Adj} TOYS', 'TOY BOX', 'GAMES'],
|
||||
book: ['BOOKS', 'BOOK BARN', '{Suburb} BOOKS', 'READS'],
|
||||
video: ['VIDEO', '{Suburb} VIDEO', 'MOVIES', 'RENTALS'],
|
||||
pawn: ['CASH', 'PAWN', 'LOANS', '{Init}'],
|
||||
milkbar: ['MILK BAR', '{Family}', 'DELI', 'CORNER STORE'],
|
||||
dept: ['{Suburb}', 'EMPORIUM', '{Family} & CO'],
|
||||
stall: ['STALL', 'BITS', 'BRIC-A-BRAC', 'WARES'],
|
||||
};
|
||||
|
||||
// Public: name a shop from its stable seed + type. Returns { name, sign }.
|
||||
export function shopName(seed, type) {
|
||||
const r = mulberry32((seed ^ 0x9e3779b9) >>> 0); // decorrelate from other seed uses
|
||||
const pats = PATTERNS[type] || PATTERNS.stall;
|
||||
const signs = SIGN_PATTERNS[type] || SIGN_PATTERNS.stall;
|
||||
const name = fill(r, pick(r, pats)).replace(/\s+/g, ' ').trim();
|
||||
let sign = fill(r, pick(r, signs)).replace(/\s+/g, ' ').trim();
|
||||
// Signboard can't be too long — fall back to a punchy word from the full name.
|
||||
if (sign.length > 16) {
|
||||
const word = name.split(' ').sort((a, b) => b.length - a.length)[0] || sign;
|
||||
sign = word.slice(0, 16).toUpperCase();
|
||||
}
|
||||
return { name, sign };
|
||||
}
|
||||
|
||||
// Public: name the town from the city seed.
|
||||
export function townName(citySeed) {
|
||||
const r = mulberry32((citySeed ^ 0x2545f491) >>> 0);
|
||||
const root = pick(r, W.TOWN_ROOT);
|
||||
const suffix = pick(r, W.TOWN_SUFFIX);
|
||||
const usePrefix = r() < 0.35;
|
||||
const prefix = usePrefix ? pick(r, W.TOWN_PREFIX) + ' ' : '';
|
||||
return `${prefix}${cap(root)}${suffix ? ' ' + suffix : ''}`.trim();
|
||||
}
|
||||
430
web/js/citygen/plan.js
Normal file
430
web/js/citygen/plan.js
Normal file
@ -0,0 +1,430 @@
|
||||
// PROCITY CityGen — Layer 1. generatePlan(citySeed) → CityPlan (pure JSON-serializable data).
|
||||
// NO THREE import anywhere in this file (CITY_SPEC law). All randomness flows through core/prng.
|
||||
// Deterministic: same citySeed ⇒ byte-identical plan, forever, in <100ms.
|
||||
//
|
||||
// Coordinate frame (CITY_SPEC): metres, +Y up, ground = XZ. Origin (0,0) = centre of the town.
|
||||
// +x = east, -x = west, +z = north, -z = south. The main-street spine runs roughly N–S.
|
||||
//
|
||||
// A lot's `ry` is the Y-rotation that makes its facade's outward normal point at its street edge
|
||||
// (GLB convention: model faces -Z at ry=0). Lane B rotates the building shell by ry.
|
||||
|
||||
import { rng, seedFor, mulberry32, pick, frange, irange, shuffle } from '../core/prng.js';
|
||||
import { SHOP_TYPES, pickWeightedType } from '../core/registry.js';
|
||||
import { shopName, townName } from './names.js';
|
||||
|
||||
const r2 = v => Math.round(v * 100) / 100; // position/size precision (2dp metres)
|
||||
const r4 = v => Math.round(v * 10000) / 10000; // angle precision (radians)
|
||||
const clampHour = h => Math.max(0, Math.min(23, h | 0));
|
||||
|
||||
// World-space corners of a lot's rotated footprint. Convention matches selfcheck + Lane B:
|
||||
// a lot faces its street via `ry`; corners are [±w/2, ±d/2] rotated by ry about the lot centre.
|
||||
export function lotCorners(l) {
|
||||
const cs = Math.cos(l.ry), sn = Math.sin(l.ry), hw = l.w / 2, hd = l.d / 2;
|
||||
return [[-hw, -hd], [hw, -hd], [hw, hd], [-hw, hd]].map(([ox, oz]) => [
|
||||
l.x + ox * cs + oz * sn, l.z - ox * sn + oz * cs,
|
||||
]);
|
||||
}
|
||||
|
||||
// Separating-Axis-Theorem overlap of two convex quads, tolerance 5cm (absorbs 2dp rounding at
|
||||
// touching edges without masking real overlaps, which are metres). Axes are normalised so EPS is metric.
|
||||
export function obbOverlap(a, b) {
|
||||
const EPS = 0.05;
|
||||
for (const quad of [a, b]) {
|
||||
for (let i = 0; i < quad.length; i++) {
|
||||
const p1 = quad[i], p2 = quad[(i + 1) % quad.length];
|
||||
let ax = -(p2[1] - p1[1]), az = p2[0] - p1[0];
|
||||
const m = Math.hypot(ax, az) || 1; ax /= m; az /= m;
|
||||
let minA = Infinity, maxA = -Infinity, minB = Infinity, maxB = -Infinity;
|
||||
for (const [x, z] of a) { const d = x * ax + z * az; if (d < minA) minA = d; if (d > maxA) maxA = d; }
|
||||
for (const [x, z] of b) { const d = x * ax + z * az; if (d < minB) minB = d; if (d > maxB) maxB = d; }
|
||||
if (maxA < minB + EPS || maxB < minA + EPS) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function generatePlan(citySeed) {
|
||||
citySeed = citySeed >>> 0;
|
||||
|
||||
// ── plan accumulators + id-stamping factories ───────────────────────────────────
|
||||
const nodes = [], edges = [], districts = [], blocks = [], lots = [], shops = [];
|
||||
let nid = 0, eid = 0, did = 0, bid = 0, lid = 0, sid = 0;
|
||||
|
||||
const addNode = (x, z) => { const n = { id: nid++, x: r2(x), z: r2(z) }; nodes.push(n); return n; };
|
||||
const addEdge = (a, b, width, kind) => { const id = eid++; edges.push({ id, a, b, width, kind }); return id; };
|
||||
const addDistrict = (kind, cx, cz) => { const id = did++; districts.push({ id, kind, cx: r2(cx), cz: r2(cz) }); return id; };
|
||||
const addBlock = (district, kind, poly) => {
|
||||
const id = bid++;
|
||||
blocks.push({ id, district, kind, poly: poly.map(p => [r2(p[0]), r2(p[1])]) });
|
||||
return id;
|
||||
};
|
||||
const addLot = (block, x, z, w, d, ry, frontEdge, use) => {
|
||||
const id = lid++;
|
||||
lots.push({ id, block, x: r2(x), z: r2(z), w: r2(w), d: r2(d), ry: r4(ry), frontEdge, use });
|
||||
return id;
|
||||
};
|
||||
|
||||
// Create a shop record for a lot. `type` from registry; facade/storeys/hours seeded off id.
|
||||
function createShop(lotId, type, opts = {}) {
|
||||
const id = sid++;
|
||||
const seed = seedFor(citySeed, 'shop', id);
|
||||
const reg = SHOP_TYPES[type] || SHOP_TYPES.opshop;
|
||||
const sr = mulberry32(seed);
|
||||
const facadeSkin = pick(sr, reg.facades);
|
||||
let storeys = reg.storeys[0] + ((sr() * (reg.storeys[1] - reg.storeys[0] + 1)) | 0);
|
||||
// occasional 3-storey corner anchor — but only for types that already build tall (registry
|
||||
// max ≥ 2); never make an inherently single-storey type (video/milkbar/stall) taller.
|
||||
if (opts.cornerBoost && reg.storeys[1] >= 2 && sr() < 0.5) storeys = Math.min(3, storeys + 1);
|
||||
let open = reg.hours.open, close = reg.hours.close;
|
||||
if (sr() < 0.3) open = clampHour(open + (sr() < 0.5 ? -1 : 1));
|
||||
if (sr() < 0.3) close = clampHour(close + (sr() < 0.5 ? -1 : 1));
|
||||
if (sr() < 0.06) close = Math.min(23, Math.max(close, 19) + ((sr() * 3) | 0)); // the weirdo open late
|
||||
if (close <= open) close = clampHour(open + 2);
|
||||
const { name, sign } = shopName(seed, type);
|
||||
shops.push({ id, lot: lotId, type, name, sign, seed, facadeSkin, storeys, hours: [open, close] });
|
||||
return id;
|
||||
}
|
||||
|
||||
// March lots along one side of a street edge A→B. Lots are sequential intervals along the edge
|
||||
// ⇒ never overlap within their block by construction. Returns { blockId, lots:[{lot,use,cx,cz}] }.
|
||||
function marchStrip(streamKind, streamId, districtId, districtKind, edgeId, A, B, corridorW, side, band) {
|
||||
const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz);
|
||||
const out = { blockId: -1, lots: [] };
|
||||
if (len < 1) return out;
|
||||
const ux = dx / len, uz = dz / len;
|
||||
const nx = side > 0 ? -uz : uz; // outward normal: street → lot
|
||||
const nz = side > 0 ? ux : -ux;
|
||||
const half = corridorW / 2;
|
||||
const stripDepth = band.dmax + 2;
|
||||
const c0 = [A.x + nx * half, A.z + nz * half];
|
||||
const c1 = [B.x + nx * half, B.z + nz * half];
|
||||
const c2 = [B.x + nx * (half + stripDepth), B.z + nz * (half + stripDepth)];
|
||||
const c3 = [A.x + nx * (half + stripDepth), A.z + nz * (half + stripDepth)];
|
||||
const blockId = addBlock(districtId, districtKind, [c0, c1, c2, c3]);
|
||||
out.blockId = blockId;
|
||||
const ry = Math.atan2(nx, nz); // facade faces back down the outward normal, to the street
|
||||
const r = rng(citySeed, streamKind, streamId);
|
||||
const iStart = band.insetStart ?? 0, iEnd = band.insetEnd ?? 0;
|
||||
let t = iStart; const end = len - iEnd;
|
||||
while (end - t >= band.fmin) {
|
||||
let f = frange(r, band.fmin, band.fmax);
|
||||
if (t + f > end) f = end - t;
|
||||
if (f < band.fmin * 0.75) break;
|
||||
const d = frange(r, band.dmin, band.dmax);
|
||||
const along = t + f / 2, off = half + d / 2;
|
||||
const cx = A.x + ux * along + nx * off;
|
||||
const cz = A.z + uz * along + nz * off;
|
||||
const lot = addLot(blockId, cx, cz, f, d, ry, edgeId, band.use);
|
||||
out.lots.push({ lot, use: band.use, cx, cz });
|
||||
t += f + (band.gap ?? 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Assign shop types to a retail block's 'shop' lots, with same-type clustering (a record shop
|
||||
// next to an opshop next to a bookshop is the vibe — bias each lot toward the block's lead type).
|
||||
function assignRetail(blockId, districtKind, lotsArr) {
|
||||
const r = rng(citySeed, 'shopmix', blockId);
|
||||
const lead = pickWeightedType(r, districtKind);
|
||||
lotsArr.forEach((L, i) => {
|
||||
if (L.use !== 'shop') return;
|
||||
let type = (r() < 0.55 && lead) ? lead : pickWeightedType(r, districtKind);
|
||||
if (!type) type = 'opshop';
|
||||
const corner = (i === 0 || i === lotsArr.length - 1);
|
||||
createShop(L.lot, type, { cornerBoost: corner && districtKind === 'mainstreet' });
|
||||
});
|
||||
}
|
||||
|
||||
// ── districts (coarse area markers; every block belongs to one) ──────────────────
|
||||
const dMain = addDistrict('mainstreet', 0, 0);
|
||||
const dRes = addDistrict('residential', 0, 0);
|
||||
|
||||
// ── the spine: 7 stations S→N through the origin, x-jitter ±30m ───────────────────
|
||||
const NST = 7, zTop = 400;
|
||||
const spinePts = [];
|
||||
for (let i = 0; i < NST; i++) {
|
||||
const z = -zTop + (2 * zTop) * i / (NST - 1);
|
||||
const rr = rng(citySeed, 'spine', i);
|
||||
const x = (i === 3) ? frange(rr, -8, 8) : frange(rr, -30, 30); // keep origin on the spine
|
||||
spinePts.push(addNode(x, z));
|
||||
}
|
||||
const spineEdges = [];
|
||||
for (let i = 0; i < NST - 1; i++) spineEdges.push(addEdge(spinePts[i].id, spinePts[i + 1].id, 28, 'main'));
|
||||
|
||||
// ── cross-street rungs at stations 1,2,4,5 (central 2 = second high streets) ──────
|
||||
const rungStations = [1, 2, 4, 5];
|
||||
const rungs = [];
|
||||
for (const st of rungStations) {
|
||||
const base = spinePts[st];
|
||||
const rr = rng(citySeed, 'rung', st);
|
||||
const theta = frange(rr, -0.14, 0.14); // ±8° jitter off pure E–W
|
||||
const central = (st === 2 || st === 4);
|
||||
const width = central ? 20 : 14;
|
||||
const eLen = frange(rr, central ? 200 : 150, central ? 250 : 190);
|
||||
const wLen = frange(rr, central ? 200 : 150, central ? 250 : 190);
|
||||
const ex = Math.cos(theta), ez = Math.sin(theta);
|
||||
const eastEnd = addNode(base.x + ex * eLen, base.z + ez * eLen);
|
||||
const westEnd = addNode(base.x - ex * wLen, base.z - ez * wLen);
|
||||
const eEdge = addEdge(base.id, eastEnd.id, width, 'side');
|
||||
const wEdge = addEdge(westEnd.id, base.id, width, 'side');
|
||||
const districtId = central ? dMain : addDistrict('backstreets', 0, base.z);
|
||||
rungs.push({ st, base, eastEnd, westEnd, eEdge, wEdge, width, central, districtId });
|
||||
}
|
||||
|
||||
// ── main-street retail strips along the spine (reserve origin: W=market, E=arcade) ─
|
||||
for (let i = 0; i < NST - 1; i++) {
|
||||
const A = spinePts[i], B = spinePts[i + 1];
|
||||
const dz = B.z - A.z, len = Math.hypot(B.x - A.x, dz), uz = dz / len;
|
||||
for (const side of [1, -1]) {
|
||||
const west = (side > 0 ? -uz : uz) < 0; // outward normal's x-sign: <0 ⇒ this side faces west
|
||||
if (west && (i === 2 || i === 3)) continue; // market square occupies west-of-origin
|
||||
if (!west && i === 3) continue; // arcade cuts east through this mid-spine block
|
||||
const band = { fmin: 6, fmax: 9, dmin: 12, dmax: 20, gap: 0, use: 'shop', insetStart: 12, insetEnd: 12 };
|
||||
const res = marchStrip('spinelot', i * 2 + (side > 0 ? 0 : 1), dMain, 'mainstreet', spineEdges[i], A, B, 28, side, band);
|
||||
assignRetail(res.blockId, 'mainstreet', res.lots);
|
||||
}
|
||||
}
|
||||
|
||||
// ── cross-street retail strips (both arms, both sides) ────────────────────────────
|
||||
for (const rung of rungs) {
|
||||
const districtKind = rung.central ? 'mainstreet' : 'backstreets';
|
||||
const arms = [
|
||||
{ edge: rung.eEdge, A: rung.base, B: rung.eastEnd, tag: 'e' },
|
||||
{ edge: rung.wEdge, A: rung.westEnd, B: rung.base, tag: 'w' },
|
||||
];
|
||||
// Reserve the intersection corner: the near-spine end of each arm must clear not just the spine
|
||||
// ROAD (half-corridor 14m) but the spine RETAIL STRIP's full depth (dmax 20m) so cross-street
|
||||
// lots don't interpenetrate the spine lots. 14+20 = 34m. (A final resolution pass mops up any
|
||||
// residual corner collisions from the ±8° rung jitter — see resolveOverlaps below.)
|
||||
const SPINE_CLEAR = 34;
|
||||
for (const arm of arms) {
|
||||
for (const side of [1, -1]) {
|
||||
const band = {
|
||||
fmin: rung.central ? 6 : 7, fmax: rung.central ? 9 : 10,
|
||||
dmin: 12, dmax: 18, gap: rung.central ? 0 : 2, use: 'shop',
|
||||
insetStart: arm.tag === 'e' ? SPINE_CLEAR : 6, // near-spine end clears the spine strip depth
|
||||
insetEnd: arm.tag === 'e' ? 6 : SPINE_CLEAR,
|
||||
};
|
||||
const streamId = rung.st * 100 + (arm.tag === 'e' ? 0 : 10) + (side > 0 ? 0 : 1);
|
||||
const res = marchStrip('runglot', streamId, rung.districtId, districtKind, arm.edge, arm.A, arm.B, rung.width, side, band);
|
||||
assignRetail(res.blockId, districtKind, res.lots);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── market square (west of origin): stalls in rows + the dept anchor fronting the spine ─
|
||||
{
|
||||
const spineX = spinePts[3].x;
|
||||
const eastEdge = spineX - 14; // west kerb of the spine corridor
|
||||
const marketW = 120, zLo = -110, zHi = 120;
|
||||
const dMarket = addDistrict('market', eastEdge - marketW / 2, (zLo + zHi) / 2);
|
||||
const poly = [[eastEdge, zLo], [eastEdge, zHi], [eastEdge - marketW, zHi], [eastEdge - marketW, zLo]];
|
||||
const block = addBlock(dMarket, 'market', poly);
|
||||
// Facades face +x (EAST, toward the spine — the market sits west of it). ry is the OUTWARD normal
|
||||
// (street→lot); a west-of-street lot's outward normal is −x, so ry = atan2(−1, 0) (= −π/2). The
|
||||
// facade then faces −(outward) = +x, matching marchStrip's convention. frontEdge is the spine
|
||||
// segment each lot actually fronts by z-band: stalls (z≈−100..−23) front segment 2; dept (z=55) → 3.
|
||||
const ryEast = Math.atan2(-1, 0);
|
||||
const stallEdge = spineEdges[2]; // spine segment spanning z∈[−133,0] — the stalls' band
|
||||
const deptEdge = spineEdges[3]; // spine segment spanning z∈[0,133] — the dept's band
|
||||
const COLS = 5, ROWS = 8, pitch = 11, sSize = 4.5; // tidy rows in the southern ~2/3 of the square
|
||||
for (let gx = 0; gx < COLS; gx++) {
|
||||
for (let gz = 0; gz < ROWS; gz++) {
|
||||
const x = eastEdge - 12 - gx * pitch;
|
||||
const z = zLo + 10 + gz * pitch; // z ∈ [−100, −23], all south of the dept strip (z≈41+)
|
||||
addLot(block, x, z, sSize, sSize, ryEast, stallEdge, 'stall');
|
||||
}
|
||||
}
|
||||
// shops for every stall lot (in id order for determinism)
|
||||
for (const lot of lots) if (lot.block === block && lot.use === 'stall') createShop(lot.id, 'stall');
|
||||
// dept anchor: big building on the square's north strip, fronting the spine
|
||||
const deptW = 28, deptD = 26;
|
||||
const deptId = addLot(block, eastEdge - deptD / 2, 55, deptW, deptD, ryEast, deptEdge, 'anchor');
|
||||
createShop(deptId, 'dept', { cornerBoost: false });
|
||||
}
|
||||
|
||||
// ── arcade: a covered pedestrian lane cutting east through a mid-spine block ───────
|
||||
{
|
||||
const midX = (spinePts[3].x + spinePts[4].x) / 2;
|
||||
const midZ = (spinePts[3].z + spinePts[4].z) / 2;
|
||||
const start = addNode(midX + 14, midZ);
|
||||
const end = addNode(midX + 14 + 42, midZ);
|
||||
const aEdge = addEdge(start.id, end.id, 5, 'arcade');
|
||||
const dArcade = addDistrict('arcade', (start.x + end.x) / 2, midZ);
|
||||
const band = { fmin: 3, fmax: 5, dmin: 5, dmax: 8, gap: 0.5, use: 'shop', insetStart: 2, insetEnd: 2 };
|
||||
for (const side of [1, -1]) {
|
||||
const res = marchStrip('arclot', side > 0 ? 0 : 1, dArcade, 'arcade', aEdge, start, end, 5, side, band);
|
||||
assignRetail(res.blockId, 'arcade', res.lots);
|
||||
}
|
||||
}
|
||||
|
||||
// ── warehouse fringe: sparse big lots beyond one spine end (seeded N or S) ─────────
|
||||
{
|
||||
const rr = rng(citySeed, 'ware', 0);
|
||||
const north = rr() < 0.5;
|
||||
const z0 = north ? 480 : -480;
|
||||
const A = addNode(-140, z0), B = addNode(140, z0);
|
||||
const edge = addEdge(A.id, B.id, 12, 'side');
|
||||
const dWare = addDistrict('warehouse', 0, z0);
|
||||
// A→B runs +x (u=(1,0)); side>0 ⇒ normal (0,+1)=north, side<0 ⇒ (0,-1)=south. Face lots inward:
|
||||
const useSide = north ? -1 : 1; // north fringe: lots to its south; south fringe: to its north
|
||||
const band = { fmin: 20, fmax: 30, dmin: 24, dmax: 34, gap: 8, use: 'infill', insetStart: 8, insetEnd: 8 };
|
||||
const res = marchStrip('warelot', 0, dWare, 'warehouse', edge, A, B, 12, useSide, band);
|
||||
// sparsely activate a few as pawn/junk shops
|
||||
for (const L of res.lots) {
|
||||
const r3 = rng(citySeed, 'wareup', L.lot);
|
||||
if (r3() < 0.3) { lots[L.lot].use = 'shop'; createShop(L.lot, 'pawn'); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── residential collar: a loose ring road of houses, with 2–4 corner milk bars ────
|
||||
{
|
||||
const Wc = 430, Hc = 445;
|
||||
const NW = addNode(-Wc, Hc), NE = addNode(Wc, Hc), SE = addNode(Wc, -Hc), SW = addNode(-Wc, -Hc);
|
||||
const ringEdges = [
|
||||
{ e: addEdge(NW.id, NE.id, 14, 'side'), A: NW, B: NE, inward: -1 }, // N: lots south (inward)
|
||||
{ e: addEdge(NE.id, SE.id, 14, 'side'), A: NE, B: SE, inward: 1 }, // E: lots west (inward)
|
||||
{ e: addEdge(SE.id, SW.id, 14, 'side'), A: SE, B: SW, inward: -1 }, // S: lots north (inward)
|
||||
{ e: addEdge(SW.id, NW.id, 14, 'side'), A: SW, B: NW, inward: 1 }, // W: lots east (inward)
|
||||
];
|
||||
const cornerLots = [];
|
||||
ringEdges.forEach((R, k) => {
|
||||
const band = { fmin: 14, fmax: 20, dmin: 12, dmax: 18, gap: 5, use: 'house', insetStart: 24, insetEnd: 24 };
|
||||
const res = marchStrip('houselot', k, dRes, 'residential', R.e, R.A, R.B, 14, R.inward, band);
|
||||
if (res.lots.length) { cornerLots.push(res.lots[0], res.lots[res.lots.length - 1]); }
|
||||
});
|
||||
const shuffled = shuffle(rng(citySeed, 'milkbar', 0), cornerLots.slice());
|
||||
const k = irange(rng(citySeed, 'milkbarN', 0), 2, 4);
|
||||
for (let i = 0; i < Math.min(k, shuffled.length); i++) {
|
||||
lots[shuffled[i].lot].use = 'shop';
|
||||
createShop(shuffled[i].lot, 'milkbar');
|
||||
}
|
||||
}
|
||||
|
||||
// ── laneways behind the central main-street blocks (back-door flavour + yard lots) ─
|
||||
{
|
||||
const spineX = spinePts[3].x;
|
||||
const eLaneX = spineX + 36, wLaneX = spineX - 36;
|
||||
const laneA_E = addNode(eLaneX, -140), laneB_E = addNode(eLaneX, 140);
|
||||
const laneA_W = addNode(wLaneX, -140), laneB_W = addNode(wLaneX, 140);
|
||||
const eLane = addEdge(laneA_E.id, laneB_E.id, 4, 'lane');
|
||||
const wLane = addEdge(laneA_W.id, laneB_W.id, 4, 'lane');
|
||||
const band = { fmin: 8, fmax: 12, dmin: 8, dmax: 12, gap: 4, use: 'yard', insetStart: 12, insetEnd: 12 };
|
||||
marchStrip('lanelot', 0, dMain, 'mainstreet', eLane, laneA_E, laneB_E, 4, -1, band); // yards on far (east) side
|
||||
marchStrip('lanelot', 1, dMain, 'mainstreet', wLane, laneA_W, laneB_W, 4, 1, band); // yards on far (west) side
|
||||
}
|
||||
|
||||
// ── corner de-confliction: guarantee no two BUILDING lots overlap across block boundaries ──
|
||||
// Strips are non-overlapping within a block by construction, but perpendicular strips can still
|
||||
// interpenetrate at street corners. Broad-phase over a spatial grid, then OBB/SAT; when two solid
|
||||
// lots from different blocks collide, demote the higher-id one to 'infill' (dropping its shop).
|
||||
// Deterministic: lots are visited in id order, so the earlier-built lot always wins the corner.
|
||||
{
|
||||
const SOLID = new Set(['shop', 'anchor', 'house', 'stall']);
|
||||
const GCELL = 24; // broad-phase cell; correctness is independent of size (we register+query full AABB)
|
||||
const grid = new Map();
|
||||
const gkey = (cx, cz) => `${cx},${cz}`;
|
||||
const demoted = new Set();
|
||||
for (const lot of lots) {
|
||||
if (!SOLID.has(lot.use)) continue;
|
||||
const q = lotCorners(lot);
|
||||
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity;
|
||||
for (const [x, z] of q) { if (x < minx) minx = x; if (x > maxx) maxx = x; if (z < minz) minz = z; if (z > maxz) maxz = z; }
|
||||
const cx0 = Math.floor(minx / GCELL), cx1 = Math.floor(maxx / GCELL);
|
||||
const cz0 = Math.floor(minz / GCELL), cz1 = Math.floor(maxz / GCELL);
|
||||
let hit = false;
|
||||
for (let cx = cx0; cx <= cx1 && !hit; cx++) for (let cz = cz0; cz <= cz1 && !hit; cz++) {
|
||||
const bucket = grid.get(gkey(cx, cz)); if (!bucket) continue;
|
||||
for (const oid of bucket) {
|
||||
if (lots[oid].block === lot.block) continue; // within-block is disjoint by construction
|
||||
if (obbOverlap(q, lotCorners(lots[oid]))) { hit = true; break; }
|
||||
}
|
||||
}
|
||||
if (hit) { demoted.add(lot.id); continue; } // don't index a demoted lot (it becomes non-solid)
|
||||
for (let cx = cx0; cx <= cx1; cx++) for (let cz = cz0; cz <= cz1; cz++) {
|
||||
const k = gkey(cx, cz); let b = grid.get(k); if (!b) grid.set(k, b = []); b.push(lot.id);
|
||||
}
|
||||
}
|
||||
if (demoted.size) {
|
||||
for (const id of demoted) lots[id].use = 'infill';
|
||||
for (let i = shops.length - 1; i >= 0; i--) if (demoted.has(shops[i].lot)) shops.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
citySeed,
|
||||
name: townName(citySeed),
|
||||
size: { w: 1024, d: 1024 },
|
||||
districts,
|
||||
streets: { nodes, edges },
|
||||
blocks,
|
||||
lots,
|
||||
shops,
|
||||
};
|
||||
}
|
||||
|
||||
// ── chunk index: chunk key "cx,cz" → { lots, shops, edges } touching that 64m cell ──────
|
||||
export const CHUNK = 64;
|
||||
export const chunkKey = (cx, cz) => `${cx},${cz}`;
|
||||
|
||||
export function chunkIndex(plan, chunkSize = CHUNK) {
|
||||
const cell = v => Math.floor(v / chunkSize);
|
||||
const chunks = {};
|
||||
const bucket = k => (chunks[k] ||= { lots: [], shops: [], edges: [] });
|
||||
|
||||
// each lot → the chunks its (rotated) footprint AABB covers
|
||||
const lotKeys = new Map();
|
||||
for (const lot of plan.lots) {
|
||||
const cs = Math.cos(lot.ry), sn = Math.sin(lot.ry), hw = lot.w / 2, hd = lot.d / 2;
|
||||
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity;
|
||||
for (const [ox, oz] of [[-hw, -hd], [hw, -hd], [hw, hd], [-hw, hd]]) {
|
||||
const wx = lot.x + ox * cs + oz * sn, wz = lot.z - ox * sn + oz * cs;
|
||||
if (wx < minx) minx = wx; if (wx > maxx) maxx = wx;
|
||||
if (wz < minz) minz = wz; if (wz > maxz) maxz = wz;
|
||||
}
|
||||
const keys = [];
|
||||
for (let cx = cell(minx); cx <= cell(maxx); cx++)
|
||||
for (let cz = cell(minz); cz <= cell(maxz); cz++) {
|
||||
const k = chunkKey(cx, cz); bucket(k).lots.push(lot.id); keys.push(k);
|
||||
}
|
||||
lotKeys.set(lot.id, keys);
|
||||
}
|
||||
|
||||
// shops ride along with their lot's chunks
|
||||
for (const shop of plan.shops)
|
||||
for (const k of (lotKeys.get(shop.lot) || [])) bucket(k).shops.push(shop.id);
|
||||
|
||||
// edges → every chunk the ROAD covers. Grid-supercover (Amanatides–Woo) traversal visits every
|
||||
// cell a segment passes through with no gaps (point-sampling skipped diagonally-crossed cells —
|
||||
// notably the origin chunk under the spine). Run it on the centreline AND the two kerb rails
|
||||
// (centreline ± halfWidth), so a wide road registers in every chunk its surface touches.
|
||||
const addEdgeCell = (id, cx, cz) => { const bk = bucket(chunkKey(cx, cz)); if (!bk.edges.includes(id)) bk.edges.push(id); };
|
||||
const traverse = (id, x0, z0, x1, z1) => {
|
||||
let cx = cell(x0), cz = cell(z0);
|
||||
const ecx = cell(x1), ecz = cell(z1), dx = x1 - x0, dz = z1 - z0;
|
||||
const stepX = dx > 0 ? 1 : dx < 0 ? -1 : 0, stepZ = dz > 0 ? 1 : dz < 0 ? -1 : 0;
|
||||
const tDeltaX = dx !== 0 ? Math.abs(chunkSize / dx) : Infinity;
|
||||
const tDeltaZ = dz !== 0 ? Math.abs(chunkSize / dz) : Infinity;
|
||||
let tMaxX = dx !== 0 ? ((stepX > 0 ? (cx + 1) * chunkSize : cx * chunkSize) - x0) / dx : Infinity;
|
||||
let tMaxZ = dz !== 0 ? ((stepZ > 0 ? (cz + 1) * chunkSize : cz * chunkSize) - z0) / dz : Infinity;
|
||||
addEdgeCell(id, cx, cz);
|
||||
let guard = 0;
|
||||
while ((cx !== ecx || cz !== ecz) && guard++ < 100000) {
|
||||
if (tMaxX < tMaxZ) { cx += stepX; tMaxX += tDeltaX; } else { cz += stepZ; tMaxZ += tDeltaZ; }
|
||||
addEdgeCell(id, cx, cz);
|
||||
}
|
||||
};
|
||||
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
|
||||
for (const e of plan.streets.edges) {
|
||||
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
|
||||
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
|
||||
const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw; // perpendicular kerb offset
|
||||
traverse(e.id, a.x, a.z, b.x, b.z);
|
||||
if (hw > 0) {
|
||||
traverse(e.id, a.x + px, a.z + pz, b.x + px, b.z + pz);
|
||||
traverse(e.id, a.x - px, a.z - pz, b.x - px, b.z - pz);
|
||||
}
|
||||
}
|
||||
return { chunkSize, chunks };
|
||||
}
|
||||
186
web/js/citygen/selfcheck.js
Normal file
186
web/js/citygen/selfcheck.js
Normal file
@ -0,0 +1,186 @@
|
||||
// PROCITY CityGen self-check — `node web/js/citygen/selfcheck.js`. Plain node, zero deps.
|
||||
// Asserts the Lane A acceptance contract. Prints all-green or dies loud with the first failure.
|
||||
//
|
||||
// The overlap/facing helpers are IMPORTED from plan.js (lotCorners/obbOverlap) so the harness and
|
||||
// the generator can never disagree about what "overlap" or "facing" means.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { generatePlan, chunkIndex, lotCorners, obbOverlap, CHUNK } from './plan.js';
|
||||
import { allFacadeSkins, SHOP_TYPES } from '../core/registry.js';
|
||||
import { xmur3 } from '../core/prng.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ASSETS = join(HERE, '..', '..', 'assets', 'gen');
|
||||
|
||||
let failures = 0, checks = 0;
|
||||
const ok = (cond, msg) => { checks++; if (!cond) { failures++; console.error(' ✗ ' + msg); } };
|
||||
const section = s => console.log('\n' + s);
|
||||
|
||||
const SEEDS = [20261990, 1, 42, 777, 2000000000, 8675309];
|
||||
const SOLID = new Set(['shop', 'anchor', 'house', 'stall']); // lots realised as building shells by Lane B
|
||||
|
||||
// A lot's outward facade normal from its ry (GLB faces -Z at ry=0 ⇒ world facing = (-sin ry,-cos ry)).
|
||||
const facing = l => [-Math.sin(l.ry), -Math.cos(l.ry)];
|
||||
// nearest point on segment AB to point P
|
||||
function nearestOnSeg(px, pz, ax, az, bx, bz) {
|
||||
const dx = bx - ax, dz = bz - az, L2 = dx * dx + dz * dz || 1;
|
||||
let t = ((px - ax) * dx + (pz - az) * dz) / L2; t = Math.max(0, Math.min(1, t));
|
||||
return [ax + dx * t, az + dz * t];
|
||||
}
|
||||
|
||||
// ── 1. determinism: two runs byte-identical ─────────────────────────────────────────
|
||||
section('determinism (deep-equal across two runs)');
|
||||
for (const s of SEEDS) {
|
||||
const a = JSON.stringify(generatePlan(s)), b = JSON.stringify(generatePlan(s));
|
||||
ok(a === b, `seed ${s}: two runs identical`);
|
||||
}
|
||||
|
||||
// ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ────
|
||||
// (If you intentionally change generator output, run selfcheck, copy the printed hash here.)
|
||||
section('golden fingerprint (output-drift guard)');
|
||||
const GOLDEN = { seed: 20261990, hash: 0xb5d5cc13 };
|
||||
{
|
||||
const hash = xmur3(JSON.stringify(generatePlan(GOLDEN.seed)))() >>> 0;
|
||||
ok(hash === GOLDEN.hash, `seed ${GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(GOLDEN.hash >>> 0).toString(16)}`);
|
||||
}
|
||||
|
||||
// ── 2. performance: generate under 100ms ────────────────────────────────────────────
|
||||
section('performance (<100ms per plan)');
|
||||
for (const s of SEEDS) {
|
||||
const t0 = process.hrtime.bigint();
|
||||
generatePlan(s);
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
ok(ms < 100, `seed ${s}: generated in ${ms.toFixed(1)}ms`);
|
||||
}
|
||||
|
||||
// ── 3. structural invariants over every seed ────────────────────────────────────────
|
||||
section('structural invariants');
|
||||
const facadeSet = new Set();
|
||||
const isFiniteNum = v => typeof v === 'number' && Number.isFinite(v);
|
||||
for (const s of SEEDS) {
|
||||
const plan = generatePlan(s);
|
||||
const edgeIds = new Set(plan.streets.edges.map(e => e.id));
|
||||
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
|
||||
const nodeIds = new Set(plan.streets.nodes.map(n => n.id));
|
||||
const blockIds = new Set(plan.blocks.map(b => b.id));
|
||||
const lotIds = new Set(plan.lots.map(l => l.id));
|
||||
const districtIds = new Set(plan.districts.map(d => d.id));
|
||||
|
||||
// every number finite (JSON would silently turn NaN/Infinity into null)
|
||||
ok(plan.streets.nodes.every(n => isFiniteNum(n.x) && isFiniteNum(n.z)), `seed ${s}: node coords finite`);
|
||||
ok(plan.lots.every(l => isFiniteNum(l.x) && isFiniteNum(l.z) && isFiniteNum(l.w) && isFiniteNum(l.d) && isFiniteNum(l.ry)), `seed ${s}: lot numbers finite`);
|
||||
ok(plan.blocks.every(b => b.poly.every(p => isFiniteNum(p[0]) && isFiniteNum(p[1]))), `seed ${s}: block poly finite`);
|
||||
|
||||
ok(plan.streets.edges.every(e => nodeIds.has(e.a) && nodeIds.has(e.b)), `seed ${s}: every edge references real nodes`);
|
||||
ok(plan.blocks.every(b => districtIds.has(b.district)), `seed ${s}: every block in a real district`);
|
||||
ok(plan.lots.every(l => blockIds.has(l.block)), `seed ${s}: every lot in a real block`);
|
||||
ok(plan.lots.every(l => edgeIds.has(l.frontEdge)), `seed ${s}: every lot has a valid frontEdge`);
|
||||
ok(plan.lots.every(l => l.w > 0 && l.d > 0), `seed ${s}: every lot has positive size`);
|
||||
ok(plan.shops.every(sh => lotIds.has(sh.lot)), `seed ${s}: every shop has a real lot`);
|
||||
ok(plan.shops.every(sh => SHOP_TYPES[sh.type]), `seed ${s}: every shop has a known type`);
|
||||
ok(plan.shops.every(sh => SHOP_TYPES[sh.type].facades.includes(sh.facadeSkin)), `seed ${s}: every facade is in its type's pool`);
|
||||
ok(plan.shops.every(sh => sh.hours[0] >= 0 && sh.hours[1] <= 23 && sh.hours[1] > sh.hours[0]), `seed ${s}: every shop has sane hours`);
|
||||
ok(plan.shops.every(sh => sh.name && sh.sign), `seed ${s}: every shop is named`);
|
||||
ok(plan.shops.every(sh => !/[{}]|undefined/.test(sh.name + sh.sign)), `seed ${s}: no unresolved tokens / undefined in names`);
|
||||
ok(plan.shops.every(sh => { const [mn, mx] = SHOP_TYPES[sh.type].storeys; return sh.storeys >= mn && sh.storeys <= Math.max(mx, 3); }), `seed ${s}: storeys within range (corner ≤3)`);
|
||||
|
||||
// one lot ↔ at most one shop
|
||||
const lotShopCounts = {};
|
||||
for (const sh of plan.shops) lotShopCounts[sh.lot] = (lotShopCounts[sh.lot] || 0) + 1;
|
||||
ok(Object.values(lotShopCounts).every(c => c === 1), `seed ${s}: no lot has two shops`);
|
||||
// a shop only ever sits on a solid lot use
|
||||
ok(plan.shops.every(sh => SOLID.has(plan.lots[sh.lot].use)), `seed ${s}: every shop sits on a solid lot`);
|
||||
|
||||
// FACING: every lot's facade normal points at (not away from) its frontEdge (the review's D1 class)
|
||||
let backwards = null;
|
||||
for (const l of plan.lots) {
|
||||
const e = plan.streets.edges.find(x => x.id === l.frontEdge);
|
||||
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
|
||||
const [nx, nz] = nearestOnSeg(l.x, l.z, a.x, a.z, b.x, b.z);
|
||||
const tox = nx - l.x, toz = nz - l.z; // vector from lot to its street
|
||||
const [fx, fz] = facing(l);
|
||||
if (tox * fx + toz * fz < -0.01) { backwards = l.id; break; } // facing away from the street it fronts
|
||||
}
|
||||
ok(backwards === null, `seed ${s}: every lot faces its frontEdge` + (backwards !== null ? ` (lot ${backwards} backwards)` : ''));
|
||||
|
||||
// OVERLAP within a block (all lots) — strips are sequential ⇒ disjoint by construction
|
||||
const byBlock = {};
|
||||
for (const l of plan.lots) (byBlock[l.block] ||= []).push(l);
|
||||
let inBlock = null;
|
||||
for (const arr of Object.values(byBlock)) {
|
||||
const cs = arr.map(lotCorners);
|
||||
for (let i = 0; i < arr.length && !inBlock; i++) for (let j = i + 1; j < arr.length; j++)
|
||||
if (obbOverlap(cs[i], cs[j])) { inBlock = [arr[i].id, arr[j].id]; break; }
|
||||
if (inBlock) break;
|
||||
}
|
||||
ok(!inBlock, `seed ${s}: no overlapping lots within a block` + (inBlock ? ` (lots ${inBlock})` : ''));
|
||||
|
||||
// OVERLAP across blocks (building lots) — the corner-deconfliction guarantee (review D2)
|
||||
const solids = plan.lots.filter(l => SOLID.has(l.use));
|
||||
const cs = solids.map(lotCorners);
|
||||
const aabb = cs.map(q => { let a = [1e9, 1e9, -1e9, -1e9]; for (const [x, z] of q) { a[0] = Math.min(a[0], x); a[1] = Math.min(a[1], z); a[2] = Math.max(a[2], x); a[3] = Math.max(a[3], z); } return a; });
|
||||
let crossBlock = null;
|
||||
for (let i = 0; i < solids.length && !crossBlock; i++) for (let j = i + 1; j < solids.length; j++) {
|
||||
if (solids[i].block === solids[j].block) continue;
|
||||
const A = aabb[i], B = aabb[j];
|
||||
if (A[2] < B[0] || B[2] < A[0] || A[3] < B[1] || B[3] < A[1]) continue; // AABB reject
|
||||
if (obbOverlap(cs[i], cs[j])) { crossBlock = [solids[i].id, solids[j].id]; break; }
|
||||
}
|
||||
ok(!crossBlock, `seed ${s}: no overlapping building lots across blocks` + (crossBlock ? ` (lots ${crossBlock})` : ''));
|
||||
|
||||
// CHUNKINDEX: covers every lot; buckets reference real ids
|
||||
const idx = chunkIndex(plan);
|
||||
const covered = new Set();
|
||||
for (const cellB of Object.values(idx.chunks)) {
|
||||
for (const id of cellB.lots) covered.add(id);
|
||||
ok(cellB.lots.every(id => lotIds.has(id)) && cellB.shops.every(id => plan.shops.some(sh => sh.id === id)) && cellB.edges.every(id => edgeIds.has(id)),
|
||||
`seed ${s}: chunk buckets reference real ids`);
|
||||
}
|
||||
ok(plan.lots.every(l => covered.has(l.id)), `seed ${s}: chunkIndex covers every lot`);
|
||||
|
||||
// CHUNKINDEX edge coverage: every chunk the centreline densely passes through is listed (review D3)
|
||||
const cell = v => Math.floor(v / CHUNK);
|
||||
let edgeGap = null;
|
||||
for (const e of plan.streets.edges) {
|
||||
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
|
||||
const len = Math.hypot(b.x - a.x, b.z - a.z), steps = Math.max(1, Math.ceil(len / 2)); // 2m dense samples
|
||||
for (let k = 0; k <= steps && !edgeGap; k++) {
|
||||
const x = a.x + (b.x - a.x) * k / steps, z = a.z + (b.z - a.z) * k / steps;
|
||||
const bk = idx.chunks[`${cell(x)},${cell(z)}`];
|
||||
if (!bk || !bk.edges.includes(e.id)) edgeGap = [e.id, cell(x), cell(z)];
|
||||
}
|
||||
if (edgeGap) break;
|
||||
}
|
||||
ok(!edgeGap, `seed ${s}: chunkIndex lists every edge in every chunk its centreline crosses` + (edgeGap ? ` (edge ${edgeGap[0]} missing @ ${edgeGap[1]},${edgeGap[2]})` : ''));
|
||||
|
||||
// JSON round-trip is lossless (and carries no stray non-schema keys)
|
||||
ok(JSON.stringify(JSON.parse(JSON.stringify(plan))) === JSON.stringify(plan), `seed ${s}: JSON round-trip lossless`);
|
||||
|
||||
// design brief presence
|
||||
ok(plan.streets.edges.some(e => e.kind === 'main'), `seed ${s}: has a main-street spine`);
|
||||
ok(plan.streets.edges.some(e => e.kind === 'arcade'), `seed ${s}: has an arcade`);
|
||||
ok(plan.districts.some(d => d.kind === 'market'), `seed ${s}: has a market district`);
|
||||
ok(plan.shops.some(sh => sh.type === 'stall'), `seed ${s}: market has stalls`);
|
||||
ok(plan.shops.some(sh => sh.type === 'dept'), `seed ${s}: has a department-store anchor`);
|
||||
const milkbars = plan.shops.filter(sh => sh.type === 'milkbar').length;
|
||||
ok(milkbars >= 2 && milkbars <= 4, `seed ${s}: 2–4 corner milk bars (${milkbars})`);
|
||||
|
||||
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
|
||||
}
|
||||
|
||||
// ── 4. every facade skin referenced by the registry exists on disk ──────────────────
|
||||
section('assets on disk');
|
||||
for (const f of allFacadeSkins()) ok(existsSync(join(ASSETS, f)), `registry facade exists: ${f}`);
|
||||
for (const f of facadeSet) ok(existsSync(join(ASSETS, f)), `used facade exists: ${f}`);
|
||||
|
||||
// ── report ──────────────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${failures ? '✗ FAIL' : '✓ ALL GREEN'} — ${checks - failures}/${checks} checks passed`);
|
||||
const sample = generatePlan(20261990);
|
||||
console.log(` sample seed 20261990 → "${sample.name}": ` +
|
||||
`${sample.streets.nodes.length} nodes, ${sample.streets.edges.length} edges, ` +
|
||||
`${sample.blocks.length} blocks, ${sample.lots.length} lots, ${sample.shops.length} shops`);
|
||||
console.log(` fingerprint hash: 0x${(xmur3(JSON.stringify(sample))() >>> 0).toString(16)} (paste into GOLDEN.hash)`);
|
||||
|
||||
process.exit(failures ? 1 : 0);
|
||||
84
web/js/citygen/wordlists.js
Normal file
84
web/js/citygen/wordlists.js
Normal file
@ -0,0 +1,84 @@
|
||||
// PROCITY name wordlists — the flavour bank for names.js. Hand-authored 90s-Australian.
|
||||
// Pure data, no logic. names.js fills pattern tokens ({Word}, {Suburb}, {Family}, …) from here.
|
||||
// Optionally regenerated/expanded by a human via an Ollama pass (see A-progress.md); the game
|
||||
// only ever reads this checked-in file — no network at runtime (CITY_SPEC law).
|
||||
|
||||
// Generic evocative single words (records, sound, exchange, rotation…)
|
||||
export const WORDS = [
|
||||
'Rotation', 'Groove', 'Vinyl', 'Static', 'Echo', 'Reverb', 'Feedback', 'Needle', 'Tempo',
|
||||
'Fable', 'Relic', 'Trinket', 'Bower', 'Nook', 'Attic', 'Cellar', 'Emporium', 'Bazaar',
|
||||
'Hodgepodge', 'Oddment', 'Sundry', 'Curio', 'Salvage', 'Remnant', 'Vestige', 'Memento',
|
||||
'Wombat', 'Galah', 'Cockatoo', 'Magpie', 'Kookaburra', 'Brumby', 'Dingo', 'Bilby',
|
||||
'Southern', 'Federal', 'Colonial', 'Paramount', 'Regal', 'Crown', 'Empire', 'Ambassador',
|
||||
'Sunburnt', 'Redgum', 'Wattle', 'Banksia', 'Bottlebrush', 'Saltbush', 'Coolabah', 'Jacaranda',
|
||||
];
|
||||
|
||||
// Adjectives (worn, faded, second-hand register)
|
||||
export const ADJ = [
|
||||
'Golden', 'Lucky', 'Little', 'Grand', 'Olde', 'Corner', 'Central', 'Wonky', 'Dusty', 'Trusty',
|
||||
'Thrifty', 'Cut-Price', 'Bargain', 'Bonza', 'Fair Dinkum', 'True Blue', 'Reliable', 'Honest',
|
||||
'Faded', 'Worn', 'Vintage', 'Retro', 'Rare', 'Groovy', 'Funky', 'Mellow', 'Cosmic', 'Electric',
|
||||
];
|
||||
|
||||
// Nouns for shop bodies
|
||||
export const NOUN = [
|
||||
'Records', 'Sounds', 'Wax', 'Grooves', 'Discs', 'Tapes', 'Tunes', 'Beat', 'Rhythm', 'Vibe',
|
||||
'Treasures', 'Treasure Trove', 'Finds', 'Bits', 'Bobs', 'Wares', 'Goods', 'Bygones', 'Antiques',
|
||||
'Toys', 'Games', 'Playthings', 'Fun', 'Kids', 'Nippers', 'Marbles', 'Kites',
|
||||
'Books', 'Reads', 'Pages', 'Volumes', 'Paperbacks', 'Stories', 'Print', 'Chapters',
|
||||
'Video', 'Movies', 'Flicks', 'Rentals', 'Screen', 'Cinema', 'Picture Show',
|
||||
'Cash', 'Loans', 'Exchange', 'Trading', 'Pawn', 'Deals', 'Swap', 'Barter',
|
||||
];
|
||||
|
||||
// AU suburb / place flavour (fake-but-plausible)
|
||||
export const SUBURB = [
|
||||
'Thornbury', 'Coburg', 'Preston', 'Footscray', 'Marrickville', 'Newtown', 'Enmore', 'Brunswick',
|
||||
'Fitzroy', 'Collingwood', 'Redfern', 'Balmain', 'Bexley', 'Ashfield', 'Petersham', 'Croydon',
|
||||
'Woop Woop', 'Bullamakanka', 'Dubbo', 'Wagga', 'Bendigo', 'Ballarat', 'Geelong', 'Toowoomba',
|
||||
'Moe', 'Sale', 'Yass', 'Cootamundra', 'Gundagai', 'Wangaratta', 'Warrnambool', 'Numurkah',
|
||||
];
|
||||
|
||||
// Family / surname flavour (milkbars, family businesses)
|
||||
export const FAMILY = [
|
||||
'Papadopoulos', 'Nguyen', 'Kowalski', 'Ferrante', 'De Luca', 'Kotsiopoulos', 'Tran', 'Petrakis',
|
||||
'Wong', 'Singh', 'Karam', 'Vella', 'Borg', 'Camilleri', 'Muscat', 'Zammit', 'Grech',
|
||||
'Kelly', 'Ryan', 'Murphy', 'O’Brien', 'Doyle', 'Callaghan', 'Sullivan', 'Flanagan',
|
||||
'Cracknell', 'Trundle', 'Bagshaw', 'Higgins', 'Prentice', 'Weatherall', 'Nabbs', 'Dorrigo',
|
||||
];
|
||||
|
||||
// Saint / charity flavour (op shops parody Vinnies/Salvos/Anglicare)
|
||||
export const SAINT = [
|
||||
'Bevan', 'Cletus', 'Doreen', 'Enid', 'Fabian', 'Gladys', 'Hildegard', 'Ignatius', 'Merle',
|
||||
'Norbert', 'Prudence', 'Quenby', 'Reginald', 'Sheila', 'Trevor', 'Ulrika', 'Verity', 'Wilfred',
|
||||
];
|
||||
|
||||
export const CHARITY = [
|
||||
'Vinnies-alike', 'Sallies', 'Op-Portunity', 'Second Chance', 'Anglibarn', 'Lifeboat',
|
||||
'Good Sammy', 'The Smith Family Room', 'Diggers’ Aid', 'Bush Mission', 'Endeavour',
|
||||
];
|
||||
|
||||
// First names for personalised shops ("Kev's", "Shazza's")
|
||||
export const FIRSTNAME = [
|
||||
'Kev', 'Shazza', 'Bazza', 'Dazza', 'Gazza', 'Robbo', 'Macca', 'Johnno', 'Stevo', 'Bluey',
|
||||
'Dot', 'Merv', 'Val', 'Col', 'Nev', 'Beryl', 'Gaz', 'Snez', 'Franga', 'Tex',
|
||||
];
|
||||
|
||||
// Animals (mascot names: The Laughing Magpie, etc.)
|
||||
export const ANIMAL = [
|
||||
'Magpie', 'Galah', 'Kookaburra', 'Wombat', 'Echidna', 'Possum', 'Rosella', 'Currawong',
|
||||
'Cockatoo', 'Bilby', 'Quokka', 'Numbat', 'Bandicoot', 'Lorikeet', 'Kelpie', 'Blue Heeler',
|
||||
];
|
||||
|
||||
// Town-name pieces (generatePlan names the town)
|
||||
export const TOWN_PREFIX = [
|
||||
'Mount', 'Port', 'Lake', 'Upper', 'Lower', 'North', 'South', 'Little', 'Old', 'New', 'Cape',
|
||||
];
|
||||
export const TOWN_ROOT = [
|
||||
'Wilga', 'Barmah', 'Yackandandah', 'Wonthaggi', 'Corryong', 'Nhill', 'Woomelang', 'Beeac',
|
||||
'Tarwin', 'Koonwarra', 'Meeniyan', 'Boolarra', 'Yinnar', 'Mirboo', 'Toora', 'Fish Creek',
|
||||
'Dunkeld', 'Penshurst', 'Skipton', 'Rokewood', 'Cressy', 'Inverleigh', 'Bannockburn',
|
||||
];
|
||||
export const TOWN_SUFFIX = [
|
||||
'Flat', 'Creek', 'Springs', 'Gully', 'Junction', 'Crossing', 'Downs', 'Vale', 'Heads',
|
||||
'Plains', 'Ridge', 'Hollow', 'Bend', 'Reach', 'Wells', '', '', '', // blanks: bare root sometimes
|
||||
];
|
||||
154
web/js/core/registry.js
Normal file
154
web/js/core/registry.js
Normal file
@ -0,0 +1,154 @@
|
||||
// PROCITY shop-type registry — the shared vocabulary (CITY_SPEC §"Shop types v1").
|
||||
// Lane A owns this file; EVERY other lane imports it read-only. Keep it flat data:
|
||||
// no THREE, no prng, no side effects — just tables + tiny pure helpers.
|
||||
//
|
||||
// Facade filenames here MUST exist in web/assets/gen/ (Lane A selfcheck asserts this).
|
||||
|
||||
// ── enumerations (single source of truth for the CityPlan schema strings) ──────────
|
||||
export const DISTRICT_KINDS = ['mainstreet', 'arcade', 'backstreets', 'warehouse', 'residential', 'market'];
|
||||
export const EDGE_KINDS = ['main', 'side', 'lane', 'arcade'];
|
||||
export const USE_KINDS = ['shop', 'anchor', 'house', 'yard', 'stall', 'infill'];
|
||||
export const SHOP_TYPE_IDS = ['record', 'opshop', 'toy', 'book', 'video', 'pawn', 'milkbar', 'dept', 'stall'];
|
||||
|
||||
// ── the registry ──────────────────────────────────────────────────────────────────
|
||||
// Per type:
|
||||
// label human name (map legend / debug)
|
||||
// facades facade-*.jpg skins in web/assets/gen/ (blank signboards; game overlays the name)
|
||||
// sign colour/style hints for Lane B's canvas signage
|
||||
// interior archetype id Lane C builds the room from
|
||||
// fittings parametric fitting kit ids (90sDJsim fittings.js) Lane C fills the room with
|
||||
// storeys [min,max] inclusive
|
||||
// hours {open,close} default trading hours (24h); plan.js jitters + picks one weirdo
|
||||
// weights district-kind -> spawn weight (0/absent = never in that district)
|
||||
// footprint {fmin,fmax,dmin,dmax} preferred frontage/depth band in metres (plan.js hint)
|
||||
export const SHOP_TYPES = {
|
||||
record: {
|
||||
label: 'Record Shop',
|
||||
facades: ['facade-timber-teal.jpg', 'facade-arcade-tile.jpg', 'facade-djsim-record.jpg'],
|
||||
sign: { bg: '#141428', fg: '#ff5470', accent: '#ffd23f', style: 'neon' },
|
||||
interior: 'crates_and_bins',
|
||||
fittings: ['record_bins', 'crates', 'counter', 'listening_corner'],
|
||||
storeys: [1, 2],
|
||||
hours: { open: 10, close: 18 },
|
||||
weights: { mainstreet: 3, arcade: 4, backstreets: 1 },
|
||||
footprint: { fmin: 6, fmax: 9, dmin: 12, dmax: 18 },
|
||||
},
|
||||
opshop: {
|
||||
label: 'Op Shop',
|
||||
facades: ['facade-weatherboard.jpg', 'facade-fibro-blue.jpg', 'facade-djsim-opshop.jpg'],
|
||||
sign: { bg: '#3d6b4a', fg: '#f6efdc', accent: '#e4573a', style: 'painted' },
|
||||
interior: 'racks_and_shelves',
|
||||
fittings: ['clothes_racks', 'bricabrac_shelving', 'book_wall', 'counter'],
|
||||
storeys: [1, 2],
|
||||
hours: { open: 9, close: 17 },
|
||||
weights: { mainstreet: 3, arcade: 1, backstreets: 2 },
|
||||
footprint: { fmin: 7, fmax: 10, dmin: 12, dmax: 20 },
|
||||
},
|
||||
toy: {
|
||||
label: 'Toy Shop',
|
||||
facades: ['facade-stucco-pink.jpg', 'facade-deco-pastel.jpg', 'facade-boutique.jpg'],
|
||||
sign: { bg: '#ffd23f', fg: '#d6336c', accent: '#2f9e44', style: 'bubbly' },
|
||||
interior: 'shelves_and_case',
|
||||
fittings: ['cube_shelves', 'display_tables', 'glass_case', 'counter'],
|
||||
storeys: [1, 2],
|
||||
hours: { open: 9, close: 17 },
|
||||
weights: { mainstreet: 2, arcade: 2, backstreets: 1 },
|
||||
footprint: { fmin: 6, fmax: 9, dmin: 12, dmax: 18 },
|
||||
},
|
||||
book: {
|
||||
label: 'Book Barn',
|
||||
facades: ['facade-federation.jpg', 'facade-sandstone.jpg', 'facade-redbrick.jpg'],
|
||||
sign: { bg: '#2b3a2e', fg: '#e9d8a6', accent: '#bb7a3b', style: 'serif' },
|
||||
interior: 'halls_of_shelves',
|
||||
fittings: ['bookshelves', 'spinner_racks', 'armchair', 'counter'],
|
||||
storeys: [1, 2],
|
||||
hours: { open: 9, close: 17 },
|
||||
weights: { mainstreet: 2, arcade: 2, backstreets: 1 },
|
||||
footprint: { fmin: 7, fmax: 10, dmin: 14, dmax: 20 },
|
||||
},
|
||||
video: {
|
||||
label: 'Video Rental',
|
||||
facades: ['facade-stripmall.jpg', 'facade-djsim-video.jpg'],
|
||||
sign: { bg: '#111111', fg: '#ffe14d', accent: '#e8412b', style: 'blocky' },
|
||||
interior: 'aisle_shelves',
|
||||
fittings: ['vhs_shelving', 'returns_slot', 'poster_wall', 'counter'],
|
||||
storeys: [1, 1],
|
||||
hours: { open: 11, close: 21 }, // rentals trade late
|
||||
weights: { mainstreet: 2, arcade: 0, backstreets: 1 },
|
||||
footprint: { fmin: 7, fmax: 10, dmin: 14, dmax: 20 },
|
||||
},
|
||||
pawn: {
|
||||
label: 'Pawnbroker',
|
||||
facades: ['facade-besser.jpg', 'facade-grimy.jpg', 'facade-djsim-pawn.jpg', 'facade-corrugated.jpg'],
|
||||
sign: { bg: '#1c1c1c', fg: '#f4c430', accent: '#a8a8a8', style: 'goldbar' },
|
||||
interior: 'counter_forward',
|
||||
fittings: ['glass_cabinets', 'wall_hooks', 'barred_window', 'counter'],
|
||||
storeys: [1, 2],
|
||||
hours: { open: 9, close: 17 },
|
||||
weights: { mainstreet: 1, backstreets: 2, warehouse: 1 },
|
||||
footprint: { fmin: 6, fmax: 9, dmin: 12, dmax: 18 },
|
||||
},
|
||||
milkbar: {
|
||||
label: 'Milk Bar',
|
||||
facades: ['facade-djsim-milkbar.jpg', 'facade-brickveneer.jpg'],
|
||||
sign: { bg: '#c92a2a', fg: '#fff5f5', accent: '#ffd23f', style: 'coke' },
|
||||
interior: 'counter_and_freezer',
|
||||
fittings: ['counter', 'fridge', 'magazine_rack', 'freezer'],
|
||||
storeys: [1, 1],
|
||||
hours: { open: 6, close: 20 }, // the corner shop opens early, closes late
|
||||
weights: { residential: 1 }, // only ever a residential corner shop
|
||||
footprint: { fmin: 9, fmax: 12, dmin: 12, dmax: 16 },
|
||||
},
|
||||
dept: {
|
||||
label: 'Department Store',
|
||||
facades: ['facade-terrazzo.jpg', 'facade-djsim-dept.jpg'],
|
||||
sign: { bg: '#7b1e3a', fg: '#f6e7c1', accent: '#c9a227', style: 'grand' },
|
||||
interior: 'grand_hall',
|
||||
fittings: ['mixed_sections', 'escalator_prop', 'glass_case', 'counter'],
|
||||
storeys: [2, 3],
|
||||
hours: { open: 9, close: 17 },
|
||||
weights: { market: 1 }, // the anchor on the market square's edge
|
||||
footprint: { fmin: 22, fmax: 34, dmin: 24, dmax: 34 },
|
||||
},
|
||||
stall: {
|
||||
label: 'Market Stall',
|
||||
facades: ['facade-market.jpg'],
|
||||
sign: { bg: '#e8e2d0', fg: '#5c3b1e', accent: '#c0392b', style: 'handpainted' },
|
||||
interior: 'open_stall',
|
||||
fittings: ['trestle_tables', 'crates'],
|
||||
storeys: [1, 1],
|
||||
hours: { open: 8, close: 14 }, // market mornings
|
||||
weights: { market: 1 },
|
||||
footprint: { fmin: 3, fmax: 5, dmin: 3, dmax: 5 },
|
||||
},
|
||||
};
|
||||
|
||||
// ── pure helpers (no randomness — plan.js supplies the prng stream) ─────────────────
|
||||
|
||||
// Types that can spawn in a district, as a flat [{type,weight}] list (weight>0), stable order.
|
||||
export function weightedTypes(districtKind) {
|
||||
const out = [];
|
||||
for (const type of SHOP_TYPE_IDS) {
|
||||
const w = SHOP_TYPES[type].weights[districtKind] || 0;
|
||||
if (w > 0) out.push({ type, weight: w });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Sample one type by weight using a prng stream fn r()->[0,1). Returns null if none for district.
|
||||
export function pickWeightedType(r, districtKind, exclude = null) {
|
||||
let list = weightedTypes(districtKind);
|
||||
if (exclude) { const f = list.filter(t => t.type !== exclude); if (f.length) list = f; }
|
||||
if (!list.length) return null;
|
||||
let total = 0; for (const t of list) total += t.weight;
|
||||
let x = r() * total;
|
||||
for (const t of list) { x -= t.weight; if (x < 0) return t.type; }
|
||||
return list[list.length - 1].type;
|
||||
}
|
||||
|
||||
// Every facade filename referenced anywhere (Lane A selfcheck asserts each exists on disk).
|
||||
export function allFacadeSkins() {
|
||||
const set = new Set();
|
||||
for (const type of SHOP_TYPE_IDS) for (const f of SHOP_TYPES[type].facades) set.add(f);
|
||||
return [...set];
|
||||
}
|
||||
337
web/map.html
Normal file
337
web/map.html
Normal file
@ -0,0 +1,337 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PROCITY — plan debugger (Lane A)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#14110d; --panel:#1c1813; --ink:#efe6d2; --dim:#a99a80; --line:#2c2519;
|
||||
--road:#3a3020; --roadmain:#5a4a2e; --roadarc:#4a3a52; --roadlane:#2a241a;
|
||||
--block:#241d14; --accent:#e0a44a;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);
|
||||
font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:hidden}
|
||||
#wrap{display:flex;height:100%}
|
||||
#stage{flex:1;position:relative}
|
||||
canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:crosshair}
|
||||
#side{width:270px;background:var(--panel);border-left:1px solid var(--line);
|
||||
padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:12px}
|
||||
h1{font-size:15px;margin:0;letter-spacing:.06em;color:var(--accent)}
|
||||
h1 small{display:block;color:var(--dim);font-size:11px;letter-spacing:.02em;margin-top:2px}
|
||||
.row{display:flex;gap:6px;align-items:center}
|
||||
input,button{font:inherit;background:#0e0b07;color:var(--ink);border:1px solid var(--line);
|
||||
border-radius:6px;padding:6px 8px}
|
||||
input{flex:1;min-width:0}
|
||||
button{cursor:pointer;background:#2a2216}
|
||||
button:hover{background:#37301f;border-color:var(--accent)}
|
||||
button.primary{background:var(--accent);color:#1a1206;border-color:var(--accent);font-weight:700}
|
||||
.stat{color:var(--dim)} .stat b{color:var(--ink);font-weight:700}
|
||||
.legend{display:grid;grid-template-columns:auto 1fr;gap:3px 8px;align-items:center;font-size:11px}
|
||||
.sw{width:12px;height:12px;border-radius:3px;border:1px solid #0006}
|
||||
.muted{color:var(--dim);font-size:11px}
|
||||
#tip{position:absolute;pointer-events:none;background:#000c;border:1px solid var(--accent);
|
||||
color:var(--ink);padding:5px 8px;border-radius:6px;font-size:12px;max-width:240px;
|
||||
transform:translate(10px,10px);display:none;z-index:5;white-space:nowrap}
|
||||
#tip .t{color:var(--accent);font-weight:700}
|
||||
#tip .s{color:var(--dim);font-size:11px}
|
||||
hr{border:0;border-top:1px solid var(--line);margin:2px 0}
|
||||
.toggles{display:grid;grid-template-columns:1fr 1fr;gap:4px}
|
||||
label.chk{display:flex;gap:5px;align-items:center;font-size:11px;color:var(--dim);cursor:pointer}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="stage">
|
||||
<canvas id="c"></canvas>
|
||||
<div id="tip"></div>
|
||||
</div>
|
||||
<aside id="side">
|
||||
<h1>PROCITY<small>plan debugger · Lane A</small></h1>
|
||||
<div class="row">
|
||||
<input id="seed" value="20261990" inputmode="numeric" title="city seed (uint32)">
|
||||
<button id="regen" class="primary">regen</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="rand" style="flex:1">🎲 random</button>
|
||||
<button id="export" style="flex:1">⤓ JSON</button>
|
||||
</div>
|
||||
<div id="name" class="stat"></div>
|
||||
<div id="stats" class="stat"></div>
|
||||
<hr>
|
||||
<div class="muted">layers</div>
|
||||
<div class="toggles">
|
||||
<label class="chk"><input type="checkbox" id="tChunks"> chunk grid</label>
|
||||
<label class="chk"><input type="checkbox" id="tBlocks" checked> blocks</label>
|
||||
<label class="chk"><input type="checkbox" id="tLots" checked> lots</label>
|
||||
<label class="chk"><input type="checkbox" id="tShops" checked> shop dots</label>
|
||||
<label class="chk"><input type="checkbox" id="tLabels"> all labels</label>
|
||||
<label class="chk"><input type="checkbox" id="tNodes"> nodes</label>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="muted">shop types</div>
|
||||
<div class="legend" id="legend"></div>
|
||||
<hr>
|
||||
<div class="muted">lot use</div>
|
||||
<div class="legend" id="legendUse"></div>
|
||||
<hr>
|
||||
<div class="muted" style="line-height:1.6">
|
||||
drag to pan · scroll to zoom · hover a shop for its name.<br>
|
||||
This is Lane A's debug view — it grows into the in-game map.
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- No importmap / no THREE: this debug page is pure Canvas 2D (CITY_SPEC allows the map page to
|
||||
use THREE, but Lane A's 2D map doesn't need it). It imports only Lane A's pure-data modules. -->
|
||||
<script type="module">
|
||||
import { generatePlan, chunkIndex, CHUNK } from './js/citygen/plan.js';
|
||||
import { SHOP_TYPES, SHOP_TYPE_IDS } from './js/core/registry.js';
|
||||
|
||||
// ── palette ─────────────────────────────────────────────────────────────────────────
|
||||
const TYPE_COLOR = {
|
||||
record:'#ff5470', opshop:'#5fbf74', toy:'#ffcf3f', book:'#c98b3b', video:'#ffe14d',
|
||||
pawn:'#c0c0c0', milkbar:'#ff6b6b', dept:'#e07b9a', stall:'#c8a24a',
|
||||
};
|
||||
const USE_COLOR = {
|
||||
shop:'#6b5a3a', anchor:'#8a4a63', house:'#3f5a4a', yard:'#2e3a2a', stall:'#7a6a3a', infill:'#4a4436',
|
||||
};
|
||||
const EDGE_STYLE = { // c = bare CSS custom-prop name OR hex — getcss() resolves either (never a var() string,
|
||||
main:{c:'--roadmain', w:1}, side:{c:'#4a3f28', w:1}, lane:{c:'#2a241a', w:1}, arcade:{c:'#6a4a72', w:1}, // which canvas can't parse)
|
||||
};
|
||||
|
||||
// ── state ───────────────────────────────────────────────────────────────────────────
|
||||
const cv = document.getElementById('c'), ctx = cv.getContext('2d');
|
||||
let plan = null, idx = null, DPR = Math.min(devicePixelRatio||1, 2);
|
||||
const view = { x:0, z:0, scale:0.6 }; // world→screen: screen = (world - viewcenter)*scale + canvascenter
|
||||
let hover = null;
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const cssPx = () => ({ w: cv.clientWidth, h: cv.clientHeight });
|
||||
|
||||
function resize(){
|
||||
const {w,h} = cssPx();
|
||||
cv.width = w*DPR; cv.height = h*DPR;
|
||||
draw();
|
||||
}
|
||||
function fitView(){
|
||||
const {w,h} = cssPx();
|
||||
const s = plan.size;
|
||||
view.x = 0; view.z = 0;
|
||||
view.scale = Math.min(w/(s.w*1.08), h/(s.d*1.08));
|
||||
}
|
||||
const w2s = (x,z) => {
|
||||
const {w,h} = cssPx();
|
||||
return [ (x-view.x)*view.scale + w/2, (z-view.z)*view.scale + h/2 ];
|
||||
};
|
||||
const s2w = (sx,sz) => {
|
||||
const {w,h} = cssPx();
|
||||
return [ (sx-w/2)/view.scale + view.x, (sz-h/2)/view.scale + view.z ];
|
||||
};
|
||||
|
||||
// ── draw ──────────────────────────────────────────────────────────────────────────
|
||||
function draw(){
|
||||
if(!plan) return;
|
||||
const {w,h}=cssPx();
|
||||
ctx.setTransform(DPR,0,0,DPR,0,0);
|
||||
ctx.clearRect(0,0,w,h);
|
||||
ctx.fillStyle = getcss('--bg'); ctx.fillRect(0,0,w,h);
|
||||
|
||||
// town bounds
|
||||
const s=plan.size;
|
||||
const [bx0,bz0]=w2s(-s.w/2,-s.d/2), [bx1,bz1]=w2s(s.w/2,s.d/2);
|
||||
ctx.fillStyle='#0e0b07'; ctx.fillRect(bx0,bz0,bx1-bx0,bz1-bz0);
|
||||
ctx.strokeStyle=getcss('--line'); ctx.lineWidth=1; ctx.strokeRect(bx0,bz0,bx1-bx0,bz1-bz0);
|
||||
|
||||
if ($('tChunks').checked) drawChunks();
|
||||
if ($('tBlocks').checked) drawBlocks();
|
||||
drawStreets();
|
||||
if ($('tLots').checked) drawLots();
|
||||
if ($('tShops').checked) drawShops();
|
||||
if ($('tNodes').checked) drawNodes();
|
||||
if (hover) drawHoverHi();
|
||||
drawCompass();
|
||||
}
|
||||
let _drawQueued=false;
|
||||
function scheduleDraw(){ if(_drawQueued) return; _drawQueued=true; requestAnimationFrame(()=>{ _drawQueued=false; draw(); }); }
|
||||
function getcss(v){ return getComputedStyle(document.documentElement).getPropertyValue(v).trim() || v; }
|
||||
|
||||
function drawChunks(){
|
||||
const s=plan.size, c=CHUNK;
|
||||
ctx.strokeStyle='#ffffff10'; ctx.lineWidth=1;
|
||||
for(let x=-s.w/2; x<=s.w/2; x+=c){ const [sx0,sy0]=w2s(x,-s.d/2),[sx1,sy1]=w2s(x,s.d/2);
|
||||
ctx.beginPath();ctx.moveTo(sx0,sy0);ctx.lineTo(sx1,sy1);ctx.stroke(); }
|
||||
for(let z=-s.d/2; z<=s.d/2; z+=c){ const [sx0,sy0]=w2s(-s.w/2,z),[sx1,sy1]=w2s(s.w/2,z);
|
||||
ctx.beginPath();ctx.moveTo(sx0,sy0);ctx.lineTo(sx1,sy1);ctx.stroke(); }
|
||||
}
|
||||
function drawBlocks(){
|
||||
for(const b of plan.blocks){
|
||||
ctx.beginPath();
|
||||
b.poly.forEach((p,i)=>{ const [sx,sy]=w2s(p[0],p[1]); i?ctx.lineTo(sx,sy):ctx.moveTo(sx,sy); });
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = DISTRICT_FILL[b.kind] || '#241d1440';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle='#00000030'; ctx.lineWidth=1; ctx.stroke();
|
||||
}
|
||||
}
|
||||
const DISTRICT_FILL = {
|
||||
mainstreet:'#2c2414aa', backstreets:'#241f16aa', arcade:'#2e2338aa',
|
||||
market:'#2c2a16aa', warehouse:'#20211caa', residential:'#1c2620aa',
|
||||
};
|
||||
function drawStreets(){
|
||||
const nById = new Map(plan.streets.nodes.map(n=>[n.id,n]));
|
||||
// widen roads with the edge width, scaled
|
||||
for(const e of plan.streets.edges){
|
||||
const a=nById.get(e.a), b=nById.get(e.b); if(!a||!b) continue;
|
||||
const [ax,ay]=w2s(a.x,a.z), [bx,by]=w2s(b.x,b.z);
|
||||
const st = EDGE_STYLE[e.kind]||EDGE_STYLE.side;
|
||||
ctx.strokeStyle = getcss(st.c);
|
||||
ctx.lineWidth = Math.max(1, e.width*view.scale);
|
||||
ctx.lineCap='round';
|
||||
ctx.beginPath();ctx.moveTo(ax,ay);ctx.lineTo(bx,by);ctx.stroke();
|
||||
}
|
||||
}
|
||||
function lotQuad(l){
|
||||
const cs=Math.cos(l.ry), sn=Math.sin(l.ry), hw=l.w/2, hd=l.d/2;
|
||||
return [[-hw,-hd],[hw,-hd],[hw,hd],[-hw,hd]].map(([ox,oz])=>[l.x+ox*cs+oz*sn, l.z-ox*sn+oz*cs]);
|
||||
}
|
||||
function drawLots(){
|
||||
for(const l of plan.lots){
|
||||
const q=lotQuad(l);
|
||||
ctx.beginPath();
|
||||
q.forEach((p,i)=>{ const [sx,sy]=w2s(p[0],p[1]); i?ctx.lineTo(sx,sy):ctx.moveTo(sx,sy); });
|
||||
ctx.closePath();
|
||||
ctx.fillStyle=USE_COLOR[l.use]||'#333'; ctx.fill();
|
||||
ctx.strokeStyle='#0008'; ctx.lineWidth=.5; ctx.stroke();
|
||||
}
|
||||
}
|
||||
let _sbl=null; // shop-by-lot cache, module-level (NEVER attached to plan — export must stay schema-clean)
|
||||
function shopByLot(){ return _sbl || (_sbl=new Map(plan.shops.map(s=>[s.lot,s]))); }
|
||||
function drawShops(){
|
||||
const sbl=shopByLot();
|
||||
const lById=new Map(plan.lots.map(l=>[l.id,l]));
|
||||
const showLabels=$('tLabels').checked;
|
||||
ctx.textAlign='center'; ctx.textBaseline='middle';
|
||||
for(const sh of plan.shops){
|
||||
const l=lById.get(sh.lot); if(!l) continue;
|
||||
const [sx,sy]=w2s(l.x,l.z);
|
||||
const rr=Math.max(2, Math.min(6, l.w*view.scale*0.35));
|
||||
ctx.beginPath();ctx.arc(sx,sy,rr,0,7); ctx.fillStyle=TYPE_COLOR[sh.type]||'#fff'; ctx.fill();
|
||||
ctx.lineWidth=1; ctx.strokeStyle='#0009'; ctx.stroke();
|
||||
if(showLabels && view.scale>0.8){
|
||||
ctx.font='9px ui-monospace,monospace'; ctx.fillStyle='#efe6d2cc';
|
||||
ctx.fillText(sh.sign, sx, sy-rr-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
function drawNodes(){
|
||||
ctx.fillStyle=getcss('--accent');
|
||||
for(const n of plan.streets.nodes){ const [sx,sy]=w2s(n.x,n.z);
|
||||
ctx.beginPath();ctx.arc(sx,sy,2,0,7);ctx.fill(); }
|
||||
}
|
||||
function drawHoverHi(){
|
||||
const l=hover.lot; const q=lotQuad(l);
|
||||
ctx.beginPath(); q.forEach((p,i)=>{const [sx,sy]=w2s(p[0],p[1]); i?ctx.lineTo(sx,sy):ctx.moveTo(sx,sy);});
|
||||
ctx.closePath(); ctx.strokeStyle=getcss('--accent'); ctx.lineWidth=2; ctx.stroke();
|
||||
}
|
||||
function drawCompass(){
|
||||
const {w}=cssPx(); const x=w-38, y=34;
|
||||
ctx.strokeStyle=getcss('--dim'); ctx.fillStyle=getcss('--dim'); ctx.lineWidth=1.5;
|
||||
ctx.beginPath();ctx.moveTo(x,y+12);ctx.lineTo(x,y-12);ctx.stroke();
|
||||
ctx.beginPath();ctx.moveTo(x,y-12);ctx.lineTo(x-3,y-7);ctx.lineTo(x+3,y-7);ctx.closePath();ctx.fill();
|
||||
ctx.font='10px monospace';ctx.textAlign='center';ctx.fillText('N',x,y-16);
|
||||
}
|
||||
|
||||
// ── picking ─────────────────────────────────────────────────────────────────────────
|
||||
function pick(sx,sz){
|
||||
const [wx,wz]=s2w(sx,sz);
|
||||
const sbl=shopByLot();
|
||||
let best=null, bestD=Infinity;
|
||||
for(const l of plan.lots){
|
||||
const d=(l.x-wx)**2+(l.z-wz)**2;
|
||||
if(d<bestD && d < Math.max(l.w,l.d)**2){ bestD=d; best=l; }
|
||||
}
|
||||
if(!best) return null;
|
||||
return { lot:best, shop:sbl.get(best.id)||null };
|
||||
}
|
||||
|
||||
// ── interaction ───────────────────────────────────────────────────────────────────
|
||||
let drag=null;
|
||||
cv.addEventListener('mousedown', e=>{ drag={x:e.clientX,y:e.clientY,vx:view.x,vz:view.z}; });
|
||||
addEventListener('mouseup', ()=> drag=null);
|
||||
addEventListener('mousemove', e=>{
|
||||
const rect=cv.getBoundingClientRect(); const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
|
||||
if(drag){ view.x=drag.vx-(e.clientX-drag.x)/view.scale; view.z=drag.vz-(e.clientY-drag.y)/view.scale; scheduleDraw(); return; }
|
||||
if(sx<0||sy<0||sx>rect.width||sy>rect.height){ hideTip(); return; }
|
||||
hover=pick(sx,sy);
|
||||
const tip=$('tip');
|
||||
if(hover){
|
||||
cv.style.cursor='pointer';
|
||||
const l=hover.lot, s=hover.shop;
|
||||
tip.style.display='block'; tip.style.left=sx+'px'; tip.style.top=sy+'px';
|
||||
tip.innerHTML = s
|
||||
? `<div class="t">${esc(s.name)}</div><div class="s">${s.type} · ${SHOP_TYPES[s.type]?.label||''} · ${s.storeys}fl · ${pad(s.hours[0])}–${pad(s.hours[1])}</div><div class="s">${s.facadeSkin}</div>`
|
||||
: `<div class="t">${l.use} lot</div><div class="s">${l.w}×${l.d}m · block ${l.block}</div>`;
|
||||
scheduleDraw();
|
||||
} else { hideTip(); }
|
||||
});
|
||||
function hideTip(){ if(hover){hover=null;scheduleDraw();} $('tip').style.display='none'; cv.style.cursor='crosshair'; }
|
||||
cv.addEventListener('wheel', e=>{
|
||||
e.preventDefault();
|
||||
const rect=cv.getBoundingClientRect(); const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
|
||||
const [wx,wz]=s2w(sx,sy);
|
||||
const f=Math.exp(-e.deltaY*0.0012);
|
||||
view.scale=Math.max(0.05, Math.min(8, view.scale*f));
|
||||
const [nx,nz]=s2w(sx,sy); view.x+=wx-nx; view.z+=wz-nz;
|
||||
scheduleDraw();
|
||||
}, {passive:false});
|
||||
const esc=s=>String(s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));
|
||||
const pad=h=>String(h).padStart(2,'0')+':00';
|
||||
|
||||
// ── panel ─────────────────────────────────────────────────────────────────────────
|
||||
function regen(seed){
|
||||
const s = (seed>>>0);
|
||||
plan = generatePlan(s); idx = chunkIndex(plan); _sbl = null; // rebuild the cache for the new plan
|
||||
$('seed').value = s;
|
||||
$('name').innerHTML = `<b style="color:var(--accent);font-size:14px">${esc(plan.name)}</b>`;
|
||||
const nShop=plan.shops.length, byType={};
|
||||
for(const sh of plan.shops) byType[sh.type]=(byType[sh.type]||0)+1;
|
||||
$('stats').innerHTML =
|
||||
`<b>${plan.streets.edges.length}</b> streets · <b>${plan.blocks.length}</b> blocks<br>`+
|
||||
`<b>${plan.lots.length}</b> lots · <b>${nShop}</b> shops · <b>${Object.keys(idx.chunks).length}</b> chunks`;
|
||||
fitView(); draw(); buildLegend(byType);
|
||||
}
|
||||
function buildLegend(byType){
|
||||
const L=$('legend'); L.innerHTML='';
|
||||
for(const t of SHOP_TYPE_IDS){
|
||||
const sw=document.createElement('span'); sw.className='sw'; sw.style.background=TYPE_COLOR[t];
|
||||
const lb=document.createElement('span'); lb.textContent=`${t} (${byType?.[t]||0})`;
|
||||
L.append(sw,lb);
|
||||
}
|
||||
const U=$('legendUse'); U.innerHTML='';
|
||||
for(const u of Object.keys(USE_COLOR)){
|
||||
const sw=document.createElement('span'); sw.className='sw'; sw.style.background=USE_COLOR[u];
|
||||
const lb=document.createElement('span'); lb.textContent=u;
|
||||
U.append(sw,lb);
|
||||
}
|
||||
}
|
||||
$('regen').onclick=()=>regen(parseInt($('seed').value,10)||0);
|
||||
$('seed').addEventListener('keydown',e=>{ if(e.key==='Enter') $('regen').click(); });
|
||||
$('rand').onclick=()=>regen((Math.random()*4294967295)>>>0); // UI-only randomness; generation stays seeded
|
||||
$('export').onclick=()=>{
|
||||
const blob=new Blob([JSON.stringify(plan,null,0)],{type:'application/json'});
|
||||
const a=document.createElement('a'); a.href=URL.createObjectURL(blob);
|
||||
a.download=`procity-${plan.citySeed}.json`; a.click(); URL.revokeObjectURL(a.href);
|
||||
};
|
||||
for(const id of ['tChunks','tBlocks','tLots','tShops','tLabels','tNodes']) $(id).onchange=draw;
|
||||
addEventListener('resize', resize);
|
||||
|
||||
// boot — seed can come from ?seed=… (Lane F / screenshot harness).
|
||||
// rAF ensures the flex layout has sized the canvas before fitView reads its dimensions.
|
||||
const q=new URLSearchParams(location.search);
|
||||
const bootSeed=parseInt(q.get('seed')||$('seed').value,10)||20261990;
|
||||
requestAnimationFrame(()=>{ resize(); regen(bootSeed); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
6
web/package.json
Normal file
6
web/package.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "procity-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Marks web/js/** as ES modules so plain `node web/js/citygen/selfcheck.js` runs. Browsers use <script type=module>+importmap and ignore this file; python http.server ignores it too. No dependencies, no build step (CITY_SPEC: zero npm). Added by Lane A for the required node self-check."
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user