|
|
|
|
@@ -138,9 +138,10 @@
|
|
|
|
|
let keys = new Set();
|
|
|
|
|
let last = performance.now();
|
|
|
|
|
let saveBusy = false;
|
|
|
|
|
let backendAvailable = true;
|
|
|
|
|
|
|
|
|
|
bind();
|
|
|
|
|
load();
|
|
|
|
|
loadLocal();
|
|
|
|
|
requestAnimationFrame(frame);
|
|
|
|
|
|
|
|
|
|
function freshState() {
|
|
|
|
|
@@ -193,8 +194,13 @@
|
|
|
|
|
function bind() {
|
|
|
|
|
root.querySelector("[data-rpg-start]").addEventListener("click", start);
|
|
|
|
|
root.querySelector("[data-rpg-save]").addEventListener("click", save);
|
|
|
|
|
root.querySelector("[data-rpg-load]").addEventListener("click", load);
|
|
|
|
|
root.querySelector("[data-rpg-load]").addEventListener("click", () => load(true));
|
|
|
|
|
root.querySelector("[data-rpg-reset]").addEventListener("click", newGame);
|
|
|
|
|
root.querySelector("[data-rpg-sound]").addEventListener("click", () => {
|
|
|
|
|
audio.start();
|
|
|
|
|
audio.setTrack(state.started ? areas[state.area].track : "title");
|
|
|
|
|
els.status.textContent = audio.supported() ? "Sound on." : "This browser does not expose Web Audio.";
|
|
|
|
|
});
|
|
|
|
|
root.querySelector("[data-rpg-act]").addEventListener("click", act);
|
|
|
|
|
root.querySelectorAll("[data-rpg-move]").forEach((button) => {
|
|
|
|
|
button.addEventListener("click", () => move(button.dataset.rpgMove));
|
|
|
|
|
@@ -227,18 +233,25 @@
|
|
|
|
|
async function newGame() {
|
|
|
|
|
state = freshState();
|
|
|
|
|
localStorage.removeItem(fallbackKey);
|
|
|
|
|
try {
|
|
|
|
|
await fetch(`/api/play/rpg/save/${slot}`, { method: "DELETE" });
|
|
|
|
|
} catch (_error) {
|
|
|
|
|
/* Local reset is enough when offline. */
|
|
|
|
|
if (backendAvailable) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/play/rpg/save/${slot}`, { method: "DELETE" });
|
|
|
|
|
if (response.status === 404) backendAvailable = false;
|
|
|
|
|
} catch (_error) {
|
|
|
|
|
backendAvailable = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
audio.setTrack("title");
|
|
|
|
|
els.status.textContent = backendAvailable ? "New game started and local save cleared." : "New game started locally. Backend save endpoint is not available.";
|
|
|
|
|
renderHud();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function save() {
|
|
|
|
|
localStorage.setItem(fallbackKey, JSON.stringify(state));
|
|
|
|
|
if (saveBusy) return;
|
|
|
|
|
if (saveBusy || !backendAvailable) {
|
|
|
|
|
els.status.textContent = backendAvailable ? "Save already running." : "Saved locally. Backend save endpoint is not available.";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
saveBusy = true;
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/play/rpg/save/${slot}`, {
|
|
|
|
|
@@ -246,33 +259,58 @@
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ payload: state })
|
|
|
|
|
});
|
|
|
|
|
els.status.textContent = response.ok ? "Saved to backend." : "Saved locally. Backend rejected the save.";
|
|
|
|
|
if (response.status === 404) {
|
|
|
|
|
backendAvailable = false;
|
|
|
|
|
els.status.textContent = "Saved locally. Backend save endpoint is not deployed yet.";
|
|
|
|
|
} else {
|
|
|
|
|
els.status.textContent = response.ok ? "Saved to backend." : "Saved locally. Backend rejected the save.";
|
|
|
|
|
}
|
|
|
|
|
} catch (_error) {
|
|
|
|
|
backendAvailable = false;
|
|
|
|
|
els.status.textContent = "Saved locally. Backend save API was not reachable.";
|
|
|
|
|
} finally {
|
|
|
|
|
saveBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
async function load(allowBackend) {
|
|
|
|
|
if (loadLocal("Loaded local save.")) return;
|
|
|
|
|
if (!allowBackend || !backendAvailable) {
|
|
|
|
|
els.status.textContent = backendAvailable ? "No local save found." : "No local save found. Backend save endpoint is not available.";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/play/rpg/save/${slot}`);
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
state = normalize(data.payload);
|
|
|
|
|
els.status.textContent = "Loaded from backend.";
|
|
|
|
|
audio.setTrack(state.started ? areas[state.area].track : "title");
|
|
|
|
|
if (response.status === 404) {
|
|
|
|
|
backendAvailable = false;
|
|
|
|
|
els.status.textContent = "No local save found. Backend save endpoint is not deployed yet.";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
els.status.textContent = "No local save found. Backend did not return a save.";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
state = normalize(data.payload);
|
|
|
|
|
localStorage.setItem(fallbackKey, JSON.stringify(state));
|
|
|
|
|
els.status.textContent = "Loaded from backend.";
|
|
|
|
|
audio.setTrack(state.started ? areas[state.area].track : "title");
|
|
|
|
|
} catch (_error) {
|
|
|
|
|
/* Fall through to localStorage. */
|
|
|
|
|
backendAvailable = false;
|
|
|
|
|
els.status.textContent = "No local save found. Backend save API was not reachable.";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadLocal(message) {
|
|
|
|
|
const local = localStorage.getItem(fallbackKey);
|
|
|
|
|
if (local) {
|
|
|
|
|
state = normalize(JSON.parse(local));
|
|
|
|
|
els.status.textContent = "Loaded local save.";
|
|
|
|
|
if (message) els.status.textContent = message;
|
|
|
|
|
audio.setTrack(state.started ? areas[state.area].track : "title");
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
audio.setTrack(state.started ? areas[state.area].track : "title");
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalize(candidate) {
|
|
|
|
|
@@ -637,11 +675,11 @@
|
|
|
|
|
for (let y = 0; y < rows; y += 1) {
|
|
|
|
|
for (let x = 0; x < cols; x += 1) {
|
|
|
|
|
const wall = area.walls.has(`${x},${y}`);
|
|
|
|
|
const c = area.floor[(x + y + Math.floor(now / 800)) % area.floor.length];
|
|
|
|
|
const c = area.floor[(x + y) % area.floor.length];
|
|
|
|
|
ctx.fillStyle = wall ? "#0b0d10" : c;
|
|
|
|
|
ctx.fillRect(x * tile, y * tile, tile, tile);
|
|
|
|
|
if (!wall) {
|
|
|
|
|
ctx.fillStyle = "rgba(255,255,255,0.025)";
|
|
|
|
|
ctx.fillStyle = (x + y) % 2 === 0 ? "rgba(255,255,255,0.018)" : "rgba(0,0,0,0.035)";
|
|
|
|
|
ctx.fillRect(x * tile, y * tile + 30, tile, 2);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -896,12 +934,20 @@
|
|
|
|
|
void: [110, 98, 82, 73]
|
|
|
|
|
};
|
|
|
|
|
function start() {
|
|
|
|
|
if (ctx) return;
|
|
|
|
|
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
|
const Audio = window.AudioContext || window.webkitAudioContext;
|
|
|
|
|
if (!Audio) return;
|
|
|
|
|
if (ctx) {
|
|
|
|
|
if (ctx.state === "suspended") ctx.resume();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
ctx = new Audio();
|
|
|
|
|
master = ctx.createGain();
|
|
|
|
|
master.gain.value = 0.055;
|
|
|
|
|
master.gain.value = 0.14;
|
|
|
|
|
master.connect(ctx.destination);
|
|
|
|
|
if (ctx.state === "suspended") ctx.resume();
|
|
|
|
|
loop();
|
|
|
|
|
const notes = patterns[track] || patterns.title;
|
|
|
|
|
tone(notes[0], 0.22, "triangle", 0.9);
|
|
|
|
|
}
|
|
|
|
|
function loop() {
|
|
|
|
|
clearInterval(timer);
|
|
|
|
|
@@ -929,12 +975,18 @@
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
start,
|
|
|
|
|
supported() {
|
|
|
|
|
return Boolean(window.AudioContext || window.webkitAudioContext);
|
|
|
|
|
},
|
|
|
|
|
setTrack(next) {
|
|
|
|
|
track = next;
|
|
|
|
|
if (ctx && ctx.state === "suspended") ctx.resume();
|
|
|
|
|
if (ctx) loop();
|
|
|
|
|
if (ctx) tone((patterns[track] || patterns.title)[0], 0.16, track === "battle" ? "sawtooth" : "triangle", 0.65);
|
|
|
|
|
},
|
|
|
|
|
sfx(kind) {
|
|
|
|
|
if (!ctx) return;
|
|
|
|
|
if (ctx.state === "suspended") ctx.resume();
|
|
|
|
|
const map = { item: 660, talk: 440, battle: 130, hit: 180, listen: 523, forget: 96, hurt: 80 };
|
|
|
|
|
tone(map[kind] || 330, kind === "forget" ? 0.8 : 0.18, kind === "forget" ? "sawtooth" : "square", kind === "hurt" ? 1.3 : 0.8);
|
|
|
|
|
}
|
|
|
|
|
|