Compare commits

...
9 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
manzolo bc19d6be53 Versione 0.1.3 2026-10-25 13:15:45 +01:00
manzoloandClaude Opus 5.5 c54023083a Il Tozzino con una voce da ragazzo (Xavier Hayasaka, più alta)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 12:46:01 +01:00
manzoloandClaude Opus 5.5 af6861350d Mappa: filtrando, i posti trovati si vedono (la mappa non resta lunga e scorsa in fondo); il Barone e le sue due donne e due jack; prove nel browser su una porta libera (con la 8080 giravano sul container)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
2026-10-25 12:40:48 +01:00
47 changed files with 426 additions and 79 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/voices /usr/share/nginx/html/assets/voices
COPY assets/credits /usr/share/nginx/html/assets/credits COPY assets/credits /usr/share/nginx/html/assets/credits
COPY assets/effects /usr/share/nginx/html/assets/effects 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. # 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 \ 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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+50 -42
View File
@@ -427,13 +427,17 @@
"file": "andrea-42e270af3d.mp3", "file": "andrea-42e270af3d.mp3",
"sec": 6.21 "sec": 6.21
}, },
"andrea|Me lo prendo. Se è suo, il proprietario lo ritrova... prima o poi.": { "andrea|Ricordo di aver visto il film da piccolo... era stupendo!": {
"file": "andrea-78a0a84b84.mp3", "file": "andrea-5528e4c7b0.mp3",
"sec": 5.95 "sec": 4.06
}, },
"andrea|Il cancello di dietro.": { "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-a2f0124f4d.mp3", "file": "andrea-bc60e0ce13.mp3",
"sec": 1.41 "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.": { "andrea|Chiuso. Si apre solo quando c'è la festa.": {
"file": "andrea-5e1cd26d46.mp3", "file": "andrea-5e1cd26d46.mp3",
@@ -627,9 +631,9 @@
"file": "andrea-0bd1b0f32e.mp3", "file": "andrea-0bd1b0f32e.mp3",
"sec": 8.7 "sec": 8.7
}, },
"andrea|Baron! Che ci fai qui?": { "andrea|Barone! Che ci fai qui?": {
"file": "andrea-1b59141c98.mp3", "file": "andrea-83abae1419.mp3",
"sec": 2.59 "sec": 2.91
}, },
"barondeduke|Aspetto uno che mi deve riportare le mutande.": { "barondeduke|Aspetto uno che mi deve riportare le mutande.": {
"file": "barondeduke-3ea60b7083.mp3", "file": "barondeduke-3ea60b7083.mp3",
@@ -643,9 +647,9 @@
"file": "barondeduke-5542a06624.mp3", "file": "barondeduke-5542a06624.mp3",
"sec": 1.65 "sec": 1.65
}, },
"barondeduke|In mano avevo full. E ho detto: «Male male si divide».": { "barondeduke|Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».": {
"file": "barondeduke-944c3e57dc.mp3", "file": "barondeduke-d4ff967179.mp3",
"sec": 5.03 "sec": 7.64
}, },
"andrea|E invece?": { "andrea|E invece?": {
"file": "andrea-435306c242.mp3", "file": "andrea-435306c242.mp3",
@@ -803,14 +807,6 @@
"file": "andrea-8ddab2d024.mp3", "file": "andrea-8ddab2d024.mp3",
"sec": 5.79 "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.": { "andrea|Il ponte vecchio, coi suoi due archi. Chissà quante piene ha visto passare: io al primo temporale cerco riparo.": {
"file": "andrea-01ec1d03ec.mp3", "file": "andrea-01ec1d03ec.mp3",
"sec": 7.67 "sec": 7.67
@@ -988,20 +984,20 @@
"sec": 3.1 "sec": 3.1
}, },
"tozzino|Aspetto Federer. Oggi si fa una partita.": { "tozzino|Aspetto Federer. Oggi si fa una partita.": {
"file": "tozzino-a069d2c5e2.mp3", "file": "tozzino-c3898e9bdb.mp3",
"sec": 2.85 "sec": 3.23
}, },
"andrea|Federer? Roger Federer?": { "andrea|Federer? Roger Federer?": {
"file": "andrea-70f150bdec.mp3", "file": "andrea-70f150bdec.mp3",
"sec": 2.67 "sec": 2.67
}, },
"tozzino|E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.": { "tozzino|E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.": {
"file": "tozzino-5072201408.mp3", "file": "tozzino-d51159c364.mp3",
"sec": 4.3 "sec": 4.08
}, },
"tozzino|Se non arriva, la vinco a tavolino.": { "tozzino|Se non arriva, la vinco a tavolino.": {
"file": "tozzino-c1736e2d62.mp3", "file": "tozzino-c06cc01612.mp3",
"sec": 1.42 "sec": 2.06
}, },
"andrea|La Carza. A maggio c'è poca acqua: d'estate si scendeva a fare le dighe coi sassi.": { "andrea|La Carza. A maggio c'è poca acqua: d'estate si scendeva a fare le dighe coi sassi.": {
"file": "andrea-91be1de49b.mp3", "file": "andrea-91be1de49b.mp3",
@@ -1183,22 +1179,6 @@
"file": "andrea-23e40a2495.mp3", "file": "andrea-23e40a2495.mp3",
"sec": 5.74 "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.": { "andrea|Un ghiacciolo al limone. Si scioglie a guardarlo.": {
"file": "andrea-8c9713081a.mp3", "file": "andrea-8c9713081a.mp3",
"sec": 3.44 "sec": 3.44
@@ -1451,6 +1431,26 @@
"file": "vecchietto-c8eae2e59e.mp3", "file": "vecchietto-c8eae2e59e.mp3",
"sec": 4.88 "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?": { "andrea|Sono bloccato. Mi dai un consiglio?": {
"file": "andrea-42b02eb9ea.mp3", "file": "andrea-42b02eb9ea.mp3",
"sec": 3.9 "sec": 3.9
@@ -1527,6 +1527,14 @@
"file": "castello-dc8a873d86.mp3", "file": "castello-dc8a873d86.mp3",
"sec": 4.36 "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.": { "andrea|Niente di speciale.": {
"file": "andrea-6ebcf8e5de.mp3", "file": "andrea-6ebcf8e5de.mp3",
"sec": 1.04 "sec": 1.04
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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. gioco, come i nove bastioni.
- **Regole perché resti divertente**: le chiavi importanti non si buttano, una porta aperta resta aperta, al Trebbio - **Regole perché resti divertente**: le chiavi importanti non si buttano, una porta aperta resta aperta, al Trebbio
c'è sempre l'aiuto. 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"> <div id="stage">
<canvas id="scene" width="640" height="360" aria-label="La scena dell'avventura"></canvas> <canvas id="scene" width="640" height="360" aria-label="La scena dell'avventura"></canvas>
<div id="speech" aria-live="polite"></div> <div id="speech" aria-live="polite"></div>
<button id="closeup-close" hidden title="Chiudi (Esc)">Chiudi</button>
<div id="corner"> <div id="corner">
<button id="pause-button" hidden title="Pausa (Esc)">Pausa</button> <button id="pause-button" hidden title="Pausa (Esc)">Pausa</button>
<button id="map-button" hidden title="Mappa (P)">Mappa</button> <button id="map-button" hidden title="Mappa (P)">Mappa</button>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "leggenda-del-regolo", "name": "leggenda-del-regolo",
"version": "0.1.2", "version": "0.1.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "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'è. // Solo profilo, come i cicli di camminata: 'up' e 'down' lasciano il verso com'è.
const FACING = { left: -1, right: 1 }; const FACING = { left: -1, right: 1 };
const WALK = ['walk_1', 'walk_2', 'walk_3', 'walk_4'], STEP = 15, REACH = 0.45; 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). // Condizioni: 'flag', '!flag', 'has:oggetto', '!has:oggetto', oppure un elenco (tutte vere).
export function check(cond, state) { export function check(cond, state) {
@@ -115,15 +118,16 @@ export class Game {
this.hurry = false; // doppio clic: si cammina al doppio della velocità this.hurry = false; // doppio clic: si cammina al doppio della velocità
this.reach = 0; // secondi di braccio teso quando prende qualcosa this.reach = 0; // secondi di braccio teso quando prende qualcosa
this.back = false; // di spalle davanti a una porta o a una bacheca 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]; } 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 // 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. // 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; } get idle() { return !this.busy && !this.task && !this.dialogue; }
// Fermo: nessuna battuta, nessun effetto in coda, nessuna camminata che fa parte di una sequenza. // 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. // Lo stato da salvare adesso: quello attuale se è fermo, altrimenti l'ultimo punto fermo.
savable() { return this.steady ? this.snapshot() : this.checkpoint; } savable() { return this.steady ? this.snapshot() : this.checkpoint; }
// A sequenza finita, se qualcosa è cambiato, nuovo punto fermo e salvataggio. // A sequenza finita, se qualcosa è cambiato, nuovo punto fermo e salvataggio.
@@ -221,6 +225,27 @@ export class Game {
return false; 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() { options() {
if (!this.dialogue || this.busy) return []; if (!this.dialogue || this.busy) return [];
const node = this.story.dialogues[this.dialogue.id].nodes[this.dialogue.node]; const node = this.story.dialogues[this.dialogue.id].nodes[this.dialogue.node];
@@ -271,6 +296,8 @@ export class Game {
} }
speakerPoint(who) { 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 (!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]; if (this.scene.speakers?.[who]) return this.scene.speakers[who];
const h = this.hotspot(who) ?? (this.scene.hotspots ?? []).find(x => x.id === who); const h = this.hotspot(who) ?? (this.scene.hotspots ?? []).find(x => x.id === who);
@@ -280,6 +307,7 @@ export class Game {
update(dt) { update(dt) {
this.time += dt; this.time += dt;
if (this.closeup) this.closeup.age += dt;
if (this.speech) { if (this.speech) {
this.speech.left -= dt; this.speech.left -= dt;
if (this.speech.left > 0) return; if (this.speech.left > 0) return;
@@ -310,6 +338,15 @@ export class Game {
this.walking = false; this.walking = false;
this.task = null; 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. // 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()); while (!this.speech && !this.walking && this.queue.length) this.run(this.queue.shift());
this.commit(); this.commit();
@@ -335,6 +372,7 @@ export class Game {
else if (e.node) this.dialogue.node = e.node; else if (e.node) this.dialogue.node = e.node;
else if (e.endDialogue) this.dialogue = null; else if (e.endDialogue) this.dialogue = null;
else if (e.end) s.ended = true; 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)); 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. // 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. // 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 { ambientEffects, ambientHash } from './ambient.js';
import { versioned } from '../version.js'; import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360; 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); drawLights(ctx, game, time);
for (const shape of art) if (shape.top) drawShape(ctx, shape); for (const shape of art) if (shape.top) drawShape(ctx, shape);
if (spots) drawSpots(ctx, game); if (spots) drawSpots(ctx, game);
if (game.closeup) drawCloseup(ctx, game, time);
if (debug) drawDebug(ctx, game, debug); if (debug) drawDebug(ctx, game, debug);
if (hover) { if (hover) {
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; 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'); label(`${game.state.scene} ${where} ${help ?? ''}`, 2, HEIGHT - 3, '#fff');
ctx.restore(); 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.scenes);
visit(story.items); visit(story.items);
visit(story.dialogues); visit(story.dialogues);
visit(story.closeups);
for (const text of ENGINE_LINES) add('andrea', text); for (const text of ENGINE_LINES) add('andrea', text);
return [...seen.values()]; return [...seen.values()];
} }
+48 -6
View File
@@ -217,6 +217,7 @@ function filterMap() {
$('map-none').hidden = shown > 0; $('map-none').hidden = shown > 0;
$('map-none').textContent = `Nessun posto con «${query.trim()}»`; $('map-none').textContent = `Nessun posto con «${query.trim()}»`;
drawRoads(); drawRoads();
$('map-scroll').scrollTo(0, 0); // i posti rimasti stanno in cima: niente mappa vuota scorsa in fondo
} }
$('map-search').addEventListener('input', filterMap); $('map-search').addEventListener('input', filterMap);
$('map-search').addEventListener('keydown', e => { $('map-search').addEventListener('keydown', e => {
@@ -226,7 +227,11 @@ $('map-search').addEventListener('keydown', e => {
}); });
// Le strade fra le schede, misurate dopo l'impaginazione (e di nuovo se la finestra cambia misura). // Le strade fra le schede, misurate dopo l'impaginazione (e di nuovo se la finestra cambia misura).
function drawRoads() { function drawRoads() {
const board = $('map-board'), svg = $('map-roads'), origin = board.getBoundingClientRect(); const board = $('map-board'), svg = $('map-roads');
// Prima si toglie la misura vecchia: il disegno delle strade, grande quanto la mappa intera, la terrebbe lunga
// anche dopo un filtro.
svg.style.width = svg.style.height = '0';
const origin = board.getBoundingClientRect();
const center = id => { const center = id => {
const card = board.querySelector(`[data-scene="${id}"]`); const card = board.querySelector(`[data-scene="${id}"]`);
const r = card && !card.hidden && !card.parentElement.hidden && card.querySelector('img').getBoundingClientRect(); const r = card && !card.hidden && !card.parentElement.hidden && card.querySelector('img').getBoundingClientRect();
@@ -380,11 +385,14 @@ let press = null;
canvas.addEventListener('pointerdown', e => { canvas.addEventListener('pointerdown', e => {
if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; } if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; }
if (!running || e.button > 0) 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); 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) }; press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) };
}); });
canvas.addEventListener('pointerup', e => { canvas.addEventListener('pointerup', e => {
if (!press) return; if (!press) return;
if (press.closeup) { press = null; if (!game.skip()) game.closeupTap(); return; }
clearTimeout(press.timer); clearTimeout(press.timer);
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità. // Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
if (!press.long) { if (!press.long) {
@@ -398,7 +406,7 @@ canvas.addEventListener('pointerup', e => {
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; }); canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; });
canvas.addEventListener('contextmenu', e => { canvas.addEventListener('contextmenu', e => {
e.preventDefault(); e.preventDefault();
if (running) command.clickScene(scenePoint(e), { look: true }); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
}); });
canvas.addEventListener('pointermove', e => { canvas.addEventListener('pointermove', e => {
if (!running) return; if (!running) return;
@@ -484,6 +492,13 @@ window.addEventListener('keydown', e => {
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; } if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; }
if (!$('notebook').hidden) { if (['Escape', 'n', 'N'].includes(e.key)) $('notebook').hidden = true; 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; } 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 === 'p' || e.key === 'P') && running) { openMap(); return; }
if ((e.key === 'h' || e.key === 'H') && running) { showSpots(); return; } if ((e.key === 'h' || e.key === 'H') && running) { showSpots(); return; }
if ((e.key === 'n' || e.key === 'N') && running) { openNotebook(); return; } if ((e.key === 'n' || e.key === 'N') && running) { openNotebook(); return; }
@@ -497,6 +512,28 @@ window.addEventListener('keydown', e => {
if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1); 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. // --- Fumetto sopra chi parla, tenuto dentro i bordi della scena.
function updateSpeech() { function updateSpeech() {
const box = $('speech'), sp = game.speech; const box = $('speech'), sp = game.speech;
@@ -523,16 +560,21 @@ function updateUi() {
// Il bersaglio si ricalcola a ogni fotogramma: la scena cambia anche se il mouse sta fermo. // 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; 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)); 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; if ($('sentence').textContent !== text) $('sentence').textContent = text;
updateInventory(); updateInventory();
updateChoices(); updateChoices();
updateSpeech(); updateSpeech();
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare. // 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; $('pause-button').hidden = !running;
$('spots-button').hidden = !running; $('spots-button').hidden = !running || !!game.closeup;
$('notebook-button').hidden = !running || !story.notebook; $('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) { if (game.state.ended && !game.speech && !game.queue.length) {
running = false; running = false;
$('ending').hidden = false; $('ending').hidden = false;
+41 -11
View File
@@ -104,6 +104,23 @@ export const story = {
{ role: 'Carattere', names: ['Press Start 2P di CodeMan38 (licenza OFL)'] }, { role: 'Carattere', names: ['Press Start 2P di CodeMan38 (licenza OFL)'] },
{ role: 'Programmazione', names: ['Manzolo, con Claude e Codex'] }, { 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: { characters: {
andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' }, andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' },
vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' }, vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' },
@@ -133,12 +150,9 @@ export const story = {
actions: { actions: {
drop: [{ say: 'Una pagina sola, e pure con la fine. Lo lascio qui per il prossimo lettore.' }, { drop: 'libro' }], 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.' }], look: [{ say: '«La storia infinita». Bello spesso: questo mi tiene compagnia fino a settembre.' }],
open: [ // Il primo piano (story.closeups.libro): la battuta la scopre chi guarda, nessuno la racconta.
{ say: 'Vediamo da dove comincia...' }, open: [{ closeup: 'libro' }],
{ say: 'C\'è una pagina sola. Con scritto: «FINE».' }, use: [{ closeup: 'libro' }],
{ say: 'Infinita, dicevano.' },
],
use: [{ say: 'Leggere adesso? Prima il palio. E poi, a quanto pare, si fa presto.' }],
}, },
}, },
// La catena del ghiacciolo (idea di Andrea, 2026-10-24): la Tosca dà ghiacciolo e cedrata; il ghiacciolo inzuppato // 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' }], art: [{ rect: [565, 203, 11, 5], fill: '#c46a3a' }, { rect: [565, 207, 11, 1], fill: '#f4efe4' }, { rect: [569, 204, 3, 3], fill: '#e8c25a' }],
actions: { actions: {
look: [{ say: 'Un libro dimenticato sulle tavolate. Sulla copertina c\'è scritto «La storia infinita».' }], 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', id: 'cancello', name: 'cancello', box: [477, 166, 23, 52], at: [488, 236], face: 'up',
actions: { 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.' }], open: [{ say: 'Chiuso. Si apre solo quando c\'è la festa.' }],
}, },
}, },
@@ -1018,11 +1036,11 @@ export const story = {
actions: { actions: {
look: [{ say: 'Il BaronDeDuke, col cappello da cowboy. Ha l\'aria di uno che aspetta qualcuno, e che spera non lo veda nessuno.' }], look: [{ say: 'Il BaronDeDuke, col cappello da cowboy. Ha l\'aria di uno che aspetta qualcuno, e che spera non lo veda nessuno.' }],
talk: [ talk: [
{ say: 'Baron! Che ci fai qui?' }, { say: 'Barone! Che ci fai qui?' },
{ say: 'Aspetto uno che mi deve riportare le mutande.', who: 'barondeduke' }, { say: 'Aspetto uno che mi deve riportare le mutande.', who: 'barondeduke' },
{ say: 'Le... mutande?' }, { say: 'Le... mutande?' },
{ say: 'Le ho perse a poker, ieri sera.', who: 'barondeduke', pose: 'facepalm' }, { say: 'Le ho perse a poker, ieri sera.', who: 'barondeduke', pose: 'facepalm' },
{ say: 'In mano avevo full. E ho detto: «Male male si divide».', who: 'barondeduke', pose: 'cards' }, { say: 'Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».', who: 'barondeduke', pose: 'cards' },
{ say: 'E invece?' }, { say: 'E invece?' },
{ say: 'L\'altro aveva colore.', who: 'barondeduke', pose: 'shrug' }, { say: 'L\'altro aveva colore.', who: 'barondeduke', pose: 'shrug' },
], ],
@@ -1321,7 +1339,7 @@ export const story = {
id: 'arcu', name: 'l\'Arcu', box: [400, 216, 38, 76], at: [362, 300], face: 'right', default: 'talk', id: 'arcu', name: 'l\'Arcu', box: [400, 216, 38, 76], at: [362, 300], face: 'right', default: 'talk',
actions: { actions: {
look: [{ say: 'L\'Arcu, il mio amico di sempre. Davanti al circolino, come se ci fosse nato.' }], 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. // Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca.
castello: { castello: {
nodes: { nodes: {
+1 -1
View File
@@ -1,5 +1,5 @@
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json. // La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
export const VERSION = '0.1.2'; export const VERSION = '0.1.4';
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica, // 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). // 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; 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; } text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; }
#speech:empty { display: none; } #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). */ /* 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; #sound { position: absolute; top: 6px; right: 6px; z-index: 2; display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 4px;
+2 -3
View File
@@ -2,14 +2,13 @@
// ritagli dei nuovi effetti, pause degli animali e nessuna modifica alla partita. // ritagli dei nuovi effetti, pause degli animali e nessuna modifica alla partita.
// npm run browser:effects — immagini in /tmp/regolo-effects (oppure $OUT). // npm run browser:effects — immagini in /tmp/regolo-effects (oppure $OUT).
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { spawn } from 'node:child_process'; import { staticServer } from './helpers.mjs';
import { mkdirSync, writeFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
const out = process.env.OUT ?? '/tmp/regolo-effects'; const out = process.env.OUT ?? '/tmp/regolo-effects';
mkdirSync(out, { recursive: true }); mkdirSync(out, { recursive: true });
const port = 9000 + Math.floor(Math.random() * 900); const server = await staticServer(), port = server.port;
const server = spawn('python3', ['-m', 'http.server', String(port), '--bind', '127.0.0.1'], { stdio: 'ignore' });
let browser; let browser;
try { try {
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
+59 -3
View File
@@ -2,14 +2,14 @@
// fino alla fine, salvataggio e impaginazione su telefono. Schermate in $OUT (predefinito: /tmp/regolo-shots). // fino alla fine, salvataggio e impaginazione su telefono. Schermate in $OUT (predefinito: /tmp/regolo-shots).
// npm run browser // npm run browser
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { spawn } from 'node:child_process'; import { staticServer } from './helpers.mjs';
import { mkdirSync, readFileSync } from 'node:fs'; import { mkdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { solution, route } from '../src/story/solution.js'; import { solution, route } from '../src/story/solution.js';
const OUT = process.env.OUT ?? '/tmp/regolo-shots', PORT = 8000 + Math.floor(Math.random() * 900); const OUT = process.env.OUT ?? '/tmp/regolo-shots';
mkdirSync(OUT, { recursive: true }); mkdirSync(OUT, { recursive: true });
const server = spawn('python3', ['-m', 'http.server', String(PORT), '--bind', '127.0.0.1'], { stdio: 'ignore' }); const server = await staticServer(), PORT = server.port;
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const errors = []; const errors = [];
@@ -276,6 +276,62 @@ try {
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
assert.ok(await page.isHidden('#map')); assert.ok(await page.isHidden('#map'));
assert.equal(await page.evaluate(() => game.state.scene), 'piazza'); assert.equal(await page.evaluate(() => game.state.scene), 'piazza');
// Con tutti i posti visti e la finestra piccola la mappa si scorre: filtrando da in fondo, i posti trovati si vedono
// (prima la mappa restava lunga quanto quella intera, scorsa su un fondo vuoto).
await page.setViewportSize({ width: 560, height: 340 });
await page.evaluate(() => { for (const id of Object.keys(game.story.scenes)) game.state.flags['visto:' + id] = true; });
await page.click('#map-button');
assert.ok(await page.evaluate(() => { const s = document.getElementById('map-scroll'); s.scrollTop = s.scrollHeight; return s.scrollTop > 0; }), 'la mappa piena si scorre');
await page.fill('#map-search', 'campo');
const found = await page.evaluate(() => {
const s = document.getElementById('map-scroll'), r = s.getBoundingClientRect();
const c = [...document.querySelectorAll('#map-board button')].find(b => !b.hidden && !b.parentElement.hidden).getBoundingClientRect();
return { inside: c.top >= r.top && c.bottom <= r.bottom, scrollTop: s.scrollTop, h: s.scrollHeight, ch: s.clientHeight, top: c.top - r.top, q: document.getElementById('map-search').value };
});
assert.ok(found.inside, `filtrando, il primo posto trovato non si vede: ${JSON.stringify(found)}`);
await page.screenshot({ path: `${OUT}/07d-mappa-ricerca-piccola.png` });
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 }); const phone = await open({ width: 390, height: 844 });
await phone.click('#splash'); // un clic salta il cartello await phone.click('#splash'); // un clic salta il cartello
+15
View File
@@ -6,6 +6,8 @@ export function settle(game, { maxSteps = 20000 } = {}) {
said.push(game.speech); said.push(game.speech);
game.skip(); 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. // 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; } if (!game.busy && !game.walking && !game.queue.length && !game.task) { game.update(0); return said; }
game.update(0.05); game.update(0.05);
@@ -16,3 +18,16 @@ export function settle(game, { maxSteps = 20000 } = {}) {
export function memoryStorage() { export function memoryStorage() {
return { data: {}, getItem(k) { return this.data[k] ?? null; }, setItem(k, v) { this.data[k] = String(v); }, removeItem(k) { delete this.data[k]; } }; return { data: {}, getItem(k) { return this.data[k] ?? null; }, setItem(k, v) { this.data[k] = String(v); }, removeItem(k) { delete this.data[k]; } };
} }
// Il server statico delle prove nel browser, su una porta libera scelta dal sistema: con una porta a caso poteva
// capitare la 8080 del gioco in Docker (make up), e la prova girava sul codice vecchio del container.
export async function staticServer() {
const { spawn } = await import('node:child_process');
const server = spawn('python3', ['-u', '-m', 'http.server', '0', '--bind', '127.0.0.1'], { stdio: ['ignore', 'pipe', 'ignore'] });
const port = await new Promise((resolve, reject) => {
let out = '';
server.stdout.on('data', d => { out += d; const m = out.match(/port (\d+)/); if (m) resolve(Number(m[1])); });
server.on('exit', () => reject(new Error('il server di prova non è partito')));
});
return { port, kill: () => server.kill() };
}
+45 -7
View File
@@ -8,7 +8,7 @@ import { inside } from '../src/engine/geometry.js';
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { settle } from './helpers.mjs'; 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`. // Visita ogni elenco di effetti della storia, dentro scene, oggetti, dialoghi e rami `if`.
function* effectLists() { 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.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.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.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}`); if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`);
} }
for (const [id, d] of Object.entries(story.dialogues)) { 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); const g = new Game(story);
g.go('oratorio', [320, 345]); g.go('oratorio', [320, 345]);
settle(g); settle(g);
assert.ok(g.hotspotAt([570, 205])?.id === 'libro_dimenticato', 'sulle tavolate il clic prende il libro, non le tavolate'); 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')); 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.ok(g.state.inventory.includes('libro'));
assert.equal(g.hotspot('libro_dimenticato'), undefined, 'preso, il libro non resta sul tavolo'); 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')); assert.ok(g.act('open', 'libro'));
const said = settle(g).map(s => s.text); settle(g);
assert.ok(said.some(t => t.includes('FINE')), 'aperto, dice FINE'); 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', () => { test('la mappa: ogni scena ci sta, i posti compaiono solo visti, il viaggio rispetta i dialoghi', () => {
@@ -475,9 +508,14 @@ test('gli amici in giro: l\'Arcu al circolino, il Tozzino al cancello del tennis
const g = new Game(story); const g = new Game(story);
settle(g); 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}`); }; 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.')); assert.ok(talk('carza', 'tozzino').includes('tozzino: E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.'));
const baron = talk('polisportiva', 'barondeduke'); const baron = talk('polisportiva', 'barondeduke');
assert.ok(baron.includes('barondeduke: In mano avevo full. E ho detto: «Male male si divide».')); assert.ok(baron.includes('barondeduke: Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».'));
assert.equal(baron.at(-1), 'barondeduke: L\'altro aveva colore.'); assert.equal(baron.at(-1), 'barondeduke: L\'altro aveva colore.');
}); });
+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».", "_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", "celo": "ce l'ho",
"Cosimo I": "Cosimo primo", "Cosimo I": "Cosimo primo",
"padel": "pàdel" "padel": "pàdel",
"l'A. Bettini": "l'Abbettini",
"da i' Trappola, ai' Barrettino": "dai Trappola, ai Barrettino"
} }
+1 -1
View File
@@ -8,6 +8,6 @@
"san_pietro": { "speaker": "Wulf Carlevaro", "filtro": "asetrate=24000*0.92,aresample=24000,atempo=0.95,aecho=0.8:0.6:60:0.2" }, "san_pietro": { "speaker": "Wulf Carlevaro", "filtro": "asetrate=24000*0.92,aresample=24000,atempo=0.95,aecho=0.8:0.6:60:0.2" },
"tosca": { "speaker": "Gitta Nikolina" }, "tosca": { "speaker": "Gitta Nikolina" },
"arcu": { "speaker": "Luis Moray" }, "arcu": { "speaker": "Luis Moray" },
"tozzino": { "speaker": "Dionisio Schuyler" }, "tozzino": { "speaker": "Xavier Hayasaka", "filtro": "asetrate=24000*1.12,aresample=24000,atempo=0.92" },
"barondeduke": { "speaker": "Craig Gutsy" } "barondeduke": { "speaker": "Craig Gutsy" }
} }