Files
leggenda-del-regolo/src/main.js
T
manzoloandClaude Opus 5.5 ab03ee0b5a Scuola, strada per Cardetole, indovinello di San Pietro con la chiave dell'oratorio, verbo Butta e Pausa
- La scuola (sfondo di Codex dalla foto di Andrea): dalla salita di Villa Adami, dal cancello alle porte a vetri.
- Alla Pieve la strada per Cardetole (per ora una battuta).
- La statua di San Pietro chiede quanti bastioni ha la Fortezza: con la risposta giusta dà la chiave
  dell'oratorio; la porta, aperta una volta, resta aperta e la chiave sparisce. Voce propria per San Pietro.
- Verbo Butta: solo gli oggetti droppable (il libro), quelli che servono alla storia no.
- Pulsante Pausa (o Esc): Riprendi, Mappa, Crediti, Torna al titolo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017egeN2amL84ErjfgQFtEDn
2026-10-23 17:48:49 +02:00

320 lines
15 KiB
JavaScript

// Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti).
import { story } from './story/story.js';
import { Game, VERBS, VERB_LABELS } from './engine/game.js';
import { Command } from './engine/command.js';
import { render, loadAssets, WIDTH, HEIGHT } from './engine/render.js';
import { readSave, writeSave, clearSave, readAudio, writeAudio } from './engine/storage.js';
import { Music, trackFor } from './engine/music.js';
import { Voices } from './engine/voices.js';
const $ = id => document.getElementById(id);
const canvas = $('scene'), ctx = canvas.getContext('2d');
const storage = (() => { try { return window.localStorage; } catch { return null; } })();
const LONG_PRESS = 450;
const debug = new URLSearchParams(location.search).has('debug');
let game = null, command = null, hoverTarget = null, mouse = null, running = false;
// --- Musica: parte subito se il browser lo permette, altrimenti al primo gesto; volume e muto si ricordano.
const music = new Music((storage && readAudio(storage)) ?? {});
const saveAudio = () => storage && writeAudio(storage, music.settings());
function showSound() {
$('mute').setAttribute('aria-pressed', String(music.muted));
$('mute').setAttribute('aria-label', music.muted ? "Musica: rimetti l'audio" : "Musica: togli l'audio");
$('volume').value = Math.round(music.volume * 100);
}
for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, () => music.unlock(), { capture: true });
$('mute').addEventListener('click', () => { music.toggle(); saveAudio(); showSound(); });
$('volume').addEventListener('input', e => { music.setVolume(e.target.value / 100); saveAudio(); showSound(); });
showSound();
window.music = music; // per i test nel browser
// --- Voci: l'indice arriva con fetch; finché non c'è (o se manca) le battute restano mute. Seguono il muto della musica.
const voices = new Voices();
fetch('assets/voices/index.json').then(r => r.ok ? r.json() : {}).then(index => { voices.index = index; }).catch(() => {});
window.voices = voices;
let voiced = null;
const say = (who, text) => voices.play(who, text, music.muted ? 0 : 1);
function start(state) {
game = new Game(story, { state, onSave: data => storage && writeSave(storage, data), voiceTime: (who, text) => voices.seconds(who, text) });
command = new Command(game);
if (!state) game.go(story.start.scene, story.start.at);
$('title').hidden = true;
$('ending').hidden = true;
running = true;
window.game = game; // per i test nel browser e per curiosare dalla console
}
// --- Introduzione: le righe di story.intro una alla volta sopra il panorama; poi comincia la partita.
let introLine = -1;
function showIntro() {
if (!story.intro?.length) return start(null);
$('title').hidden = true;
$('intro').hidden = false;
introLine = -1;
nextIntro();
}
function nextIntro() {
introLine++;
if (introLine >= story.intro.length) { voices.stop(); $('intro').hidden = true; return start(null); }
const p = $('intro-text');
p.textContent = story.intro[introLine];
say('andrea', story.intro[introLine]);
p.style.animation = 'none';
void p.offsetWidth; // riavvia la comparsa
p.style.animation = '';
}
$('intro').addEventListener('click', e => { if (e.target !== $('skip-intro')) nextIntro(); });
$('skip-intro').addEventListener('click', () => { introLine = story.intro.length; nextIntro(); });
// --- Crediti: costruiti da story.credits; si aprono dal titolo e dalla fine, Chiudi o Esc tornano indietro.
$('credits-list').replaceChildren(...(story.credits ?? []).flatMap(({ role, names }) => {
const dt = document.createElement('dt'), dd = document.createElement('dd');
dt.textContent = role;
dd.textContent = names.join(' · ');
return [dt, dd];
}));
for (const b of document.querySelectorAll('.open-credits')) b.addEventListener('click', () => { $('credits').hidden = false; });
$('close-credits').addEventListener('click', () => { $('credits').hidden = true; });
// --- Mappa: i posti visti e le strade fra loro; un clic su un posto ci porta (game.travel).
function openMap() {
if (!running || game.dialogue) return;
const board = $('map-board'), svg = $('map-roads'), places = game.mapPlaces(), at = Object.fromEntries(places.map(p => [p.id, p.at]));
svg.replaceChildren(...game.mapRoads().map(([a, b]) => {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
[['x1', at[a][0]], ['y1', at[a][1]], ['x2', at[b][0]], ['y2', at[b][1]]].forEach(([k, v]) => line.setAttribute(k, v));
return line;
}));
board.replaceChildren(svg, ...places.map(p => {
const b = document.createElement('button');
b.type = 'button';
b.dataset.scene = p.id;
b.textContent = p.name;
b.classList.toggle('here', p.here);
b.style.left = p.at[0] + '%';
b.style.top = p.at[1] + '%';
b.addEventListener('click', () => { if (game.travel(p.id)) $('map').hidden = true; });
return b;
}));
$('map').hidden = false;
}
$('map-button').addEventListener('click', openMap);
// --- Pausa: ferma il gioco; da qui mappa, crediti o ritorno al titolo (la partita resta salvata per «Continua»).
function openPause() {
if (!running) return;
running = false;
command.reset();
$('pause-map').hidden = game.mapPlaces().length < 2;
$('pause').hidden = false;
}
function closePause() {
$('pause').hidden = true;
running = true;
}
$('pause-button').addEventListener('click', openPause);
$('resume').addEventListener('click', closePause);
$('pause-map').addEventListener('click', () => { closePause(); openMap(); });
$('to-title').addEventListener('click', () => {
$('pause').hidden = true;
game.onSave(game.snapshot());
showTitle();
});
$('close-map').addEventListener('click', () => { $('map').hidden = true; });
function showTitle() {
running = false;
const saved = storage && readSave(storage);
$('continue').hidden = !saved || saved.ended;
$('title').hidden = false;
}
// --- Puntatore: clic, clic destro e pressione lunga = guarda.
const scenePoint = e => {
const r = canvas.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * WIDTH, (e.clientY - r.top) / r.height * HEIGHT];
};
let press = null;
canvas.addEventListener('pointerdown', e => {
if (!running || e.button > 0) return;
const point = scenePoint(e);
press = { point, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true }); }, LONG_PRESS) };
});
canvas.addEventListener('pointerup', e => {
if (!press) return;
clearTimeout(press.timer);
if (!press.long) command.clickScene(scenePoint(e));
press = null;
});
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; });
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
if (running) command.clickScene(scenePoint(e), { look: true });
});
canvas.addEventListener('pointermove', e => {
if (!running) return;
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
});
canvas.addEventListener('pointerleave', () => { mouse = null; });
// --- Verbi.
for (const verb of VERBS) {
const b = document.createElement('button');
b.type = 'button';
b.dataset.verb = verb;
b.textContent = VERB_LABELS[verb];
b.addEventListener('click', () => running && command.pickVerb(verb));
$('verbs').appendChild(b);
}
// --- Inventario: si ridisegna solo quando cambia.
let inventoryKey = '', hoverItem = null;
function updateInventory() {
const key = game.state.inventory.join(',') + '|' + command.item;
if (key === inventoryKey) return;
inventoryKey = key;
const box = $('inventory');
box.replaceChildren(...game.state.inventory.map(id => {
const b = document.createElement('button'), item = story.items[id], dot = document.createElement('i');
b.type = 'button';
b.dataset.item = id;
b.setAttribute('aria-pressed', String(command.item === id));
dot.style.background = item.icon?.fill ?? '#ccc';
b.append(dot, item.name);
let timer = null, long = false;
b.addEventListener('pointerdown', () => { long = false; timer = setTimeout(() => { long = true; command.clickItem(id, { look: true }); }, LONG_PRESS); });
b.addEventListener('pointerup', () => clearTimeout(timer));
b.addEventListener('click', () => { if (!long && running) command.clickItem(id); });
b.addEventListener('contextmenu', e => { e.preventDefault(); clearTimeout(timer); if (running) command.clickItem(id, { look: true }); });
b.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') hoverItem = id; });
b.addEventListener('pointerleave', () => { if (hoverItem === id) hoverItem = null; });
return b;
}));
}
// --- Dialoghi: scelte numerate, anche da tastiera.
let choicesKey = '';
function updateChoices() {
const options = game.options(), inDialogue = !!game.dialogue;
$('panel').hidden = inDialogue;
$('choices').hidden = !inDialogue;
const key = inDialogue + '|' + options.map(o => o.text).join('|');
if (key === choicesKey) return;
choicesKey = key;
$('choices').replaceChildren(...options.map((o, i) => {
const li = document.createElement('li');
li.tabIndex = 0;
li.textContent = o.text;
li.addEventListener('click', () => game.choose(i));
li.addEventListener('keydown', e => { if (e.key === 'Enter') game.choose(i); });
return li;
}));
}
window.addEventListener('keydown', e => {
if (e.target === $('volume')) return;
if (!$('credits').hidden) { if (e.key === 'Escape') $('credits').hidden = true; return; }
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; }
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
if ((e.key === 'p' || e.key === 'P') && running) { openMap(); return; }
if (!$('intro').hidden && [' ', 'Enter'].includes(e.key)) { e.preventDefault(); nextIntro(); return; }
if (e.key === 'm' || e.key === 'M') { music.toggle(); saveAudio(); showSound(); return; }
if (!running) return;
if ([' ', '.', 'Enter'].includes(e.key) && game.speech) { e.preventDefault(); game.skip(); return; }
// Esc annulla il comando a metà; se non c'è niente da annullare, mette in pausa.
if (e.key === 'Escape') { if (command.verb || command.item || game.dialogue) command.reset(); else openPause(); }
const n = Number(e.key);
if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1);
});
// --- Fumetto sopra chi parla, tenuto dentro i bordi della scena.
function updateSpeech() {
const box = $('speech'), sp = game.speech;
// Una battuta nuova fa partire la sua voce; saltata o finita, la voce si ferma.
if (sp !== voiced) {
voiced = sp;
if (sp) say(sp.who, sp.text); else voices.stop();
}
if (!sp) { if (box.textContent) box.textContent = ''; return; }
const [x, y] = game.speakerPoint(sp.who);
box.textContent = sp.text;
box.style.color = story.characters[sp.who]?.color ?? '#fff';
box.style.left = Math.min(77, Math.max(23, x / WIDTH * 100)) + '%';
box.style.top = Math.max(18, y / HEIGHT * 100) + '%';
}
function updateUi() {
// Il bersaglio si ricalcola a ogni fotogramma: la scena cambia anche se il mouse sta fermo.
hoverTarget = (game.state.inventory.includes(hoverItem) && hoverItem) || (mouse && game.hotspotAt(mouse)?.id) || null;
for (const b of $('verbs').children) b.setAttribute('aria-pressed', String(command.verb === b.dataset.verb));
const text = !running || game.speech || game.dialogue ? '' : command.sentence(hoverTarget);
if ($('sentence').textContent !== text) $('sentence').textContent = text;
updateInventory();
updateChoices();
updateSpeech();
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
$('map-button').hidden = !running || !!game.dialogue || game.mapPlaces().length < 2;
$('pause-button').hidden = !running;
if (game.state.ended && !game.speech && !game.queue.length) {
running = false;
$('ending').hidden = false;
}
}
let last = performance.now();
function frame(now) {
// Il primo timestamp di requestAnimationFrame può precedere `last`: un dt negativo darebbe alla musica un volume
// sotto zero, il browser lancerebbe un errore e il ciclo dei fotogrammi si fermerebbe (gioco congelato).
const dt = Math.max(0, Math.min(0.1, (now - last) / 1000));
last = now;
const screen = !$('title').hidden ? 'title' : !$('splash').hidden ? 'splash' : !$('intro').hidden ? 'intro' : !$('ending').hidden ? 'ending' : 'game';
music.duck = voices.playing ? 0.45 : 1;
music.update(trackFor(story, { screen, scene: game?.state.scene }), dt);
if (game) {
if (running) game.update(dt);
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null });
updateUi();
}
requestAnimationFrame(frame);
}
$('new-game').addEventListener('click', () => { if (storage) clearSave(storage); showIntro(); });
$('continue').addEventListener('click', () => start(readSave(storage)));
$('restart').addEventListener('click', () => { if (storage) clearSave(storage); start(null); });
// Dietro il titolo si vede già la piazza.
// --- Cartello d'apertura «Manzolo (Poca) Art Presents»: 5 secondi (un clic o un tasto lo salta), poi il titolo.
// Il titolo compare sotto il cartello quando questo comincia a sfumare (style.css, 4,3 s): dissolvenza incrociata,
// senza intravedere la piazza nel mezzo.
const SPLASH_FADE = 4300, SPLASH_END = 5000;
function showSplash() {
let done = false, revealed = false;
const reveal = () => { if (!revealed) { revealed = true; showTitle(); } };
const end = () => {
if (done) return;
done = true;
$('splash').hidden = true;
jingle?.pause(); // saltato il cartello, si salta anche la musichetta
reveal();
};
// La musichetta: senza un gesto del giocatore il browser può non lasciarla partire, e allora il cartello resta muto.
const jingle = story.music?.splash ? new Audio(story.music.splash) : null;
if (jingle) {
jingle.volume = music.muted ? 0 : music.volume;
jingle.play().catch(() => {});
window.jingle = jingle; // per i test nel browser
}
setTimeout(reveal, SPLASH_FADE);
setTimeout(end, SPLASH_END);
$('splash').addEventListener('click', end);
addEventListener('keydown', end, { once: true });
}
game = new Game(story);
command = new Command(game);
showSplash();
requestAnimationFrame(frame);
loadAssets(story).then(ok => { if (ok.includes(false)) console.warn('Alcune immagini non sono state caricate.'); });