diff --git a/docs/STATO.md b/docs/STATO.md
index f0fc344..d5531c8 100644
--- a/docs/STATO.md
+++ b/docs/STATO.md
@@ -75,6 +75,24 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
## Cosa manca
### Prossimi passi
+- **Revisione di Codex del 24 ottobre, parte tecnica fatta**:
+ - **salvataggi a punti fermi**: si salva solo quando ogni sequenza è finita (`Game.commit`, `Game.savable`). Uscire a metà di una
+ risposta o della casetta riporta a prima, e la squama presa resta salvata anche senza cambiare scena. Un test interrompe ogni
+ passo della soluzione e della catena del ghiacciolo;
+ - **Salva su file** (pausa) e **Carica** (titolo), per portare la partita su un altro dispositivo;
+ - **cursore delle voci** («bla», 50% di partenza) accanto a quello della musica. Il muto zittisce subito la voce in corso, e la pausa
+ la ferma e poi la riprende;
+ - **col dito** un oggetto mancato di poco (circa 22 pixel di schermo) si prende lo stesso; i verbi in orizzontale vanno su due colonne;
+ - **sfondi caricati per vicinanza** alla scena di partenza (o a quella salvata), tre alla volta; a gioco fermo non si ridisegna;
+ - **`?debug`**: area calpestabile coi vertici numerati, hotspot con id, punti `at`/`talkAt`/`speakers`, coordinate del
+ puntatore; Maiusc+clic segna e copia un punto, K copia il poligono, X azzera, G nasconde;
+ - corretto: i crediti dal titolo restavano dietro il titolo.
+ - seconda passata di Codex: **Carica** controlla versione e contenuto (`checkSave`) prima di sostituire il salvataggio del
+ browser; col dito gli **oggetti piccoli** (sotto i 24×24) vincono sui grandi che li contengono (la squama sulla panchina);
+ uno **sfondo fallito** si riprova dopo 2, 4 e 8 secondi, poi basta.
+- **Revisione di Codex, parte di storia (da decidere con Andrea)**: dare alla torcia un uso e una ricompensa, un obiettivo
+ riconoscibile (un taccuino?), indizi del Trebbio anche per gli enigmi nuovi; oggi la soluzione arriva alla fine in 10 passi
+ e lascia fuori indovinello, oratorio e catena del ghiacciolo.
- **Sessione del 23-24 ottobre**: il paese è quasi tutto in gioco (circa 40 scene, sfondi di Codex dalle foto di
Andrea), collegato come quello vero; un test controlla che ogni strada vada e torni. Nuovi: carattere a pixel
Press Start 2P, pulsante «?» per vedere cosa si tocca, mappa scorrevole sul telefono, titolo e introduzione a tutto
diff --git a/index.html b/index.html
index cb0d545..ed78e5f 100644
--- a/index.html
+++ b/index.html
@@ -18,7 +18,9 @@
-
+
+
+
Manzolo
@@ -28,7 +30,9 @@
La Leggenda del Regolo
San Piero a Sieve · Palio della Fortezza · 2007
-
+
+
+
Al Lele, che mi ha fatto conoscere Monkey Island.
@@ -52,10 +56,11 @@
+
-
La partita è salvata: dal titolo si riprende con «Continua».
+
La partita è salvata: dal titolo si riprende con «Continua». Con «Salva su file» la porti su un altro dispositivo e la apri con «Carica».
Crediti
diff --git a/src/engine/command.js b/src/engine/command.js
index 94a602e..6521784 100644
--- a/src/engine/command.js
+++ b/src/engine/command.js
@@ -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);
diff --git a/src/engine/game.js b/src/engine/game.js
index 07d905b..a35b4e2 100644
--- a/src/engine/game.js
+++ b/src/engine/game.js
@@ -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
}
}
diff --git a/src/engine/geometry.js b/src/engine/geometry.js
index 119e9e7..09a9df9 100644
--- a/src/engine/geometry.js
+++ b/src/engine/geometry.js
@@ -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));
diff --git a/src/engine/render.js b/src/engine/render.js
index 7d1f2f0..7acb4bb 100644
--- a/src/engine/render.js
+++ b/src/engine/render.js
@@ -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();
+}
diff --git a/src/engine/storage.js b/src/engine/storage.js
index d486bf3..6119d9e 100644
--- a/src/engine/storage.js
+++ b/src/engine/storage.js
@@ -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;
}
diff --git a/src/engine/voices.js b/src/engine/voices.js
index a7b379d..80a6bf0 100644
--- a/src/engine/voices.js
+++ b/src/engine/voices.js
@@ -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;
diff --git a/src/main.js b/src/main.js
index 1cabcb8..0f1e1c6 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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.'); });
diff --git a/style.css b/style.css
index f355ffd..ed4d28c 100644
--- a/style.css
+++ b/style.css
@@ -30,12 +30,13 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; }
#speech:empty { display: none; }
-/* Musica: nota per il muto e cursore del volume, in alto a destra sopra la scena (anche sul titolo). */
+/* Audio: nota per il muto, cursore della musica e cursore delle voci («bla»), in alto a destra sopra la scena (anche sul titolo). */
#sound { position: absolute; top: 6px; right: 6px; z-index: 2; display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 4px;
background: rgba(27, 20, 32, .62); border-radius: 18px; }
#mute { width: 30px; height: 30px; padding: 0; font: bold 12.96px var(--font); color: var(--accent); background: none; border: 0; cursor: pointer; }
#mute[aria-pressed="true"] { color: var(--dim); text-decoration: line-through; }
-#volume { width: clamp(60px, 10vw, 110px); accent-color: var(--accent); cursor: pointer; }
+#volume, #voice-volume { width: clamp(50px, 9vw, 100px); accent-color: var(--accent); cursor: pointer; }
+.sound-label { margin-left: 4px; font-size: 9px; color: var(--accent); }
#sentence { min-height: 1.8em; padding: .25em .6em; text-align: center; color: var(--blue); font-size: clamp(10.8px, 1.44vw, 15.84px); font-weight: bold; background: var(--panel); }
@@ -140,7 +141,8 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
#pause { z-index: 3; background: rgba(15, 10, 20, .9); }
.pause-buttons { flex-direction: column; align-items: stretch; min-width: min(70%, 16em); }
-#pause .hint { color: var(--dim); }
+#pause .hint { color: var(--dim); max-width: 34em; text-align: center; }
+.load-error { margin: .6em 0 0; padding: .4em .8em; color: #fff; background: #7a1f1f; border-radius: 6px; font-size: max(9px, calc(var(--u) * 1.4)); }
#map { z-index: 3; justify-content: flex-start; padding-top: 2.5%; background: rgba(15, 10, 20, .94); }
#map h2 { font-size: clamp(12.96px, 2.16vw, 23.04px); }
/* Sui telefoni la pianta resta grande quanto serve per leggerla e si scorre col dito. */
@@ -155,7 +157,7 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#map-board button.here { color: var(--ink); background: #2f5fb3; cursor: default; }
#map-board button.here::after { content: ' · sei qui'; font-weight: normal; }
/* I crediti crescono con chi contribuisce: scorre solo l'elenco, dedica e Chiudi restano sempre in vista. */
-#credits { z-index: 3; justify-content: flex-start; overflow: hidden; padding-top: 4%; background: rgba(15, 10, 20, .96); }
+#credits { justify-content: flex-start; overflow: hidden; padding-top: 4%; background: rgba(15, 10, 20, .96); }
#credits .credits-dedication { margin: .2em 0 .5em !important; font-size: clamp(10px, 1.6vw, 17px); }
/* Due colonne: il ruolo a destra della sua colonna, il nome a sinistra della sua, allineati sulla riga. */
#credits dl { flex: 1 1 auto; min-height: 0; overflow-y: auto; align-content: start; padding-right: .4em; margin: .2em 0 0; display: grid; grid-template-columns: 1fr 1fr; column-gap: 1em; row-gap: .45em; align-items: baseline; width: min(100%, 44em); }
@@ -182,9 +184,10 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
#panel { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); flex: 1; min-height: 0; gap: 6px; padding: 6px; }
#inventory { grid-template-columns: repeat(2, 1fr); min-height: 0; height: auto; }
#verbs button, #inventory button, #choices li { font-size: 10.08px; }
- /* La colonna di lato è stretta: verbi piccoli che vanno a capo, mai più larghi della colonna (sennò il telefono
- allarga la pagina e taglia tutto a destra). */
- #verbs button { font-size: clamp(6px, 1vw, 8px); padding: .7em 0; letter-spacing: -.04em; }
+ /* La colonna di lato è stretta: verbi su due colonne, così il dito li prende e si leggono; mai più larghi della
+ colonna (sennò il telefono allarga la pagina e taglia tutto a destra). */
+ #verbs { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ #verbs button { font-size: clamp(8px, 1.25vw, 10px); padding: .75em 0; letter-spacing: -.04em; }
#inventory button { font-size: 8px; }
/* Pausa, mappa, crediti e titolo: tutto dentro l'altezza della scena. */
.overlay { gap: .3em; padding: 6px; overflow-y: auto; }
diff --git a/tests/browser.mjs b/tests/browser.mjs
index ba7e1f9..f321d53 100644
--- a/tests/browser.mjs
+++ b/tests/browser.mjs
@@ -3,7 +3,7 @@
// npm run browser
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
-import { mkdirSync } from 'node:fs';
+import { mkdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { solution } from '../src/story/solution.js';
@@ -13,12 +13,12 @@ const server = spawn('python3', ['-m', 'http.server', String(PORT), '--bind', '1
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const errors = [];
-async function open(viewport) {
- const page = await browser.newPage({ viewport });
+async function open(viewport, query = '') {
+ const page = await browser.newPage({ viewport, acceptDownloads: true });
page.on('pageerror', e => errors.push(e.message));
page.on('console', m => m.type() === 'error' && errors.push(m.text()));
for (let i = 0; i < 50; i++) {
- try { await page.goto(`http://127.0.0.1:${PORT}/`); break; } catch { await page.waitForTimeout(100); }
+ try { await page.goto(`http://127.0.0.1:${PORT}/${query}`); break; } catch { await page.waitForTimeout(100); }
}
return page;
}
@@ -63,6 +63,9 @@ try {
assert.match(await page.textContent('#credits'), /Crediti\s*Al Lele/, 'la dedica al Lele viene per prima');
assert.match(await page.textContent('#credits'), /Yuri Baldi/);
assert.match(await page.textContent('#credits'), /Francesco Bacci/);
+ const onTop = await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
+ return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; });
+ assert.ok(onTop, 'i crediti stanno sopra il titolo, non dietro');
await page.screenshot({ path: `${OUT}/01c-crediti.png` });
// L'elenco può scorrere; la dedica e il pulsante Chiudi devono restare dentro lo schermo.
const fitsCredits = await page.evaluate(() => { const c = document.getElementById('credits').getBoundingClientRect(), b = document.getElementById('close-credits').getBoundingClientRect(); return b.bottom <= c.bottom && b.top >= c.top; });
@@ -108,6 +111,32 @@ try {
await page.keyboard.press('Escape');
await page.waitForSelector('#pause:not([hidden])');
await page.screenshot({ path: `${OUT}/02b-pausa.png` });
+ // Salva su file: si scarica la partita; dal titolo «Carica» la riapre anche senza il salvataggio del browser.
+ const [download] = await Promise.all([page.waitForEvent('download'), page.click('#save-file')]);
+ assert.match(download.suggestedFilename(), /^regolo-\d{4}-\d\d-\d\d-\d{4}\.json$/);
+ const savedFile = `${OUT}/partita.json`;
+ await download.saveAs(savedFile);
+ const before = await page.evaluate(() => game.snapshot());
+ await page.click('#to-title');
+ await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
+ await page.setInputFiles('#load-file', { name: 'altro.json', mimeType: 'application/json', buffer: Buffer.from('{"ciao":1}') });
+ await page.waitForSelector('#load-error:not([hidden])');
+ assert.ok(await page.isVisible('#title'), 'un file sbagliato lascia il titolo dov\'è');
+ // Una scena che non esiste: rifiutato, e il salvataggio del browser resta com'era.
+ await page.evaluate(() => localStorage.setItem('leggenda-regolo-save', '{"scene":"piazza","resta":true}'));
+ const lost = JSON.parse(readFileSync(savedFile, 'utf8'));
+ lost.state.scene = 'luna';
+ await page.setInputFiles('#load-file', { name: 'luna.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(lost)) });
+ await page.waitForFunction(() => /non torna/.test(document.getElementById('load-error').textContent));
+ assert.ok(await page.isVisible('#title'));
+ assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')).resta), true, 'il salvataggio del browser non è toccato');
+ await page.evaluate(() => localStorage.removeItem('leggenda-regolo-save'));
+ await page.setInputFiles('#load-file', savedFile);
+ await page.waitForSelector('#title', { state: 'hidden' });
+ assert.deepEqual(await page.evaluate(() => game.snapshot()), before, 'la partita caricata è quella salvata');
+ assert.ok(await page.evaluate(() => !!localStorage.getItem('leggenda-regolo-save')), 'e diventa quella di «Continua»');
+ await page.keyboard.press('Escape');
+ await page.waitForSelector('#pause:not([hidden])');
await page.click('#resume');
assert.ok(await page.isHidden('#pause'));
await page.click('#pause-button');
@@ -121,7 +150,9 @@ try {
assert.ok(playing, 'musica del paese: ' + await page.evaluate(() => music.current?.src));
await page.click('#mute');
await page.locator('#volume').fill('30');
- assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false });
+ await page.locator('#voice-volume').fill('70');
+ assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7 });
+ assert.equal(await page.evaluate(() => voices.volume), 0.7);
for (const [verb, target, item] of solution) {
if (verb === 'choose') {
@@ -144,6 +175,8 @@ try {
await page.screenshot({ path: `${OUT}/06-fine.png` });
await page.click('#ending .open-credits');
assert.ok(await page.isVisible('#credits'), 'crediti anche dalla fine');
+ assert.ok(await page.evaluate(() => { const r = document.getElementById('close-credits').getBoundingClientRect();
+ return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.id === 'close-credits'; }), 'i crediti stanno sopra la fine');
await page.click('#close-credits');
assert.ok(await page.isHidden('#credits'));
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')));
@@ -208,8 +241,24 @@ try {
await landscape.click('#skip-intro');
await settle(landscape);
assert.equal(await landscape.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
+ assert.ok(await landscape.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.getBoundingClientRect().height >= 26)), 'in orizzontale i verbi si leggono interi e il dito li prende');
await landscape.screenshot({ path: `${OUT}/09-orizzontale.png`, fullPage: true });
+ // Debug (?debug): area calpestabile e hotspot disegnati; Maiusc+clic segna punti senza muovere Andrea, X li azzera.
+ const dbg = await open({ width: 1280, height: 860 }, '?debug');
+ await dbg.click('#new-game');
+ await dbg.click('#skip-intro');
+ await settle(dbg);
+ const start = await dbg.evaluate(() => [game.state.x, game.state.y]);
+ await dbg.keyboard.down('Shift');
+ await clickScene(dbg, [100, 300]);
+ await clickScene(dbg, [200, 320]);
+ await dbg.keyboard.up('Shift');
+ await dbg.mouse.move(700, 400);
+ await dbg.waitForTimeout(200);
+ assert.deepEqual(await dbg.evaluate(() => [game.state.x, game.state.y]), start, 'Maiusc+clic non muove Andrea');
+ await dbg.screenshot({ path: `${OUT}/10-debug.png` });
+
assert.deepEqual(errors, [], 'errori nella console');
console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`);
} finally {
diff --git a/tests/helpers.mjs b/tests/helpers.mjs
index 1ed2938..336a5bb 100644
--- a/tests/helpers.mjs
+++ b/tests/helpers.mjs
@@ -6,7 +6,8 @@ export function settle(game, { maxSteps = 20000 } = {}) {
said.push(game.speech);
game.skip();
}
- if (!game.busy && !game.walking && !game.queue.length && !game.task) return said;
+ // Un ultimo fotogramma, come nel gioco vero: a sequenza finita il motore prende il punto fermo e salva.
+ if (!game.busy && !game.walking && !game.queue.length && !game.task) { game.update(0); return said; }
game.update(0.05);
}
throw new Error('Il gioco non si ferma: ' + JSON.stringify(game.snapshot()));
diff --git a/tests/logic.test.mjs b/tests/logic.test.mjs
index e1b23bd..1c0a176 100644
--- a/tests/logic.test.mjs
+++ b/tests/logic.test.mjs
@@ -195,3 +195,13 @@ test('col doppio clic si cammina al doppio della velocità', () => {
const slow = far(false), fast = far(true);
assert.ok(slow > 0 && Math.abs(fast - 2 * slow) < 1, `normale ${slow}, doppio ${fast}`);
});
+
+test('col dito un oggetto mancato di poco si prende lo stesso; il punto preciso vince sempre', () => {
+ const g = new Game(mini());
+ assert.equal(g.hotspotAt([95, 45])?.id, 'chiave_a_terra', 'dentro');
+ assert.equal(g.hotspotAt([95, 60]), null, 'col mouse, fuori è fuori');
+ assert.equal(g.hotspotAt([95, 60], 12)?.id, 'chiave_a_terra', 'col dito, 10 sotto vale');
+ assert.equal(g.hotspotAt([95, 75], 12), null, 'ma non troppo lontano');
+ assert.equal(g.hotspotAt([62, 15], 20)?.id, 'tizio', 'vince il più vicino');
+ assert.equal(g.hotspotAt([3, 50], 20)?.id, 'uscita', 'chi è preso in pieno vince sui vicini');
+});
diff --git a/tests/render.test.mjs b/tests/render.test.mjs
new file mode 100644
index 0000000..41cd62a
--- /dev/null
+++ b/tests/render.test.mjs
@@ -0,0 +1,43 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { loadBackground, hasBackground, RETRIES, RETRY_WAIT } from '../src/engine/render.js';
+
+// Un Image finto: fallisce le prime `fails` volte per ogni indirizzo, e conta le richieste.
+function fakeImage(fails) {
+ const asked = new Map();
+ globalThis.Image = class {
+ set src(v) {
+ const n = (asked.get(v) ?? 0) + 1;
+ asked.set(v, n);
+ queueMicrotask(() => (n <= fails ? this.onerror() : this.onload()));
+ }
+ };
+ return asked;
+}
+
+test('uno sfondo che non arriva si riprova dopo un po\', non a ogni fotogramma, e alla fine arriva', async () => {
+ const asked = fakeImage(2), src = 'x/rete-ballerina.png';
+ try {
+ assert.equal(await loadBackground(src), false);
+ assert.equal(await loadBackground(src), false, 'subito dopo non si riprova');
+ assert.equal(asked.get(src), 1);
+ assert.equal(await loadBackground(src, Date.now() + RETRY_WAIT + 1), false, 'dopo l\'attesa si riprova');
+ assert.equal(asked.get(src), 2);
+ assert.equal(await loadBackground(src, Date.now() + RETRY_WAIT + 1), false, 'la seconda attesa è più lunga');
+ assert.equal(asked.get(src), 2);
+ assert.equal(await loadBackground(src, Date.now() + 2 * RETRY_WAIT + 1), true);
+ assert.ok(hasBackground(src));
+ } finally {
+ delete globalThis.Image;
+ }
+});
+
+test('uno sfondo che non c\'è proprio si lascia stare dopo pochi tentativi', async () => {
+ const asked = fakeImage(Infinity), src = 'x/non-esiste.png', later = () => Date.now() + 3600e3;
+ try {
+ for (let i = 0; i < 10; i++) await loadBackground(src, later());
+ assert.equal(asked.get(src), RETRIES + 1);
+ } finally {
+ delete globalThis.Image;
+ }
+});
diff --git a/tests/storage.test.mjs b/tests/storage.test.mjs
index 2bd9392..7a20b29 100644
--- a/tests/storage.test.mjs
+++ b/tests/storage.test.mjs
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { readSave, writeSave, clearSave, readAudio, writeAudio } from '../src/engine/storage.js';
+import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from '../src/engine/storage.js';
import { memoryStorage } from './helpers.mjs';
test('scrive, rilegge e cancella il salvataggio', () => {
@@ -22,11 +22,27 @@ test('un archivio rotto o bloccato non fa cadere il gioco', () => {
assert.equal(readSave(garbage), null);
});
-test('volume e muto della musica si ricordano', () => {
+test('volume della musica, volume delle voci e muto si ricordano', () => {
const s = memoryStorage();
assert.equal(readAudio(s), null);
- assert.ok(writeAudio(s, { volume: 0.3, muted: true }));
- assert.deepEqual(readAudio(s), { volume: 0.3, muted: true });
+ assert.ok(writeAudio(s, { volume: 0.3, muted: true, voices: 0.4 }));
+ assert.deepEqual(readAudio(s), { volume: 0.3, muted: true, voices: 0.4 });
+ writeAudio(s, { volume: 0.3, muted: false });
+ assert.deepEqual(readAudio(s), { volume: 0.3, muted: false }, 'un salvataggio di prima delle voci resta valido');
s.setItem('leggenda-regolo-audio', '{rotto');
assert.equal(readAudio(s), null);
});
+
+test('la partita va in un file e torna indietro uguale; un file qualsiasi non si carica', () => {
+ const state = { scene: 'piazza', x: 300, y: 340, facing: -1, inventory: ['chiave'], flags: { 'visto:piazza': true }, used: {}, ended: false };
+ const date = new Date(2007, 4, 27, 9, 5);
+ assert.deepEqual(readSaveFile(saveFile(state, date)), { state });
+ assert.equal(saveFileName(date), 'regolo-2007-05-27-0905.json');
+ const not = { error: 'not-save' }, env = extra => JSON.stringify({ game: 'la-leggenda-del-regolo', version: 1, state, ...extra });
+ assert.deepEqual(readSaveFile('{non è json'), not);
+ assert.deepEqual(readSaveFile(JSON.stringify(state)), not, 'senza la busta del gioco no');
+ assert.deepEqual(readSaveFile(JSON.stringify({ game: 'altro-gioco', version: 1, state })), not);
+ assert.deepEqual(readSaveFile(env({ state: [] })), not);
+ assert.deepEqual(readSaveFile(env({ version: undefined })), not, 'senza versione no');
+ assert.deepEqual(readSaveFile(env({ version: 2 })), { error: 'newer' }, 'da una versione più nuova no');
+});
diff --git a/tests/story.test.mjs b/tests/story.test.mjs
index ed42451..2fd66e0 100644
--- a/tests/story.test.mjs
+++ b/tests/story.test.mjs
@@ -1,8 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
+import { backgroundOrder } from '../src/engine/render.js';
import { story } from '../src/story/story.js';
import { solution } from '../src/story/solution.js';
-import { Game, VERBS } from '../src/engine/game.js';
+import { Game, VERBS, checkSave } from '../src/engine/game.js';
import { inside } from '../src/engine/geometry.js';
import { existsSync, readFileSync } from 'node:fs';
import { settle } from './helpers.mjs';
@@ -264,3 +265,101 @@ test('la catena del ghiacciolo: Tosca, cedrata, stecco, gomma, griglia, chiavett
g.act('open', 'casetta'); say();
assert.ok(g.state.flags.casetta_aperta && g.state.inventory.includes('torcia') && !g.state.inventory.includes('chiavetta'));
});
+
+// Uscire dal gioco in un istante qualsiasi di un passo deve riportare a prima del passo o a dopo, mai a metà:
+// si fa il passo fotogramma per fotogramma e a ogni fotogramma si guarda cosa verrebbe salvato.
+const essence = s => s && JSON.stringify({ ...s, x: 0, y: 0, facing: 0 });
+function stepChecked(g, label, step) {
+ const before = essence(g.checkpoint), seen = [];
+ step();
+ for (let i = 0; i < 20000; i++) {
+ seen.push(essence(g.savable()));
+ if (g.speech) g.skip();
+ if (!g.busy && !g.walking && !g.queue.length && !g.task) break;
+ g.update(0.05);
+ }
+ g.update(0);
+ const after = essence(g.savable());
+ for (const s of seen) assert.ok(s === before || s === after, `${label}: interrompendo a metà si salverebbe uno stato a metà`);
+ assert.equal(essence(g.checkpoint), after, `${label}: a passo finito il punto fermo è aggiornato`);
+}
+
+test('interrompere la soluzione in qualsiasi istante non lascia enigmi risolti a metà', () => {
+ const saves = [];
+ const g = new Game(story, { onSave: s => saves.push(s) });
+ stepChecked(g, 'inizio', () => g.go(story.start.scene, story.start.at));
+ for (const [verb, target, item] of solution)
+ stepChecked(g, `${verb} ${target}`, () => verb === 'choose' ? g.choose(g.options().findIndex(o => o.text === target)) : g.act(verb, target, item));
+ assert.ok(saves.at(-1).ended);
+});
+
+test('la squama presa resta salvata anche senza cambiare scena', () => {
+ const saves = [];
+ const g = new Game(story, { onSave: s => saves.push(s) });
+ g.go(story.start.scene, story.start.at); settle(g);
+ for (const [verb, target, item] of solution.slice(0, 5)) {
+ if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item);
+ settle(g);
+ }
+ assert.ok(g.state.inventory.includes('squama'));
+ assert.ok(saves.at(-1).inventory.includes('squama'), 'il salvataggio ha la squama');
+ const again = new Game(story, { state: JSON.parse(JSON.stringify(saves.at(-1))) });
+ assert.ok(again.state.inventory.includes('squama'));
+});
+
+test('interrompere la catena del ghiacciolo non perde la torcia né la chiavetta', () => {
+ const g = new Game(story);
+ const steps = [
+ ['go tennis', () => g.go('tennis_chiesa')], ['parla con Tosca', () => g.act('talk', 'chiosco')],
+ ...['Una Coca.', 'Un ghiacciolo al limone.', 'Una cedrata, da portare via.', 'Niente, grazie Tosca.']
+ .map(t => [t, () => g.choose(g.options().findIndex(o => o.text === t))]),
+ ['bevi la Coca', () => g.act('use', 'coca')], ['cedrata e ghiacciolo', () => g.act('use', 'cedrata', 'ghiacciolo')],
+ ['go oratorio', () => g.go('oratorio')], ['prendi la gomma', () => g.act('take', 'gomma_terra')],
+ ['gomma e stecco', () => g.act('use', 'gomma', 'stecco')], ['go ponticino', () => g.go('ponticino')],
+ ['griglia', () => g.act('use', 'griglia', 'stecco_gomma')], ['go area feste', () => g.go('area_feste')],
+ ['apri la casetta', () => g.act('open', 'casetta')],
+ ];
+ for (const [label, step] of steps) stepChecked(g, label, step);
+ assert.ok(g.checkpoint.inventory.includes('torcia') && g.checkpoint.flags.casetta_aperta);
+});
+
+test('gli sfondi si caricano dalla scena di partenza verso le più lontane, tutti e una volta sola', () => {
+ const order = backgroundOrder(story, story.start.scene);
+ const all = new Set(Object.values(story.scenes).map(s => s.background).filter(Boolean));
+ assert.equal(order.length, all.size);
+ assert.deepEqual(new Set(order), all);
+ assert.equal(order[0], story.scenes[story.start.scene].background, 'prima la piazza');
+ const near = story.scenes[story.start.scene].hotspots.filter(h => h.exit).map(h => story.scenes[h.exit.scene].background);
+ for (const bg of near) assert.ok(order.indexOf(bg) <= near.length, `${bg} è vicina e deve venire presto`);
+});
+
+test('col dito la squama si prende anche toccando la panchina tutt\'intorno; col mouse vale il punto preciso', () => {
+ const g = new Game(story);
+ g.go('piazza'); settle(g);
+ g.state.flags.luccichio = true;
+ const sq = g.hotspot('cosa_luccicante');
+ assert.ok(sq, 'la squama è in scena');
+ const [x, y, w, h] = sq.box, cx = x + w / 2, cy = y + h / 2, reach = 14;
+ for (const p of [[x - 8, cy], [x + w + 8, cy], [cx, y - 8], [cx, y + h + 3]])
+ assert.equal(g.hotspotAt(p, reach)?.id, 'cosa_luccicante', `col dito in ${p}`);
+ assert.notEqual(g.hotspotAt([x - 8, cy])?.id, 'cosa_luccicante', 'col mouse fuori dalla squama non la prende');
+ assert.equal(g.hotspotAt([cx, cy])?.id, 'cosa_luccicante');
+});
+
+test('un salvataggio da file si accetta solo se torna in tutto con la storia', () => {
+ const g = new Game(story);
+ g.go(story.start.scene, story.start.at); settle(g);
+ const good = JSON.parse(JSON.stringify(g.snapshot()));
+ assert.equal(checkSave(story, good), null);
+ const bad = change => checkSave(story, { ...good, ...change });
+ assert.match(bad({ scene: 'luna' }), /scena sconosciuta/);
+ assert.match(bad({ scene: 'toString' }), /scena sconosciuta/);
+ assert.ok(bad({ x: 'qui' }));
+ assert.ok(bad({ facing: 0 }));
+ assert.ok(bad({ inventory: ['astronave'] }), 'oggetto che non esiste');
+ assert.ok(bad({ inventory: 'squama' }));
+ assert.ok(bad({ flags: { a: 1 } }));
+ assert.ok(bad({ used: null }));
+ assert.ok(bad({ ended: 'no' }));
+ assert.ok(checkSave(story, null));
+});
diff --git a/tests/voices.test.mjs b/tests/voices.test.mjs
index 6c8786a..066c15e 100644
--- a/tests/voices.test.mjs
+++ b/tests/voices.test.mjs
@@ -56,6 +56,16 @@ test('le voci si cercano per chi parla e testo; senza indice il gioco resta muto
assert.equal(v.playing, false);
});
+test('le voci hanno il loro volume, più basso della musica finché non lo si cambia', () => {
+ const v = new Voices();
+ assert.equal(v.volume, 0.5);
+ assert.equal(v.setVolume(1.7), 1);
+ assert.equal(v.setVolume(-1), 0);
+ const quiet = new Voices({ [voiceKey('leone', 'Grr')]: { file: 'leone-x.mp3', sec: 1.5 } }, 'x/', 0);
+ assert.equal(quiet.play('leone', 'Grr'), false, 'a volume zero non parte niente');
+ assert.equal(new Voices({ [voiceKey('leone', 'Grr')]: { file: 'leone-x.mp3', sec: 1.5 } }).play('leone', 'Grr', true), false, 'col muto non parte niente');
+});
+
test('il leone e il castello parlano dalla loro posizione, non da quella di Andrea', () => {
const g = new Game(story);
g.go('salita_villa_adami', [340, 352]);
@@ -63,3 +73,25 @@ test('il leone e il castello parlano dalla loro posizione, non da quella di Andr
g.go('trebbio', [110, 345]);
assert.deepEqual(g.speakerPoint('castello'), story.scenes.trebbio.speakers.castello);
});
+
+test('la pausa ferma la voce e riprendendo continua; stop la toglie', () => {
+ globalThis.Audio = class { constructor(src) { this.src = src; this.paused = true; this.ended = false; } play() { this.paused = false; return Promise.resolve(); } pause() { this.paused = true; } };
+ try {
+ const v = new Voices({ [voiceKey('leone', 'Grr')]: { file: 'leone-x.mp3', sec: 1.5 } }, 'x/', 0.4);
+ assert.ok(v.play('leone', 'Grr'));
+ assert.equal(v.audio.volume, 0.4);
+ assert.ok(v.playing);
+ v.pause();
+ assert.ok(!v.playing && v.audio, 'in pausa la voce resta, ferma');
+ v.resume();
+ assert.ok(v.playing, 'riprende la stessa battuta');
+ v.setVolume(0.8);
+ assert.equal(v.audio.volume, 0.8, 'il cursore cambia anche la voce che sta parlando');
+ v.stop();
+ assert.ok(!v.playing && !v.audio);
+ v.resume();
+ assert.ok(!v.playing, 'dopo stop non riparte niente');
+ } finally {
+ delete globalThis.Audio;
+ }
+});