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.
@@ -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,7 +27,20 @@
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';
32
45
 
33
46
  // ── the two-shot, in numbers ────────────────────────────────────────────────────────────────────
@@ -102,19 +115,18 @@ export class Dialogue {
102
115
 
103
116
  open(npc) {
104
117
  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
- ] };
118
+ if (this.ui.dialogueOpen && this.npc === npc) {
119
+ if (this.tree?.nodes) this._renderNode(this.tree.start?.(this.game) ?? 'hello');
120
+ else this._render(pick(this.tree.openers));
121
+ return;
117
122
  }
123
+ const tree = treeFor(npc);
124
+ if (!tree) return; // an NPC with nothing to say opens nothing
125
+ this.npc = npc;
126
+ this.tree = tree;
127
+ // game hook: append/modify topics per-NPC at open time (campaign specials — a ride-along
128
+ // toggle, a mission-gated ask) without the game forking treeFor for one conversation
129
+ if (topicInjector) this.tree = topicInjector(npc, this.tree, this.ui.game) ?? this.tree;
118
130
  this._asked = new Set(); // topics already put to him THIS conversation — they drop off the
119
131
  // list once answered, so a chat PROGRESSES toward "So long"
120
132
  // instead of handing back the same three buttons forever.
@@ -129,7 +141,8 @@ export class Dialogue {
129
141
  this._mx = input?.mouse?.x ?? 0; this._my = input?.mouse?.y ?? 0;
130
142
  this._engaged = false;
131
143
  this.el.root.style.display = 'block';
132
- this._render(pick(this.tree.openers));
144
+ if (this.tree.nodes) this._renderNode(this.tree.start?.(this.game) ?? 'hello');
145
+ else this._render(pick(this.tree.openers));
133
146
  this.game.audio?.play?.('ui');
134
147
  }
135
148
 
