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
+93 -21
View File
@@ -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.'); });