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 +11 -2
- package/js/citygen.js +168 -136
- package/js/config.js +3 -1
- package/js/render_map.js +7 -6
- package/js/render_skyline.js +4 -3
- package/js/sim.js +141 -69
- package/js/tui.js +118 -44
- package/js/walker.js +122 -51
- package/js/world.js +29 -0
- package/package.json +2 -2
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// is
|
|
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
|
|
31
|
+
jitter: 0.5, // 0 = even lattice, 1 = full range
|
|
30
32
|
},
|
|
31
33
|
|
|
32
|
-
// weight: share of blocks
|
|
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,
|
|
37
|
-
commercial: { weight: 1.1,
|
|
38
|
-
residential: { weight: 1.0,
|
|
39
|
-
industrial: { weight: 0.9,
|
|
40
|
-
park: { weight: 0.7
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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(
|
|
153
|
+
// Block intervals (world coords) between road spans on one axis.
|
|
154
|
+
function intervals(cuts) {
|
|
145
155
|
const iv = [];
|
|
146
|
-
let
|
|
147
|
-
|
|
148
|
-
if (
|
|
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
|
-
|
|
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
|
|
182
|
-
const N = W * D;
|
|
195
|
+
const M = 64; // margin: catches any block overlapping the window edge
|
|
183
196
|
|
|
184
|
-
const
|
|
185
|
-
const
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
205
|
-
const type =
|
|
206
|
-
const center =
|
|
207
|
-
for (let z =
|
|
208
|
-
for (let x =
|
|
209
|
-
const i =
|
|
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(
|
|
218
|
-
const zIv = intervals(
|
|
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
|
-
|
|
242
|
+
const blocks = [];
|
|
243
|
+
for (const [z0, z1] of zIv) {
|
|
228
244
|
for (const [x0, x1] of xIv) {
|
|
229
|
-
const w = x1 - x0, d =
|
|
230
|
-
if (w < minBlock || d < minBlock) continue;
|
|
231
|
-
|
|
232
|
-
const
|
|
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
|
|
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:
|
|
255
|
+
blocks.push({ x: x0, z: z0, w, d, district: best, kind: 'built' });
|
|
241
256
|
}
|
|
242
257
|
}
|
|
243
258
|
|
|
244
|
-
//
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (tree &&
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
(x
|
|
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
|
-
|
|
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++)
|
|
286
|
-
for (let z = inner.z; z < inner.z + inner.d; z++)
|
|
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++)
|
|
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))
|
|
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
|
|
305
|
-
const brng = rngStream(seed, 'bldg ' +
|
|
306
|
-
if (!brng.chance(dcfg.fill ?? 0.8)) continue;
|
|
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 =
|
|
309
|
-
const bw =
|
|
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
|
|
324
|
-
for (let t = 0; 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
|
|
360
|
+
const idx = buildings.length;
|
|
334
361
|
buildings.push({
|
|
335
|
-
id
|
|
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 =
|
|
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] =
|
|
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 =
|
|
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
|
-
// ---
|
|
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 =
|
|
365
|
-
for (let x =
|
|
366
|
-
const i =
|
|
367
|
-
if (cells[i] === CELL.SIDEWALK &&
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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),
|
|
383
|
-
r: 0.3 + 0.2 * hash01(seed, 'treer', x, z),
|
|
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,
|
|
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
|
-
|
|
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
|
|
61
|
-
else if ((ax & CENTER_H) && x
|
|
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
|
}
|
package/js/render_skyline.js
CHANGED
|
@@ -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);
|