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
+100 -1
View File
@@ -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));
});