Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
1252 lines
79 KiB
JavaScript
1252 lines
79 KiB
JavaScript
// 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 { 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(); });
|
||
$('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;
|
||
// 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 Spazio). 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 o le frecce sinistra e destra.' },
|
||
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;
|
||
}));
|
||
}
|
||
|
||
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.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, 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', '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);
|
||
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,
|
||
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);
|
||
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.'); });
|