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
+94 -7
View File
@@ -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();
}