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,137 @@
1
+ // INTRO — a cinematic, letterboxed prologue slideshow. Shown ONCE after the audio-unlock press and
2
+ // BEFORE the title menu, so the theme (Dustwater.mp3, started on the unlock) carries the whole thing.
3
+ // Widescreen: black bars top and bottom, a Ken-Burns image behind each beat's line, crossfaded.
4
+ // X (or Esc / the on-screen button) skips straight to the title. Resolves when the last beat ends
5
+ // or the player skips.
6
+ //
7
+ // IMAGES: /assets/intro/01.jpg … 06.jpg — one per beat, 16:9 or wider (they're covered into the
8
+ // letterbox band). If a file is missing it falls back to a dark dust gradient, so the intro plays
9
+ // text-only until the art is dropped in. Prompts for generating them live in docs/INTRO_IMAGES.md.
10
+ const FONT = "Copperplate,'Copperplate Gothic Bold','Palatino','Book Antiqua',Georgia,serif";
11
+ const IMG = '/assets/intro';
12
+ const HOLD = 11100; // ms each beat is held before it advances (tuned to the theme)
13
+ const FADE = 1300; // ms crossfade between beats
14
+
15
+ // The prologue. Each beat is one image + one line. Sets up the county, the crime, the lawless
16
+ // vacuum, and drops the player in — straight into Deputy Toomey's first job.
17
+ // The prologue script is GAME data: registerIntroBeats([{ img, line }, ...])
18
+ let BEATS = [];
19
+ export function registerIntroBeats(beats) { BEATS = beats ?? []; }
20
+
21
+ function el(tag, css, text) {
22
+ const e = document.createElement(tag);
23
+ if (css) e.style.cssText = css;
24
+ if (text != null) e.textContent = text;
25
+ return e;
26
+ }
27
+
28
+ export function showIntro(host) {
29
+ return new Promise((resolve) => {
30
+ // one keyframe block for the letterbox reveal + the slow Ken-Burns drift
31
+ const kf = document.createElement('style');
32
+ kf.textContent =
33
+ '@keyframes introKen { from { transform: scale(1.02) translate(0,0); } to { transform: scale(1.14) translate(-2.5%,-1.5%); } }'
34
+ + '@keyframes introBar { from { height: 50vh; } to { height: 10.5vh; } }';
35
+ const root = el('div',
36
+ `position:absolute;inset:0;z-index:60;background:#000;font-family:${FONT};overflow:hidden;`);
37
+ root.appendChild(kf);
38
+
39
+ // the image band (two stacked layers, crossfaded by opacity)
40
+ const stage = el('div', 'position:absolute;inset:0;');
41
+ const DARK = 'radial-gradient(120% 90% at 50% 40%, #3a3026 0%, #1a140d 55%, #0a0805 100%)';
42
+ const layer = (z) => el('div',
43
+ `position:absolute;inset:0;z-index:${z};opacity:0;transition:opacity ${FADE}ms ease;`
44
+ + `background:${DARK};background-size:cover;background-position:center;`);
45
+ const a = layer(1), b = layer(2);
46
+ stage.appendChild(a); stage.appendChild(b);
47
+ root.appendChild(stage);
48
+
49
+ // a bottom scrim so the line reads over any image
50
+ root.appendChild(el('div',
51
+ 'position:absolute;left:0;right:0;bottom:0;height:42vh;z-index:3;pointer-events:none;'
52
+ + 'background:linear-gradient(to top, rgba(0,0,0,0.82) 8%, rgba(0,0,0,0) 100%);'));
53
+
54
+ // the cinematic letterbox bars — they sweep in from a full-black screen
55
+ const barCss = 'position:absolute;left:0;right:0;height:10.5vh;z-index:5;background:#000;animation:introBar 900ms ease both;';
56
+ root.appendChild(el('div', barCss + 'top:0;'));
57
+ root.appendChild(el('div', barCss + 'bottom:0;'));
58
+
59
+ // the line
60
+ const caption = el('div',
61
+ 'position:absolute;left:50%;bottom:15vh;transform:translateX(-50%);z-index:6;'
62
+ + 'width:min(1100px,86vw);text-align:center;color:#efe6d2;font-size:clamp(18px,2.5vw,30px);'
63
+ + 'line-height:1.5;letter-spacing:1px;text-shadow:0 3px 16px rgba(0,0,0,0.95);'
64
+ + `opacity:0;transition:opacity ${FADE}ms ease;text-wrap:balance;`);
65
+ root.appendChild(caption);
66
+
67
+ // skip affordance
68
+ const skip = el('div',
69
+ 'position:absolute;right:26px;bottom:calc(10.5vh + 14px);z-index:7;color:#c9bfa8;'
70
+ + 'font-size:13px;letter-spacing:2px;opacity:0.7;cursor:pointer;user-select:none;'
71
+ + 'text-shadow:0 2px 6px rgba(0,0,0,0.9);', 'PRESS X TO SKIP');
72
+ root.appendChild(skip);
73
+
74
+ host.appendChild(root);
75
+
76
+ let i = -1, top = b, timer = null, ended = false;
77
+ const preload = (n) => { const im = new Image(); im.src = `${IMG}/${n}.jpg`; };
78
+
79
+ const finish = () => {
80
+ if (ended) return; ended = true;
81
+ clearTimeout(timer);
82
+ window.removeEventListener('keydown', onKey);
83
+ // Hand off to the dark title cleanly: darken the host and drop the splash's loading title
84
+ // BEFORE fading out, so we don't reveal the full-cover vista behind us for a frame
85
+ // (Nick: "shows the full pic before the black edges").
86
+ host.style.background = '#080604';
87
+ const lh = host.querySelector('h1'); if (lh) lh.style.display = 'none';
88
+ const ls = host.querySelector('.sub'); if (ls) ls.style.display = 'none';
89
+ root.style.transition = 'opacity 600ms ease';
90
+ root.style.opacity = '0';
91
+ setTimeout(() => { root.remove(); resolve(); }, 640);
92
+ };
93
+
94
+ const next = () => {
95
+ i++;
96
+ if (i >= BEATS.length) { finish(); return; }
97
+ const beat = BEATS[i];
98
+ const under = top === a ? b : a; // the layer to fade OUT
99
+ const show = top === a ? b : a; // wait — keep it simple: alternate
100
+ // fade the caption out, swap the incoming layer's image, drift + fade it in over the other
101
+ caption.style.opacity = '0';
102
+ const inc = (i % 2 === 0) ? a : b;
103
+ const out = (i % 2 === 0) ? b : a;
104
+ inc.style.backgroundImage = `url(${IMG}/${beat.img}.jpg), ${DARK}`;
105
+ inc.style.animation = 'none'; void inc.offsetWidth; // restart the Ken-Burns drift
106
+ inc.style.animation = `introKen ${HOLD + FADE}ms linear both`;
107
+ inc.style.opacity = '1';
108
+ out.style.opacity = '0';
109
+ setTimeout(() => { caption.textContent = beat.text; caption.style.opacity = '1'; }, FADE * 0.55);
110
+ if (BEATS[i + 1]) preload(BEATS[i + 1].img);
111
+ timer = setTimeout(next, HOLD);
112
+ };
113
+
114
+ const onKey = (e) => { if (e.key === 'x' || e.key === 'X' || e.key === 'Escape') finish(); };
115
+ window.addEventListener('keydown', onKey);
116
+ skip.addEventListener('click', finish);
117
+
118
+ // gamepad: any face button / Start / Select skips too — but only on a FRESH press. The button
119
+ // that started the splash is very likely still held as we open, so adopt its current state on
120
+ // the first frame and require a release-then-press, or a single press blasts straight through.
121
+ let padPrev = null;
122
+ const padPoll = () => {
123
+ if (ended) return;
124
+ const pads = navigator.getGamepads ? navigator.getGamepads() : null;
125
+ let gp = null; if (pads) for (const p of pads) if (p && p.connected) { gp = p; break; }
126
+ const pressed = !!(gp && [0, 1, 2, 3, 8, 9].some((i) => gp.buttons[i]?.pressed));
127
+ if (padPrev === null) padPrev = pressed; // first frame: don't fire on a held-over button
128
+ else if (pressed && !padPrev) { finish(); return; }
129
+ padPrev = pressed;
130
+ requestAnimationFrame(padPoll);
131
+ };
132
+ requestAnimationFrame(padPoll);
133
+
134
+ preload(BEATS[0].img);
135
+ setTimeout(next, 700); // let the letterbox bars sweep in first
136
+ });
137
+ }
@@ -0,0 +1,79 @@
1
+ // Controller-aware footer hints. When a gamepad is connected we show its FACE-button glyphs
2
+ // (PlayStation ✕/○/△ or Xbox A/B/Y) for select/back/delete; otherwise the keyboard keys. The
3
+ // directional hints stay as arrows — they read as the D-pad on a controller and the arrow keys on a
4
+ // keyboard, so there's nothing to swap. Re-render on gamepadconnected/disconnected (the menus also
5
+ // poll padMode() each frame and re-render on a change) so plugging a pad in flips the labels live.
6
+ const GOLD = '#e8c87a';
7
+
8
+ export function padMode() {
9
+ const pads = typeof navigator !== 'undefined' && navigator.getGamepads ? navigator.getGamepads() : null;
10
+ if (pads) for (const p of pads) if (p && p.connected) {
11
+ const id = (p.id || '').toLowerCase();
12
+ if (/xbox|045e|xinput/.test(id)) return 'xbox'; // Microsoft vendor 045e
13
+ if (/054c|dualsense|dualshock|0ce6|09cc|05c4|playstation|sony/.test(id)) return 'ps'; // Sony vendor 054c
14
+ return 'ps'; // unknown pad → PlayStation-style glyphs are the safe default
15
+ }
16
+ return null; // no controller → keyboard
17
+ }
18
+
19
+ const FACE = {
20
+ select: { ps: '✕', xbox: 'Ⓐ' },
21
+ back: { ps: '○', xbox: 'Ⓑ' },
22
+ del: { ps: '△', xbox: 'Ⓨ' },
23
+ };
24
+
25
+ // token → the glyph/text for the current input mode
26
+ export function hintKey(token, mode = padMode()) {
27
+ switch (token) {
28
+ case 'nav': case 'scroll': return '↑ ↓';
29
+ case 'change': return '← →';
30
+ case 'select': case 'load': case 'confirm': return mode ? FACE.select[mode] : 'Enter';
31
+ case 'back': case 'cancel': return mode ? FACE.back[mode] : 'Esc';
32
+ case 'del': return mode ? FACE.del[mode] : 'Del';
33
+ default: return token;
34
+ }
35
+ }
36
+
37
+ // ── generated button-icon sheets (Gemini) → per-button keyed canvases ─────────────────────────────
38
+ // One 2×2 sheet per platform on black: [cross|circle / square|triangle]. We draw the right quadrant
39
+ // into a small canvas and key the black out to transparency (a luminance ramp) so the button floats.
40
+ const SHEETS = { ps: '/assets/ui/buttons/ps_sheet.png', xbox: '/assets/ui/buttons/xbox_sheet.png' };
41
+ const QUAD = { cross: [0, 0], circle: [1, 0], square: [0, 1], triangle: [1, 1] }; // col,row in the 2×2 (same layout both sheets)
42
+ const _sheet = {}; // mode → Image
43
+ function loadSheet(mode) { let img = _sheet[mode]; if (!img) { img = new Image(); img.src = SHEETS[mode]; _sheet[mode] = img; } return img; }
44
+
45
+ // A <canvas> showing the `action` button for `mode` ('ps'|'xbox'), black keyed to transparent.
46
+ export function buttonCanvas(action, mode, size = 30) {
47
+ const cv = document.createElement('canvas');
48
+ cv.width = cv.height = size * 2; // 2× for crispness
49
+ cv.style.cssText = `width:${size}px;height:${size}px;display:block;`;
50
+ const [col, row] = QUAD[action] || [0, 0];
51
+ const draw = () => {
52
+ const img = _sheet[mode]; if (!img || !img.complete || !img.naturalWidth) return;
53
+ const sw = img.naturalWidth / 2, sh = img.naturalHeight / 2;
54
+ const ctx = cv.getContext('2d'); ctx.clearRect(0, 0, cv.width, cv.height);
55
+ ctx.drawImage(img, col * sw, row * sh, sw, sh, 0, 0, cv.width, cv.height);
56
+ try {
57
+ const d = ctx.getImageData(0, 0, cv.width, cv.height), p = d.data;
58
+ for (let i = 0; i < p.length; i += 4) { const m = Math.max(p[i], p[i + 1], p[i + 2]); if (m < 26) p[i + 3] = 0; else if (m < 72) p[i + 3] = Math.round((m - 26) / 46 * 255); }
59
+ ctx.putImageData(d, 0, 0);
60
+ } catch (e) { /* tainted — leave as drawn */ }
61
+ };
62
+ const img = loadSheet(mode);
63
+ if (img.complete && img.naturalWidth) draw(); else img.addEventListener('load', draw, { once: true });
64
+ return cv;
65
+ }
66
+
67
+ // pairs: [[token, label], …] — e.g. [['nav','Navigate'], ['select','Load'], ['back','Back']]
68
+ export function renderHints(footEl, pairs, mode = padMode()) {
69
+ footEl._hintPairs = pairs; // remembered so a mode flip can re-render in place
70
+ footEl.innerHTML = pairs.map(([tok, label]) =>
71
+ `<span style="margin-right:30px;white-space:nowrap"><b style="color:${GOLD};letter-spacing:1px">${hintKey(tok, mode)}</b>&nbsp; ${label}</span>`).join('');
72
+ }
73
+
74
+ // Re-render a foot bar if the controller connection state changed since last render.
75
+ export function refreshHints(footEl, lastMode) {
76
+ const mode = padMode();
77
+ if (mode !== lastMode && footEl?._hintPairs) renderHints(footEl, footEl._hintPairs, mode);
78
+ return mode;
79
+ }
@@ -0,0 +1,194 @@
1
+ // IN-GAME PAUSE MENU — the dark gold-framed dress, matching the title/death screens (the old
2
+ // ui.js renderMenu('pause')/'options' was the plain leftover Nick flagged). Over a dimmed frozen
3
+ // scene: Resume · Save Game · Load Game · Settings · Quit to Title, with a Settings sub-panel
4
+ // (graphics + volumes + camera). Self-contained pad + keyboard + mouse nav with controller-aware
5
+ // footer hints; ui.menuOpen keeps the world paused + the player frozen while it's up.
6
+ import { getQualityName, setQualityName, PRESETS } from '../core/quality.js';
7
+ import { renderHints, refreshHints, padMode } from './menuHints.js';
8
+ import { showSaveLoad } from './saveLoadScreen.js';
9
+
10
+ const GOLD = '#e8c87a';
11
+ const TXT = '#ecdfc2';
12
+ const MUT = '#b6a577';
13
+ const FONT = "Copperplate,'Copperplate Gothic Bold','Palatino','Book Antiqua',Georgia,serif";
14
+ const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
15
+ 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
+ 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>`;
17
+
18
+ function el(tag, css, text) {
19
+ const e = document.createElement(tag);
20
+ if (css) e.style.cssText = css;
21
+ if (text != null) e.textContent = text;
22
+ return e;
23
+ }
24
+ function framedPanel(w = 420) {
25
+ const p = el('div', `position:relative;width:${w}px;max-width:90vw;padding:28px 36px 24px;color:${TXT};border:1px solid rgba(232,200,122,0.55);border-radius:3px;`
26
+ + 'background:linear-gradient(157deg,#241b11 0%,#160f09 58%,#0d0805 100%);box-shadow:0 26px 74px rgba(0,0,0,0.86);');
27
+ p.appendChild(el('div', 'position:absolute;inset:6px;border:1px solid rgba(232,200,122,0.26);border-radius:2px;pointer-events:none;'));
28
+ const c = (pos, rot) => { const d = el('div', `position:absolute;${pos}width:30px;height:30px;pointer-events:none;transform:rotate(${rot}deg);`); d.innerHTML = CORNER_SVG; p.appendChild(d); };
29
+ c('left:3px;top:3px;', 0); c('right:3px;top:3px;', 90); c('right:3px;bottom:3px;', 180); c('left:3px;bottom:3px;', 270);
30
+ return p;
31
+ }
32
+
33
+ // game: the Game. opts.onResume() must clear ui.menuOpen (i.e. call ui.closeMenu). Returns { close }.
34
+ export function showPauseMenu(game, opts = {}) {
35
+ const root = el('div', `position:fixed;inset:0;z-index:120;display:flex;align-items:center;justify-content:center;font-family:${FONT};background:rgba(6,4,3,0.62);`);
36
+ document.body.appendChild(root);
37
+
38
+ const stack = [];
39
+ const cur = () => stack[stack.length - 1];
40
+ const paint = () => { const s = cur(); if (s) s.items.forEach((c, i) => (i === s.sel ? c.focus?.() : c.blur?.())); };
41
+ const push = (s) => { s.sel = s.sel ?? 0; s.items.forEach((c, i) => c.el && c.el.addEventListener('mouseenter', () => { if (cur() === s) { s.sel = i; paint(); } })); stack.push(s); paint(); };
42
+ const pop = () => { const s = stack.pop(); s?.onPop?.(); paint(); };
43
+
44
+ let raf = 0, closed = false, first = true, prev = {}, lastMode = padMode();
45
+ const close = () => { if (closed) return; closed = true; cancelAnimationFrame(raf); window.removeEventListener('keydown', onKey); root.remove(); };
46
+ const resume = () => { close(); opts.onResume?.(); };
47
+
48
+ const hl = (b, on) => { b.style.background = on ? GOLD : 'rgba(232,200,122,0.06)'; b.style.color = on ? '#1a1207' : TXT; b.style.borderColor = on ? GOLD : 'rgba(232,200,122,0.45)'; };
49
+ const BTN = `padding:9px 20px;border:1px solid rgba(232,200,122,0.45);border-radius:5px;cursor:pointer;font-family:inherit;letter-spacing:2px;font-size:15px;background:rgba(232,200,122,0.06);color:${TXT};transition:background .12s,color .12s;`;
50
+ const rowWrap = () => el('div', 'display:flex;align-items:center;gap:14px;margin:12px 0;padding:5px 6px;border-radius:5px;');
51
+ const rowLabel = (t) => el('div', `width:110px;text-align:right;color:${MUT};font-size:14px;letter-spacing:1px;text-transform:uppercase;`, t);
52
+
53
+ const titleBar = (p, t) => {
54
+ p.appendChild(el('div', `font-size:22px;letter-spacing:5px;font-weight:700;text-align:center;color:${GOLD};text-shadow:0 2px 8px rgba(0,0,0,0.7);`, t));
55
+ const d = el('div', 'display:flex;justify-content:center;margin:9px 0 16px;'); d.innerHTML = DIVIDER_SVG; p.appendChild(d);
56
+ };
57
+ const menuItem = (label, fn) => {
58
+ const b = el('button', `display:block;width:100%;margin:8px 0;${BTN}text-align:center;letter-spacing:3px;`, label);
59
+ b.onclick = fn;
60
+ return { el: b, activate: fn, focus: () => hl(b, true), blur: () => hl(b, false) };
61
+ };
62
+ const footer = (p) => { const f = el('div', `margin-top:16px;text-align:center;color:${MUT};font-size:12px;letter-spacing:1px;`); p.appendChild(f); return f; };
63
+
64
+ // ── controls for the settings sub-panel ──────────────────────────────────────────────────────
65
+ const gfxRow = () => {
66
+ const wrap = rowWrap(); wrap.appendChild(rowLabel('Graphics'));
67
+ const btns = el('div', 'display:flex;gap:8px;flex:1;'); const names = ['low', 'medium', 'high'];
68
+ 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); });
70
+ paintQ(); wrap.appendChild(btns);
71
+ const step = (d) => { const i = clamp(names.indexOf(getQualityName()) + d, 0, 2); setQualityName(names[i]); paintQ(); };
72
+ 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
+ };
74
+ const slider = (label, get, set) => {
75
+ const wrap = rowWrap(); wrap.appendChild(rowLabel(label));
76
+ const track = el('div', 'position:relative;flex:1;height:7px;border-radius:5px;background:rgba(232,200,122,0.18);cursor:pointer;');
77
+ const fill = el('div', `position:absolute;left:0;top:0;bottom:0;border-radius:5px;background:${GOLD};`);
78
+ 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%);`);
79
+ track.appendChild(fill); track.appendChild(knob);
80
+ const num = el('div', `width:32px;text-align:right;color:${MUT};font-size:13px;`);
81
+ let v = clamp(get(), 0, 1);
82
+ const pv = () => { fill.style.width = knob.style.left = (v * 100) + '%'; num.textContent = Math.round(v * 100); };
83
+ const st = (nv) => { v = clamp(nv, 0, 1); set(v); pv(); }; pv();
84
+ const fromX = (x) => { const r = track.getBoundingClientRect(); st((x - r.left) / r.width); };
85
+ 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); };
86
+ wrap.appendChild(track); wrap.appendChild(num);
87
+ return { el: wrap, focus: () => (wrap.style.background = 'rgba(232,200,122,0.1)'), blur: () => (wrap.style.background = 'none'), left: () => st(v - 0.05), right: () => st(v + 0.05) };
88
+ };
89
+ const toggleRow = (label, get, set) => {
90
+ const wrap = rowWrap(); wrap.appendChild(rowLabel(label));
91
+ const b = el('button', `${BTN}flex:1;text-align:center;`, get() ? 'On' : 'Off');
92
+ const flip = () => { set(!get()); b.textContent = get() ? 'On' : 'Off'; };
93
+ b.onclick = flip; wrap.appendChild(b);
94
+ return { el: wrap, focus: () => (wrap.style.background = 'rgba(232,200,122,0.1)'), blur: () => (wrap.style.background = 'none'), left: flip, right: flip };
95
+ };
96
+
97
+ // ── panels ───────────────────────────────────────────────────────────────────────────────────
98
+ const toast = (m) => game.ui?.toast?.(m, 2200);
99
+ const openSettings = () => {
100
+ const p = framedPanel(500); titleBar(p, 'SETTINGS');
101
+ const rows = [
102
+ gfxRow(),
103
+ slider('Music', () => +(localStorage.getItem('av_volMusic') ?? 0.55), (v) => game.music?.setMusicVolume?.(v)),
104
+ slider('Ambience', () => +(localStorage.getItem('av_volAmb') ?? 0.6), (v) => { try { localStorage.setItem('av_volAmb', v); } catch (e) {} game.ambience?.setVolume?.(v); }),
105
+ slider('Effects', () => +(localStorage.getItem('av_volSfx') ?? 0.85), (v) => game.audio?.setSfxVolume?.(v)),
106
+ 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
+ toggleRow('Invert Y', () => !!game.player.invertY, (on) => { game.player.invertY = on; try { localStorage.setItem('av_invertY', on ? '1' : '0'); } catch (e) {} }),
108
+ toggleRow('Fullscreen', () => !!document.fullscreenElement, (on) => { try { if (on && !document.fullscreenElement) document.documentElement.requestFullscreen?.(); else if (!on && document.fullscreenElement) document.exitFullscreen?.(); } catch (e) {} }),
109
+ ];
110
+ rows.forEach((r) => p.appendChild(r.el));
111
+ 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
+ const f = footer(p);
113
+ root.replaceChildren(p);
114
+ push({ items: rows, foot: f, onPop: () => root.replaceChildren(mainPanel) });
115
+ renderHints(f, [['nav', 'Navigate'], ['change', 'Change'], ['back', 'Back']]);
116
+ };
117
+
118
+ // Save/Load open the shared slot screen over the pause menu; `sub` suspends this menu's own input
119
+ // while it's up so the two don't both drive the pad.
120
+ let sub = false;
121
+ const doSave = () => {
122
+ if (!game.missions) { toast('Nothing to save yet.'); return; }
123
+ sub = true;
124
+ showSaveLoad({ mode: 'save', list: () => game.missions.listSaves(),
125
+ onSave: (slot) => game.missions.saveGame(slot),
126
+ onDelete: (slot) => game.missions.deleteSave(slot),
127
+ onClose: () => { sub = false; } });
128
+ };
129
+ const doLoad = () => {
130
+ if (!game.missions?.hasSave?.()) { toast('No saved game found.'); return; }
131
+ sub = true;
132
+ showSaveLoad({ mode: 'load', list: () => game.missions.listSaves(),
133
+ onLoad: (slot) => { sub = false; resume(); const p = game.player; if (p) { p.alive = true; p.dead = false; p.health = p.maxHealth; p.busy = false; } game.dismountAll?.(); game.missions.loadGame(slot); },
134
+ onDelete: (slot) => game.missions.deleteSave(slot),
135
+ onClose: () => { sub = false; } });
136
+ };
137
+
138
+ const mainPanel = framedPanel(360);
139
+ titleBar(mainPanel, 'PAUSED');
140
+ const items = [
141
+ menuItem('RESUME', resume),
142
+ menuItem('SAVE GAME', () => { doSave(); }),
143
+ menuItem('LOAD GAME', () => { doLoad(); }),
144
+ menuItem('SETTINGS', openSettings),
145
+ menuItem('QUIT TO TITLE', () => { close(); location.reload(); }),
146
+ ];
147
+ items.forEach((it) => mainPanel.appendChild(it.el));
148
+ const mFoot = footer(mainPanel);
149
+ root.appendChild(mainPanel);
150
+ push({ items, foot: mFoot });
151
+ renderHints(mFoot, [['nav', 'Navigate'], ['select', 'Select'], ['back', 'Resume']]);
152
+
153
+ // ── input ──────────────────────────────────────────────────────────────────────────────────
154
+ const move = (d) => { const s = cur(); s.sel = (s.sel + d + s.items.length) % s.items.length; paint(); };
155
+ const onUp = () => move(-1);
156
+ const onDown = () => move(1);
157
+ const onLeft = () => cur().items[cur().sel]?.left?.();
158
+ const onRight = () => cur().items[cur().sel]?.right?.();
159
+ const onAct = () => cur().items[cur().sel]?.activate?.();
160
+ const onBack = () => { if (stack.length > 1) pop(); else resume(); };
161
+
162
+ const onKey = (e) => {
163
+ if (sub) return; // the Save/Load screen owns input while it's up
164
+ switch (e.code) {
165
+ case 'ArrowUp': case 'KeyW': onUp(); e.preventDefault(); break;
166
+ case 'ArrowDown': case 'KeyS': onDown(); e.preventDefault(); break;
167
+ case 'ArrowLeft': case 'KeyA': onLeft(); break;
168
+ case 'ArrowRight': case 'KeyD': onRight(); break;
169
+ case 'Enter': case 'Space': case 'NumpadEnter': onAct(); e.preventDefault(); break;
170
+ case 'Escape': case 'Backspace': onBack(); e.preventDefault(); break;
171
+ default: return;
172
+ }
173
+ };
174
+ window.addEventListener('keydown', onKey);
175
+
176
+ const poll = () => {
177
+ if (closed) return;
178
+ if (sub) { raf = requestAnimationFrame(poll); return; } // suspend while the Save/Load screen is up
179
+ const f = cur()?.foot; if (f) lastMode = refreshHints(f, lastMode);
180
+ const pads = navigator.getGamepads ? navigator.getGamepads() : null;
181
+ let gp = null; if (pads) for (const p of pads) if (p && p.connected) { gp = p; break; }
182
+ if (gp) {
183
+ const b = (i) => !!gp.buttons[i]?.pressed, ax = (i) => gp.axes[i] || 0;
184
+ 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), c: b(1) };
185
+ const edge = (k) => st[k] && !prev[k];
186
+ if (first) first = false;
187
+ else { if (edge('up')) onUp(); if (edge('down')) onDown(); if (edge('left')) onLeft(); if (edge('right')) onRight(); if (edge('a')) onAct(); if (edge('c')) onBack(); }
188
+ prev = st;
189
+ }
190
+ raf = requestAnimationFrame(poll);
191
+ };
192
+ raf = requestAnimationFrame(poll);
193
+ return { close };
194
+ }
@@ -0,0 +1,156 @@
1
+ // SAVE / LOAD SCREEN — a full-screen slot browser shared by the title, the pause menu and the death
2
+ // screen. Left: a column of slot cards (the 'auto' slot in load mode, plus numbered manual slots),
3
+ // each with its frame thumbnail + name + date + playtime. Right: a detail pane (big thumbnail + the
4
+ // full readout). Load mode loads a slot; save mode writes/overwrites one; △/Y deletes (with a confirm).
5
+ // Decoupled from the game via callbacks, so the pre-world TITLE and the in-world pause both use it.
6
+ //
7
+ // opts: { mode:'load'|'save', slots:[…ids], list:()=>saves, onLoad(slot), onSave(slot,name),
8
+ // onDelete(slot), onClose() }. Returns { close }.
9
+ import { renderHints, refreshHints, padMode } from './menuHints.js';
10
+
11
+ const UI = '/assets/ui/menu';
12
+ const GOLD = '#e8c87a';
13
+ const TXT = '#ecdfc2';
14
+ const MUT = '#b6a577';
15
+ const FONT = "Copperplate,'Copperplate Gothic Bold','Palatino','Book Antiqua',Georgia,serif";
16
+
17
+ function el(tag, css, text) {
18
+ const e = document.createElement(tag);
19
+ if (css) e.style.cssText = css;
20
+ if (text != null) e.textContent = text;
21
+ return e;
22
+ }
23
+ const fmtDate = (ts) => { try { return ts ? new Date(ts).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : '—'; } catch (e) { return '—'; } };
24
+ const fmtTime = (s) => { s = Math.max(0, Math.round(s || 0)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60); return h ? `${h}h ${m}m` : `${m}m`; };
25
+
26
+ export function showSaveLoad(opts = {}) {
27
+ const save = opts.mode === 'save';
28
+ const slotIds = opts.slots || ['1', '2', '3', '4', '5', '6'];
29
+
30
+ const root = el('div', `position:fixed;inset:0;z-index:130;display:flex;flex-direction:column;font-family:${FONT};background:#0a0705;`);
31
+ root.appendChild(el('div', `position:absolute;inset:0;background:#000 url(${UI}/title_bg.jpg) center/cover;opacity:0.08;`));
32
+ 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
+ const head = el('div', 'position:relative;padding:44px 60px 16px;border-bottom:1px solid rgba(232,200,122,0.22);flex:none;');
34
+ 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);`, save ? 'SAVE GAME' : 'LOAD GAME'));
35
+ root.appendChild(head);
36
+ const body = el('div', 'position:relative;flex:1;min-height:0;display:flex;gap:34px;padding:26px 60px;');
37
+ root.appendChild(body);
38
+ const foot = el('div', `position:relative;padding:15px 60px;border-top:1px solid rgba(232,200,122,0.22);color:${MUT};font-size:13px;letter-spacing:2px;flex:none;`);
39
+ root.appendChild(foot);
40
+ document.body.appendChild(root);
41
+
42
+ const listEl = el('div', 'width:440px;overflow:auto;flex:none;display:flex;flex-direction:column;gap:9px;');
43
+ const detail = el('div', 'flex:1;border:1px solid rgba(232,200,122,0.3);border-radius:6px;background:rgba(232,200,122,0.04);overflow:hidden;display:flex;flex-direction:column;');
44
+ body.appendChild(listEl); body.appendChild(detail);
45
+
46
+ let items = [], sel = 0, raf = 0, closed = false, first = true, prev = {}, lastMode = padMode(), modal = null;
47
+ const close = () => { if (closed) return; closed = true; cancelAnimationFrame(raf); window.removeEventListener('keydown', onKey); root.remove(); };
48
+ const back = () => { if (modal) { modal.remove(); modal = null; setHints(); paint(); return; } close(); opts.onClose?.(); };
49
+
50
+ const showDetail = (rec) => {
51
+ if (!rec) { detail.innerHTML = `<div style="flex:1;display:flex;align-items:center;justify-content:center;color:${MUT};letter-spacing:2px">${save ? 'NEW SAVE' : 'EMPTY'}</div>`; return; }
52
+ const img = rec.thumb ? `<img src="${rec.thumb}" style="width:100%;height:100%;object-fit:cover">`
53
+ : `<div style="flex:1;display:flex;align-items:center;justify-content:center;color:${MUT};font-size:12px;letter-spacing:3px">NO PREVIEW</div>`;
54
+ detail.innerHTML = `<div style="flex:1;min-height:190px;background:linear-gradient(135deg,#1a130b,#0d0906);display:flex;overflow:hidden">${img}</div>`
55
+ + `<div style="padding:20px 24px"><div style="color:${GOLD};font-size:22px;letter-spacing:2px;font-weight:700">${rec.name || rec.title || 'Dustwater'}</div>`
56
+ + `<div style="color:${MUT};font-size:13px;margin-top:8px;line-height:1.9">${rec.auto ? 'Autosave · ' : ''}${fmtDate(rec.savedAt)}<br>Played ${fmtTime(rec.playtime)} &nbsp;·&nbsp; Purse $${rec.money ?? 0}${rec.objective ? '<br>' + rec.objective : ''}</div></div>`;
57
+ };
58
+
59
+ const rebuild = () => {
60
+ const saves = opts.list ? opts.list() : [];
61
+ 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
63
+ listEl.innerHTML = ''; items = [];
64
+ if (!ids.length) { listEl.appendChild(el('div', `color:${MUT};opacity:0.8;padding:12px;`, 'No saved games yet.')); showDetail(null); return; }
65
+ ids.forEach((id) => {
66
+ const rec = bySlot[id];
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
+ const thumb = rec && rec.thumb ? `<img src="${rec.thumb}" style="width:74px;height:44px;object-fit:cover;border-radius:3px;flex:none">`
69
+ : `<div style="width:74px;height:44px;border-radius:3px;background:rgba(232,200,122,0.08);flex:none"></div>`;
70
+ const title = rec ? (rec.name || rec.title || 'Dustwater') : (save ? 'New Save' : 'Empty');
71
+ const subtitle = rec ? `${rec.auto ? 'AUTO' : 'SLOT ' + id} &nbsp;·&nbsp; ${fmtDate(rec.savedAt)}` : (save ? 'Empty slot' : '');
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
+ const ctrl = {
74
+ el: card, rec, slot: id,
75
+ focus: () => { card.style.background = 'rgba(232,200,122,0.16)'; card.style.borderColor = GOLD; showDetail(rec); },
76
+ blur: () => { card.style.background = 'rgba(232,200,122,0.05)'; card.style.borderColor = 'rgba(232,200,122,0.3)'; },
77
+ activate: () => choose(ctrl),
78
+ };
79
+ card.onclick = ctrl.activate;
80
+ card.addEventListener('mouseenter', () => { sel = items.indexOf(ctrl); paint(); });
81
+ listEl.appendChild(card); items.push(ctrl);
82
+ });
83
+ if (sel >= items.length) sel = items.length - 1;
84
+ if (sel < 0) sel = 0;
85
+ paint();
86
+ };
87
+
88
+ const paint = () => items.forEach((c, i) => (i === sel ? c.focus() : c.blur()));
89
+
90
+ // a small centered confirm over the screen
91
+ const confirm = (msg, onYes) => {
92
+ modal = el('div', 'position:absolute;inset:0;z-index:5;display:flex;align-items:center;justify-content:center;background:rgba(6,4,3,0.5);');
93
+ const p = el('div', `width:400px;max-width:88vw;padding:26px 32px;color:${TXT};border:1px solid rgba(232,200,122,0.55);border-radius:4px;background:linear-gradient(157deg,#241b11,#160f09 60%,#0d0805);box-shadow:0 24px 60px rgba(0,0,0,0.8);text-align:center;`);
94
+ p.appendChild(el('div', `color:${GOLD};letter-spacing:2px;margin-bottom:16px;line-height:1.5;`, msg));
95
+ const rowEl = el('div', 'display:flex;gap:12px;justify-content:center;');
96
+ const mk = (label) => el('button', `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};`, label);
97
+ const noB = mk('NO'), yesB = mk('YES');
98
+ rowEl.append(noB, yesB); p.appendChild(rowEl); modal.appendChild(p); root.appendChild(modal);
99
+ let msel = 0; const mitems = [{ el: noB, fn: () => { modal.remove(); modal = null; setHints(); paint(); } }, { el: yesB, fn: () => { modal.remove(); modal = null; onYes(); setHints(); } }];
100
+ const mpaint = () => mitems.forEach((m, i) => { m.el.style.background = i === msel ? GOLD : 'rgba(232,200,122,0.06)'; m.el.style.color = i === msel ? '#1a1207' : TXT; });
101
+ mpaint();
102
+ noB.onclick = mitems[0].fn; yesB.onclick = mitems[1].fn;
103
+ modal._nav = { left: () => { msel = 0; mpaint(); }, right: () => { msel = 1; mpaint(); }, act: () => mitems[msel].fn() };
104
+ renderHints(foot, [['change', 'Choose'], ['select', 'Confirm'], ['back', 'Cancel']]);
105
+ };
106
+
107
+ const choose = (ctrl) => {
108
+ if (save) {
109
+ if (ctrl.rec) confirm(`Overwrite ${ctrl.rec.name || 'this save'}?`, () => { opts.onSave?.(ctrl.slot); rebuild(); });
110
+ else { opts.onSave?.(ctrl.slot); rebuild(); }
111
+ } else if (ctrl.rec) { close(); opts.onLoad?.(ctrl.slot); }
112
+ };
113
+ const del = () => { const c = items[sel]; if (!c || !c.rec) return; confirm(`Delete ${c.rec.name || 'this save'}?`, () => { opts.onDelete?.(c.slot); rebuild(); }); };
114
+
115
+ const setHints = () => renderHints(foot, save
116
+ ? [['nav', 'Navigate'], ['select', 'Save'], ['del', 'Delete'], ['back', 'Back']]
117
+ : [['nav', 'Navigate'], ['select', 'Load'], ['del', 'Delete'], ['back', 'Back']]);
118
+
119
+ rebuild(); setHints();
120
+
121
+ const move = (d) => { if (!items.length) return; sel = (sel + d + items.length) % items.length; paint(); };
122
+ const onKey = (e) => {
123
+ if (modal) {
124
+ switch (e.code) { case 'ArrowLeft': case 'KeyA': modal._nav.left(); break; case 'ArrowRight': case 'KeyD': modal._nav.right(); break; case 'Enter': case 'Space': modal._nav.act(); e.preventDefault(); break; case 'Escape': case 'Backspace': back(); break; default: return; }
125
+ return;
126
+ }
127
+ switch (e.code) {
128
+ case 'ArrowUp': case 'KeyW': move(-1); e.preventDefault(); break;
129
+ case 'ArrowDown': case 'KeyS': move(1); e.preventDefault(); break;
130
+ case 'Enter': case 'Space': case 'NumpadEnter': items[sel]?.activate(); e.preventDefault(); break;
131
+ case 'Delete': case 'KeyR': del(); break;
132
+ case 'Escape': case 'Backspace': back(); break;
133
+ default: return;
134
+ }
135
+ };
136
+ window.addEventListener('keydown', onKey);
137
+
138
+ const poll = () => {
139
+ if (closed) return;
140
+ lastMode = refreshHints(foot, lastMode);
141
+ const pads = navigator.getGamepads ? navigator.getGamepads() : null;
142
+ let gp = null; if (pads) for (const p of pads) if (p && p.connected) { gp = p; break; }
143
+ if (gp) {
144
+ const b = (i) => !!gp.buttons[i]?.pressed, ax = (i) => gp.axes[i] || 0;
145
+ 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), c: b(1), y: b(3) };
146
+ const edge = (k) => st[k] && !prev[k];
147
+ if (first) first = false;
148
+ else if (modal) { if (edge('left')) modal._nav.left(); if (edge('right')) modal._nav.right(); if (edge('a')) modal._nav.act(); if (edge('c')) back(); }
149
+ else { if (edge('up')) move(-1); if (edge('down')) move(1); if (edge('a')) items[sel]?.activate(); if (edge('y')) del(); if (edge('c')) back(); }
150
+ prev = st;
151
+ }
152
+ raf = requestAnimationFrame(poll);
153
+ };
154
+ raf = requestAnimationFrame(poll);
155
+ return { close };
156
+ }