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.
- package/package.json +1 -1
- package/src/audio/ambience.js +65 -0
- package/src/audio/music.js +234 -0
- package/src/audio/synth.js +228 -0
- package/src/systems/casino/blackjack.js +187 -0
- package/src/systems/casino/cards3d.js +55 -0
- package/src/systems/casino/coins3d.js +75 -0
- package/src/systems/casino/table3d.js +130 -0
- package/src/systems/coach.js +1034 -0
- package/src/systems/combat.js +586 -0
- package/src/systems/crime.js +582 -0
- package/src/systems/encounters.js +1 -1
- package/src/systems/enemy.js +442 -0
- package/src/systems/loot.js +303 -0
- package/src/systems/missions.js +418 -0
- package/src/systems/npc.js +1 -1
- package/src/systems/player.js +1619 -0
- package/src/systems/roadGraph.js +264 -0
- package/src/systems/satchel.js +500 -0
- package/src/systems/scenes.js +105 -0
- package/src/systems/shop.js +515 -0
- package/src/systems/train.js +1957 -0
- package/src/ui/cartograph.js +456 -0
- package/src/ui/deathScreen.js +130 -0
- package/src/ui/dialogue.js +434 -0
- package/src/ui/iconBaker.js +180 -0
- package/src/ui/intro.js +137 -0
- package/src/ui/menuHints.js +79 -0
- package/src/ui/pauseMenu.js +194 -0
- package/src/ui/saveLoadScreen.js +156 -0
- package/src/ui/titleScreen.js +359 -0
- package/src/ui/worldmap.js +837 -0
package/src/ui/intro.js
ADDED
|
@@ -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> ${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)} · 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} · ${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
|
+
}
|