sindicate 0.4.0 → 0.6.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.
@@ -0,0 +1,456 @@
1
+ // THE CHART OF DUSTWATER COUNTY — and the gazetteer of everything on it.
2
+ //
3
+ // THREE THINGS LIVE HERE, and they are deliberately the three things that are PURE:
4
+ // renderChart() — the paper. A function of the terrain and nothing else (heightAt +
5
+ // roadDistance + ROADS + RIVERS), rendered ONCE into a bitmap.
6
+ // allPOIs() — the places. Assembled from the world's OWN tables (TOWN, OUTPOSTS,
7
+ // STOPS, SETTLEMENTS), so the map cannot disagree with the county.
8
+ // allBuildings() — the ROOFS. Every building in the county in world space, from those same
9
+ // tables — what the minimap paints its grey blocks from.
10
+ // Everything that MOVES — markers, labels, the player, the waypoint, pan and zoom — lives in
11
+ // worldmap.js and rides on top in screen space. That split is the whole point: the ground does
12
+ // not move, so re-rendering it on a wheel tick is a waste, and an icon that scales with the
13
+ // chart is unreadable at both ends of the zoom.
14
+ //
15
+ // WHY IT LOOKS LIKE PAPER. The first chart was a screenshot-coloured heightmap and it read as a
16
+ // grey-brown smear, because Dustwater IS a smear: a near-flat plain where 900 m of desert differ
17
+ // by two metres. A satellite view of flat ground is a picture of nothing. So this is drawn the
18
+ // way a rider's map would be drawn — ink CONTOURS every four metres (the one thing that makes
19
+ // flat country legible: the lines fan out where it's flat and crowd where it's steep), a lit
20
+ // relief over the top, the roads as ink, the creek in wash, and the whole thing on aged paper.
21
+ // The chart is a pure projection of GAME world data — register it all before the first
22
+ // renderChart/allPOIs call (the game's boot does this from its world module):
23
+ // setChartWorld({ heightAt, roadDistance, roads, rivers, lakes, worldSize, town, fbm,
24
+ // railPaths, outposts, campfires, coachStop, coachStops, buildings })
25
+ let heightAt = () => 0, roadDistance = () => 1e9, fbm = () => 0;
26
+ let ROADS = [], RIVERS = [], LAKES = [], TOWN = null, RAIL_PATHS = [];
27
+ let OUTPOSTS = [], CAMPFIRE_SPOTS = [], COACH_STOP = null, STOPS = [], DUSTWATER = [];
28
+ export let WORLD_SIZE = 4320;
29
+ export function setChartWorld(w = {}) {
30
+ if (w.heightAt) heightAt = w.heightAt;
31
+ if (w.roadDistance) roadDistance = w.roadDistance;
32
+ if (w.fbm) fbm = w.fbm;
33
+ if (w.roads) ROADS = w.roads;
34
+ if (w.rivers) RIVERS = w.rivers;
35
+ if (w.lakes) LAKES = w.lakes;
36
+ if (w.worldSize) WORLD_SIZE = w.worldSize;
37
+ if (w.town !== undefined) TOWN = w.town;
38
+ if (w.railPaths) RAIL_PATHS = w.railPaths;
39
+ if (w.outposts) OUTPOSTS = w.outposts;
40
+ if (w.campfires) CAMPFIRE_SPOTS = w.campfires;
41
+ if (w.coachStop !== undefined) COACH_STOP = w.coachStop;
42
+ if (w.coachStops) STOPS = w.coachStops;
43
+ if (w.buildings) DUSTWATER = w.buildings;
44
+ }
45
+ // ...continued registered world data (see setChartWorld): frontier areas, railway
46
+ // areas, the county's population defs, and the shop tables for the gazetteer.
47
+ let AREAS = [], RAILWAY_AREAS = [], COUNTY_DEFS = [];
48
+ let shopIdFor = () => null, SHOPS = {}, isMerchant = () => false;
49
+ export function setChartGazetteer(g = {}) {
50
+ if (g.areas) AREAS = g.areas;
51
+ if (g.railwayAreas) RAILWAY_AREAS = g.railwayAreas;
52
+ if (g.countyDefs) COUNTY_DEFS = g.countyDefs;
53
+ if (g.shopIdFor) shopIdFor = g.shopIdFor;
54
+ if (g.shops) SHOPS = g.shops;
55
+ if (g.isMerchant) isMerchant = g.isMerchant;
56
+ }
57
+
58
+ // Settlements are registered like the rest of the survey (the old in-game glob import
59
+ // worked only game-side; an engine file cannot glob a game's data directory).
60
+ let SETTLEMENTS = [], SETTLEMENT_AREAS = {};
61
+ export function setChartSettlements(s = {}) {
62
+ if (s.settlements) SETTLEMENTS = s.settlements;
63
+ if (s.settlementAreas) SETTLEMENT_AREAS = s.settlementAreas;
64
+ }
65
+
66
+ const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
67
+ const lerp = (a, b, t) => a + (b - a) * t;
68
+ const smoothstep = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
69
+
70
+ // ————————————————————————————— THE GAZETTEER —————————————————————————————
71
+ // Entry: { id, x, z, name, kind, category, icon, big }
72
+ // kind — what the place IS ('town'|'fort'|'mission'|'camp'|'mine'|'stop'|'ranch'|…)
73
+ // category — the legend's language ('Settlement'|'Outpost'|'Stage'), i.e. how it FILTERS
74
+ // icon — a basename in /assets/ui/map/ (white Synty silhouettes; worldmap inks them)
75
+ // big — a place you navigate a county by: bigger stamp, bigger label, never suppressed
76
+ //
77
+ // The county's proper names. outposts.js carries these in its comments (the world knows what it
78
+ // built); this is the one place that says them out loud, so a new pin renders with a name instead
79
+ // of the raw id the old map printed.
80
+ const OUTPOST_INFO = {
81
+ fort: { name: 'Fort Copperhead', kind: 'fort', icon: 'flag' },
82
+ mexican: { name: 'Mission San Rojo', kind: 'mission', icon: 'cross' }, // a cross, because it is a church
83
+ native: { name: 'Red Hawk Camp', kind: 'camp', icon: 'fire' }, // a campfire
84
+ drill: { name: 'Bonebreak Quarry', kind: 'mine', icon: 'ore' }, // the diggings
85
+ };
86
+
87
+ // SHOPS ON THE CHART — derived from the posted merchants (county.js) so the map cannot disagree with
88
+ // who actually stands behind a counter. def.post/.roam are already world-space (toWorld ran at load).
89
+ // MVP points every trade at one 'shop' silhouette; specialise per shop id as the art lands (edit SHOP_ICON).
90
+ const SHOP_ICON = { general: 'shop', gunsmith: 'shop', doctor: 'shop', saloon: 'shop', stable: 'shop' };
91
+ let _shops = null;
92
+ function buildShopSpots() {
93
+ const out = [];
94
+ for (const def of COUNTY_DEFS) {
95
+ if (!isMerchant(def)) continue;
96
+ const pos = def.post ?? def.roam;
97
+ if (!pos || !Number.isFinite(pos.x) || !Number.isFinite(pos.z)) continue;
98
+ const shopId = shopIdFor(def);
99
+ out.push({ id: `shop:${def.id}`, x: pos.x, z: pos.z, shopId,
100
+ name: SHOPS[shopId]?.name ?? 'Shop', icon: SHOP_ICON[shopId] ?? 'shop' });
101
+ }
102
+ return out;
103
+ }
104
+ export function shopSpots() { return (_shops ??= buildShopSpots()); }
105
+
106
+ // A settlement's trade decides its stamp. SETTLEMENTS is someone else's table and may land with
107
+ // any shape at all, so every field is read defensively and nothing here is required.
108
+ const settlementIcon = (s) => {
109
+ const t = `${s.kind ?? ''} ${s.id ?? ''} ${s.name ?? ''}`;
110
+ if (/mine|dig|claim|quarry/i.test(t)) return 'ore';
111
+ if (/grave|boot ?hill|cemet/i.test(t)) return 'skull';
112
+ if (/mission|church|chapel/i.test(t)) return 'cross';
113
+ if (/camp|fire/i.test(t)) return 'fire';
114
+ return 'home'; // a farm, a ranch, a homestead: a roof is a roof
115
+ };
116
+
117
+ let _pois = null;
118
+ function buildPOIs() {
119
+ const out = [
120
+ { id: 'town', x: TOWN.x, z: TOWN.z, name: 'Dustwater', kind: 'town', category: 'Settlement', icon: 'home', big: true },
121
+ ];
122
+ for (const o of OUTPOSTS) {
123
+ const info = OUTPOST_INFO[o.id] ?? { name: o.id, kind: 'outpost', icon: 'star' };
124
+ out.push({ id: `outpost:${o.id}`, x: o.x, z: o.z, name: info.name, kind: info.kind, category: 'Outpost', icon: info.icon, big: true });
125
+ }
126
+ for (const s of STOPS) {
127
+ // the depot is Dustwater's own stage office — it sits inside the town's marker, so it is
128
+ // named for what it is rather than repeating the town's name at half size
129
+ out.push({ id: `stop:${s.id}`, x: s.x, z: s.z, name: s.inTown ? 'Stage Depot' : `${s.name} Stop`, kind: 'stop', category: 'Stage', icon: 'horse' });
130
+ }
131
+ // rest-fires — the same six the game builds (see data/campfires.js); always on the map so a ride
132
+ // into the wilds can be planned around a place to sleep. The compass/minimap draw them too.
133
+ CAMPFIRE_SPOTS.forEach((s, i) => {
134
+ out.push({ id: `camp:${i}`, x: s.x, z: s.z, name: 'Campfire', kind: 'camp', category: 'Camp', icon: 'fire' });
135
+ });
136
+ for (const s of SETTLEMENTS ?? []) {
137
+ if (!Number.isFinite(s?.x) || !Number.isFinite(s?.z)) continue; // half-authored row: skip, don't crash the map
138
+ out.push({
139
+ id: `settle:${s.id ?? s.name}`, x: s.x, z: s.z,
140
+ name: s.name ?? s.id ?? 'Homestead',
141
+ kind: s.kind ?? 'ranch', category: 'Settlement',
142
+ icon: s.mapIcon ?? settlementIcon(s), big: !!s.big,
143
+ });
144
+ }
145
+ // the storefronts — the same posted merchants the compass/minimap draw, so all three agree
146
+ for (const s of shopSpots()) {
147
+ out.push({ id: s.id, x: s.x, z: s.z, name: s.name, kind: 'shop', category: 'Shop', icon: s.icon });
148
+ }
149
+ return out;
150
+ }
151
+ export function allPOIs() { return (_pois ??= buildPOIs()); }
152
+ export function poiById(id) { return allPOIs().find((p) => p.id === id) ?? null; }
153
+
154
+ // ————————————————————————————— THE ROOFS —————————————————————————————
155
+ // EVERY BUILDING IN THE COUNTY, in world space: { x, z, ry, collider, name }.
156
+ //
157
+ // The minimap used to paint its grey blocks straight out of town.js's BUILDINGS — which is
158
+ // dustwaterLayout's table and NOTHING else. That was the whole map when Dustwater was the whole
159
+ // county; it is now 48 of the county's 121 buildings, so a rider standing in the middle of
160
+ // Perdition's main street — a second town, eleven buildings — had a minimap with two roads on it
161
+ // and not one roof. Same failure at the fort, the mission, the quarry, every farm and ranch, both
162
+ // line shacks, the burnt claim, the three stage offices and the two railway platforms.
163
+ //
164
+ // The reason they were missing is not a cull: they were never in the list. Dustwater's layout is
165
+ // authored in WORLD coordinates, and every other place in the county is an AREA — a layout authored
166
+ // around its own centre and stamped at a pin (town.js buildArea). So an area's buildings have to go
167
+ // through the SAME transform the stamper uses, or they all pile up on the county's origin:
168
+ // world = pin + R(pin.ry)·local heading = b.ry + pin.ry
169
+ // with R three's own Y rotation (x' = x·cos + z·sin, z' = −x·sin + z·cos) — buildArea:517, copied
170
+ // here rather than guessed, because a sign error here puts the barn on the wrong side of the yard.
171
+ //
172
+ // The pins come from the same arrays the WORLD builds from (world.js:69-99): STOPS, OUTPOSTS — which
173
+ // outposts.js has already spread SETTLEMENTS into — and RAILWAY_AREAS, whose pins are computed off
174
+ // the solved rail centreline. One list, two consumers: a building cannot stand in the world and be
175
+ // missing from the map.
176
+ let _blds = null;
177
+ function buildStructures() {
178
+ const out = DUSTWATER.map((b) => ({ x: b.x, z: b.z, ry: b.ry ?? 0, collider: b.collider ?? 4, name: b.name }));
179
+ const stamp = (area, at) => {
180
+ if (!area || !at || !Number.isFinite(at.x) || !Number.isFinite(at.z)) return;
181
+ const cos = Math.cos(at.ry ?? 0), sin = Math.sin(at.ry ?? 0);
182
+ for (const b of area.buildings ?? []) {
183
+ out.push({
184
+ x: at.x + b.x * cos + b.z * sin,
185
+ z: at.z - b.x * sin + b.z * cos,
186
+ ry: (b.ry ?? 0) + (at.ry ?? 0),
187
+ collider: b.collider ?? 4,
188
+ name: b.name ?? area.name,
189
+ });
190
+ }
191
+ };
192
+ for (const stop of STOPS) stamp(COACH_STOP, stop); // the three stage offices
193
+ for (const spot of OUTPOSTS) {
194
+ if (spot.rail) continue; // a pin that is only a pin — see outposts.js
195
+ stamp(SETTLEMENT_AREAS[spot.area] ?? AREAS.find((a) => a.id === spot.id), spot);
196
+ }
197
+ for (const { area, at } of RAILWAY_AREAS) stamp(area, at); // the station and the loading stage
198
+ return out;
199
+ }
200
+ export function allBuildings() { return (_blds ??= buildStructures()); }
201
+
202
+ // ————————————————————————————— THE PAPER —————————————————————————————
203
+
204
+ // HEIGHT → INK. Not a satellite palette: the range a WATERCOLOURIST would use on a survey of
205
+ // this county — bleached flats, dust, ochre, the red rock the quarry is cut into. Everything is
206
+ // pulled toward the paper so the sheet reads as one document rather than a picture with a border.
207
+ const LAND = [
208
+ [-8, [186, 169, 133]], // the washes and the low ground
209
+ [2, [214, 198, 161]], // the flats — sun-bleached, almost the paper itself
210
+ [16, [208, 183, 139]], // dust
211
+ [36, [201, 163, 113]], // rising ground — ochre
212
+ [64, [180, 130, 90]], // mesa shoulder
213
+ [100, [153, 100, 74]], // red rock
214
+ [145, [190, 162, 142]], // bare stone, catching the light
215
+ ];
216
+ function tint(h, out) {
217
+ for (let i = 1; i < LAND.length; i++) {
218
+ if (h <= LAND[i][0]) {
219
+ const [h0, c0] = LAND[i - 1], [h1, c1] = LAND[i];
220
+ const t = (h - h0) / (h1 - h0 || 1);
221
+ out[0] = lerp(c0[0], c1[0], t); out[1] = lerp(c0[1], c1[1], t); out[2] = lerp(c0[2], c1[2], t);
222
+ return out;
223
+ }
224
+ }
225
+ const c = LAND[LAND.length - 1][1];
226
+ out[0] = c[0]; out[1] = c[1]; out[2] = c[2];
227
+ return out;
228
+ }
229
+
230
+ // THE PADS. Town, every outpost, and any settlement that brought its own radius stand on
231
+ // FLATTENED ground, and a flattened disc has a hard rim. Hillshade a hard rim and you get a grey
232
+ // coffee-ring round every settlement; contour a hard rim and you get a bullseye. So relief AND
233
+ // contours are suppressed inside them — they are drawn as markers anyway, and nobody needs to see
234
+ // the shape of a slab.
235
+ // Computed at CALL time, never at import: the registered survey lands after this module
236
+ // evaluates (the module-level version crashed on TOWN=null before registration).
237
+ function chartPads() {
238
+ return [
239
+ ...(TOWN ? [[TOWN.x, TOWN.z, TOWN.r + 14]] : []),
240
+ ...OUTPOSTS.map((o) => [o.x, o.z, o.r * 1.2]),
241
+ ...(SETTLEMENTS ?? []).filter((s) => Number.isFinite(s?.x) && Number.isFinite(s?.r)).map((s) => [s.x, s.z, s.r * 1.2]),
242
+ ];
243
+ }
244
+ const onAPad = (x, z) => {
245
+ for (const [px, pz, r] of chartPads()) if ((x - px) ** 2 + (z - pz) ** 2 < r * r) return true;
246
+ return false;
247
+ };
248
+
249
+ // CONTOURS. The interval is the whole design decision: at 10 m the plain has no lines at all and
250
+ // the map is blank; at 2 m the mesas turn into a solid black band. 4 m gives the flats one or two
251
+ // lazy lines to a mile and still resolves the terraced buttes in the outer ring. Every 5th is an
252
+ // INDEX contour, drawn heavier, the way a real sheet does it.
253
+ const CONTOUR = 4;
254
+ const INDEX_EVERY = 5;
255
+
256
+ // Render the chart. `size` is the bitmap's edge in pixels; the county is WORLD_SIZE metres, so at
257
+ // 1600 one pixel is 1.35 m of desert — which still holds together at the map's 8× zoom, where the
258
+ // old 900px sheet went to mush. It is nearly TWICE the sheet for LESS time (~1.2s measured, against
259
+ // the old ~1.7s), because the height field is sampled into an array ONCE and the relief and the
260
+ // contours are then read back out of it; the old one called heightAt five times per pixel to
261
+ // difference its neighbours in place.
262
+ export function renderChart(size = 1600) {
263
+ const cv = document.createElement('canvas');
264
+ cv.width = cv.height = size;
265
+ const ctx = cv.getContext('2d');
266
+ const M = WORLD_SIZE / size; // metres per pixel
267
+ const half = WORLD_SIZE / 2;
268
+ const w2p = (v) => (v / WORLD_SIZE + 0.5) * size;
269
+
270
+ // 1) THE FIELD, sampled once.
271
+ const H = new Float32Array(size * size);
272
+ const PAD = new Uint8Array(size * size);
273
+ for (let j = 0; j < size; j++) {
274
+ const z = -half + (j + 0.5) * M;
275
+ for (let i = 0; i < size; i++) {
276
+ const x = -half + (i + 0.5) * M;
277
+ const o = j * size + i;
278
+ H[o] = heightAt(x, z);
279
+ PAD[o] = onAPad(x, z) ? 1 : 0;
280
+ }
281
+ }
282
+
283
+ // 2) THE SHEET.
284
+ const img = ctx.createImageData(size, size);
285
+ const px = img.data;
286
+ const c = [0, 0, 0];
287
+ for (let j = 0; j < size; j++) {
288
+ const z = -half + (j + 0.5) * M;
289
+ for (let i = 0; i < size; i++) {
290
+ const o = j * size + i;
291
+ const x = -half + (i + 0.5) * M;
292
+ const h = H[o];
293
+ tint(h, c);
294
+ let r = c[0], g = c[1], b = c[2];
295
+
296
+ if (!PAD[o]) {
297
+ // RELIEF, lit from the NORTH-WEST — as every chart ever drawn is, because the eye reads a
298
+ // lit slope as raised and a dark one as sunk, and light it from the south-east and every
299
+ // canyon in the county comes out as a ridge. Sun from NW ⇒ ground is lit where it rises
300
+ // toward the north-west, i.e. where dh/dx + dh/dz > 0 (north is −Z).
301
+ const i0 = i > 0 ? o - 1 : o, i1 = i < size - 1 ? o + 1 : o;
302
+ const j0 = j > 0 ? o - size : o, j1 = j < size - 1 ? o + size : o;
303
+ const hx = (H[i1] - H[i0]) / (2 * M);
304
+ const hz = (H[j1] - H[j0]) / (2 * M);
305
+ // tanh, not a linear ramp: Dustwater's plain slopes by centimetres per metre and its mesas
306
+ // by whole metres. A linear shade tuned to the mesas leaves the plain dead flat white; one
307
+ // tuned to the plain clips the mesas to solid black. The knee (×5) gives the flats real
308
+ // modelling and lets the buttes ride the shoulder instead of blowing out.
309
+ const s = Math.tanh((hx + hz) * 5);
310
+ // ASYMMETRIC: the shadow side gets more than the lit side. A symmetric shade on ground this
311
+ // pale drives every sunlit slope to white paper, and a white blob with white highlights is
312
+ // the exact "washed-out" chart this replaced — the modelling has to come from the SHADOWS.
313
+ const k = 1 + s * (s > 0 ? 0.17 : 0.34);
314
+ r *= k; g *= k; b *= k;
315
+
316
+ // CONTOURS. A band edge = a line. Compared against the RIGHT and DOWN neighbours only, so
317
+ // each line is drawn once and lands on the uphill side, which is what a pen does.
318
+ const band = Math.floor(h / CONTOUR);
319
+ if (!PAD[i1] && !PAD[j1] && (band !== Math.floor(H[i1] / CONTOUR) || band !== Math.floor(H[j1] / CONTOUR))) {
320
+ const index = band % INDEX_EVERY === 0;
321
+ const t = index ? 0.52 : 0.26; // index contours heavier, as on a real sheet
322
+ r = lerp(r, 92, t); g = lerp(g, 64, t); b = lerp(b, 38, t);
323
+ }
324
+ }
325
+
326
+ // THE ROADS. Not lines drawn on top — sampled from the same roadDistance the TERRAIN paints
327
+ // its dirt with, so the chart's road is where the road IS, to the metre. The graded corridor
328
+ // is ~±10 m, so this is the width you would actually ride on.
329
+ const rd = roadDistance(x, z);
330
+ if (rd < 11) {
331
+ const t = smoothstep(11, 4, rd) * 0.8;
332
+ r = lerp(r, 176, t); g = lerp(g, 143, t); b = lerp(b, 95, t); // pale graded dirt
333
+ }
334
+
335
+ // PAPER GRAIN. A deterministic hash, not Math.random: the chart is rebuilt on every boot and
336
+ // a map whose specks move between sessions reads as noise, not as paper.
337
+ let hs = (i * 374761393 + j * 668265263) | 0;
338
+ hs = (hs ^ (hs >>> 13)) * 1274126177;
339
+ const n = (((hs ^ (hs >>> 16)) >>> 0) / 4294967295 - 0.5) * 13;
340
+ r += n; g += n; b += n * 0.8;
341
+
342
+ const q = o * 4;
343
+ px[q] = clamp(r, 0, 255);
344
+ px[q + 1] = clamp(g, 0, 255);
345
+ px[q + 2] = clamp(b, 0, 255);
346
+ px[q + 3] = 255;
347
+ }
348
+ }
349
+ ctx.putImageData(img, 0, 0);
350
+
351
+ // 3) THE PEN WORK, over the top.
352
+ ctx.lineJoin = ctx.lineCap = 'round';
353
+ const K = size / 1200; // every width below is drawn at 1200 and scaled
354
+
355
+ // THE GRATICULE — a survey grid every 200 m, faint. It is what tells you, at a glance, that the
356
+ // county is 2 km across; without it the flats have no scale at all.
357
+ ctx.strokeStyle = 'rgba(92,70,44,0.13)';
358
+ ctx.lineWidth = 1 * K;
359
+ for (let v = -1000; v <= 1000; v += 200) {
360
+ ctx.beginPath(); ctx.moveTo(w2p(v), 0); ctx.lineTo(w2p(v), size); ctx.stroke();
361
+ ctx.beginPath(); ctx.moveTo(0, w2p(v)); ctx.lineTo(size, w2p(v)); ctx.stroke();
362
+ }
363
+
364
+ // THE CREEK — the county's only water, away north. Drawn from RIVERS, so it runs in its own
365
+ // channel: a pale wash for the banks, then the water.
366
+ for (const R of RIVERS) {
367
+ const trace = () => {
368
+ ctx.beginPath();
369
+ R.pts.forEach((q, i) => (i ? ctx.lineTo(w2p(q.x), w2p(q.z)) : ctx.moveTo(w2p(q.x), w2p(q.z))));
370
+ ctx.stroke();
371
+ };
372
+ ctx.strokeStyle = 'rgba(196,186,150,0.75)'; ctx.lineWidth = Math.max(7, (R.w * 2.4) / M) * K; trace(); // the dry banks
373
+ ctx.strokeStyle = 'rgba(104,132,132,0.85)'; ctx.lineWidth = Math.max(2.6, R.w / M) * K; trace(); // the water
374
+ }
375
+
376
+ // THE LAKES — the same two inks as the creek: a bank ring, then the water as a filled disc.
377
+ // LAKES is empty in Dustwater, so this prints nothing today — the chart is simply ready for
378
+ // the day the table gains a row (drawn before the road ink, like the creek, so roads print
379
+ // over water edges consistently).
380
+ // Drawn to the terrain's OWN wavy shoreline, NOT a perfect ctx.arc — a circle on the map when the world's
381
+ // shore wanders was the "why are the lakes circular" tell. Same fbm field + params + amplitude as the
382
+ // carve (terrainData rawHeight: d = √ld2 + fbm(x+L.x·7.3, z−L.z·5.1,2,2,.5,.06)·L.r·0.26), so the shore on
383
+ // the map is the shore you walk up to. (The old rClip disc was ~1.3× the real shore AND perfectly round.)
384
+ for (const Lk of LAKES) {
385
+ const N = 72;
386
+ ctx.beginPath();
387
+ for (let i = 0; i <= N; i++) {
388
+ const th = (i / N) * Math.PI * 2, cx = Math.cos(th), cz = Math.sin(th);
389
+ const sx = Lk.x + cx * Lk.r, sz = Lk.z + cz * Lk.r; // a point on the nominal ring
390
+ const wob = fbm(sx + Lk.x * 7.3, sz - Lk.z * 5.1, 2, 2, 0.5, 0.06) * Lk.r * 0.26; // the carve's shore wobble
391
+ const rr = Lk.r - wob; // shoreline radius at this bearing
392
+ const px = w2p(Lk.x + cx * rr), pz = w2p(Lk.z + cz * rr);
393
+ i ? ctx.lineTo(px, pz) : ctx.moveTo(px, pz);
394
+ }
395
+ ctx.closePath();
396
+ ctx.strokeStyle = 'rgba(196,186,150,0.75)'; ctx.lineWidth = Math.max(7, 9.6 / M) * K; ctx.stroke(); // the dry banks
397
+ ctx.fillStyle = 'rgba(104,132,132,0.85)'; ctx.fill(); // the water
398
+ }
399
+
400
+ // THE ROAD CENTRELINES. The dirt is already painted underneath; this is the line the coaches
401
+ // steer by, and on a chart you want to be able to trace a road with your finger across ground
402
+ // where the dirt has run thin.
403
+ ctx.strokeStyle = 'rgba(78,56,32,0.62)';
404
+ ctx.lineWidth = 1.6 * K;
405
+ for (const road of ROADS) {
406
+ ctx.beginPath();
407
+ road.forEach((q, i) => (i ? ctx.lineTo(w2p(q.x), w2p(q.z)) : ctx.moveTo(w2p(q.x), w2p(q.z))));
408
+ ctx.stroke();
409
+ }
410
+
411
+ // THE RAILROAD. Drawn the way a railroad is drawn on a survey sheet — a black line with cross
412
+ // ties hatched across it — because on a chart a rail line must never be mistaken for a road, and
413
+ // the county has both running the same way for kilometres. Two kilometres of line, a spur to the
414
+ // quarry, and it was on none of the chart at all: the trains ran across a map that did not know
415
+ // they existed.
416
+ for (const [name, path] of Object.entries(RAIL_PATHS ?? {})) {
417
+ if (!path?.length) continue;
418
+ ctx.strokeStyle = 'rgba(30,26,22,0.78)';
419
+ ctx.lineWidth = 2.2 * K;
420
+ ctx.beginPath();
421
+ path.forEach((q, i) => (i ? ctx.lineTo(w2p(q.x), w2p(q.z)) : ctx.moveTo(w2p(q.x), w2p(q.z))));
422
+ ctx.stroke();
423
+ // the ties: a short tick square to the line, every 40 m of it — enough to read as track, few
424
+ // enough not to turn a 2 km line into a smear of ink
425
+ ctx.lineWidth = 1.1 * K;
426
+ const TIE_EVERY = 20; // the survey publishes a point every 2 m
427
+ const TIE_HALF = 2.6 * K;
428
+ for (let i = TIE_EVERY; i < path.length - 1; i += TIE_EVERY) {
429
+ const a = path[i - 1], b = path[i + 1];
430
+ const dx = w2p(b.x) - w2p(a.x), dz = w2p(b.z) - w2p(a.z);
431
+ const l = Math.hypot(dx, dz) || 1;
432
+ const nx = -dz / l, nz = dx / l; // square to the rails
433
+ const px = w2p(path[i].x), pz = w2p(path[i].z);
434
+ ctx.beginPath();
435
+ ctx.moveTo(px - nx * TIE_HALF, pz - nz * TIE_HALF);
436
+ ctx.lineTo(px + nx * TIE_HALF, pz + nz * TIE_HALF);
437
+ ctx.stroke();
438
+ }
439
+ }
440
+
441
+ // AGE. A warm vignette and a burnt edge — the sheet has been in a saddlebag. Kept mild: the
442
+ // corners of this map are real country (the mesas), not decoration to be smoked out.
443
+ const vg = ctx.createRadialGradient(size / 2, size / 2, size * 0.34, size / 2, size / 2, size * 0.74);
444
+ vg.addColorStop(0, 'rgba(0,0,0,0)');
445
+ vg.addColorStop(1, 'rgba(74,48,20,0.30)');
446
+ ctx.fillStyle = vg;
447
+ ctx.fillRect(0, 0, size, size);
448
+ ctx.strokeStyle = 'rgba(58,38,16,0.5)';
449
+ ctx.lineWidth = 10 * K;
450
+ ctx.strokeRect(5 * K, 5 * K, size - 10 * K, size - 10 * K); // the sheet's darkened border
451
+
452
+ return cv;
453
+ }
454
+
455
+ // world metres → a fraction across the chart (0..1) — the one conversion every consumer needs
456
+ export const w2f = (v) => v / WORLD_SIZE + 0.5;
@@ -0,0 +1,130 @@
1
+ // DEATH SCREEN — laid out like the Synty "Fantasy Menus" reference Nick keeps showing, in our western
2
+ // palette. The frozen game scene stays visible washed RED; a FULL-WIDTH banner band crosses the middle
3
+ // (soft red, edges fading out); the generated longhorn-skull emblem sits FAINT and CENTRED BEHIND the
4
+ // text; "YOU DIED" is silver-metallic in the band; a gold chevron divider under it; then the choices as
5
+ // a small, tight, CENTRED list of icon+label rows — press the shown pad button (PS ✕/□/○, Xbox A/X/B)
6
+ // or click. ui.deathOpen pauses the world + freezes the player.
7
+ import { padMode, buttonCanvas } from './menuHints.js';
8
+
9
+ const FONT = "Copperplate,'Copperplate Gothic Bold','Palatino','Book Antiqua',Georgia,serif";
10
+ const UI = '/assets/ui/death';
11
+ const KEYNAME = { cross: '↵', square: 'R', circle: 'Esc' };
12
+ const keyCss = 'display:inline-flex;align-items:center;justify-content:center;min-width:30px;height:24px;padding:0 7px;border-radius:5px;background:#efe6da;color:#241a10;font-weight:700;font-size:12px;flex:none;box-sizing:border-box;';
13
+ // gold rule broken by two downward chevrons near the centre (the kit's divider)
14
+ const DIVIDER_SVG = `<svg width="430" height="20" viewBox="0 0 430 20" fill="none" stroke="#c9a84e" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
15
+ <line x1="6" y1="9" x2="188" y2="9"/><path d="M196 4 L205 13 L214 4"/><path d="M216 4 L225 13 L234 4"/><line x1="242" y1="9" x2="424" y2="9"/>
16
+ <circle cx="6" cy="9" r="1.5" fill="#c9a84e"/><circle cx="424" cy="9" r="1.5" fill="#c9a84e"/></svg>`;
17
+
18
+ function el(tag, css, text) {
19
+ const e = document.createElement(tag);
20
+ if (css) e.style.cssText = css;
21
+ if (text != null) e.textContent = text;
22
+ return e;
23
+ }
24
+
25
+ // button-prompt badge for face button `btn`: generated PS/Xbox icon when a pad is up, else a key pill.
26
+ function paintBadge(holder, btn, mode) {
27
+ holder.textContent = '';
28
+ if (mode === 'ps' || mode === 'xbox') holder.appendChild(buttonCanvas(btn, mode, 22));
29
+ else holder.appendChild(el('span', keyCss, KEYNAME[btn]));
30
+ }
31
+
32
+ export function showDeathScreen(opts = {}) {
33
+ const root = el('div', 'position:fixed;inset:0;z-index:200;overflow:hidden;'
34
+ + `font-family:${FONT};`
35
+ // SOLID dark letterbox bars top & bottom (hide the HUD), scene showing through washed-red in the middle
36
+ + 'background:linear-gradient(180deg, #0a0302 0%, #0a0302 13%, rgba(96,13,10,0.5) 30%, rgba(96,13,10,0.5) 70%, #0a0302 87%, #0a0302 100%);');
37
+ document.body.appendChild(root);
38
+
39
+ // full-width banner band across the middle, soft red with edges fading out
40
+ const band = el('div', 'position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);height:44vh;display:flex;flex-direction:column;align-items:center;justify-content:center;'
41
+ + 'background:linear-gradient(180deg, rgba(122,18,14,0) 0%, rgba(122,18,14,0.4) 18%, rgba(122,18,14,0.5) 50%, rgba(122,18,14,0.4) 82%, rgba(122,18,14,0) 100%);'
42
+ + 'border-top:1px solid rgba(210,80,60,0.14);border-bottom:1px solid rgba(210,80,60,0.14);');
43
+ root.appendChild(band);
44
+
45
+ // the emblem — CENTRED, FAINT, BEHIND the text; reddened so it reads as part of the band. Its black
46
+ // is keyed out at runtime (the art is on black) so it doesn't sit in a box.
47
+ const emblem = el('canvas', 'position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(820px,90vw);z-index:1;pointer-events:none;'
48
+ + 'opacity:0.5;filter:brightness(0.72) sepia(0.7) saturate(3) hue-rotate(-24deg);');
49
+ { const img = new Image();
50
+ img.onload = () => {
51
+ emblem.width = img.naturalWidth; emblem.height = img.naturalHeight;
52
+ const ctx = emblem.getContext('2d'); ctx.drawImage(img, 0, 0);
53
+ try {
54
+ const d = ctx.getImageData(0, 0, emblem.width, emblem.height), px = d.data;
55
+ for (let i = 0; i < px.length; i += 4) { const m = Math.max(px[i], px[i + 1], px[i + 2]); if (m < 30) px[i + 3] = 0; else if (m < 82) px[i + 3] = Math.round((m - 30) / 52 * 255); }
56
+ ctx.putImageData(d, 0, 0);
57
+ } catch (e) { /* tainted — leave as drawn */ }
58
+ };
59
+ img.onerror = () => { emblem.style.display = 'none'; };
60
+ img.src = `${UI}/banner.png`;
61
+ }
62
+ band.appendChild(emblem);
63
+
64
+ // content over the emblem
65
+ const content = el('div', 'position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;');
66
+ band.appendChild(content);
67
+
68
+ content.appendChild(el('div',
69
+ 'color:#ddd7cc;font-size:min(8vw,76px);letter-spacing:min(1.4vw,10px);font-weight:700;line-height:1;'
70
+ + 'text-shadow:0 1px 0 rgba(255,255,255,0.28),0 2px 0 #5a1712,0 3px 3px rgba(0,0,0,0.6),0 0 30px rgba(0,0,0,0.45);', 'YOU DIED'));
71
+ const dv = el('div', 'margin:14px 0 15px;line-height:0;'); dv.innerHTML = DIVIDER_SVG; content.appendChild(dv);
72
+
73
+ // options — small, tight, centred: [icon] label
74
+ const menu = el('div', 'display:flex;flex-direction:column;align-items:center;gap:4px;');
75
+ content.appendChild(menu);
76
+
77
+ const rows = [];
78
+ const addRow = (label, btn, fn) => {
79
+ const row = el('button', 'display:flex;align-items:center;gap:11px;padding:5px 16px;border:none;background:none;border-radius:6px;cursor:pointer;'
80
+ + 'color:#e6dcc4;font-family:inherit;font-size:15px;letter-spacing:2px;font-weight:600;transition:color .12s,background .12s;');
81
+ const badge = el('span', 'display:inline-flex;align-items:center;'); row.appendChild(badge);
82
+ row.appendChild(el('span', '', label));
83
+ const on = () => { row.style.color = '#fff8ec'; row.style.background = 'rgba(150,26,20,0.35)'; };
84
+ const off = () => { row.style.color = '#e6dcc4'; row.style.background = 'none'; };
85
+ off();
86
+ row.onclick = () => act(fn);
87
+ row.addEventListener('mouseenter', on); row.addEventListener('mouseleave', off);
88
+ menu.appendChild(row);
89
+ rows.push({ btn, fn, el: row, badge });
90
+ };
91
+ if (opts.hasSave) addRow('LOAD LAST SAVE', 'cross', opts.onLoad);
92
+ addRow('WAKE AT THE WELL', opts.hasSave ? 'square' : 'cross', opts.onRevive);
93
+ addRow('QUIT TO TITLE', 'circle', opts.onQuit);
94
+
95
+ let raf = 0, closed = false, first = true, prev = {}, lastMode = false;
96
+ const close = () => { if (closed) return; closed = true; cancelAnimationFrame(raf); window.removeEventListener('keydown', onKey); root.remove(); };
97
+ const act = (fn) => { close(); fn?.(); };
98
+ const fire = (btn) => { const r = rows.find((x) => x.btn === btn); if (r) act(r.fn); };
99
+ const repaintBadges = () => { const m = padMode(); rows.forEach((r) => paintBadge(r.badge, r.btn, m)); return m; };
100
+ lastMode = repaintBadges();
101
+
102
+ const onKey = (e) => {
103
+ switch (e.code) {
104
+ case 'Enter': case 'Space': case 'NumpadEnter': fire(rows.some((r) => r.btn === 'cross') ? 'cross' : rows[0].btn); e.preventDefault(); break;
105
+ case 'KeyR': fire('square'); break;
106
+ case 'Escape': case 'Backspace': fire('circle'); break;
107
+ default: return;
108
+ }
109
+ };
110
+ window.addEventListener('keydown', onKey);
111
+
112
+ const poll = () => {
113
+ if (closed) return;
114
+ const m = padMode();
115
+ if (m !== lastMode) lastMode = repaintBadges();
116
+ const pads = navigator.getGamepads ? navigator.getGamepads() : null;
117
+ let gp = null; if (pads) for (const p of pads) if (p && p.connected) { gp = p; break; }
118
+ if (gp) {
119
+ const b = (i) => !!gp.buttons[i]?.pressed;
120
+ const st = { cross: b(0), circle: b(1), square: b(2) };
121
+ const edge = (k) => st[k] && !prev[k];
122
+ if (first) first = false; // adopt held state — the Cross that was firing when you died mustn't confirm
123
+ else { if (edge('cross')) fire('cross'); if (edge('square')) fire('square'); if (edge('circle')) fire('circle'); }
124
+ prev = st;
125
+ }
126
+ raf = requestAnimationFrame(poll);
127
+ };
128
+ raf = requestAnimationFrame(poll);
129
+ return { close };
130
+ }