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/deathScreen.js
CHANGED
|
@@ -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;
|
package/src/ui/dialogue.js
CHANGED
|
@@ -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) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
17
|
+
// The prologue script is GAME data: registerIntroBeats([{ img, text }, ...])
|
|
18
18
|
let BEATS = [];
|
|
19
19
|
export function registerIntroBeats(beats) { BEATS = beats ?? []; }
|
|
20
20
|
|
package/src/ui/pauseMenu.js
CHANGED
|
@@ -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),
|
package/src/ui/saveLoadScreen.js
CHANGED
|
@@ -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 || '
|
|
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)} · Purse
|
|
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)} · ${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 || '
|
|
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} · ${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 = {
|
package/src/ui/titleScreen.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(${
|
|
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);',
|
|
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);',
|
|
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(${
|
|
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 {
|
|
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="
|
|
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
|
|
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;',
|
|
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
|
+
}
|