sindicate 0.10.0 → 0.12.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/docs/plans/world-splits-recon.json +387 -0
- package/package.json +1 -1
- package/src/core/assets.js +18 -0
- package/src/core/engine.js +9 -0
- package/src/core/retarget.js +4 -1
- package/src/core/tune.js +50 -0
- package/src/systems/nav.js +4 -1
- package/src/ui/cartograph.js +6 -0
- package/src/ui/deathScreen.js +3 -3
- package/src/ui/dialogue.js +85 -14
- package/src/ui/intro.js +1 -1
- package/src/ui/pauseMenu.js +32 -3
- package/src/ui/saveLoadScreen.js +3 -3
- package/src/ui/titleScreen.js +28 -34
- package/src/ui/weaponWheel.js +138 -0
- package/src/ui/worldmap.js +119 -161
- package/tools/devPlugin.js +69 -0
package/src/ui/worldmap.js
CHANGED
|
@@ -17,7 +17,43 @@
|
|
|
17
17
|
// paper map open, and the coaches, the law and the outlaws can all wait. It also means the map is
|
|
18
18
|
// not a thing you have to read while being shot at.
|
|
19
19
|
import { WORLD_SIZE, allPOIs, allBuildings } from './cartograph.js';
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
// WORLD-MAP CONTENT is GAME data (setWorldMapContent in engineSetup):
|
|
22
|
+
// title/iconDir/horseIcon — the sheet's dress
|
|
23
|
+
// legend [{cat,label}] + ink {cat:col} + kindLabel {kind:label} — the marker taxonomy
|
|
24
|
+
// labelZoomRule(p, zoom) → bool — may this label print at this zoom (western: stage stops
|
|
25
|
+
// hide under 2.2×; return true/undefined to print)
|
|
26
|
+
// tabs [{id, label, render(el, game, api)}] — hub tabs after MAP; render may return
|
|
27
|
+
// { padRows: [{el, act?, cycle?}] } for pad row-nav, else up/down scrolls the panel
|
|
28
|
+
// discovery { known(p), version() } — POIs filter through known(); bump version to redraw
|
|
29
|
+
// layersUnder / layersOver [(ctx, d, api) => void] — extra paint (fog, home stamps);
|
|
30
|
+
// under = below buildings, over = above markers, below the player arrow
|
|
31
|
+
// onPick(target, api) → bool — click interception (fast-travel popups); target is
|
|
32
|
+
// { p } for a marker hit or { x, z } for open ground; return true = handled
|
|
33
|
+
// waypoint { get(game), set(game, wp), route(game) } — the pin/route adapter; default
|
|
34
|
+
// is the engine contract (game.waypoint / game.route / ui.recomputeRoute)
|
|
35
|
+
let WM = {
|
|
36
|
+
title: 'WORLD MAP',
|
|
37
|
+
iconDir: '/assets/ui/map', horseIcon: '/assets/ui/icon_horse.png',
|
|
38
|
+
legend: [], ink: {}, inkDefault: '#3a2a16', kindLabel: {},
|
|
39
|
+
labelZoomRule: null,
|
|
40
|
+
tabs: [],
|
|
41
|
+
discovery: null,
|
|
42
|
+
layersUnder: [], layersOver: [],
|
|
43
|
+
onPick: null,
|
|
44
|
+
drawPOIs: null, // FULL marker-pass override (ctx, d, api) — marker style is game
|
|
45
|
+
// identity; the api's addHit/w2s/filters keep hit-testing engine-side
|
|
46
|
+
hoverLabel: null, // (h) => { name, kind } — mystery masking, custom kind copy
|
|
47
|
+
routeColors: null, // (rt) => { line, casing } — the route's ink
|
|
48
|
+
fogLayer: null, // { px, version(), draw(ctx, px) } — WORLD-ANCHORED canvas between
|
|
49
|
+
// chart and overlay (discovery fog); redrawn only when version moves
|
|
50
|
+
waypoint: {
|
|
51
|
+
get: (g) => g.waypoint,
|
|
52
|
+
set: (g, w) => { g.waypoint = w; g.ui?.recomputeRoute?.(); },
|
|
53
|
+
route: (g) => g.route,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
export function setWorldMapContent(c = {}) { WM = { ...WM, ...c, waypoint: { ...WM.waypoint, ...(c.waypoint ?? {}) } }; }
|
|
21
57
|
|
|
22
58
|
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
|
23
59
|
// THE CHART BITMAP STAYS AT 1600, AND THAT IS A DELIBERATE REFUSAL.
|
|
@@ -36,7 +72,7 @@ const ZOOM_MAX = 16; // 16× ⇒ ~270 m across the frame: close enoug
|
|
|
36
72
|
// (dusty and plain — the Military set's chrome and the Fantasy set's gold filigree both read
|
|
37
73
|
// wrong on a western), except horse.png, which is the Fantasy pack's — no other pack ships a
|
|
38
74
|
// horse, and a horse is what a stage stop is.
|
|
39
|
-
const ICON = (n) =>
|
|
75
|
+
const ICON = (n) => `${WM.iconDir}/${n}.png`;
|
|
40
76
|
const _imgs = new Map();
|
|
41
77
|
function iconImg(name, onReady) {
|
|
42
78
|
let img = _imgs.get(name);
|
|
@@ -68,11 +104,7 @@ function inkIcon(name, col, px, onReady) {
|
|
|
68
104
|
|
|
69
105
|
// Ink by category — the one colour cue on an otherwise monochrome sheet. Outposts are the places
|
|
70
106
|
// with people in them who care who you are, and they are red.
|
|
71
|
-
|
|
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
|
-
};
|
|
107
|
+
|
|
76
108
|
|
|
77
109
|
export class WorldMap {
|
|
78
110
|
// `getChart` hands back the prebuilt chart bitmap (ui.prebuildChart) — the map never renders
|
|
@@ -96,26 +128,22 @@ export class WorldMap {
|
|
|
96
128
|
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
129
|
font-family:Copperplate,'Copperplate Gothic Bold',Palatino,'Book Antiqua',Georgia,serif; pointer-events:auto; user-select:none;`;
|
|
98
130
|
root.innerHTML = `
|
|
99
|
-
<div style="color:#e8c98a; font-size:21px; letter-spacing:7px; text-shadow:0 2px 6px #000; font-weight:700;"
|
|
100
|
-
<div id="wm-tabs" style="display:flex; gap:0; border-bottom:1px solid rgba(122,99,54,0.4);">
|
|
131
|
+
<div style="color:#e8c98a; font-size:21px; letter-spacing:7px; text-shadow:0 2px 6px #000; font-weight:700;">${WM.title}</div>
|
|
132
|
+
<div id="wm-tabs" style="display:flex; gap:0; border-bottom:1px solid rgba(122,99,54,0.4); ${WM.tabs.length ? '' : 'display:none;'}">
|
|
101
133
|
<button data-tab="map" style="${TAB}">MAP</button>
|
|
102
|
-
|
|
103
|
-
<button data-tab="system" style="${TAB}">SYSTEM</button>
|
|
134
|
+
${WM.tabs.map((t) => `<button data-tab="${t.id}" style="${TAB}">${t.label}</button>`).join('')}
|
|
104
135
|
</div>
|
|
105
136
|
<div id="wm-mapview" style="display:flex; flex-direction:column; align-items:center; gap:10px;">
|
|
106
|
-
<div id="wm-legend" style="display:
|
|
137
|
+
<div id="wm-legend" style="display:${WM.legend.length ? 'flex' : 'none'}; gap:6px;">
|
|
107
138
|
<button data-cat="" style="${LBTN}">All</button>
|
|
108
|
-
|
|
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>
|
|
139
|
+
${WM.legend.map((l) => `<button data-cat="${l.cat}" style="${LBTN}">${l.label}</button>`).join('')}
|
|
113
140
|
</div>
|
|
114
141
|
<div id="wm-frame" style="position:relative; width:min(74vh,74vw); height:min(74vh,74vw); overflow:hidden;
|
|
115
142
|
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
143
|
background:#1a1611; cursor:grab;">
|
|
117
144
|
<div id="wm-pan" style="position:absolute; inset:0; transform-origin:0 0;">
|
|
118
145
|
<canvas id="wm-canvas" style="position:absolute; inset:0; width:100%; height:100%;"></canvas>
|
|
146
|
+
${WM.fogLayer ? '<canvas id="wm-fog" style="position:absolute; inset:0; width:100%; height:100%; pointer-events:none;"></canvas>' : ''}
|
|
119
147
|
</div>
|
|
120
148
|
<canvas id="wm-overlay" style="position:absolute; inset:0; width:100%; height:100%; pointer-events:none;"></canvas>
|
|
121
149
|
<div id="wm-target" style="position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:30px; height:30px;
|
|
@@ -135,12 +163,9 @@ export class WorldMap {
|
|
|
135
163
|
<div style="color:#c0ad82; font-size:12.5px; letter-spacing:0.5px; opacity:0.85;">
|
|
136
164
|
wheel zooms · drag pans · click sets your waypoint · right-click clears it · [M] closes</div>
|
|
137
165
|
</div>
|
|
138
|
-
|
|
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);
|
|
166
|
+
${WM.tabs.map((t) => `<div id="wm-tab-${t.id}" style="display:none; width:min(74vh,74vw); height:min(70vh,70vw); overflow-y:auto;
|
|
142
167
|
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
|
|
168
|
+
padding:24px 30px; color:#e6d6ad; text-align:left;"></div>`).join('')}`;
|
|
144
169
|
document.body.appendChild(root);
|
|
145
170
|
|
|
146
171
|
this.root = root;
|
|
@@ -148,6 +173,8 @@ export class WorldMap {
|
|
|
148
173
|
this.pan = root.querySelector('#wm-pan');
|
|
149
174
|
this.canvas = root.querySelector('#wm-canvas');
|
|
150
175
|
this.canvas.width = this.canvas.height = CHART_PX;
|
|
176
|
+
this.fog = root.querySelector('#wm-fog');
|
|
177
|
+
if (this.fog) { this.fog.width = this.fog.height = WM.fogLayer.px ?? CHART_PX; this._fogVer = -1; }
|
|
151
178
|
this.overlay = root.querySelector('#wm-overlay');
|
|
152
179
|
|
|
153
180
|
// the hover card — name, what the place is, and how far you have to ride
|
|
@@ -171,8 +198,7 @@ export class WorldMap {
|
|
|
171
198
|
|
|
172
199
|
// HUB TABS — MAP · JOURNAL (SYSTEM lands in Phase 3). The map is the pause screen already.
|
|
173
200
|
this.mapview = root.querySelector('#wm-mapview');
|
|
174
|
-
this.
|
|
175
|
-
this.system = root.querySelector('#wm-system');
|
|
201
|
+
this.panels = Object.fromEntries(WM.tabs.map((t) => [t.id, root.querySelector(`#wm-tab-${t.id}`)]));
|
|
176
202
|
this.target = root.querySelector('#wm-target'); // the pad's centre reticle (where Square drops the pin)
|
|
177
203
|
this.tab = 'map';
|
|
178
204
|
root.querySelectorAll('#wm-tabs button').forEach((b) =>
|
|
@@ -184,56 +210,42 @@ export class WorldMap {
|
|
|
184
210
|
_showTab(tab) {
|
|
185
211
|
this.tab = tab;
|
|
186
212
|
this.mapview.style.display = tab === 'map' ? 'flex' : 'none';
|
|
187
|
-
this.
|
|
188
|
-
this.system.style.display = tab === 'system' ? 'flex' : 'none';
|
|
213
|
+
for (const [id, el] of Object.entries(this.panels)) el.style.display = id === tab ? 'block' : 'none';
|
|
189
214
|
if (this.target && tab !== 'map') this.target.style.display = 'none'; // reticle is chart-only (pad shows it)
|
|
190
215
|
this.root.querySelectorAll('#wm-tabs button').forEach((b) => {
|
|
191
216
|
const on = b.dataset.tab === tab;
|
|
192
217
|
b.style.color = on ? '#e8c98a' : '#9a855a';
|
|
193
218
|
b.style.borderBottomColor = on ? '#e8c98a' : 'transparent';
|
|
194
219
|
});
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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();
|
|
220
|
+
const def = WM.tabs.find((t) => t.id === tab);
|
|
221
|
+
this._padRows = null; this._padSel = 0;
|
|
222
|
+
if (def) {
|
|
223
|
+
const out = def.render?.(this.panels[tab], this.game, this._api());
|
|
224
|
+
if (out?.padRows) { this._padRows = out.padRows; this._paintSysSel(); }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// the helpers game callbacks paint with — one w2s, one stamp, so nothing can drift
|
|
229
|
+
_api() {
|
|
230
|
+
return {
|
|
231
|
+
game: this.game, map: this, zoom: this.zoom,
|
|
232
|
+
w2s: (x, z) => this._w2s(x, z), s2w: (sx, sy) => this._s2w(sx, sy),
|
|
233
|
+
stamp: (ctx, X, Y, r, p, redraw) => this._stamp(ctx, X, Y, r, p, redraw),
|
|
234
|
+
inkIcon, redraw: () => this._draw(), close: () => this.toggle(),
|
|
235
|
+
setWaypoint: (w) => this._setWaypoint(w),
|
|
236
|
+
addHit: (h) => this._hit.push(h), filters: this.filters, frame: this.frame,
|
|
237
|
+
};
|
|
226
238
|
}
|
|
227
239
|
|
|
228
240
|
// ── GAMEPAD in the hub. The game's own pad mapping is suppressed while a panel is up (input.js),
|
|
229
241
|
// so the map drives itself: L1/R1 swap tabs, D-pad/stick navigates the current tab, Cross confirms,
|
|
230
242
|
// Circle (or the map button) closes.
|
|
231
243
|
_cycleTab(dir) {
|
|
232
|
-
const tabs = ['map',
|
|
244
|
+
const tabs = ['map', ...WM.tabs.map((t) => t.id)];
|
|
233
245
|
this._showTab(tabs[(tabs.indexOf(this.tab) + dir + tabs.length) % tabs.length]);
|
|
234
246
|
}
|
|
235
247
|
_paintSysSel() {
|
|
236
|
-
(this.
|
|
248
|
+
(this._padRows || []).forEach((r, i) => { r.el.style.outline = i === this._padSel ? '2px solid #e8c98a' : 'none'; r.el.style.outlineOffset = '2px'; });
|
|
237
249
|
}
|
|
238
250
|
_startPadNav() {
|
|
239
251
|
cancelAnimationFrame(this._padRAF);
|
|
@@ -262,19 +274,17 @@ export class WorldMap {
|
|
|
262
274
|
if (edge('a')) this._zoomAt(c, c, 1.4); // Cross zooms IN about the reticle
|
|
263
275
|
if (edge('tri')) this._zoomAt(c, c, 1 / 1.4); // Triangle zooms out
|
|
264
276
|
if (edge('sq')) this._clickAt(c, c); // Square drops the waypoint at the reticle
|
|
265
|
-
} else if (this.
|
|
266
|
-
const rows = this.
|
|
267
|
-
if (rows.length)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (b(12) || ly < -0.4) this.journal.scrollTop -= 16;
|
|
277
|
-
if (b(13) || ly > 0.4) this.journal.scrollTop += 16;
|
|
277
|
+
} else if (this._padRows?.length) { // a rows tab: up/down moves · left/right cycles · Cross fires
|
|
278
|
+
const rows = this._padRows;
|
|
279
|
+
if (edge('up')) { this._padSel = (this._padSel + rows.length - 1) % rows.length; this._paintSysSel(); }
|
|
280
|
+
if (edge('down')) { this._padSel = (this._padSel + 1) % rows.length; this._paintSysSel(); }
|
|
281
|
+
const r = rows[this._padSel];
|
|
282
|
+
if (edge('left') && r?.cycle) r.cycle(-1);
|
|
283
|
+
if (edge('right') && r?.cycle) r.cycle(1);
|
|
284
|
+
if (edge('a') && r?.act) r.act();
|
|
285
|
+
} else if (this.panels[this.tab]) { // a scroll tab (held)
|
|
286
|
+
if (b(12) || ly < -0.4) this.panels[this.tab].scrollTop -= 16;
|
|
287
|
+
if (b(13) || ly > 0.4) this.panels[this.tab].scrollTop += 16;
|
|
278
288
|
}
|
|
279
289
|
if (edge('back') || edge('pad')) { this.toggle(); return; }
|
|
280
290
|
}
|
|
@@ -285,66 +295,6 @@ export class WorldMap {
|
|
|
285
295
|
this._padRAF = requestAnimationFrame(poll);
|
|
286
296
|
}
|
|
287
297
|
|
|
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
298
|
_wireEvents() {
|
|
349
299
|
const f = this.frame;
|
|
350
300
|
f.addEventListener('wheel', (e) => {
|
|
@@ -453,16 +403,19 @@ export class WorldMap {
|
|
|
453
403
|
// they must keep reading it — the map is where you SAY where you are going; the HUD is what gets
|
|
454
404
|
// you there once the map is shut.
|
|
455
405
|
_setWaypoint(w) {
|
|
456
|
-
|
|
457
|
-
// Re-plan the road route NOW, before we repaint: the world is paused behind the map, so ui.js's
|
|
406
|
+
// Re-plan the route NOW, before we repaint: the world is paused behind the map, so the
|
|
458
407
|
// movement-gated re-plan would not fire, and the route would lag a click behind the pin.
|
|
459
|
-
this.game
|
|
408
|
+
WM.waypoint.set(this.game, w);
|
|
460
409
|
this._draw();
|
|
461
410
|
}
|
|
462
411
|
// CLICK-SNAP: a click on (or near) a marker means the PLACE, not the pixel you happened to hit.
|
|
463
412
|
// Without it, "waypoint the fort" puts you 30 m into the scrub beside the fort.
|
|
464
413
|
_clickAt(sx, sy) {
|
|
465
414
|
const hit = this._hitAt(sx, sy, 10);
|
|
415
|
+
if (WM.onPick) {
|
|
416
|
+
const [x, z] = this._s2w(sx, sy);
|
|
417
|
+
if (WM.onPick(hit ? { p: hit.p, masked: hit.masked, sx, sy } : { x, z, sx, sy }, this._api())) return;
|
|
418
|
+
}
|
|
466
419
|
if (hit) { this._setWaypoint({ x: hit.p.x, z: hit.p.z, name: hit.p.name }); return; }
|
|
467
420
|
const [x, z] = this._s2w(sx, sy);
|
|
468
421
|
this._setWaypoint({ x, z });
|
|
@@ -484,8 +437,9 @@ export class WorldMap {
|
|
|
484
437
|
if (!h) { this.card.style.display = 'none'; return; }
|
|
485
438
|
const pl = this.game.player?.position;
|
|
486
439
|
const dist = pl ? `${Math.round(Math.hypot(h.p.x - pl.x, h.p.z - pl.z))} m` : '';
|
|
487
|
-
|
|
488
|
-
|
|
440
|
+
const lab = WM.hoverLabel?.(h) ?? { name: h.p.name, kind: WM.kindLabel[h.p.kind] ?? h.p.kind };
|
|
441
|
+
this.card.innerHTML = `<b style="color:#f0d89e;">${lab.name}</b><br>` +
|
|
442
|
+
`<span style="opacity:0.8;">${lab.kind}${dist ? ' · ' + dist : ''}</span>`;
|
|
489
443
|
this.card.style.display = '';
|
|
490
444
|
const d = this._disp();
|
|
491
445
|
this.card.style.left = `${Math.min(sx + 14, d - 160)}px`;
|
|
@@ -509,6 +463,16 @@ export class WorldMap {
|
|
|
509
463
|
this.built = true;
|
|
510
464
|
}
|
|
511
465
|
|
|
466
|
+
// the discovery fog repaints ONLY when its version moves — the grain pass is O(px²)
|
|
467
|
+
_drawFogIfStale() {
|
|
468
|
+
const f = WM.fogLayer;
|
|
469
|
+
if (!f || !this.fog) return;
|
|
470
|
+
const v = f.version?.() ?? 0;
|
|
471
|
+
if (v === this._fogVer) return;
|
|
472
|
+
this._fogVer = v;
|
|
473
|
+
f.draw(this.fog.getContext('2d'), f.px ?? CHART_PX);
|
|
474
|
+
}
|
|
475
|
+
|
|
512
476
|
// Everything below is drawn in SCREEN pixels. Nothing here multiplies a size by this.zoom —
|
|
513
477
|
// that is the invariant that keeps the map readable at 1× and at 8×.
|
|
514
478
|
_draw() {
|
|
@@ -523,12 +487,14 @@ export class WorldMap {
|
|
|
523
487
|
const redraw = () => this._draw(); // a late icon load repaints the overlay
|
|
524
488
|
this._hit = [];
|
|
525
489
|
|
|
490
|
+
const api = this._api();
|
|
491
|
+
for (const fn of WM.layersUnder) fn(ctx, d, api);
|
|
526
492
|
this._drawBuildings(ctx, d);
|
|
527
493
|
this._drawRoute(ctx, d);
|
|
528
494
|
this._drawPOIs(ctx, d, redraw);
|
|
529
495
|
this._drawObjectives(ctx, d, redraw);
|
|
530
496
|
this._drawWaypoint(ctx, d, redraw);
|
|
531
|
-
|
|
497
|
+
for (const fn of WM.layersOver) fn(ctx, d, api);
|
|
532
498
|
this._drawHorse(ctx, d);
|
|
533
499
|
this._drawPlayer(ctx, d);
|
|
534
500
|
this._drawScale(ctx, d);
|
|
@@ -572,17 +538,18 @@ export class WorldMap {
|
|
|
572
538
|
// network, DASHED for the two short stubs off it (you→road, road→pin). If the router has no plan yet,
|
|
573
539
|
// or the pin is nowhere near a road, it falls back to an honest crow-flies dashed line.
|
|
574
540
|
_drawRoute(ctx, d) {
|
|
575
|
-
const wp = this.game
|
|
541
|
+
const wp = WM.waypoint.get(this.game);
|
|
576
542
|
const pl = this.game.player?.position;
|
|
577
543
|
if (!wp || !pl) return;
|
|
578
|
-
const rt = this.game
|
|
544
|
+
const rt = WM.waypoint.route(this.game);
|
|
579
545
|
const segs = rt?.segments?.length
|
|
580
546
|
? rt.segments
|
|
581
547
|
: [{ pts: [{ x: pl.x, z: pl.z }, { x: wp.x, z: wp.z }], style: 'dash' }];
|
|
582
|
-
const
|
|
548
|
+
const cols = WM.routeColors?.(rt) ?? { line: rt && rt.reachable ? '#c2401f' : '#a86a3c', casing: 'rgba(245,232,198,0.85)' };
|
|
549
|
+
const line = cols.line;
|
|
583
550
|
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
|
584
551
|
for (let pass = 0; pass < 2; pass++) {
|
|
585
|
-
ctx.strokeStyle = pass ? line :
|
|
552
|
+
ctx.strokeStyle = pass ? line : cols.casing; // casing under the line, so it reads on ink AND on paper
|
|
586
553
|
ctx.lineWidth = pass ? 2.6 : 5;
|
|
587
554
|
for (const seg of segs) {
|
|
588
555
|
if (!seg.pts || seg.pts.length < 2) continue;
|
|
@@ -599,8 +566,10 @@ export class WorldMap {
|
|
|
599
566
|
}
|
|
600
567
|
|
|
601
568
|
_drawPOIs(ctx, d, redraw) {
|
|
569
|
+
if (WM.drawPOIs) { WM.drawPOIs(ctx, d, this._api()); return; }
|
|
602
570
|
const placed = []; // label rects — the small ones yield to the big ones
|
|
603
|
-
const
|
|
571
|
+
const known = WM.discovery?.known;
|
|
572
|
+
const shown = allPOIs().filter((p) => (!this.filters.size || this.filters.has(p.category)) && (!known || known(p)));
|
|
604
573
|
// big places first, so their labels reserve their ground before the stage stops ask for any
|
|
605
574
|
const order = [...shown].sort((a, b) => (b.big ? 1 : 0) - (a.big ? 1 : 0));
|
|
606
575
|
const stamps = [];
|
|
@@ -633,7 +602,7 @@ export class WorldMap {
|
|
|
633
602
|
// a dot tells you something is there and nothing about what it is, and this county has a fort, a
|
|
634
603
|
// mission, a camp, a quarry and a stage line, which are five different reasons to ride somewhere.
|
|
635
604
|
_stamp(ctx, X, Y, r, p, redraw) {
|
|
636
|
-
const ink =
|
|
605
|
+
const ink = WM.ink[p.category] ?? WM.inkDefault;
|
|
637
606
|
ctx.save();
|
|
638
607
|
ctx.shadowColor = 'rgba(30,20,8,0.55)'; ctx.shadowBlur = 5; ctx.shadowOffsetY = 1;
|
|
639
608
|
ctx.beginPath(); ctx.arc(X, Y, r, 0, 7);
|
|
@@ -656,7 +625,7 @@ export class WorldMap {
|
|
|
656
625
|
// themselves from there. (The first cut suppressed every non-"big" place, which meant every
|
|
657
626
|
// farm, ranch, shack and the cemetery went unnamed at county zoom — the zoom you actually plan
|
|
658
627
|
// a ride at. The label-collision pass below already handles crowding; it does not need help.)
|
|
659
|
-
if (
|
|
628
|
+
if (WM.labelZoomRule && WM.labelZoomRule(p, this.zoom) === false) return;
|
|
660
629
|
const fs = p.big ? 13.5 : 11;
|
|
661
630
|
ctx.font = `${p.big ? 700 : 600} ${fs}px Georgia, 'Palatino Linotype', serif`;
|
|
662
631
|
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
|
@@ -675,7 +644,7 @@ export class WorldMap {
|
|
|
675
644
|
}
|
|
676
645
|
|
|
677
646
|
_drawWaypoint(ctx, d, redraw) {
|
|
678
|
-
const wp = this.game
|
|
647
|
+
const wp = WM.waypoint.get(this.game);
|
|
679
648
|
if (!wp) return;
|
|
680
649
|
const [X, Y] = this._w2s(wp.x, wp.z);
|
|
681
650
|
const s = 24;
|
|
@@ -718,19 +687,6 @@ export class WorldMap {
|
|
|
718
687
|
}
|
|
719
688
|
}
|
|
720
689
|
|
|
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
690
|
// YOUR HORSE on the paper — a green dot so you can find where you left it. Not while you're in the
|
|
735
691
|
// saddle (it sits under your own arrow), and culled when it's off the drawn frame.
|
|
736
692
|
_drawHorse(ctx, d) {
|
|
@@ -740,7 +696,7 @@ export class WorldMap {
|
|
|
740
696
|
if (X < -30 || Y < -30 || X > d + 30 || Y > d + 30) return;
|
|
741
697
|
ctx.save();
|
|
742
698
|
ctx.shadowColor = 'rgba(30,20,8,0.5)'; ctx.shadowBlur = 4;
|
|
743
|
-
const img = (this._horseImg ??= Object.assign(new Image(), { src:
|
|
699
|
+
const img = (this._horseImg ??= Object.assign(new Image(), { src: WM.horseIcon }));
|
|
744
700
|
if (img.complete && img.naturalWidth) {
|
|
745
701
|
const s = 30;
|
|
746
702
|
ctx.drawImage(img, X - s / 2, Y - s / 2, s, s);
|
|
@@ -805,9 +761,10 @@ export class WorldMap {
|
|
|
805
761
|
tick() {
|
|
806
762
|
const p = this.game.player;
|
|
807
763
|
if (!p?.position) return;
|
|
808
|
-
const k = `${p.position.x.toFixed(1)}|${p.position.z.toFixed(1)}|${(p.heading ?? 0).toFixed(2)}`;
|
|
764
|
+
const k = `${p.position.x.toFixed(1)}|${p.position.z.toFixed(1)}|${(p.heading ?? 0).toFixed(2)}|${WM.discovery?.version?.() ?? 0}`;
|
|
809
765
|
if (k === this._playerKey) return;
|
|
810
766
|
this._playerKey = k;
|
|
767
|
+
if (this.visible) this._drawFogIfStale();
|
|
811
768
|
this._draw();
|
|
812
769
|
}
|
|
813
770
|
|
|
@@ -816,6 +773,7 @@ export class WorldMap {
|
|
|
816
773
|
this.root.style.display = this.visible ? 'flex' : 'none';
|
|
817
774
|
if (!this.visible) { this.card.style.display = 'none'; this._hover = null; if (this.target) this.target.style.display = 'none'; return; }
|
|
818
775
|
if (!this.built) this._render();
|
|
776
|
+
this._drawFogIfStale();
|
|
819
777
|
this._showTab(this.tab); // restore the last tab + refresh the journal from live mission state
|
|
820
778
|
this._startPadNav(); // the pad drives the hub now (L1/R1 tabs · D-pad navigates · Circle closes)
|
|
821
779
|
// THE MOUSE HAS TO COME BACK. The game holds pointer lock, and under lock there is no cursor
|
package/tools/devPlugin.js
CHANGED
|
@@ -205,6 +205,74 @@ function editorShotsPlugin({ shotsDir, mapsDir }) {
|
|
|
205
205
|
};
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
// THE ENGINE DEV GUI (/__sindicate/) — served by the dev server, talks to the RUNNING
|
|
209
|
+
// game through the game bus. v1: live tunables (core/tune.js groups render as sliders;
|
|
210
|
+
// edits apply in the game the same frame). Panels for add-ons/packs mount here next.
|
|
211
|
+
export function guiPlugin() {
|
|
212
|
+
const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Sindicate</title><style>
|
|
213
|
+
body{margin:0;font:14px/1.5 -apple-system,sans-serif;background:#14141f;color:#e8e2d0;padding:28px}
|
|
214
|
+
h1{font-size:18px;letter-spacing:0.14em;color:#e8c66a;margin:0 0 4px} .sub{color:#8d8875;font-size:12px;margin-bottom:22px}
|
|
215
|
+
.grp{background:#1c1c2b;border:1px solid #2c2c40;border-radius:10px;padding:16px 18px;margin-bottom:14px;max-width:520px}
|
|
216
|
+
.grp h2{font-size:13px;letter-spacing:0.1em;text-transform:uppercase;color:#e8c66a;margin:0 0 10px}
|
|
217
|
+
.row{display:grid;grid-template-columns:130px 1fr 64px;gap:12px;align-items:center;margin:7px 0}
|
|
218
|
+
.row label{color:#b8b09a;font-size:12px} .row output{text-align:right;font-variant-numeric:tabular-nums;color:#f5ead0}
|
|
219
|
+
input[type=range]{width:100%;accent-color:#e8c66a}
|
|
220
|
+
.file{color:#6d6858;font-size:11px;margin-top:8px} .status{color:#8d8875;font-size:12px;margin-top:18px}
|
|
221
|
+
</style></head><body>
|
|
222
|
+
<h1>SINDICATE</h1><div class="sub">dev panel — tunables apply LIVE in the running game</div>
|
|
223
|
+
<div id="groups"></div><div class="status" id="status">connecting to the game bus…</div>
|
|
224
|
+
<script>
|
|
225
|
+
const cmd = (js) => fetch('/__game/cmd', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ js }) }).then((r) => r.json());
|
|
226
|
+
const take = async (id) => { for (let i = 0; i < 40; i++) { const r = await fetch('/__game/take?id=' + id).then((x) => x.json()); if (!r.pending) return r; await new Promise((res) => setTimeout(res, 250)); } return { err: 'timeout' }; };
|
|
227
|
+
const bus = async (js) => { const { id, err } = await cmd(js); if (err) throw new Error(err); const r = await take(id); if (!r.ok) throw new Error(r.err ?? 'bus error'); return r.value; };
|
|
228
|
+
let sig = '', dragging = false;
|
|
229
|
+
const rows = new Map();
|
|
230
|
+
addEventListener('pointerup', () => { dragging = false; });
|
|
231
|
+
async function refresh() {
|
|
232
|
+
try {
|
|
233
|
+
const r = await bus('return { who: location.pathname + location.search, title: document.title, groups: window.__sindTune ? window.__sindTune.list() : [] }');
|
|
234
|
+
const groups = r.groups, tab = r.title + ' — ' + r.who;
|
|
235
|
+
const s = JSON.stringify(groups.map((g) => [g.name, Object.keys(g.spec)]));
|
|
236
|
+
document.getElementById('status').textContent = (groups.length ? 'live — ' + groups.length + ' group(s)' : 'connected — no tunables registered') + ' · answering tab: ' + tab;
|
|
237
|
+
if (s === sig) {
|
|
238
|
+
if (!dragging) for (const g of groups) for (const [k, v] of Object.entries(g.spec)) {
|
|
239
|
+
const row = rows.get(g.name + '.' + k);
|
|
240
|
+
if (row && String(row.inp.value) !== String(v.value)) { row.inp.value = v.value; row.out.textContent = v.value; }
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
sig = s; rows.clear();
|
|
245
|
+
const root = document.getElementById('groups'); root.innerHTML = '';
|
|
246
|
+
for (const g of groups) {
|
|
247
|
+
const div = document.createElement('div'); div.className = 'grp';
|
|
248
|
+
div.innerHTML = '<h2>' + g.name + '</h2>';
|
|
249
|
+
for (const [k, v] of Object.entries(g.spec)) {
|
|
250
|
+
const row = document.createElement('div'); row.className = 'row';
|
|
251
|
+
row.innerHTML = '<label>' + k + '</label><input type="range" min="' + (v.min ?? 0) + '" max="' + (v.max ?? 1) + '" step="' + (v.step ?? 0.01) + '" value="' + v.value + '"><output>' + v.value + '</output>';
|
|
252
|
+
const inp = row.querySelector('input'), out = row.querySelector('output');
|
|
253
|
+
rows.set(g.name + '.' + k, { inp, out });
|
|
254
|
+
inp.onpointerdown = () => { dragging = true; };
|
|
255
|
+
inp.oninput = () => { out.textContent = inp.value; bus('return window.__sindTune.set(' + JSON.stringify(g.name) + ',' + JSON.stringify(k) + ',' + inp.value + ')').catch(() => {}); };
|
|
256
|
+
div.appendChild(row);
|
|
257
|
+
}
|
|
258
|
+
if (g.file) { const f = document.createElement('div'); f.className = 'file'; f.textContent = 'persists to: ' + g.file; div.appendChild(f); }
|
|
259
|
+
root.appendChild(div);
|
|
260
|
+
}
|
|
261
|
+
} catch (e) { document.getElementById('status').textContent = 'game bus unreachable — is a game tab open? (' + e.message + ')'; }
|
|
262
|
+
}
|
|
263
|
+
refresh(); setInterval(refresh, 3000);
|
|
264
|
+
</script></body></html>`;
|
|
265
|
+
return {
|
|
266
|
+
name: 'sindicate-gui',
|
|
267
|
+
apply: 'serve',
|
|
268
|
+
configureServer(server) {
|
|
269
|
+
server.middlewares.use('/__sindicate', (req, res) => {
|
|
270
|
+
res.setHeader('content-type', 'text/html'); res.end(PAGE);
|
|
271
|
+
});
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
208
276
|
export function sindicateDevTools({
|
|
209
277
|
sourceWrites = [],
|
|
210
278
|
assetLists = [],
|
|
@@ -225,6 +293,7 @@ export function sindicateDevTools({
|
|
|
225
293
|
...assetLists.map(assetListPlugin),
|
|
226
294
|
...stores.map(binStorePlugin),
|
|
227
295
|
perfSinkPlugin(perfSink),
|
|
296
|
+
guiPlugin(),
|
|
228
297
|
busPlugin({ name: 'game', prefix: '/__game', originGuard: true }),
|
|
229
298
|
busPlugin({ name: 'editor', prefix: '/__editor' }),
|
|
230
299
|
editorShotsPlugin({ shotsDir: editorShotsDir, mapsDir }),
|