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:
2026-10-24 12:06:49 +02:00
co-authored by Claude Opus 5.5
parent 66d946b230
commit 605e9a6dd1
17 changed files with 590 additions and 64 deletions
+56 -6
View File
@@ -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
}
}