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
This commit is contained in:
@@ -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
@@ -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. Di qui si vede ancora l'A. Bettini, il campo da calcio di quando eravamo piccoli: lassù ci sono ancora i riflettori.": {
|
||||
"file": "andrea-13058a6ac3.mp3",
|
||||
"sec": 10.26
|
||||
},
|
||||
"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 vede, Arcu.": {
|
||||
"file": "andrea-d3b8c2b6c2.mp3",
|
||||
"sec": 1.17
|
||||
},
|
||||
"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 forte... devo fermarlo!": {
|
||||
"file": "andrea-ef4a291996.mp3",
|
||||
"sec": 4.58
|
||||
},
|
||||
"andrea|Niente di speciale.": {
|
||||
"file": "andrea-6ebcf8e5de.mp3",
|
||||
"sec": 1.04
|
||||
|
||||
@@ -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>
|
||||
|
||||
+40
-2
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 forte... devo fermarlo!' }],
|
||||
// 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. Di qui si vede 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 vede, Arcu.', next: 'end' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca.
|
||||
castello: {
|
||||
nodes: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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 forte... devo fermarlo!']);
|
||||
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 vede, Arcu.');
|
||||
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».'));
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user