sindicate 0.4.0 → 0.6.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.
@@ -0,0 +1,359 @@
1
+ // TITLE MENU — dark, RDR2-flavoured (not a copy). A near-black field, the DUSTWATER brand up top,
2
+ // a LETTERBOXED vista band across the middle (the town at sundown, faded into black top & bottom),
3
+ // and an understated gold menu below. Pure DOM, shown before any 3D exists.
4
+ //
5
+ // The MENU ITEMS are the full set (Nick): Continue · New Game · Load Game · Settings · Credits ·
6
+ // Quit — Continue/Load appear only when a save exists. It resolves a CHOICE that boot() acts on
7
+ // ('continue'/'load' apply the save after the world builds; 'new' starts fresh; Settings/Credits are
8
+ // handled here and never resolve). One nav controller drives it on pad, keyboard AND mouse.
9
+ //
10
+ // PANELS (Settings, Credits, Load, confirms) wear a DARK gold-FRAMED dress modelled on the Synty
11
+ // "Fantasy Menus" kit Nick pointed at — recoloured to Dustwater's leather-and-gold: a dark ground, a
12
+ // double gold rule, filigree corners and a diamond divider under the title. No flat parchment.
13
+ import { getQualityName, setQualityName, PRESETS } from '../core/quality.js';
14
+ import { renderHints, refreshHints, padMode } from './menuHints.js';
15
+ import { showSaveLoad } from './saveLoadScreen.js';
16
+
17
+ const UI = '/assets/ui/menu';
18
+ const GOLD = '#e8c87a';
19
+ const TXT = '#ecdfc2'; // light body text on the dark panels
20
+ const MUT = '#b6a577'; // muted labels / captions
21
+ const FONT = "Copperplate,'Copperplate Gothic Bold','Palatino','Book Antiqua',Georgia,serif";
22
+ const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
23
+
24
+ // filigree corner (drawn for TOP-LEFT; rotated 90/180/270 for the others) and a diamond divider.
25
+ const CORNER_SVG = `<svg viewBox="0 0 44 44" width="34" height="34" fill="none" stroke="${GOLD}" stroke-linecap="round">
26
+ <path d="M3 24 V3 H24" stroke-width="2"/><path d="M9 19 V9 H19" stroke-width="1" opacity="0.6"/>
27
+ <path d="M3 3 h9 l-9 9 z" fill="${GOLD}" stroke="none"/></svg>`;
28
+ const DIVIDER_SVG = `<svg viewBox="0 0 220 14" width="210" height="13" fill="none" stroke="${GOLD}" stroke-width="1.4" stroke-linecap="round">
29
+ <line x1="8" y1="7" x2="94" y2="7"/><line x1="126" y1="7" x2="212" y2="7"/>
30
+ <circle cx="8" cy="7" r="1.6" fill="${GOLD}"/><circle cx="212" cy="7" r="1.6" fill="${GOLD}"/>
31
+ <path d="M110 1 l7 6 l-7 6 l-7 -6 z" fill="${GOLD}" stroke="${GOLD}"/></svg>`;
32
+
33
+ function el(tag, css, text) {
34
+ const e = document.createElement(tag);
35
+ if (css) e.style.cssText = css;
36
+ if (text != null) e.textContent = text;
37
+ return e;
38
+ }
39
+
40
+ 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));
51
+ }
52
+ const fmtDate = (ts) => { try { return ts ? new Date(ts).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : '—'; } catch (e) { return '—'; } };
53
+
54
+ // A horizontal menu entry (RDR2-style row): gold serif that lifts, brightens and underlines when
55
+ // focused — no letter-spacing change, so the row never reflows as the highlight moves along it.
56
+ function menuBtn(label) {
57
+ const b = el('button',
58
+ 'background:none;border:none;border-bottom:2px solid transparent;cursor:pointer;'
59
+ + `color:${GOLD};font-family:inherit;font-size:21px;letter-spacing:4px;font-weight:700;white-space:nowrap;`
60
+ + 'padding:7px 4px 6px;text-shadow:0 2px 10px rgba(0,0,0,0.9);transition:color .15s,text-shadow .15s,transform .15s,border-color .15s;',
61
+ label);
62
+ b.__on = () => { b.style.color = '#fff3d2'; b.style.transform = 'translateY(-2px)'; b.style.borderBottomColor = GOLD; b.style.textShadow = '0 0 22px rgba(232,200,122,0.7),0 2px 10px rgba(0,0,0,0.9)'; };
63
+ b.__off = () => { b.style.color = GOLD; b.style.transform = 'none'; b.style.borderBottomColor = 'transparent'; b.style.textShadow = '0 2px 10px rgba(0,0,0,0.9)'; };
64
+ b.__off();
65
+ return b;
66
+ }
67
+
68
+ // A dark, gold-framed panel: leather ground, double gold rule, filigree corners.
69
+ function framedPanel(w = 560) {
70
+ const p = el('div', `position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:${w}px;max-width:90vw;z-index:8;`
71
+ + `padding:30px 40px 28px;color:${TXT};border:1px solid rgba(232,200,122,0.55);border-radius:3px;`
72
+ + 'background:linear-gradient(157deg,#241b11 0%,#160f09 58%,#0d0805 100%);'
73
+ + 'box-shadow:0 26px 74px rgba(0,0,0,0.86);');
74
+ p.appendChild(el('div', 'position:absolute;inset:6px;border:1px solid rgba(232,200,122,0.26);border-radius:2px;pointer-events:none;')); // inner rule
75
+ const corner = (pos, rot) => { const c = el('div', `position:absolute;${pos}width:34px;height:34px;pointer-events:none;transform:rotate(${rot}deg);`); c.innerHTML = CORNER_SVG; p.appendChild(c); };
76
+ corner('left:3px;top:3px;', 0); corner('right:3px;top:3px;', 90); corner('right:3px;bottom:3px;', 180); corner('left:3px;bottom:3px;', 270);
77
+ return p;
78
+ }
79
+
80
+ // A FULL-SCREEN view (Load / Settings / Credits) — the kit's layout: a gold title top-left over a
81
+ // hairline rule, the content beneath, and a footer hint bar. The town vista sits faded behind. Modals
82
+ // (framedPanel) are kept ONLY for the little confirms, which should be modal.
83
+ function fullView(titleText) {
84
+ 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;`));
86
+ 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
+ const head = el('div', 'position:relative;padding:46px 64px 18px;border-bottom:1px solid rgba(232,200,122,0.22);flex:none;');
88
+ 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));
89
+ root.appendChild(head);
90
+ const body = el('div', 'position:relative;flex:1;min-height:0;padding:30px 64px;display:flex;flex-direction:column;');
91
+ root.appendChild(body);
92
+ const foot = el('div', `position:relative;padding:15px 64px;border-top:1px solid rgba(232,200,122,0.22);color:${MUT};font-size:13px;letter-spacing:2px;flex:none;`);
93
+ root.appendChild(foot);
94
+ return { root, body, foot };
95
+ }
96
+
97
+ export function showTitleMenu(host, { music } = {}) {
98
+ return new Promise((resolve) => {
99
+ host.style.background = '#080604';
100
+ host.style.paddingTop = '0';
101
+ const lh = host.querySelector('h1'); if (lh) lh.style.display = 'none';
102
+ const ls = host.querySelector('.sub'); if (ls) ls.style.display = 'none';
103
+
104
+ const root = el('div',
105
+ `position:absolute;inset:0;font-family:${FONT};display:flex;flex-direction:column;align-items:center;justify-content:space-between;`);
106
+ host.appendChild(root);
107
+
108
+ // ── brand (top)
109
+ const top = el('div', 'text-align:center;margin-top:6vh;z-index:2;');
110
+ top.appendChild(el('div',
111
+ `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'));
113
+ top.appendChild(el('div',
114
+ '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'));
116
+ root.appendChild(top);
117
+
118
+ // ── letterboxed vista band (middle)
119
+ const band = el('div',
120
+ `position:relative;width:100%;height:38vh;background:#000 url(${UI}/title_bg.jpg) center 44%/cover no-repeat;`);
121
+ band.appendChild(el('div',
122
+ '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
+ root.appendChild(band);
124
+
125
+ // ── menu (bottom)
126
+ const col = el('div', 'text-align:center;margin-bottom:6vh;z-index:2;');
127
+ root.appendChild(col);
128
+
129
+ // ── nav: a stack of SCREENS. Each = {items:[ctrl], sel, horizontal?}.
130
+ const stack = [];
131
+ let subOpen = false; // true while the shared Save/Load screen owns input
132
+ const cur = () => stack[stack.length - 1];
133
+ const paint = () => { const s = cur(); if (s) s.items.forEach((c, i) => (i === s.sel ? c.focus?.() : c.blur?.())); };
134
+ const pushScreen = (s) => {
135
+ s.sel = s.sel ?? 0;
136
+ s.items.forEach((c, i) => c.el && c.el.addEventListener('mouseenter', () => { if (cur() === s) { s.sel = i; paint(); } }));
137
+ stack.push(s); paint();
138
+ };
139
+ const popScreen = () => { const s = stack.pop(); s?.onPop?.(); paint(); };
140
+
141
+ let raf = 0;
142
+ const teardown = () => { cancelAnimationFrame(raf); window.removeEventListener('keydown', onKey); };
143
+ const done = (choice) => { teardown(); root.remove(); resolve(choice); };
144
+
145
+ // ── panel furniture ──────────────────────────────────────────────────────────────────────────
146
+ const titleBar = (p, t) => {
147
+ p.appendChild(el('div', `font-size:23px;letter-spacing:5px;font-weight:700;text-align:center;color:${GOLD};text-shadow:0 2px 8px rgba(0,0,0,0.7);`, t));
148
+ const d = el('div', 'display:flex;justify-content:center;margin:9px 0 18px;'); d.innerHTML = DIVIDER_SVG; p.appendChild(d);
149
+ };
150
+ const addClose = (p) => { const c = el('button', `position:absolute;top:9px;right:14px;border:none;background:none;cursor:pointer;font-size:19px;color:${MUT};font-family:inherit;`, '✕'); c.onclick = popScreen; p.appendChild(c); };
151
+ const hl = (b, on) => { b.style.background = on ? GOLD : 'rgba(232,200,122,0.06)'; b.style.color = on ? '#1a1207' : TXT; b.style.borderColor = on ? GOLD : 'rgba(232,200,122,0.45)'; };
152
+ const BTN = `padding:9px 22px;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;`;
153
+
154
+ // ── controls ───────────────────────────────────────────────────────────────────────────────
155
+ function item(label, activate) {
156
+ const b = menuBtn(label);
157
+ const ctrl = { el: b, activate, focus: b.__on, blur: b.__off };
158
+ b.onclick = activate;
159
+ b.onmouseleave = paint;
160
+ return ctrl;
161
+ }
162
+ const rowWrap = () => el('div', 'display:flex;align-items:center;gap:14px;margin:13px 0;padding:6px 8px;border-radius:5px;');
163
+ const rowLabel = (t) => el('div', `width:118px;text-align:right;color:${MUT};font-size:14px;letter-spacing:1px;text-transform:uppercase;`, t);
164
+ function gfxRow() {
165
+ const wrap = rowWrap(); wrap.appendChild(rowLabel('Graphics'));
166
+ const btns = el('div', 'display:flex;gap:8px;flex:1;');
167
+ const names = ['low', 'medium', 'high'];
168
+ const paintQ = () => [...btns.children].forEach((b, i) => hl(b, getQualityName() === names[i]));
169
+ names.forEach((nm, i) => { const b = el('button', `flex:1;${BTN}text-align:center;`, PRESETS[nm].label); b.onclick = () => { setQualityName(nm); paintQ(); }; btns.appendChild(b); });
170
+ paintQ(); wrap.appendChild(btns);
171
+ const step = (d) => { const i = clamp(names.indexOf(getQualityName()) + d, 0, 2); setQualityName(names[i]); paintQ(); };
172
+ return { el: wrap, focus: () => (wrap.style.background = 'rgba(232,200,122,0.09)'), blur: () => (wrap.style.background = 'none'), left: () => step(-1), right: () => step(1) };
173
+ }
174
+ function slider(label, key, live) {
175
+ const wrap = rowWrap(); wrap.appendChild(rowLabel(label));
176
+ const track = el('div', 'position:relative;flex:1;height:7px;border-radius:5px;background:rgba(232,200,122,0.18);cursor:pointer;');
177
+ const fill = el('div', `position:absolute;left:0;top:0;bottom:0;border-radius:5px;background:${GOLD};`);
178
+ const knob = el('div', `position:absolute;top:50%;width:15px;height:15px;border-radius:50%;background:${GOLD};box-shadow:0 0 0 3px rgba(232,200,122,0.22);transform:translate(-50%,-50%);`);
179
+ track.appendChild(fill); track.appendChild(knob);
180
+ const num = el('div', `width:32px;text-align:right;color:${MUT};font-size:13px;`);
181
+ let v = clamp(+(localStorage.getItem(key) ?? 0.6), 0, 1);
182
+ const paintV = () => { fill.style.width = knob.style.left = (v * 100) + '%'; num.textContent = Math.round(v * 100); };
183
+ const set = (nv) => { v = clamp(nv, 0, 1); try { localStorage.setItem(key, v); } catch (e) {} paintV(); live?.(v); };
184
+ paintV();
185
+ const fromX = (x) => { const r = track.getBoundingClientRect(); set((x - r.left) / r.width); };
186
+ track.onmousedown = (e) => { fromX(e.clientX); const mv = (ev) => fromX(ev.clientX); const up = () => { removeEventListener('mousemove', mv); removeEventListener('mouseup', up); }; addEventListener('mousemove', mv); addEventListener('mouseup', up); };
187
+ wrap.appendChild(track); wrap.appendChild(num);
188
+ return { el: wrap, focus: () => (wrap.style.background = 'rgba(232,200,122,0.09)'), blur: () => (wrap.style.background = 'none'), left: () => set(v - 0.05), right: () => set(v + 0.05) };
189
+ }
190
+
191
+ // ON/OFF row (Off · On), pad/keys via left/right, mouse via the buttons.
192
+ function toggleRow(label, getV, setV) {
193
+ const wrap = rowWrap(); wrap.appendChild(rowLabel(label));
194
+ const btns = el('div', 'display:flex;gap:8px;flex:1;');
195
+ const names = ['Off', 'On'];
196
+ const paintT = () => [...btns.children].forEach((b, i) => hl(b, (getV() ? 1 : 0) === i));
197
+ names.forEach((nm, i) => { const b = el('button', `flex:1;${BTN}text-align:center;`, nm); b.onclick = () => { setV(i === 1); paintT(); }; btns.appendChild(b); });
198
+ paintT(); wrap.appendChild(btns);
199
+ return { el: wrap, focus: () => (wrap.style.background = 'rgba(232,200,122,0.09)'), blur: () => (wrap.style.background = 'none'), left: () => { setV(false); paintT(); }, right: () => { setV(true); paintT(); } };
200
+ }
201
+ const isFullscreen = () => !!document.fullscreenElement;
202
+ const setFullscreen = (on) => {
203
+ try {
204
+ if (on && !document.fullscreenElement) document.documentElement.requestFullscreen?.();
205
+ else if (!on && document.fullscreenElement) document.exitFullscreen?.();
206
+ } catch (e) { /* denied / unsupported */ }
207
+ };
208
+
209
+ // ── panels ─────────────────────────────────────────────────────────────────────────────────
210
+ const openSettings = () => {
211
+ const view = fullView('SETTINGS');
212
+ const scroll = el('div', 'flex:1;min-height:0;overflow:auto;');
213
+ const col = el('div', 'max-width:660px;margin:8px auto 0;');
214
+ const rows = [gfxRow(), toggleRow('Fullscreen', isFullscreen, setFullscreen), slider('Music', 'av_volMusic', (v) => music?.setMusicVolume?.(v)), slider('Ambience', 'av_volAmb'), slider('Effects', 'av_volSfx')];
215
+ rows.forEach((r) => col.appendChild(r.el));
216
+ col.appendChild(el('div', `font-size:12px;color:${MUT};opacity:0.7;margin-top:18px;text-align:center;letter-spacing:1px;`, 'Graphics takes effect when the world loads.'));
217
+ scroll.appendChild(col); view.body.appendChild(scroll);
218
+ renderHints(view.foot, [['nav', 'Navigate'], ['change', 'Change'], ['back', 'Back']]);
219
+ root.appendChild(view.root);
220
+ pushScreen({ items: rows, foot: view.foot, onPop: () => view.root.remove() });
221
+ };
222
+
223
+ const openCredits = () => {
224
+ const view = fullView('CREDITS');
225
+ 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('');
235
+ view.body.appendChild(body);
236
+ renderHints(view.foot, [['scroll', 'Scroll'], ['back', 'Back']]);
237
+ root.appendChild(view.root);
238
+ pushScreen({ items: [{ el: body, focus: () => {}, blur: () => {}, up: () => body.scrollBy(0, -60), down: () => body.scrollBy(0, 60) }], foot: view.foot, onPop: () => view.root.remove() });
239
+ };
240
+
241
+ const openLoad = () => {
242
+ subOpen = true; // hand input to the shared Save/Load screen while it's up
243
+ showSaveLoad({
244
+ mode: 'load',
245
+ list: () => readSaves(),
246
+ onLoad: (slot) => done({ choice: 'load', slot }), // boot() applies it once the world builds
247
+ onDelete: (slot) => { try { localStorage.removeItem('dustwater_save_' + slot); } catch (e) {} },
248
+ onClose: () => { subOpen = false; },
249
+ });
250
+ };
251
+
252
+ const confirm = (title, msg, onYes, yes = 'YES', no = 'NO') => {
253
+ const p = framedPanel(430); titleBar(p, title);
254
+ p.appendChild(el('div', `text-align:center;color:${TXT};margin:0 0 20px;line-height:1.5;`, msg));
255
+ const rowEl = el('div', 'display:flex;gap:14px;justify-content:center;');
256
+ const noB = el('button', BTN, no), yesB = el('button', BTN, yes);
257
+ rowEl.appendChild(noB); rowEl.appendChild(yesB); p.appendChild(rowEl); root.appendChild(p);
258
+ const nc = { el: noB, activate: popScreen, focus: () => hl(noB, true), blur: () => hl(noB, false) };
259
+ const yc = { el: yesB, activate: () => { popScreen(); onYes(); }, focus: () => hl(yesB, true), blur: () => hl(yesB, false) };
260
+ noB.onclick = nc.activate; yesB.onclick = yc.activate;
261
+ pushScreen({ items: [nc, yc], sel: 0, horizontal: true, onPop: () => p.remove() });
262
+ };
263
+
264
+ const openDifficulty = () => {
265
+ const view = fullView('SELECT DIFFICULTY');
266
+ 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
+ ];
272
+ const openT = performance.now(); // settle window — the click that OPENED this must not fall through onto a card
273
+ const items = DIFFS.map((d) => {
274
+ 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
+ + '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'">`
277
+ + `<div style="color:${MUT};font-size:12px;letter-spacing:4px;margin-top:6px">${d.tag}</div>`
278
+ + `<div style="color:${GOLD};font-size:23px;letter-spacing:2px;font-weight:700;margin:12px 0 15px">${d.name}</div>`
279
+ + `<div style="color:${TXT};opacity:0.85;font-size:15px;line-height:1.6;font-family:'Book Antiqua',Georgia,serif">${d.desc}</div>`;
280
+ const activate = () => { if (performance.now() - openT < 250) return; done({ choice: 'new', difficulty: d.id }); };
281
+ card.onclick = activate;
282
+ return { el: card, activate,
283
+ focus: () => { card.style.background = 'rgba(232,200,122,0.15)'; card.style.borderColor = GOLD; card.style.transform = 'translateY(-6px)'; },
284
+ blur: () => { card.style.background = 'rgba(232,200,122,0.05)'; card.style.borderColor = 'rgba(232,200,122,0.32)'; card.style.transform = 'none'; } };
285
+ });
286
+ items.forEach((it) => row.appendChild(it.el));
287
+ view.body.appendChild(row);
288
+ renderHints(view.foot, [['change', 'Choose'], ['select', 'Start'], ['back', 'Back']]);
289
+ root.appendChild(view.root);
290
+ pushScreen({ items, sel: 1, horizontal: true, foot: view.foot, onPop: () => view.root.remove() });
291
+ };
292
+
293
+ const newGame = () => { readSaves().length ? confirm('NEW GAME', 'Start a new game? Your autosave is replaced — your manual saves are kept.', openDifficulty) : openDifficulty(); };
294
+
295
+ // ── the main menu: a HORIZONTAL row (RDR2-style). Continue/Load only when a save exists. ───────
296
+ const hasSave = readSaves().length > 0;
297
+ const mainItems = [];
298
+ if (hasSave) mainItems.push(item('CONTINUE', () => done('continue')));
299
+ mainItems.push(item('NEW GAME', newGame));
300
+ if (hasSave) mainItems.push(item('LOAD', openLoad));
301
+ mainItems.push(item('SETTINGS', openSettings));
302
+ mainItems.push(item('CREDITS', openCredits));
303
+ const rowEl = el('div', 'display:flex;gap:34px;justify-content:center;align-items:center;flex-wrap:wrap;');
304
+ mainItems.forEach((it) => rowEl.appendChild(it.el));
305
+ col.appendChild(rowEl);
306
+ col.appendChild(el('div', 'color:#6a5a3a;font-size:11px;letter-spacing:3px;margin-top:22px;', 'v0.1'));
307
+ pushScreen({ items: mainItems, sel: 0, horizontal: true });
308
+
309
+ // ── input: pad + keyboard over the whole stack ────────────────────────────────────────────────
310
+ const move = (d) => { const s = cur(); s.sel = (s.sel + d + s.items.length) % s.items.length; paint(); };
311
+ // up/down always step the list (unless the focused control scrolls its own body, e.g. credits);
312
+ // left/right step a HORIZONTAL screen (the main row, a confirm), else work the control (sliders).
313
+ const onUp = () => { const c = cur().items[cur().sel]; c?.up ? c.up() : move(-1); };
314
+ const onDown = () => { const c = cur().items[cur().sel]; c?.down ? c.down() : move(1); };
315
+ const onLeft = () => { const s = cur(), c = s.items[s.sel]; s.horizontal ? move(-1) : c?.left?.(); };
316
+ const onRight = () => { const s = cur(), c = s.items[s.sel]; s.horizontal ? move(1) : c?.right?.(); };
317
+ const onAct = () => cur().items[cur().sel]?.activate?.();
318
+ const onBack = () => { if (stack.length > 1) popScreen(); };
319
+
320
+ const onKey = (e) => {
321
+ if (subOpen) return; // the Save/Load screen owns input while it's up
322
+ switch (e.code) {
323
+ case 'ArrowUp': case 'KeyW': onUp(); e.preventDefault(); break;
324
+ case 'ArrowDown': case 'KeyS': onDown(); e.preventDefault(); break;
325
+ case 'ArrowLeft': case 'KeyA': onLeft(); break;
326
+ case 'ArrowRight': case 'KeyD': onRight(); break;
327
+ case 'Enter': case 'Space': case 'NumpadEnter': onAct(); e.preventDefault(); break;
328
+ case 'Escape': case 'Backspace': onBack(); break;
329
+ default: return;
330
+ }
331
+ };
332
+ window.addEventListener('keydown', onKey);
333
+
334
+ // Gamepad: D-pad / left stick move · Cross(0)/Options(9) activate · Circle(1) back. First frame
335
+ // ADOPTS the held state without acting (the A that skipped the intro is likely still down).
336
+ let prev = {}, first = true, lastPadMode = padMode();
337
+ const padPoll = () => {
338
+ if (!root.isConnected) return;
339
+ if (subOpen) { raf = requestAnimationFrame(padPoll); return; } // suspend while the Save/Load screen is up
340
+ const f = cur()?.foot; if (f) lastPadMode = refreshHints(f, lastPadMode); // flip hint glyphs live on pad connect/disconnect
341
+ const pads = navigator.getGamepads ? navigator.getGamepads() : null;
342
+ let gp = null; if (pads) for (const p of pads) if (p && p.connected) { gp = p; break; }
343
+ if (gp) {
344
+ const b = (i) => !!gp.buttons[i]?.pressed, ax = (i) => gp.axes[i] || 0;
345
+ const st = { up: b(12) || ax(1) < -0.55, down: b(13) || ax(1) > 0.55, left: b(14) || ax(0) < -0.55, right: b(15) || ax(0) > 0.55, a: b(0) || b(9), c: b(1) };
346
+ const edge = (k) => st[k] && !prev[k];
347
+ if (first) first = false;
348
+ else {
349
+ if (edge('up')) onUp(); if (edge('down')) onDown();
350
+ if (edge('left')) onLeft(); if (edge('right')) onRight();
351
+ if (edge('a')) onAct(); if (edge('c')) onBack();
352
+ }
353
+ prev = st;
354
+ }
355
+ raf = requestAnimationFrame(padPoll);
356
+ };
357
+ raf = requestAnimationFrame(padPoll);
358
+ });
359
+ }