Suoni d'ambiente sintetizzati (vento, acqua, uccelli, grilli, campane) col loro volume in Pausa; la fessura in primo piano: si cerca col fascio della torcia il quadro e gli occhi (lavoro di Codex). La foto del palio leggibile da nginx, e un test che lo controlla per tutti i file serviti

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
This commit is contained in:
2026-10-25 18:17:01 +01:00
co-authored by Claude Opus 5.5
parent 1fabb6e543
commit de303c5825
22 changed files with 732 additions and 61 deletions
+7
View File
@@ -41,6 +41,12 @@ python3 -m http.server 8000 # poi http://localhost:8000
Clic = azione più sensata (parla, guarda, vai); clic destro o pressione lunga = guarda; un verbo scelto vale per il Clic = azione più sensata (parla, guarda, vai); clic destro o pressione lunga = guarda; un verbo scelto vale per il
clic successivo. Spazio salta la battuta, i numeri scelgono nel dialogo, Esc annulla il verbo. clic successivo. Spazio salta la battuta, i numeri scelgono nel dialogo, Esc annulla il verbo.
Il libro dell'oratorio si apre e si sfoglia con clic o tocco; Esc lo chiude. Nella fessura della Fortezza,
la torcia segue mouse o dito (anche trascinando): si cercano i dettagli tenendo brevemente la luce su di essi.
Si possono usare anche le frecce, oppure Invio per cercare il prossimo dettaglio. «Torna fuori» permette di
riprovare; dopo il ritrovamento, «Continua» conclude il capitolo.
Il volume dei suoni ambientali e del fruscio delle pagine si regola in **Pausa → Ambiente ed effetti**.
## In Docker ## In Docker
```zsh ```zsh
@@ -64,5 +70,6 @@ make down
npm test # logica, storia coerente, soluzione percorsa dall'inizio alla fine npm test # logica, storia coerente, soluzione percorsa dall'inizio alla fine
npm install && npx playwright install chromium-headless-shell npm install && npx playwright install chromium-headless-shell
npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono
npm run browser -- --media-only # libro, torcia e audio: controllo mirato desktop/telefono
``` ```
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+4
View File
@@ -1,5 +1,9 @@
# Da dove vengono le immagini # Da dove vengono le immagini
- `assets/closeups/palio-2007.jpg`: copia della foto `assets/raw/palio-2007-foto.jpg` fornita da Andrea
il 24 ottobre 2026. Usata nel primo piano della fessura; il renderer mostra soltanto la tela tramite
le coordinate `story.closeups.fessura.crop`, senza modificare la foto originale.
Da controllare **prima del primo push**: quello che è nella storia di git, una volta pubblicato, resta pubblico Da controllare **prima del primo push**: quello che è nella storia di git, una volta pubblicato, resta pubblico
anche se poi lo si cancella. Le foto originali (`assets/raw/`) e i prompt (`prompts/`) sono esclusi da git e non sono anche se poi lo si cancella. Le foto originali (`assets/raw/`) e i prompt (`prompts/`) sono esclusi da git e non sono
mai stati committati (verificato il 2026-10-23); qui si parla degli sfondi ridisegnati in `assets/scenes/`, che invece mai stati committati (verificato il 2026-10-23); qui si parla degli sfondi ridisegnati in `assets/scenes/`, che invece
+17
View File
@@ -18,6 +18,23 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
## Dove siamo arrivati ## Dove siamo arrivati
- **Multimedia (25 ottobre, modifiche locali)**: il libro sfogliabile già presente conserva immagini, battute e
riapertura dall'inventario; il fruscio ora segue il volume degli effetti, non quello delle voci.
Nuovi paesaggi sonori sintetizzati con Web Audio (`src/engine/soundscape.js`, profili in
`src/story/soundscapes.js`): vento, acqua, uccelli di giorno e grilli di sera, campane, bicchieri,
gocce e scricchiolii distanziati. In **Pausa → Ambiente ed effetti** si regola il volume, salvato nel browser.
Muto, pausa e pagina nascosta fermano anche i suoni già avviati.
La **torcia nella fessura** apre un primo piano: mouse, tocco/trascinamento o frecce spostano la luce;
Invio aiuta a cercare il prossimo dettaglio. Pietra → palio → occhi; la musica si abbassa durante la ricerca.
«Torna fuori»/Esc permette di riprovare; soltanto dopo gli occhi «Continua» avvia la conclusione.
Nessun progresso parziale viene salvato. La tela usa la foto vera del 2007, ritagliata durante il disegno.
Controllo mirato: `npm run browser -- --media-only`; il collaudo normale include anche queste prove.
Verificati `npm test` e `npm run browser`: soluzione completa, salvataggi, libro su desktop/telefono,
torcia con mouse/tastiera/tocco/trascinamento, pausa e muto, segnale Web Audio senza saturazione.
Schermate in `/tmp/regolo-shots/media-*.png` e `/tmp/regolo-shots/05b-fessura-*.png`.
I copioni video sono aggiornati ai gesti richiesti dalla torcia; il montaggio audio da file continua a
includere solo musica e voci (gli effetti Web Audio non vengono esportati dal copione).
- **Ambienti animati (25 ottobre)**: alle prove già presenti (bandiere del campeggio, fascio di luce nella Pieve, - **Ambienti animati (25 ottobre)**: alle prove già presenti (bandiere del campeggio, fascio di luce nella Pieve,
ombre al Bosco ai Frati) si aggiungono 51 dettagli nelle altre 35 scene. Configurazione in ombre al Bosco ai Frati) si aggiungono 51 dettagli nelle altre 35 scene. Configurazione in
`src/story/ambience.js`, richiamata da `story.js`; nuovi disegni in `src/engine/ambient.js`: `src/story/ambience.js`, richiamata da `story.js`; nuovi disegni in `src/engine/ambient.js`:
+4 -1
View File
@@ -19,7 +19,7 @@
<button id="spots-button" hidden title="Mostra cosa si può toccare (H)">?</button> <button id="spots-button" hidden title="Mostra cosa si può toccare (H)">?</button>
</div> </div>
<div id="sound"> <div id="sound">
<button id="mute" aria-label="Musica: togli l'audio" title="Musica (M)">♪</button> <button id="mute" aria-label="Togli l'audio" title="Audio (M)">♪</button>
<input id="volume" type="range" min="0" max="100" step="5" aria-label="Volume della musica" title="Volume della musica"> <input id="volume" type="range" min="0" max="100" step="5" aria-label="Volume della musica" title="Volume della musica">
<label for="voice-volume" class="sound-label" title="Volume delle voci">bla</label> <label for="voice-volume" class="sound-label" title="Volume delle voci">bla</label>
<input id="voice-volume" type="range" min="0" max="100" step="5" aria-label="Volume delle voci" title="Volume delle voci"> <input id="voice-volume" type="range" min="0" max="100" step="5" aria-label="Volume delle voci" title="Volume delle voci">
@@ -73,6 +73,9 @@
</section> </section>
<section id="pause" class="overlay" hidden aria-label="Pausa"> <section id="pause" class="overlay" hidden aria-label="Pausa">
<h2>Pausa</h2> <h2>Pausa</h2>
<label class="effects-control" for="effects-volume">Ambiente ed effetti
<input id="effects-volume" type="range" min="0" max="100" step="5" aria-label="Volume ambiente ed effetti">
</label>
<div class="buttons pause-buttons"> <div class="buttons pause-buttons">
<button id="resume">Riprendi</button> <button id="resume">Riprendi</button>
<button id="pause-map" class="secondary">Mappa</button> <button id="pause-map" class="secondary">Mappa</button>
+41 -2
View File
@@ -229,6 +229,7 @@ export class Game {
closeupTap() { closeupTap() {
const c = this.closeup; const c = this.closeup;
if (!c || this.speech) return false; if (!c || this.speech) return false;
if (this.story.closeups[c.id].type === 'torch') return false;
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 }); if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 }); else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
else return false; else return false;
@@ -241,11 +242,46 @@ export class Game {
if (!c) return false; if (!c) return false;
this.closeup = null; this.closeup = null;
this.speech = null; this.speech = null;
const def = this.story.closeups?.[c.id];
if (def?.type === 'torch') {
if (c.step === 'done') this.queue.unshift(...def.complete);
return true;
}
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`). // Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
if (c.step === 'end') this.queue.unshift(...(this.story.closeups?.[c.id]?.closing ?? [])); if (c.step === 'end') this.queue.unshift(...(this.story.closeups?.[c.id]?.closing ?? []));
return true; return true;
} }
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
// uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale.
closeupAim([x, y]) {
const c = this.closeup;
if (!c || this.story.closeups[c.id].type !== 'torch' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
return true;
}
updateTorch(dt) {
const c = this.closeup, def = this.story.closeups[c.id];
c.t += dt;
if (c.lines?.length) { this.run(c.lines.shift()); return; }
if (c.step === 'reveal') {
if (c.t < 1.4) return;
c.found++;
c.step = c.found === def.targets.length ? 'done' : 'search';
c.t = c.dwell = 0;
return;
}
const target = def.targets[c.found];
if (c.step !== 'search' || !target) return;
c.dwell = Math.hypot(c.beam[0] - target.at[0], c.beam[1] - target.at[1]) < target.radius ? c.dwell + dt : 0;
if (c.dwell >= 0.45) {
c.step = 'reveal';
c.t = 0;
c.lines = [...(target.lines ?? [])];
}
}
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];
@@ -341,6 +377,7 @@ export class Game {
// Nel primo piano il tempo scorre solo per la pagina che gira e per la pausa prima del commento. // Nel primo piano il tempo scorre solo per la pagina che gira e per la pausa prima del commento.
const c = this.closeup; const c = this.closeup;
if (c) { if (c) {
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
c.t += dt; c.t += dt;
if (c.step === 'turn' && c.t >= PAGE_TURN) Object.assign(c, { step: 'end', t: 0 }); 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.step === 'end' && !c.lines && c.t >= CLOSEUP_PAUSE) c.lines = [...(this.story.closeups?.[c.id]?.comment ?? [])];
@@ -348,7 +385,7 @@ export class Game {
return; 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.closeup && this.queue.length) this.run(this.queue.shift());
this.commit(); this.commit();
} }
@@ -372,7 +409,9 @@ 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 if (e.closeup) this.closeup = this.story.closeups[e.closeup].type === 'torch'
? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
: { 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));
} }
+6
View File
@@ -2,6 +2,7 @@
// 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, PAGE_TURN } from './game.js'; import { check, PAGE_TURN } from './game.js';
import { ambientEffects, ambientHash } from './ambient.js'; import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js';
import { versioned } from '../version.js'; import { versioned } from '../version.js';
export const WIDTH = 640, HEIGHT = 360; export const WIDTH = 640, HEIGHT = 360;
@@ -663,6 +664,11 @@ const twoLines = title => { const i = title.lastIndexOf(' '); return i > 0 ? [ti
function drawCloseup(ctx, game, time) { function drawCloseup(ctx, game, time) {
const c = game.closeup, def = game.story.closeups?.[c.id]; const c = game.closeup, def = game.story.closeups?.[c.id];
if (!def) return; if (!def) return;
if (def.type === 'torch') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawTorch(ctx, c, def, backgrounds.get(def.picture));
return;
}
ctx.save(); ctx.save();
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`; ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT); ctx.fillRect(0, 0, WIDTH, HEIGHT);
+132
View File
@@ -0,0 +1,132 @@
// Web Audio: vento/acqua filtrati e piccoli eventi sintetizzati. Nessun download,
// nessun timer in sottofondo; il ciclo del gioco decide quando suonare e quando fermarsi.
import { soundscapes } from '../story/soundscapes.js';
export class Soundscape {
constructor({ volume = 0.45, createContext = () => new (globalThis.AudioContext ?? globalThis.webkitAudioContext)(), random = Math.random } = {}) {
this.volume = volume;
this.createContext = createContext;
this.random = random;
this.context = null;
this.nodes = new Set();
this.active = false;
this.muted = false;
this.key = null;
this.elapsed = 0;
this.next = 4;
this.bed = null;
}
unlock() {
try {
if (!this.context) {
this.context = this.createContext();
this.master = this.context.createGain();
this.master.gain.value = 0;
this.master.connect(this.context.destination);
const rate = this.context.sampleRate;
this.noise = this.context.createBuffer(1, rate * 4, rate);
const data = this.noise.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = this.random() * 2 - 1;
}
if (this.context.state === 'suspended') this.context.resume().catch(() => {});
} catch {
// Anche un'inizializzazione parziale fallita deve lasciare giocabile l'avventura.
this.master?.disconnect(); this.context = null; this.master = null; this.noise = null;
}
}
get audible() { return !!this.context && this.active && !this.muted && this.volume > 0; }
configure({ active = this.active, muted = this.muted, volume = this.volume, duck = 1 } = {}) {
this.active = active;
this.muted = muted;
this.volume = Math.max(0, Math.min(1, volume));
if (!this.context) return;
const gain = this.audible ? this.volume * duck : 0;
this.master.gain.cancelScheduledValues(this.context.currentTime);
if (!gain) {
this.master.gain.setValueAtTime(0, this.context.currentTime);
this.clear();
} else this.master.gain.setTargetAtTime(gain, this.context.currentTime, 0.12);
}
clear() {
for (const node of [...this.nodes]) node.stop();
this.bed = null;
}
// Tutti i nodi, inclusi filtro e gain, vengono scollegati alla fine del suono.
connect(source, gain, filter = null, pan = 0) {
const panner = this.context.createStereoPanner?.();
if (panner) panner.pan.value = pan;
const chain = [source, filter, gain, panner, this.master].filter(Boolean);
for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
const node = { stop: () => {
if (!this.nodes.delete(node)) return;
try { source.stop(); } catch { /* già finito */ }
chain.slice(0, -1).forEach(n => n.disconnect());
} };
source.onended = node.stop;
this.nodes.add(node);
return node;
}
noiseSound(duration, frequency, level, { loop = false, pan = 0, paper = false } = {}) {
const c = this.context, now = c.currentTime;
const source = c.createBufferSource(), filter = c.createBiquadFilter(), gain = c.createGain();
source.buffer = this.noise; source.loop = loop;
filter.type = paper ? 'bandpass' : 'lowpass'; filter.frequency.value = frequency; filter.Q.value = 0.5;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(level, now + (loop ? 1.3 : duration * 0.15));
if (!loop) gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
const node = this.connect(source, gain, filter, pan);
source.start();
if (!loop) source.stop(now + duration);
return { node, gain, level };
}
tone(frequency, end, duration, level, delay = 0, pan = 0) {
const c = this.context, at = c.currentTime + delay, source = c.createOscillator(), gain = c.createGain();
source.type = 'sine';
source.frequency.setValueAtTime(frequency, at);
source.frequency.exponentialRampToValueAtTime(end, at + duration);
gain.gain.setValueAtTime(0, at);
gain.gain.linearRampToValueAtTime(level, at + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, at + duration);
this.connect(source, gain, null, pan);
source.start(at); source.stop(at + duration + 0.03);
}
play(kind, { pan = (this.random() - 0.5) * 1.3 } = {}) {
if (!this.audible) return;
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
else if (kind === 'bird') {
for (let i = 0; i < 3; i++) this.tone(2100 + this.random() * 600, 3500, 0.13, 0.035, i * 0.22, pan);
} else if (kind === 'cricket') {
for (let i = 0; i < 5; i++) this.tone(3900, 4200, 0.055, 0.016, i * 0.12, pan);
} else if (kind === 'bell' || kind === 'glass') {
const base = kind === 'bell' ? 440 : 1420, duration = kind === 'bell' ? 3.8 : 0.65;
for (const [ratio, level] of [[1, 0.035], [2.76, 0.012], [4.07, 0.006]])
this.tone(base * ratio, base * ratio, duration, level, 0, pan);
}
}
update(profile, phase, dt) {
if (!this.audible) return;
const key = `${profile}:${phase}`, def = soundscapes[profile];
if (!def) return;
if (this.key !== key) {
this.clear(); this.key = key; this.elapsed = 0; this.next = 4 + this.random() * 5;
}
if (!this.bed && def.bed) this.bed = this.noiseSound(0, def.bed === 'water' ? 1700 : 340, def.level, { loop: true });
this.elapsed += dt;
if (this.bed && this.elapsed > 1.5) this.bed.gain.gain.setTargetAtTime(def.level * (0.8 + 0.2 * Math.sin(this.elapsed * 0.7)), this.context.currentTime, 0.6);
if (this.elapsed < this.next) return;
const events = phase === 'sera' ? def.night : def.day;
this.play(events[Math.floor(this.random() * events.length)]);
this.next = this.elapsed + def.gap[0] + this.random() * (def.gap[1] - def.gap[0]);
}
}
+2 -1
View File
@@ -60,7 +60,8 @@ export function readAudio(storage) {
try { try {
const a = JSON.parse(storage.getItem(AUDIO_KEY)); const a = JSON.parse(storage.getItem(AUDIO_KEY));
if (!a || typeof a.volume !== 'number') return null; if (!a || typeof a.volume !== 'number') return null;
return { volume: a.volume, muted: !!a.muted, ...(typeof a.voices === 'number' && { voices: a.voices }) }; return { volume: a.volume, muted: !!a.muted, ...(typeof a.voices === 'number' && { voices: a.voices }),
...(typeof a.effects === 'number' && Number.isFinite(a.effects) && { effects: Math.max(0, Math.min(1, a.effects)) }) };
} catch { } catch {
return null; return null;
} }
+89
View File
@@ -0,0 +1,89 @@
// Primo piano della fessura, disegnato alla risoluzione del gioco. Il quadro è la tela
// della foto del 2007 fornita da Andrea; il ritaglio viene eseguito solo nel disegno.
import { ambientHash as hash } from './ambient.js';
export function drawTorch(ctx, c, def, picture) {
const [x, y] = c.beam;
ctx.save();
ctx.fillStyle = '#090c10';
ctx.fillRect(0, 0, 640, 360);
// Muratura irregolare, muschio e giunti profondi.
for (let row = 0; row < 7; row++) for (let col = 0; col < 10; col++) {
const n = row * 10 + col, px = col * 73 - (row % 2) * 36, py = 24 + row * 47;
ctx.fillStyle = ['#4b4940', '#555247', '#3e443c', '#615b4b'][Math.floor(hash(n + 1) * 4)];
ctx.fillRect(px + 2, py + 2, 68, 42);
ctx.fillStyle = '#73705b';
ctx.fillRect(px + 5, py + 3, 61, 2);
ctx.fillStyle = '#282e29';
ctx.fillRect(px + 3, py + 39, 65, 4);
for (let k = 0; k < 6; k++) {
ctx.fillStyle = k % 2 ? '#424b35' : '#77715a';
ctx.fillRect(px + 7 + Math.floor(hash(n * 9 + k) * 54), py + 7 + Math.floor(hash(n * 13 + k) * 28), 3, 2);
}
}
// Il vano si perde dietro il muro; il quadro è appoggiato a terra, senza cornice dorata.
ctx.fillStyle = '#111617';
ctx.beginPath();
[[250, 84], [470, 67], [533, 135], [527, 288], [247, 300], [271, 231]].forEach(([a, b], i) => i ? ctx.lineTo(a, b) : ctx.moveTo(a, b));
ctx.closePath(); ctx.fill();
if (c.found >= 1) {
ctx.save();
ctx.translate(350, 224); ctx.rotate(-0.09);
ctx.fillStyle = '#242320'; ctx.fillRect(-48, -79, 98, 144);
if (picture) ctx.drawImage(picture, ...def.crop, -46, -77, 92, 138);
else {
// Anche se l'immagine non arriva il ritrovamento resta comprensibile e giocabile.
ctx.fillStyle = '#a6a67c'; ctx.fillRect(-46, -77, 92, 138);
ctx.fillStyle = '#e4d8ac'; ctx.beginPath(); ctx.moveTo(-43, 54); ctx.lineTo(0, -50); ctx.lineTo(10, -50); ctx.lineTo(25, 54); ctx.fill();
ctx.fillStyle = '#a83e2f'; ctx.fillRect(-6, 0, 12, 21); ctx.fillRect(-17, 0, 34, 5);
}
ctx.restore();
}
const eyes = c.found === 2 && c.step === 'reveal';
if (eyes) {
ctx.fillStyle = '#e5bd51';
for (const ex of [447, 470]) {
ctx.fillRect(ex, 134, 13, 8); ctx.fillRect(ex + 3, 132, 7, 12);
ctx.fillStyle = '#151912'; ctx.fillRect(ex + 6, 133, 2, 10);
ctx.fillStyle = '#fff1ad'; ctx.fillRect(ex + 3, 135, 2, 2);
ctx.fillStyle = '#e5bd51';
}
}
// Oscuramento radiale: il centro caldo lascia leggibile il dettaglio, fuori è quasi nero.
const light = ctx.createRadialGradient(x, y, 16, x, y, 112);
light.addColorStop(0, 'rgba(8,10,15,0)');
light.addColorStop(0.42, 'rgba(8,10,15,0.12)');
light.addColorStop(0.75, 'rgba(4,6,10,0.8)');
light.addColorStop(1, 'rgba(3,5,9,0.985)');
ctx.fillStyle = light; ctx.fillRect(0, 0, 640, 360);
// Qualche granello di polvere attraversa il fascio, senza lampeggiamenti.
for (let i = 0; i < 15; i++) {
const px = (hash(i + 100) * 640 + c.age * (2 + hash(i) * 3)) % 640;
const py = (hash(i + 200) * 360 + c.age * 2) % 360;
const distance = Math.hypot(px - x, py - y);
if (distance < 83) { ctx.fillStyle = `rgba(233,216,165,${0.35 * (1 - distance / 83)})`; ctx.fillRect(Math.round(px), Math.round(py), 1, 1); }
}
// Pietra vicina ai bordi: il giocatore guarda attraverso una fessura.
ctx.fillStyle = '#181b1c';
for (const points of [[[0, 0], [77, 0], [60, 97], [85, 178], [53, 278], [95, 360], [0, 360]],
[[640, 0], [568, 0], [594, 104], [564, 194], [583, 265], [552, 360], [640, 360]]]) {
ctx.beginPath(); points.forEach(([a, b], i) => i ? ctx.lineTo(a, b) : ctx.moveTo(a, b)); ctx.closePath(); ctx.fill();
}
// Torcia impugnata sul bordo inferiore, il puntatore resta libero per cercare.
ctx.save();
ctx.translate(430, 328); ctx.rotate((x - 320) / 1100);
const rect = (color, ...box) => { ctx.fillStyle = color; ctx.fillRect(...box); };
rect('#372722', 7, -2, 30, 44); rect('#9b6849', 10, 0, 24, 42);
rect('#c18c61', 11, 0, 11, 38); rect('#50382e', 29, 10, 5, 32);
// Corpo rosso e bordo metallico, come l'icona della torcia nell'inventario.
rect('#171c20', -16, -28, 28, 62); rect('#762e27', -13, -18, 21, 50);
rect('#a74330', -11, -17, 7, 46); rect('#4a2925', 4, -17, 4, 49);
for (let i = 0; i < 5; i++) rect('#482924', -12, 10 + i * 4, 20, 1);
rect('#343b3b', -19, -29, 34, 12); rect('#93998a', -17, -29, 30, 3);
rect('#d8ce9f', -15, -31, 26, 3); rect('#777c71', -17, -24, 3, 6);
rect('#d0a078', 4, -5, 14, 8); rect('#a37350', 4, 2, 14, 2);
for (let i = 0; i < 3; i++) { rect('#bf8b60', 4, 6 + i * 8, 17, 6); rect('#71503c', 5, 12 + i * 8, 16, 2); }
rect('#dfb58b', 6, -4, 5, 3);
ctx.restore();
ctx.restore();
}
+56 -32
View File
@@ -6,6 +6,8 @@ import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/rende
import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js'; import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
import { Music, trackFor } from './engine/music.js'; import { Music, trackFor } from './engine/music.js';
import { Voices } from './engine/voices.js'; import { Voices } from './engine/voices.js';
import { Soundscape } from './engine/soundscape.js';
import { soundscapeFor } from './story/soundscapes.js';
import { VERSION, versioned } from './version.js'; import { VERSION, versioned } from './version.js';
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
@@ -22,22 +24,26 @@ let game = null, command = null, hoverTarget = null, mouse = null, running = fal
// --- Musica: parte subito se il browser lo permette, altrimenti al primo gesto; volumi e muto si ricordano. // --- Musica: parte subito se il browser lo permette, altrimenti al primo gesto; volumi e muto si ricordano.
const audioSettings = (storage && readAudio(storage)) ?? {}; const audioSettings = (storage && readAudio(storage)) ?? {};
const music = new Music(audioSettings); const music = new Music(audioSettings);
const ambience = new Soundscape({ volume: audioSettings.effects });
window.ambience = ambience;
// --- Voci: l'indice arriva con fetch; finché non c'è (o se manca) le battute restano mute. Seguono il muto della musica, // --- Voci: l'indice arriva con fetch; finché non c'è (o se manca) le battute restano mute. Seguono il muto della musica,
// ma hanno il loro volume. // ma hanno il loro volume.
const voices = new Voices({}, undefined, audioSettings.voices); const voices = new Voices({}, undefined, audioSettings.voices);
const saveAudio = () => storage && writeAudio(storage, { ...music.settings(), voices: voices.volume }); const saveAudio = () => storage && writeAudio(storage, { ...music.settings(), voices: voices.volume, effects: ambience.volume });
function showSound() { function showSound() {
$('mute').setAttribute('aria-pressed', String(music.muted)); $('mute').setAttribute('aria-pressed', String(music.muted));
$('mute').setAttribute('aria-label', music.muted ? "Musica: rimetti l'audio" : "Musica: togli l'audio"); $('mute').setAttribute('aria-label', music.muted ? "Rimetti l'audio" : "Togli l'audio");
$('volume').value = Math.round(music.volume * 100); $('volume').value = Math.round(music.volume * 100);
$('voice-volume').value = Math.round(voices.volume * 100); $('voice-volume').value = Math.round(voices.volume * 100);
$('effects-volume').value = Math.round(ambience.volume * 100);
} }
for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, () => music.unlock(), { capture: true }); for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, () => { music.unlock(); ambience.unlock(); }, { capture: true });
// Il muto zittisce subito anche la voce che sta parlando. // Il muto zittisce subito anche la voce che sta parlando.
const toggleMute = () => { if (music.toggle()) voices.stop(); saveAudio(); showSound(); }; const toggleMute = () => { if (music.toggle()) voices.stop(); ambience.configure({ muted: music.muted }); saveAudio(); showSound(); };
$('mute').addEventListener('click', toggleMute); $('mute').addEventListener('click', toggleMute);
$('volume').addEventListener('input', e => { music.setVolume(e.target.value / 100); saveAudio(); showSound(); }); $('volume').addEventListener('input', e => { music.setVolume(e.target.value / 100); saveAudio(); showSound(); });
$('voice-volume').addEventListener('input', e => { voices.setVolume(e.target.value / 100); saveAudio(); showSound(); }); $('voice-volume').addEventListener('input', e => { voices.setVolume(e.target.value / 100); saveAudio(); showSound(); });
$('effects-volume').addEventListener('input', e => { ambience.configure({ volume: e.target.value / 100 }); saveAudio(); showSound(); });
showSound(); showSound();
window.music = music; // per i test nel browser window.music = music; // per i test nel browser
@@ -171,7 +177,7 @@ $('close-credits').addEventListener('click', () => { $('credits').hidden = true;
// dalla Fortezza ai campi), e in ogni gradino da sinistra a destra. Le strade si disegnano dopo, fra le schede. // dalla Fortezza ai campi), e in ogni gradino da sinistra a destra. Le strade si disegnano dopo, fra le schede.
const MAP_BANDS = [25, 50, 75, Infinity]; const MAP_BANDS = [25, 50, 75, Infinity];
function openMap() { function openMap() {
if (!running || game.dialogue) return; if (!running || game.dialogue || game.closeup) return;
const board = $('map-board'), svg = $('map-roads'), places = game.mapPlaces(); const board = $('map-board'), svg = $('map-roads'), places = game.mapPlaces();
const bands = MAP_BANDS.map(() => []); const bands = MAP_BANDS.map(() => []);
for (const p of places) bands[MAP_BANDS.findIndex(top => p.at[1] < top)].push(p); for (const p of places) bands[MAP_BANDS.findIndex(top => p.at[1] < top)].push(p);
@@ -261,8 +267,9 @@ function openPause() {
running = false; running = false;
music.hold = true; music.hold = true;
voices.pause(); voices.pause();
ambience.configure({ active: false });
command.reset(); command.reset();
$('pause-map').hidden = game.mapPlaces().length < 2; $('pause-map').hidden = !!game.closeup || game.mapPlaces().length < 2;
$('pause').hidden = false; $('pause').hidden = false;
} }
function closePause() { function closePause() {
@@ -278,6 +285,7 @@ $('pause-button').addEventListener('click', openPause);
function leave(hidden) { function leave(hidden) {
music.setAway(hidden); music.setAway(hidden);
if (hidden) { if (hidden) {
ambience.configure({ active: false });
voices.pause(); voices.pause();
window.jingle?.pause(); window.jingle?.pause();
if (running) openPause(); if (running) openPause();
@@ -301,7 +309,7 @@ $('close-map').addEventListener('click', () => { $('map').hidden = true; });
// --- Taccuino: le tappe fatte, barrate, e quella di adesso; in fondo dove chiedere aiuto. // --- Taccuino: le tappe fatte, barrate, e quella di adesso; in fondo dove chiedere aiuto.
function openNotebook() { function openNotebook() {
if (!running) return; if (!running || game.closeup) return;
const { done, now } = game.notebook(); const { done, now } = game.notebook();
$('notebook-list').replaceChildren(...done.map(t => Object.assign(document.createElement('li'), { textContent: t, className: 'done' })), $('notebook-list').replaceChildren(...done.map(t => Object.assign(document.createElement('li'), { textContent: t, className: 'done' })),
...(now ? [Object.assign(document.createElement('li'), { textContent: now, className: 'now' })] : [])); ...(now ? [Object.assign(document.createElement('li'), { textContent: now, className: 'now' })] : []));
@@ -346,6 +354,7 @@ $('load-file').addEventListener('change', async e => {
function showTitle() { function showTitle() {
running = false; running = false;
ambience.configure({ active: false });
const saved = storage && readSave(storage); const saved = storage && readSave(storage);
$('continue').hidden = !saved || saved.ended; $('continue').hidden = !saved || saved.ended;
$('load-error').hidden = true; $('load-error').hidden = true;
@@ -386,13 +395,18 @@ 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. // 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; } if (game.closeup) {
press = { closeup: true };
game.closeupAim(scenePoint(e));
canvas.setPointerCapture(e.pointerId);
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; } if (press.closeup) { press = null; if (running && !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) {
@@ -411,6 +425,7 @@ canvas.addEventListener('contextmenu', e => {
canvas.addEventListener('pointermove', e => { canvas.addEventListener('pointermove', e => {
if (!running) return; if (!running) return;
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null; mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
}); });
canvas.addEventListener('pointerleave', () => { mouse = null; }); canvas.addEventListener('pointerleave', () => { mouse = null; });
@@ -486,7 +501,7 @@ function updateChoices() {
} }
window.addEventListener('keydown', e => { window.addEventListener('keydown', e => {
if (e.target === $('volume') || e.target === $('voice-volume')) return; if (e.target instanceof HTMLInputElement && e.target.type === 'range' && e.key !== 'Escape') return;
// Nella ricerca della mappa le lettere si scrivono; Esc prima svuota la casella, poi chiude. // Nella ricerca della mappa le lettere si scrivono; Esc prima svuota la casella, poi chiude.
if (e.target === $('map-search')) { if (e.target === $('map-search')) {
if (e.key === 'Escape') { if (e.target.value) { e.target.value = ''; filterMap(); } else $('map').hidden = true; } if (e.key === 'Escape') { if (e.target.value) { e.target.value = ''; filterMap(); } else $('map').hidden = true; }
@@ -499,6 +514,20 @@ window.addEventListener('keydown', e => {
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. // Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
if (running && game.closeup) { if (running && game.closeup) {
if (story.closeups[game.closeup.id].type === 'torch' && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) {
// Invio cerca il prossimo dettaglio anche per chi non può usare il puntatore.
e.preventDefault();
const c = game.closeup, target = story.closeups[c.id].targets[c.found];
if (e.key === 'Enter') {
if (game.speech) game.skip();
else if (c.step === 'done') game.closeupClose();
else if (target) game.closeupAim(target.at);
} else {
const move = { ArrowLeft: [-24, 0], ArrowRight: [24, 0], ArrowUp: [0, -24], ArrowDown: [0, 24] }[e.key];
game.closeupAim([c.beam[0] + move[0], c.beam[1] + move[1]]);
}
return;
}
if (e.key === 'Escape') game.closeupClose(); if (e.key === 'Escape') game.closeupClose();
else if ([' ', 'Enter', 'ArrowRight'].includes(e.key)) { e.preventDefault(); if (!game.skip()) game.closeupTap(); } else if ([' ', 'Enter', 'ArrowRight'].includes(e.key)) { e.preventDefault(); if (!game.skip()) game.closeupTap(); }
else if (e.key === 'm' || e.key === 'M') toggleMute(); else if (e.key === 'm' || e.key === 'M') toggleMute();
@@ -519,25 +548,7 @@ window.addEventListener('keydown', e => {
// --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio). // --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio).
$('closeup-close').addEventListener('click', () => game?.closeupClose()); $('closeup-close').addEventListener('click', () => game?.closeupClose());
let turning = null, audioCtx = null; let turning = null, reveal = 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() {
@@ -565,7 +576,10 @@ 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 || game.closeup ? '' : command.sentence(hoverTarget); const c = game.closeup, torch = c && story.closeups[c.id].type === 'torch';
const text = torch ? c.step === 'done' ? 'Gli occhi sono spariti. Continua o premi Invio.'
: `${story.closeups[c.id].targets[c.found]?.hint ?? ''} Sposta la luce; Invio per cercare.`
: !running || game.speech || game.dialogue || c ? '' : command.sentence(hoverTarget);
if ($('sentence').textContent !== text) $('sentence').textContent = text; if ($('sentence').textContent !== text) $('sentence').textContent = text;
updateInventory(); updateInventory();
updateChoices(); updateChoices();
@@ -576,10 +590,17 @@ function updateUi() {
$('spots-button').hidden = !running || !!game.closeup; $('spots-button').hidden = !running || !!game.closeup;
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup; $('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
$('closeup-close').hidden = !running || !game.closeup; $('closeup-close').hidden = !running || !game.closeup;
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : 'Chiudi';
$('panel').inert = !!c;
canvas.classList.toggle('torch', !!torch);
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : "La scena dell'avventura");
// La pagina che comincia a girare fa il suo fruscio. // La pagina che comincia a girare fa il suo fruscio.
const step = game.closeup?.step ?? null; const step = game.closeup?.step ?? null;
if (step === 'turn' && turning !== 'turn') rustle(); if (step === 'turn' && turning !== 'turn') ambience.play('page');
turning = step; turning = step;
const nextReveal = torch ? c.found : null;
if (nextReveal === 2 && reveal !== 2) ambience.play('wood', { pan: 0.7 });
reveal = nextReveal;
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;
@@ -596,10 +617,13 @@ function frame(now) {
const screen = !$('title').hidden ? 'title' : !$('splash').hidden ? 'splash' : !$('intro').hidden || !$('rione').hidden ? 'intro' : !$('ending').hidden ? 'ending' : 'game'; const screen = !$('title').hidden ? 'title' : !$('splash').hidden ? 'splash' : !$('intro').hidden || !$('rione').hidden ? 'intro' : !$('ending').hidden ? 'ending' : 'game';
document.body.classList.toggle('full', screen === 'title' || screen === 'splash' || screen === 'intro' || !$('credits').hidden); document.body.classList.toggle('full', screen === 'title' || screen === 'splash' || screen === 'intro' || !$('credits').hidden);
// Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa. // Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa.
music.duck = game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1; const searching = game?.closeup?.id === 'fessura';
music.duck = searching ? game.closeup.found >= 2 ? 0.05 : 0.25 : game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1;
music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase() }), dt); music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase() }), dt);
// Un minuto senza toccare niente, a gioco fermo: pausa da sola. // Un minuto senza toccare niente, a gioco fermo: pausa da sola.
if (running && now - idleSince > IDLE_PAUSE && !game.speech && !game.walking && !game.queue.length) openPause(); if (running && now - idleSince > IDLE_PAUSE && !game.speech && !game.walking && !game.queue.length) openPause();
ambience.configure({ active: screen === 'game' && running && !document.hidden && $('credits').hidden, muted: music.muted, duck: voices.playing ? 0.4 : 1 });
ambience.update(soundscapeFor(game?.state.scene, game?.closeup?.id), game?.phase()?.id, dt);
// A gioco fermo (titolo, pausa, fine) la scena non cambia: un ultimo disegno e poi niente, per la batteria del // A gioco fermo (titolo, pausa, fine) la scena non cambia: un ultimo disegno e poi niente, per la batteria del
// telefono. Si ridisegna solo se nel frattempo arriva lo sfondo che mancava. // telefono. Si ridisegna solo se nel frattempo arriva lo sfondo che mancava.
const still = game && !running && `${game.state.scene}|${hasBackground(game.scene.background)}`; const still = game && !running && `${game.state.scene}|${hasBackground(game.scene.background)}`;
+23
View File
@@ -0,0 +1,23 @@
// Paesaggi sonori discreti: nessun parlato aggiunto e lunghe pause fra gli eventi.
export const soundscapes = {
paese: { bed: 'wind', level: 0.025, day: ['bird', 'bell'], night: ['cricket'], gap: [12, 24] },
bosco: { bed: 'wind', level: 0.065, day: ['bird', 'wood'], night: ['cricket', 'wood'], gap: [9, 19] },
acqua: { bed: 'water', level: 0.1, day: ['bird', 'drip'], night: ['cricket', 'drip'], gap: [9, 18] },
pieve: { bed: null, level: 0, day: ['bell'], night: ['bell'], gap: [24, 38] },
circolo: { bed: null, level: 0, day: ['glass'], night: ['glass'], gap: [12, 22] },
fortezza: { bed: 'wind', level: 0.085, day: ['wood', 'bird'], night: ['wood', 'cricket'], gap: [14, 25] },
fessura: { bed: 'wind', level: 0.045, day: ['drip'], night: ['drip'], gap: [10, 18] },
};
const groups = {
bosco: ['bosco_ai_frati', 'viale_cipressi', 'trebbio', 'salita_fortezza', 'casa_maestro_bacci', 'campo_giannini', 'area_camper', 'giardini_poste'],
acqua: ['ponte_sieve', 'campomigliaio_ponte', 'verso_luna'],
pieve: ['pieve', 'pieve_interno', 'oratorio'],
circolo: ['campomigliaio_circolo', 'tennis_chiesa', 'area_feste_festa'],
fortezza: ['fortezza'],
};
export function soundscapeFor(scene, closeup) {
if (closeup === 'fessura') return 'fessura';
return Object.keys(groups).find(id => groups[id].includes(scene)) ?? 'paese';
}
+25 -14
View File
@@ -104,9 +104,32 @@ 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. // Primi piani interattivi: libro dell'oratorio e ricerca con la torcia nella fessura.
// Le immagini (Codex, assets/closeups/) hanno l'etichetta e le pagine vuote: titolo e «FINE» li scrive il codice. // Le immagini (Codex, assets/closeups/) hanno l'etichetta e le pagine vuote: titolo e «FINE» li scrive il codice.
closeups: { closeups: {
fessura: {
type: 'torch',
picture: 'assets/closeups/palio-2007.jpg',
crop: [82, 36, 220, 331], // la tela nella foto fornita da Andrea, senza il muro intorno
targets: [
{ at: [185, 195], radius: 52, hint: 'Esamina la pietra a sinistra…', lines: [] },
{ at: [348, 225], radius: 55, hint: 'Qualcosa riflette la luce, più in basso…', lines: [
{ say: 'Laggiù, appoggiato al muro, c\'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.' },
{ say: 'È il palio! Il quadro dei ragazzi delle medie!' },
] },
{ at: [462, 138], radius: 45, hint: 'Un rumore nel buio, a destra…', lines: [
{ say: 'E quelli... sono due occhi gialli!' },
] },
],
complete: [
{ say: 'Spariti nel buio.' },
{ set: 'palio_trovato' },
{ say: 'Il Regolo. E ha il nostro palio.' },
{ say: 'Però non l\'ha portato via: l\'ha messo proprio lassù, in Fortezza. Dove domani portiamo i mattoni col passamano.' },
{ say: 'Il palio è già pronto ad aspettarci. Torno giù a dirlo a tutti: la festa si fa!' },
{ end: true },
],
},
libro: { libro: {
closed: 'assets/closeups/libro-chiuso.png', closed: 'assets/closeups/libro-chiuso.png',
open: 'assets/closeups/libro-aperto.png', open: 'assets/closeups/libro-aperto.png',
@@ -443,19 +466,7 @@ export const story = {
stecco_gomma: [{ say: 'Troppo buio: non vedo nemmeno dove lo infilo. Prima ci vuole una luce.' }], stecco_gomma: [{ say: 'Troppo buio: non vedo nemmeno dove lo infilo. Prima ci vuole una luce.' }],
torcia: [ torcia: [
{ say: 'Accendo la torcia e la punto dentro...' }, { say: 'Accendo la torcia e la punto dentro...' },
{ set: 'luce_fessura' }, { closeup: 'fessura' },
{ say: 'Laggiù, appoggiato al muro, c\'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.' },
{ say: 'È il palio! Il quadro dei ragazzi delle medie!' },
{ set: 'occhi_regolo' },
{ say: 'E quelli... sono due occhi gialli!' },
{ unset: 'occhi_regolo' },
{ say: 'Spariti nel buio.' },
{ unset: 'luce_fessura' },
{ set: 'palio_trovato' },
{ say: 'Il Regolo. E ha il nostro palio.' },
{ say: 'Però non l\'ha portato via: l\'ha messo proprio lassù, in Fortezza. Dove domani portiamo i mattoni col passamano.' },
{ say: 'Il palio è già pronto ad aspettarci. Torno giù a dirlo a tutti: la festa si fa!' },
{ end: true },
], ],
}, },
}, },
+3
View File
@@ -24,6 +24,9 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; } #stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; } #scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
#scene.torch { touch-action: none; }
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
#effects-volume { width: 130px; accent-color: var(--accent); }
/* max-content: vicino al bordo destro il fumetto non si stringe a colonna (lo spazio dopo `left` non conta). */ /* max-content: vicino al bordo destro il fumetto non si stringe a colonna (lo spazio dopo `left` non conta). */
#speech { position: absolute; left: 0; top: 0; width: max-content; max-width: 46%; transform: translate(-50%, -100%); pointer-events: none; #speech { position: absolute; left: 0; top: 0; width: max-content; max-width: 46%; transform: translate(-50%, -100%); pointer-events: none;
+35 -4
View File
@@ -6,6 +6,7 @@ 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';
import { checkMultimedia } from './multimedia.browser.mjs';
const OUT = process.env.OUT ?? '/tmp/regolo-shots'; const OUT = process.env.OUT ?? '/tmp/regolo-shots';
mkdirSync(OUT, { recursive: true }); mkdirSync(OUT, { recursive: true });
@@ -47,6 +48,8 @@ const hotspotCenter = async (page, id) => {
}; };
try { try {
await checkMultimedia(browser, PORT, OUT, errors);
if (!process.argv.includes('--media-only')) {
const page = await open({ width: 1280, height: 860 }); const page = await open({ width: 1280, height: 860 });
// Il cartello d'apertura, poi da solo il titolo. // Il cartello d'apertura, poi da solo il titolo.
assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/); assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/);
@@ -66,10 +69,22 @@ try {
// Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo. // Col codice lento ad arrivare (rete vera) il cartello aspetta il titolo: non sfuma prima per conto suo.
{ {
const slow = await browser.newPage({ viewport: { width: 1280, height: 860 } }); const slow = await browser.newPage({ viewport: { width: 1280, height: 860 } });
// Misura al momento dell'apparizione: il polling di Playwright può arrivare dopo
// diversi fotogrammi su una macchina occupata, quando la dissolvenza è già in corso.
await slow.addInitScript(() => {
const observer = new MutationObserver(() => {
const title = document.getElementById('title'), splash = document.getElementById('splash');
if (title && !title.hidden && splash) {
window.titleRevealOpacity = Number(getComputedStyle(splash).opacity);
observer.disconnect();
}
});
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden'] });
});
await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); }); await slow.route('**/src/main.js', async route => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); });
await slow.goto(`http://127.0.0.1:${PORT}/`); await slow.goto(`http://127.0.0.1:${PORT}/`);
await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 }); await slow.waitForSelector('#title:not([hidden])', { timeout: 12000 });
const opacity = await slow.evaluate(() => Number(getComputedStyle(document.getElementById('splash')).opacity)); const opacity = await slow.evaluate(() => window.titleRevealOpacity);
assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`); assert.ok(opacity > 0.9, `il cartello era già sfumato (${opacity}) quando è comparso il titolo`);
await slow.close(); await slow.close();
} }
@@ -182,12 +197,13 @@ try {
await page.click('#mute'); await page.click('#mute');
await page.locator('#volume').fill('30'); await page.locator('#volume').fill('30');
await page.locator('#voice-volume').fill('70'); await page.locator('#voice-volume').fill('70');
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7 }); assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false, voices: 0.7, effects: 0.45 });
assert.equal(await page.evaluate(() => voices.volume), 0.7); assert.equal(await page.evaluate(() => voices.volume), 0.7);
// Sul telefono si passa a un'altra app: la pagina diventa nascosta, la musica si ferma subito e la partita va in // Sul telefono si passa a un'altra app: la pagina diventa nascosta, la musica si ferma subito e la partita va in
// pausa; tornando la musica riparte solo con Riprendi (la pausa la tiene ferma). // pausa; tornando la musica riparte solo con Riprendi (la pausa la tiene ferma).
const hide = hidden => page.evaluate(h => { Object.defineProperty(document, 'hidden', { value: h, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); }, hidden); const hide = hidden => page.evaluate(h => { Object.defineProperty(document, 'hidden', { value: h, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); }, hidden);
await hide(true); await hide(true);
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'nascosta la pagina, gli effetti si fermano subito');
assert.ok(await page.evaluate(() => music.current.audio.paused), 'nascosta la pagina, la musica è ferma'); assert.ok(await page.evaluate(() => music.current.audio.paused), 'nascosta la pagina, la musica è ferma');
assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa'); assert.ok(await page.isVisible('#pause'), 'e la partita è in pausa');
await hide(false); await hide(false);
@@ -211,8 +227,21 @@ try {
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` }); if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
// La torcia nella fessura: il palio illuminato e i due occhi gialli, prima che spariscano. // La torcia nella fessura: il palio illuminato e i due occhi gialli, prima che spariscano.
if (target === 'fessura' && item) { if (target === 'fessura' && item) {
await page.waitForFunction(() => game.state.flags.occhi_regolo, null, { timeout: 30000 }); await page.waitForFunction(() => game.closeup?.id === 'fessura', null, { timeout: 30000 });
await page.screenshot({ path: `${OUT}/05b-occhi.png` }); for (let i = 0; i < 3; i++) {
const at = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
const box = await page.locator('#scene').boundingBox();
await page.mouse.move(box.x + at[0] / 640 * box.width, box.y + at[1] / 360 * box.height);
await page.waitForFunction(() => game.closeup?.step === 'reveal');
await page.screenshot({ path: `${OUT}/05b-fessura-${i}.png` });
for (let n = 0; n < 200; n++) {
if (await page.evaluate(i => game.closeup.found > i, i)) break;
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
await page.waitForTimeout(30);
}
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
}
await page.click('#closeup-close');
} }
await settle(page); await settle(page);
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` }); if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
@@ -386,6 +415,8 @@ try {
assert.deepEqual(errors, [], 'errori nella console'); assert.deepEqual(errors, [], 'errori nella console');
console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`); console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`);
}
assert.deepEqual(errors, [], 'errori nella console');
} finally { } finally {
await browser.close(); await browser.close();
server.kill(); server.kill();
+13 -2
View File
@@ -1,5 +1,5 @@
// Fa avanzare il gioco come farebbe un giocatore impaziente: tempo che scorre e battute saltate. // Fa avanzare il gioco come farebbe un giocatore impaziente: tempo che scorre e battute saltate.
export function settle(game, { maxSteps = 20000 } = {}) { export function settle(game, { maxSteps = 20000, explore = false } = {}) {
const said = []; const said = [];
for (let i = 0; i < maxSteps; i++) { for (let i = 0; i < maxSteps; i++) {
if (game.speech) { if (game.speech) {
@@ -7,7 +7,10 @@ export function settle(game, { maxSteps = 20000 } = {}) {
game.skip(); game.skip();
} }
// Un primo piano aperto aspetta il giocatore (sfogliare, chiudere): qui ci si ferma e decide il test. // 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; if (game.closeup && !game.speech && game.reach <= 0) {
if (!explore || game.story.closeups[game.closeup.id].type !== 'torch') return said;
exploreTorch(game);
}
// 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);
@@ -15,6 +18,14 @@ export function settle(game, { maxSteps = 20000 } = {}) {
throw new Error('Il gioco non si ferma: ' + JSON.stringify(game.snapshot())); throw new Error('Il gioco non si ferma: ' + JSON.stringify(game.snapshot()));
} }
// Il collaudo della soluzione compie anche i gesti richiesti dal finale interattivo.
export function exploreTorch(game) {
const c = game.closeup, def = c && game.story.closeups[c.id];
if (def?.type !== 'torch') return;
if (c.step === 'done') game.closeupClose();
else if (c.step === 'search') game.closeupAim(def.targets[c.found].at);
}
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]; } };
} }
+127
View File
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
export async function checkMultimedia(browser, port, out, errors) {
for (const mobile of [false, true]) {
const page = await browser.newPage({ viewport: mobile ? { width: 844, height: 390 } : { width: 1280, height: 860 }, hasTouch: mobile, isMobile: mobile });
page.on('pageerror', e => errors.push(e.message));
await page.goto(`http://127.0.0.1:${port}/`);
await page.click('#splash'); await page.click('#new-game');
await page.click('[data-rione="campomigliaio"]'); await page.click('#skip-intro');
const settle = async () => {
for (let i = 0; i < 200; i++) {
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
if (await page.evaluate(() => game.closeup || game.idle)) return;
await page.waitForTimeout(30);
}
throw new Error('la sequenza non si ferma');
};
await settle();
await page.waitForFunction(() => ambience.audible && ambience.context.state === 'running');
await page.click('#mute');
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'muto immediato');
await page.click('#mute');
await page.click('#pause-button');
await page.locator('#effects-volume').fill('65');
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio')).effects), 0.65);
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'in pausa nessun suono');
await page.screenshot({ path: `${out}/media-pausa-${mobile ? 'telefono' : 'desktop'}.png` });
await page.click('#resume');
if (mobile) {
// Il libro già esistente deve continuare a sfogliarsi anche sul telefono.
await page.evaluate(() => { game.state.inventory.push('libro'); game.act('open', 'libro'); });
await page.waitForFunction(() => game.closeup?.step === 'cover');
const r = await page.locator('#scene').boundingBox();
await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height * 0.6);
await page.waitForFunction(() => game.closeup.step === 'title');
await page.touchscreen.tap(r.x + r.width / 2, r.y + r.height * 0.6);
await page.waitForFunction(() => game.closeup.step === 'end');
await page.screenshot({ path: `${out}/media-libro-telefono.png` });
await page.tap('#closeup-close'); await settle();
}
await page.evaluate(() => {
game.state.flags.indizio_regolo = true;
game.state.flags.casetta_aperta = true;
game.state.inventory.push('torcia');
game.go('fortezza', [462, 310]);
});
await settle();
const at = async ([x, y]) => {
const r = await page.locator('#scene').boundingBox();
return [r.x + x / 640 * r.width, r.y + y / 360 * r.height];
};
const openTorch = async () => {
await page.click('#verbs [data-verb="use"]'); await page.click('#inventory [data-item="torcia"]');
await page.mouse.click(...await at([501, 285]));
await settle();
await page.waitForFunction(() => game.closeup?.id === 'fessura');
};
await openTorch();
const saved = await page.evaluate(() => game.savable());
await page.keyboard.press('Escape'); await settle();
assert.equal(await page.evaluate(() => game.state.ended), false, 'uscire presto non vince');
await openTorch();
await page.click('#pause-button');
const age = await page.evaluate(() => game.closeup.age);
await page.waitForTimeout(150);
assert.equal(await page.evaluate(() => game.closeup.age), age, 'il primo piano è fermo in pausa');
await page.click('#resume');
const origin = await page.evaluate(() => [game.state.x, game.state.y]);
if (mobile) {
const cdp = await page.context().newCDPSession(page);
const [x, y] = await at([320, 180]), [tx, ty] = await at([300, 90]);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: tx, y: ty }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const beam = await page.evaluate(() => game.closeup.beam);
assert.ok(Math.abs(beam[0] - 300) < 2 && Math.abs(beam[1] - 90) < 2, 'trascinare il dito sposta la torcia');
await cdp.detach();
}
for (let i = 0; i < 3; i++) {
const target = await page.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
if (mobile) await page.touchscreen.tap(...await at(target));
else if (i === 0) await page.keyboard.press('Enter');
else await page.mouse.move(...await at(target));
await page.waitForFunction(() => game.closeup?.step === 'reveal');
await page.screenshot({ path: `${out}/media-torcia-${mobile ? 'telefono' : 'desktop'}-${i}.png` });
for (let n = 0; n < 200; n++) {
if (await page.evaluate(i => game.closeup.found > i, i)) break;
if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space');
await page.waitForTimeout(25);
}
assert.equal(await page.evaluate(() => game.closeup.found), i + 1);
}
assert.deepEqual(await page.evaluate(() => [game.state.x, game.state.y]), origin, 'il puntatore non muove Andrea');
assert.deepEqual(await page.evaluate(() => game.savable()), saved, 'durante il primo piano resta il punto fermo');
if (mobile) await page.tap('#closeup-close'); else await page.keyboard.press('Enter');
await settle();
await page.waitForSelector('#ending:not([hidden])');
assert.ok(await page.evaluate(() => game.checkpoint.ended));
assert.equal(await page.evaluate(() => ambience.nodes.size), 0, 'alla fine non resta il vento della fessura');
// Misura il segnale realmente prodotto da Web Audio: udibile, finito, senza saturazione.
if (!mobile) {
const levels = await page.evaluate(async () => {
const { Soundscape } = await import('/src/engine/soundscape.js');
const results = {};
for (const kind of ['page', 'wood', 'drip', 'bird', 'cricket', 'bell', 'glass', 'water', 'wind']) {
const ctx = new OfflineAudioContext(2, 44100 * 2, 44100);
const s = new Soundscape({ createContext: () => ctx });
s.unlock(); s.configure({ active: true });
if (kind === 'water' || kind === 'wind') s.update(kind === 'water' ? 'acqua' : 'fortezza', 'sera', 0.1);
else s.play(kind);
const data = (await ctx.startRendering()).getChannelData(0);
let peak = 0, sum = 0;
for (const v of data) { peak = Math.max(peak, Math.abs(v)); sum += v * v; }
results[kind] = { peak, rms: Math.sqrt(sum / data.length) };
}
return results;
});
for (const [kind, { peak, rms }] of Object.entries(levels)) {
assert.ok(rms > 0.00001 && peak < 0.5, `${kind}: audio non valido ${JSON.stringify({ peak, rms })}`);
}
}
await page.reload();
await page.waitForFunction(() => window.ambience?.volume === 0.65);
await page.close();
}
console.log('Multimedia OK: torcia con mouse, tastiera e touch; uscita e pausa; audio sintetizzato e volume salvato.');
}
+102
View File
@@ -0,0 +1,102 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Game } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
import { Soundscape } from '../src/engine/soundscape.js';
import { soundscapes, soundscapeFor } from '../src/story/soundscapes.js';
function finale() {
const g = new Game(story, { state: { scene: 'fortezza', x: 462, y: 310, facing: 1, inventory: ['torcia'],
flags: { indizio_regolo: true, casetta_aperta: true }, used: {}, ended: false } });
g.act('use', 'fessura', 'torcia'); settle(g);
return g;
}
test('la torcia aspetta la ricerca, richiede una sosta e non scopre gli occhi prima del quadro', () => {
const g = finale();
assert.equal(g.closeup.step, 'search');
assert.equal(g.act('look', 'bastione'), false);
assert.equal(g.walkTo([100, 300]), false);
assert.equal(g.travel('piazza'), false);
const eyes = story.closeups.fessura.targets[2].at;
g.closeupAim(eyes); g.update(2);
assert.equal(g.closeup.found, 0);
g.closeupAim(story.closeups.fessura.targets[0].at); g.update(0.3);
g.closeupAim(eyes); g.update(0.2);
g.closeupAim(story.closeups.fessura.targets[0].at); g.update(0.3);
assert.equal(g.closeup.step, 'search', 'la sosta riparte quando si sposta il fascio');
g.update(0.2);
assert.equal(g.closeup.step, 'reveal');
assert.equal(g.speech, null, 'la pietra non rivela il testo del quadro');
assert.equal(g.state.flags.palio_trovato, undefined);
const lines = settle(g, { explore: true }).map(s => s.text);
assert.ok(lines.indexOf('È il palio! Il quadro dei ragazzi delle medie!') < lines.indexOf('E quelli... sono due occhi gialli!'));
assert.ok(g.checkpoint.ended && g.checkpoint.flags.palio_trovato);
});
test('uscire dalla fessura o ricaricare durante la ricerca non finisce la partita e permette di riprovare', () => {
const g = finale(), saved = g.savable();
g.closeupAim(story.closeups.fessura.targets[0].at); g.update(1);
assert.deepEqual(g.savable(), saved);
assert.ok(g.closeupClose()); settle(g);
assert.ok(!g.state.ended && !g.state.flags.palio_trovato);
assert.ok(g.state.inventory.includes('torcia'));
const reload = new Game(story, { state: g.savable() });
reload.act('use', 'fessura', 'torcia'); settle(reload);
assert.equal(reload.closeup.found, 0);
settle(reload, { explore: true });
assert.ok(reload.state.ended);
});
test('aprire un primo piano ferma gli effetti successivi fino alla sua chiusura', () => {
const g = new Game(story);
g.queue = [{ closeup: 'libro' }, { set: 'dopo_libro' }];
g.update(0);
assert.equal(g.state.flags.dopo_libro, undefined);
g.closeupClose(); settle(g);
assert.equal(g.state.flags.dopo_libro, true);
});
function audioContext() {
const param = () => ({ value: 0, setValueAtTime(v) { this.value = v; }, linearRampToValueAtTime(v) { this.value = v; },
exponentialRampToValueAtTime(v) { this.value = v; }, setTargetAtTime(v) { this.value = v; }, cancelScheduledValues() {} });
const node = () => ({ gain: param(), frequency: param(), Q: param(), pan: param(), connect() {}, disconnect() {}, start() {}, stop() {} });
return { currentTime: 0, sampleRate: 100, destination: {}, state: 'running',
createGain: node, createBufferSource: node, createBiquadFilter: node, createStereoPanner: node, createOscillator: node,
createBuffer: (_, length) => ({ getChannelData: () => new Float32Array(length) }) };
}
test('suoni: muto e pausa fermano anche gli eventi già avviati, poi il fondo riparte', () => {
const s = new Soundscape({ createContext: audioContext, random: () => 0.3 });
s.unlock(); s.configure({ active: true }); s.update('acqua', 'pomeriggio', 0.1); s.play('page');
assert.ok(s.nodes.size > 1);
s.configure({ muted: true });
assert.equal(s.nodes.size, 0); assert.equal(s.master.gain.value, 0);
s.play('bell'); assert.equal(s.nodes.size, 0);
s.configure({ muted: false }); s.update('acqua', 'pomeriggio', 0.1);
assert.ok(s.bed);
s.configure({ active: false }); assert.equal(s.nodes.size, 0);
s.configure({ active: true, volume: 0 }); s.play('page'); assert.equal(s.nodes.size, 0);
});
test('suoni: profili distinti, niente uccelli di sera, nessun accumulo ai cambi di luogo', () => {
assert.equal(soundscapeFor('fortezza', 'fessura'), 'fessura');
assert.notEqual(soundscapeFor('campomigliaio_ponte'), soundscapeFor('pieve_interno'));
for (const def of Object.values(soundscapes)) assert.ok(!def.night.includes('bird'));
const s = new Soundscape({ createContext: audioContext, random: () => 0.3 });
s.unlock(); s.configure({ active: true });
for (let i = 0; i < 30; i++) {
s.update(i % 2 ? 'acqua' : 'bosco', 'sera', 0.1);
assert.equal(s.nodes.size, 1);
}
const events = []; s.play = kind => events.push(kind);
s.update('paese', 'sera', 0.1); s.update('paese', 'sera', 10);
assert.deepEqual(events, ['cricket']);
s.update('pieve', 'sera', 0.1); assert.equal(s.nodes.size, 0);
});
test('Web Audio mancante non interrompe la partita', () => {
const s = new Soundscape({ createContext: () => { throw new Error('non disponibile'); } });
assert.doesNotThrow(() => { s.unlock(); s.configure({ active: true }); s.update('paese', 'sera', 1); s.play('page'); });
});
+2
View File
@@ -27,6 +27,8 @@ test('volume della musica, volume delle voci e muto si ricordano', () => {
assert.equal(readAudio(s), null); assert.equal(readAudio(s), null);
assert.ok(writeAudio(s, { volume: 0.3, muted: true, voices: 0.4 })); assert.ok(writeAudio(s, { volume: 0.3, muted: true, voices: 0.4 }));
assert.deepEqual(readAudio(s), { volume: 0.3, muted: true, voices: 0.4 }); assert.deepEqual(readAudio(s), { volume: 0.3, muted: true, voices: 0.4 });
writeAudio(s, { volume: 0.3, muted: false, voices: 0.4, effects: 0.65 });
assert.deepEqual(readAudio(s), { volume: 0.3, muted: false, voices: 0.4, effects: 0.65 });
writeAudio(s, { volume: 0.3, muted: false }); writeAudio(s, { volume: 0.3, muted: false });
assert.deepEqual(readAudio(s), { volume: 0.3, muted: false }, 'un salvataggio di prima delle voci resta valido'); assert.deepEqual(readAudio(s), { volume: 0.3, muted: false }, 'un salvataggio di prima delle voci resta valido');
s.setItem('leggenda-regolo-audio', '{rotto'); s.setItem('leggenda-regolo-audio', '{rotto');
+24 -5
View File
@@ -6,12 +6,16 @@ import { solution } from '../src/story/solution.js';
import { Game, VERBS, checkSave, introText, shuffledCredits, matchPlace } from '../src/engine/game.js'; import { Game, VERBS, checkSave, introText, shuffledCredits, matchPlace } from '../src/engine/game.js';
import { inside } from '../src/engine/geometry.js'; 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, exploreTorch } from './helpers.mjs';
const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end', 'closeup']; 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() {
for (const [id, c] of Object.entries(story.closeups ?? {})) {
for (const k of ['comment', 'closing', 'complete']) yield [`${id}.${k}`, c[k] ?? []];
for (const t of c.targets ?? []) yield [`${id}.target`, t.lines ?? []];
}
for (const [id, scene] of Object.entries(story.scenes)) { for (const [id, scene] of Object.entries(story.scenes)) {
yield [`${id}.enter`, scene.enter ?? []]; yield [`${id}.enter`, scene.enter ?? []];
for (const h of scene.hotspots ?? []) for (const h of scene.hotspots ?? [])
@@ -66,7 +70,7 @@ test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esist
if (e.closeup) { if (e.closeup) {
const c = story.closeups?.[e.closeup]; const c = story.closeups?.[e.closeup];
assert.ok(c, `${where}: primo piano ${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}`); for (const src of c.type === 'torch' ? [c.picture] : [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}`);
} }
@@ -118,7 +122,7 @@ test('il taccuino: lungo la soluzione le tappe si spuntano in ordine, una alla v
let last = 0; let last = 0;
for (const [verb, target, item] of solution) { for (const [verb, target, item] of solution) {
if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item); if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item);
settle(g); settle(g, { explore: true });
const n = g.notebook().done.length; const n = g.notebook().done.length;
assert.ok(n === last || n === last + 1, `tappe saltate dopo ${verb} ${target}`); assert.ok(n === last || n === last + 1, `tappe saltate dopo ${verb} ${target}`);
last = n; last = n;
@@ -134,7 +138,7 @@ test('le fasi del giorno seguono la storia: pomeriggio, poi tramonto, poi sera,
const ids = story.phases.map(p => p.id), seen = [g.phase().id]; const ids = story.phases.map(p => p.id), seen = [g.phase().id];
for (const [verb, target, item] of solution) { for (const [verb, target, item] of solution) {
if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item); if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item);
settle(g); settle(g, { explore: true });
if (seen.at(-1) !== g.phase().id) seen.push(g.phase().id); if (seen.at(-1) !== g.phase().id) seen.push(g.phase().id);
} }
assert.deepEqual(seen, ids); assert.deepEqual(seen, ids);
@@ -166,7 +170,7 @@ test('la soluzione si percorre tutta dall\'inizio alla fine', () => {
if (item) assert.ok(g.state.inventory.includes(item), `manca ${item}`); if (item) assert.ok(g.state.inventory.includes(item), `manca ${item}`);
assert.ok(g.act(verb, target, item), `comando rifiutato: ${verb} ${target}`); assert.ok(g.act(verb, target, item), `comando rifiutato: ${verb} ${target}`);
} }
settle(g); settle(g, { explore: true });
} }
assert.ok(g.state.ended, 'la partita non è finita'); assert.ok(g.state.ended, 'la partita non è finita');
assert.ok(g.state.flags.indizio_regolo && g.state.flags.palio_trovato, 'il palio visto nella fessura'); assert.ok(g.state.flags.indizio_regolo && g.state.flags.palio_trovato, 'il palio visto nella fessura');
@@ -367,6 +371,7 @@ function stepChecked(g, label, step) {
for (let i = 0; i < 20000; i++) { for (let i = 0; i < 20000; i++) {
seen.push(essence(g.savable())); seen.push(essence(g.savable()));
if (g.speech) g.skip(); if (g.speech) g.skip();
exploreTorch(g);
if (!g.busy && !g.walking && !g.queue.length && !g.task) break; if (!g.busy && !g.walking && !g.queue.length && !g.task) break;
g.update(0.05); g.update(0.05);
} }
@@ -584,3 +589,17 @@ test('il pettegolezzo cambia con le scoperte: vecchietti, Arcu e Tosca', () => {
assert.ok(texts().includes('Ho la torcia. Stasera entro nella fessura.')); assert.ok(texts().includes('Ho la torcia. Stasera entro nella fessura.'));
leave(); leave();
}); });
test('i file che il Docker copia sono leggibili da tutti (nginx gira con un altro utente)', async () => {
const { readdirSync, statSync } = await import('node:fs');
const root = new URL('../', import.meta.url);
const dirs = readFileSync(new URL('Dockerfile', root), 'utf8').match(/^COPY (assets\/\S+|src)\s/gm).map(l => l.split(' ')[1]);
const closed = [];
const walk = dir => { for (const e of readdirSync(new URL(dir + '/', root), { withFileTypes: true })) {
const path = `${dir}/${e.name}`;
if (e.isDirectory()) walk(path);
else if (!(statSync(new URL(path, root)).mode & 0o004)) closed.push(path);
} };
dirs.forEach(walk);
assert.deepEqual(closed, [], 'file non leggibili da nginx (chmod 644)');
});
+10
View File
@@ -81,6 +81,16 @@ async function giro(modo) {
const fermo = async passo => { const fermo = async passo => {
let ultima = null; let ultima = null;
for (let i = 0; i < 600; i++) { for (let i = 0; i < 600; i++) {
const search = await page.evaluate(() => {
const c = game.closeup;
if (c?.id !== 'fessura' || game.speech) return null;
return c.step === 'done' ? { done: true } : c.step === 'search' ? { at: game.story.closeups.fessura.targets[c.found].at } : null;
});
if (search?.done) await premi('#closeup-close');
else if (search?.at) {
const r = await page.locator('#scene').boundingBox(), [x, y] = search.at;
await tocca(r.x + x / 640 * r.width, r.y + y / 360 * r.height);
}
const s = await page.evaluate(() => ({ t: game.speech?.text ?? null, idle: !game.busy && !game.walking && !game.queue.length && !game.task })); const s = await page.evaluate(() => ({ t: game.speech?.text ?? null, idle: !game.busy && !game.walking && !game.queue.length && !game.task }));
if (s.t && s.t !== ultima) { ultima = s.t; await page.waitForTimeout(60); await foto(`battuta-${s.t}`, passo); await page.evaluate(() => game.skip()); } if (s.t && s.t !== ultima) { ultima = s.t; await page.waitForTimeout(60); await foto(`battuta-${s.t}`, passo); await page.evaluate(() => game.skip()); }
else if (!s.t && s.idle) return; else if (!s.t && s.idle) return;
+10
View File
@@ -94,6 +94,16 @@ export async function run(d) {
if (await p.evaluate((t) => !!game.story.items[t], target)) await oggetto(target); if (await p.evaluate((t) => !!game.story.items[t], target)) await oggetto(target);
else await clicca(await centro(target)); else await clicca(await centro(target));
await fermi(verb === "walk" ? 900 : 700); await fermi(verb === "walk" ? 900 : 700);
if (await p.evaluate(() => game.closeup?.id === 'fessura')) {
for (let i = 0; i < 3; i++) {
const target = await p.evaluate(i => game.story.closeups.fessura.targets[i].at, i);
const [x, y] = await punto(...target);
d.vm(`DISPLAY=:0 ~/lab/video/mv.py ${x} ${y} 1.2`);
await p.waitForFunction(i => game.closeup?.found > i, i, { timeout: 60000 });
}
await d.click(p.locator('#closeup-close'), 0.6);
await fermi();
}
}; };
const passi = async (lista) => { for (const s of lista) await passo(s); }; const passi = async (lista) => { for (const s of lista) await passo(s); };