@@ -156,7 +169,9 @@ export class Dialogue {
156
169
  // Called once per frame by the integrator while this panel is the active one. Mouse works without
157
170
  // it (native :hover + onclick); this drives keys/pad and consumes Escape.
158
171
  handleKey(input) {
159
- if (!this.ui.dialogueOpen || !input) return;
172
+ // !npc: the flag is up but the conversation isn't OURS (a game-scripted panel set
173
+ // ui.dialogueOpen itself) — its own nav drives it; consuming its Escape strands it
174
+ if (!this.ui.dialogueOpen || !input || !this.npc) return;
160
175
  const fresh = this._navFresh; this._navFresh = false;
161
176
  this._paintByeKey(); // swap the way-out keycap live if a pad was (un)plugged
162
177
 
@@ -289,6 +304,62 @@ export class Dialogue {
289
304
  this._tt.x = chosen.tx; this._tt.y = chosen.ty; this._tt.z = chosen.tz;
290
305
  }
291
306
 
307
+ // ── node-graph mode ───────────────────────────────────────────────────────────────────────────
308
+ _renderNode(id) {
309
+ if (!id) return this.close();
310
+ const node = this.tree?.nodes?.[id];
311
+ if (!node) return this.close();
312
+ this._nodeId = id;
313
+ let text = node.text, options = [...(node.options ?? [])];
314
+ if (nodeHook) {
315
+ const out = nodeHook(this.npc, id, { text, options }, this.game);
316
+ if (out) { text = out.text ?? text; options = out.options ?? options; }
317
+ }
318
+ this._renderCustom(text, options.map((o) => ({ label: o.label, onPick: () => this._pickNode(o) })), node.bye ?? this.tree.bye);
319
+ }
320
+
321
+ _pickNode(opt) {
322
+ const ctx = {
323
+ npc: this.npc, game: this.game, dialogue: this,
324
+ goto: (nid) => this._renderNode(nid),
325
+ close: () => this.close(),
326
+ render: (line, options, byeLabel) => this._renderCustom(line, options, byeLabel),
327
+ };
328
+ if (optionHandler?.(opt, ctx)) return;
329
+ if (opt.next !== undefined) { if (opt.next === null) return this.close(); return this._renderNode(opt.next); }
330
+ this.close();
331
+ }
332
+
333
+ // an explicit line + option list (node mode and game-driven sub-screens like boast offers)
334
+ _renderCustom(line, options, byeLabel) {
335
+ const opts = this.el.opts;
336
+ this.el.name.textContent = this.npc?.name || 'Stranger';
337
+ this.el.say.textContent = line || '';
338
+ opts.innerHTML = '';
339
+ let k = 0;
340
+ const add = (label, fn, cls) => {
341
+ const b = document.createElement('button');
342
+ b.className = 'dlg-opt' + (cls ? ' ' + cls : '');
343
+ b.textContent = label;
344
+ b.onmouseenter = () => { this._engaged = false; };
345
+ b.onclick = () => { this.game.audio?.play?.('ui'); fn(); };
346
+ opts.appendChild(b);
347
+ return b;
348
+ };
349
+ for (const o of options ?? []) {
350
+ k++;
351
+ const b = add(`${k}. ${o.label}`, () => o.onPick?.());
352
+ b.dataset.num = String(k);
353
+ }
354
+ const bye = add(byeLabel ?? 'So long.', () => this.close(), 'dlg-bye');
355
+ this._byeKey = document.createElement('span'); this._byeKey.className = 'dlg-byekey';
356
+ bye.insertBefore(this._byeKey, bye.firstChild);
357
+ this._byeMode = undefined; this._paintByeKey();
358
+ this._navFresh = true;
359
+ this._idx = 0;
360
+ [...opts.children].forEach((b) => b.classList.remove('navsel'));
361
+ }
362
+
292
363
  // ── rendering ─────────────────────────────────────────────────────────────────────────────────
293
364
  _opts() { return [...this.el.opts.querySelectorAll('button')].filter((b) => !b.disabled); }
294
365
 
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
 
@@ -7,6 +7,16 @@ 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
9
 
10
+ // PAUSE CONTENT is GAME data (setPauseContent in engineSetup):
11
+ // extraItems(game, {resume,close}) → [{label, action}] — game pages (CHARACTER/JOURNAL…)
12
+ // listed after RESUME; typically resume() then open the game's own screen
13
+ // settingsRows({game, slider, toggleRow}) → rows appended to the Settings panel
14
+ // onQualityChange(name, game) — live-apply hook (default: takes effect next load)
15
+ // saves — adapter replacing the game.missions default: { hasSave, list, saveSlots,
16
+ // loadSlots, moneyLabel, defaultTitle, onSave/onLoad/onDelete(slot, game) }
17
+ let PAUSE = { saves: null, extraItems: null, settingsRows: null, onQualityChange: null };
18
+ export function setPauseContent(c = {}) { PAUSE = { ...PAUSE, ...c }; }
19
+
10
20
  const GOLD = '#e8c87a';
11
21
  const TXT = '#ecdfc2';
12
22
  const MUT = '#b6a577';
@@ -66,9 +76,9 @@ export function showPauseMenu(game, opts = {}) {
66
76
  const wrap = rowWrap(); wrap.appendChild(rowLabel('Graphics'));
67
77
  const btns = el('div', 'display:flex;gap:8px;flex:1;'); const names = ['low', 'medium', 'high'];
68
78
  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); });
79
+ 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
80
  paintQ(); wrap.appendChild(btns);
71
- const step = (d) => { const i = clamp(names.indexOf(getQualityName()) + d, 0, 2); setQualityName(names[i]); paintQ(); };
81
+ const step = (d) => { const i = clamp(names.indexOf(getQualityName()) + d, 0, 2); setQualityName(names[i]); PAUSE.onQualityChange?.(names[i], game); paintQ(); };
72
82
  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
83
  };
