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

1323 lines
84 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, check, 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 { cabinetAway } from './engine/flipper-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 micro from './engine/micro.js';
import * as cerbottana from './engine/cerbottana.js';
import * as condor from './engine/condor.js';
import * as campana from './engine/campana.js';
import * as bruciate from './engine/bruciate.js';
import * as flipper from './engine/flipper.js';
import * as poker from './engine/poker.js';
import { cardAt as pokerAt } from './engine/poker-render.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
// Il gioco non usa service worker: se ne è rimasto uno di un'altra app passata su questo indirizzo (la Bussola su
// localhost:8080, 31 ottobre), si sgancia, sennò intercetta le pagine aperte dal gioco (il muro nel telefonino).
navigator.serviceWorker?.getRegistrations().then(list => list.forEach(r => r.unregister())).catch(() => {});
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, story.start.face);
$('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);
$('back-button').addEventListener('click', () => game.goBack());
// --- «?»: 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() {
dropPointers();
if (!running) return;
running = false;
game.closeupCancel();
music.hold = true;
voices.pause();
ambience.configure({ active: false });
command.reset();
$('pause-map').hidden = !!game.closeup || !!game.scene?.viaggio || 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(); });
// I comandi di un minigioco (molla, pulsanti, carte, «Come si gioca») sono più alti del pannello dei verbi: con quelli
// aperti la scena lascia intorno lo spazio che occupano davvero, fasce comprese (--below, misurato), così su uno schermo grande o a schermo
// intero non finiscono sotto il bordo. Senza comandi vale la riserva del pannello in unità (style.css, #app).
// La misura cambia la scena solo per differenze grandi: una frase di stato che va a capo (PONTE SX al flipper) non deve
// ridimensionarla a ogni colpo, che a schermo sembra un piano che salta di qualche pixel (Andrea, 1 novembre). E «Come si
// gioca» aperto non conta: se lo si apre, la pagina scorre invece di stringere la scena (e poi non torna larga).
function fitControls() {
const app = $('app'), open = !!document.querySelector('#side > section[id$="-controls"]:not([hidden])');
if (!app.classList.toggle('controls', open)) { app.style.removeProperty('--below'); return; }
// Tutto quello che non è la scena 16:9: le fasce di audio, pausa e uscita e la pulsantiera (dalla larghezza, non
// dalla tela, che mentre cambia risoluzione può avere per un attimo un'altezza qualunque).
const box = app.getBoundingClientRect(), help = document.querySelector('#side details[id$="-help"][open]');
const extra = help ? help.getBoundingClientRect().height - help.querySelector('summary').getBoundingClientRect().height : 0;
const below = Math.ceil(box.height - box.width * 9 / 16 - extra), was = parseFloat(app.style.getPropertyValue('--below'));
// Cresce subito (niente deve finire sotto il bordo), cala solo per differenze grandi (niente salti a ogni frase).
if (!(below <= was + 1 && below >= was - 24)) app.style.setProperty('--below', `${below}px`);
}
// «Solo tavola» al flipper (Andrea, 1 novembre): nasconde la pulsantiera, la tavola prende tutta l'altezza; il browser
// se lo ricorda (sul telefono in orizzontale la pulsantiera del flipper è già nascosta, style.css).
let tableOnly = (() => { try { return storage?.getItem('regolo-solo-tavola') === '1'; } catch { return false; } })();
$('table-only').addEventListener('click', e => {
tableOnly = !tableOnly;
try { storage?.setItem('regolo-solo-tavola', tableOnly ? '1' : '0'); } catch { /* niente memoria: vale per questa volta */ }
e.currentTarget.blur();
});
// Schermo intero, dove il browser lo permette (su iPhone no: i pulsanti spariscono): dalla Pausa, dal pulsante accanto ai
// volumi (anche sul titolo) o col tasto F; Esc, il pulsante o F per uscire.
function toggleFullscreen() {
if (!document.fullscreenEnabled) return false;
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
return true;
}
{
const full = $('fullscreen'), hud = $('fullscreen-hud');
if (!document.fullscreenEnabled) full.hidden = hud.hidden = true;
else {
full.addEventListener('click', toggleFullscreen);
hud.addEventListener('click', e => { toggleFullscreen(); e.currentTarget.blur(); });
document.addEventListener('fullscreenchange', () => {
const on = !!document.fullscreenElement;
full.textContent = on ? 'Esci da schermo intero' : 'Schermo intero';
hud.setAttribute('aria-label', on ? 'Esci da schermo intero' : 'Schermo intero');
hud.title = on ? 'Esci da schermo intero (F o Esc)' : 'Schermo intero (F)';
hud.setAttribute('aria-pressed', on);
});
}
}
$('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; }
// Nel flipper, S: tre giri sulla rampa sinistra e tre sulla destra (due LOCK), così la SUPER SPECIAL al centro è
// aperta e si prova subito il Regolo del futuro.
if (k === 's' && story.closeups[game?.closeup?.id]?.type === 'flipper' && game.closeup.table !== 'future') { flipper.openSuper(game.closeup); 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;
// Le dita in più sul flipper (oltre a `press`): ognuna lascia il suo flipper quando si alza. Il tasto destro del
// mouse ha lo stesso pointerId del sinistro: per il flipper vale come un dito a parte ('mouse-right', sempre il
// flipper di destra), e i due tasti si allineano alla maschera `e.buttons` a ogni evento (syncMouse).
const extra = new Set(), pointerKey = e => e.button === 2 ? 'mouse-right' : e.pointerId;
function syncMouse(e) {
const left = !!(e.buttons & 1), right = !!(e.buttons & 2);
if (right && !extra.has('mouse-right')) { game.closeupAim(scenePoint(e)); if (game.closeupPress('mouse-right')) extra.add('mouse-right'); }
if (!right && extra.has('mouse-right')) { extra.delete('mouse-right'); game.closeupRelease('mouse-right'); }
if (left && !press) { game.closeupAim(scenePoint(e)); press = { closeup: true, pointerId: e.pointerId, state: game.closeup }; if (!game.skip()) game.closeupPress(e.pointerId); }
if (!left && press?.closeup && press.pointerId === e.pointerId) { const state = press.state; press = null; if (game.closeup === state) game.closeupRelease(e.pointerId); }
}
// Pausa, finestra che perde il fuoco: niente resta premuto, né nel motore né qui.
function dropPointers() { press = null; extra.clear(); }
canvas.addEventListener('pointerdown', e => {
if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; }
if (!running || (e.button > 0 && !(e.button === 2 && game.closeup && story.closeups[game.closeup.id].type === 'flipper'))) 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;
}
// Il flipper: un secondo dito comanda l'altro flipper, per conto suo; il tasto destro del mouse quello di destra.
if (type === 'flipper' && e.pointerType === 'mouse' && e.button === 2) { syncMouse(e); canvas.setPointerCapture(e.pointerId); return; }
if (press?.closeup && type === 'flipper' && !extra.has(pointerKey(e))) {
game.closeupAim(scenePoint(e));
if (game.closeupPress(pointerKey(e))) { extra.add(pointerKey(e)); canvas.setPointerCapture(e.pointerId); }
return;
}
if (press?.closeup) return;
const aimed = game.closeupAim(scenePoint(e));
if (['biliardo', 'rinquarto', 'basket', 'micro', 'cerbottana', 'condor'].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, pointerId: e.pointerId, state: game.closeup };
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
if (!game.skip()) game.closeupPress(e.pointerId);
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 (e.pointerType === 'mouse' && game?.closeup && story.closeups[game.closeup.id].type === 'flipper' && (extra.has('mouse-right') || press?.closeup)) { syncMouse(e); return; }
if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupRelease(e.pointerId); return; }
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) {
if (e.pointerId !== press.pointerId) return;
const state = press.state; press = null;
if (running && game.closeup === state && !game.closeupRelease(e.pointerId) && !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', e => {
if (extra.has('mouse-right') && e.pointerType === 'mouse') { extra.delete('mouse-right'); game?.closeupCancel('mouse-right'); }
if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; }
if (press?.closeup && e.pointerId !== press.pointerId) return;
if (press) clearTimeout(press.timer);
if (!press?.closeup || game?.closeup === press.state) game?.closeupCancel(e.pointerId);
press = null;
});
canvas.addEventListener('lostpointercapture', e => { if (extra.has('mouse-right') && e.pointerType === 'mouse' && !(e.buttons & 2)) { extra.delete('mouse-right'); game?.closeupCancel('mouse-right'); } if (extra.has(e.pointerId)) { extra.delete(e.pointerId); game?.closeupCancel(e.pointerId); return; } if (press?.closeup && e.pointerId !== press.pointerId) return; if (press?.closeup || press?.gara !== undefined || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(e.pointerId); } });
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;
// Un tasto del mouse premuto o lasciato mentre l'altro è giù arriva come pointermove (pulsanti «in accordo»):
// al flipper si legge la maschera dei pulsanti e si allineano i due comandi.
if (e.pointerType === 'mouse' && e.button >= 0 && game.closeup && story.closeups[game.closeup.id].type === 'flipper') { syncMouse(e); return; }
// 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', 'micro', 'cerbottana', 'condor', 'campana'].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 Trappizzio Berti', label: 'I rinquarti di Trappizzio Berti: clic o tocco fissa la mira, frecce o pulsanti Mira per girare la stecca (Maiusc per i passi fini), Tira per tirare. Dopo l’esito, Prossima.' },
poker: { ...poker, back: 'Torna al Barrettino', label: 'Il pokerino: tocca le carte da cambiare, poi Cambia; nei giri di puntate Lascio, Vedo o Parola, Apro o Rilancio. Dopo la mostra un tocco va avanti.' },
cerbottana: { ...cerbottana, back: 'Torna in piazza', label: 'La cerbottana: muovi il mouse o tocca il muro per mirare, tieni premuto per prendere fiato e lascia per sparare; oppure regola il fiato e premi Spara. Frecce per spostare il mirino.' },
condor: { ...condor, back: 'Torna al campone', label: 'La Condor: muovi il mouse o tocca il campo per mirare, tieni premuto per trattenere il fiato e lascia per sparare; oppure premi Spara. Frecce per spostare il mirino.' },
campana: { ...campana, back: 'Torna al campone', label: 'La campana: muovi il mouse o tocca la campana per mirare, tieni premuto per la forza e lascia per tirare il sassolino; oppure regola la forza e premi Tira (frecce su e giù per mirare, destra e sinistra per la forza). Ai salti premi, o Spazio, quando la lancetta è nel verde.' },
bruciate: { ...bruciate, back: 'Torna alla festa', label: 'Le bruciate: tieni premuto e fai dei giri, col mouse o col dito, per girare la manovella; oppure Spazio o le frecce, una spinta a ogni colpo. Né troppo piano né troppo forte; quando son cotte, Rovescia nel sacco (o Invio).' },
flipper: { ...flipper, toggle: true, back: 'Torna in strada', label: 'Il flipper del River: tieni premuto per caricare la molla e lascia per lanciare (o regola la forza e premi Lancia; o tieni premuto Spazio o la freccia giù). In gioco, tieni premuto sulla metà sinistra o destra della scena per alzare il flipper di quel lato, anche con due dita o col tasto destro del mouse; oppure i pulsanti Flipper, le frecce sinistra e destra o i due Shift. La SUPER SPECIAL fa esplodere la festa e apre Il Regolo del futuro: due giri completi su Orbita (35.000), Ponte (50.000) e Spira (65.000), poi colpisci il Regolo mobile per 500.000 e il ritorno al classico senza perdere vite. Perdere l’ultima pallina del multiball riporta al classico consumando una vita. Durante la trasformazione le palline sono al sicuro; comandi invariati.' },
micro: { ...micro, back: 'Torna dal Chiaru', label: 'La pista col gesso: muovi il mouse o tocca il parcheggio per mirare, tieni premuto per caricare la spinta e lascia; oppure regola la forza e premi Spingi. Frecce o pulsanti Mira per girare.' },
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. Alla bona e Seria: tieni premuto sulla scena (o Spazio) per il gas e lascia per frenare, lo sterzo va da solo; in curva troppo forte si esce. Ginocchio in terra: 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());
// GP, guida semplice: il pulsante Gas si tiene premuto (giù gas, su freno) invece di accendersi e spegnersi al clic;
// sul telefono, alla bona, il clic arrivava solo alzando il dito (Andrea, 29 ottobre).
const holdGas = c => !!c && story.closeups[c.id].type === 'gara' && gara.simple(c) && c.step !== 'intro' && c.step !== 'result';
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech && !holdGas(c)) mini().shoot?.(c); });
$('bocce-throw').addEventListener('pointerdown', e => {
const c = miniState(); if (!holdGas(c) || game.speech) return;
e.preventDefault();
if (gara.press(c, 'gas-button')) try { $('bocce-throw').setPointerCapture(e.pointerId); } catch { /* un tocco sintetico: va bene lo stesso */ }
});
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) $('bocce-throw').addEventListener(ev, () => { const c = miniState(); if (holdGas(c)) gara.release(c, 'gas-button'); });
$('bocce-throw').addEventListener('contextmenu', e => { if (holdGas(miniState())) e.preventDefault(); });
// Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco.
const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt, poker: pokerAt };
// Il pokerino: tre pulsanti fissi (su iPhone un pulsante rifatto a ogni fotogramma perde il tocco), la scritta cambia.
for (let i = 0; i < 3; i++) $('poker-move-' + i).addEventListener('click', e => {
const c = game?.closeup; if (!c || story.closeups[c.id].type !== 'poker' || game.speech) return;
if (c.step === 'show') poker.advance(c); else poker.choose(c, e.currentTarget.dataset.move);
});
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', e => { const c = miniState(); if (!c) return;
if (mini().turn) { if (!e.detail) { mini().turn(c, dir, 'click'); mini().stopTurn(c, 'click'); } }
else if (mini().nudge(c, dir)) c.aimLocked = true;
});
$(id).addEventListener('pointerdown', e => { const c = miniState();
if (c && mini().turn?.(c, dir, e.pointerId)) $(id).setPointerCapture(e.pointerId);
});
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) $(id).addEventListener(ev, e => {
const c = miniState(); if (c) mini().stopTurn?.(c, e.pointerId);
});
// 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', () => { dropPointers(); 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;
}));
}
// Ai minigiochi lo Shift arriva coi due lati distinti (ShiftLeft, ShiftRight): nel flipper comandano le due palette.
const keyName = e => e.key === 'Shift' ? e.code : e.key;
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;
// F: schermo intero ovunque (nessun gioco usa la F; nella ricerca della mappa si scrive, sopra).
if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.metaKey && !e.altKey && !e.repeat && toggleFullscreen()) { e.preventDefault(); 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, keyName(e), true, e.shiftKey)) { e.preventDefault(); return; }
if (onControl && [' ', 'Enter'].includes(e.key)) 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?.(game.closeup, keyName(e), 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', 'poker'].includes(def?.type);
// La presentazione dei piloti del GP a tutto schermo: i comandi arrivano col semaforo (Andrea, 29 ottobre).
const presenta = !!mg && story.closeups[c.id].type === 'gara' && c.step === 'intro';
$('app').classList.toggle('presenta', presenta);
setProp($('bocce-controls'), 'hidden', !mg || cards || presenta);
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);
setProp($('poker-controls'), 'hidden', def?.type !== 'poker');
setProp($('poker-controls'), 'inert', !running);
if (def?.type === 'poker') {
setProp($('panel'), 'hidden', true); setProp($('choices'), 'hidden', true);
setText($('poker-status'), poker.status(c));
const moves = c.step === 'show' ? [{ id: 'avanti', label: 'Prossima mano' }] : poker.moves(c);
for (let i = 0; i < 3; i++) {
const b = $('poker-move-' + i), m = moves[i];
setProp(b, 'hidden', !m);
if (!m) continue;
setText(b, m.label); b.dataset.move = m.id;
setProp(b, 'disabled', !!m.disabled || !!game.speech);
}
}
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', micro: 'Comandi delle macchinine', cerbottana: 'Comandi della cerbottana', condor: 'Comandi della Condor', campana: 'Comandi della campana', bruciate: 'Comandi delle bruciate', flipper: 'Comandi del flipper' }[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);
// Al flipper i due pulsanti valgono per tutta la partita, il cursore della molla solo prima del lancio.
if (mg.canFlip) for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'disabled', !mg.canFlip(c) || !!game.speech);
setProp($('bocce-throw'), 'disabled', !(mg.canPress?.(c) ?? 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');
// Ai rinquarti la forza è sempre piena: il cursore non serve. Nella guida semplice del GP (alla bona e seria) si
// usa solo il gas: niente sensibilità, sterzo, freno né riquadro dello swipe.
const easy = def.type === 'gara' && gara.simple(c);
setProp(powerLabel, 'hidden', def.type === 'rinquarto' || def.type === 'condor' || def.type === 'bruciate' || easy);
for (const id of ['bocce-left', 'bocce-right']) setProp($(id), 'hidden', easy || def.type === 'campana' || def.type === 'bruciate');
const powerName = def.type === 'gara' ? 'Sensibilità ' : def.type === 'cerbottana' ? 'Fiato ' : def.type === 'flipper' ? 'Molla ' : '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' || easy);
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' : def.type === 'flipper' ? '← Flipper' : '← Mira');
setText($('bocce-right'), def.type === 'gara' ? 'Sterza →' : def.type === 'flipper' ? 'Flipper →' : 'Mira →');
setProp($('gara-brake'), 'hidden', def.type !== 'gara' || easy);
$('bocce-throw').classList.toggle('hold', easy);
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($('micro-help'), 'hidden', def.type !== 'micro');
setProp($('cerbottana-help'), 'hidden', def.type !== 'cerbottana');
setProp($('condor-help'), 'hidden', def.type !== 'condor');
setProp($('campana-help'), 'hidden', def.type !== 'campana');
setProp($('bruciate-help'), 'hidden', def.type !== 'bruciate');
setProp($('flipper-help'), 'hidden', def.type !== 'flipper');
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 || c?.events) 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.scene?.viaggio || game.mapPlaces().length < 2);
// «Torna al 2007»: nelle scene fuori dal tempo, finché non si parla con qualcuno.
setProp($('back-button'), 'hidden', !running || !game.scene?.back || !!game.dialogue || !!game.closeup);
setText($('back-button'), game.scene?.back?.label ?? '');
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);
// Il Muro del Sanpierino: a muro aperto sul telefonino del Barone, la pagina vera sopra la scena. L'iframe c'è solo
// allora (sempre presente, anche nascosto, rallentava la pagina): ogni volta che si riapre si ricarica, coi
// messaggi nuovi della storia.
const wall = running && c && story.closeups[c.id]?.type === 'telefono' && c.step === 'wall';
let muro = $('muro');
if (wall && !muro) {
muro = Object.assign(document.createElement('iframe'), { id: 'muro', title: 'Il Muro del Sanpierino', src: versioned('assets/muro/index.html') });
canvas.after(muro);
}
if (!wall && muro) muro.remove();
if (wall) muro.style.setProperty('--muro-scale', String(canvas.clientWidth / 1067));
setText($('closeup-close'), torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : c && story.closeups[c.id]?.type === 'risveglio' ? 'Salta' : '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', 'micro', 'cerbottana', 'condor', 'campana', 'flipper'].includes(story.closeups[c.id].type));
$('stage').classList.toggle('briscola', !!cards);
const pinball = story.closeups[c?.id]?.type === 'flipper';
$('app').classList.toggle('flipper', pinball);
$('app').classList.toggle('table-only', pinball && tableOnly);
setProp($('table-only'), 'hidden', !pinball);
setText($('table-only'), tableOnly ? 'Pulsantiera' : 'Solo tavola');
fitControls();
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,
table: game?.closeup?.table, at: game && [game.state.x, game.state.y], ok: cond => !!game && check(cond, game.state) }), 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.backdrop())}`;
if (game && (!still || still !== drawnStill)) {
if (running) game.update(dt);
// Il tavolo del flipper ha superfici e luci continue; gli altri ambienti restano in pixel art.
const pinballHD = game.closeup && story.closeups[game.closeup.id].type === 'flipper';
const sceneScale = pinballHD ? 2 : 1;
if (canvas.width !== WIDTH * sceneScale) {
canvas.width = WIDTH * sceneScale; canvas.height = HEIGHT * sceneScale;
canvas.classList.toggle('pinball-hd', !!pinballHD);
if (!pinballHD) cabinetAway(canvas);
}
ctx.setTransform(sceneScale, 0, 0, sceneScale, 0, 0);
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.'); });