Revisione di Codex: salvataggi a punti fermi, salva e carica da file, volume delle voci
- si salva solo a sequenza finita: uscire a metà di una risposta o della casetta riporta a prima; la squama presa resta salvata anche senza cambiare scena; test di interruzione su soluzione e catena del ghiacciolo - «Salva su file» nella pausa e «Carica» nel titolo, con controllo di versione e contenuto prima di sostituire il salvataggio del browser - cursore del volume delle voci («bla»), 50% di partenza; il muto zittisce subito la voce, la pausa la riprende - col dito un oggetto mancato di poco si prende, e i piccoli vincono sui grandi che li contengono (la squama); verbi su due colonne nel telefono in orizzontale - sfondi caricati per vicinanza alla scena di partenza, con tentativi limitati se falliscono; a gioco fermo non si ridisegna - modalità ?debug per scrivere le scene: area calpestabile, hotspot, punti, coordinate copiabili - corretto: i crediti dal titolo restavano dietro il titolo Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApqNBvNbNCwXqR2RLo8QJh
This commit is contained in:
@@ -27,13 +27,14 @@ export class Command {
|
||||
return isItem ? `Usa ${name} con…` : `${VERB_LABELS[g.defaultVerb(target)]} ${name}`;
|
||||
}
|
||||
|
||||
// Clic sulla scena: `look` per il clic destro o la pressione lunga, `hurry` per il doppio clic (passo doppio).
|
||||
clickScene(point, { look = false, hurry = false } = {}) {
|
||||
// Clic sulla scena: `look` per il clic destro o la pressione lunga, `hurry` per il doppio clic (passo doppio),
|
||||
// `reach` per il dito (quanto lontano dall'oggetto vale ancora come preso).
|
||||
clickScene(point, { look = false, hurry = false, reach = 0 } = {}) {
|
||||
const g = this.game;
|
||||
g.hurry = hurry;
|
||||
if (g.skip()) return 'skip';
|
||||
if (g.dialogue || g.busy) return null;
|
||||
const h = g.hotspotAt(point);
|
||||
const h = g.hotspotAt(point, reach);
|
||||
let done;
|
||||
if (look && h) done = g.act('look', h.id);
|
||||
else if (!h) done = g.walkTo(point);
|
||||
|
||||
+56
-6
@@ -1,6 +1,6 @@
|
||||
// Il cuore dell'avventura, senza DOM: stato, comandi, coda delle azioni, dialoghi.
|
||||
// La storia (scene, oggetti, dialoghi) arriva tutta dai dati: qui non c'è nessun enigma scritto a mano.
|
||||
import { findPath, dist, inBox, nearestInside } from './geometry.js';
|
||||
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
|
||||
|
||||
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
|
||||
export const VERB_LABELS = { walk: 'Vai a', look: 'Guarda', take: 'Prendi', use: 'Usa', talk: 'Parla con', open: 'Apri', give: 'Dai', drop: 'Butta' };
|
||||
@@ -29,6 +29,9 @@ export function check(cond, state) {
|
||||
return not ? !value : value;
|
||||
}
|
||||
|
||||
// Un oggetto è «piccolo» per il dito sotto i 24×24 pixel di scena: col tocco ha la precedenza sui vicini grandi.
|
||||
const SMALL_AREA = 24 * 24;
|
||||
|
||||
export const sayTime = text => Math.max(1.6, 0.9 + text.length * 0.055);
|
||||
|
||||
export function newState(story) {
|
||||
@@ -36,6 +39,20 @@ export function newState(story) {
|
||||
return { scene, x: at[0], y: at[1], facing: 1, inventory: [], flags: {}, used: {}, ended: false };
|
||||
}
|
||||
|
||||
// Un salvataggio portato da fuori (Carica) deve tornare in tutto con la storia attuale, prima di prendere il posto di
|
||||
// quello del browser: a differenza di restoreState non si aggiusta niente. Restituisce cosa non va, o null.
|
||||
export function checkSave(story, data) {
|
||||
if (!data || typeof data !== 'object') return 'non è una partita';
|
||||
if (!Object.hasOwn(story.scenes, data.scene)) return `scena sconosciuta: ${data.scene}`;
|
||||
if (!Number.isFinite(data.x) || !Number.isFinite(data.y)) return 'posizione mancante';
|
||||
if (data.facing !== 1 && data.facing !== -1) return 'verso sbagliato';
|
||||
if (!Array.isArray(data.inventory) || data.inventory.some(i => typeof i !== 'string' || !Object.hasOwn(story.items, i))) return 'oggetti sconosciuti';
|
||||
for (const key of ['flags', 'used'])
|
||||
if (!data[key] || typeof data[key] !== 'object' || Array.isArray(data[key]) || Object.values(data[key]).some(v => v !== true)) return `${key} non validi`;
|
||||
if (typeof data.ended !== 'boolean') return 'fine non valida';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ripristina un salvataggio scartando tutto quello che la storia attuale non conosce più.
|
||||
export function restoreState(story, data) {
|
||||
const fresh = newState(story);
|
||||
@@ -54,6 +71,10 @@ export class Game {
|
||||
this.voiceTime = voiceTime ?? (() => undefined);
|
||||
this.state = state ? restoreState(story, state) : newState(story);
|
||||
this.onSave = onSave ?? (() => {});
|
||||
// Ultimo stato coerente: preso solo a sequenza finita, è quello che si salva e da cui si riprende.
|
||||
// Uscire a metà di una sequenza (una risposta, la porta che si apre) riporta qui, mai a un enigma risolto a metà.
|
||||
this.checkpoint = state ? this.snapshot() : null;
|
||||
this.dirty = false;
|
||||
this.queue = [];
|
||||
this.task = null;
|
||||
this.path = [];
|
||||
@@ -72,6 +93,18 @@ export class Game {
|
||||
// o verso un oggetto si può sempre cambiare con un altro clic: la coda che la seguiva viene scartata.
|
||||
get busy() { return !!this.speech || this.reach > 0 || (this.task ? !this.task.interruptible : this.queue.length > 0); }
|
||||
get idle() { return !this.busy && !this.task && !this.dialogue; }
|
||||
// Fermo: nessuna battuta, nessun effetto in coda, nessuna camminata che fa parte di una sequenza.
|
||||
get steady() { return !this.speech && this.reach <= 0 && !this.queue.length && !(this.task && !this.task.interruptible); }
|
||||
// Lo stato da salvare adesso: quello attuale se è fermo, altrimenti l'ultimo punto fermo.
|
||||
savable() { return this.steady ? this.snapshot() : this.checkpoint; }
|
||||
// A sequenza finita, se qualcosa è cambiato, nuovo punto fermo e salvataggio.
|
||||
commit() {
|
||||
if (!this.dirty || !this.steady) return false;
|
||||
this.dirty = false;
|
||||
this.checkpoint = this.snapshot();
|
||||
this.onSave(this.checkpoint);
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
const { scene, x, y, facing, inventory, flags, used, ended } = this.state;
|
||||
@@ -81,10 +114,25 @@ export class Game {
|
||||
hotspots() { return (this.scene.hotspots ?? []).filter(h => check(h.when, this.state)); }
|
||||
hotspot(id) { return this.hotspots().find(h => h.id === id); }
|
||||
people() { return (this.scene.people ?? []).filter(p => check(p.when, this.state)); }
|
||||
hotspotAt(p) {
|
||||
// Col mouse vale il punto preciso (l'ultimo della lista sta sopra). Col dito, `reach` allarga l'area: prima gli
|
||||
// oggetti piccoli (la squama sotto la panchina) entro quella distanza, anche se il dito cade sull'oggetto grande
|
||||
// che li contiene; poi il punto preciso; poi l'oggetto più vicino entro `reach`.
|
||||
hotspotAt(p, reach = 0) {
|
||||
const list = this.hotspots();
|
||||
const nearest = keep => {
|
||||
let best = null, bestD = Infinity;
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
const d = keep(list[i]) ? boxDistance(list[i].box, p) : Infinity;
|
||||
if (d <= reach && d < bestD) { best = list[i]; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
};
|
||||
if (reach > 0) {
|
||||
const small = nearest(h => h.box[2] * h.box[3] <= SMALL_AREA);
|
||||
if (small) return small;
|
||||
}
|
||||
for (let i = list.length - 1; i >= 0; i--) if (inBox(list[i].box, p)) return list[i];
|
||||
return null;
|
||||
return reach > 0 ? nearest(() => true) : null;
|
||||
}
|
||||
itemName(id) { return this.story.items[id]?.name ?? id; }
|
||||
targetName(id) { return this.story.items[id] ? this.itemName(id) : this.hotspot(id)?.name ?? id; }
|
||||
@@ -153,7 +201,7 @@ export class Game {
|
||||
choose(index) {
|
||||
const option = this.options()[index];
|
||||
if (!option) return false;
|
||||
if (option.once) this.state.used[option.id] = true;
|
||||
if (option.once) { this.state.used[option.id] = true; this.dirty = true; }
|
||||
const next = option.next ?? this.dialogue.node;
|
||||
this.queue.push({ say: option.say ?? option.text, who: 'andrea' }, ...(option.then ?? []), next === 'end' ? { endDialogue: true } : { node: next });
|
||||
return true;
|
||||
@@ -235,10 +283,12 @@ export class Game {
|
||||
}
|
||||
// Esegue gli effetti istantanei di fila e si ferma al primo che richiede tempo.
|
||||
while (!this.speech && !this.walking && this.queue.length) this.run(this.queue.shift());
|
||||
this.commit();
|
||||
}
|
||||
|
||||
run(e) {
|
||||
const s = this.state;
|
||||
this.dirty = true;
|
||||
if (e.say != null) {
|
||||
const who = e.who ?? 'andrea', voice = this.voiceTime(who, e.say);
|
||||
this.speech = { text: e.say, who, pose: e.pose, left: Math.max(sayTime(e.say), voice ? voice + 0.35 : 0) };
|
||||
@@ -255,7 +305,7 @@ export class Game {
|
||||
else if (e.dialogue) this.dialogue = { id: e.dialogue, node: 'start' };
|
||||
else if (e.node) this.dialogue.node = e.node;
|
||||
else if (e.endDialogue) this.dialogue = null;
|
||||
else if (e.end) { s.ended = true; this.onSave(this.snapshot()); }
|
||||
else if (e.end) s.ended = true;
|
||||
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
|
||||
}
|
||||
|
||||
@@ -291,6 +341,6 @@ export class Game {
|
||||
this.back = false;
|
||||
this.state.flags['visto:' + scene] = true; // per la mappa
|
||||
this.queue.unshift(...(this.scene.enter ?? []));
|
||||
this.onSave(this.snapshot());
|
||||
this.dirty = true; // si salva quando l'ingresso nella scena è finito
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,3 +95,6 @@ export function findPath(poly, from, to) {
|
||||
}
|
||||
|
||||
export const inBox = ([x, y, w, h], p) => p[0] >= x && p[0] <= x + w && p[1] >= y && p[1] <= y + h;
|
||||
|
||||
// Distanza da un punto al rettangolo [x, y, w, h]; zero se ci sta dentro.
|
||||
export const boxDistance = ([x, y, w, h], p) => Math.hypot(Math.max(x - p[0], 0, p[0] - x - w), Math.max(y - p[1], 0, p[1] - y - h));
|
||||
|
||||
+94
-7
@@ -66,12 +66,33 @@ const loadImage = src => new Promise(resolve => {
|
||||
img.src = src;
|
||||
});
|
||||
|
||||
export function loadAssets(story) {
|
||||
const scenes = Object.values(story.scenes).filter(s => s.background).map(async s => {
|
||||
const img = await loadImage(s.background);
|
||||
if (img) backgrounds.set(s.background, img);
|
||||
return !!img;
|
||||
export const hasBackground = src => backgrounds.has(src);
|
||||
|
||||
// Uno sfondo si chiede una volta sola: la stessa promessa serve a chi lo vuole subito e al caricamento di fondo.
|
||||
// Se fallisce (rete ballerina) si riprova, ma non a ogni fotogramma: dopo 2, 4, 8 secondi, poi basta fino al
|
||||
// ricaricamento della pagina.
|
||||
const pending = new Map(), failed = new Map();
|
||||
export const RETRIES = 3, RETRY_WAIT = 2000;
|
||||
export function loadBackground(src, now = Date.now()) {
|
||||
if (backgrounds.has(src)) return Promise.resolve(true);
|
||||
if (pending.has(src)) return pending.get(src);
|
||||
const f = failed.get(src);
|
||||
if (f && (f.count > RETRIES || now < f.until)) return Promise.resolve(false);
|
||||
const p = loadImage(src).then(img => {
|
||||
pending.delete(src);
|
||||
if (img) { backgrounds.set(src, img); failed.delete(src); return true; }
|
||||
const count = (failed.get(src)?.count ?? 0) + 1;
|
||||
failed.set(src, { count, until: Date.now() + RETRY_WAIT * 2 ** (count - 1) });
|
||||
return false;
|
||||
});
|
||||
pending.set(src, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Prima i personaggi e la scena dove si parte, poi le scene vicine (le uscite), poi tutte le altre poche alla volta:
|
||||
// la prima scena compare subito invece di aspettare in fila con i 10 MB degli altri sfondi.
|
||||
// Una scena dove si arriva prima del suo turno passa avanti (render la chiede da sé).
|
||||
export async function loadAssets(story, first = story.start.scene, { together = 3 } = {}) {
|
||||
const people = Object.entries(story.characters).filter(([, c]) => c.sprite).map(async ([id, c]) => {
|
||||
try {
|
||||
const atlas = await (await fetch(c.sprite)).json();
|
||||
@@ -82,7 +103,22 @@ export function loadAssets(story) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return Promise.all([...scenes, ...people]);
|
||||
const order = backgroundOrder(story, story.scenes[first] ? first : story.start.scene), results = [];
|
||||
results.push(...await Promise.all([...people, ...order.slice(0, 1).map(loadBackground)]));
|
||||
let next = 1;
|
||||
const worker = async () => { while (next < order.length) results.push(await loadBackground(order[next++])); };
|
||||
await Promise.all(Array.from({ length: together }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
// Gli sfondi in ordine di distanza dalla scena di partenza, seguendo le uscite; in fondo quelle non raggiungibili.
|
||||
export function backgroundOrder(story, first) {
|
||||
const seen = new Set([first]), queue = [first];
|
||||
for (let i = 0; i < queue.length; i++)
|
||||
for (const h of story.scenes[queue[i]]?.hotspots ?? [])
|
||||
if (h.exit && story.scenes[h.exit.scene] && !seen.has(h.exit.scene)) { seen.add(h.exit.scene); queue.push(h.exit.scene); }
|
||||
for (const id of Object.keys(story.scenes)) if (!seen.has(id)) queue.push(id);
|
||||
return [...new Set(queue.map(id => story.scenes[id].background).filter(Boolean))];
|
||||
}
|
||||
|
||||
// Il personaggio alto PERSON pixel a grandezza 1 (per `size` 1), con i piedi in (x, y); rivolto a sinistra si specchia.
|
||||
@@ -103,8 +139,9 @@ function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing, size = 1) {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function render(ctx, game, { hover, spots } = {}) {
|
||||
export function render(ctx, game, { hover, spots, debug } = {}) {
|
||||
const scene = game.scene, s = game.state, bg = backgrounds.get(scene.background);
|
||||
if (!bg && scene.background) loadBackground(scene.background);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, WIDTH, HEIGHT);
|
||||
if (bg) ctx.drawImage(bg, 0, 0, WIDTH, HEIGHT);
|
||||
@@ -122,6 +159,7 @@ export function render(ctx, game, { hover, spots } = {}) {
|
||||
}
|
||||
for (const q of queue.sort((a, b) => a.y - b.y)) q.draw();
|
||||
if (spots) drawSpots(ctx, game);
|
||||
if (debug) drawDebug(ctx, game, debug);
|
||||
if (hover) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
||||
ctx.setLineDash([3, 3]);
|
||||
@@ -152,3 +190,52 @@ function drawSpots(ctx, game) {
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Modalità debug (?debug nell'indirizzo): area calpestabile coi vertici numerati, tutti gli hotspot col loro id
|
||||
// (tratteggiati quelli spenti dal loro `when`), punti dove si va (`at`), dove si parla e dove stanno i personaggi.
|
||||
// In basso le coordinate del puntatore e i punti segnati con Maiusc+clic, per scrivere scene a mano.
|
||||
function drawDebug(ctx, game, { mouse, points = [], help }) {
|
||||
const scene = game.scene, active = new Set(game.hotspots());
|
||||
ctx.save();
|
||||
ctx.font = '6px "Press Start 2P", monospace';
|
||||
ctx.lineWidth = 1;
|
||||
const label = (text, x, y, color) => {
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.fillRect(x - 1, y - 7, ctx.measureText(text).width + 2, 8);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(text, x, y);
|
||||
};
|
||||
const cross = ([x, y], color) => { ctx.strokeStyle = color; ctx.beginPath(); ctx.moveTo(x - 3, y); ctx.lineTo(x + 3, y); ctx.moveTo(x, y - 3); ctx.lineTo(x, y + 3); ctx.stroke(); };
|
||||
if (scene.walk) {
|
||||
ctx.fillStyle = 'rgba(80,220,120,0.18)';
|
||||
ctx.strokeStyle = '#50dc78';
|
||||
ctx.beginPath();
|
||||
scene.walk.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
scene.walk.forEach(([x, y], i) => label(String(i), Math.min(WIDTH - 12, x + 2), Math.max(8, y - 2), '#50dc78'));
|
||||
}
|
||||
for (const h of scene.hotspots ?? []) {
|
||||
const on = active.has(h), color = h.exit ? '#ffd27a' : on ? '#7ad7ff' : '#999';
|
||||
ctx.setLineDash(on ? [] : [2, 2]);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.strokeRect(...h.box);
|
||||
ctx.setLineDash([]);
|
||||
label(h.id, Math.max(1, Math.min(WIDTH - ctx.measureText(h.id).width - 2, h.box[0] + 1)), Math.max(8, h.box[1] + 8), color);
|
||||
if (on) cross(h.at ?? [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3]], color);
|
||||
if (h.talkAt) cross(h.talkAt, '#ff8ad8');
|
||||
}
|
||||
for (const [who, p] of Object.entries(scene.speakers ?? {})) { cross(p, '#ff8ad8'); label(who, p[0] + 4, p[1], '#ff8ad8'); }
|
||||
for (const p of game.people()) { cross(p.at, '#ffffff'); label(p.who, p.at[0] + 4, p.at[1] + 8, '#fff'); }
|
||||
if (points.length) {
|
||||
ctx.strokeStyle = '#ff5050';
|
||||
ctx.beginPath();
|
||||
points.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
|
||||
ctx.stroke();
|
||||
points.forEach(p => cross(p, '#ff5050'));
|
||||
}
|
||||
const where = mouse ? `${Math.round(mouse[0])}, ${Math.round(mouse[1])}` : '-';
|
||||
label(`${game.state.scene} ${where} ${help ?? ''}`, 2, HEIGHT - 3, '#fff');
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
+28
-2
@@ -28,13 +28,39 @@ export function clearSave(storage) {
|
||||
}
|
||||
}
|
||||
|
||||
// Volume e muto della musica: si ricordano anche senza una partita salvata.
|
||||
// File di salvataggio da portare su un altro dispositivo: la partita dentro una busta che dice di che gioco è.
|
||||
const FILE_GAME = 'la-leggenda-del-regolo';
|
||||
|
||||
export function saveFile(data, date = new Date()) {
|
||||
return JSON.stringify({ game: FILE_GAME, version: FILE_VERSION, saved: date.toISOString(), state: data }, null, 1);
|
||||
}
|
||||
|
||||
export function saveFileName(date = new Date()) {
|
||||
const p = n => String(n).padStart(2, '0');
|
||||
return `regolo-${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}-${p(date.getHours())}${p(date.getMinutes())}.json`;
|
||||
}
|
||||
|
||||
const FILE_VERSION = 1;
|
||||
|
||||
// La partita contenuta nel file: { state } se è un salvataggio di questo gioco in una versione che si sa leggere,
|
||||
// altrimenti { error: 'not-save' | 'newer' }. Che la partita torni con la storia lo controlla checkSave (game.js).
|
||||
export function readSaveFile(text) {
|
||||
let f;
|
||||
try { f = JSON.parse(text); } catch { return { error: 'not-save' }; }
|
||||
if (!f || f.game !== FILE_GAME || !f.state || typeof f.state !== 'object' || Array.isArray(f.state)) return { error: 'not-save' };
|
||||
if (!Number.isInteger(f.version) || f.version < 1) return { error: 'not-save' };
|
||||
if (f.version > FILE_VERSION) return { error: 'newer' };
|
||||
return { state: f.state };
|
||||
}
|
||||
|
||||
// Volume della musica, volume delle voci e muto: si ricordano anche senza una partita salvata.
|
||||
const AUDIO_KEY = 'leggenda-regolo-audio';
|
||||
|
||||
export function readAudio(storage) {
|
||||
try {
|
||||
const a = JSON.parse(storage.getItem(AUDIO_KEY));
|
||||
return a && typeof a.volume === 'number' ? { volume: a.volume, muted: !!a.muted } : null;
|
||||
if (!a || typeof a.volume !== 'number') return null;
|
||||
return { volume: a.volume, muted: !!a.muted, ...(typeof a.voices === 'number' && { voices: a.voices }) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
+15
-4
@@ -22,24 +22,35 @@ export function voiceLines(story) {
|
||||
}
|
||||
|
||||
export class Voices {
|
||||
constructor(index = {}, base = 'assets/voices/') {
|
||||
constructor(index = {}, base = 'assets/voices/', volume = 0.5) {
|
||||
this.index = index;
|
||||
this.base = base;
|
||||
this.audio = null;
|
||||
this.volume = volume; // scelto dal giocatore col cursore «bla»
|
||||
}
|
||||
|
||||
setVolume(v) {
|
||||
this.volume = Math.max(0, Math.min(1, v));
|
||||
if (this.audio) this.audio.volume = this.volume;
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
seconds(who, text) { return this.index[voiceKey(who, text)]?.sec; }
|
||||
|
||||
play(who, text, volume = 1) {
|
||||
play(who, text, muted = false) {
|
||||
this.stop();
|
||||
const entry = this.index[voiceKey(who, text)];
|
||||
if (!entry || volume <= 0) return false;
|
||||
if (!entry || muted || this.volume <= 0) return false;
|
||||
this.audio = new Audio(this.base + entry.file);
|
||||
this.audio.volume = Math.min(1, volume);
|
||||
this.audio.volume = this.volume;
|
||||
this.audio.play().catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
// La pausa ferma la voce a metà; riprendendo, la battuta continua da dove era rimasta.
|
||||
pause() { if (this.audio && !this.audio.ended) this.audio.pause(); }
|
||||
resume() { if (this.audio && this.audio.paused && !this.audio.ended) this.audio.play().catch(() => {}); }
|
||||
|
||||
stop() {
|
||||
if (this.audio) this.audio.pause();
|
||||
this.audio = null;
|
||||
|
||||
+93
-21
@@ -1,9 +1,9 @@
|
||||
// Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti).
|
||||
import { story } from './story/story.js';
|
||||
import { Game, VERBS, VERB_LABELS } from './engine/game.js';
|
||||
import { Game, VERBS, VERB_LABELS, checkSave } from './engine/game.js';
|
||||
import { Command } from './engine/command.js';
|
||||
import { render, loadAssets, WIDTH, HEIGHT } from './engine/render.js';
|
||||
import { readSave, writeSave, clearSave, readAudio, writeAudio } from './engine/storage.js';
|
||||
import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js';
|
||||
import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
|
||||
import { Music, trackFor } from './engine/music.js';
|
||||
import { Voices } from './engine/voices.js';
|
||||
|
||||
@@ -18,26 +18,32 @@ const debug = new URLSearchParams(location.search).has('debug');
|
||||
|
||||
let game = null, command = null, hoverTarget = null, mouse = null, running = false;
|
||||
|
||||
// --- Musica: parte subito se il browser lo permette, altrimenti al primo gesto; volume e muto si ricordano.
|
||||
const music = new Music((storage && readAudio(storage)) ?? {});
|
||||
const saveAudio = () => storage && writeAudio(storage, music.settings());
|
||||
// --- Musica: parte subito se il browser lo permette, altrimenti al primo gesto; volumi e muto si ricordano.
|
||||
const audioSettings = (storage && readAudio(storage)) ?? {};
|
||||
const music = new Music(audioSettings);
|
||||
// --- Voci: l'indice arriva con fetch; finché non c'è (o se manca) le battute restano mute. Seguono il muto della musica,
|
||||
// ma hanno il loro volume.
|
||||
const voices = new Voices({}, undefined, audioSettings.voices);
|
||||
const saveAudio = () => storage && writeAudio(storage, { ...music.settings(), voices: voices.volume });
|
||||
function showSound() {
|
||||
$('mute').setAttribute('aria-pressed', String(music.muted));
|
||||
$('mute').setAttribute('aria-label', music.muted ? "Musica: rimetti l'audio" : "Musica: togli l'audio");
|
||||
$('volume').value = Math.round(music.volume * 100);
|
||||
$('voice-volume').value = Math.round(voices.volume * 100);
|
||||
}
|
||||
for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, () => music.unlock(), { capture: true });
|
||||
$('mute').addEventListener('click', () => { music.toggle(); saveAudio(); showSound(); });
|
||||
// Il muto zittisce subito anche la voce che sta parlando.
|
||||
const toggleMute = () => { if (music.toggle()) voices.stop(); saveAudio(); showSound(); };
|
||||
$('mute').addEventListener('click', toggleMute);
|
||||
$('volume').addEventListener('input', e => { music.setVolume(e.target.value / 100); saveAudio(); showSound(); });
|
||||
$('voice-volume').addEventListener('input', e => { voices.setVolume(e.target.value / 100); saveAudio(); showSound(); });
|
||||
showSound();
|
||||
window.music = music; // per i test nel browser
|
||||
|
||||
// --- Voci: l'indice arriva con fetch; finché non c'è (o se manca) le battute restano mute. Seguono il muto della musica.
|
||||
const voices = new Voices();
|
||||
fetch('assets/voices/index.json').then(r => r.ok ? r.json() : {}).then(index => { voices.index = index; }).catch(() => {});
|
||||
window.voices = voices;
|
||||
let voiced = null;
|
||||
const say = (who, text) => voices.play(who, text, music.muted ? 0 : 1);
|
||||
const say = (who, text) => voices.play(who, text, music.muted);
|
||||
|
||||
function start(state) {
|
||||
game = new Game(story, { state, onSave: data => storage && writeSave(storage, data), voiceTime: (who, text) => voices.seconds(who, text) });
|
||||
@@ -118,7 +124,7 @@ function openPause() {
|
||||
if (!running) return;
|
||||
running = false;
|
||||
music.hold = true;
|
||||
voices.stop();
|
||||
voices.pause();
|
||||
command.reset();
|
||||
$('pause-map').hidden = game.mapPlaces().length < 2;
|
||||
$('pause').hidden = false;
|
||||
@@ -126,6 +132,7 @@ function openPause() {
|
||||
function closePause() {
|
||||
$('pause').hidden = true;
|
||||
music.hold = false;
|
||||
voices.resume();
|
||||
idleSince = performance.now();
|
||||
running = true;
|
||||
}
|
||||
@@ -135,15 +142,50 @@ $('pause-map').addEventListener('click', () => { closePause(); openMap(); });
|
||||
$('to-title').addEventListener('click', () => {
|
||||
$('pause').hidden = true;
|
||||
music.hold = false;
|
||||
game.onSave(game.snapshot());
|
||||
voices.stop();
|
||||
// Se si esce a metà di una sequenza resta salvato l'ultimo punto fermo: niente enigmi risolti a metà.
|
||||
const data = game.savable();
|
||||
if (data) game.onSave(data);
|
||||
showTitle();
|
||||
});
|
||||
$('close-map').addEventListener('click', () => { $('map').hidden = true; });
|
||||
|
||||
// --- Salva su file / Carica: la partita in un file .json da aprire su un altro dispositivo.
|
||||
$('save-file').addEventListener('click', () => {
|
||||
const data = game.savable();
|
||||
if (!data) return;
|
||||
game.onSave(data);
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([saveFile(data)], { type: 'application/json' }));
|
||||
a.download = saveFileName();
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
});
|
||||
$('load-game').addEventListener('click', () => { $('load-error').hidden = true; $('load-file').click(); });
|
||||
const LOAD_ERRORS = {
|
||||
'not-save': 'Questo file non è un salvataggio della Leggenda del Regolo.',
|
||||
newer: 'Questo salvataggio viene da una versione più nuova del gioco: aggiorna la pagina e riprova.',
|
||||
story: 'Questo salvataggio non torna con questa versione del gioco.',
|
||||
};
|
||||
const loadError = key => { $('load-error').textContent = LOAD_ERRORS[key]; $('load-error').hidden = false; };
|
||||
// Il salvataggio del browser si sostituisce solo dopo che il file è stato controllato e la partita è ripartita.
|
||||
$('load-file').addEventListener('change', async e => {
|
||||
const file = e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
const { state, error } = readSaveFile(await file.text());
|
||||
if (error) return loadError(error);
|
||||
const wrong = checkSave(story, state);
|
||||
if (wrong) { console.warn('Salvataggio scartato:', wrong); return loadError('story'); }
|
||||
start(state);
|
||||
if (storage) writeSave(storage, game.snapshot());
|
||||
});
|
||||
|
||||
function showTitle() {
|
||||
running = false;
|
||||
const saved = storage && readSave(storage);
|
||||
$('continue').hidden = !saved || saved.ended;
|
||||
$('load-error').hidden = true;
|
||||
$('title').hidden = false;
|
||||
}
|
||||
|
||||
@@ -152,11 +194,36 @@ const scenePoint = e => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return [(e.clientX - r.left) / r.width * WIDTH, (e.clientY - r.top) / r.height * HEIGHT];
|
||||
};
|
||||
// --- Debug (?debug): Maiusc+clic segna un punto e ne copia le coordinate; K copia tutti i punti come poligono
|
||||
// (per `walk`), X li cancella, G nasconde e mostra il disegno di debug. Il resto si fa dalla console (game.go('oratorio')).
|
||||
const debugPoints = [];
|
||||
let debugShown = true;
|
||||
const copy = text => navigator.clipboard?.writeText(text).catch(() => {});
|
||||
function debugPoint(p) {
|
||||
const point = p.map(Math.round);
|
||||
debugPoints.push(point);
|
||||
copy(`[${point.join(', ')}]`);
|
||||
console.log('punto', point);
|
||||
}
|
||||
function debugKey(e) {
|
||||
if (!debug) return false;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === 'k') { const text = `[${debugPoints.map(p => `[${p.join(', ')}]`).join(', ')}]`; copy(text); console.log(text); return true; }
|
||||
if (k === 'x') { debugPoints.length = 0; return true; }
|
||||
if (k === 'g') { debugShown = !debugShown; return true; }
|
||||
return false;
|
||||
}
|
||||
const debugView = () => debug && debugShown && game && { mouse, points: debugPoints, help: `Maiusc+clic punto · K copia ${debugPoints.length} · X azzera · G` };
|
||||
|
||||
// Col dito si prende anche un oggetto mancato di poco: TOUCH_REACH pixel di schermo, in coordinate di scena.
|
||||
const TOUCH_REACH = 22;
|
||||
const reachOf = e => e.pointerType === 'touch' || e.pointerType === 'pen' ? TOUCH_REACH * WIDTH / canvas.getBoundingClientRect().width : 0;
|
||||
let press = null;
|
||||
canvas.addEventListener('pointerdown', e => {
|
||||
if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; }
|
||||
if (!running || e.button > 0) return;
|
||||
const point = scenePoint(e);
|
||||
press = { point, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true }); }, LONG_PRESS) };
|
||||
const point = scenePoint(e), reach = reachOf(e);
|
||||
press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) };
|
||||
});
|
||||
canvas.addEventListener('pointerup', e => {
|
||||
if (!press) return;
|
||||
@@ -165,7 +232,7 @@ canvas.addEventListener('pointerup', e => {
|
||||
if (!press.long) {
|
||||
const point = scenePoint(e), now = performance.now();
|
||||
const hurry = !!lastTap && now - lastTap.time < DOUBLE_CLICK && Math.hypot(point[0] - lastTap.point[0], point[1] - lastTap.point[1]) < 24;
|
||||
command.clickScene(point, { hurry });
|
||||
command.clickScene(point, { hurry, reach: press.reach });
|
||||
lastTap = hurry ? null : { time: now, point };
|
||||
}
|
||||
press = null;
|
||||
@@ -248,14 +315,15 @@ function updateChoices() {
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', e => {
|
||||
if (e.target === $('volume')) return;
|
||||
if (e.target === $('volume') || e.target === $('voice-volume')) return;
|
||||
if (debugKey(e)) return;
|
||||
if (!$('credits').hidden) { if (e.key === 'Escape') $('credits').hidden = true; return; }
|
||||
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; }
|
||||
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
|
||||
if ((e.key === 'p' || e.key === 'P') && running) { openMap(); return; }
|
||||
if ((e.key === 'h' || e.key === 'H') && running) { showSpots(); return; }
|
||||
if (!$('intro').hidden && [' ', 'Enter'].includes(e.key)) { e.preventDefault(); nextIntro(); return; }
|
||||
if (e.key === 'm' || e.key === 'M') { music.toggle(); saveAudio(); showSound(); return; }
|
||||
if (e.key === 'm' || e.key === 'M') { toggleMute(); return; }
|
||||
if (!running) return;
|
||||
if ([' ', '.', 'Enter'].includes(e.key) && game.speech) { e.preventDefault(); game.skip(); return; }
|
||||
// Esc annulla il comando a metà; se non c'è niente da annullare, mette in pausa.
|
||||
@@ -299,7 +367,7 @@ function updateUi() {
|
||||
}
|
||||
}
|
||||
|
||||
let last = performance.now();
|
||||
let last = performance.now(), drawnStill = false;
|
||||
function frame(now) {
|
||||
// Il primo timestamp di requestAnimationFrame può precedere `last`: un dt negativo darebbe alla musica un volume
|
||||
// sotto zero, il browser lancerebbe un errore e il ciclo dei fotogrammi si fermerebbe (gioco congelato).
|
||||
@@ -311,11 +379,15 @@ function frame(now) {
|
||||
music.update(trackFor(story, { screen, scene: game?.state.scene }), dt);
|
||||
// Un minuto senza toccare niente, a gioco fermo: pausa da sola.
|
||||
if (running && now - idleSince > IDLE_PAUSE && !game.speech && !game.walking && !game.queue.length) openPause();
|
||||
if (game) {
|
||||
// A gioco fermo (titolo, pausa, fine) la scena non cambia: un ultimo disegno e poi niente, per la batteria del
|
||||
// telefono. Si ridisegna solo se nel frattempo arriva lo sfondo che mancava.
|
||||
const still = game && !running && `${game.state.scene}|${hasBackground(game.scene.background)}`;
|
||||
if (game && (!still || still !== drawnStill)) {
|
||||
if (running) game.update(dt);
|
||||
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil });
|
||||
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() });
|
||||
updateUi();
|
||||
}
|
||||
drawnStill = still;
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
@@ -355,4 +427,4 @@ game = new Game(story);
|
||||
command = new Command(game);
|
||||
showSplash();
|
||||
requestAnimationFrame(frame);
|
||||
loadAssets(story).then(ok => { if (ok.includes(false)) console.warn('Alcune immagini non sono state caricate.'); });
|
||||
loadAssets(story, (storage && readSave(storage))?.scene ?? story.start.scene).then(ok => { if (ok.includes(false)) console.warn('Alcune immagini non sono state caricate.'); });
|
||||
|
||||
Reference in New Issue
Block a user