ascii-city 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,10 @@ layers:
8
8
 
9
9
  - **The base** — `js/citygen.js`, a seeded city generator: spec in, deterministic
10
10
  city model out. Pure data, no rendering, strictly printable ASCII (0x20–0x7E).
11
+ The city is INFINITE: roads sit on a jittered lattice and every block is a
12
+ pure function of (seed, coordinates), so `generateRegion()` can materialize
13
+ any window of it; `js/world.js` slides a 160×160 window around the player
14
+ (regen ~3ms, bit-identical overlap, so recentring is invisible).
11
15
  - **The sim** — `js/sim.js`, the shared gameplay core: player (on foot or
12
16
  driving), civilian traffic, pedestrians, police + wanted stars, delivery
13
17
  missions + money, radio stations, and BFS GPS routing.
@@ -24,7 +28,10 @@ python3 serve.py 8670
24
28
  ```
25
29
 
26
30
  http://localhost:8670 — click to walk. WASD move, mouse look, shift run,
27
- M minimap, N new seed, 1-4 glyph size, arrows work without pointer lock.
31
+ F or left-click shoots, E enters/exits cars, V toggles third person,
32
+ M minimap, N new seed, 1-4 glyph size. Police respond to crimes by default
33
+ (`engine.police: false` for a peaceful city; `engine.missions: true` adds
34
+ deliveries and money).
28
35
 
29
36
  ## Run — terminal
30
37
 
@@ -32,7 +39,9 @@ M minimap, N new seed, 1-4 glyph size, arrows work without pointer lock.
32
39
  npx . # from the repo, or: npm i -g . && ascii-city
33
40
  ```
34
41
 
35
- Same city, same raycaster, rendered as ANSI truecolor cells. In terminals
42
+ Same city, same raycaster, rendered as ANSI truecolor cells. V toggles a
43
+ third-person chase cam, F fires (hold in kitty terminals), E jacks cars,
44
+ and the HUD shows a speedometer while driving. In terminals
36
45
  with the kitty keyboard protocol (iTerm2, Ghostty, kitty, WezTerm) you get
37
46
  real hold-to-move: hold WASD, shift to run, hold arrows or drag the mouse
38
47
  to look. Elsewhere (e.g. Terminal.app, which never reports key releases)
package/js/citygen.js CHANGED
@@ -1,8 +1,10 @@
1
1
  // citygen.js — the city factory.
2
2
  //
3
- // generateCity(config) -> CityModel. Pure data out, no rendering, fully
4
- // deterministic from config.seed. The model (cells + heightMap + buildings)
5
- // is what the future first-person raycast engine will consume.
3
+ // The city is INFINITE and deterministic: every road, block, and building is a
4
+ // pure function of (seed, world coordinates). generateRegion() materializes any
5
+ // rectangular window of it; generateCity() is the finite wrapper the debug page
6
+ // uses. Roads live on a jittered lattice (avenue k sits at k*spacing + hash
7
+ // jitter), so "what is at (x,z)?" never depends on anything far away.
6
8
  //
7
9
  // Fully ASCII: every configurable glyph must be printable ASCII (0x20–0x7E);
8
10
  // assertAscii() warns if a config breaks that.
@@ -19,25 +21,25 @@ export const AXIS_V = 1, AXIS_H = 2, CENTER_V = 4, CENTER_H = 8;
19
21
 
