sindicate 0.11.0 → 0.13.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.
@@ -26,7 +26,11 @@ let heightAt = () => 0, roadDistance = () => 1e9, fbm = () => 0;
26
26
  let ROADS = [], RIVERS = [], LAKES = [], TOWN = null, RAIL_PATHS = [];
27
27
  let OUTPOSTS = [], CAMPFIRE_SPOTS = [], COACH_STOP = null, STOPS = [], DUSTWATER = [];
28
28
  export let WORLD_SIZE = 4320;
29
+ // Registration order must NOT be load-bearing: every setter invalidates the derived
30
+ // memos (shopSpots/allPOIs/allBuildings), so data landing after a first read still lands.
31
+ function invalidateChart() { _shops = _pois = _blds = null; }
29
32
  export function setChartWorld(w = {}) {
33
+ invalidateChart();
30
34
  if (w.heightAt) heightAt = w.heightAt;
31
35
  if (w.roadDistance) roadDistance = w.roadDistance;
32
36
  if (w.fbm) fbm = w.fbm;
@@ -47,6 +51,7 @@ export function setChartWorld(w = {}) {
47
51
  let AREAS = [], RAILWAY_AREAS = [], COUNTY_DEFS = [];
48
52
  let shopIdFor = () => null, SHOPS = {}, isMerchant = () => false;
49
53
  export function setChartGazetteer(g = {}) {
54
+ invalidateChart();
50
55
  if (g.areas) AREAS = g.areas;
51
56
  if (g.railwayAreas) RAILWAY_AREAS = g.railwayAreas;
52
57
  if (g.countyDefs) COUNTY_DEFS = g.countyDefs;
@@ -59,6 +64,7 @@ export function setChartGazetteer(g = {}) {
59
64
  // worked only game-side; an engine file cannot glob a game's data directory).
60
65
  let SETTLEMENTS = [], SETTLEMENT_AREAS = {};
61
66
  export function setChartSettlements(s = {}) {
67
+ invalidateChart();
62
68
  if (s.settlements) SETTLEMENTS = s.settlements;
63
69
  if (s.settlementAreas) SETTLEMENT_AREAS = s.settlementAreas;
64
70
  }
@@ -57,7 +57,7 @@ export function showDeathScreen(opts = {}) {
57
57
  } catch (e) { /* tainted — leave as drawn */ }
58
58
  };
59
59
  img.onerror = () => { emblem.style.display = 'none'; };
60
- img.src = `${UI}/banner.png`;
60
+ img.src = opts.emblem ?? `${UI}/banner.png`;
61
61
  }
62
62
  band.appendChild(emblem);
63
63
 
@@ -67,7 +67,7 @@ export function showDeathScreen(opts = {}) {
67
67
 
68
68
  content.appendChild(el('div',
69
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'));
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);', opts.title ?? 'YOU DIED'));
71
71
  const dv = el('div', 'margin:14px 0 15px;line-height:0;'); dv.innerHTML = DIVIDER_SVG; content.appendChild(dv);
72
72
 
73
73
  // options — small, tight, centred: [icon] label
@@ -89,7 +89,7 @@ export function showDeathScreen(opts = {}) {
89
89
  rows.push({ btn, fn, el: row, badge });
90
90
  };
91
91
  if (opts.hasSave) addRow('LOAD LAST SAVE', 'cross', opts.onLoad);
92
- addRow('WAKE AT THE WELL', opts.hasSave ? 'square' : 'cross', opts.onRevive);
92
+ addRow(opts.reviveLabel ?? 'WAKE AT THE WELL', opts.hasSave ? 'square' : 'cross', opts.onRevive);
93
93
  addRow('QUIT TO TITLE', 'circle', opts.onQuit);
94
94
 
95
95
  let raf = 0, closed = false, first = true, prev = {}, lastMode = false;
@@ -27,8 +27,22 @@
27
27
  // back for free, without an integrator edit.
28
28
  // dialogue trees are GAME data: setDialogueTrees(treeForFn) — (npc) => tree
29
29
  let treeFor = () => null;
30
+ let topicInjector = null;
31
+ export function setDialogueTopicInjector(fn) { topicInjector = fn; }
30
32
  export function setDialogueTrees(fn) { treeFor = fn ?? (() => null); }
33
+ // NODE-GRAPH MODE (fable-shaped trees): a tree with { nodes, start(game) } renders per-node
34
+ // instead of per-topic. The option side-effect VOCABULARY is game data:
35
+ // setDialogueOptionHandler((opt, ctx) => bool) — ctx = { npc, game, dialogue, goto(id),
36
+ // close(), render(line, [{label,onPick}], byeLabel) }; return true = handled, false =
37
+ // the engine falls through to opt.next (null → close)
38
+ // setDialogueNodeHook((npc, id, {text,options}, game) => {text,options}|null) — flavour a
39
+ // node before it renders (mood lines, romance extras)
40
+ let optionHandler = null;
41
+ let nodeHook = null;
42
+ export function setDialogueOptionHandler(fn) { optionHandler = fn; }
43
+ export function setDialogueNodeHook(fn) { nodeHook = fn; }
31
44
  import { padMode, buttonCanvas } from './menuHints.js';
45
+ import { THEME } from './theme.js';
32
46
 
33
47
  // ── the two-shot, in numbers ────────────────────────────────────────────────────────────────────
34
48
  // The lens stands in FRONT of the NPC (he has already turned to face the player, so his front faces
@@ -102,19 +116,18 @@ export class Dialogue {
102
116
 
103
117
  open(npc) {
104
118
  if (!npc) return;
105
- if (this.ui.dialogueOpen && this.npc === npc) { this._render(pick(this.tree.openers)); return; }
106
- this.npc = npc;
107
- this.tree = treeFor(npc);
108
- // A HOUSED GUNHAND (COPPERHEAD crew at the Halloway place) always offers the ride-along toggle,
109
- // appended non-destructively over WHATEVER tree he's using — the merchant counter, a mission
110
- // stage, his own lines — so taking August along never costs you his shop.
111
- if (npc._atHouse && npc.def?.gunhand) {
112
- const g = this.ui.game;
113
- this.tree = { ...this.tree, topics: [...(this.tree.topics ?? []), npc._follow
114
- ? { q: `Head on home.`, a: [`Holler when it turns interesting again.`], action: (n) => g._toggleRideAlong?.(n) }
115
- : { q: `Ride with me.`, a: [`(A check of the load, a tug of the hat, and he falls in.)`], action: (n) => g._toggleRideAlong?.(n) },
116
- ] };
119
+ if (this.ui.dialogueOpen && this.npc === npc) {
120
+ if (this.tree?.nodes) this._renderNode(this.tree.start?.(this.game) ?? 'hello');
121
+ else this._render(pick(this.tree.openers));
122
+ return;
117
123
  }
124
+ const tree = treeFor(npc);
125
+ if (!tree) return; // an NPC with nothing to say opens nothing
126
+ this.npc = npc;
127
+ this.tree = tree;
128
+ // game hook: append/modify topics per-NPC at open time (campaign specials — a ride-along
129
+ // toggle, a mission-gated ask) without the game forking treeFor for one conversation
130
+ if (topicInjector) this.tree = topicInjector(npc, this.tree, this.ui.game) ?? this.tree;
118
131
  this._asked = new Set(); // topics already put to him THIS conversation — they drop off the
119
132
  // list once answered, so a chat PROGRESSES toward "So long"
120
133
  // instead of handing back the same three buttons forever.
@@ -129,7 +142,8 @@ export class Dialogue {
129
142
  this._mx = input?.mouse?.x ?? 0; this._my = input?.mouse?.y ?? 0;
130
143
  this._engaged = false;
131
144
  this.el.root.style.display = 'block';
132
- this._render(pick(this.tree.openers));
145
+ if (this.tree.nodes) this._renderNode(this.tree.start?.(this.game) ?? 'hello');
146
+ else this._render(pick(this.tree.openers));
133
147
  this.game.audio?.play?.('ui');
134
148
  }
135
149
 
@@ -156,7 +170,9 @@ export class Dialogue {
156
170
  // Called once per frame by the integrator while this panel is the active one. Mouse works without
157
171
  // it (native :hover + onclick); this drives keys/pad and consumes Escape.
158
172
  handleKey(input) {
159
- if (!this.ui.dialogueOpen || !input) return;
173
+ // !npc: the flag is up but the conversation isn't OURS (a game-scripted panel set
174
+ // ui.dialogueOpen itself) — its own nav drives it; consuming its Escape strands it
175
+ if (!this.ui.dialogueOpen || !input || !this.npc) return;
160
176
  const fresh = this._navFresh; this._navFresh = false;
161
177
  this._paintByeKey(); // swap the way-out keycap live if a pad was (un)plugged
162
178
 
@@ -289,6 +305,62 @@ export class Dialogue {
289
305
  this._tt.x = chosen.tx; this._tt.y = chosen.ty; this._tt.z = chosen.tz;
290
306
  }
291
307
 
308
+ // ── node-graph mode ───────────────────────────────────────────────────────────────────────────
309
+ _renderNode(id) {
310
+ if (!id) return this.close();
311
+ const node = this.tree?.nodes?.[id];
312
+ if (!node) return this.close();
313
+ this._nodeId = id;
314
+ let text = node.text, options = [...(node.options ?? [])];
315
+ if (nodeHook) {
316
+ const out = nodeHook(this.npc, id, { text, options }, this.game);
317
+ if (out) { text = out.text ?? text; options = out.options ?? options; }
318
+ }
319
+ this._renderCustom(text, options.map((o) => ({ label: o.label, onPick: () => this._pickNode(o) })), node.bye ?? this.tree.bye);
320
+ }
321
+
322
+ _pickNode(opt) {
323
+ const ctx = {
324
+ npc: this.npc, game: this.game, dialogue: this,
325
+ goto: (nid) => this._renderNode(nid),
326
+ close: () => this.close(),
327
+ render: (line, options, byeLabel) => this._renderCustom(line, options, byeLabel),
328
+ };
329
+ if (optionHandler?.(opt, ctx)) return;
330
+ if (opt.next !== undefined) { if (opt.next === null) return this.close(); return this._renderNode(opt.next); }
331
+ this.close();
332
+ }
333
+
334
+ // an explicit line + option list (node mode and game-driven sub-screens like boast offers)
335
+ _renderCustom(line, options, byeLabel) {
336
+ const opts = this.el.opts;
337
+ this.el.name.textContent = this.npc?.name || 'Stranger';
338
+ this.el.say.textContent = line || '';
339
+ opts.innerHTML = '';
340
+ let k = 0;
341
+ const add = (label, fn, cls) => {
342
+ const b = document.createElement('button');
343
+ b.className = 'dlg-opt' + (cls ? ' ' + cls : '');
344
+ b.textContent = label;
345
+ b.onmouseenter = () => { this._engaged = false; };
346
+ b.onclick = () => { this.game.audio?.play?.('ui'); fn(); };
347
+ opts.appendChild(b);
348
+ return b;
349
+ };
350
+ for (const o of options ?? []) {
351
+ k++;
352
+ const b = add(`${k}. ${o.label}`, () => o.onPick?.());
353
+ b.dataset.num = String(k);
354
+ }
355
+ const bye = add(byeLabel ?? 'So long.', () => this.close(), 'dlg-bye');
356
+ this._byeKey = document.createElement('span'); this._byeKey.className = 'dlg-byekey';
357
+ bye.insertBefore(this._byeKey, bye.firstChild);
358
+ this._byeMode = undefined; this._paintByeKey();
359
+ this._navFresh = true;
360
+ this._idx = 0;
361
+ [...opts.children].forEach((b) => b.classList.remove('navsel'));
362
+ }
363
+
292
364
  // ── rendering ─────────────────────────────────────────────────────────────────────────────────
293
365
  _opts() { return [...this.el.opts.querySelectorAll('button')].filter((b) => !b.disabled); }
294
366
 
@@ -428,7 +500,7 @@ export class Dialogue {
428
500
  #dlg .dlg-byekey { display:inline-flex; align-items:center; vertical-align:middle; margin-right:9px; }
429
501
  #dlg .dlg-byekey.kbd { width:18px; height:18px; justify-content:center; line-height:17px; font-size:12px;
430
502
  font-weight:700; color:#f4e3b0; border:1px solid #8a6a3a; border-radius:4px; background:rgba(40,28,14,0.92); }
431
- `;
503
+ ` + (THEME.dialogueCss || '');
432
504
  document.head.appendChild(css);
433
505
  }
434
506
  }
package/src/ui/intro.js CHANGED
@@ -14,7 +14,7 @@ const FADE = 1300; // ms crossfade between beats
14
14
 
15
15
  // The prologue. Each beat is one image + one line. Sets up the county, the crime, the lawless
16
16
  // vacuum, and drops the player in — straight into Deputy Toomey's first job.
17
- // The prologue script is GAME data: registerIntroBeats([{ img, line }, ...])
17
+ // The prologue script is GAME data: registerIntroBeats([{ img, text }, ...])
18
18
  let BEATS = [];
19
19
  export function registerIntroBeats(beats) { BEATS = beats ?? []; }
20
20
 
@@ -6,11 +6,22 @@
6
6
  import { getQualityName, setQualityName, PRESETS } from '../core/quality.js';
7
7
  import { renderHints, refreshHints, padMode } from './menuHints.js';
8
8
  import { showSaveLoad } from './saveLoadScreen.js';
9
+ import { THEME } from './theme.js';
9
10
 
10
- const GOLD = '#e8c87a';
11
- const TXT = '#ecdfc2';
12
- const MUT = '#b6a577';
13
- const FONT = "Copperplate,'Copperplate Gothic Bold','Palatino','Book Antiqua',Georgia,serif";
11
+ // PAUSE CONTENT is GAME data (setPauseContent in engineSetup):
12
+ // extraItems(game, {resume,close}) [{label, action}] — game pages (CHARACTER/JOURNAL…)
13
+ // listed after RESUME; typically resume() then open the game's own screen
14
+ // settingsRows({game, slider, toggleRow}) rows appended to the Settings panel
15
+ // onQualityChange(name, game) — live-apply hook (default: takes effect next load)
16
+ // saves — adapter replacing the game.missions default: { hasSave, list, saveSlots,
17
+ // loadSlots, moneyLabel, defaultTitle, onSave/onLoad/onDelete(slot, game) }
18
+ let PAUSE = { saves: null, extraItems: null, settingsRows: null, onQualityChange: null };
19
+ export function setPauseContent(c = {}) { PAUSE = { ...PAUSE, ...c }; }
20
+
21
+ const GOLD = THEME.gold;
22
+ const TXT = THEME.text;
23
+ const MUT = THEME.mut;
24
+ const FONT = THEME.font;
14
25
  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
15
26
  const CORNER_SVG = `<svg viewBox="0 0 44 44" width="30" height="30" fill="none" stroke="${GOLD}" stroke-linecap="round"><path d="M3 24 V3 H24" stroke-width="2"/><path d="M9 19 V9 H19" stroke-width="1" opacity="0.6"/><path d="M3 3 h9 l-9 9 z" fill="${GOLD}" stroke="none"/></svg>`;
16
27
  const DIVIDER_SVG = `<svg viewBox="0 0 220 14" width="200" height="12" fill="none" stroke="${GOLD}" stroke-width="1.4" stroke-linecap="round"><line x1="8" y1="7" x2="94" y2="7"/><line x1="126" y1="7" x2="212" y2="7"/><circle cx="8" cy="7" r="1.6" fill="${GOLD}"/><circle cx="212" cy="7" r="1.6" fill="${GOLD}"/><path d="M110 1 l7 6 l-7 6 l-7 -6 z" fill="${GOLD}"/></svg>`;
@@ -22,11 +33,15 @@ function el(tag, css, text) {
22
33
  return e;
23
34
  }
24
35
  function framedPanel(w = 420) {
25
- const p = el('div', `position:relative;width:${w}px;max-width:90vw;padding:28px 36px 24px;color:${TXT};border:1px solid rgba(232,200,122,0.55);border-radius:3px;`
26
- + 'background:linear-gradient(157deg,#241b11 0%,#160f09 58%,#0d0805 100%);box-shadow:0 26px 74px rgba(0,0,0,0.86);');
27
- p.appendChild(el('div', 'position:absolute;inset:6px;border:1px solid rgba(232,200,122,0.26);border-radius:2px;pointer-events:none;'));
28
- const c = (pos, rot) => { const d = el('div', `position:absolute;${pos}width:30px;height:30px;pointer-events:none;transform:rotate(${rot}deg);`); d.innerHTML = CORNER_SVG; p.appendChild(d); };
29
- c('left:3px;top:3px;', 0); c('right:3px;top:3px;', 90); c('right:3px;bottom:3px;', 180); c('left:3px;bottom:3px;', 270);
36
+ const ground = THEME.panelCss
37
+ ?? `color:${TXT};border:1px solid rgba(232,200,122,0.55);border-radius:3px;`
38
+ + 'background:linear-gradient(157deg,#241b11 0%,#160f09 58%,#0d0805 100%);box-shadow:0 26px 74px rgba(0,0,0,0.86);';
39
+ const p = el('div', `position:relative;width:${w}px;max-width:90vw;padding:28px 36px 24px;${ground}`);
40
+ if (THEME.filigree) {
41
+ p.appendChild(el('div', 'position:absolute;inset:6px;border:1px solid rgba(232,200,122,0.26);border-radius:2px;pointer-events:none;'));
42
+ const c = (pos, rot) => { const d = el('div', `position:absolute;${pos}width:30px;height:30px;pointer-events:none;transform:rotate(${rot}deg);`); d.innerHTML = CORNER_SVG; p.appendChild(d); };
43
+ c('left:3px;top:3px;', 0); c('right:3px;top:3px;', 90); c('right:3px;bottom:3px;', 180); c('left:3px;bottom:3px;', 270);
44
+ }
30
45
  return p;
31
46
  }
32
47
 
@@ -45,8 +60,10 @@ export function showPauseMenu(game, opts = {}) {
45
60
  const close = () => { if (closed) return; closed = true; cancelAnimationFrame(raf); window.removeEventListener('keydown', onKey); root.remove(); };
46
61
  const resume = () => { close(); opts.onResume?.(); };
47
62
 
48
- const hl = (b, on) => { b.style.background = on ? GOLD : 'rgba(232,200,122,0.06)'; b.style.color = on ? '#1a1207' : TXT; b.style.borderColor = on ? GOLD : 'rgba(232,200,122,0.45)'; };
49
- const BTN = `padding:9px 20px;border:1px solid rgba(232,200,122,0.45);border-radius:5px;cursor:pointer;font-family:inherit;letter-spacing:2px;font-size:15px;background:rgba(232,200,122,0.06);color:${TXT};transition:background .12s,color .12s;`;
63
+ const ACT = THEME.btnActive ?? { bg: GOLD, color: '#1a1207', border: GOLD };
64
+ const OFF = THEME.btnCss ? { bg: '', color: '', border: '' } : { bg: 'rgba(232,200,122,0.06)', color: TXT, border: 'rgba(232,200,122,0.45)' };
65
+ const hl = (b, on) => { b.style.background = on ? ACT.bg : OFF.bg; b.style.color = on ? ACT.color : OFF.color; b.style.borderColor = on ? ACT.border : OFF.border; };
66
+ const BTN = THEME.btnCss ?? `padding:9px 20px;border:1px solid rgba(232,200,122,0.45);border-radius:5px;cursor:pointer;font-family:inherit;letter-spacing:2px;font-size:15px;background:rgba(232,200,122,0.06);color:${TXT};transition:background .12s,color .12s;`;
50
67
  const rowWrap = () => el('div', 'display:flex;align-items:center;gap:14px;margin:12px 0;padding:5px 6px;border-radius:5px;');
51
68
  const rowLabel = (t) => el('div', `width:110px;text-align:right;color:${MUT};font-size:14px;letter-spacing:1px;text-transform:uppercase;`, t);
52
69
 
@@ -66,9 +83,9 @@ export function showPauseMenu(game, opts = {}) {
66
83
  const wrap = rowWrap(); wrap.appendChild(rowLabel('Graphics'));
67
84
  const btns = el('div', 'display:flex;gap:8px;flex:1;'); const names = ['low', 'medium', 'high'];
68
85
  const paintQ = () => [...btns.children].forEach((b, i) => hl(b, getQualityName() === names[i]));
69
- names.forEach((nm) => { const b = el('button', `flex:1;${BTN}text-align:center;`, PRESETS[nm].label); b.onclick = () => { setQualityName(nm); paintQ(); }; btns.appendChild(b); });
86
+ names.forEach((nm) => { const b = el('button', `flex:1;${BTN}text-align:center;`, PRESETS[nm].label); b.onclick = () => { setQualityName(nm); PAUSE.onQualityChange?.(nm, game); paintQ(); }; btns.appendChild(b); });
70
87
  paintQ(); wrap.appendChild(btns);
71
- const step = (d) => { const i = clamp(names.indexOf(getQualityName()) + d, 0, 2); setQualityName(names[i]); paintQ(); };
88
+ const step = (d) => { const i = clamp(names.indexOf(getQualityName()) + d, 0, 2); setQualityName(names[i]); PAUSE.onQualityChange?.(names[i], game); paintQ(); };
72
89
  return { el: wrap, focus: () => (wrap.style.background = 'rgba(232,200,122,0.1)'), blur: () => (wrap.style.background = 'none'), left: () => step(-1), right: () => step(1) };
73
90
  };
74
91
  const slider = (label, get, set) => {
@@ -100,13 +117,14 @@ export function showPauseMenu(game, opts = {}) {
100
117
  const p = framedPanel(500); titleBar(p, 'SETTINGS');
101
118
  const rows = [
102
119
  gfxRow(),
103
- slider('Music', () => +(localStorage.getItem('av_volMusic') ?? 0.55), (v) => game.music?.setMusicVolume?.(v)),
120
+ slider('Music', () => +(localStorage.getItem('av_volMusic') ?? 0.55), (v) => { try { localStorage.setItem('av_volMusic', v); } catch (e) {} game.music?.setMusicVolume?.(v); }),
104
121
  slider('Ambience', () => +(localStorage.getItem('av_volAmb') ?? 0.6), (v) => { try { localStorage.setItem('av_volAmb', v); } catch (e) {} game.ambience?.setVolume?.(v); }),
105
122
  slider('Effects', () => +(localStorage.getItem('av_volSfx') ?? 0.85), (v) => game.audio?.setSfxVolume?.(v)),
106
123
  slider('Look Speed', () => clamp((game.player.camSensitivity - 0.25) / 2.75, 0, 1), (v) => { game.player.camSensitivity = +(0.25 + v * 2.75).toFixed(2); try { localStorage.setItem('av_camSens', game.player.camSensitivity); } catch (e) {} }),
107
124
  toggleRow('Invert Y', () => !!game.player.invertY, (on) => { game.player.invertY = on; try { localStorage.setItem('av_invertY', on ? '1' : '0'); } catch (e) {} }),
108
125
  toggleRow('Fullscreen', () => !!document.fullscreenElement, (on) => { try { if (on && !document.fullscreenElement) document.documentElement.requestFullscreen?.(); else if (!on && document.fullscreenElement) document.exitFullscreen?.(); } catch (e) {} }),
109
126
  ];
127
+ rows.push(...(PAUSE.settingsRows?.({ game, slider, toggleRow }) ?? []));
110
128
  rows.forEach((r) => p.appendChild(r.el));
111
129
  p.appendChild(el('div', `font-size:12px;color:${MUT};opacity:0.7;margin-top:10px;text-align:center;`, 'Graphics takes full effect on the next load.'));
112
130
  const f = footer(p);
@@ -119,6 +137,14 @@ export function showPauseMenu(game, opts = {}) {
119
137
  // while it's up so the two don't both drive the pad.
120
138
  let sub = false;
121
139
  const doSave = () => {
140
+ if (PAUSE.saves) {
141
+ const S = PAUSE.saves;
142
+ sub = true;
143
+ showSaveLoad({ mode: 'save', slots: S.saveSlots, moneyLabel: S.moneyLabel, defaultTitle: S.defaultTitle,
144
+ list: S.list, onSave: (slot) => S.onSave?.(slot, game), onDelete: (slot) => S.onDelete?.(slot, game),
145
+ onClose: () => { sub = false; } });
146
+ return;
147
+ }
122
148
  if (!game.missions) { toast('Nothing to save yet.'); return; }
123
149
  sub = true;
124
150
  showSaveLoad({ mode: 'save', list: () => game.missions.listSaves(),
@@ -127,6 +153,15 @@ export function showPauseMenu(game, opts = {}) {
127
153
  onClose: () => { sub = false; } });
128
154
  };
129
155
  const doLoad = () => {
156
+ if (PAUSE.saves) {
157
+ const S = PAUSE.saves;
158
+ if (S.hasSave && !S.hasSave()) { toast('No saved game found.'); return; }
159
+ sub = true;
160
+ showSaveLoad({ mode: 'load', slots: S.loadSlots ?? S.saveSlots, moneyLabel: S.moneyLabel, defaultTitle: S.defaultTitle,
161
+ list: S.list, onLoad: (slot) => { sub = false; resume(); S.onLoad?.(slot, game); },
162
+ onDelete: (slot) => S.onDelete?.(slot, game), onClose: () => { sub = false; } });
163
+ return;
164
+ }
130
165
  if (!game.missions?.hasSave?.()) { toast('No saved game found.'); return; }
131
166
  sub = true;
132
167
  showSaveLoad({ mode: 'load', list: () => game.missions.listSaves(),
@@ -139,6 +174,7 @@ export function showPauseMenu(game, opts = {}) {
139
174
  titleBar(mainPanel, 'PAUSED');
140
175
  const items = [
141
176
  menuItem('RESUME', resume),
177
+ ...(PAUSE.extraItems?.(game, { resume, close }) ?? []).map((x) => menuItem(x.label, x.action)),
142
178
  menuItem('SAVE GAME', () => { doSave(); }),
143
179
  menuItem('LOAD GAME', () => { doLoad(); }),
144
180
  menuItem('SETTINGS', openSettings),
@@ -7,12 +7,13 @@
7
7
  // opts: { mode:'load'|'save', slots:[…ids], list:()=>saves, onLoad(slot), onSave(slot,name),
8
8
  // onDelete(slot), onClose() }. Returns { close }.
9
9
  import { renderHints, refreshHints, padMode } from './menuHints.js';
10
+ import { THEME } from './theme.js';
10
11
 
11
12
  const UI = '/assets/ui/menu';
12
- const GOLD = '#e8c87a';
13
- const TXT = '#ecdfc2';
14
- const MUT = '#b6a577';
15
- const FONT = "Copperplate,'Copperplate Gothic Bold','Palatino','Book Antiqua',Georgia,serif";
13
+ const GOLD = THEME.gold;
14
+ const TXT = THEME.text;
15
+ const MUT = THEME.mut;
16
+ const FONT = THEME.font;
16
17
 
17
18
  function el(tag, css, text) {
18
19
  const e = document.createElement(tag);
@@ -27,7 +28,7 @@ export function showSaveLoad(opts = {}) {
27
28
  const save = opts.mode === 'save';
28
29
  const slotIds = opts.slots || ['1', '2', '3', '4', '5', '6'];
29
30
 
30
- const root = el('div', `position:fixed;inset:0;z-index:130;display:flex;flex-direction:column;font-family:${FONT};background:#0a0705;`);
31
+ const root = el('div', `position:fixed;inset:0;z-index:130;display:flex;flex-direction:column;font-family:${FONT};background:${THEME.screenBg};`);
31
32
  root.appendChild(el('div', `position:absolute;inset:0;background:#000 url(${UI}/title_bg.jpg) center/cover;opacity:0.08;`));
32
33
  root.appendChild(el('div', 'position:absolute;inset:0;background:linear-gradient(180deg,rgba(10,7,5,0.86),rgba(10,7,5,0.96));'));
33
34
  const head = el('div', 'position:relative;padding:44px 60px 16px;border-bottom:1px solid rgba(232,200,122,0.22);flex:none;');
@@ -49,17 +50,20 @@ export function showSaveLoad(opts = {}) {
49
50
 
50
51
  const showDetail = (rec) => {
51
52
  if (!rec) { detail.innerHTML = `<div style="flex:1;display:flex;align-items:center;justify-content:center;color:${MUT};letter-spacing:2px">${save ? 'NEW SAVE' : 'EMPTY'}</div>`; return; }
52
- const img = rec.thumb ? `<img src="${rec.thumb}" style="width:100%;height:100%;object-fit:cover">`
53
- : `<div style="flex:1;display:flex;align-items:center;justify-content:center;color:${MUT};font-size:12px;letter-spacing:3px">NO PREVIEW</div>`;
54
- detail.innerHTML = `<div style="flex:1;min-height:190px;background:linear-gradient(135deg,#1a130b,#0d0906);display:flex;overflow:hidden">${img}</div>`
55
- + `<div style="padding:20px 24px"><div style="color:${GOLD};font-size:22px;letter-spacing:2px;font-weight:700">${rec.name || rec.title || 'Dustwater'}</div>`
56
- + `<div style="color:${MUT};font-size:13px;margin-top:8px;line-height:1.9">${rec.auto ? 'Autosave · ' : ''}${fmtDate(rec.savedAt)}<br>Played ${fmtTime(rec.playtime)} &nbsp;·&nbsp; Purse $${rec.money ?? 0}${rec.objective ? '<br>' + rec.objective : ''}</div></div>`;
53
+ const img = rec.thumb ? `<img src="${rec.thumb}" style="width:100%;height:100%;object-fit:cover;display:block">`
54
+ : `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:${MUT};font-size:12px;letter-spacing:3px">NO PREVIEW</div>`;
55
+ // a FRAMED 16:9 plate, not a full-pane stretch — a small thumb blown to the pane reads as mush
56
+ detail.innerHTML = `<div style="padding:22px 24px 0"><div style="aspect-ratio:16/9;max-height:340px;margin:0 auto;`
57
+ + `background:linear-gradient(135deg,#1a130b,#0d0906);border:1px solid rgba(232,200,122,0.28);border-radius:3px;`
58
+ + `box-shadow:0 10px 30px rgba(0,0,0,0.5), inset 0 0 40px rgba(0,0,0,0.35);overflow:hidden">${img}</div></div>`
59
+ + `<div style="padding:20px 24px"><div style="color:${GOLD};font-size:22px;letter-spacing:2px;font-weight:700">${rec.name || rec.title || opts.defaultTitle || 'Saved game'}</div>`
60
+ + `<div style="color:${MUT};font-size:13px;margin-top:8px;line-height:1.9">${rec.auto ? 'Autosave · ' : ''}${fmtDate(rec.savedAt)}<br>Played ${fmtTime(rec.playtime)} &nbsp;·&nbsp; ${opts.moneyLabel ?? 'Purse $'}${rec.money ?? 0}${rec.objective ? '<br>' + rec.objective : ''}</div></div>`;
57
61
  };
58
62
 
59
63
  const rebuild = () => {
60
64
  const saves = opts.list ? opts.list() : [];
61
65
  const bySlot = {}; saves.forEach((s) => { bySlot[String(s.slot)] = s; });
62
- const ids = save ? slotIds : ['auto', ...slotIds].filter((id) => bySlot[id]); // load: only filled; save: all manual slots
66
+ const ids = save ? slotIds : [...new Set(['auto', ...slotIds])].filter((id) => bySlot[id]); // load: only filled (auto deduped — a game may list it in its own slots); save: all manual slots
63
67
  listEl.innerHTML = ''; items = [];
64
68
  if (!ids.length) { listEl.appendChild(el('div', `color:${MUT};opacity:0.8;padding:12px;`, 'No saved games yet.')); showDetail(null); return; }
65
69
  ids.forEach((id) => {
@@ -67,7 +71,7 @@ export function showSaveLoad(opts = {}) {
67
71
  const card = el('button', `display:flex;align-items:center;gap:12px;width:100%;text-align:left;padding:10px 12px;border:1px solid rgba(232,200,122,0.3);border-radius:6px;background:rgba(232,200,122,0.05);cursor:pointer;font-family:inherit;color:${TXT};transition:background .12s;`);
68
72
  const thumb = rec && rec.thumb ? `<img src="${rec.thumb}" style="width:74px;height:44px;object-fit:cover;border-radius:3px;flex:none">`
69
73
  : `<div style="width:74px;height:44px;border-radius:3px;background:rgba(232,200,122,0.08);flex:none"></div>`;
70
- const title = rec ? (rec.name || rec.title || 'Dustwater') : (save ? 'New Save' : 'Empty');
74
+ const title = rec ? (rec.name || rec.title || opts.defaultTitle || 'Saved game') : (save ? 'New Save' : 'Empty');
71
75
  const subtitle = rec ? `${rec.auto ? 'AUTO' : 'SLOT ' + id} &nbsp;·&nbsp; ${fmtDate(rec.savedAt)}` : (save ? 'Empty slot' : '');
72
76
  card.innerHTML = `${thumb}<div style="min-width:0"><div style="font-size:15px;letter-spacing:1px;font-weight:700;color:${GOLD};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${title}</div><div style="font-size:11px;color:${MUT};margin-top:3px">${subtitle}</div></div>`;
73
77
  const ctrl = {
@@ -0,0 +1,34 @@
1
+ // THE UI THEME — the rule, codified after the fable title-menu incident: the ENGINE owns
2
+ // the FUNCTIONALITY of a screen; the GAME owns its LOOK. Every value here is declarative
3
+ // (a game ships them as src/game/uiTheme.json and registers it in engineSetup with
4
+ // setUiTheme(theme)), so a re-dress never touches engine code.
5
+ //
6
+ // The defaults are the neutral dark leather-and-gold dress the engine screens shipped with
7
+ // (Dustwater's palette — western's own uiTheme.json restates it explicitly). The engine UI
8
+ // modules are lazily imported AFTER engineSetup runs, so module-load reads of THEME see the
9
+ // game's registration.
10
+ //
11
+ // font — the screens' font stack
12
+ // gold/text/mut — accent, body text, muted labels
13
+ // panelCss — the framed panel's ground (popup confirms, the pause panel). Replaces the
14
+ // default gradient+border block; the engine still positions and sizes it.
15
+ // filigree — draw the gold corner flourishes + inner rule on panels (default true; a
16
+ // parchment skin usually wants them off)
17
+ // btnCss — the standard button plate (settings rows, confirms, save-slot actions)
18
+ // btnActive — { bg, color, border } for the highlighted/selected state
19
+ // screenBg — full-screen browser background (save/load screen ground)
20
+ // dialogueCss — appended after the dialogue panel's own CSS; override any #dlg selector
21
+ export const THEME = {
22
+ font: "Copperplate,'Copperplate Gothic Bold','Palatino','Book Antiqua',Georgia,serif",
23
+ gold: '#e8c87a',
24
+ text: '#ecdfc2',
25
+ mut: '#b6a577',
26
+ panelCss: null, // null = the engine's dark leather gradient
27
+ filigree: true,
28
+ btnCss: null, // null = the engine's gold-outline plate
29
+ btnActive: null, // null = { bg: gold, color: '#1a1207', border: gold }
30
+ screenBg: '#0a0705',
31
+ dialogueCss: '',
32
+ };
33
+
34
+ export function setUiTheme(t = {}) { Object.assign(THEME, t); }
@@ -14,7 +14,20 @@ import { getQualityName, setQualityName, PRESETS } from '../core/quality.js';
14
14
  import { renderHints, refreshHints, padMode } from './menuHints.js';
15
15
  import { showSaveLoad } from './saveLoadScreen.js';
16
16
 
17
- const UI = '/assets/ui/menu';
17
+ // TITLE CONTENT is GAME data — brand, credits, difficulty roster, and the save-browser
18
+ // adapter all register here (setTitleContent in the game's engineSetup). The engine ships
19
+ // neutral defaults; difficulties: null skips the picker (NEW resolves { choice:'new',
20
+ // difficulty:null }); saves.list/remove adapt whatever save model the game uses.
21
+ let TITLE = {
22
+ brand: 'SINDICATE', subtitle: '', version: '',
23
+ assetDir: '/assets/ui/menu', bg: 'title_bg.jpg', difficultyArtDir: '/assets/ui/difficulty',
24
+ credits: [],
25
+ difficulties: null,
26
+ newGameConfirm: 'Start a new game? Your autosave is replaced — your manual saves are kept.',
27
+ moneyLabel: undefined, slotIds: undefined,
28
+ saves: { list: () => [], remove: null },
29
+ };
30
+ export function setTitleContent(c = {}) { TITLE = { ...TITLE, ...c, saves: { ...TITLE.saves, ...(c.saves ?? {}) } }; }
18
31
  const GOLD = '#e8c87a';
19
32
  const TXT = '#ecdfc2'; // light body text on the dark panels
20
33
  const MUT = '#b6a577'; // muted labels / captions
@@ -38,16 +51,7 @@ function el(tag, css, text) {
38
51
  }
39
52
 
40
53
  function readSaves() {
41
- const out = [];
42
- try {
43
- for (let i = 0; i < localStorage.length; i++) {
44
- const k = localStorage.key(i);
45
- if (!k || !k.startsWith('dustwater_save_')) continue;
46
- const s = JSON.parse(localStorage.getItem(k));
47
- if (s && s.active !== undefined) out.push({ key: k, id: k.slice('dustwater_save_'.length), ...s, slot: s.slot ?? k.slice('dustwater_save_'.length) });
48
- }
49
- } catch (e) { /* private mode / bad json */ }
50
- return out.sort((a, b) => (b.savedAt ?? 0) - (a.savedAt ?? 0));
54
+ try { return TITLE.saves.list() ?? []; } catch (e) { return []; }
51
55
  }
52
56
  const fmtDate = (ts) => { try { return ts ? new Date(ts).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : '—'; } catch (e) { return '—'; } };
53
57
 
@@ -82,7 +86,7 @@ function framedPanel(w = 560) {
82
86
  // (framedPanel) are kept ONLY for the little confirms, which should be modal.
83
87
  function fullView(titleText) {
84
88
  const root = el('div', 'position:absolute;inset:0;z-index:9;display:flex;flex-direction:column;background:#0a0705;');
85
- root.appendChild(el('div', `position:absolute;inset:0;background:#000 url(${UI}/title_bg.jpg) center/cover;opacity:0.09;`));
89
+ root.appendChild(el('div', `position:absolute;inset:0;background:#000 url(${TITLE.assetDir}/${TITLE.bg}) center/cover;opacity:0.09;`));
86
90
  root.appendChild(el('div', 'position:absolute;inset:0;background:linear-gradient(180deg,rgba(10,7,5,0.84),rgba(10,7,5,0.96));'));
87
91
  const head = el('div', 'position:relative;padding:46px 64px 18px;border-bottom:1px solid rgba(232,200,122,0.22);flex:none;');
88
92
  head.appendChild(el('div', `color:${GOLD};font-size:34px;letter-spacing:9px;font-weight:700;text-shadow:0 3px 12px rgba(0,0,0,0.7);`, titleText));
@@ -109,15 +113,15 @@ export function showTitleMenu(host, { music } = {}) {
109
113
  const top = el('div', 'text-align:center;margin-top:6vh;z-index:2;');
110
114
  top.appendChild(el('div',
111
115
  `color:${GOLD};font-size:min(9vw,88px);letter-spacing:min(2.2vw,18px);font-weight:700;line-height:1;`
112
- + 'text-shadow:0 0 44px rgba(232,200,122,0.34),0 4px 14px rgba(0,0,0,0.95);', 'DUSTWATER'));
116
+ + 'text-shadow:0 0 44px rgba(232,200,122,0.34),0 4px 14px rgba(0,0,0,0.95);', TITLE.brand));
113
117
  top.appendChild(el('div',
114
118
  'color:#9a8558;font-style:italic;font-size:15px;letter-spacing:7px;margin-top:8px;'
115
- + 'text-shadow:0 2px 6px rgba(0,0,0,0.9);', 'A WESTERN TALE'));
119
+ + 'text-shadow:0 2px 6px rgba(0,0,0,0.9);', TITLE.subtitle));
116
120
  root.appendChild(top);
117
121
 
118
122
  // ── letterboxed vista band (middle)
119
123
  const band = el('div',
120
- `position:relative;width:100%;height:38vh;background:#000 url(${UI}/title_bg.jpg) center 44%/cover no-repeat;`);
124
+ `position:relative;width:100%;height:38vh;background:#000 url(${TITLE.assetDir}/${TITLE.bg}) center 44%/cover no-repeat;`);
121
125
  band.appendChild(el('div',
122
126
  'position:absolute;inset:0;background:linear-gradient(to bottom,#080604 0%,rgba(8,6,4,0) 24%,rgba(8,6,4,0) 72%,#080604 100%);'));
123
127
  root.appendChild(band);
@@ -223,15 +227,7 @@ export function showTitleMenu(host, { music } = {}) {
223
227
  const openCredits = () => {
224
228
  const view = fullView('CREDITS');
225
229
  const body = el('div', `flex:1;min-height:0;overflow:auto;max-width:640px;margin:0 auto;text-align:center;color:${TXT};line-height:1.8;font-size:16px;letter-spacing:1px;`);
226
- body.innerHTML = [
227
- ['DUSTWATER', 'A Western Tale'],
228
- ['Design · Code · Direction', 'Nick Thompson'],
229
- ['Built with', 'three.js · WebGPU'],
230
- ['Characters & World', 'Synty Studios (POLYGON)'],
231
- ['Music', 'Original score — the Dustwater cuts'],
232
- ['Assistance', 'Claude Code'],
233
- ['', 'Thanks for riding out.'],
234
- ].map(([h, s]) => `<div style="margin:18px 0"><div style="color:${GOLD};font-size:12px;letter-spacing:3px;text-transform:uppercase;opacity:.9">${h}</div><div>${s}</div></div>`).join('');
230
+ body.innerHTML = TITLE.credits.map(([h, s]) => `<div style="margin:18px 0"><div style="color:${GOLD};font-size:12px;letter-spacing:3px;text-transform:uppercase;opacity:.9">${h}</div><div>${s}</div></div>`).join('');
235
231
  view.body.appendChild(body);
236
232
  renderHints(view.foot, [['scroll', 'Scroll'], ['back', 'Back']]);
237
233
  root.appendChild(view.root);
@@ -242,9 +238,10 @@ export function showTitleMenu(host, { music } = {}) {
242
238
  subOpen = true; // hand input to the shared Save/Load screen while it's up
243
239
  showSaveLoad({
244
240
  mode: 'load',
241
+ slots: TITLE.slotIds, moneyLabel: TITLE.moneyLabel, defaultTitle: TITLE.brand,
245
242
  list: () => readSaves(),
246
243
  onLoad: (slot) => done({ choice: 'load', slot }), // boot() applies it once the world builds
247
- onDelete: (slot) => { try { localStorage.removeItem('dustwater_save_' + slot); } catch (e) {} },
244
+ onDelete: (slot) => { try { TITLE.saves.remove?.(slot); } catch (e) {} },
248
245
  onClose: () => { subOpen = false; },
249
246
  });
250
247
  };
@@ -264,16 +261,12 @@ export function showTitleMenu(host, { music } = {}) {
264
261
  const openDifficulty = () => {
265
262
  const view = fullView('SELECT DIFFICULTY');
266
263
  const row = el('div', 'flex:1;min-height:0;display:flex;gap:24px;justify-content:center;align-items:center;');
267
- const DIFFS = [
268
- { id: 'easy', name: 'GREENHORN', tag: 'EASY', desc: 'For the story and the scenery. Lead finds you slowly and lands soft.' },
269
- { id: 'normal', name: 'GUNSLINGER', tag: 'NORMAL', desc: 'The county as it was meant to be rode — a fair fight, and a fatal one.' },
270
- { id: 'hard', name: 'DEAD MAN WALKING', tag: 'HARD', desc: 'The desert wants you buried. Every round bites, and the law rides hard.' },
271
- ];
264
+ const DIFFS = TITLE.difficulties;
272
265
  const openT = performance.now(); // settle window — the click that OPENED this must not fall through onto a card
273
266
  const items = DIFFS.map((d) => {
274
267
  const card = el('button', 'width:300px;max-width:30vw;height:392px;padding:24px 26px;border:1px solid rgba(232,200,122,0.32);border-radius:8px;'
275
268
  + 'background:rgba(232,200,122,0.05);cursor:pointer;font-family:inherit;color:' + TXT + ';display:flex;flex-direction:column;align-items:center;text-align:center;transition:background .12s,border-color .12s,transform .12s;');
276
- card.innerHTML = `<img src="/assets/ui/difficulty/${d.id}.png" style="width:132px;height:132px;object-fit:contain;mix-blend-mode:screen;pointer-events:none" onerror="this.style.display='none'">`
269
+ card.innerHTML = `<img src="${TITLE.difficultyArtDir}/${d.id}.png" style="width:132px;height:132px;object-fit:contain;mix-blend-mode:screen;pointer-events:none" onerror="this.style.display='none'">`
277
270
  + `<div style="color:${MUT};font-size:12px;letter-spacing:4px;margin-top:6px">${d.tag}</div>`
278
271
  + `<div style="color:${GOLD};font-size:23px;letter-spacing:2px;font-weight:700;margin:12px 0 15px">${d.name}</div>`
279
272
  + `<div style="color:${TXT};opacity:0.85;font-size:15px;line-height:1.6;font-family:'Book Antiqua',Georgia,serif">${d.desc}</div>`;
@@ -290,7 +283,8 @@ export function showTitleMenu(host, { music } = {}) {
290
283
  pushScreen({ items, sel: 1, horizontal: true, foot: view.foot, onPop: () => view.root.remove() });
291
284
  };
292
285
 
293
- const newGame = () => { readSaves().length ? confirm('NEW GAME', 'Start a new game? Your autosave is replaced — your manual saves are kept.', openDifficulty) : openDifficulty(); };
286
+ const startNew = () => (TITLE.difficulties ? openDifficulty() : done({ choice: 'new', difficulty: null }));
287
+ const newGame = () => { readSaves().length && TITLE.newGameConfirm ? confirm('NEW GAME', TITLE.newGameConfirm, startNew) : startNew(); };
294
288
 
295
289
  // ── the main menu: a HORIZONTAL row (RDR2-style). Continue/Load only when a save exists. ───────
296
290
  const hasSave = readSaves().length > 0;
@@ -299,11 +293,11 @@ export function showTitleMenu(host, { music } = {}) {
299
293
  mainItems.push(item('NEW GAME', newGame));
300
294
  if (hasSave) mainItems.push(item('LOAD', openLoad));
301
295
  mainItems.push(item('SETTINGS', openSettings));
302
- mainItems.push(item('CREDITS', openCredits));
296
+ if (TITLE.credits.length) mainItems.push(item('CREDITS', openCredits));
303
297
  const rowEl = el('div', 'display:flex;gap:34px;justify-content:center;align-items:center;flex-wrap:wrap;');
304
298
  mainItems.forEach((it) => rowEl.appendChild(it.el));
305
299
  col.appendChild(rowEl);
306
- col.appendChild(el('div', 'color:#6a5a3a;font-size:11px;letter-spacing:3px;margin-top:22px;', 'v0.1'));
300
+ if (TITLE.version) col.appendChild(el('div', 'color:#6a5a3a;font-size:11px;letter-spacing:3px;margin-top:22px;', TITLE.version));
307
301
  pushScreen({ items: mainItems, sel: 0, horizontal: true });
308
302
 
309
303
  // ── input: pad + keyboard over the whole stack ────────────────────────────────────────────────