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:
+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