Compare commits

..
6 Commits
Author SHA1 Message Date
manzolo 4bda52d9ac Versione 0.1.4 2026-10-25 14:28:24 +01:00
manzoloandClaude Opus 5.5 ec008b5ea1 Salutando l'Arcu: «Ci si becca da i' Trappola, ai' Barrettino!»
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 14:24:53 +01:00
manzoloandClaude Opus 5.5 73f3418bab Il cancello dell'oratorio: «se chiudo gli occhi vedo ancora l'A. Bettini», letto «Abbettini»
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 14:14:37 +01:00
manzoloandClaude Opus 5.5 ce4e47a2ed Chiudendo il libro: il «Nulla» viaggia troppo velocemente nell'animo umano
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 14:13:00 +01:00
manzoloandClaude Opus 5.5 f6fd2fdfdc Il libro dell'oratorio in primo piano: copertina, frontespizio, una pagina che gira col fruscio e «FINE» (immagini di Codex, testi disegnati dal codice); l'Arcu a scelte (Borgo e la Casa dell'Armando); il cancello dell'oratorio e l'A. Bettini
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 14:06:12 +01:00
manzoloandClaude Opus 5.5 9d9dc81bcb Idee: le mutande del Barone e la racchetta del Tozzino
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 13:21:59 +01:00
35 changed files with 370 additions and 56 deletions
+1
View File
@@ -12,6 +12,7 @@ COPY assets/fonts /usr/share/nginx/html/assets/fonts
COPY assets/voices /usr/share/nginx/html/assets/voices
COPY assets/credits /usr/share/nginx/html/assets/credits
COPY assets/effects /usr/share/nginx/html/assets/effects
COPY assets/closeups /usr/share/nginx/html/assets/closeups
# Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice.
RUN cd /usr/share/nginx/html \
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+38 -30
View File
@@ -427,13 +427,17 @@
"file": "andrea-42e270af3d.mp3",
"sec": 6.21
},
"andrea|Me lo prendo. Se è suo, il proprietario lo ritrova... prima o poi.": {
"file": "andrea-78a0a84b84.mp3",
"sec": 5.95
"andrea|Ricordo di aver visto il film da piccolo... era stupendo!": {
"file": "andrea-5528e4c7b0.mp3",
"sec": 4.06
},
"andrea|Il cancello di dietro.": {
"file": "andrea-a2f0124f4d.mp3",
"sec": 1.41
"andrea|Il cancello di dietro. Se chiudo gli occhi vedo ancora l'A. Bettini, il campo da calcio di quando eravamo piccoli: lassù ci sono ancora i riflettori.": {
"file": "andrea-bc60e0ce13.mp3",
"sec": 9.9
},
"andrea|Il primo vero campo del paese: inaugurato nel 1964 e intitolato ad Alberto Bettini, un giovane consigliere della società scomparso troppo presto.": {
"file": "andrea-f035c38acc.mp3",
"sec": 11.19
},
"andrea|Chiuso. Si apre solo quando c'è la festa.": {
"file": "andrea-5e1cd26d46.mp3",
@@ -803,14 +807,6 @@
"file": "andrea-8ddab2d024.mp3",
"sec": 5.79
},
"andrea|Arcu! Ma che è sempre là, Borgo?": {
"file": "andrea-aaeb40d873.mp3",
"sec": 3.42
},
"arcu|E chi lo sposta???": {
"file": "arcu-64735a5925.mp3",
"sec": 0.97
},
"andrea|Il ponte vecchio, coi suoi due archi. Chissà quante piene ha visto passare: io al primo temporale cerco riparo.": {
"file": "andrea-01ec1d03ec.mp3",
"sec": 7.67
@@ -1183,22 +1179,6 @@
"file": "andrea-23e40a2495.mp3",
"sec": 5.74
},
"andrea|Vediamo da dove comincia...": {
"file": "andrea-239bf733a6.mp3",
"sec": 1.26
},
"andrea|C'è una pagina sola. Con scritto: «FINE».": {
"file": "andrea-1d80123a55.mp3",
"sec": 3.93
},
"andrea|Infinita, dicevano.": {
"file": "andrea-77f1348d3a.mp3",
"sec": 2.13
},
"andrea|Leggere adesso? Prima il palio. E poi, a quanto pare, si fa presto.": {
"file": "andrea-3a6af3897f.mp3",
"sec": 6.06
},
"andrea|Un ghiacciolo al limone. Si scioglie a guardarlo.": {
"file": "andrea-8c9713081a.mp3",
"sec": 3.44
@@ -1451,6 +1431,26 @@
"file": "vecchietto-c8eae2e59e.mp3",
"sec": 4.88
},
"andrea|Ma che è sempre là, Borgo?": {
"file": "andrea-73c12c835b.mp3",
"sec": 2.34
},
"andrea|Come va alla Casa dell'Armando?": {
"file": "andrea-413cf7673b.mp3",
"sec": 1.56
},
"andrea|Ci si becca da i' Trappola, ai' Barrettino!": {
"file": "andrea-fca6f59a70.mp3",
"sec": 3.41
},
"arcu|E chi lo sposta???": {
"file": "arcu-64735a5925.mp3",
"sec": 0.97
},
"arcu|Mah... un si fa una sega...": {
"file": "arcu-5d07c15776.mp3",
"sec": 2.89
},
"andrea|Sono bloccato. Mi dai un consiglio?": {
"file": "andrea-42b02eb9ea.mp3",
"sec": 3.9
@@ -1527,6 +1527,14 @@
"file": "castello-dc8a873d86.mp3",
"sec": 4.36
},
"andrea|Mi ricordavo un finale diverso.": {
"file": "andrea-d9ff79ed54.mp3",
"sec": 1.73
},
"andrea|Il «Nulla» sta viaggiando troppo velocemente nell'animo umano, devo darmi una mossa!": {
"file": "andrea-71dc4628cc.mp3",
"sec": 4.81
},
"andrea|Niente di speciale.": {
"file": "andrea-6ebcf8e5de.mp3",
"sec": 1.04
+6
View File
@@ -90,3 +90,9 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone
gioco, come i nove bastioni.
- **Regole perché resti divertente**: le chiavi importanti non si buttano, una porta aperta resta aperta, al Trebbio
c'è sempre l'aiuto.
- **Le mutande del Barone** (Andrea, 25 ottobre; ci pensa ancora): si ritrovano e gli si riportano, e lui in cambio
dà qualcosa che serve alla storia. Proposta: appese come trofeo all'insegna del circolino di Campomigliaio
(l'Arcu ci scherza), e in cambio due pile per la torcia della casetta, scarica.
- **La racchetta del Tozzino** (Andrea, 25 ottobre): non la trova, Federer sta arrivando, ha paura di perdere e chiede
aiuto ad Andrea. Si potrebbe legare al poker del Barone (persa la stessa sera?). Serve un foglio sprite del Tozzino
senza racchetta, più una posa con la racchetta ritrovata.
+1
View File
@@ -11,6 +11,7 @@
<div id="stage">
<canvas id="scene" width="640" height="360" aria-label="La scena dell'avventura"></canvas>
<div id="speech" aria-live="polite"></div>
<button id="closeup-close" hidden title="Chiudi (Esc)">Chiudi</button>
<div id="corner">
<button id="pause-button" hidden title="Pausa (Esc)">Pausa</button>
<button id="map-button" hidden title="Mappa (P)">Mappa</button>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "leggenda-del-regolo",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"type": "module",
"scripts": {
+40 -2
View File
@@ -22,6 +22,9 @@ const SPEED = 95;
// Solo profilo, come i cicli di camminata: 'up' e 'down' lasciano il verso com'è.
const FACING = { left: -1, right: 1 };
const WALK = ['walk_1', 'walk_2', 'walk_3', 'walk_4'], STEP = 15, REACH = 0.45;
// Il primo piano di un libro (story.closeups, effetto `closeup`): copertina → frontespizio → una pagina girata →
// l'ultima pagina, poi dopo una pausa il commento. Tempi in secondi.
export const PAGE_TURN = 0.7, CLOSEUP_PAUSE = 1.4;
// Condizioni: 'flag', '!flag', 'has:oggetto', '!has:oggetto', oppure un elenco (tutte vere).
export function check(cond, state) {
@@ -115,15 +118,16 @@ export class Game {
this.hurry = false; // doppio clic: si cammina al doppio della velocità
this.reach = 0; // secondi di braccio teso quando prende qualcosa
this.back = false; // di spalle davanti a una porta o a una bacheca
this.closeup = null; // il primo piano aperto: { id, step: 'cover' | 'title' | 'turn' | 'end', t, age }
}
get scene() { return this.story.scenes[this.state.scene]; }
// Occupato: c'è qualcosa in corso che il giocatore non può interrompere. La camminata verso un punto
// 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 busy() { return !!this.speech || !!this.closeup || 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); }
get steady() { return !this.speech && !this.closeup && 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.
@@ -221,6 +225,27 @@ export class Game {
return false;
}
// Il primo piano: un tocco sfoglia (copertina → frontespizio → si gira la pagina); all'ultima pagina non fa niente.
closeupTap() {
const c = this.closeup;
if (!c || this.speech) return false;
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
else return false;
return true;
}
// Chiuso il libro si torna alla scena, e gli effetti che seguivano il primo piano ripartono.
closeupClose() {
const c = this.closeup;
if (!c) return false;
this.closeup = null;
this.speech = null;
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
if (c.step === 'end') this.queue.unshift(...(this.story.closeups?.[c.id]?.closing ?? []));
return true;
}
options() {
if (!this.dialogue || this.busy) return [];
const node = this.story.dialogues[this.dialogue.id].nodes[this.dialogue.node];
@@ -271,6 +296,8 @@ export class Game {
}
speakerPoint(who) {
// Nel primo piano Andrea parla da sopra il libro, non dalla scena oscurata dietro.
if (this.closeup && (!who || who === 'andrea')) return [320, 34];
if (!who || who === 'andrea') return [this.state.x, this.state.y - 62 * this.scaleAt(this.state.y)];
if (this.scene.speakers?.[who]) return this.scene.speakers[who];
const h = this.hotspot(who) ?? (this.scene.hotspots ?? []).find(x => x.id === who);
@@ -280,6 +307,7 @@ export class Game {
update(dt) {
this.time += dt;
if (this.closeup) this.closeup.age += dt;
if (this.speech) {
this.speech.left -= dt;
if (this.speech.left > 0) return;
@@ -310,6 +338,15 @@ export class Game {
this.walking = false;
this.task = null;
}
// Nel primo piano il tempo scorre solo per la pagina che gira e per la pausa prima del commento.
const c = this.closeup;
if (c) {
c.t += dt;
if (c.step === 'turn' && c.t >= PAGE_TURN) Object.assign(c, { step: 'end', t: 0 });
if (c.step === 'end' && !c.lines && c.t >= CLOSEUP_PAUSE) c.lines = [...(this.story.closeups?.[c.id]?.comment ?? [])];
if (c.lines?.length) this.run(c.lines.shift());
return;
}
// 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();
@@ -335,6 +372,7 @@ export class Game {
else if (e.node) this.dialogue.node = e.node;
else if (e.endDialogue) this.dialogue = null;
else if (e.end) s.ended = true;
else if (e.closeup) this.closeup = { id: e.closeup, step: 'cover', t: 0, age: 0 };
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
}
+80 -1
View File
@@ -1,6 +1,6 @@
// Disegno della scena su un canvas 640×360, ingrandito dal CSS senza sfocatura.
// Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art.
import { check } from './game.js';
import { check, PAGE_TURN } from './game.js';
import { ambientEffects, ambientHash } from './ambient.js';
import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360;
@@ -170,6 +170,7 @@ export function render(ctx, game, { hover, spots, debug, time = performance.now(
drawLights(ctx, game, time);
for (const shape of art) if (shape.top) drawShape(ctx, shape);
if (spots) drawSpots(ctx, game);
if (game.closeup) drawCloseup(ctx, game, time);
if (debug) drawDebug(ctx, game, debug);
if (hover) {
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
@@ -644,3 +645,81 @@ function drawDebug(ctx, game, { mouse, points = [], help }) {
label(`${game.state.scene} ${where} ${help ?? ''}`, 2, HEIGHT - 3, '#fff');
ctx.restore();
}
// --- Il primo piano di un libro (story.closeups, game.closeup): la scena si oscura, le mani alzano il libro dal basso.
// Le immagini hanno etichetta e pagine vuote: i testi si scrivono qui, nitidi. Una pagina sola gira, a fisarmonica
// sulla piega; sotto c'è già l'ultima pagina.
const ink = '#3a1f12', FONT = '"Press Start 2P", monospace';
function pageText(ctx, lines, [x, y, w, h], size, at = 0.5, color = ink) {
ctx.font = `${size}px ${FONT}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = color;
const gap = size * 1.7, top = y + h * at - (lines.length - 1) * gap / 2;
lines.forEach((line, i) => ctx.fillText(line, Math.round(x + w / 2), Math.round(top + i * gap)));
}
const twoLines = title => { const i = title.lastIndexOf(' '); return i > 0 ? [title.slice(0, i), title.slice(i + 1)] : [title]; };
function drawCloseup(ctx, game, time) {
const c = game.closeup, def = game.story.closeups?.[c.id];
if (!def) return;
ctx.save();
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
const src = c.step === 'cover' ? def.closed : def.open, img = backgrounds.get(src);
if (!img) { loadBackground(src); loadBackground(c.step === 'cover' ? def.open : def.closed); ctx.restore(); return; }
// Il libro sale dal basso (solo all'inizio), con le braccia che entrano dal bordo.
const rise = 1 - Math.min(1, c.age / 0.45), ox = Math.round((WIDTH - img.width) / 2);
const oy = Math.round(HEIGHT - img.height + 140 * rise * rise);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, ox, oy);
ctx.translate(ox, oy);
const lines = twoLines(def.title), { left, right, spine } = def.pages;
if (c.step === 'cover') pageText(ctx, lines, def.label, 8);
else {
const flip = c.step === 'turn' ? Math.min(1, c.t / PAGE_TURN) : c.step === 'end' ? 1 : 0;
// Sotto la pagina che gira c'è già l'ultima: «FINE», e nient'altro.
if (flip > 0) pageText(ctx, [def.last], right, 20, 0.46);
else pageText(ctx, lines, right, 8, 0.32);
if (flip > 0 && flip < 1) {
// La pagina che gira: si stringe sulla piega e passa a sinistra, col dorso un po' più scuro.
const e = (1 - Math.cos(Math.PI * flip)) / 2, k = Math.cos(Math.PI * e);
const lift = Math.round(Math.sin(Math.PI * e) * 10), [, y, , h] = right, pw = right[0] + right[2] - spine;
ctx.save();
ctx.translate(spine, 0);
ctx.scale(k || 0.001, 1);
ctx.fillStyle = k > 0 ? def.paper : '#e6cc9c';
ctx.beginPath();
ctx.moveTo(0, y); ctx.lineTo(pw, y - lift); ctx.lineTo(pw, y + h - lift); ctx.lineTo(0, y + h);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(58, 31, 18, 0.55)';
ctx.lineWidth = 1 / Math.max(0.2, Math.abs(k));
ctx.stroke();
if (k > 0) pageText(ctx, lines, [right[0] - spine, y - lift, right[2], h], 8, 0.32);
ctx.restore();
}
// Dove toccare per sfogliare: l'angolo della pagina che pulsa.
if (c.step === 'title' && !game.speech) {
const pulse = 0.5 + 0.5 * Math.sin(time / 260), cx = right[0] + right[2] - 2, cy = right[1] + right[3] - 2, d = 12 + 3 * pulse;
ctx.fillStyle = `rgba(232, 194, 90, ${0.55 + 0.4 * pulse})`;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx - d, cy); ctx.lineTo(cx, cy - d); ctx.closePath(); ctx.fill();
}
}
ctx.restore();
// Il suggerimento in alto, finché c'è qualcosa da sfogliare.
const hint = c.step === 'cover' ? 'Clic o tocco: apri il libro' : c.step === 'title' ? 'Clic o tocco: gira la pagina' : null;
if (hint && !game.speech && c.age > 0.5) {
ctx.save();
ctx.globalAlpha = 0.65 + 0.35 * Math.sin(time / 300);
ctx.font = `7px ${FONT}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
const tw = ctx.measureText(hint).width;
ctx.fillRect(WIDTH / 2 - tw / 2 - 6, 8, tw + 12, 14);
ctx.fillStyle = '#ffd27a';
ctx.fillText(hint, WIDTH / 2, 15);
ctx.restore();
}
}
+1
View File
@@ -23,6 +23,7 @@ export function voiceLines(story) {
visit(story.scenes);
visit(story.items);
visit(story.dialogues);
visit(story.closeups);
for (const text of ENGINE_LINES) add('andrea', text);
return [...seen.values()];
}
+42 -5
View File
@@ -385,11 +385,14 @@ let press = null;
canvas.addEventListener('pointerdown', e => {
if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; }
if (!running || e.button > 0) return;
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) { press = { closeup: true }; return; }
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;
if (press.closeup) { press = null; if (!game.skip()) game.closeupTap(); return; }
clearTimeout(press.timer);
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
if (!press.long) {
@@ -403,7 +406,7 @@ canvas.addEventListener('pointerup', e => {
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; });
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
if (running) command.clickScene(scenePoint(e), { look: true });
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
});
canvas.addEventListener('pointermove', e => {
if (!running) return;
@@ -489,6 +492,13 @@ window.addEventListener('keydown', e => {
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; }
if (!$('notebook').hidden) { if (['Escape', 'n', 'N'].includes(e.key)) $('notebook').hidden = true; return; }
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
// Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
if (running && game.closeup) {
if (e.key === 'Escape') game.closeupClose();
else if ([' ', 'Enter', 'ArrowRight'].includes(e.key)) { e.preventDefault(); if (!game.skip()) game.closeupTap(); }
else if (e.key === 'm' || e.key === 'M') toggleMute();
return;
}
if ((e.key === 'p' || e.key === 'P') && running) { openMap(); return; }
if ((e.key === 'h' || e.key === 'H') && running) { showSpots(); return; }
if ((e.key === 'n' || e.key === 'N') && running) { openNotebook(); return; }
@@ -502,6 +512,28 @@ window.addEventListener('keydown', e => {
if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1);
});
// --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio).
$('closeup-close').addEventListener('click', () => game?.closeupClose());
let turning = null, audioCtx = null;
function rustle() {
if (music.muted || voices.volume <= 0) return;
try {
audioCtx ??= new AudioContext();
const sr = audioCtx.sampleRate, len = Math.floor(sr * 0.42), buf = audioCtx.createBuffer(1, len, sr), d = buf.getChannelData(0);
// Un fruscio a strappi: rumore che si accende e si spegne, come la carta che striscia.
for (let i = 0; i < len; i++) { const t = i / len; d[i] = (Math.random() * 2 - 1) * Math.sin(Math.PI * t) * (0.55 + 0.45 * Math.sin(t * 38)); }
const src = audioCtx.createBufferSource(), filter = audioCtx.createBiquadFilter(), gain = audioCtx.createGain();
src.buffer = buf;
filter.type = 'bandpass';
filter.frequency.setValueAtTime(2400, audioCtx.currentTime);
filter.frequency.linearRampToValueAtTime(5200, audioCtx.currentTime + 0.42);
filter.Q.value = 0.8;
gain.gain.value = 0.5 * voices.volume;
src.connect(filter).connect(gain).connect(audioCtx.destination);
src.start();
} catch { /* senza audio si gira la pagina in silenzio */ }
}
// --- Fumetto sopra chi parla, tenuto dentro i bordi della scena.
function updateSpeech() {
const box = $('speech'), sp = game.speech;
@@ -528,16 +560,21 @@ function updateUi() {
// Il bersaglio si ricalcola a ogni fotogramma: la scena cambia anche se il mouse sta fermo.
hoverTarget = (game.state.inventory.includes(hoverItem) && hoverItem) || (mouse && game.hotspotAt(mouse)?.id) || null;
for (const b of $('verbs').children) b.setAttribute('aria-pressed', String(command.verb === b.dataset.verb));
const text = !running || game.speech || game.dialogue ? '' : command.sentence(hoverTarget);
const text = !running || game.speech || game.dialogue || game.closeup ? '' : command.sentence(hoverTarget);
if ($('sentence').textContent !== text) $('sentence').textContent = text;
updateInventory();
updateChoices();
updateSpeech();
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
$('map-button').hidden = !running || !!game.dialogue || game.mapPlaces().length < 2;
$('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
$('pause-button').hidden = !running;
$('spots-button').hidden = !running;
$('notebook-button').hidden = !running || !story.notebook;
$('spots-button').hidden = !running || !!game.closeup;
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
$('closeup-close').hidden = !running || !game.closeup;
// La pagina che comincia a girare fa il suo fruscio.
const step = game.closeup?.step ?? null;
if (step === 'turn' && turning !== 'turn') rustle();
turning = step;
if (game.state.ended && !game.speech && !game.queue.length) {
running = false;
$('ending').hidden = false;
+39 -9
View File
@@ -104,6 +104,23 @@ export const story = {
{ role: 'Carattere', names: ['Press Start 2P di CodeMan38 (licenza OFL)'] },
{ role: 'Programmazione', names: ['Manzolo, con Claude e Codex'] },
],
// I primi piani a tutto schermo (effetto `closeup`, disegnati da render.js): per ora il libro dell'oratorio.
// Le immagini (Codex, assets/closeups/) hanno l'etichetta e le pagine vuote: titolo e «FINE» li scrive il codice.
closeups: {
libro: {
closed: 'assets/closeups/libro-chiuso.png',
open: 'assets/closeups/libro-aperto.png',
title: 'La storia infinita',
last: 'FINE',
comment: [{ say: 'Mi ricordavo un finale diverso.' }],
// Chiuso dopo aver visto l'ultima pagina (idea di Andrea, 25 ottobre).
closing: [{ say: 'Il «Nulla» sta viaggiando troppo velocemente nell\'animo umano, devo darmi una mossa!' }],
// Dove scrivere, in pixel delle due immagini: l'etichetta della copertina, le due pagine, la piega.
label: [78, 48, 104, 54],
pages: { left: [56, 26, 96, 194], right: [163, 12, 101, 204], spine: 157 },
paper: '#fbe6bc',
},
},
characters: {
andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' },
vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' },
@@ -133,12 +150,9 @@ export const story = {
actions: {
drop: [{ say: 'Una pagina sola, e pure con la fine. Lo lascio qui per il prossimo lettore.' }, { drop: 'libro' }],
look: [{ say: '«La storia infinita». Bello spesso: questo mi tiene compagnia fino a settembre.' }],
open: [
{ say: 'Vediamo da dove comincia...' },
{ say: 'C\'è una pagina sola. Con scritto: «FINE».' },
{ say: 'Infinita, dicevano.' },
],
use: [{ say: 'Leggere adesso? Prima il palio. E poi, a quanto pare, si fa presto.' }],
// Il primo piano (story.closeups.libro): la battuta la scopre chi guarda, nessuno la racconta.
open: [{ closeup: 'libro' }],
use: [{ closeup: 'libro' }],
},
},
// La catena del ghiacciolo (idea di Andrea, 2026-10-24): la Tosca dà ghiacciolo e cedrata; il ghiacciolo inzuppato
@@ -656,13 +670,17 @@ export const story = {
art: [{ rect: [565, 203, 11, 5], fill: '#c46a3a' }, { rect: [565, 207, 11, 1], fill: '#f4efe4' }, { rect: [569, 204, 3, 3], fill: '#e8c25a' }],
actions: {
look: [{ say: 'Un libro dimenticato sulle tavolate. Sulla copertina c\'è scritto «La storia infinita».' }],
take: [{ give: 'libro' }, { set: 'libro_preso' }, { say: 'Me lo prendo. Se è suo, il proprietario lo ritrova... prima o poi.' }],
take: [{ give: 'libro' }, { set: 'libro_preso' }, { say: 'Ricordo di aver visto il film da piccolo... era stupendo!' }, { closeup: 'libro' }],
},
},
{
id: 'cancello', name: 'cancello', box: [477, 166, 23, 52], at: [488, 236], face: 'up',
actions: {
look: [{ say: 'Il cancello di dietro.' }],
// Il vecchio campo «Alberto Bettini» (Andrea, 25 ottobre; www.polisportivasanpieroasieve.it).
look: [
{ say: 'Il cancello di dietro. Se chiudo gli occhi vedo ancora l\'A. Bettini, il campo da calcio di quando eravamo piccoli: lassù ci sono ancora i riflettori.' },
{ say: 'Il primo vero campo del paese: inaugurato nel 1964 e intitolato ad Alberto Bettini, un giovane consigliere della società scomparso troppo presto.' },
],
open: [{ say: 'Chiuso. Si apre solo quando c\'è la festa.' }],
},
},
@@ -1321,7 +1339,7 @@ export const story = {
id: 'arcu', name: 'l\'Arcu', box: [400, 216, 38, 76], at: [362, 300], face: 'right', default: 'talk',
actions: {
look: [{ say: 'L\'Arcu, il mio amico di sempre. Davanti al circolino, come se ci fosse nato.' }],
talk: [{ say: 'Arcu! Ma che è sempre là, Borgo?' }, { say: 'E chi lo sposta???', who: 'arcu', pose: 'shrug' }],
talk: [{ dialogue: 'arcu' }],
},
},
],
@@ -1942,6 +1960,18 @@ export const story = {
},
},
},
// L'Arcu davanti al circolino (battute di Andrea, 25 ottobre). Lavora alla Casa dell'Armadio: «Armando» è apposta.
arcu: {
nodes: {
start: {
options: [
{ text: 'Ma che è sempre là, Borgo?', then: [{ say: 'E chi lo sposta???', who: 'arcu', pose: 'shrug' }] },
{ text: 'Come va alla Casa dell\'Armando?', then: [{ say: 'Mah... un si fa una sega...', who: 'arcu', pose: 'explain' }] },
{ text: 'Ci si becca da i\' Trappola, ai\' Barrettino!', next: 'end' },
],
},
},
},
// Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca.
castello: {
nodes: {
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.3';
export const VERSION = '0.1.4';
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
+3
View File
@@ -30,6 +30,9 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
font-size: clamp(10.08px, 1.66vw, 18.72px); font-weight: bold; line-height: 1.2; text-align: center;
text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; }
#speech:empty { display: none; }
/* Il primo piano del libro si chiude da qui (o con Esc). */
#closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold clamp(10px, 1.5vw, 15px) var(--font);
padding: .45em 1.2em; border-radius: 6px; border: 0; cursor: pointer; color: var(--bg); background: var(--accent); }
/* 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;
+40
View File
@@ -293,6 +293,46 @@ try {
await page.click('#close-map');
await page.setViewportSize({ width: 1280, height: 860 });
// Il libro dell'oratorio: preso, il primo piano; si sfoglia coi clic veri, «FINE» senza che nessuno lo dica,
// Esc chiude; dall'inventario «Apri» lo riapre e il pulsante Chiudi lo chiude. Intanto la scena dietro non risponde.
{
const book = await open({ width: 1280, height: 860 });
await book.click('#splash');
await book.click('#new-game');
await book.click('#rione [data-rione="campomigliaio"]');
await book.click('#skip-intro');
await settle(book);
await book.evaluate(() => game.go('oratorio', [540, 300]));
await settle(book);
await book.click('#verbs [data-verb="take"]');
await clickScene(book, await hotspotCenter(book, 'libro_dimenticato'));
await book.waitForFunction(() => game.closeup?.step === 'cover', null, { timeout: 15000 });
await book.waitForTimeout(600);
await book.screenshot({ path: `${OUT}/03a-libro-copertina.png` });
const where = await book.evaluate(() => [game.state.x, game.state.y]);
await clickScene(book, [320, 200]);
assert.equal(await book.evaluate(() => game.closeup.step), 'title', 'un clic apre il libro');
await clickScene(book, [320, 200]);
assert.equal(await book.evaluate(() => game.closeup.step), 'turn', 'un altro gira la pagina');
await book.waitForFunction(() => game.closeup?.step === 'end');
assert.equal(await book.evaluate(() => game.speech), null, '«FINE» non lo dice nessuno');
await book.screenshot({ path: `${OUT}/03b-libro-fine.png` });
await book.waitForFunction(() => game.speech?.text === 'Mi ricordavo un finale diverso.', null, { timeout: 5000 });
assert.deepEqual(await book.evaluate(() => [game.state.x, game.state.y]), where, 'i clic sul libro non muovono Andrea');
await book.keyboard.press('Space');
await book.keyboard.press('Escape');
assert.equal(await book.evaluate(() => game.closeup), null, 'Esc chiude il libro');
await book.waitForSelector('#closeup-close', { state: 'hidden' });
await settle(book);
await book.click('#verbs [data-verb="open"]');
await book.click('#inventory button');
await book.waitForFunction(() => game.closeup?.step === 'cover');
assert.ok(await book.isVisible('#closeup-close'), 'si vede il pulsante Chiudi');
await book.click('#closeup-close');
assert.equal(await book.evaluate(() => game.closeup), null, 'Chiudi chiude il libro');
await book.close();
}
const phone = await open({ width: 390, height: 844 });
await phone.click('#splash'); // un clic salta il cartello
assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo');
+2
View File
@@ -6,6 +6,8 @@ export function settle(game, { maxSteps = 20000 } = {}) {
said.push(game.speech);
game.skip();
}
// Un primo piano aperto aspetta il giocatore (sfogliare, chiudere): qui ci si ferma e decide il test.
if (game.closeup && !game.speech && game.reach <= 0) 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);
+44 -6
View File
@@ -8,7 +8,7 @@ import { inside } from '../src/engine/geometry.js';
import { existsSync, readFileSync } from 'node:fs';
import { settle } from './helpers.mjs';
const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end'];
const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end', 'closeup'];
// Visita ogni elenco di effetti della storia, dentro scene, oggetti, dialoghi e rami `if`.
function* effectLists() {
@@ -63,6 +63,11 @@ test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esist
if (e.give || e.drop) assert.ok(story.items[e.give ?? e.drop], `${where}: oggetto ${e.give ?? e.drop}`);
if (e.dialogue) assert.ok(story.dialogues[e.dialogue], `${where}: dialogo ${e.dialogue}`);
if (e.go) assert.ok(story.scenes[e.go], `${where}: scena ${e.go}`);
if (e.closeup) {
const c = story.closeups?.[e.closeup];
assert.ok(c, `${where}: primo piano ${e.closeup}`);
for (const src of [c.closed, c.open]) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`);
}
if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`);
}
for (const [id, d] of Object.entries(story.dialogues)) {
@@ -192,18 +197,46 @@ test('il foglio di Andrea ha tutti i fotogrammi e l\'immagine', () => {
}
});
test('il libro dell\'oratorio: «La storia infinita» ha una pagina sola', () => {
test('il libro dell\'oratorio: il primo piano si sfoglia, l\'ultima pagina è «FINE» e nessuno la dice prima', () => {
const g = new Game(story);
g.go('oratorio', [320, 345]);
settle(g);
assert.ok(g.hotspotAt([570, 205])?.id === 'libro_dimenticato', 'sulle tavolate il clic prende il libro, non le tavolate');
assert.ok(g.act('take', 'libro_dimenticato'));
settle(g);
const before = settle(g).map(s => s.text);
assert.deepEqual(before, ['Ricordo di aver visto il film da piccolo... era stupendo!']);
assert.ok(g.state.inventory.includes('libro'));
assert.equal(g.hotspot('libro_dimenticato'), undefined, 'preso, il libro non resta sul tavolo');
assert.equal(g.closeup?.step, 'cover', 'preso in mano, il primo piano sulla copertina');
// Durante il primo piano gli oggetti dell'oratorio non rispondono.
assert.equal(g.act('take', 'gomma_terra'), false);
assert.equal(g.walkTo([300, 340]), false);
assert.ok(g.closeupTap());
assert.equal(g.closeup.step, 'title');
assert.ok(g.closeupTap());
assert.equal(g.closeup.step, 'turn');
assert.equal(g.closeupTap(), false, 'una pagina sola: mentre gira non se ne gira un\'altra');
for (let i = 0; i < 20; i++) g.update(0.05);
assert.equal(g.closeup.step, 'end');
assert.equal(g.speech, null, '«FINE» si scopre guardando: nessuna battuta subito');
for (let i = 0; i < 40 && !g.speech; i++) g.update(0.05);
assert.equal(g.speech?.text, 'Mi ricordavo un finale diverso.');
assert.deepEqual(g.speakerPoint('andrea'), [320, 34], 'nel primo piano Andrea parla da sopra il libro');
for (let i = 0; i < 200; i++) g.update(0.05);
assert.equal(g.closeup?.step, 'end', '«FINE» resta finché non si chiude');
assert.equal(g.closeupTap(), false);
assert.ok(g.closeupClose());
assert.deepEqual(settle(g).map(s => s.text), ['Il «Nulla» sta viaggiando troppo velocemente nell\'animo umano, devo darmi una mossa!']);
assert.equal(g.closeup, null);
// Dall'inventario si riapre; chiuso prima della fine, nessun commento.
assert.ok(g.act('open', 'libro'));
const said = settle(g).map(s => s.text);
assert.ok(said.some(t => t.includes('FINE')), 'aperto, dice FINE');
settle(g);
assert.equal(g.closeup?.step, 'cover');
assert.ok(g.closeupClose());
assert.deepEqual(settle(g), []);
// Nessuna battuta racconta la gag: «FINE» c'è solo nel disegno.
const lines = JSON.stringify([story.items.libro, story.scenes.oratorio]);
assert.ok(!lines.includes('FINE'), 'nessuna battuta anticipa «FINE»');
});
test('la mappa: ogni scena ci sta, i posti compaiono solo visti, il viaggio rispetta i dialoghi', () => {
@@ -475,7 +508,12 @@ test('gli amici in giro: l\'Arcu al circolino, il Tozzino al cancello del tennis
const g = new Game(story);
settle(g);
const talk = (scene, who) => { g.go(scene); settle(g); g.act('talk', who); return settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`); };
assert.deepEqual(talk('campomigliaio_circolo', 'arcu'), ['andrea: Arcu! Ma che è sempre là, Borgo?', 'arcu: E chi lo sposta???']);
talk('campomigliaio_circolo', 'arcu');
const ask = text => { g.choose(g.options().findIndex(o => o.text === text)); return settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`); };
assert.deepEqual(ask('Ma che è sempre là, Borgo?'), ['andrea: Ma che è sempre là, Borgo?', 'arcu: E chi lo sposta???']);
assert.deepEqual(ask('Come va alla Casa dell\'Armando?'), ['andrea: Come va alla Casa dell\'Armando?', 'arcu: Mah... un si fa una sega...']);
ask('Ci si becca da i\' Trappola, ai\' Barrettino!');
assert.equal(g.dialogue, null);
assert.ok(talk('carza', 'tozzino').includes('tozzino: E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.'));
const baron = talk('polisportiva', 'barondeduke');
assert.ok(baron.includes('barondeduke: Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».'));
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""I primi piani (story.closeups) da un foglio di Codex su magenta pieno con due disegni affiancati: toglie il
magenta, separa i due disegni sulla colonna vuota fra loro e le riduce di SCALA. Stampa i riquadri dei disegni ritagliati, per
misurare dove vanno i testi.
python3 tools/primo_piano.py assets/generated/libro-storia-infinita.png assets/closeups/libro-chiuso.png \\
assets/closeups/libro-aperto.png 0.42"""
import sys
from PIL import Image
src, left_out, right_out, scale = sys.argv[1], sys.argv[2], sys.argv[3], float(sys.argv[4])
im = Image.open(src).convert('RGBA')
px = im.load()
W, H = im.size
for y in range(H):
for x in range(W):
r, g, b, a = px[x, y]
# Il magenta e i bordi rosati che lascia intorno alle figure.
if r > 150 and b > 150 and g < 120 and abs(r - b) < 90: px[x, y] = (0, 0, 0, 0)
# Si taglia sulla colonna vuota più vicina al centro: i due disegni non stanno per forza nelle due metà esatte.
alpha = im.getchannel('A').load()
empty = [x for x in range(W) if not any(alpha[x, y] for y in range(H))]
cut = min(empty, key=lambda x: abs(x - W // 2))
for half, out in ((im.crop((0, 0, cut, H)), left_out), (im.crop((cut, 0, W, H)), right_out)):
box = half.getchannel('A').getbbox()
part = half.crop(box)
small = part.resize((round(part.width * scale), round(part.height * scale)), Image.LANCZOS)
small.save(out, optimize=True)
print(out, 'ritaglio', box, '→', small.size)
+3 -1
View File
@@ -2,5 +2,7 @@
"_nota": "Parole che XTTS legge male in italiano: a sinistra come sono scritte nella storia (e nel fumetto), a destra come vanno lette dalla voce. Si cerca la parola intera, senza badare alle maiuscole. Cambiare una riga rigenera solo le battute che la contengono (make voci). Esempio: «celo» (ce l'ho, delle figurine) XTTS lo legge «cielo».",
"celo": "ce l'ho",
"Cosimo I": "Cosimo primo",
"padel": "pàdel"
"padel": "pàdel",
"l'A. Bettini": "l'Abbettini",
"da i' Trappola, ai' Barrettino": "dai Trappola, ai Barrettino"
}