20
22
  export const DEFAULT_CONFIG = {
21
23
  seed: 'neon-sector-101',
22
- size: { width: 160, depth: 120 },
24
+ size: { width: 160, depth: 120 }, // finite mode (debug page) only
23
25
 
24
26
  roads: {
25
27
  avenueSpacing: [30, 44],
26
28
  streetSpacing: [13, 20],
27
29
  avenueWidth: 5,
28
30
  streetWidth: 3,
29
- jitter: 0.5, // 0 = even spacing, 1 = full range
31
+ jitter: 0.5, // 0 = even lattice, 1 = full range
30
32
  },
31
33
 
32
- // weight: share of blocks. centerBias > 0 pulls toward the center, < 0 to the edge.
34
+ // weight: share of blocks; clustering comes from each district's noise field.
33
35
  districts: {
34
36
  noiseScale: 26,
35
37
  types: {
36
- downtown: { weight: 0.9, centerBias: 1.2, height: [16, 52], parcelSize: 20, setback: [1, 2], fill: 0.9, windowDensity: 0.6, wallGlyph: '#', wallRamp: '@#%*+=:.', landmarkChance: 0.08 },
37
- commercial: { weight: 1.1, centerBias: 0.5, height: [8, 20], parcelSize: 12, setback: [1, 2], fill: 0.85, windowDensity: 0.5, wallGlyph: '%', wallRamp: '&%#*+~;.', landmarkChance: 0.02 },
38
- residential: { weight: 1.0, centerBias: -0.2, height: [3, 9], parcelSize: 8, setback: [1, 3], fill: 0.75, windowDensity: 0.35, wallGlyph: ':', wallRamp: '#x*+=;:.', landmarkChance: 0 },
39
- industrial: { weight: 0.9, centerBias: -1.0, height: [3, 7], parcelSize: 16, setback: [1, 2], fill: 0.8, windowDensity: 0.18, wallGlyph: '=', wallRamp: 'W#%*+~-.', landmarkChance: 0 },
40
- park: { weight: 0.7, centerBias: -0.1 },
38
+ downtown: { weight: 0.9, height: [16, 52], parcelSize: 20, setback: [1, 2], fill: 0.9, windowDensity: 0.6, wallGlyph: '#', wallRamp: '@#%*+=:.', landmarkChance: 0.08 },
39
+ commercial: { weight: 1.1, height: [8, 20], parcelSize: 12, setback: [1, 2], fill: 0.85, windowDensity: 0.5, wallGlyph: '%', wallRamp: '&%#*+~;.', landmarkChance: 0.02 },
40
+ residential: { weight: 1.0, height: [3, 9], parcelSize: 8, setback: [1, 3], fill: 0.75, windowDensity: 0.35, wallGlyph: ':', wallRamp: '#x*+=;:.', landmarkChance: 0 },
41
+ industrial: { weight: 0.9, height: [3, 7], parcelSize: 16, setback: [1, 2], fill: 0.8, windowDensity: 0.18, wallGlyph: '=', wallRamp: 'W#%*+~-.', landmarkChance: 0 },
42
+ park: { weight: 0.7 },
41
43
  },
42
44
  },
43
45
 
@@ -110,45 +112,51 @@ export function assertAscii(config) {
110
112
  if (bad.length) console.warn('[citygen] non-ASCII glyphs configured:', bad.join(' '));
111
113
  }
112
114
 
113
- const gapFrom = (rng, [a, b], jitter) => {
114
- const mid = (a + b) / 2;
115
- return Math.max(3, Math.round(mid + rng.range(-0.5, 0.5) * jitter * (b - a)));
116
- };
117
-
118
- // Road left-edge positions along one axis: avenues first, streets fill the bands.
119
- function roadPlan(rng, total, roads) {
120
- const list = [];
121
- const avPos = [];
122
- let x = Math.round(rng.range(roads.avenueSpacing[0] * 0.4, roads.avenueSpacing[1] * 0.9));
123
- while (x < total - roads.avenueSpacing[0] * 0.5) {
124
- avPos.push(x);
125
- list.push({ pos: x, w: roads.avenueWidth, avenue: true });
126
- x += gapFrom(rng, roads.avenueSpacing, roads.jitter) + roads.avenueWidth;
127
- }
128
- const bands = [];
129
- let prev = 0;
130
- for (const p of avPos) { bands.push([prev, p]); prev = p + roads.avenueWidth; }
131
- bands.push([prev, total]);
132
- const [smin, smax] = roads.streetSpacing;
133
- for (const [b0, b1] of bands) {
134
- let s = b0 + Math.round(rng.range(smin * 0.7, smax));
135
- while (s + roads.streetWidth < b1 - smin * 0.4) {
136
- list.push({ pos: s, w: roads.streetWidth, avenue: false });
137
- s += gapFrom(rng, roads.streetSpacing, roads.jitter) + roads.streetWidth;
115
+ // --- the road lattice: cut positions as pure functions of their index -----------
116
+
117
+ // All road cuts (left edges) along one axis whose span intersects [lo, hi].
118
+ // Avenues sit on a jittered lattice; streets subdivide each avenue band evenly,
119
+ // each jittered by its own hash. Deterministic for any range, any order.
120
+ function axisCuts(seed, axis, lo, hi, roads) {
121
+ const [aMin, aMax] = roads.avenueSpacing;
122
+ const pitch = (aMin + aMax) / 2 + roads.avenueWidth; // avenue-to-avenue pitch
123
+ const jA = roads.jitter * (aMax - aMin) * 0.5;
124
+ const k0 = Math.floor(lo / pitch) - 2;
125
+ const k1 = Math.ceil(hi / pitch) + 2;
126
+ const avePos = k => Math.round(k * pitch + (hash01(seed, axis, 'av', k) - 0.5) * 2 * jA);
127
+ const cuts = [];
128
+ for (let k = k0; k <= k1; k++) {
129
+ const a0 = avePos(k), a1 = avePos(k + 1);
130
+ cuts.push({ pos: a0, w: roads.avenueWidth, avenue: true });
131
+ // streets in the band (a0+width .. a1)
132
+ const s0 = a0 + roads.avenueWidth;
133
+ const L = a1 - s0;
134
+ const [sMin, sMax] = roads.streetSpacing;
135
+ const sPitch = (sMin + sMax) / 2 + roads.streetWidth;
136
+ const n = Math.max(0, Math.round(L / sPitch) - 1);
137
+ const jS = Math.max(0, Math.min(roads.jitter * (sMax - sMin) * 0.5, L / (n + 1) / 2 - roads.streetWidth));
138
+ for (let j = 1; j <= n; j++) {
139
+ let pos = Math.round(s0 + (L * j) / (n + 1) + (hash01(seed, axis, 'st', k, j) - 0.5) * 2 * jS);
140
+ pos = Math.max(s0 + 3, Math.min(a1 - roads.streetWidth - 3, pos));
141
+ cuts.push({ pos, w: roads.streetWidth, avenue: false });
138
142
  }
139
143
  }
140
- return list.sort((a, b) => a.pos - b.pos);
144
+ cuts.sort((a, b) => a.pos - b.pos);
145
+ // drop any accidental overlaps (extreme configs)
146
+ const out = [];
147
+ for (const c of cuts) {
148
+ if (!out.length || c.pos >= out[out.length - 1].pos + out[out.length - 1].w + 3) out.push(c);
149
+ }
150
+ return out.filter(c => c.pos + c.w > lo && c.pos < hi);
141
151
  }
142
152
 
143
- // Block intervals between road spans on one axis.
144
- function intervals(total, roadsList) {
153
+ // Block intervals (world coords) between road spans on one axis.
154
+ function intervals(cuts) {
145
155
  const iv = [];
146
- let prev = 0;
147
- for (const r of roadsList) {
148
- if (r.pos > prev) iv.push([prev, r.pos]);
149
- prev = Math.max(prev, r.pos + r.w);
156
+ for (let i = 0; i + 1 < cuts.length; i++) {
157
+ const a = cuts[i].pos + cuts[i].w, b = cuts[i + 1].pos;
158
+ if (b > a) iv.push([a, b]);
150
159
  }
151
- if (prev < total) iv.push([prev, total]);
152
160
  return iv;
153
161
  }
154
162
 
@@ -173,92 +181,110 @@ function subdivide(rng, rect, target, minSide) {
173
181
  return out;
174
182
  }
175
183
 
176
- export function generateCity(userConfig = {}) {
184
+ // --- region generation ------------------------------------------------------------
185
+
186
+ // Materialize the window [rx, rx+rw) x [rz, rz+rd) of the infinite city.
187
+ // Cell arrays are local to the window; buildings/trees/fountains/blocks carry
188
+ // WORLD coordinates. model.ox/oz is the window origin.
189
+ export function generateRegion(userConfig, rx, rz, rw, rd) {
177
190
  const t0 = performance.now();
178
- const config = mergeConfig(DEFAULT_CONFIG, userConfig);
191
+ const config = userConfig.__merged ? userConfig : mergeConfig(DEFAULT_CONFIG, userConfig);
192
+ config.__merged = true;
179
193
  assertAscii(config);
180
194
  const seed = String(config.seed);
181
- const W = config.size.width | 0, D = config.size.depth | 0;
182
- const N = W * D;
195
+ const M = 64; // margin: catches any block overlapping the window edge
183
196
 
184
- const cells = new Uint8Array(N); // CELL.*
185
- const roadAxis = new Uint8Array(N); // AXIS_* flags
197
+ const N = rw * rd;
198
+ const cells = new Uint8Array(N);
199
+ const roadAxis = new Uint8Array(N);
186
200
  const heightMap = new Uint16Array(N);
187
201
  const bIndex = new Int32Array(N).fill(-1);
188
- const idx = (x, z) => z * W + x;
189
-
190
- // --- roads ---------------------------------------------------------------
191
- const vRoads = roadPlan(rngStream(seed, 'roads-x'), W, config.roads);
192
- const hRoads = roadPlan(rngStream(seed, 'roads-z'), D, config.roads);
193
- for (const r of vRoads) {
194
- const type = r.avenue ? CELL.AVENUE : CELL.STREET;
195
- const center = r.pos + (r.w >> 1);
196
- for (let x = r.pos; x < r.pos + r.w && x < W; x++) {
197
- for (let z = 0; z < D; z++) {
198
- const i = idx(x, z);
202
+ const inR = (x, z) => x >= rx && z >= rz && x < rx + rw && z < rz + rd;
203
+ const li = (x, z) => (z - rz) * rw + (x - rx);
204
+
205
+ const xCuts = axisCuts(seed, 'x', rx - M, rx + rw + M, config.roads);
206
+ const zCuts = axisCuts(seed, 'z', rz - M, rz + rd + M, config.roads);
207
+
208
+ // --- roads -----------------------------------------------------------------
209
+ for (const c of xCuts) { // vertical roads
210
+ const type = c.avenue ? CELL.AVENUE : CELL.STREET;
211
+ const center = c.pos + (c.w >> 1);
212
+ for (let x = Math.max(c.pos, rx); x < Math.min(c.pos + c.w, rx + rw); x++) {
213
+ for (let z = rz; z < rz + rd; z++) {
214
+ const i = li(x, z);
199
215
  cells[i] = Math.max(cells[i], type);
200
216
  roadAxis[i] |= AXIS_V | (x === center ? CENTER_V : 0);
201
217
  }
202
218
  }
203
219
  }
204
- for (const r of hRoads) {
205
- const type = r.avenue ? CELL.AVENUE : CELL.STREET;
206
- const center = r.pos + (r.w >> 1);
207
- for (let z = r.pos; z < r.pos + r.w && z < D; z++) {
208
- for (let x = 0; x < W; x++) {
209
- const i = idx(x, z);
220
+ for (const c of zCuts) { // horizontal roads
221
+ const type = c.avenue ? CELL.AVENUE : CELL.STREET;
222
+ const center = c.pos + (c.w >> 1);
223
+ for (let z = Math.max(c.pos, rz); z < Math.min(c.pos + c.w, rz + rd); z++) {
224
+ for (let x = rx; x < rx + rw; x++) {
225
+ const i = li(x, z);
210
226
  cells[i] = Math.max(cells[i], type);
211
227
  roadAxis[i] |= AXIS_H | (z === center ? CENTER_H : 0);
212
228
  }
213
229
  }
214
230
  }
215
231
 
216
- // --- blocks + districts ---------------------------------------------------
217
- const xIv = intervals(W, vRoads);
218
- const zIv = intervals(D, hRoads);
232
+ // --- blocks + districts --------------------------------------------------------
233
+ const xIv = intervals(xCuts);
234
+ const zIv = intervals(zCuts);
219
235
  const typeNames = Object.keys(config.districts.types);
220
236
  const noiseFns = {};
221
237
  for (const name of typeNames) {
222
238
  noiseFns[name] = makeValueNoise(seed + ' district ' + name, config.districts.noiseScale);
223
239
  }
224
240
 
225
- const blocks = [];
226
241
  const minBlock = config.blocks.minSide;
227
- for (const [zx0, zx1] of zIv) {
242
+ const blocks = [];
243
+ for (const [z0, z1] of zIv) {
228
244
  for (const [x0, x1] of xIv) {
229
- const w = x1 - x0, d = zx1 - zx0;
230
- if (w < minBlock || d < minBlock) continue; // sliver stays LOT
231
- const cx = x0 + w / 2, cz = zx0 + d / 2;
232
- const dist = Math.hypot(cx - W / 2, cz - D / 2) / Math.hypot(W / 2, D / 2); // 0 center..1 corner
245
+ const w = x1 - x0, d = z1 - z0;
246
+ if (w < minBlock || d < minBlock) continue;
247
+ if (x1 <= rx || z1 <= rz || x0 >= rx + rw || z0 >= rz + rd) continue; // outside window
248
+ const cx = x0 + w / 2, cz = z0 + d / 2;
233
249
  let best = typeNames[0], bestScore = -1;
234
250
  for (const name of typeNames) {
235
251
  const t = config.districts.types[name];
236
- const bias = Math.max(0.05, 1 + (t.centerBias ?? 0) * (0.5 - dist));
237
- const score = (t.weight ?? 1) * bias * (0.3 + 1.4 * noiseFns[name](cx, cz));
252
+ const score = (t.weight ?? 1) * (0.3 + 1.4 * noiseFns[name](cx, cz));
238
253
  if (score > bestScore) { bestScore = score; best = name; }
239
254
  }
240
- blocks.push({ x: x0, z: zx0, w, d, district: best, kind: 'built' });
255
+ blocks.push({ x: x0, z: z0, w, d, district: best, kind: 'built' });
241
256
  }
242
257
  }
243
258
 
244
- // --- fill blocks -----------------------------------------------------------
259
+ // sidewalk lookup for the street-tree spacing rule (pure, order-free)
260
+ const findIv = (iv, v) => {
261
+ for (const [a, b] of iv) if (v >= a && v < b) return [a, b];
262
+ return null;
263
+ };
264
+ const isSidewalk = (x, z) => {
265
+ const bx = findIv(xIv, x), bz = findIv(zIv, z);
266
+ if (!bx || !bz) return false;
267
+ if (bx[1] - bx[0] < minBlock || bz[1] - bz[0] < minBlock) return false;
268
+ return x === bx[0] || x === bx[1] - 1 || z === bz[0] || z === bz[1] - 1;
269
+ };
270
+
271
+ // --- fill blocks ---------------------------------------------------------------
245
272
  const buildings = [];
246
273
  const fountains = [];
247
- const G = config.glyphs;
248
274
  const B = config.buildings;
275
+ const setCell = (x, z, v) => { if (inR(x, z)) cells[li(x, z)] = v; };
249
276
 
250
277
  for (const blk of blocks) {
251
278
  const rng = rngStream(seed, 'block ' + blk.x + ',' + blk.z);
252
279
  const dcfg = config.districts.types[blk.district];
253
280
 
254
- // sidewalk ring
255
281
  for (let x = blk.x; x < blk.x + blk.w; x++) {
256
- cells[idx(x, blk.z)] = CELL.SIDEWALK;
257
- cells[idx(x, blk.z + blk.d - 1)] = CELL.SIDEWALK;
282
+ setCell(x, blk.z, CELL.SIDEWALK);
283
+ setCell(x, blk.z + blk.d - 1, CELL.SIDEWALK);
258
284
  }
259
285
  for (let z = blk.z; z < blk.z + blk.d; z++) {
260
- cells[idx(blk.x, z)] = CELL.SIDEWALK;
261
- cells[idx(blk.x + blk.w - 1, z)] = CELL.SIDEWALK;
286
+ setCell(blk.x, z, CELL.SIDEWALK);
287
+ setCell(blk.x + blk.w - 1, z, CELL.SIDEWALK);
262
288
  }
263
289
 
264
290
  const inner = { x: blk.x + 1, z: blk.z + 1, w: blk.w - 2, d: blk.d - 2 };
@@ -266,47 +292,48 @@ export function generateCity(userConfig = {}) {
266
292
 
267
293
  if (blk.district === 'park') {
268
294
  blk.kind = 'park';
269
- for (let z = inner.z; z < inner.z + inner.d; z++) {
270
- for (let x = inner.x; x < inner.x + inner.w; x++) {
271
- const i = idx(x, z);
272
- let tree = hash01(seed, 'tree', x, z) < config.props.treeDensity;
273
- // keep trees at least 2 cells apart (checked against already-placed neighbors)
274
- if (tree && x > inner.x && cells[idx(x - 1, z)] === CELL.TREE) tree = false;
275
- if (tree && z > inner.z && (
276
- cells[idx(x, z - 1)] === CELL.TREE ||
277
- (x > inner.x && cells[idx(x - 1, z - 1)] === CELL.TREE) ||
278
- (x < inner.x + inner.w - 1 && cells[idx(x + 1, z - 1)] === CELL.TREE)
295
+ // plant on a block-local grid so spacing decisions never depend on the window
296
+ const tmp = new Uint8Array(inner.w * inner.d);
297
+ for (let z = 0; z < inner.d; z++) {
298
+ for (let x = 0; x < inner.w; x++) {
299
+ const wx = inner.x + x, wz = inner.z + z;
300
+ let tree = hash01(seed, 'tree', wx, wz) < config.props.treeDensity;
301
+ if (tree && x > 0 && tmp[z * inner.w + x - 1]) tree = false;
302
+ if (tree && z > 0 && (
303
+ tmp[(z - 1) * inner.w + x] ||
304
+ (x > 0 && tmp[(z - 1) * inner.w + x - 1]) ||
305
+ (x < inner.w - 1 && tmp[(z - 1) * inner.w + x + 1])
279
306
  )) tree = false;
280
- cells[i] = tree ? CELL.TREE : CELL.PARK;
307
+ tmp[z * inner.w + x] = tree ? 1 : 0;
308
+ setCell(wx, wz, tree ? CELL.TREE : CELL.PARK);
281
309
  }
282
310
  }
283
- // cross paths through the middle
284
311
  const px = inner.x + (inner.w >> 1), pz = inner.z + (inner.d >> 1);
285
- for (let x = inner.x; x < inner.x + inner.w; x++) cells[idx(x, pz)] = CELL.PLAZA;
286
- for (let z = inner.z; z < inner.z + inner.d; z++) cells[idx(px, z)] = CELL.PLAZA;
312
+ for (let x = inner.x; x < inner.x + inner.w; x++) setCell(x, pz, CELL.PLAZA);
313
+ for (let z = inner.z; z < inner.z + inner.d; z++) setCell(px, z, CELL.PLAZA);
287
314
  continue;
288
315
  }
289
316
 
290
317
  if (rng.chance(config.blocks.plazaChance)) {
291
318
  blk.kind = 'plaza';
292
319
  for (let z = inner.z; z < inner.z + inner.d; z++) {
293
- for (let x = inner.x; x < inner.x + inner.w; x++) cells[idx(x, z)] = CELL.PLAZA;
320
+ for (let x = inner.x; x < inner.x + inner.w; x++) setCell(x, z, CELL.PLAZA);
294
321
  }
295
322
  fountains.push({ x: inner.x + (inner.w >> 1), z: inner.z + (inner.d >> 1) });
296
323
  for (const [cx2, cz2] of [[inner.x, inner.z], [inner.x + inner.w - 1, inner.z], [inner.x, inner.z + inner.d - 1], [inner.x + inner.w - 1, inner.z + inner.d - 1]]) {
297
- if (rng.chance(0.5)) cells[idx(cx2, cz2)] = CELL.TREE;
324
+ if (rng.chance(0.5)) setCell(cx2, cz2, CELL.TREE);
298
325
  }
299
326
  continue;
300
327
  }
301
328
 
302
329
  // parcels -> buildings
303
330
  const parcels = subdivide(rng, inner, dcfg.parcelSize ?? 12, Math.max(4, B.minSide + 2));
304
- for (const p of parcels) {
305
- const brng = rngStream(seed, 'bldg ' + p.x + ',' + p.z);
306
- if (!brng.chance(dcfg.fill ?? 0.8)) continue; // vacant lot
331
+ for (const pcl of parcels) {
332
+ const brng = rngStream(seed, 'bldg ' + pcl.x + ',' + pcl.z);
333
+ if (!brng.chance(dcfg.fill ?? 0.8)) continue;
307
334
  const sb = brng.int(dcfg.setback?.[0] ?? 1, dcfg.setback?.[1] ?? 2);
308
- const bx = p.x + sb, bz = p.z + sb;
309
- const bw = p.w - sb * 2, bd = p.d - sb * 2;
335
+ const bx = pcl.x + sb, bz = pcl.z + sb;
336
+ const bw = pcl.w - sb * 2, bd = pcl.d - sb * 2;
310
337
  if (bw < B.minSide || bd < B.minSide) continue;
311
338
 
312
339
  const [h0, h1] = dcfg.height ?? [3, 8];
@@ -320,8 +347,8 @@ export function generateCity(userConfig = {}) {
320
347
  const tiers = [{ inset: 0, h }];
321
348
  if (h >= 14 && brng.chance(B.tieredChance)) {
322
349
  let inset = 0, top = h;
323
- const n = brng.int(1, 2);
324
- for (let t = 0; t < n; t++) {
350
+ const nT = brng.int(1, 2);
351
+ for (let t = 0; t < nT; t++) {
325
352
  inset += brng.int(1, 2);
326
353
  if (bw - inset * 2 < 3 || bd - inset * 2 < 3) break;
327
354
  top += brng.int(4, 10);
@@ -330,9 +357,10 @@ export function generateCity(userConfig = {}) {
330
357
  }
331
358
  const height = tiers[tiers.length - 1].h;
332
359
 
333
- const id = buildings.length;
360
+ const idx = buildings.length;
334
361
  buildings.push({
335
- id, x: bx, z: bz, w: bw, d: bd,
362
+ id: bx * 92821 + bz, // stable across any window
363
+ x: bx, z: bz, w: bw, d: bd,
336
364
  district: blk.district, height, tiers, landmark,
337
365
  windowDensity: Math.min(1, (dcfg.windowDensity ?? 0.4) * B.windowScale * brng.range(0.75, 1.15)),
338
366
  litIdx: brng.int(0, 3),
@@ -341,17 +369,17 @@ export function generateCity(userConfig = {}) {
341
369
  antenna: landmark || (height >= 18 && brng.chance(B.antennaChance)),
342
370
  });
343
371
 
344
- for (let z = bz; z < bz + bd; z++) {
345
- for (let x = bx; x < bx + bw; x++) {
346
- const i = idx(x, z);
372
+ for (let z = Math.max(bz, rz); z < Math.min(bz + bd, rz + rd); z++) {
373
+ for (let x = Math.max(bx, rx); x < Math.min(bx + bw, rx + rw); x++) {
374
+ const i = li(x, z);
347
375
  cells[i] = CELL.BUILDING;
348
- bIndex[i] = id;
376
+ bIndex[i] = idx;
349
377
  }
350
378
  }
351
379
  for (const t of tiers) {
352
- for (let z = bz + t.inset; z < bz + bd - t.inset; z++) {
353
- for (let x = bx + t.inset; x < bx + bw - t.inset; x++) {
354
- const i = idx(x, z);
380
+ for (let z = Math.max(bz + t.inset, rz); z < Math.min(bz + bd - t.inset, rz + rd); z++) {
381
+ for (let x = Math.max(bx + t.inset, rx); x < Math.min(bx + bw - t.inset, rx + rw); x++) {
382
+ const i = li(x, z);
355
383
  if (t.h > heightMap[i]) heightMap[i] = t.h;
356
384
  }
357
385
  }
@@ -359,41 +387,39 @@ export function generateCity(userConfig = {}) {
359
387
  }
360
388
  }
361
389
 
362
- // --- props ------------------------------------------------------------------
390
+ // --- street trees + tree list ------------------------------------------------------
391
+ const stChance = config.props.streetTreeChance;
392
+ const candidate = (x, z) => isSidewalk(x, z) && hash01(seed, 'streettree', x, z) < stChance;
363
393
  const trees = [];
364
- for (let z = 0; z < D; z++) {
365
- for (let x = 0; x < W; x++) {
366
- const i = idx(x, z);
367
- if (cells[i] === CELL.SIDEWALK && hash01(seed, 'streettree', x, z) < config.props.streetTreeChance) {
368
- let clear = true;
369
- for (let dz2 = -2; dz2 <= 0 && clear; dz2++) {
370
- for (let dx2 = -2; dx2 <= 2; dx2++) {
371
- if (dz2 === 0 && dx2 >= 0) break; // only cells already scanned
372
- const nx = x + dx2, nz = z + dz2;
373
- if (nx >= 0 && nx < W && nz >= 0 && cells[nz * W + nx] === CELL.TREE) { clear = false; break; }
374
- }
375
- }
376
- if (clear) cells[i] = CELL.TREE;
394
+ for (let z = rz; z < rz + rd; z++) {
395
+ for (let x = rx; x < rx + rw; x++) {
396
+ const i = li(x, z);
397
+ if (cells[i] === CELL.SIDEWALK && candidate(x, z) &&
398
+ !candidate(x - 1, z) && !candidate(x - 2, z) &&
399
+ !candidate(x, z - 1) && !candidate(x, z - 2) &&
400
+ !candidate(x - 1, z - 1) && !candidate(x + 1, z - 1)) {
401
+ cells[i] = CELL.TREE;
377
402
  }
378
403
  if (cells[i] === CELL.TREE) {
379
404
  heightMap[i] = Math.max(heightMap[i], 2);
380
405
  trees.push({
381
406
  x, z,
382
- h: 1.2 + 0.8 * hash01(seed, 'treeh', x, z), // total height in storeys — game scale
383
- r: 0.3 + 0.2 * hash01(seed, 'treer', x, z), // canopy radius in cells
407
+ h: 1.2 + 0.8 * hash01(seed, 'treeh', x, z),
408
+ r: 0.3 + 0.2 * hash01(seed, 'treer', x, z),
384
409
  });
385
410
  }
386
411
  }
387
412
  }
388
413
 
389
- // --- stats ------------------------------------------------------------------
414
+ // --- stats ----------------------------------------------------------------------
390
415
  const byDistrict = {};
391
416
  for (const b of blocks) byDistrict[b.district] = (byDistrict[b.district] ?? 0) + 1;
392
417
  let tallest = 0;
393
418
  for (const b of buildings) tallest = Math.max(tallest, b.height);
394
419
 
395
420
  return {
396
- config, seed, width: W, depth: D,
421
+ config, seed,
422
+ ox: rx, oz: rz, width: rw, depth: rd,
397
423
  cells, roadAxis, heightMap, bIndex,
398
424
  blocks, buildings, trees, fountains,
399
425
  stats: {
@@ -406,3 +432,9 @@ export function generateCity(userConfig = {}) {
406
432
  },
407
433
  };
408
434
  }
435
+
436
+ // Finite wrapper (debug page, skyline probe, tests).
437
+ export function generateCity(userConfig = {}) {
438
+ const config = mergeConfig(DEFAULT_CONFIG, userConfig);
439
+ return generateRegion(config, 0, 0, config.size.width | 0, config.size.depth | 0);
440
+ }
package/js/config.js CHANGED
@@ -5,6 +5,7 @@
5
5
  export const CITY_SPEC = {
6
6
  city: {
7
7
  seed: 'neon-sector-101',
8
+ // the city is INFINITE in the walker/TUI; size only bounds the debug page
8
9
  size: { width: 160, depth: 120 },
9
10
  },
10
11
 
@@ -17,6 +18,7 @@ export const CITY_SPEC = {
17
18
  walkSpeed: 4.5, // cells per second
18
19
  runMult: 2.4,
19
20
  mouseSens: 0.0023,
20
- game: false, // true = GTA mode: missions, money, wanted stars, cops
21
+ police: true, // wanted stars + cops respond to crimes
22
+ missions: false, // deliveries + money + GPS ('game: true' turns both on)
21
23
  },
22
24
  };
package/js/render_map.js CHANGED
@@ -7,6 +7,7 @@ import { shade } from './palettes.js';
7
7
 
8
8
  export function renderMap(canvas, model, atlas, pal) {
9
9
  const { width: W, depth: D, cells, roadAxis, heightMap, bIndex, buildings, seed } = model;
10
+ const ox = model.ox ?? 0, oz = model.oz ?? 0;
10
11
  const G = model.config.glyphs;
11
12
  canvas.width = W * atlas.cw;
12
13
  canvas.height = D * atlas.ch;
@@ -57,13 +58,13 @@ export function renderMap(canvas, model, atlas, pal) {
57
58
  const ax = roadAxis[i];
58
59
  const crossing = (ax & AXIS_V) && (ax & AXIS_H);
59
60
  if (!crossing) {
60
- if ((ax & CENTER_V) && z % 4 < 2) { ch = '|'; col = pal.lane; }
61
- else if ((ax & CENTER_H) && x % 4 < 2) { ch = '-'; col = pal.lane; }
61
+ if ((ax & CENTER_V) && ((z + oz) & 3) < 2) { ch = '|'; col = pal.lane; }
62
+ else if ((ax & CENTER_H) && ((x + ox) & 3) < 2) { ch = '-'; col = pal.lane; }
62
63
  }
63
64
  break;
64
65
  }
65
66
  case CELL.STREET:
66
- if (hash01(seed, 'tex', x, z) < 0.15) { ch = '.'; col = pal.road; }
67
+ if (hash01(seed, 'tex', x + ox, z + oz) < 0.15) { ch = '.'; col = pal.road; }
67
68
  break;
68
69
  case CELL.SIDEWALK:
69
70
  ch = G.sidewalk; col = pal.sidewalk;
@@ -76,7 +77,7 @@ export function renderMap(canvas, model, atlas, pal) {
76
77
  break;
77
78
  }
78
79
  case CELL.PARK:
79
- ch = G.grass[Math.floor(hash01(seed, 'grass', x, z) * G.grass.length)];
80
+ ch = G.grass[Math.floor(hash01(seed, 'grass', x + ox, z + oz) * G.grass.length)];
80
81
  col = pal.park;
81
82
  break;
82
83
  case CELL.TREE:
@@ -86,11 +87,11 @@ export function renderMap(canvas, model, atlas, pal) {
86
87
  ch = G.plaza; col = pal.plaza;
87
88
  break;
88
89
  default: // LOT
89
- if (hash01(seed, 'lot', x, z) < 0.3) { ch = G.lot; col = pal.lot; }
90
+ if (hash01(seed, 'lot', x + ox, z + oz) < 0.3) { ch = G.lot; col = pal.lot; }
90
91
  }
91
92
  if (ch !== ' ') atlas.draw(ctx, ch, col, x, z);
92
93
  }
93
94
  }
94
95
 
95
- for (const f of model.fountains) atlas.draw(ctx, G.fountain, pal.fountain, f.x, f.z);
96
+ for (const f of model.fountains) atlas.draw(ctx, G.fountain, pal.fountain, f.x - ox, f.z - oz);
96
97
  }
@@ -13,6 +13,7 @@ const BANDS = [
13
13
 
14
14
  export function renderSkyline(canvas, model, atlas, pal) {
15
15
  const { width: W, depth: D, buildings, seed } = model;
16
+ const ox = model.ox ?? 0, oz = model.oz ?? 0;
16
17
  const G = model.config.glyphs;
17
18
 
18
19
  let maxH = 6;
@@ -48,7 +49,7 @@ export function renderSkyline(canvas, model, atlas, pal) {
48
49
 
49
50
  const sorted = buildings.slice().sort((a, b) => a.z - b.z); // far first
50
51
  for (const b of sorted) {
51
- const frac = (b.z + b.d / 2) / D;
52
+ const frac = (b.z - oz + b.d / 2) / D;
52
53
  const band = BANDS[frac < 0.34 ? 0 : frac < 0.67 ? 1 : 2];
53
54
  const br = band.brightness;
54
55
  const wallC = shade(pal.districts[b.district] ?? pal.wall, 0.5 * br);
@@ -59,7 +60,7 @@ export function renderSkyline(canvas, model, atlas, pal) {
59
60
  const skyBgC = shade(pal.sky[1], 0.9); // fill behind glyphs so far layers get occluded
60
61
 
61
62
  for (const tier of b.tiers) {
62
- const tx = b.x + tier.inset;
63
+ const tx = b.x - ox + tier.inset;
63
64
  const tw = b.w - tier.inset * 2;
64
65
  if (tw < 1) continue;
65
66
  const th = Math.min(tier.h, rows - band.lift - 2);
@@ -95,7 +96,7 @@ export function renderSkyline(canvas, model, atlas, pal) {
95
96
  }
96
97
 
97
98
  if (b.antenna) {
98
- const ax = b.x + (b.w >> 1);
99
+ const ax = b.x - ox + (b.w >> 1);
99
100
  const topY = ground - band.lift - Math.min(b.height, rows - band.lift - 2) - 1;
100
101
  if (topY - 2 >= 0 && ax >= 0 && ax < W) {
101
102
  atlas.draw(ctx, '|', edgeC, ax, topY - 1);