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,837 @@
|
|
|
1
|
+
// THE MAP (M) — a rider's chart of Dustwater County, on the table.
|
|
2
|
+
//
|
|
3
|
+
// THE STRUCTURE IS THE POINT. Three layers, and they are separate on purpose:
|
|
4
|
+
// #wm-pan the CHART (cartograph.js), a bitmap of the ground. It is the ONLY thing the
|
|
5
|
+
// pan/zoom transform touches, and it is drawn exactly once.
|
|
6
|
+
// #wm-overlay the MARKERS, a canvas that sits OVER the transform, in SCREEN space. Icons,
|
|
7
|
+
// labels, the route, the player and the waypoint are all drawn here at fixed pixel
|
|
8
|
+
// sizes — so they stay the same readable size while the county grows underneath
|
|
9
|
+
// them. This is what the old map got wrong: it scaled everything together, so at
|
|
10
|
+
// county zoom the labels were unreadable and at street zoom they were billboards.
|
|
11
|
+
// the DOM only the things that must accept a pointer: the legend, the hover card, the
|
|
12
|
+
// zoom buttons.
|
|
13
|
+
//
|
|
14
|
+
// Ported from the fable build's worldmap, which had this shape right; the content is ours.
|
|
15
|
+
//
|
|
16
|
+
// PAUSE. main.js reads ui.mapOpen and stops the world — you are standing in the street with a
|
|
17
|
+
// paper map open, and the coaches, the law and the outlaws can all wait. It also means the map is
|
|
18
|
+
// not a thing you have to read while being shot at.
|
|
19
|
+
import { WORLD_SIZE, allPOIs, allBuildings } from './cartograph.js';
|
|
20
|
+
import { getQualityName, setQualityName, PRESETS } from '../core/quality.js';
|
|
21
|
+
|
|
22
|
+
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
|
23
|
+
// THE CHART BITMAP STAYS AT 1600, AND THAT IS A DELIBERATE REFUSAL.
|
|
24
|
+
// renderChart is O(px²) and INDEPENDENT of WORLD_SIZE (cartograph.js samples a fixed grid), so raising
|
|
25
|
+
// it buys resolution for pure cost — and the cost lands in the worst possible place: ui.js builds the
|
|
26
|
+
// chart LAZILY, on the first map open, as ~1.6 s of blocking heightAt on the main thread. Going to
|
|
27
|
+
// 2048 would make an existing stall 60% worse to fix a legibility problem that ZOOM_MAX fixes for free.
|
|
28
|
+
const CHART_PX = 1600; // 2.70 m/px across a 4320 m county (was 1.35 across 2160)
|
|
29
|
+
// ...so the ZOOM goes up instead. At ×2 the county is twice as wide on the paper, so 8× would show
|
|
30
|
+
// 540 m across the frame and you could no longer pick out a building. 16× puts it back to ~270 m —
|
|
31
|
+
// the same view of the same ground, and it costs nothing but a bigger number.
|
|
32
|
+
const ZOOM_MAX = 16; // 16× ⇒ ~270 m across the frame: close enough to pick a building
|
|
33
|
+
|
|
34
|
+
// ————— the map's own hand: Synty's white map silhouettes, inked onto the paper —————
|
|
35
|
+
// Provenance: /assets/ui/map/*.png are extracted from Synty's INTERFACE_Apocalypse_HUD pack
|
|
36
|
+
// (dusty and plain — the Military set's chrome and the Fantasy set's gold filigree both read
|
|
37
|
+
// wrong on a western), except horse.png, which is the Fantasy pack's — no other pack ships a
|
|
38
|
+
// horse, and a horse is what a stage stop is.
|
|
39
|
+
const ICON = (n) => `/assets/ui/map/${n}.png`;
|
|
40
|
+
const _imgs = new Map();
|
|
41
|
+
function iconImg(name, onReady) {
|
|
42
|
+
let img = _imgs.get(name);
|
|
43
|
+
if (!img) { img = new Image(); img.src = ICON(name); _imgs.set(name, img); img.onload = () => onReady?.(); }
|
|
44
|
+
return img.complete && img.naturalWidth ? img : null;
|
|
45
|
+
}
|
|
46
|
+
// The sprites are WHITE silhouettes with alpha, which is invisible on parchment. Ink them: draw
|
|
47
|
+
// the shape, then flood it through `source-in`. Cached per name+colour+resolution — a hover
|
|
48
|
+
// repaint must not re-rasterise ten icons.
|
|
49
|
+
const _inked = new Map();
|
|
50
|
+
function inkIcon(name, col, px, onReady) {
|
|
51
|
+
const img = iconImg(name, onReady);
|
|
52
|
+
if (!img) return null;
|
|
53
|
+
const R = Math.ceil(px * Math.min(devicePixelRatio || 1, 2));
|
|
54
|
+
const key = `${name}|${col}|${R}`;
|
|
55
|
+
let cv = _inked.get(key);
|
|
56
|
+
if (!cv) {
|
|
57
|
+
cv = document.createElement('canvas');
|
|
58
|
+
cv.width = cv.height = R;
|
|
59
|
+
const c = cv.getContext('2d');
|
|
60
|
+
c.drawImage(img, 0, 0, R, R);
|
|
61
|
+
c.globalCompositeOperation = 'source-in';
|
|
62
|
+
c.fillStyle = col;
|
|
63
|
+
c.fillRect(0, 0, R, R);
|
|
64
|
+
_inked.set(key, cv);
|
|
65
|
+
}
|
|
66
|
+
return cv;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Ink by category — the one colour cue on an otherwise monochrome sheet. Outposts are the places
|
|
70
|
+
// with people in them who care who you are, and they are red.
|
|
71
|
+
const INK = { Settlement: '#3a2a16', Outpost: '#7d3123', Stage: '#3c5860', Camp: '#b5642a', Shop: '#4a6b2e' };
|
|
72
|
+
const KIND_LABEL = {
|
|
73
|
+
town: 'Town', fort: 'Army fort', mission: 'Mission', camp: 'Camp', mine: 'Diggings',
|
|
74
|
+
stop: 'Stage stop', ranch: 'Ranch', farm: 'Farm', outpost: 'Outpost', shop: 'Shop',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export class WorldMap {
|
|
78
|
+
// `getChart` hands back the prebuilt chart bitmap (ui.prebuildChart) — the map never renders
|
|
79
|
+
// the ground itself, because that costs the best part of a second and belongs on the loading
|
|
80
|
+
// screen, not on the first press of M.
|
|
81
|
+
constructor(game, getChart) {
|
|
82
|
+
this.game = game;
|
|
83
|
+
this.getChart = getChart;
|
|
84
|
+
this.visible = false;
|
|
85
|
+
this.built = false;
|
|
86
|
+
this.zoom = 1; this.panX = 0; this.panY = 0;
|
|
87
|
+
this.filters = new Set(); // empty = show everything
|
|
88
|
+
this._hit = [];
|
|
89
|
+
this._buildDOM();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_buildDOM() {
|
|
93
|
+
const root = document.createElement('div');
|
|
94
|
+
root.id = 'worldmap';
|
|
95
|
+
root.style.cssText = `position:fixed; inset:0; z-index:60; display:none; align-items:center; justify-content:center;
|
|
96
|
+
flex-direction:column; gap:10px; background:radial-gradient(circle at 50% 42%, rgba(24,17,10,0.86), rgba(8,6,4,0.96));
|
|
97
|
+
font-family:Copperplate,'Copperplate Gothic Bold',Palatino,'Book Antiqua',Georgia,serif; pointer-events:auto; user-select:none;`;
|
|
98
|
+
root.innerHTML = `
|
|
99
|
+
<div style="color:#e8c98a; font-size:21px; letter-spacing:7px; text-shadow:0 2px 6px #000; font-weight:700;">DUSTWATER COUNTY</div>
|
|
100
|
+
<div id="wm-tabs" style="display:flex; gap:0; border-bottom:1px solid rgba(122,99,54,0.4);">
|
|
101
|
+
<button data-tab="map" style="${TAB}">MAP</button>
|
|
102
|
+
<button data-tab="journal" style="${TAB}">JOURNAL</button>
|
|
103
|
+
<button data-tab="system" style="${TAB}">SYSTEM</button>
|
|
104
|
+
</div>
|
|
105
|
+
<div id="wm-mapview" style="display:flex; flex-direction:column; align-items:center; gap:10px;">
|
|
106
|
+
<div id="wm-legend" style="display:flex; gap:6px;">
|
|
107
|
+
<button data-cat="" style="${LBTN}">All</button>
|
|
108
|
+
<button data-cat="Settlement" style="${LBTN}">Settlements</button>
|
|
109
|
+
<button data-cat="Outpost" style="${LBTN}">Outposts</button>
|
|
110
|
+
<button data-cat="Stage" style="${LBTN}">Stage line</button>
|
|
111
|
+
<button data-cat="Camp" style="${LBTN}">Campfires</button>
|
|
112
|
+
<button data-cat="Shop" style="${LBTN}">Shops</button>
|
|
113
|
+
</div>
|
|
114
|
+
<div id="wm-frame" style="position:relative; width:min(74vh,74vw); height:min(74vh,74vw); overflow:hidden;
|
|
115
|
+
border:3px solid #6d5730; border-radius:4px; box-shadow:0 0 44px rgba(0,0,0,0.85), inset 0 0 70px rgba(0,0,0,0.45);
|
|
116
|
+
background:#1a1611; cursor:grab;">
|
|
117
|
+
<div id="wm-pan" style="position:absolute; inset:0; transform-origin:0 0;">
|
|
118
|
+
<canvas id="wm-canvas" style="position:absolute; inset:0; width:100%; height:100%;"></canvas>
|
|
119
|
+
</div>
|
|
120
|
+
<canvas id="wm-overlay" style="position:absolute; inset:0; width:100%; height:100%; pointer-events:none;"></canvas>
|
|
121
|
+
<div id="wm-target" style="position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:30px; height:30px;
|
|
122
|
+
pointer-events:none; display:none; z-index:6; filter:drop-shadow(0 1px 2px rgba(0,0,0,0.9));">
|
|
123
|
+
<div style="position:absolute; left:50%; top:0; width:2px; height:100%; margin-left:-1px; background:#e8c98a;"></div>
|
|
124
|
+
<div style="position:absolute; top:50%; left:0; height:2px; width:100%; margin-top:-1px; background:#e8c98a;"></div>
|
|
125
|
+
<div style="position:absolute; inset:7px; border:2px solid #e8c98a; border-radius:50%;"></div>
|
|
126
|
+
</div>
|
|
127
|
+
<div style="position:absolute; top:7px; right:11px; color:#efe0bb; font:600 13px/1 Georgia,serif; opacity:0.75; pointer-events:none;
|
|
128
|
+
text-shadow:0 1px 3px #000;">N ↑</div>
|
|
129
|
+
<div id="wm-ctrls" style="position:absolute; bottom:9px; right:9px; display:flex; gap:6px;">
|
|
130
|
+
<button data-z="0.72" style="${BTN}">−</button>
|
|
131
|
+
<button data-z="1.4" style="${BTN}">+</button>
|
|
132
|
+
<button data-reset style="${BTN} width:auto; padding:0 10px; font-size:12px;">Reset</button>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div style="color:#c0ad82; font-size:12.5px; letter-spacing:0.5px; opacity:0.85;">
|
|
136
|
+
wheel zooms · drag pans · click sets your waypoint · right-click clears it · [M] closes</div>
|
|
137
|
+
</div>
|
|
138
|
+
<div id="wm-journal" style="display:none; width:min(74vh,74vw); height:min(70vh,70vw); overflow-y:auto;
|
|
139
|
+
border:3px solid #6d5730; border-radius:4px; background:rgba(20,15,9,0.55); box-shadow:0 0 44px rgba(0,0,0,0.85);
|
|
140
|
+
padding:24px 30px; color:#e6d6ad; text-align:left;"></div>
|
|
141
|
+
<div id="wm-system" style="display:none; width:min(74vh,74vw); height:min(70vh,70vw);
|
|
142
|
+
border:3px solid #6d5730; border-radius:4px; background:rgba(20,15,9,0.55); box-shadow:0 0 44px rgba(0,0,0,0.85);
|
|
143
|
+
padding:30px 34px; color:#e6d6ad; text-align:left; display:none; flex-direction:column; gap:22px;"></div>`;
|
|
144
|
+
document.body.appendChild(root);
|
|
145
|
+
|
|
146
|
+
this.root = root;
|
|
147
|
+
this.frame = root.querySelector('#wm-frame');
|
|
148
|
+
this.pan = root.querySelector('#wm-pan');
|
|
149
|
+
this.canvas = root.querySelector('#wm-canvas');
|
|
150
|
+
this.canvas.width = this.canvas.height = CHART_PX;
|
|
151
|
+
this.overlay = root.querySelector('#wm-overlay');
|
|
152
|
+
|
|
153
|
+
// the hover card — name, what the place is, and how far you have to ride
|
|
154
|
+
this.card = document.createElement('div');
|
|
155
|
+
this.card.style.cssText = `position:absolute; display:none; pointer-events:none; z-index:5; padding:5px 9px;
|
|
156
|
+
background:rgba(28,21,12,0.94); border:1px solid #7a6336; border-radius:4px; color:#ecdfbe;
|
|
157
|
+
font:12px Georgia,serif; white-space:nowrap; box-shadow:0 2px 8px rgba(0,0,0,0.7);`;
|
|
158
|
+
this.frame.appendChild(this.card);
|
|
159
|
+
|
|
160
|
+
this.legend = root.querySelector('#wm-legend');
|
|
161
|
+
this.legend.querySelectorAll('button').forEach((b) => b.addEventListener('click', (e) => {
|
|
162
|
+
e.stopPropagation();
|
|
163
|
+
const cat = b.dataset.cat;
|
|
164
|
+
if (!cat) this.filters.clear();
|
|
165
|
+
else if (this.filters.has(cat)) this.filters.delete(cat);
|
|
166
|
+
else this.filters.add(cat);
|
|
167
|
+
this._syncLegend();
|
|
168
|
+
this._draw();
|
|
169
|
+
}));
|
|
170
|
+
this._syncLegend();
|
|
171
|
+
|
|
172
|
+
// HUB TABS — MAP · JOURNAL (SYSTEM lands in Phase 3). The map is the pause screen already.
|
|
173
|
+
this.mapview = root.querySelector('#wm-mapview');
|
|
174
|
+
this.journal = root.querySelector('#wm-journal');
|
|
175
|
+
this.system = root.querySelector('#wm-system');
|
|
176
|
+
this.target = root.querySelector('#wm-target'); // the pad's centre reticle (where Square drops the pin)
|
|
177
|
+
this.tab = 'map';
|
|
178
|
+
root.querySelectorAll('#wm-tabs button').forEach((b) =>
|
|
179
|
+
b.addEventListener('click', (e) => { e.stopPropagation(); this._showTab(b.dataset.tab); }));
|
|
180
|
+
|
|
181
|
+
this._wireEvents();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_showTab(tab) {
|
|
185
|
+
this.tab = tab;
|
|
186
|
+
this.mapview.style.display = tab === 'map' ? 'flex' : 'none';
|
|
187
|
+
this.journal.style.display = tab === 'journal' ? 'block' : 'none';
|
|
188
|
+
this.system.style.display = tab === 'system' ? 'flex' : 'none';
|
|
189
|
+
if (this.target && tab !== 'map') this.target.style.display = 'none'; // reticle is chart-only (pad shows it)
|
|
190
|
+
this.root.querySelectorAll('#wm-tabs button').forEach((b) => {
|
|
191
|
+
const on = b.dataset.tab === tab;
|
|
192
|
+
b.style.color = on ? '#e8c98a' : '#9a855a';
|
|
193
|
+
b.style.borderBottomColor = on ? '#e8c98a' : 'transparent';
|
|
194
|
+
});
|
|
195
|
+
if (tab === 'journal') this._renderJournal();
|
|
196
|
+
if (tab === 'system') this._renderSystem();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// SYSTEM tab — the map is the game's hub. A vertical list of ITEMS: up/down moves between them,
|
|
200
|
+
// Cross fires Save/Load/Quit, and LEFT/RIGHT changes the value on a setting row (Graphics).
|
|
201
|
+
_renderSystem() {
|
|
202
|
+
const m = this.game.missions, el = this.system;
|
|
203
|
+
const ROW = `display:flex; align-items:center; justify-content:space-between; padding:14px 20px; border:1px solid #6d5730;
|
|
204
|
+
border-radius:4px; background:linear-gradient(180deg,#3f2d14,#281b0c); color:#e8c98a; font-family:inherit; font-size:16px;
|
|
205
|
+
letter-spacing:2px; cursor:pointer; pointer-events:auto; transition:filter .12s;`;
|
|
206
|
+
el.innerHTML = `
|
|
207
|
+
<div style="color:#e8c98a; font-size:13px; letter-spacing:3px; font-weight:700; opacity:0.85;">SYSTEM</div>
|
|
208
|
+
<div id="wm-r-save" style="${ROW}">Save Game</div>
|
|
209
|
+
<div id="wm-r-load" style="${ROW}${m?.hasSave?.() ? '' : ' opacity:0.4;'}">Load Game</div>
|
|
210
|
+
<div id="wm-r-gfx" style="${ROW}"><span>Graphics</span><span>‹ <span id="wm-gfxval" style="color:#f4e6bf; display:inline-block; min-width:74px; text-align:center;"></span> ›</span></div>
|
|
211
|
+
<div style="flex:1;"></div>
|
|
212
|
+
<div id="wm-r-quit" style="${ROW} border-color:#7a3f2c; color:#dfb090;">Quit to Title</div>`;
|
|
213
|
+
const gfxVal = el.querySelector('#wm-gfxval');
|
|
214
|
+
const setGfx = () => { gfxVal.textContent = PRESETS[getQualityName()].label; };
|
|
215
|
+
const cycleGfx = (dir) => { const o = ['low', 'medium', 'high']; setQualityName(o[(o.indexOf(getQualityName()) + dir + 3) % 3]); setGfx(); };
|
|
216
|
+
setGfx();
|
|
217
|
+
this._sysRows = [
|
|
218
|
+
{ el: el.querySelector('#wm-r-save'), act: () => m?.saveGame?.() },
|
|
219
|
+
{ el: el.querySelector('#wm-r-load'), act: () => { if (m?.hasSave?.() && m.loadGame()) this.toggle(); } },
|
|
220
|
+
{ el: el.querySelector('#wm-r-gfx'), cycle: cycleGfx },
|
|
221
|
+
{ el: el.querySelector('#wm-r-quit'), act: () => location.reload() },
|
|
222
|
+
];
|
|
223
|
+
// mouse still works: click a row to fire it (the Graphics row cycles forward)
|
|
224
|
+
for (const r of this._sysRows) r.el.onclick = (e) => { e.stopPropagation(); (r.act || (() => r.cycle(1)))(); };
|
|
225
|
+
this._padSel = 0; this._paintSysSel();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── GAMEPAD in the hub. The game's own pad mapping is suppressed while a panel is up (input.js),
|
|
229
|
+
// so the map drives itself: L1/R1 swap tabs, D-pad/stick navigates the current tab, Cross confirms,
|
|
230
|
+
// Circle (or the map button) closes.
|
|
231
|
+
_cycleTab(dir) {
|
|
232
|
+
const tabs = ['map', 'journal', 'system'];
|
|
233
|
+
this._showTab(tabs[(tabs.indexOf(this.tab) + dir + tabs.length) % tabs.length]);
|
|
234
|
+
}
|
|
235
|
+
_paintSysSel() {
|
|
236
|
+
(this._sysRows || []).forEach((r, i) => { r.el.style.outline = i === this._padSel ? '2px solid #e8c98a' : 'none'; r.el.style.outlineOffset = '2px'; });
|
|
237
|
+
}
|
|
238
|
+
_startPadNav() {
|
|
239
|
+
cancelAnimationFrame(this._padRAF);
|
|
240
|
+
const prev = {}; let first = true; // first frame ADOPTS held buttons — the press that opened the map must not act
|
|
241
|
+
const poll = () => {
|
|
242
|
+
if (!this.visible) return;
|
|
243
|
+
const pads = navigator.getGamepads ? navigator.getGamepads() : null;
|
|
244
|
+
let gp = null; if (pads) for (const p of pads) if (p && p.connected) { gp = p; break; }
|
|
245
|
+
if (gp) {
|
|
246
|
+
const b = (i) => !!(gp.buttons[i]?.pressed);
|
|
247
|
+
const lx = gp.axes[0] || 0, ly = gp.axes[1] || 0;
|
|
248
|
+
const st = { l1: b(4), r1: b(5), up: b(12) || ly < -0.55, down: b(13) || ly > 0.55, left: b(14) || lx < -0.55, right: b(15) || lx > 0.55, a: b(0), sq: b(2), tri: b(3), back: b(1), pad: b(16) || b(17) };
|
|
249
|
+
const edge = (n) => st[n] && !prev[n];
|
|
250
|
+
if (this.target) this.target.style.display = this.tab === 'map' ? 'block' : 'none'; // reticle only on the chart, only with a pad
|
|
251
|
+
if (!first) {
|
|
252
|
+
if (edge('l1')) this._cycleTab(-1);
|
|
253
|
+
if (edge('r1')) this._cycleTab(1);
|
|
254
|
+
if (this.tab === 'map') { // left stick pans the chart under the reticle
|
|
255
|
+
const s = 15; let moved = false;
|
|
256
|
+
if (b(12) || ly < -0.4) { this.panY += s; moved = true; }
|
|
257
|
+
if (b(13) || ly > 0.4) { this.panY -= s; moved = true; }
|
|
258
|
+
if (b(14) || lx < -0.4) { this.panX += s; moved = true; }
|
|
259
|
+
if (b(15) || lx > 0.4) { this.panX -= s; moved = true; }
|
|
260
|
+
if (moved) { this._clampPan(); this._apply(); }
|
|
261
|
+
const c = this._disp() / 2; // the reticle sits at the view centre
|
|
262
|
+
if (edge('a')) this._zoomAt(c, c, 1.4); // Cross zooms IN about the reticle
|
|
263
|
+
if (edge('tri')) this._zoomAt(c, c, 1 / 1.4); // Triangle zooms out
|
|
264
|
+
if (edge('sq')) this._clickAt(c, c); // Square drops the waypoint at the reticle
|
|
265
|
+
} else if (this.tab === 'system') { // up/down moves rows · left/right changes a value · Cross fires
|
|
266
|
+
const rows = this._sysRows || [];
|
|
267
|
+
if (rows.length) {
|
|
268
|
+
if (edge('up')) { this._padSel = (this._padSel + rows.length - 1) % rows.length; this._paintSysSel(); }
|
|
269
|
+
if (edge('down')) { this._padSel = (this._padSel + 1) % rows.length; this._paintSysSel(); }
|
|
270
|
+
const r = rows[this._padSel];
|
|
271
|
+
if (edge('left') && r?.cycle) r.cycle(-1);
|
|
272
|
+
if (edge('right') && r?.cycle) r.cycle(1);
|
|
273
|
+
if (edge('a') && r?.act) r.act();
|
|
274
|
+
}
|
|
275
|
+
} else if (this.tab === 'journal') { // scroll (held)
|
|
276
|
+
if (b(12) || ly < -0.4) this.journal.scrollTop -= 16;
|
|
277
|
+
if (b(13) || ly > 0.4) this.journal.scrollTop += 16;
|
|
278
|
+
}
|
|
279
|
+
if (edge('back') || edge('pad')) { this.toggle(); return; }
|
|
280
|
+
}
|
|
281
|
+
Object.assign(prev, st); first = false;
|
|
282
|
+
}
|
|
283
|
+
this._padRAF = requestAnimationFrame(poll);
|
|
284
|
+
};
|
|
285
|
+
this._padRAF = requestAnimationFrame(poll);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// The journal reads straight from the mission engine (definitions + progress). Active job with its
|
|
289
|
+
// current objective highlighted (and n/N on kill/collect), then the finished ones.
|
|
290
|
+
_renderJournal() {
|
|
291
|
+
const m = this.game.missions;
|
|
292
|
+
const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
293
|
+
if (!m) { this.journal.innerHTML = `<div style="opacity:0.7;">No journal yet.</div>`; return; }
|
|
294
|
+
const name = (id) => esc(m.npcs?.[id]?.name || id);
|
|
295
|
+
const prog = m.progress;
|
|
296
|
+
const active = m.defs.find((d) => d.id === prog.active);
|
|
297
|
+
const completed = m.defs.filter((d) => prog.missions[d.id]?.done);
|
|
298
|
+
const H = (s) => `<div style="color:#e8c98a; font-size:13px; letter-spacing:3px; font-weight:700; margin:6px 0 12px; opacity:0.85;">${s}</div>`;
|
|
299
|
+
let html = '';
|
|
300
|
+
if (active) {
|
|
301
|
+
const rec = prog.missions[active.id] || { stage: 0, counters: {} };
|
|
302
|
+
html += H('CURRENT JOB');
|
|
303
|
+
html += `<div style="margin-bottom:20px;">
|
|
304
|
+
<div style="color:#f4e6bf; font-size:23px; letter-spacing:2px; margin-bottom:4px;">${esc(active.title)}</div>
|
|
305
|
+
<div style="color:#b9a678; font-size:13px; margin-bottom:11px;">Given by ${name(active.giver)}</div>
|
|
306
|
+
<div style="color:#d8c79c; font-style:italic; font-size:14px; line-height:1.55; margin-bottom:16px;">${esc(active.summary)}</div>
|
|
307
|
+
<div style="display:flex; flex-direction:column; gap:9px;">`;
|
|
308
|
+
// The checklist stops at the CURRENT beat — a journal that prints the mission's whole future
|
|
309
|
+
// is a spoiler, and with branching it printed the OTHER fork's stages too. Behind you: only
|
|
310
|
+
// the stages your path actually walked (skipIf/onlyIf-mismatched ones never happened —
|
|
311
|
+
// m._condOk); ahead of you: a single quiet ellipsis.
|
|
312
|
+
active.stages.forEach((s, i) => {
|
|
313
|
+
const done = i < rec.stage, cur = i === rec.stage;
|
|
314
|
+
if (!done && !cur) return;
|
|
315
|
+
if (done && m._condOk && !m._condOk(s)) return; // the fork not taken
|
|
316
|
+
let label = esc(s.desc || s.id);
|
|
317
|
+
if ((s.type === 'kill' || s.type === 'collect') && s.count) {
|
|
318
|
+
const c = done ? s.count : (rec.counters[s.id] || 0);
|
|
319
|
+
label += ` <span style="opacity:0.7;">(${c}/${s.count})</span>`;
|
|
320
|
+
}
|
|
321
|
+
const mark = done ? '✓' : '▸';
|
|
322
|
+
const col = done ? '#83a06c' : '#f4e6bf';
|
|
323
|
+
html += `<div style="color:${col}; font-size:15.5px; font-weight:${cur ? '700' : '400'}; ${done ? 'opacity:0.7;' : ''}">
|
|
324
|
+
<span style="display:inline-block; width:22px;">${mark}</span>${label}</div>`;
|
|
325
|
+
});
|
|
326
|
+
if (rec.stage < active.stages.length - 1) html += `<div style="color:#8a7c5c; font-size:15px;"><span style="display:inline-block; width:22px;">•</span>…</div>`;
|
|
327
|
+
html += `</div></div>`;
|
|
328
|
+
// THE STANDING — how the county reads you (missions.stat): Bracken's trust in his deputy and
|
|
329
|
+
// the town's regard, once the star's been taken. Signed, so a debt reads as one.
|
|
330
|
+
if (prog.flags?.badge) {
|
|
331
|
+
const sgn = (n) => (n > 0 ? '+' : '') + n;
|
|
332
|
+
const trust = m.stat?.('trust') ?? 0, rep = m.stat?.('reputation') ?? 0;
|
|
333
|
+
html += `<div style="color:#b9a678; font-size:13.5px; margin:-6px 0 18px; letter-spacing:0.5px;">
|
|
334
|
+
Bracken's trust <b style="color:${trust < 0 ? '#c98a6a' : '#d8c79c'}">${sgn(trust)}</b>
|
|
335
|
+
· the town's regard <b style="color:${rep < 0 ? '#c98a6a' : '#d8c79c'}">${sgn(rep)}</b></div>`;
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
html += `<div style="color:#b9a678; font-style:italic; font-size:15px; margin:8px 0 20px;">No job in hand. Ask around town — someone always needs a gun.</div>`;
|
|
339
|
+
}
|
|
340
|
+
if (completed.length) {
|
|
341
|
+
html += H('DONE');
|
|
342
|
+
for (const d of completed) html += `<div style="display:flex; justify-content:space-between; padding:8px 0; border-top:1px solid rgba(122,99,54,0.25); color:#a99a72; font-size:14.5px;">
|
|
343
|
+
<span>${esc(d.title)}</span>${d.reward?.money ? `<span style="color:#83a06c;">$${d.reward.money}</span>` : ''}</div>`;
|
|
344
|
+
}
|
|
345
|
+
this.journal.innerHTML = html;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
_wireEvents() {
|
|
349
|
+
const f = this.frame;
|
|
350
|
+
f.addEventListener('wheel', (e) => {
|
|
351
|
+
e.preventDefault();
|
|
352
|
+
this._zoomOnPlayer(e.deltaY < 0 ? 1.22 : 1 / 1.22); // zoom about YOU, not the cursor (Nick)
|
|
353
|
+
}, { passive: false });
|
|
354
|
+
f.addEventListener('contextmenu', (e) => { e.preventDefault(); this._setWaypoint(null); });
|
|
355
|
+
|
|
356
|
+
let down = null;
|
|
357
|
+
f.addEventListener('mousedown', (e) => {
|
|
358
|
+
if (e.button !== 0) return;
|
|
359
|
+
const [sx, sy] = this._local(e);
|
|
360
|
+
down = { sx, sy, panX: this.panX, panY: this.panY, moved: 0 };
|
|
361
|
+
f.style.cursor = 'grabbing';
|
|
362
|
+
});
|
|
363
|
+
// on WINDOW, not the frame: a drag that leaves the frame must keep panning, and must still
|
|
364
|
+
// end when the button comes up out over the black
|
|
365
|
+
addEventListener('mousemove', (e) => {
|
|
366
|
+
if (!this.visible) return;
|
|
367
|
+
const [sx, sy] = this._local(e);
|
|
368
|
+
if (down) {
|
|
369
|
+
down.moved += Math.abs(sx - down.sx) + Math.abs(sy - down.sy);
|
|
370
|
+
this.panX = down.panX + (sx - down.sx);
|
|
371
|
+
this.panY = down.panY + (sy - down.sy);
|
|
372
|
+
this._clampPan();
|
|
373
|
+
this._hover = null;
|
|
374
|
+
this.card.style.display = 'none';
|
|
375
|
+
// _apply(), NOT _draw(). _draw() only repaints the MARKER OVERLAY; the chart is moved by the
|
|
376
|
+
// CSS transform on #wm-pan, which only _apply() writes. Calling the wrong one meant the
|
|
377
|
+
// labels slid about over a chart that never moved — the county standing still while its
|
|
378
|
+
// towns walked across it.
|
|
379
|
+
this._apply();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const d = this._disp();
|
|
383
|
+
if (sx < 0 || sy < 0 || sx > d || sy > d) { this._setHover(null); return; }
|
|
384
|
+
this._setHover(this._hitAt(sx, sy), sx, sy);
|
|
385
|
+
});
|
|
386
|
+
addEventListener('mouseup', (e) => {
|
|
387
|
+
if (!down) return;
|
|
388
|
+
f.style.cursor = 'grab';
|
|
389
|
+
// a CLICK, not a drag, sets the waypoint. 5px of slop, because a hand on a mouse always
|
|
390
|
+
// moves a little and a map that refuses your click because you twitched is infuriating.
|
|
391
|
+
if (down.moved < 5) {
|
|
392
|
+
const [sx, sy] = this._local(e);
|
|
393
|
+
const d = this._disp();
|
|
394
|
+
if (sx >= 0 && sy >= 0 && sx <= d && sy <= d) this._clickAt(sx, sy);
|
|
395
|
+
}
|
|
396
|
+
down = null;
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
this.root.querySelectorAll('#wm-ctrls button').forEach((b) => {
|
|
400
|
+
b.addEventListener('mousedown', (e) => e.stopPropagation()); // the buttons are not a pan
|
|
401
|
+
b.addEventListener('click', (e) => {
|
|
402
|
+
e.stopPropagation();
|
|
403
|
+
if (b.dataset.reset !== undefined) { this.zoom = 1; this.panX = this.panY = 0; this._apply(); }
|
|
404
|
+
else this._zoomOnPlayer(+b.dataset.z);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ————————————————————— frame geometry —————————————————————
|
|
410
|
+
_disp() { return this.frame.clientWidth; }
|
|
411
|
+
_local(e) { const r = this.frame.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; }
|
|
412
|
+
// the chart may never come away from the frame's edge — a map you can drag off into the corner
|
|
413
|
+
// is a map you then have to hunt for
|
|
414
|
+
_clampPan() {
|
|
415
|
+
const lim = (1 - this.zoom) * this._disp();
|
|
416
|
+
this.panX = clamp(this.panX, lim, 0);
|
|
417
|
+
this.panY = clamp(this.panY, lim, 0);
|
|
418
|
+
}
|
|
419
|
+
_apply() {
|
|
420
|
+
this.pan.style.transform = `translate(${this.panX}px,${this.panY}px) scale(${this.zoom})`;
|
|
421
|
+
this._draw();
|
|
422
|
+
}
|
|
423
|
+
// ZOOM ABOUT THE CURSOR, not the centre: the ground under the pointer stays under the pointer.
|
|
424
|
+
// Centre-anchored zoom (what the old map did) means every zoom is followed by a corrective drag.
|
|
425
|
+
_zoomAt(sx, sy, factor) {
|
|
426
|
+
const z2 = clamp(this.zoom * factor, 1, ZOOM_MAX);
|
|
427
|
+
this.panX = sx - (sx - this.panX) * (z2 / this.zoom);
|
|
428
|
+
this.panY = sy - (sy - this.panY) * (z2 / this.zoom);
|
|
429
|
+
this.zoom = z2;
|
|
430
|
+
this._clampPan();
|
|
431
|
+
this._apply();
|
|
432
|
+
}
|
|
433
|
+
// Zoom keeping YOU under the crosshair — the map is a rider's chart of where he is, so the wheel
|
|
434
|
+
// (and the +/- buttons) close in on the player, not on wherever the cursor happens to sit.
|
|
435
|
+
_zoomOnPlayer(factor) {
|
|
436
|
+
const p = this.game.player?.position;
|
|
437
|
+
const [sx, sy] = p ? this._w2s(p.x, p.z) : [this._disp() / 2, this._disp() / 2];
|
|
438
|
+
this._zoomAt(sx, sy, factor);
|
|
439
|
+
}
|
|
440
|
+
// world metres → screen px inside the frame (pan + zoom applied). The markers are drawn through
|
|
441
|
+
// THIS and nothing else, which is why they never scale.
|
|
442
|
+
_w2s(x, z) {
|
|
443
|
+
const d = this._disp();
|
|
444
|
+
return [(x / WORLD_SIZE + 0.5) * d * this.zoom + this.panX, (z / WORLD_SIZE + 0.5) * d * this.zoom + this.panY];
|
|
445
|
+
}
|
|
446
|
+
_s2w(sx, sy) {
|
|
447
|
+
const d = this._disp();
|
|
448
|
+
return [((sx - this.panX) / this.zoom / d - 0.5) * WORLD_SIZE, ((sy - this.panY) / this.zoom / d - 0.5) * WORLD_SIZE];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ————————————————————— the waypoint —————————————————————
|
|
452
|
+
// THE CONTRACT: game.waypoint = {x,z} | null. The compass and the minimap already read it, and
|
|
453
|
+
// they must keep reading it — the map is where you SAY where you are going; the HUD is what gets
|
|
454
|
+
// you there once the map is shut.
|
|
455
|
+
_setWaypoint(w) {
|
|
456
|
+
this.game.waypoint = w;
|
|
457
|
+
// Re-plan the road route NOW, before we repaint: the world is paused behind the map, so ui.js's
|
|
458
|
+
// movement-gated re-plan would not fire, and the route would lag a click behind the pin.
|
|
459
|
+
this.game.ui?.recomputeRoute?.();
|
|
460
|
+
this._draw();
|
|
461
|
+
}
|
|
462
|
+
// CLICK-SNAP: a click on (or near) a marker means the PLACE, not the pixel you happened to hit.
|
|
463
|
+
// Without it, "waypoint the fort" puts you 30 m into the scrub beside the fort.
|
|
464
|
+
_clickAt(sx, sy) {
|
|
465
|
+
const hit = this._hitAt(sx, sy, 10);
|
|
466
|
+
if (hit) { this._setWaypoint({ x: hit.p.x, z: hit.p.z, name: hit.p.name }); return; }
|
|
467
|
+
const [x, z] = this._s2w(sx, sy);
|
|
468
|
+
this._setWaypoint({ x, z });
|
|
469
|
+
}
|
|
470
|
+
_hitAt(sx, sy, slop = 6) {
|
|
471
|
+
let best = null, bd = Infinity;
|
|
472
|
+
for (const h of this._hit) {
|
|
473
|
+
const d = Math.hypot(sx - h.X, sy - h.Y);
|
|
474
|
+
if (d < h.r + slop && d < bd) { bd = d; best = h; }
|
|
475
|
+
}
|
|
476
|
+
return best;
|
|
477
|
+
}
|
|
478
|
+
_setHover(h, sx, sy) {
|
|
479
|
+
if (h !== this._hover) {
|
|
480
|
+
this._hover = h;
|
|
481
|
+
this.frame.style.cursor = h ? 'pointer' : 'grab';
|
|
482
|
+
this._draw();
|
|
483
|
+
}
|
|
484
|
+
if (!h) { this.card.style.display = 'none'; return; }
|
|
485
|
+
const pl = this.game.player?.position;
|
|
486
|
+
const dist = pl ? `${Math.round(Math.hypot(h.p.x - pl.x, h.p.z - pl.z))} m` : '';
|
|
487
|
+
this.card.innerHTML = `<b style="color:#f0d89e;">${h.p.name}</b><br>` +
|
|
488
|
+
`<span style="opacity:0.8;">${KIND_LABEL[h.p.kind] ?? h.p.kind}${dist ? ' · ' + dist : ''}</span>`;
|
|
489
|
+
this.card.style.display = '';
|
|
490
|
+
const d = this._disp();
|
|
491
|
+
this.card.style.left = `${Math.min(sx + 14, d - 160)}px`;
|
|
492
|
+
this.card.style.top = `${Math.max(6, sy - 42)}px`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
_syncLegend() {
|
|
496
|
+
this.legend.querySelectorAll('button').forEach((b) => {
|
|
497
|
+
const on = b.dataset.cat ? this.filters.has(b.dataset.cat) : !this.filters.size;
|
|
498
|
+
b.style.borderColor = on ? '#c19a4a' : '#5a4a2c';
|
|
499
|
+
b.style.color = on ? '#f0d89e' : '#8d7d5c';
|
|
500
|
+
b.style.background = on ? 'rgba(60,44,20,0.9)' : 'rgba(26,22,17,0.85)';
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ————————————————————— the two layers —————————————————————
|
|
505
|
+
_render() {
|
|
506
|
+
const chart = this.getChart?.();
|
|
507
|
+
const ctx = this.canvas.getContext('2d');
|
|
508
|
+
if (chart) ctx.drawImage(chart, 0, 0, CHART_PX, CHART_PX);
|
|
509
|
+
this.built = true;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Everything below is drawn in SCREEN pixels. Nothing here multiplies a size by this.zoom —
|
|
513
|
+
// that is the invariant that keeps the map readable at 1× and at 8×.
|
|
514
|
+
_draw() {
|
|
515
|
+
const d = this._disp();
|
|
516
|
+
if (!d || !this.visible) return;
|
|
517
|
+
const dpr = Math.min(devicePixelRatio || 1, 2);
|
|
518
|
+
if (this.overlay.width !== Math.round(d * dpr)) this.overlay.width = this.overlay.height = Math.round(d * dpr);
|
|
519
|
+
const ctx = this.overlay.getContext('2d');
|
|
520
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
521
|
+
ctx.clearRect(0, 0, d, d);
|
|
522
|
+
|
|
523
|
+
const redraw = () => this._draw(); // a late icon load repaints the overlay
|
|
524
|
+
this._hit = [];
|
|
525
|
+
|
|
526
|
+
this._drawBuildings(ctx, d);
|
|
527
|
+
this._drawRoute(ctx, d);
|
|
528
|
+
this._drawPOIs(ctx, d, redraw);
|
|
529
|
+
this._drawObjectives(ctx, d, redraw);
|
|
530
|
+
this._drawWaypoint(ctx, d, redraw);
|
|
531
|
+
this._drawHome(ctx, d, redraw);
|
|
532
|
+
this._drawHorse(ctx, d);
|
|
533
|
+
this._drawPlayer(ctx, d);
|
|
534
|
+
this._drawScale(ctx, d);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// THE ROOFS — every building in the county as a crisp ink footprint: the same square the minimap
|
|
538
|
+
// paints, but drawn in SCREEN space so the EDGE stays ~1px at any zoom while the block scales with
|
|
539
|
+
// the ground under it. One source with the minimap (cartograph.allBuildings()), so they can't drift.
|
|
540
|
+
// FOOTPRINT IS A SQUARE — a DATA limit, not a choice: a building is {x,z,ry,collider} (one radius, no
|
|
541
|
+
// width/depth), so the honest footprint is a collider box turned to its heading, with the same R three
|
|
542
|
+
// uses (x' = x·cos + z·sin, z' = −x·sin + z·cos, matching cartograph stamp()). Drawn UNDER route+stamps.
|
|
543
|
+
_drawBuildings(ctx, d) {
|
|
544
|
+
ctx.save();
|
|
545
|
+
ctx.lineJoin = 'miter';
|
|
546
|
+
ctx.lineWidth = 1.1; // fixed screen px — crisp at 1× and at 16×
|
|
547
|
+
ctx.strokeStyle = 'rgba(74,52,30,0.85)'; // the contour/road ink, on parchment
|
|
548
|
+
ctx.fillStyle = 'rgba(238,226,192,0.16)'; // a whisper of parchment body, so a roof reads as a block
|
|
549
|
+
const mPerPx = WORLD_SIZE / (d * this.zoom);
|
|
550
|
+
for (const b of allBuildings()) {
|
|
551
|
+
const [X, Y] = this._w2s(b.x, b.z);
|
|
552
|
+
if (X < -20 || Y < -20 || X > d + 20 || Y > d + 20) continue; // off the frame
|
|
553
|
+
const hw = (b.collider ?? 4) * 0.75; // half-side in metres (minimap uses collider·1.5 full)
|
|
554
|
+
if (hw / mPerPx < 1.1) continue; // sub-pixel at county zoom: a speck — skip, keep the sheet clean
|
|
555
|
+
const cos = Math.cos(b.ry ?? 0), sin = Math.sin(b.ry ?? 0);
|
|
556
|
+
ctx.beginPath();
|
|
557
|
+
for (let k = 0; k < 4; k++) {
|
|
558
|
+
const lx = (k === 0 || k === 3) ? -hw : hw; // corners (−,−)(+,−)(+,+)(−,+)
|
|
559
|
+
const lz = k < 2 ? -hw : hw;
|
|
560
|
+
const [sx, sy] = this._w2s(b.x + lx * cos + lz * sin, b.z - lx * sin + lz * cos);
|
|
561
|
+
k ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
|
|
562
|
+
}
|
|
563
|
+
ctx.closePath();
|
|
564
|
+
ctx.fill();
|
|
565
|
+
ctx.stroke();
|
|
566
|
+
}
|
|
567
|
+
ctx.restore();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// THE LINE TO WHERE YOU SAID YOU WERE GOING — and now it IS a route. game.route (planned by ui.js
|
|
571
|
+
// over the road graph, kept fresh while a waypoint is set) follows the actual roads: SOLID on the
|
|
572
|
+
// network, DASHED for the two short stubs off it (you→road, road→pin). If the router has no plan yet,
|
|
573
|
+
// or the pin is nowhere near a road, it falls back to an honest crow-flies dashed line.
|
|
574
|
+
_drawRoute(ctx, d) {
|
|
575
|
+
const wp = this.game.waypoint;
|
|
576
|
+
const pl = this.game.player?.position;
|
|
577
|
+
if (!wp || !pl) return;
|
|
578
|
+
const rt = this.game.route;
|
|
579
|
+
const segs = rt?.segments?.length
|
|
580
|
+
? rt.segments
|
|
581
|
+
: [{ pts: [{ x: pl.x, z: pl.z }, { x: wp.x, z: wp.z }], style: 'dash' }];
|
|
582
|
+
const line = rt && rt.reachable ? '#c2401f' : '#a86a3c'; // rust when it's a real road route; faded when crow-flies
|
|
583
|
+
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
|
584
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
585
|
+
ctx.strokeStyle = pass ? line : 'rgba(245,232,198,0.85)'; // cream casing, so it reads on ink AND on paper
|
|
586
|
+
ctx.lineWidth = pass ? 2.6 : 5;
|
|
587
|
+
for (const seg of segs) {
|
|
588
|
+
if (!seg.pts || seg.pts.length < 2) continue;
|
|
589
|
+
ctx.setLineDash(seg.style === 'dash' ? [9, 7] : []);
|
|
590
|
+
ctx.beginPath();
|
|
591
|
+
for (let i = 0; i < seg.pts.length; i++) {
|
|
592
|
+
const [sx, sy] = this._w2s(seg.pts[i].x, seg.pts[i].z);
|
|
593
|
+
i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
|
|
594
|
+
}
|
|
595
|
+
ctx.stroke();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
ctx.setLineDash([]);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
_drawPOIs(ctx, d, redraw) {
|
|
602
|
+
const placed = []; // label rects — the small ones yield to the big ones
|
|
603
|
+
const shown = allPOIs().filter((p) => !this.filters.size || this.filters.has(p.category));
|
|
604
|
+
// big places first, so their labels reserve their ground before the stage stops ask for any
|
|
605
|
+
const order = [...shown].sort((a, b) => (b.big ? 1 : 0) - (a.big ? 1 : 0));
|
|
606
|
+
const stamps = [];
|
|
607
|
+
for (const p of order) {
|
|
608
|
+
const [X, Y] = this._w2s(p.x, p.z);
|
|
609
|
+
if (X < -30 || Y < -30 || X > d + 30 || Y > d + 30) continue; // off the frame: don't draw, don't hit-test
|
|
610
|
+
const r = p.big ? 14 : 10;
|
|
611
|
+
this._stamp(ctx, X, Y, r, p, redraw);
|
|
612
|
+
this._hit.push({ X, Y, r: r + 2, p });
|
|
613
|
+
stamps.push([p, X, Y, r]);
|
|
614
|
+
}
|
|
615
|
+
// A STAMP IS AN OBSTACLE TOO, not just another label. The stage depot stands 28 m from
|
|
616
|
+
// Dustwater's marker, and with only labels in the way its name printed straight across the
|
|
617
|
+
// town's icon — the collision has to know about the ink, not only the words.
|
|
618
|
+
for (const [, X, Y, r] of stamps) placed.push({ x: X - r, y: Y - r, w: r * 2, h: r * 2 });
|
|
619
|
+
for (const [p, X, Y, r] of stamps) this._label(ctx, p, X, Y, r, placed);
|
|
620
|
+
// the hovered place gets a ring, so you know which one you are about to waypoint
|
|
621
|
+
if (this._hover) {
|
|
622
|
+
const [X, Y] = this._w2s(this._hover.p.x, this._hover.p.z);
|
|
623
|
+
ctx.beginPath();
|
|
624
|
+
ctx.arc(X, Y, (this._hover.p.big ? 14 : 10) + 5, 0, 7);
|
|
625
|
+
ctx.strokeStyle = '#ffcf5a'; ctx.lineWidth = 2;
|
|
626
|
+
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 4;
|
|
627
|
+
ctx.stroke();
|
|
628
|
+
ctx.shadowBlur = 0;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// A PLACE IS A STAMP: a paper disc, an ink ring, the icon inked inside it. Not a coloured dot —
|
|
633
|
+
// a dot tells you something is there and nothing about what it is, and this county has a fort, a
|
|
634
|
+
// mission, a camp, a quarry and a stage line, which are five different reasons to ride somewhere.
|
|
635
|
+
_stamp(ctx, X, Y, r, p, redraw) {
|
|
636
|
+
const ink = INK[p.category] ?? '#3a2a16';
|
|
637
|
+
ctx.save();
|
|
638
|
+
ctx.shadowColor = 'rgba(30,20,8,0.55)'; ctx.shadowBlur = 5; ctx.shadowOffsetY = 1;
|
|
639
|
+
ctx.beginPath(); ctx.arc(X, Y, r, 0, 7);
|
|
640
|
+
ctx.fillStyle = '#efe2c0'; ctx.fill();
|
|
641
|
+
ctx.restore();
|
|
642
|
+
ctx.beginPath(); ctx.arc(X, Y, r, 0, 7);
|
|
643
|
+
ctx.strokeStyle = ink; ctx.lineWidth = p.big ? 2 : 1.5; ctx.stroke();
|
|
644
|
+
const s = r * (p.big ? 1.42 : 1.34);
|
|
645
|
+
const img = inkIcon(p.icon, ink, s, redraw);
|
|
646
|
+
if (img) ctx.drawImage(img, X - s / 2, Y - s / 2, s, s);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
_label(ctx, p, X, Y, r, placed) {
|
|
650
|
+
// NAME THE PLACES. All of them — a map whose farms and ranches have no names is a map of dots,
|
|
651
|
+
// and "there is something over there" is not information you can ride on.
|
|
652
|
+
//
|
|
653
|
+
// The ONE exception is the stage stops, and only because of where they physically are: the
|
|
654
|
+
// depot stands 28 m from Dustwater's marker, which at 1x is four pixels, so its name lands
|
|
655
|
+
// straight across the town's. They pull apart from what they serve by about 2.2x, and they name
|
|
656
|
+
// themselves from there. (The first cut suppressed every non-"big" place, which meant every
|
|
657
|
+
// farm, ranch, shack and the cemetery went unnamed at county zoom — the zoom you actually plan
|
|
658
|
+
// a ride at. The label-collision pass below already handles crowding; it does not need help.)
|
|
659
|
+
if (p.category === 'Stage' && this.zoom < 2.2) return;
|
|
660
|
+
const fs = p.big ? 13.5 : 11;
|
|
661
|
+
ctx.font = `${p.big ? 700 : 600} ${fs}px Georgia, 'Palatino Linotype', serif`;
|
|
662
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
|
663
|
+
const w = ctx.measureText(p.name).width, h = fs * 1.15;
|
|
664
|
+
const lx = X - w / 2, ly = Y + r + 3;
|
|
665
|
+
// a small label that would land on a big one simply doesn't get drawn — an unreadable pile of
|
|
666
|
+
// overlapping names is worse than a missing one, and you can always zoom in to separate them
|
|
667
|
+
for (const q of placed) {
|
|
668
|
+
if (lx < q.x + q.w && lx + w > q.x && ly < q.y + q.h && ly + h > q.y) { if (!p.big) return; }
|
|
669
|
+
}
|
|
670
|
+
placed.push({ x: lx, y: ly, w, h });
|
|
671
|
+
ctx.lineWidth = 3.2; ctx.strokeStyle = 'rgba(243,232,203,0.92)'; // cream halo = ink on paper
|
|
672
|
+
ctx.strokeText(p.name, X, ly);
|
|
673
|
+
ctx.fillStyle = p.big ? '#2a1c0c' : '#3d2e1a';
|
|
674
|
+
ctx.fillText(p.name, X, ly);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
_drawWaypoint(ctx, d, redraw) {
|
|
678
|
+
const wp = this.game.waypoint;
|
|
679
|
+
if (!wp) return;
|
|
680
|
+
const [X, Y] = this._w2s(wp.x, wp.z);
|
|
681
|
+
const s = 24;
|
|
682
|
+
const img = inkIcon('target', '#c2401f', s, redraw);
|
|
683
|
+
ctx.save();
|
|
684
|
+
ctx.shadowColor = 'rgba(255,240,210,0.9)'; ctx.shadowBlur = 4; // a cream glow: the mark is ON the paper
|
|
685
|
+
if (img) ctx.drawImage(img, X - s / 2, Y - s / 2, s, s);
|
|
686
|
+
else { ctx.beginPath(); ctx.arc(X, Y, 7, 0, 7); ctx.fillStyle = '#c2401f'; ctx.fill(); }
|
|
687
|
+
ctx.restore();
|
|
688
|
+
const pl = this.game.player?.position;
|
|
689
|
+
if (!pl) return;
|
|
690
|
+
// ABOVE the pin, never below: a place's own name is written under its stamp, and a waypoint is
|
|
691
|
+
// usually ON a place — put the distance underneath and the two lines print on top of each other.
|
|
692
|
+
const m = Math.round(Math.hypot(wp.x - pl.x, wp.z - pl.z));
|
|
693
|
+
ctx.font = '700 12px Georgia, serif';
|
|
694
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
|
|
695
|
+
const txt = `${m} m`;
|
|
696
|
+
const ly = Y - s / 2 - 2;
|
|
697
|
+
ctx.lineWidth = 3.2; ctx.strokeStyle = 'rgba(243,232,203,0.92)';
|
|
698
|
+
ctx.strokeText(txt, X, ly);
|
|
699
|
+
ctx.fillStyle = '#8c2a12';
|
|
700
|
+
ctx.fillText(txt, X, ly);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// THE MISSION'S OBJECTIVE, on the paper. game.questMarkers is the very list the compass reads
|
|
704
|
+
// (missions.js _setMarkers): the mission giver, or the active stage's target / goto point. A GOLD
|
|
705
|
+
// mark so it reads apart from the rust waypoint you set yourself — the same gold as the compass pip.
|
|
706
|
+
_drawObjectives(ctx, d, redraw) {
|
|
707
|
+
const marks = (this.game.questMarkers ?? []).filter((e) => e && e.alive !== false && e.position);
|
|
708
|
+
for (const e of marks) {
|
|
709
|
+
const [X, Y] = this._w2s(e.position.x, e.position.z);
|
|
710
|
+
if (X < -30 || Y < -30 || X > d + 30 || Y > d + 30) continue;
|
|
711
|
+
const s = 22;
|
|
712
|
+
const img = inkIcon('target', '#ffc63a', s, redraw);
|
|
713
|
+
ctx.save();
|
|
714
|
+
ctx.shadowColor = 'rgba(0,0,0,0.7)'; ctx.shadowBlur = 5;
|
|
715
|
+
if (img) ctx.drawImage(img, X - s / 2, Y - s / 2, s, s);
|
|
716
|
+
else { ctx.beginPath(); ctx.arc(X, Y, 7, 0, 7); ctx.fillStyle = '#ffc63a'; ctx.fill(); }
|
|
717
|
+
ctx.restore();
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// THE HALLOWAY PLACE — once the deed is yours, home gets its own stamp on the paper (the static
|
|
722
|
+
// POI list is built before the campaign can sell you a house, so it rides the live layer).
|
|
723
|
+
_drawHome(ctx, d, redraw) {
|
|
724
|
+
if (!this.game.missions?.flag?.('house_owned')) return;
|
|
725
|
+
const [X, Y] = this._w2s(-15.58, -79.79);
|
|
726
|
+
if (X < -30 || Y < -30 || X > d + 30 || Y > d + 30) return;
|
|
727
|
+
this._stamp(ctx, X, Y, 11, { category: 'Settlement', icon: 'home', big: false, name: 'The Halloway Place' }, redraw);
|
|
728
|
+
ctx.font = '600 11px Georgia, serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
|
729
|
+
ctx.lineWidth = 3.2; ctx.strokeStyle = 'rgba(243,232,203,0.92)';
|
|
730
|
+
ctx.strokeText('The Halloway Place', X, Y + 14);
|
|
731
|
+
ctx.fillStyle = '#3d2e1a'; ctx.fillText('The Halloway Place', X, Y + 14);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// YOUR HORSE on the paper — a green dot so you can find where you left it. Not while you're in the
|
|
735
|
+
// saddle (it sits under your own arrow), and culled when it's off the drawn frame.
|
|
736
|
+
_drawHorse(ctx, d) {
|
|
737
|
+
const h = this.game.ownHorse;
|
|
738
|
+
if (!h || h.rider || h.alive === false || !h.position) return;
|
|
739
|
+
const [X, Y] = this._w2s(h.position.x, h.position.z);
|
|
740
|
+
if (X < -30 || Y < -30 || X > d + 30 || Y > d + 30) return;
|
|
741
|
+
ctx.save();
|
|
742
|
+
ctx.shadowColor = 'rgba(30,20,8,0.5)'; ctx.shadowBlur = 4;
|
|
743
|
+
const img = (this._horseImg ??= Object.assign(new Image(), { src: '/assets/ui/icon_horse.png' }));
|
|
744
|
+
if (img.complete && img.naturalWidth) {
|
|
745
|
+
const s = 30;
|
|
746
|
+
ctx.drawImage(img, X - s / 2, Y - s / 2, s, s);
|
|
747
|
+
} else { // first frames: the old green pip until the icon lands
|
|
748
|
+
ctx.beginPath(); ctx.arc(X, Y, 6, 0, 7);
|
|
749
|
+
ctx.fillStyle = '#7dc24a'; ctx.fill();
|
|
750
|
+
ctx.shadowBlur = 0;
|
|
751
|
+
ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(20,26,14,0.9)'; ctx.stroke();
|
|
752
|
+
}
|
|
753
|
+
ctx.restore();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// YOU, AND WHICH WAY YOU ARE FACING. rotation.y = heading and the model looks down its +Z, so
|
|
757
|
+
// a heading of h points at (sin h, cos h) in world XZ; on a north-up chart +Z is screen-DOWN, so
|
|
758
|
+
// an arrow drawn pointing up must be turned by (π − h).
|
|
759
|
+
_drawPlayer(ctx, d) {
|
|
760
|
+
const p = this.game.player;
|
|
761
|
+
if (!p?.position) return;
|
|
762
|
+
const [X, Y] = this._w2s(p.position.x, p.position.z);
|
|
763
|
+
ctx.save();
|
|
764
|
+
ctx.translate(X, Y);
|
|
765
|
+
ctx.rotate(Math.PI - (p.heading ?? 0));
|
|
766
|
+
ctx.beginPath();
|
|
767
|
+
ctx.moveTo(0, -10); ctx.lineTo(7, 8); ctx.lineTo(0, 4); ctx.lineTo(-7, 8);
|
|
768
|
+
ctx.closePath();
|
|
769
|
+
ctx.fillStyle = '#1d1408';
|
|
770
|
+
ctx.strokeStyle = '#f3e8cb'; ctx.lineWidth = 2; ctx.lineJoin = 'round';
|
|
771
|
+
ctx.stroke(); ctx.fill();
|
|
772
|
+
ctx.restore();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// A SCALE BAR, because "how far is that" is the only question anyone actually asks a map. It is
|
|
776
|
+
// screen-space and recomputed per zoom: the bar stays ~a fifth of the frame and the NUMBER
|
|
777
|
+
// changes, which is how a chart does it.
|
|
778
|
+
_drawScale(ctx, d) {
|
|
779
|
+
const mPerPx = WORLD_SIZE / (d * this.zoom);
|
|
780
|
+
const want = mPerPx * (d * 0.2);
|
|
781
|
+
const pow = 10 ** Math.floor(Math.log10(want));
|
|
782
|
+
const nice = [1, 2, 5, 10].map((k) => k * pow).find((v) => v >= want * 0.62) ?? pow * 10;
|
|
783
|
+
const px = nice / mPerPx;
|
|
784
|
+
const x0 = 14, y0 = d - 16;
|
|
785
|
+
ctx.lineCap = 'butt';
|
|
786
|
+
ctx.strokeStyle = 'rgba(243,232,203,0.9)'; ctx.lineWidth = 5;
|
|
787
|
+
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x0 + px, y0); ctx.stroke();
|
|
788
|
+
ctx.strokeStyle = '#2a1c0c'; ctx.lineWidth = 2;
|
|
789
|
+
ctx.beginPath();
|
|
790
|
+
ctx.moveTo(x0, y0 - 4); ctx.lineTo(x0, y0 + 4);
|
|
791
|
+
ctx.moveTo(x0, y0); ctx.lineTo(x0 + px, y0);
|
|
792
|
+
ctx.moveTo(x0 + px, y0 - 4); ctx.lineTo(x0 + px, y0 + 4);
|
|
793
|
+
ctx.stroke();
|
|
794
|
+
ctx.font = '600 11.5px Georgia, serif';
|
|
795
|
+
ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
|
|
796
|
+
ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(243,232,203,0.92)';
|
|
797
|
+
const txt = nice >= 1000 ? `${nice / 1000} km` : `${nice} m`;
|
|
798
|
+
ctx.strokeText(txt, x0, y0 - 6);
|
|
799
|
+
ctx.fillStyle = '#2a1c0c';
|
|
800
|
+
ctx.fillText(txt, x0, y0 - 6);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Called every frame while the map is up (the world is paused, so almost nothing moves) — the
|
|
804
|
+
// redraw is gated on the player actually having moved, so an idle map costs one comparison.
|
|
805
|
+
tick() {
|
|
806
|
+
const p = this.game.player;
|
|
807
|
+
if (!p?.position) return;
|
|
808
|
+
const k = `${p.position.x.toFixed(1)}|${p.position.z.toFixed(1)}|${(p.heading ?? 0).toFixed(2)}`;
|
|
809
|
+
if (k === this._playerKey) return;
|
|
810
|
+
this._playerKey = k;
|
|
811
|
+
this._draw();
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
toggle() {
|
|
815
|
+
this.visible = !this.visible;
|
|
816
|
+
this.root.style.display = this.visible ? 'flex' : 'none';
|
|
817
|
+
if (!this.visible) { this.card.style.display = 'none'; this._hover = null; if (this.target) this.target.style.display = 'none'; return; }
|
|
818
|
+
if (!this.built) this._render();
|
|
819
|
+
this._showTab(this.tab); // restore the last tab + refresh the journal from live mission state
|
|
820
|
+
this._startPadNav(); // the pad drives the hub now (L1/R1 tabs · D-pad navigates · Circle closes)
|
|
821
|
+
// THE MOUSE HAS TO COME BACK. The game holds pointer lock, and under lock there is no cursor
|
|
822
|
+
// and clientX never moves — every click would land wherever the last real mouse event was.
|
|
823
|
+
// (The old map never released it, which is why clicking a waypoint on it was a lottery.)
|
|
824
|
+
this.game.input?.releaseLock?.();
|
|
825
|
+
this.zoom = 1; this.panX = this.panY = 0;
|
|
826
|
+
this._playerKey = null;
|
|
827
|
+
this._apply();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const BTN = `width:28px; height:28px; background:rgba(26,22,17,0.9); color:#e8d9b0; border:1px solid #7a6336; border-radius:4px;
|
|
832
|
+
font:15px Georgia,serif; cursor:pointer; pointer-events:auto;`;
|
|
833
|
+
const LBTN = `height:25px; padding:0 13px; background:rgba(26,22,17,0.85); color:#8d7d5c; border:1px solid #5a4a2c; border-radius:13px;
|
|
834
|
+
font:12px Georgia,serif; letter-spacing:0.5px; cursor:pointer; pointer-events:auto;`;
|
|
835
|
+
// hub tabs (MAP · JOURNAL · SYSTEM) — the active one is inked gold + underlined by _showTab
|
|
836
|
+
const TAB = `background:none; border:none; border-bottom:2px solid transparent; color:#9a855a; font-family:inherit;
|
|
837
|
+
font-size:15px; letter-spacing:3px; font-weight:700; padding:6px 22px; cursor:pointer; pointer-events:auto; transition:color .12s;`;
|