74
84
  const slider = (label, get, set) => {
@@ -100,13 +110,14 @@ export function showPauseMenu(game, opts = {}) {
100
110
  const p = framedPanel(500); titleBar(p, 'SETTINGS');
101
111
  const rows = [
102
112
  gfxRow(),
103
- slider('Music', () => +(localStorage.getItem('av_volMusic') ?? 0.55), (v) => game.music?.setMusicVolume?.(v)),
113
+ slider('Music', () => +(localStorage.getItem('av_volMusic') ?? 0.55), (v) => { try { localStorage.setItem('av_volMusic', v); } catch (e) {} game.music?.setMusicVolume?.(v); }),
104
114
  slider('Ambience', () => +(localStorage.getItem('av_volAmb') ?? 0.6), (v) => { try { localStorage.setItem('av_volAmb', v); } catch (e) {} game.ambience?.setVolume?.(v); }),
105
115
  slider('Effects', () => +(localStorage.getItem('av_volSfx') ?? 0.85), (v) => game.audio?.setSfxVolume?.(v)),
106
116
  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
117
  toggleRow('Invert Y', () => !!game.player.invertY, (on) => { game.player.invertY = on; try { localStorage.setItem('av_invertY', on ? '1' : '0'); } catch (e) {} }),
108
118
  toggleRow('Fullscreen', () => !!document.fullscreenElement, (on) => { try { if (on && !document.fullscreenElement) document.documentElement.requestFullscreen?.(); else if (!on && document.fullscreenElement) document.exitFullscreen?.(); } catch (e) {} }),
109
119
  ];
120
+ rows.push(...(PAUSE.settingsRows?.({ game, slider, toggleRow }) ?? []));
110
121
  rows.forEach((r) => p.appendChild(r.el));
111
122
  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
123
  const f = footer(p);
@@ -119,6 +130,14 @@ export function showPauseMenu(game, opts = {}) {
119
130
  // while it's up so the two don't both drive the pad.
120
131
  let sub = false;
121
132
  const doSave = () => {
133
+ if (PAUSE.saves) {
134
+ const S = PAUSE.saves;
135
+ sub = true;
136
+ showSaveLoad({ mode: 'save', slots: S.saveSlots, moneyLabel: S.moneyLabel, defaultTitle: S.defaultTitle,
137
+ list: S.list, onSave: (slot) => S.onSave?.(slot, game), onDelete: (slot) => S.onDelete?.(slot, game),
138
+ onClose: () => { sub = false; } });
139
+ return;
140
+ }
122
141
  if (!game.missions) { toast('Nothing to save yet.'); return; }
123
142
  sub = true;
124
143
  showSaveLoad({ mode: 'save', list: () => game.missions.listSaves(),
@@ -127,6 +146,15 @@ export function showPauseMenu(game, opts = {}) {
127
146
  onClose: () => { sub = false; } });
128
147
  };
129
148
  const doLoad = () => {
149
+ if (PAUSE.saves) {
150
+ const S = PAUSE.saves;
151
+ if (S.hasSave && !S.hasSave()) { toast('No saved game found.'); return; }
152
+ sub = true;
153
+ showSaveLoad({ mode: 'load', slots: S.loadSlots ?? S.saveSlots, moneyLabel: S.moneyLabel, defaultTitle: S.defaultTitle,
154
+ list: S.list, onLoad: (slot) => { sub = false; resume(); S.onLoad?.(slot, game); },
155
+ onDelete: (slot) => S.onDelete?.(slot, game), onClose: () => { sub = false; } });
156
+ return;
157
+ }
130
158
  if (!game.missions?.hasSave?.()) { toast('No saved game found.'); return; }
131
159
  sub = true;
132
160
  showSaveLoad({ mode: 'load', list: () => game.missions.listSaves(),
@@ -139,6 +167,7 @@ export function showPauseMenu(game, opts = {}) {
139
167
  titleBar(mainPanel, 'PAUSED');
140
168
  const items = [
141
169
  menuItem('RESUME', resume),
170
+ ...(PAUSE.extraItems?.(game, { resume, close }) ?? []).map((x) => menuItem(x.label, x.action)),
142
171
  menuItem('SAVE GAME', () => { doSave(); }),
143
172
  menuItem('LOAD GAME', () => { doLoad(); }),
144
173
  menuItem('SETTINGS', openSettings),
@@ -52,8 +52,8 @@ export function showSaveLoad(opts = {}) {
52
52
  const img = rec.thumb ? `<img src="${rec.thumb}" style="width:100%;height:100%;object-fit:cover">`
53
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
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>`;
55
+ + `<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>`
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; ${opts.moneyLabel ?? 'Purse $'}${rec.money ?? 0}${rec.objective ? '<br>' + rec.objective : ''}</div></div>`;
57
57
  };
58
58
 
59
59
  const rebuild = () => {
@@ -67,7 +67,7 @@ export function showSaveLoad(opts = {}) {
67
67
  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
68
  const thumb = rec && rec.thumb ? `<img src="${rec.thumb}" style="width:74px;height:44px;object-fit:cover;border-radius:3px;flex:none">`
69
69
  : `<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');
70
+ const title = rec ? (rec.name || rec.title || opts.defaultTitle || 'Saved game') : (save ? 'New Save' : 'Empty');
71
71
  const subtitle = rec ? `${rec.auto ? 'AUTO' : 'SLOT ' + id} &nbsp;·&nbsp; ${fmtDate(rec.savedAt)}` : (save ? 'Empty slot' : '');
72
72
  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
73
  const ctrl = {
@@ -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 ────────────────────────────────────────────────
@@ -0,0 +1,138 @@
1
+ // WEAPON WHEEL — the GTA-style radial select ring, as an engine UI component.
2
+ // Hold the bound key/button → the ring fades in (optionally slowing the world),
3
+ // point with the mouse or the right stick → release to commit the highlighted slot.
4
+ //
5
+ // const wheel = new WeaponWheel({
6
+ // engine, // for the optional slow-mo contributor
7
+ // items: () => game.player.wheelItems(), // [{ id, label, icon?, disabled? }] — icon
8
+ // // is an <img> URL or inline SVG/emoji text
9
+ // onSelect: (id) => game.player.equip(id),
10
+ // holdKey: 'Tab', // Input key code that holds the wheel open
11
+ // slowMo: 0.25, // world timeScale while open (1 = off)
12
+ // theme: { ring: 'rgba(20,20,32,0.85)', hi: '#e8c66a', text: '#f5ead0' },
13
+ // });
14
+ // // once per frame, AFTER input.pollGamepad, BEFORE the game reads the hold key:
15
+ // wheel.update(input, dt);
16
+ //
17
+ // The wheel consumes its key while open (input.consume) so releasing never leaks the
18
+ // key into gameplay — the same edge discipline as the engine's panel nav. Right-stick
19
+ // pointing uses input.stickLook, so the pad needs no extra bindings.
20
+ import { tune } from '../core/tune.js';
21
+
22
+ export class WeaponWheel {
23
+ constructor({ engine = null, items, onSelect, onTap = null, holdKey = 'Tab', holdDelay = 0.18, slowMo = 1, theme = {} } = {}) {
24
+ this.engine = engine;
25
+ this.itemsFn = items;
26
+ this.onSelect = onSelect;
27
+ this.onTap = onTap; // released under holdDelay — the game's quick-switch
28
+ this.holdKey = holdKey;
29
+ this.holdDelay = holdDelay; // TAP under this stays the game's (quick-switch); HOLD opens the wheel
30
+ this._heldT = 0;
31
+ this.slowMo = slowMo;
32
+ this.theme = { ring: 'rgba(18,18,30,0.86)', hi: '#e8c66a', text: '#f5ead0', dim: '#8d8875', ...theme };
33
+ this.open = false;
34
+ this._sel = -1;
35
+ this._items = [];
36
+ this._el = null;
37
+ // slow-mo joins the engine's timeScale chain like hit-stop does — composed, not imposed
38
+ if (engine && slowMo < 1) engine.addTimeScale(() => (this.open ? this.slowMo : 1));
39
+ // live-tunable in the dev GUI (/__sindicate/)
40
+ tune.group('weaponWheel', {
41
+ slowMo: { value: this.slowMo, min: 0.05, max: 1, step: 0.05 },
42
+ holdDelay: { value: this.holdDelay, min: 0, max: 0.6, step: 0.02 },
43
+ }, { onChange: (k, v) => { this[k] = v; } });
44
+ }
45
+
46
+ _build() {
47
+ const el = this._el = document.createElement('div');
48
+ el.style.cssText = 'position:fixed;inset:0;display:none;place-items:center;z-index:900;pointer-events:none;';
49
+ const ring = this._ring = document.createElement('div');
50
+ ring.style.cssText = 'position:relative;width:340px;height:340px;';
51
+ el.appendChild(ring);
52
+ document.body.appendChild(el);
53
+ }
54
+
55
+ _layout() {
56
+ const items = this._items;
57
+ this._ring.innerHTML = '';
58
+ const R = 128, n = Math.max(1, items.length);
59
+ const t = this.theme;
60
+ items.forEach((it, i) => {
61
+ const a = (i / n) * Math.PI * 2 - Math.PI / 2; // slot 0 at the top, clockwise
62
+ const x = 170 + Math.cos(a) * R, y = 170 + Math.sin(a) * R;
63
+ const d = document.createElement('div');
64
+ const hi = i === this._sel && !it.disabled;
65
+ d.style.cssText = `position:absolute;left:${x}px;top:${y}px;transform:translate(-50%,-50%) scale(${hi ? 1.18 : 1});`
66
+ + `width:84px;height:84px;border-radius:50%;display:grid;place-items:center;text-align:center;`
67
+ + `background:${t.ring};border:2px solid ${hi ? t.hi : 'rgba(255,255,255,0.14)'};`
68
+ + `box-shadow:${hi ? `0 0 18px ${t.hi}55` : '0 2px 10px rgba(0,0,0,0.45)'};`
69
+ + `transition:transform 80ms,border-color 80ms;opacity:${it.disabled ? 0.35 : 1};`;
70
+ const icon = document.createElement('div');
71
+ if (it.icon && /^(\/|https?:|data:)/.test(it.icon)) {
72
+ icon.innerHTML = `<img src="${it.icon}" style="width:44px;height:44px;object-fit:contain" alt="">`;
73
+ } else icon.textContent = it.icon ?? '•';
74
+ icon.style.cssText = 'font-size:30px;line-height:1;';
75
+ const lab = document.createElement('div');
76
+ lab.textContent = it.label ?? it.id;
77
+ lab.style.cssText = `font:600 10px/1.1 sans-serif;letter-spacing:0.06em;text-transform:uppercase;`
78
+ + `color:${hi ? t.text : t.dim};margin-top:3px;max-width:76px;`;
79
+ d.append(icon, lab);
80
+ this._ring.appendChild(d);
81
+ });
82
+ // centre label = current highlight
83
+ const c = document.createElement('div');
84
+ const sel = items[this._sel];
85
+ c.textContent = sel && !sel.disabled ? (sel.label ?? sel.id) : '';
86
+ c.style.cssText = `position:absolute;left:170px;top:170px;transform:translate(-50%,-50%);`
87
+ + `font:700 15px/1 sans-serif;letter-spacing:0.08em;color:${t.text};text-transform:uppercase;`
88
+ + `text-shadow:0 2px 8px rgba(0,0,0,0.8);`;
89
+ this._ring.appendChild(c);
90
+ }
91
+
92
+ // Angle → slot. Mouse: vector from screen centre. Pad: the right stick directly.
93
+ _pick(input) {
94
+ let vx = 0, vy = 0;
95
+ const sl = input.stickLook;
96
+ if (Math.hypot(sl.x, sl.y) > 0.35) { vx = sl.x; vy = sl.y; }
97
+ else {
98
+ vx = input.mouse.x - window.innerWidth / 2;
99
+ vy = input.mouse.y - window.innerHeight / 2;
100
+ }
101
+ if (Math.hypot(vx, vy) < 24) return this._sel; // dead centre: keep the last highlight
102
+ const n = Math.max(1, this._items.length);
103
+ const a = Math.atan2(vy, vx) + Math.PI / 2; // slot 0 at the top
104
+ let idx = Math.round((a / (Math.PI * 2)) * n) % n;
105
+ if (idx < 0) idx += n;
106
+ return idx;
107
+ }
108
+
109
+ update(input, dt = 0.016) {
110
+ const held = input.down(this.holdKey);
111
+ if (held) {
112
+ input.consume(this.holdKey); // the wheel owns this key entirely (press edge included)
113
+ this._heldT += dt;
114
+ if (!this.open && this._heldT >= this.holdDelay) {
115
+ this._items = (this.itemsFn?.() ?? []).slice();
116
+ if (!this._items.length) return;
117
+ this.open = true;
118
+ if (!this._el) this._build();
119
+ this._sel = Math.max(0, this._items.findIndex((i) => i.current));
120
+ this._el.style.display = 'grid';
121
+ this._layout();
122
+ } else if (this.open) {
123
+ const pick = this._pick(input);
124
+ if (pick !== this._sel) { this._sel = pick; this._layout(); }
125
+ }
126
+ } else {
127
+ if (this.open) {
128
+ this.open = false;
129
+ this._el.style.display = 'none';
130
+ const it = this._items[this._sel];
131
+ if (it && !it.disabled) this.onSelect?.(it.id);
132
+ } else if (this._heldT > 0) {
133
+ this.onTap?.(); // a TAP: quick-switch, exactly the old key behaviour
134
+ }
135
+ this._heldT = 0;
136
+ }
137
+ }
138
+ }