Files
leggenda-del-regolo/src/main.js
T

1123 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti).
import { story } from './story/story.js';
import { Game, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js';
import { Command } from './engine/command.js';
import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js';
import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
import { Music, trackFor } from './engine/music.js';
import { Voices } from './engine/voices.js';
import { Soundscape } from './engine/soundscape.js';
import { soundscapeFor } from './story/soundscapes.js';
import * as bocce from './engine/bocce.js';
import * as biliardo from './engine/biliardo.js';
import * as rinquarto from './engine/rinquarto.js';
import * as basket from './engine/basket.js';
import * as pesca from './engine/pesca.js';
import * as gara from './engine/gara.js';
import * as briscola from './engine/briscola.js';
import { cardAt } from './engine/briscola-render.js';
import * as tressette from './engine/tressette.js';
import { cardAt as tressetteAt } from './engine/tressette-render.js';
import * as scopa from './engine/scopa.js';
import { cardAt as scopaAt, tableAt as scopaTableAt } from './engine/scopa-render.js';
import { VERSION, versioned } from './version.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, DOUBLE_CLICK = 350, IDLE_PAUSE = 60000;
let idleSince = performance.now();
for (const ev of ['pointerdown', 'pointermove', 'keydown', 'wheel']) addEventListener(ev, () => { idleSince = performance.now(); }, { capture: true, passive: true });
let lastTap = null;
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; volumi e muto si ricordano.
const audioSettings = (storage && readAudio(storage)) ?? {};
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,
// ma hanno il loro volume.
const voices = new Voices({}, undefined, audioSettings.voices);
const saveAudio = () => storage && writeAudio(storage, { ...music.settings(), voices: voices.volume, effects: ambience.volume });
function showSound() {
$('mute').setAttribute('aria-pressed', String(music.muted));
$('mute').setAttribute('aria-label', music.muted ? "Rimetti l'audio" : "Togli l'audio");
$('volume').value = Math.round(music.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(); ambience.unlock(); }, { capture: true });
// Il muto zittisce subito anche la voce che sta parlando.
const toggleMute = () => { if (music.toggle()) voices.stop(); ambience.configure({ muted: music.muted }); saveAudio(); showSound(); };
$('mute').addEventListener('click', toggleMute);
$('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(); });
$('effects-volume').addEventListener('input', e => { ambience.configure({ volume: e.target.value / 100 }); saveAudio(); showSound(); });
showSound();
window.music = music; // per i test nel browser
fetch(versioned('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);
// Una partita salvata (state) o nuova; la nuova col rione scelto, che resta nei flag (`rione:<id>`).
function start(state, rione = null) {
game = new Game(story, { state, onSave: data => storage && writeSave(storage, data), voiceTime: (who, text) => voices.seconds(who, text) });
command = new Command(game);
if (!state && rione) game.state.flags['rione:' + rione] = true;
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.
// --- Il rione: dopo «Nuova partita», per chi corre Andrea (story.rioni); poi l'introduzione con la sua riga.
let rione = null;
$('rione-buttons').replaceChildren(...Object.entries(story.rioni ?? {}).map(([id, r]) => {
const b = document.createElement('button');
b.type = 'button';
b.dataset.rione = id;
b.style.setProperty('--rione', r.color);
b.innerHTML = '<span class="rione-name"></span><span class="rione-colors"></span>';
b.querySelector('.rione-name').textContent = r.name;
b.querySelector('.rione-colors').textContent = r.locked ? 'prossimamente' : r.colors;
// Un rione chiuso si vede ma non si sceglie (per ora si corre solo per Campomigliaio).
if (r.locked) { b.disabled = true; b.classList.add('locked'); b.title = 'Prossimamente'; }
b.addEventListener('click', () => { rione = id; $('rione').hidden = true; showIntro(); });
return b;
}));
function chooseRione() {
if (!story.rioni) return showIntro();
$('title').hidden = true;
$('rione').hidden = false;
}
let introLine = -1;
function showIntro() {
if (!story.intro?.length) return start(null, rione);
$('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, rione); }
const p = $('intro-text'), text = introText(story.intro[introLine], rione);
p.textContent = text;
say('andrea', text);
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, con i nomi di ogni voce rimescolati a ogni apertura; si aprono dal titolo
// e dalla fine, Chiudi o Esc tornano indietro.
function openCredits() {
$('credits-list').replaceChildren(...shuffledCredits(story.credits ?? []).flatMap(({ role, names }) => {
const dt = document.createElement('dt'), dd = document.createElement('dd');
dt.textContent = role;
dd.replaceChildren(...names.map(n => {
if (typeof n === 'string') return Object.assign(document.createElement('span'), { textContent: n });
// Chi ha il ritratto: il nome è un pulsante che lo mostra.
const b = Object.assign(document.createElement('button'), { type: 'button', className: 'credit-face', textContent: n.name });
b.addEventListener('click', () => showFace(facePicture(n)));
return b;
}));
return [dt, dd];
}));
fillReel();
$('credits').hidden = false;
rollCredits();
rollReel();
}
// Sotto i nomi, il carosello: tutti gli sfondi del paese e i ritratti (game.reelPictures), due giri uguali in fila per
// non vedere mai la cucitura. Scorre da solo; si ferma sotto il mouse, si trascina, si spinge con la rotella, le
// frecce ai lati o quelle della tastiera, e toccando una foto la si vede grande (Andrea, 27 ottobre).
const reelTrack = $('credits-reel').querySelector('.track');
let reeling = 0, reelWait = 0, reelDrag = null, reelDragged = false, reelTap = null;
const reelPics = new WeakMap();
function fillReel() {
if (reelTrack.children.length) return;
const all = reelPictures(story);
for (const p of [...all, ...all]) {
const img = Object.assign(document.createElement('img'), { src: versioned(p.src), alt: p.name, title: p.name, className: p.kind, loading: 'lazy', decoding: 'async', draggable: false });
reelPics.set(img, p);
reelTrack.append(img);
}
}
function rollReel() {
cancelAnimationFrame(reeling);
reelTrack.scrollLeft = 0;
reelWait = 0;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let last = performance.now(), pos = 0;
const step = now => {
if ($('credits').hidden) return;
const dt = now - last; last = now;
const half = reelTrack.scrollWidth / 2;
if (reelTrack.matches(':hover') || reelDrag || !$('face').hidden || (reelWait -= dt) > 0) pos = reelTrack.scrollLeft;
else { pos += dt * 0.05; if (pos >= half) pos -= half; reelTrack.scrollLeft = pos; }
reeling = requestAnimationFrame(step);
};
reeling = requestAnimationFrame(step);
}
// Di una foto alla volta, con le frecce.
function nudgeReel(dir) {
reelWait = 4000;
reelTrack.scrollBy({ left: dir * ((reelTrack.firstElementChild?.offsetWidth ?? 300) + 10), behavior: 'smooth' });
}
for (const b of $('credits-reel').querySelectorAll('.arrow')) b.addEventListener('click', () => nudgeReel(b.classList.contains('next') ? 1 : -1));
// Col dito si trascina da sé (touch-action: pan-x); col mouse lo facciamo noi. Un tocco fermo su una foto la mostra
// grande: si guarda al rilascio, perché con la cattura del puntatore il clic non arriva più all'immagine.
reelTrack.addEventListener('pointerdown', e => {
reelDragged = false;
reelTap = reelPics.get(e.target) ?? null;
if (e.pointerType !== 'mouse') { reelWait = 4000; return; }
reelDrag = { x: e.clientX, left: reelTrack.scrollLeft, id: e.pointerId };
reelTrack.setPointerCapture(e.pointerId);
});
reelTrack.addEventListener('pointermove', e => {
if (!reelDrag) return;
const dx = e.clientX - reelDrag.x;
if (Math.abs(dx) > 6) reelDragged = true;
reelTrack.scrollLeft = reelDrag.left - dx;
});
const endReelDrag = () => { if (reelDrag && reelDragged) reelWait = 4000; reelDrag = null; };
reelTrack.addEventListener('pointerup', () => { if (!reelDragged && reelTap) showFace(reelTap); reelTap = null; endReelDrag(); });
reelTrack.addEventListener('pointercancel', () => { reelTap = null; endReelDrag(); });
reelTrack.addEventListener('wheel', e => { reelWait = 4000; if (e.deltaY && !e.deltaX) { reelTrack.scrollLeft += e.deltaY; e.preventDefault(); } }, { passive: false });
// I nomi salgono piano da soli; si fermano se ci si passa sopra o si tocca, e ricominciano dall'alto in fondo.
let rolling = 0, rollWait = 0;
$('credits-list').addEventListener('pointerdown', () => { rollWait = 4000; });
$('credits-list').addEventListener('wheel', () => { rollWait = 4000; }, { passive: true });
function rollCredits() {
const list = $('credits-list');
list.scrollTop = 0;
cancelAnimationFrame(rolling);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let last = performance.now(), pos = 0;
rollWait = 1500;
const step = now => {
if ($('credits').hidden) return;
const dt = now - last; last = now;
if (list.matches(':hover') || !$('face').hidden || (rollWait -= dt) > 0) pos = list.scrollTop;
else if (list.scrollTop + list.clientHeight >= list.scrollHeight - 1) { rollWait = 2500; list.scrollTop = pos = 0; }
else list.scrollTop = pos += dt * 0.025;
rolling = requestAnimationFrame(step);
};
rolling = requestAnimationFrame(step);
}
function showFace({ name, src, kind }) {
const img = $('face').querySelector('img');
img.src = versioned(src);
img.className = kind;
$('face').querySelector('figcaption').textContent = name;
$('face').hidden = false;
}
$('face').addEventListener('click', () => { $('face').hidden = true; });
for (const b of document.querySelectorAll('.open-credits')) b.addEventListener('click', openCredits);
$('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).
// La mappa: i posti visti come schede con la miniatura, a gradini come il paese (le quattro fasce della pianta,
// 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];
function openMap() {
if (!running || game.dialogue || game.closeup) return;
const board = $('map-board'), svg = $('map-roads'), places = game.mapPlaces();
const bands = MAP_BANDS.map(() => []);
for (const p of places) bands[MAP_BANDS.findIndex(top => p.at[1] < top)].push(p);
board.replaceChildren(svg, ...bands.filter(b => b.length).map(band => {
const row = document.createElement('div');
row.className = 'map-band';
row.append(...band.sort((a, b) => a.at[0] - b.at[0]).map(p => {
const b = document.createElement('button');
b.type = 'button';
b.dataset.scene = p.id;
b.title = p.name;
b.classList.toggle('here', p.here);
const img = document.createElement('img');
img.alt = '';
if (p.mini) img.src = versioned(p.mini);
const name = document.createElement('span');
name.textContent = p.name;
b.append(img, name);
b.addEventListener('click', () => { if (game.travel(p.id)) $('map').hidden = true; });
return b;
}));
return row;
}));
$('map-search').value = '';
filterMap();
$('map').hidden = false;
drawRoads();
// Col mouse si può scrivere subito; sul telefono la tastiera si apre solo toccando la casella.
if (matchMedia('(pointer: fine)').matches) $('map-search').focus({ preventScroll: true });
// Sul telefono la mappa scorre: si parte col posto dove si è, al centro.
board.querySelector('.here')?.scrollIntoView({ block: 'center', inline: 'center' });
}
// La ricerca: mentre si scrive restano solo i posti che corrispondono; Invio porta al primo.
function filterMap() {
const query = $('map-search').value;
let shown = 0;
for (const row of $('map-board').querySelectorAll('.map-band')) {
let inRow = 0;
for (const b of row.children) inRow += (b.hidden = !matchPlace(b.title, query)) ? 0 : 1;
row.hidden = !inRow;
shown += inRow;
}
$('map-none').hidden = shown > 0;
$('map-none').textContent = `Nessun posto con «${query.trim()}»`;
drawRoads();
$('map-scroll').scrollTo(0, 0); // i posti rimasti stanno in cima: niente mappa vuota scorsa in fondo
}
$('map-search').addEventListener('input', filterMap);
$('map-search').addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
const first = [...$('map-board').querySelectorAll('button')].find(b => !b.hidden && !b.classList.contains('here'));
if (first) first.click();
});
// Le strade fra le schede, misurate dopo l'impaginazione (e di nuovo se la finestra cambia misura).
function drawRoads() {
const board = $('map-board'), svg = $('map-roads');
// Prima si toglie la misura vecchia: il disegno delle strade, grande quanto la mappa intera, la terrebbe lunga
// anche dopo un filtro.
svg.style.width = svg.style.height = '0';
const origin = board.getBoundingClientRect();
const center = id => {
const card = board.querySelector(`[data-scene="${id}"]`);
const r = card && !card.hidden && !card.parentElement.hidden && card.querySelector('img').getBoundingClientRect();
return r && [r.left + r.width / 2 - origin.left, r.top + r.height / 2 - origin.top];
};
svg.setAttribute('viewBox', `0 0 ${board.scrollWidth} ${board.scrollHeight}`);
svg.style.width = board.scrollWidth + 'px';
svg.style.height = board.scrollHeight + 'px';
svg.replaceChildren(...game.mapRoads().map(([a, b]) => [center(a), center(b)]).filter(([a, b]) => a && b).map(([a, b]) => {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
[['x1', a[0]], ['y1', a[1]], ['x2', b[0]], ['y2', b[1]]].forEach(([k, v]) => line.setAttribute(k, v));
return line;
}));
}
addEventListener('resize', () => { if (!$('map').hidden) drawRoads(); });
$('map-button').addEventListener('click', openMap);
// --- «?»: per qualche secondo si vede tutto quello che si può toccare (sul telefono non c'è il mouse da passarci sopra).
const SPOTS_TIME = 3500;
let spotsUntil = 0;
const showSpots = () => { spotsUntil = performance.now() + SPOTS_TIME; };
$('spots-button').addEventListener('click', showSpots);
// --- 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;
game.closeupCancel();
music.hold = true;
voices.pause();
ambience.configure({ active: false });
command.reset();
$('pause-map').hidden = !!game.closeup || game.mapPlaces().length < 2;
$('pause').hidden = false;
}
function closePause() {
$('pause').hidden = true;
music.hold = false;
voices.resume();
idleSince = performance.now();
running = true;
}
$('pause-button').addEventListener('click', openPause);
// I video «Come si gioca» (story.tutorials): il gioco si ferma come in pausa, la musica tace, il video ha il suo audio.
function openTutorial(kind) {
const t = story.tutorials?.[kind];
if (!t || !running) return;
running = false;
game.closeupCancel();
music.hold = true;
voices.pause();
ambience.configure({ active: false });
$('tutorial-title').textContent = t.title;
const video = $('tutorial-video');
video.src = versioned(t.src);
if (t.poster) video.poster = versioned(t.poster);
video.muted = music.muted;
$('tutorial').hidden = false;
video.play().catch(() => {});
$('tutorial-close').focus();
}
function closeTutorial() {
const video = $('tutorial-video');
video.pause(); video.removeAttribute('src'); video.load();
$('tutorial').hidden = true;
music.hold = false;
voices.resume();
idleSince = performance.now();
running = true;
}
for (const button of document.querySelectorAll('.tutorial-open')) {
button.hidden = !story.tutorials?.[button.dataset.tutorial];
button.addEventListener('click', () => openTutorial(button.dataset.tutorial));
}
$('tutorial-close').addEventListener('click', closeTutorial);
// Sul telefono, passando a un'altra app (o cambiando scheda), si ferma tutto: musica, voce, musichetta del cartello;
// la partita va in pausa e aspetta. Tornando riparte la musica; la voce solo se non c'è la pausa (riparte con Riprendi).
function leave(hidden) {
music.setAway(hidden);
if (hidden) {
ambience.configure({ active: false });
voices.pause();
window.jingle?.pause();
if (running) openPause();
} else if ($('pause').hidden) voices.resume();
}
document.addEventListener('visibilitychange', () => leave(document.hidden));
addEventListener('pagehide', () => leave(true));
addEventListener('pageshow', () => leave(document.hidden));
$('resume').addEventListener('click', closePause);
$('pause-map').addEventListener('click', () => { closePause(); openMap(); });
$('to-title').addEventListener('click', () => {
$('pause').hidden = true;
music.hold = false;
voices.stop();
// Se si esce a metà di una sequenza resta salvato l'ultimo punto fermo: niente enigmi risolti a metà.
const data = game.savable();
if (data) game.onSave(data);
showTitle();
});
$('close-map').addEventListener('click', () => { $('map').hidden = true; });
// --- Taccuino: le tappe fatte, barrate, e quella di adesso; in fondo dove chiedere aiuto.
function openNotebook() {
if (!running || game.closeup) return;
const { done, now } = game.notebook();
$('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' })] : []));
$('notebook-help').textContent = story.notebookHelp ?? '';
$('notebook').hidden = false;
// Su uno schermo basso il foglio scorre: si parte dalla tappa di adesso, in fondo.
$('notebook-list').scrollTop = $('notebook-list').scrollHeight;
}
$('notebook-button').addEventListener('click', openNotebook);
$('close-notebook').addEventListener('click', () => { $('notebook').hidden = true; });
// --- Salva su file / Carica: la partita in un file .json da aprire su un altro dispositivo.
$('save-file').addEventListener('click', () => {
const data = game.savable();
if (!data) return;
game.onSave(data);
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([saveFile(data)], { type: 'application/json' }));
a.download = saveFileName();
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
});
$('load-game').addEventListener('click', () => { $('load-error').hidden = true; $('load-file').click(); });
const LOAD_ERRORS = {
'not-save': 'Questo file non è un salvataggio della Leggenda del Regolo.',
newer: 'Questo salvataggio viene da una versione più nuova del gioco: aggiorna la pagina e riprova.',
story: 'Questo salvataggio non torna con questa versione del gioco.',
};
const loadError = key => { $('load-error').textContent = LOAD_ERRORS[key]; $('load-error').hidden = false; };
// Il salvataggio del browser si sostituisce solo dopo che il file è stato controllato e la partita è ripartita.
$('load-file').addEventListener('change', async e => {
const file = e.target.files[0];
e.target.value = '';
if (!file) return;
const { state, error } = readSaveFile(await file.text());
if (error) return loadError(error);
const wrong = checkSave(story, state);
if (wrong) { console.warn('Salvataggio scartato:', wrong); return loadError('story'); }
start(state);
if (storage) writeSave(storage, game.snapshot());
});
function showTitle() {
running = false;
ambience.configure({ active: false });
const saved = storage && readSave(storage);
$('continue').hidden = !saved || saved.ended;
$('load-error').hidden = true;
$('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];
};
// --- Debug (?debug): Maiusc+clic segna un punto e ne copia le coordinate; K copia tutti i punti come poligono
// (per `walk`), X li cancella, G nasconde e mostra il disegno di debug. Il resto si fa dalla console (game.go('oratorio')).
const debugPoints = [];
let debugShown = true;
const copy = text => navigator.clipboard?.writeText(text).catch(() => {});
function debugPoint(p) {
const point = p.map(Math.round);
debugPoints.push(point);
copy(`[${point.join(', ')}]`);
console.log('punto', point);
}
function debugKey(e) {
if (!debug) return false;
const k = e.key.toLowerCase();
if (k === 'k') { const text = `[${debugPoints.map(p => `[${p.join(', ')}]`).join(', ')}]`; copy(text); console.log(text); return true; }
if (k === 'x') { debugPoints.length = 0; return true; }
if (k === 'g') { debugShown = !debugShown; return true; }
return false;
}
const debugView = () => debug && debugShown && game && { mouse, points: debugPoints, help: `Maiusc+clic punto · K copia ${debugPoints.length} · X azzera · G` };
// Col dito si prende anche un oggetto mancato di poco: TOUCH_REACH pixel di schermo, in coordinate di scena.
const TOUCH_REACH = 22;
const reachOf = e => e.pointerType === 'touch' || e.pointerType === 'pen' ? TOUCH_REACH * WIDTH / canvas.getBoundingClientRect().width : 0;
let press = null;
canvas.addEventListener('pointerdown', e => {
if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; }
if (!running || e.button > 0) return;
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
if (game.closeup) {
const type = story.closeups[game.closeup.id].type, deck = CARD_AT[type];
if (deck) {
if (game.skip()) return;
// Tressette e scopa: fra una smazzata e l'altra un tocco va avanti.
if (MINI[type].advance?.(game.closeup)) return;
// Scopa, più prese possibili: si tocca una carta in tavola (si decide al rilascio, vedi pointerup).
if (type === 'scopa' && game.closeup.step === 'choose') {
press = { table: scopaTableAt(game.closeup, scenePoint(e)), state: game.closeup };
canvas.setPointerCapture(e.pointerId);
return;
}
press = { card: deck(game.closeup, scenePoint(e)), state: game.closeup, at: deck };
canvas.setPointerCapture(e.pointerId);
return;
}
// Il GP di Fortezza: un dito solo dà gas e indica la direzione sullo schermo; gli altri tocchi non contano
// (sterzo e freno hanno i loro pulsanti, che si tengono insieme al gas).
if (type === 'gara') {
if (press || game.skip()) return;
gara.aimScreen(game.closeup, scenePoint(e), e.pointerId);
if (gara.press(game.closeup, e.pointerId)) { press = { gara: e.pointerId }; canvas.setPointerCapture(e.pointerId); }
return;
}
const aimed = game.closeupAim(scenePoint(e));
if (['biliardo', 'rinquarto', 'basket'].includes(story.closeups[game.closeup.id].type)) {
if (!aimed && !game.speech) return; // dove non si può mirare (dietro Andrea) non si carica nemmeno
if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira
}
if (['bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type) && aimed) game.closeup.aimLocked = true;
press = { closeup: true };
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
if (!game.skip()) game.closeupPress();
canvas.setPointerCapture(e.pointerId);
return;
}
const point = scenePoint(e), reach = reachOf(e);
press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) };
});
canvas.addEventListener('pointerup', e => {
if (!press) return;
// Scopa: la carta toccata e lasciata sullo stesso punto. Se sta nella presa già scelta, si prende quella; se no
// si sceglie la prima presa che la contiene (un altro tocco conferma). Trascinando via non succede niente.
if (press.table !== undefined) {
const p = press; press = null;
const c = game.closeup;
if (!running || c !== p.state || c.step !== 'choose' || p.table < 0 || scopaTableAt(c, scenePoint(e)) !== p.table) return;
const card = c.table[p.table], options = c.choice.options.flatMap((o, k) => o.includes(card) ? [k] : []);
if (options.includes(c.choice.selected)) scopa.take(c); else if (options.length) scopa.select(c, options[0]);
return;
}
if (press.card !== undefined) {
const p = press; press = null;
if (running && game.closeup === p.state && p.card >= 0 && p.at(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card);
return;
}
if (press.gara !== undefined) {
if (e.pointerId === press.gara) { if (game?.closeup) gara.release(game.closeup, e.pointerId); press = null; }
return;
}
if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; }
clearTimeout(press.timer);
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
if (!press.long) {
const point = scenePoint(e), now = performance.now();
const hurry = !!lastTap && now - lastTap.time < DOUBLE_CLICK && Math.hypot(point[0] - lastTap.point[0], point[1] - lastTap.point[1]) < 24;
command.clickScene(point, { hurry, reach: press.reach });
lastTap = hurry ? null : { time: now, point };
}
press = null;
});
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); });
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(); } });
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
});
canvas.addEventListener('pointermove', e => {
if (!running) return;
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
// Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo,
// attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera.
// (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.)
if (game.closeup && story.closeups[game.closeup.id].type === 'gara') {
if (press?.gara === e.pointerId) gara.aimScreen(game.closeup, scenePoint(e), e.pointerId);
return;
}
const aiming = game.closeup && ['biliardo', 'rinquarto', 'bocce', 'basket', 'pesca'].includes(story.closeups[game.closeup.id].type);
if (aiming) { if (e.pointerType === 'mouse' && !press && !game.closeup.aimLocked) game.closeupAim(scenePoint(e)); }
else if (e.pointerType === 'mouse' || press?.closeup) game.closeupAim(scenePoint(e));
});
canvas.addEventListener('pointerleave', () => { mouse = null; });
// Il pannello si aggiorna a ogni fotogramma: si scrive nel DOM solo quando qualcosa cambia davvero. Su iPhone
// Safari, se la pagina cambia subito dopo un tocco, il tocco vale come «passaggio» e il clic non arriva: i pulsanti
// dei minigiochi, riscritti a ogni fotogramma, non rispondevano (un amico di Andrea, 28 ottobre).
const setText = (el, text) => { if (el.textContent !== text) el.textContent = text; };
const setAttr = (el, name, value) => { if (el.getAttribute(name) !== value) el.setAttribute(name, value); };
const setValue = (el, value) => { if (el.value !== String(value)) el.value = value; };
const setProp = (el, name, value) => { if (typeof el[name] === 'boolean') value = !!value; if (el[name] !== value) el[name] = value; };
// Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
const MINI = {
scopa: { ...scopa, back: 'Torna all’oratorio', label: 'Scopa: tocca una carta per giocarla. Se le prese sono più d’una, tocca una carta in tavola o usa le frecce, Invio prende. Esc esce.' },
tressette: { ...tressette, back: 'Torna all’oratorio', label: 'Tressette: tocca una carta per giocarla. Frecce scelgono, Invio gioca; B, S, V fanno busso, striscio e volo. Esc esce.' },
briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' },
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
biliardo: { ...biliardo, back: 'Torna al bar', label: 'Biliardo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
rinquarto: { ...rinquarto, back: 'Torna da Bertovo', label: 'I rinquarti di Bertovo: clic o tocco fissa la mira. Frecce per affinarla, Maiusc per passi più piccoli. Regola la forza e premi Tira.' },
basket: { ...basket, back: 'Torna in strada', label: 'Fai due tiri: muovi il mouse o tocca per mirare, tieni premuto per caricare la forza, lascia per tirare.' },
// La gara: niente mira né forza, il pulsante alterna gas e freno (chi non può tenere premuto).
gara: { ...gara, toggle: true, back: 'Torna dal Trappola', label: 'GP di Fortezza: tieni premuto sulla scena per il gas e trascina verso la direzione in cui vuoi andare sullo schermo; alza il dito per mollare. Anche col mouse. Oppure Spazio, Invio o ↑ per il gas, ← → per sterzare, ↓ per frenare; disponibili anche i pulsanti Gas, Sterza e Frena.' },
pesca: { ...pesca, back: 'Torna in Fortezza', label: 'La pesca: tieni premuto per lanciare, premi quando il galleggiante va sotto, tieni premuto per recuperare.' },
};
const miniState = () => running && game?.closeup && MINI[story.closeups[game.closeup.id].type] ? game.closeup : null;
const mini = () => MINI[story.closeups[game.closeup.id].type];
$('bocce-power').addEventListener('input', e => {
const c = miniState(); if (!c) return;
mini().setPower?.(c, Number(e.target.value) / 100);
// Nel GP di Fortezza il cursore è la sensibilità dello sterzo: il gioco se la ricorda.
if (story.closeups[c.id].type === 'gara') try { localStorage.setItem(GARA_SENS, e.target.value); } catch { /* senza memoria, pazienza */ }
});
const GARA_SENS = 'regolo-gara-sensibilita';
// Il riquadro dello swipe sotto la pista: come il dito sulla scena, con le distanze in pixel della scena.
const padPoint = e => { const r = canvas.getBoundingClientRect(), k = 640 / r.width; return [(e.clientX - r.left) * k, (e.clientY - r.top) * k]; };
let padPointer = null;
$('gara-pad').addEventListener('pointerdown', e => {
const c = miniState(); if (!c || padPointer !== null || game.skip()) return;
e.preventDefault();
gara.aimScreen(c, padPoint(e), 'pad' + e.pointerId);
if (gara.press(c, 'pad' + e.pointerId)) { padPointer = e.pointerId; $('gara-pad').setPointerCapture(e.pointerId); $('gara-pad').classList.add('active'); }
});
$('gara-pad').addEventListener('pointermove', e => { const c = miniState(); if (c && e.pointerId === padPointer) gara.aimScreen(c, padPoint(e), 'pad' + e.pointerId); });
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) $('gara-pad').addEventListener(ev, e => {
if (e.pointerId !== padPointer) return;
const c = miniState(); if (c) gara.release(c, 'pad' + e.pointerId);
padPointer = null; $('gara-pad').classList.remove('active');
});
$('gara-pad').addEventListener('contextmenu', e => e.preventDefault());
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); });
// Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco.
const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt };
for (let i = 0; i < 3; i++) {
const button = $('scopa-card-' + i);
button.addEventListener('click', () => game?.closeupPlay(i));
button.addEventListener('focus', () => { if (game?.closeup?.id === 'scopa' && game.closeup.step === 'play') scopa.select(game.closeup, i); });
}
$('scopa-options').addEventListener('focusin', e => { const k = e.target.dataset?.option; if (k !== undefined && game?.closeup?.step === 'choose') scopa.select(game.closeup, Number(k)); });
$('scopa-options').addEventListener('click', e => { const k = e.target.dataset?.option; if (k !== undefined && game?.closeup?.id === 'scopa') scopa.take(game.closeup, Number(k)); });
$('scopa-next').addEventListener('click', () => { if (game?.closeup?.id === 'scopa') scopa.advance(game.closeup); });
for (let i = 0; i < 10; i++) {
const button = $('tressette-card-' + i);
button.addEventListener('click', () => game?.closeupPlay(i));
button.addEventListener('focus', () => { if (game?.closeup?.id === 'tressette') tressette.select(game.closeup, i); });
}
for (const signal of tressette.SIGNALS) $('tressette-' + signal).addEventListener('click', () => { if (game?.closeup?.id === 'tressette') tressette.setSignal(game.closeup, signal); });
$('tressette-next').addEventListener('click', () => { if (game?.closeup?.id === 'tressette') tressette.advance(game.closeup); });
for (let i = 0; i < 3; i++) {
const button = $('briscola-card-' + i);
button.addEventListener('click', () => game?.closeupPlay(i));
button.addEventListener('focus', () => { if (game?.closeup?.id === 'briscola') briscola.select(game.closeup, i); });
}
for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) {
$(id).addEventListener('click', () => { const c = miniState(); if (c && mini().nudge(c, dir)) c.aimLocked = true; });
// Al GP di Fortezza si sterza finché il pulsante resta premuto (anche col dito, insieme a Gas).
$(id).addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().steerButton?.(c, dir); });
for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $(id).addEventListener(ev, () => { const c = miniState(); if (c) mini().steerButton?.(c, 0); });
}
// Il freno del GP, tenuto premuto (sul telefono, insieme a Gas e Sterza).
$('gara-brake').addEventListener('pointerdown', () => { const c = miniState(); if (c) mini().brakeButton?.(c, true); });
for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) $('gara-brake').addEventListener(ev, () => { const c = miniState(); if (c) mini().brakeButton?.(c, false); });
addEventListener('blur', () => { if (press?.card !== undefined) press = null; game?.closeupCancel(); });
// --- 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;
const grew = game.state.inventory.length > inventoryKey.split('|')[0].split(',').filter(Boolean).length;
inventoryKey = key;
const box = $('inventory');
box.replaceChildren(...game.state.inventory.map(id => {
const b = document.createElement('button'), item = story.items[id];
b.type = 'button';
b.dataset.item = id;
b.setAttribute('aria-pressed', String(command.item === id));
// L'icona a pixel dell'oggetto (assets/icons); senza, un pallino del suo colore.
let icon;
if (item.icon?.src) {
icon = document.createElement('img');
icon.src = versioned(item.icon.src);
icon.alt = '';
} else {
icon = document.createElement('i');
icon.style.background = item.icon?.fill ?? '#ccc';
}
// Il nome in uno span: i nomi lunghi («stecco e gomma») vanno a capo su due righe strette, non su tre.
const name = document.createElement('span');
// Dopo l'apostrofo si può andare a capo («chiave dell'» / «oratorio»): le parole restano intere.
name.textContent = item.name.replace(/'/g, "'\u200b");
if (item.name.length > 10) name.className = 'long';
b.append(icon, 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;
}));
// Un oggetto nuovo finisce in fondo: si scorre fin lì, così si vede subito.
if (grew) box.scrollTop = box.scrollHeight;
}
// --- Dialoghi: scelte numerate, anche da tastiera.
let choicesKey = '';
function updateChoices() {
const options = game.options(), inDialogue = !!game.dialogue;
// Con un minigioco aperto il suo pannello prende il posto di verbi e scelte: si decide qui, una volta sola, senza
// mostrarli e nasconderli a ogni fotogramma.
const covered = !!miniState();
setProp($('panel'), 'hidden', inDialogue || covered);
setProp($('choices'), 'hidden', !inDialogue || covered);
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 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.
if (e.target === $('map-search')) {
if (e.key === 'Escape') { if (e.target.value) { e.target.value = ''; filterMap(); } else $('map').hidden = true; }
return;
}
if (debugKey(e)) return;
if (!$('tutorial').hidden) { if (e.key === 'Escape') closeTutorial(); return; }
if (!$('credits').hidden) {
if (e.key === 'Escape') { if (!$('face').hidden) $('face').hidden = true; else $('credits').hidden = true; }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { if ($('face').hidden) nudgeReel(e.key === 'ArrowRight' ? 1 : -1); }
return;
}
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; }
if (!$('notebook').hidden) { if (['Escape', 'n', 'N'].includes(e.key)) $('notebook').hidden = true; return; }
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
// Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
if (running && game.closeup) {
if (story.closeups[game.closeup.id].type === 'scopa') {
const inControls = e.target.closest?.('#scopa-controls'), c = game.closeup, key = e.key.toLowerCase();
if (e.repeat && ['enter', ' '].includes(key)) { e.preventDefault(); return; }
// Sui pulsanti delle carte e delle prese Invio e Spazio valgono per quella scelta (una sola fonte: lo stato).
const choice = e.target.id?.startsWith('scopa-card-') || e.target.dataset?.option !== undefined;
if (inControls && ['BUTTON', 'SUMMARY'].includes(e.target.tagName) && ['enter', ' '].includes(key) && !choice) return;
if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'enter', ' '].includes(key) || /^[1-9]$/.test(key)) {
e.preventDefault();
if (e.repeat || game.skip()) return;
if (/^[1-9]$/.test(key)) scopa.select(c, Number(key) - 1);
else if (key.startsWith('arrow')) scopa.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1);
else if (c.step === 'result') game.closeupClose();
else if (c.step === 'choose') scopa.take(c);
else if (!scopa.advance(c)) game.closeupPlay();
// Il fuoco segue la scelta, così quel che si vede scelto è quel che parte.
if (inControls && c.step === 'choose') $('scopa-options').querySelector(`[data-option="${c.choice.selected}"]`)?.focus();
else if (inControls && scopa.canPlay(c)) $('scopa-card-' + c.selected).focus();
return;
}
// Esc: nella scelta della presa si torna alla mano, altrimenti si lascia il tavolo.
if (e.key === 'Escape') { if (!scopa.cancel(c)) game.closeupClose(); }
else if (key === 'm') toggleMute();
return;
}
if (story.closeups[game.closeup.id].type === 'tressette') {
const inControls = e.target.closest?.('#tressette-controls'), c = game.closeup;
if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; }
// Sui pulsanti della mano Invio e Spazio giocano la carta scelta (anche se se ne è scelta una che non si
// può giocare: allora si legge perché), mai quella che aveva il fuoco prima.
const card = e.target.id?.startsWith('tressette-card-');
if (inControls && ['BUTTON', 'SUMMARY'].includes(e.target.tagName) && ['Enter', ' '].includes(e.key) && !card) return;
const key = e.key.toLowerCase(), signal = { b: 'busso', s: 'striscio', v: 'volo' }[key];
if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'enter', ' '].includes(key) || /^[0-9]$/.test(key) || signal) {
e.preventDefault();
if (e.repeat || game.skip()) return;
if (signal) tressette.setSignal(c, signal);
else if (/^[0-9]$/.test(key)) tressette.select(c, (Number(key) + 9) % 10);
else if (key.startsWith('arrow')) tressette.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1);
else if (c.step === 'result') game.closeupClose();
else if (!tressette.advance(c)) game.closeupPlay(); // Invio o Spazio, come negli altri due giochi
// Il fuoco segue la carta scelta: Invio su un pulsante gioca sempre quella che si vede scelta.
if (inControls && tressette.canPlay(c) && !signal) $('tressette-card-' + c.selected).focus();
return;
}
if (e.key === 'Escape') game.closeupClose();
else if (key === 'm') toggleMute();
return;
}
if (story.closeups[game.closeup.id].type === 'briscola') {
const inControls = e.target.closest?.('#briscola-controls');
if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; }
if (inControls && e.target.tagName === 'SUMMARY' && e.key === 'Enter') return;
if (inControls && e.target.tagName === 'BUTTON' && !e.target.disabled && ['Enter', ' '].includes(e.key) && briscola.canPlay(game.closeup)) return;
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '1', '2', '3', 'Enter', ' '].includes(e.key)) {
e.preventDefault();
if (e.repeat || game.skip()) return;
const c = game.closeup;
if (/^[123]$/.test(e.key)) briscola.select(c, Number(e.key) - 1);
else if (e.key.startsWith('Arrow')) briscola.nudge(c, ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 1);
else if (c.step === 'result') game.closeupClose();
else game.closeupPlay(); // Invio o Spazio, come negli altri due giochi
if (inControls && briscola.canPlay(c)) $('briscola-card-' + c.selected).focus();
return;
}
if (e.key === 'Escape') game.closeupClose();
else if (e.key.toLowerCase() === 'm') toggleMute();
return;
}
if (miniState()) {
// Esc esce anche col fuoco sui pulsanti del pannello.
if (e.key === 'Escape') { game.closeupClose(); return; }
const onButton = ['BUTTON', 'SUMMARY'].includes(e.target.tagName), onControl = onButton && e.target.closest?.('#bocce-controls');
// Il GP di Fortezza: frecce, Spazio e Invio finché si tengono giù (su un pulsante Spazio e Invio lo premono).
if (!(onButton && [' ', 'Enter'].includes(e.key)) && mini().key?.(game.closeup, e.key, true)) { e.preventDefault(); return; }
if (onControl) return;
const c = game.closeup, m = mini();
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
e.preventDefault();
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { if (m.nudge(c, e.key === 'ArrowLeft' ? -1 : 1, e.shiftKey)) c.aimLocked = true; }
else m.setPower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01));
return;
}
if (c.step === 'result' && [' ', 'Enter'].includes(e.key)) { e.preventDefault(); game.closeupClose(); return; }
}
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();
else if ([' ', 'Enter'].includes(e.key) && miniState()) { e.preventDefault(); if (!e.repeat && !game.skip()) game.closeupPress(); }
else if ([' ', 'Enter', 'ArrowRight'].includes(e.key)) { e.preventDefault(); if (!game.skip()) game.closeupTap(); }
else if (e.key === 'm' || e.key === 'M') toggleMute();
return;
}
if ((e.key === 'p' || e.key === 'P') && running) { openMap(); return; }
if ((e.key === 'h' || e.key === 'H') && running) { showSpots(); return; }
if ((e.key === 'n' || e.key === 'N') && running) { openNotebook(); return; }
if (!$('intro').hidden && [' ', 'Enter'].includes(e.key)) { e.preventDefault(); nextIntro(); return; }
if (e.key === 'm' || e.key === 'M') { toggleMute(); 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);
});
// Alle bocce e al biliardo, lasciando spazio o Invio parte il tiro.
window.addEventListener('keyup', e => {
// Il GP di Fortezza tiene conto di ogni tasto per conto suo: lasciarne uno non toglie il gas del dito o degli altri.
if (miniState() && mini().key) { mini().key(game.closeup, e.key, false); return; }
if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease();
});
// --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio).
$('closeup-close').addEventListener('click', () => game?.closeupClose());
let turning = null, reveal = null;
// --- 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);
if (box.textContent !== sp.text) box.textContent = sp.text;
const color = story.characters[sp.who]?.color ?? '#fff';
if (box.style.color !== color && box.dataset.color !== color) { box.style.color = color; box.dataset.color = color; }
// Sopra chi parla, ma sempre dentro la scena e mai sotto i pulsanti in alto (Pausa, volume): sul telefono un
// fumetto lungo è alto quasi quanto la scena, e si misura in pixel.
const stage = $('stage').getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight;
const roof = Math.max(...['sound', 'corner'].map(id => $(id).getBoundingClientRect().bottom - stage.top)) + 4;
const px = Math.min(stage.width - w / 2 - 4, Math.max(w / 2 + 4, x / WIDTH * stage.width));
const py = Math.min(stage.height - 4, Math.max(roof + h, y / HEIGHT * stage.height));
if (box.style.left !== px + 'px') box.style.left = px + 'px';
if (box.style.top !== py + 'px') box.style.top = py + 'px';
}
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) setAttr(b, 'aria-pressed', String(command.verb === b.dataset.verb));
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;
updateInventory();
updateChoices();
updateSpeech();
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type);
setProp($('bocce-controls'), 'hidden', !mg || cards);
setProp($('bocce-controls'), 'inert', !running);
setProp($('briscola-controls'), 'hidden', def?.type !== 'briscola');
setProp($('briscola-controls'), 'inert', !running);
setProp($('scopa-controls'), 'hidden', def?.type !== 'scopa');
setProp($('scopa-controls'), 'inert', !running);
if (def?.type === 'scopa') {
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = scopa.status(c, def.names);
if ($('scopa-status').textContent !== line) $('scopa-status').textContent = line;
for (let i = 0; i < 3; i++) {
const button = $('scopa-card-' + i), card = c.hands.player[i];
const label = card ? `${i + 1}. ${scopa.cardName(card)}` : `${i + 1}. —`;
if (button.textContent !== label) button.textContent = label;
setProp(button, 'disabled', !scopa.canPlay(c) || !card || !!game.speech);
setAttr(button, 'aria-pressed', String(c.selected === i && !!card && c.step === 'play'));
}
// Le prese fra cui scegliere, una per pulsante.
const options = c.step === 'choose' ? c.choice.options.map(o => o.map(scopa.cardName).join(' + ')) : [];
const box = $('scopa-options');
if (box.dataset.list !== options.join('|')) {
box.dataset.list = options.join('|');
box.replaceChildren(...options.map((o, k) => {
const b = Object.assign(document.createElement('button'), { type: 'button', textContent: `Prendi ${o}` });
b.dataset.option = k; return b;
}));
}
setProp(box, 'hidden', !options.length);
[...box.children].forEach((b, k) => setAttr(b, 'aria-pressed', String(c.choice?.selected === k)));
setProp($('scopa-next'), 'hidden', c.step !== 'smazzata');
}
setProp($('tressette-controls'), 'hidden', def?.type !== 'tressette');
setProp($('tressette-controls'), 'inert', !running);
if (def?.type === 'tressette') {
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = tressette.status(c, def.seats);
if ($('tressette-status').textContent !== line) $('tressette-status').textContent = line;
const mine = tressette.canPlay(c) && !game.speech, ok = mine ? tressette.legal(c.hands[0], c.table) : [];
for (let i = 0; i < 10; i++) {
const button = $('tressette-card-' + i), card = c.hands[0][i];
const label = card ? `${(i + 1) % 10}. ${tressette.cardName(card)}` : '—';
if (button.textContent !== label) button.textContent = label;
setProp(button, 'hidden', !card);
setProp(button, 'disabled', !ok.includes(i));
setAttr(button, 'aria-pressed', String(c.selected === i && !!card));
}
for (const signal of tressette.SIGNALS) {
setProp($('tressette-' + signal), 'disabled', !mine || c.table.length > 0);
setAttr($('tressette-' + signal), 'aria-pressed', String(c.signal === signal));
}
setProp($('tressette-next'), 'hidden', c.step !== 'smazzata');
}
if (def?.type === 'briscola') {
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = briscola.status(c, def.names);
if ($('briscola-status').textContent !== line) $('briscola-status').textContent = line;
for (let i = 0; i < 3; i++) {
const button = $('briscola-card-' + i), card = c.hands.player[i];
const label = card ? `${i + 1}. ${briscola.cardName(card)}` : `${i + 1}. —`;
if (button.textContent !== label) button.textContent = label;
setProp(button, 'disabled', !briscola.canPlay(c) || !card || !!game.speech);
setAttr(button, 'aria-pressed', String(c.selected === i && !!card));
}
}
if (mg && !cards) {
setAttr($('bocce-controls'), 'aria-label', { rinquarto: 'Comandi dei rinquarti', biliardo: 'Comandi del biliardo', basket: 'Comandi del basket', pesca: 'Comandi della pesca', gara: 'Comandi della gara' }[def.type] ?? 'Comandi delle bocce');
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
const line = mg.status(c, def.names);
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
setValue($('bocce-power'), Math.round(c.power * 100));
setValue($('bocce-power-value'), `${Math.round(c.power * 100)}%`);
for (const id of ['bocce-power', 'bocce-left', 'bocce-right']) setProp($(id), 'disabled', !(mg.canAdjust?.(c) ?? mg.canThrow(c)) || (c.holding && !mg.toggle) || !!game.speech);
setProp($('bocce-throw'), 'disabled', !mg.canThrow(c) || (c.holding && !mg.toggle) || !!game.speech);
// Il GP di Fortezza: niente forza, e i due pulsanti di mira diventano lo sterzo (si tengono premuti).
// Nel GP di Fortezza il cursore della forza diventa la sensibilità dello sterzo col dito (ricordata).
const powerLabel = $('bocce-power').closest('label');
const powerName = def.type === 'gara' ? 'Sensibilità ' : 'Forza ';
if (powerLabel.firstChild.nodeValue !== powerName) powerLabel.firstChild.nodeValue = powerName;
setAttr($('bocce-power'), 'aria-label', def.type === 'gara' ? 'Sensibilità dello sterzo col dito' : 'Forza del lancio');
setProp($('gara-pad'), 'hidden', def.type !== 'gara');
if (def.type === 'gara' && c.sensLoaded === undefined) {
c.sensLoaded = true;
try { const v = localStorage.getItem(GARA_SENS); if (v !== null) gara.setPower(c, Number(v) / 100); } catch { /* si resta al valore di partenza */ }
}
setText($('bocce-left'), def.type === 'gara' ? '← Sterza' : '← Mira');
setText($('bocce-right'), def.type === 'gara' ? 'Sterza →' : 'Mira →');
setProp($('gara-brake'), 'hidden', def.type !== 'gara');
setProp($('gara-brake'), 'disabled', !(mg.canAdjust?.(c) ?? false) || !!game.speech);
setText($('bocce-throw'), mg.button?.(c) ?? (c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira'));
setProp($('bocce-help'), 'hidden', def.type !== 'bocce');
setProp($('biliardo-help'), 'hidden', def.type !== 'biliardo');
setProp($('rinquarto-help'), 'hidden', def.type !== 'rinquarto');
setProp($('basket-help'), 'hidden', def.type !== 'basket');
setProp($('pesca-help'), 'hidden', def.type !== 'pesca');
setProp($('gara-help'), 'hidden', def.type !== 'gara');
if (mg.aimFree?.(c) ?? !mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera
}
if (mg) for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
setProp($('map-button'), 'hidden', !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2);
setProp($('pause-button'), 'hidden', !running);
setProp($('spots-button'), 'hidden', !running || !!game.closeup);
setProp($('notebook-button'), 'hidden', !running || !story.notebook || !!game.closeup);
setProp($('closeup-close'), 'hidden', !running || !game.closeup);
setText($('closeup-close'), torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi');
setProp($('panel'), 'inert', !!c);
canvas.classList.toggle('torch', !!torch);
canvas.classList.toggle('bocce', !!mg);
// Biliardo e GP di Fortezza: audio, pausa e uscita fuori dal quadro (al GP la pista passa negli angoli).
$('stage').classList.toggle('biliardo', !!c && ['biliardo', 'rinquarto', 'gara'].includes(story.closeups[c.id].type));
$('stage').classList.toggle('briscola', !!cards);
setAttr(canvas, 'aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : mg ? mg.label : "La scena dell'avventura");
// La pagina che comincia a girare fa il suo fruscio.
const step = game.closeup?.step ?? null;
if (step === 'turn' && turning !== 'turn') ambience.play('page');
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) {
running = false;
setProp($('ending'), 'hidden', false);
}
}
let last = performance.now(), drawnStill = false, fade = 0, fadeScene = null;
const FADE = 0.4; // secondi della dissolvenza fra le scene
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 || !$('rione').hidden ? 'intro' : !$('ending').hidden ? 'ending' : 'game';
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.
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(), closeup: game?.closeup?.id }), dt);
// 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();
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
// telefono. Si ridisegna solo se nel frattempo arriva lo sfondo che mancava.
const still = game && !running && `${game.state.scene}|${hasBackground(game.scene.background)}`;
if (game && (!still || still !== drawnStill)) {
if (running) game.update(dt);
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() });
// Cambiando scena, un attimo di nero che sfuma: la dissolvenza dei vecchi giochi, invece del taglio netto.
if (game.state.scene !== fadeScene) { if (fadeScene) fade = 1; fadeScene = game.state.scene; }
if (fade > 0) {
ctx.fillStyle = `rgba(0, 0, 0, ${fade})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
fade = Math.max(0, fade - dt / FADE);
}
updateUi();
}
drawnStill = still;
requestAnimationFrame(frame);
}
$('new-game').addEventListener('click', () => { if (storage) clearSave(storage); chooseRione(); });
$('continue').addEventListener('click', () => start(readSave(storage)));
// Ricomincia: stessa scelta di rione della partita appena finita.
$('restart').addEventListener('click', () => {
if (storage) clearSave(storage);
start(null, Object.keys(game.state.flags).find(f => f.startsWith('rione:'))?.slice(6) ?? rione);
});
// 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 (#splash.out in style.css): dissolvenza
// incrociata, senza intravedere la piazza nel mezzo. I tempi partono da qui, non dal primo disegno della pagina.
const SPLASH_FADE = 4300, SPLASH_END = 5000;
function showSplash() {
let done = false, revealed = false, waiting = false;
const reveal = () => { if (!revealed) { revealed = true; showTitle(); $('splash').classList.add('out'); } };
const end = () => {
if (done) return;
done = true;
$('splash').hidden = true;
jingle?.pause(); // saltato il cartello, si salta anche la musichetta
reveal();
removeEventListener('keydown', onKey);
};
const schedule = () => { setTimeout(reveal, SPLASH_FADE); setTimeout(end, SPLASH_END); };
// La musichetta: su un sito mai visitato il browser non la fa partire senza un gesto (in locale si fida, perché ci
// si gioca da giorni: Andrea, 26 ottobre, «online non sento gli effetti»). Allora il cartello aspetta il primo clic
// o tasto, che fa partire musichetta e conto alla rovescia; il secondo lo salta, come sempre.
const jingle = story.music?.splash ? new Audio(versioned(story.music.splash)) : null;
const begin = () => { waiting = false; $('splash').classList.remove('wait'); jingle.play().catch(() => {}); schedule(); };
const onKey = () => { if (waiting) begin(); else end(); };
if (jingle) {
jingle.volume = music.muted ? 0 : music.volume;
jingle.play().then(schedule).catch(() => { waiting = true; $('splash').classList.add('wait'); });
window.jingle = jingle; // per i test nel browser
} else schedule();
$('splash').addEventListener('click', () => { if (waiting) begin(); else end(); });
addEventListener('keydown', onKey);
}
for (const el of document.querySelectorAll('.version')) el.textContent = `v${VERSION}`;
game = new Game(story);
command = new Command(game);
showSplash();
requestAnimationFrame(frame);
loadAssets(story, (storage && readSave(storage))?.scene ?? story.start.scene).then(ok => { if (ok.includes(false)) console.warn('Alcune immagini non sono state caricate.'); });