diff --git a/Dockerfile b/Dockerfile index 8bbf276..97c1b6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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=, come quelli caricati dal codice. RUN cd /usr/share/nginx/html \ diff --git a/assets/closeups/libro-aperto.png b/assets/closeups/libro-aperto.png new file mode 100644 index 0000000..94f9927 Binary files /dev/null and b/assets/closeups/libro-aperto.png differ diff --git a/assets/closeups/libro-chiuso.png b/assets/closeups/libro-chiuso.png new file mode 100644 index 0000000..0810d2a Binary files /dev/null and b/assets/closeups/libro-chiuso.png differ diff --git a/assets/voices/andrea-13058a6ac3.mp3 b/assets/voices/andrea-13058a6ac3.mp3 new file mode 100644 index 0000000..372696a Binary files /dev/null and b/assets/voices/andrea-13058a6ac3.mp3 differ diff --git a/assets/voices/andrea-1d80123a55.mp3 b/assets/voices/andrea-1d80123a55.mp3 deleted file mode 100644 index 240f1be..0000000 Binary files a/assets/voices/andrea-1d80123a55.mp3 and /dev/null differ diff --git a/assets/voices/andrea-239bf733a6.mp3 b/assets/voices/andrea-239bf733a6.mp3 deleted file mode 100644 index e0c738a..0000000 Binary files a/assets/voices/andrea-239bf733a6.mp3 and /dev/null differ diff --git a/assets/voices/andrea-3a6af3897f.mp3 b/assets/voices/andrea-3a6af3897f.mp3 deleted file mode 100644 index 0d207a9..0000000 Binary files a/assets/voices/andrea-3a6af3897f.mp3 and /dev/null differ diff --git a/assets/voices/andrea-413cf7673b.mp3 b/assets/voices/andrea-413cf7673b.mp3 new file mode 100644 index 0000000..1eb89da Binary files /dev/null and b/assets/voices/andrea-413cf7673b.mp3 differ diff --git a/assets/voices/andrea-5528e4c7b0.mp3 b/assets/voices/andrea-5528e4c7b0.mp3 new file mode 100644 index 0000000..14c8eda Binary files /dev/null and b/assets/voices/andrea-5528e4c7b0.mp3 differ diff --git a/assets/voices/andrea-73c12c835b.mp3 b/assets/voices/andrea-73c12c835b.mp3 new file mode 100644 index 0000000..f1d26f5 Binary files /dev/null and b/assets/voices/andrea-73c12c835b.mp3 differ diff --git a/assets/voices/andrea-77f1348d3a.mp3 b/assets/voices/andrea-77f1348d3a.mp3 deleted file mode 100644 index 15a92cb..0000000 Binary files a/assets/voices/andrea-77f1348d3a.mp3 and /dev/null differ diff --git a/assets/voices/andrea-78a0a84b84.mp3 b/assets/voices/andrea-78a0a84b84.mp3 deleted file mode 100644 index 251fe8c..0000000 Binary files a/assets/voices/andrea-78a0a84b84.mp3 and /dev/null differ diff --git a/assets/voices/andrea-a2f0124f4d.mp3 b/assets/voices/andrea-a2f0124f4d.mp3 deleted file mode 100644 index 163f3b4..0000000 Binary files a/assets/voices/andrea-a2f0124f4d.mp3 and /dev/null differ diff --git a/assets/voices/andrea-aaeb40d873.mp3 b/assets/voices/andrea-aaeb40d873.mp3 deleted file mode 100644 index 2006671..0000000 Binary files a/assets/voices/andrea-aaeb40d873.mp3 and /dev/null differ diff --git a/assets/voices/andrea-d3b8c2b6c2.mp3 b/assets/voices/andrea-d3b8c2b6c2.mp3 new file mode 100644 index 0000000..ce3862e Binary files /dev/null and b/assets/voices/andrea-d3b8c2b6c2.mp3 differ diff --git a/assets/voices/andrea-d9ff79ed54.mp3 b/assets/voices/andrea-d9ff79ed54.mp3 new file mode 100644 index 0000000..4502e07 Binary files /dev/null and b/assets/voices/andrea-d9ff79ed54.mp3 differ diff --git a/assets/voices/andrea-ef4a291996.mp3 b/assets/voices/andrea-ef4a291996.mp3 new file mode 100644 index 0000000..20e1839 Binary files /dev/null and b/assets/voices/andrea-ef4a291996.mp3 differ diff --git a/assets/voices/andrea-f035c38acc.mp3 b/assets/voices/andrea-f035c38acc.mp3 new file mode 100644 index 0000000..6172007 Binary files /dev/null and b/assets/voices/andrea-f035c38acc.mp3 differ diff --git a/assets/voices/arcu-5d07c15776.mp3 b/assets/voices/arcu-5d07c15776.mp3 new file mode 100644 index 0000000..e96c2cd Binary files /dev/null and b/assets/voices/arcu-5d07c15776.mp3 differ diff --git a/assets/voices/index.json b/assets/voices/index.json index c9d2c2e..a336ff9 100644 --- a/assets/voices/index.json +++ b/assets/voices/index.json @@ -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 diff --git a/index.html b/index.html index ba7362d..9bf4def 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,7 @@
+
diff --git a/src/engine/game.js b/src/engine/game.js index 87d34c9..715c1d6 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -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)); } diff --git a/src/engine/render.js b/src/engine/render.js index 490c161..0f902da 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -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(); + } +} diff --git a/src/engine/voices.js b/src/engine/voices.js index 78d1763..b9c0f2e 100644 --- a/src/engine/voices.js +++ b/src/engine/voices.js @@ -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()]; } diff --git a/src/main.js b/src/main.js index bc27175..957f1c6 100644 --- a/src/main.js +++ b/src/main.js @@ -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; diff --git a/src/story/story.js b/src/story/story.js index 1f1b145..f5bfb38 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -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: { diff --git a/style.css b/style.css index d9bc8ab..5699297 100644 --- a/style.css +++ b/style.css @@ -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; diff --git a/tests/browser.mjs b/tests/browser.mjs index 54893ac..2407939 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -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'); diff --git a/tests/helpers.mjs b/tests/helpers.mjs index 567e223..91700f4 100644 --- a/tests/helpers.mjs +++ b/tests/helpers.mjs @@ -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); diff --git a/tests/story.test.mjs b/tests/story.test.mjs index e32d1ad..cc76599 100644 --- a/tests/story.test.mjs +++ b/tests/story.test.mjs @@ -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».')); diff --git a/tools/primo_piano.py b/tools/primo_piano.py new file mode 100644 index 0000000..d41b298 --- /dev/null +++ b/tools/primo_piano.py @@ -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)