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.
- package/docs/plans/world-splits-recon.json +387 -0
- package/package.json +9 -2
- package/src/core/assets.js +15 -0
- package/src/core/engine.js +9 -0
- package/src/core/tune.js +50 -0
- package/src/systems/missions.js +2 -2
- package/src/systems/nav.js +4 -1
- package/src/systems/shatter.js +2 -1
- package/src/ui/cartograph.js +6 -0
- package/src/ui/deathScreen.js +3 -3
- package/src/ui/dialogue.js +87 -15
- package/src/ui/intro.js +1 -1
- package/src/ui/pauseMenu.js +50 -14
- package/src/ui/saveLoadScreen.js +16 -12
- package/src/ui/theme.js +34 -0
- package/src/ui/titleScreen.js +28 -34
- package/src/ui/weaponWheel.js +138 -0
- package/src/ui/worldmap.js +119 -161
- package/src/world/bridgeSplice.js +176 -0
- package/src/world/geo.js +53 -0
- package/src/world/railFormation.js +185 -0
- package/tools/devPlugin.js +336 -0
- package/tools/registry.json +45 -0
- package/tools/sindicate-cli.mjs +71 -0
package/src/ui/cartograph.js
CHANGED
|
@@ -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
|
}
|
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,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) {
|
|
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
|
-
] };
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
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
|
|
49
|
-
const
|
|
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),
|
package/src/ui/saveLoadScreen.js
CHANGED
|
@@ -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 =
|
|
13
|
-
const TXT =
|
|
14
|
-
const MUT =
|
|
15
|
-
const FONT =
|
|
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
|
|
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="
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
+
|
|
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)} · ${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 || '
|
|
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} · ${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 = {
|
package/src/ui/theme.js
ADDED
|
@@ -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); }
|
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 ────────────────────────────────────────────────
|