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:
@@ -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 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)}`;
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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]; } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
@@ -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'); });
|
||||||
|
});
|
||||||
@@ -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
@@ -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)');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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); };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user