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.
- package/package.json +1 -1
- package/src/audio/ambience.js +65 -0
- package/src/audio/music.js +234 -0
- package/src/audio/synth.js +228 -0
- package/src/systems/casino/blackjack.js +187 -0
- package/src/systems/casino/cards3d.js +55 -0
- package/src/systems/casino/coins3d.js +75 -0
- package/src/systems/casino/table3d.js +130 -0
- package/src/systems/coach.js +1034 -0
- package/src/systems/combat.js +586 -0
- package/src/systems/crime.js +582 -0
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +442 -0
- package/src/systems/loot.js +303 -0
- package/src/systems/missions.js +418 -0
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +1619 -0
- package/src/systems/roadGraph.js +264 -0
- package/src/systems/satchel.js +500 -0
- package/src/systems/scenes.js +105 -0
- package/src/systems/shop.js +515 -0
- package/src/systems/train.js +1957 -0
- package/src/ui/cartograph.js +456 -0
- package/src/ui/deathScreen.js +130 -0
- package/src/ui/dialogue.js +434 -0
- package/src/ui/iconBaker.js +180 -0
- package/src/ui/intro.js +137 -0
- package/src/ui/menuHints.js +79 -0
- package/src/ui/pauseMenu.js +194 -0
- package/src/ui/saveLoadScreen.js +156 -0
- package/src/ui/titleScreen.js +359 -0
- package/src/ui/worldmap.js +837 -0
|
@@ -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
|
+
}
|