// Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti). import { story } from './story/story.js'; import { Game, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace } 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 briscola from './engine/briscola.js'; import { cardAt } from './engine/briscola-render.js'; import * as tressette from './engine/tressette.js'; import { cardAt as tressetteAt } from './engine/tressette-render.js'; import * as scopa from './engine/scopa.js'; import { cardAt as scopaAt, tableAt as scopaTableAt } from './engine/scopa-render.js'; import { VERSION, versioned } from './version.js'; const $ = id => document.getElementById(id); const canvas = $('scene'), ctx = canvas.getContext('2d'); const storage = (() => { try { return window.localStorage; } catch { return null; } })(); const LONG_PRESS = 450, DOUBLE_CLICK = 350, IDLE_PAUSE = 60000; let idleSince = performance.now(); for (const ev of ['pointerdown', 'pointermove', 'keydown', 'wheel']) addEventListener(ev, () => { idleSince = performance.now(); }, { capture: true, passive: true }); let lastTap = null; const debug = new URLSearchParams(location.search).has('debug'); let game = null, command = null, hoverTarget = null, mouse = null, running = false; // --- Musica: parte subito se il browser lo permette, altrimenti al primo gesto; volumi e muto si ricordano. const audioSettings = (storage && readAudio(storage)) ?? {}; const music = new Music(audioSettings); const ambience = new Soundscape({ volume: audioSettings.effects }); window.ambience = ambience; // --- Voci: l'indice arriva con fetch; finché non c'è (o se manca) le battute restano mute. Seguono il muto della musica, // ma hanno il loro volume. const voices = new Voices({}, undefined, audioSettings.voices); const saveAudio = () => storage && writeAudio(storage, { ...music.settings(), voices: voices.volume, effects: ambience.volume }); function showSound() { $('mute').setAttribute('aria-pressed', String(music.muted)); $('mute').setAttribute('aria-label', music.muted ? "Rimetti l'audio" : "Togli l'audio"); $('volume').value = Math.round(music.volume * 100); $('voice-volume').value = Math.round(voices.volume * 100); $('effects-volume').value = Math.round(ambience.volume * 100); } for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, () => { music.unlock(); ambience.unlock(); }, { capture: true }); // Il muto zittisce subito anche la voce che sta parlando. const toggleMute = () => { if (music.toggle()) voices.stop(); ambience.configure({ muted: music.muted }); saveAudio(); showSound(); }; $('mute').addEventListener('click', toggleMute); $('volume').addEventListener('input', e => { music.setVolume(e.target.value / 100); saveAudio(); showSound(); }); $('voice-volume').addEventListener('input', e => { voices.setVolume(e.target.value / 100); saveAudio(); showSound(); }); $('effects-volume').addEventListener('input', e => { ambience.configure({ volume: e.target.value / 100 }); saveAudio(); showSound(); }); showSound(); window.music = music; // per i test nel browser fetch(versioned('assets/voices/index.json')).then(r => r.ok ? r.json() : {}).then(index => { voices.index = index; }).catch(() => {}); window.voices = voices; let voiced = null; const say = (who, text) => voices.play(who, text, music.muted); // Una partita salvata (state) o nuova; la nuova col rione scelto, che resta nei flag (`rione:`). function start(state, rione = null) { game = new Game(story, { state, onSave: data => storage && writeSave(storage, data), voiceTime: (who, text) => voices.seconds(who, text) }); command = new Command(game); if (!state && rione) game.state.flags['rione:' + rione] = true; if (!state) game.go(story.start.scene, story.start.at); $('title').hidden = true; $('ending').hidden = true; running = true; window.game = game; // per i test nel browser e per curiosare dalla console } // --- Introduzione: le righe di story.intro una alla volta sopra il panorama; poi comincia la partita. // --- Il rione: dopo «Nuova partita», per chi corre Andrea (story.rioni); poi l'introduzione con la sua riga. let rione = null; $('rione-buttons').replaceChildren(...Object.entries(story.rioni ?? {}).map(([id, r]) => { const b = document.createElement('button'); b.type = 'button'; b.dataset.rione = id; b.style.setProperty('--rione', r.color); b.innerHTML = ''; 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(n)); return b; })); return [dt, dd]; })); fillReel(); $('credits').hidden = false; rollCredits(); } // Sotto i nomi, un carosello che scorre da solo: gli sfondi del paese e i ritratti, due giri uguali in fila per non // vedere mai la cucitura. function fillReel() { const track = $('credits-reel').querySelector('.track'); if (track.children.length) return; const pictures = [...new Set(Object.values(story.scenes).map(s => s.background).filter(Boolean))]; const faces = (story.credits ?? []).flatMap(c => c.names).filter(n => n.face).map(n => n.face); // I ritratti sparsi fra gli sfondi, uno ogni quattro. const all = pictures.flatMap((p, i) => i % 4 === 3 && faces.length ? [p, faces[(i >> 2) % faces.length]] : [p]); for (const src of [...all, ...all]) { const img = Object.assign(document.createElement('img'), { src, alt: '', loading: 'lazy', decoding: 'async' }); if (src.includes('/credits/')) img.className = src.endsWith('palio.png') ? 'palio' : 'face'; track.append(img); } } // 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, face }) { $('face').querySelector('img').src = versioned(face); $('face').querySelector('img').classList.toggle('palio', face.endsWith('palio.png')); $('face').querySelector('figcaption').textContent = name; $('face').hidden = false; } $('face').addEventListener('click', () => { $('face').hidden = true; }); for (const b of document.querySelectorAll('.open-credits')) b.addEventListener('click', openCredits); $('close-credits').addEventListener('click', () => { $('credits').hidden = true; }); // --- Mappa: i posti visti e le strade fra loro; un clic su un posto ci porta (game.travel). // La mappa: i posti visti come schede con la miniatura, a gradini come il paese (le quattro fasce della pianta, // dalla Fortezza ai campi), e in ogni gradino da sinistra a destra. Le strade si disegnano dopo, fra le schede. const MAP_BANDS = [25, 50, 75, Infinity]; function openMap() { if (!running || game.dialogue || game.closeup) return; const board = $('map-board'), svg = $('map-roads'), places = game.mapPlaces(); const bands = MAP_BANDS.map(() => []); for (const p of places) bands[MAP_BANDS.findIndex(top => p.at[1] < top)].push(p); board.replaceChildren(svg, ...bands.filter(b => b.length).map(band => { const row = document.createElement('div'); row.className = 'map-band'; row.append(...band.sort((a, b) => a.at[0] - b.at[0]).map(p => { const b = document.createElement('button'); b.type = 'button'; b.dataset.scene = p.id; b.title = p.name; b.classList.toggle('here', p.here); const img = document.createElement('img'); img.alt = ''; if (p.mini) img.src = versioned(p.mini); const name = document.createElement('span'); name.textContent = p.name; b.append(img, name); b.addEventListener('click', () => { if (game.travel(p.id)) $('map').hidden = true; }); return b; })); return row; })); $('map-search').value = ''; filterMap(); $('map').hidden = false; drawRoads(); // Col mouse si può scrivere subito; sul telefono la tastiera si apre solo toccando la casella. if (matchMedia('(pointer: fine)').matches) $('map-search').focus({ preventScroll: true }); // Sul telefono la mappa scorre: si parte col posto dove si è, al centro. board.querySelector('.here')?.scrollIntoView({ block: 'center', inline: 'center' }); } // La ricerca: mentre si scrive restano solo i posti che corrispondono; Invio porta al primo. function filterMap() { const query = $('map-search').value; let shown = 0; for (const row of $('map-board').querySelectorAll('.map-band')) { let inRow = 0; for (const b of row.children) inRow += (b.hidden = !matchPlace(b.title, query)) ? 0 : 1; row.hidden = !inRow; shown += inRow; } $('map-none').hidden = shown > 0; $('map-none').textContent = `Nessun posto con «${query.trim()}»`; drawRoads(); $('map-scroll').scrollTo(0, 0); // i posti rimasti stanno in cima: niente mappa vuota scorsa in fondo } $('map-search').addEventListener('input', filterMap); $('map-search').addEventListener('keydown', e => { if (e.key !== 'Enter') return; const first = [...$('map-board').querySelectorAll('button')].find(b => !b.hidden && !b.classList.contains('here')); if (first) first.click(); }); // Le strade fra le schede, misurate dopo l'impaginazione (e di nuovo se la finestra cambia misura). function drawRoads() { const board = $('map-board'), svg = $('map-roads'); // Prima si toglie la misura vecchia: il disegno delle strade, grande quanto la mappa intera, la terrebbe lunga // anche dopo un filtro. svg.style.width = svg.style.height = '0'; const origin = board.getBoundingClientRect(); const center = id => { const card = board.querySelector(`[data-scene="${id}"]`); const r = card && !card.hidden && !card.parentElement.hidden && card.querySelector('img').getBoundingClientRect(); return r && [r.left + r.width / 2 - origin.left, r.top + r.height / 2 - origin.top]; }; svg.setAttribute('viewBox', `0 0 ${board.scrollWidth} ${board.scrollHeight}`); svg.style.width = board.scrollWidth + 'px'; svg.style.height = board.scrollHeight + 'px'; svg.replaceChildren(...game.mapRoads().map(([a, b]) => [center(a), center(b)]).filter(([a, b]) => a && b).map(([a, b]) => { const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); [['x1', a[0]], ['y1', a[1]], ['x2', b[0]], ['y2', b[1]]].forEach(([k, v]) => line.setAttribute(k, v)); return line; })); } addEventListener('resize', () => { if (!$('map').hidden) drawRoads(); }); $('map-button').addEventListener('click', openMap); // --- «?»: per qualche secondo si vede tutto quello che si può toccare (sul telefono non c'è il mouse da passarci sopra). const SPOTS_TIME = 3500; let spotsUntil = 0; const showSpots = () => { spotsUntil = performance.now() + SPOTS_TIME; }; $('spots-button').addEventListener('click', showSpots); // --- Pausa: ferma il gioco; da qui mappa, crediti o ritorno al titolo (la partita resta salvata per «Continua»). function openPause() { if (!running) return; running = false; game.closeupCancel(); music.hold = true; voices.pause(); ambience.configure({ active: false }); command.reset(); $('pause-map').hidden = !!game.closeup || game.mapPlaces().length < 2; $('pause').hidden = false; } function closePause() { $('pause').hidden = true; music.hold = false; voices.resume(); idleSince = performance.now(); running = true; } $('pause-button').addEventListener('click', openPause); // I video «Come si gioca» (story.tutorials): il gioco si ferma come in pausa, la musica tace, il video ha il suo audio. function openTutorial(kind) { const t = story.tutorials?.[kind]; if (!t || !running) return; running = false; game.closeupCancel(); music.hold = true; voices.pause(); ambience.configure({ active: false }); $('tutorial-title').textContent = t.title; const video = $('tutorial-video'); video.src = versioned(t.src); if (t.poster) video.poster = versioned(t.poster); video.muted = music.muted; $('tutorial').hidden = false; video.play().catch(() => {}); $('tutorial-close').focus(); } function closeTutorial() { const video = $('tutorial-video'); video.pause(); video.removeAttribute('src'); video.load(); $('tutorial').hidden = true; music.hold = false; voices.resume(); idleSince = performance.now(); running = true; } for (const button of document.querySelectorAll('.tutorial-open')) { button.hidden = !story.tutorials?.[button.dataset.tutorial]; button.addEventListener('click', () => openTutorial(button.dataset.tutorial)); } $('tutorial-close').addEventListener('click', closeTutorial); // Sul telefono, passando a un'altra app (o cambiando scheda), si ferma tutto: musica, voce, musichetta del cartello; // la partita va in pausa e aspetta. Tornando riparte la musica; la voce solo se non c'è la pausa (riparte con Riprendi). function leave(hidden) { music.setAway(hidden); if (hidden) { ambience.configure({ active: false }); voices.pause(); window.jingle?.pause(); if (running) openPause(); } else if ($('pause').hidden) voices.resume(); } document.addEventListener('visibilitychange', () => leave(document.hidden)); addEventListener('pagehide', () => leave(true)); addEventListener('pageshow', () => leave(document.hidden)); $('resume').addEventListener('click', closePause); $('pause-map').addEventListener('click', () => { closePause(); openMap(); }); $('to-title').addEventListener('click', () => { $('pause').hidden = true; music.hold = false; voices.stop(); // Se si esce a metà di una sequenza resta salvato l'ultimo punto fermo: niente enigmi risolti a metà. const data = game.savable(); if (data) game.onSave(data); showTitle(); }); $('close-map').addEventListener('click', () => { $('map').hidden = true; }); // --- Taccuino: le tappe fatte, barrate, e quella di adesso; in fondo dove chiedere aiuto. function openNotebook() { if (!running || game.closeup) return; const { done, now } = game.notebook(); $('notebook-list').replaceChildren(...done.map(t => Object.assign(document.createElement('li'), { textContent: t, className: 'done' })), ...(now ? [Object.assign(document.createElement('li'), { textContent: now, className: 'now' })] : [])); $('notebook-help').textContent = story.notebookHelp ?? ''; $('notebook').hidden = false; // Su uno schermo basso il foglio scorre: si parte dalla tappa di adesso, in fondo. $('notebook-list').scrollTop = $('notebook-list').scrollHeight; } $('notebook-button').addEventListener('click', openNotebook); $('close-notebook').addEventListener('click', () => { $('notebook').hidden = true; }); // --- Salva su file / Carica: la partita in un file .json da aprire su un altro dispositivo. $('save-file').addEventListener('click', () => { const data = game.savable(); if (!data) return; game.onSave(data); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([saveFile(data)], { type: 'application/json' })); a.download = saveFileName(); a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); }); $('load-game').addEventListener('click', () => { $('load-error').hidden = true; $('load-file').click(); }); const LOAD_ERRORS = { 'not-save': 'Questo file non è un salvataggio della Leggenda del Regolo.', newer: 'Questo salvataggio viene da una versione più nuova del gioco: aggiorna la pagina e riprova.', story: 'Questo salvataggio non torna con questa versione del gioco.', }; const loadError = key => { $('load-error').textContent = LOAD_ERRORS[key]; $('load-error').hidden = false; }; // Il salvataggio del browser si sostituisce solo dopo che il file è stato controllato e la partita è ripartita. $('load-file').addEventListener('change', async e => { const file = e.target.files[0]; e.target.value = ''; if (!file) return; const { state, error } = readSaveFile(await file.text()); if (error) return loadError(error); const wrong = checkSave(story, state); if (wrong) { console.warn('Salvataggio scartato:', wrong); return loadError('story'); } start(state); if (storage) writeSave(storage, game.snapshot()); }); function showTitle() { running = false; ambience.configure({ active: false }); const saved = storage && readSave(storage); $('continue').hidden = !saved || saved.ended; $('load-error').hidden = true; $('title').hidden = false; } // --- Puntatore: clic, clic destro e pressione lunga = guarda. const scenePoint = e => { const r = canvas.getBoundingClientRect(); return [(e.clientX - r.left) / r.width * WIDTH, (e.clientY - r.top) / r.height * HEIGHT]; }; // --- Debug (?debug): Maiusc+clic segna un punto e ne copia le coordinate; K copia tutti i punti come poligono // (per `walk`), X li cancella, G nasconde e mostra il disegno di debug. Il resto si fa dalla console (game.go('oratorio')). const debugPoints = []; let debugShown = true; const copy = text => navigator.clipboard?.writeText(text).catch(() => {}); function debugPoint(p) { const point = p.map(Math.round); debugPoints.push(point); copy(`[${point.join(', ')}]`); console.log('punto', point); } function debugKey(e) { if (!debug) return false; const k = e.key.toLowerCase(); if (k === 'k') { const text = `[${debugPoints.map(p => `[${p.join(', ')}]`).join(', ')}]`; copy(text); console.log(text); return true; } if (k === 'x') { debugPoints.length = 0; return true; } if (k === 'g') { debugShown = !debugShown; return true; } return false; } const debugView = () => debug && debugShown && game && { mouse, points: debugPoints, help: `Maiusc+clic punto · K copia ${debugPoints.length} · X azzera · G` }; // Col dito si prende anche un oggetto mancato di poco: TOUCH_REACH pixel di schermo, in coordinate di scena. const TOUCH_REACH = 22; const reachOf = e => e.pointerType === 'touch' || e.pointerType === 'pen' ? TOUCH_REACH * WIDTH / canvas.getBoundingClientRect().width : 0; let press = null; canvas.addEventListener('pointerdown', e => { if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; } if (!running || e.button > 0) return; // Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano. if (game.closeup) { const type = story.closeups[game.closeup.id].type, deck = CARD_AT[type]; if (deck) { if (game.skip()) return; // Tressette e scopa: fra una smazzata e l'altra un tocco va avanti. if (MINI[type].advance?.(game.closeup)) return; // Scopa, più prese possibili: si tocca una carta in tavola (si decide al rilascio, vedi pointerup). if (type === 'scopa' && game.closeup.step === 'choose') { press = { table: scopaTableAt(game.closeup, scenePoint(e)), state: game.closeup }; canvas.setPointerCapture(e.pointerId); return; } press = { card: deck(game.closeup, scenePoint(e)), state: game.closeup, at: deck }; canvas.setPointerCapture(e.pointerId); return; } const aimed = game.closeupAim(scenePoint(e)); if (story.closeups[game.closeup.id].type === 'biliardo') { if (!aimed && !game.speech) return; if (aimed) game.closeup.aimLocked = true; // il clic fissa la mira } if (story.closeups[game.closeup.id].type === 'bocce' && aimed) game.closeup.aimLocked = true; press = { closeup: true }; // Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta). if (!game.skip()) game.closeupPress(); canvas.setPointerCapture(e.pointerId); return; } const point = scenePoint(e), reach = reachOf(e); press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) }; }); canvas.addEventListener('pointerup', e => { if (!press) return; // Scopa: la carta toccata e lasciata sullo stesso punto. Se sta nella presa già scelta, si prende quella; se no // si sceglie la prima presa che la contiene (un altro tocco conferma). Trascinando via non succede niente. if (press.table !== undefined) { const p = press; press = null; const c = game.closeup; if (!running || c !== p.state || c.step !== 'choose' || p.table < 0 || scopaTableAt(c, scenePoint(e)) !== p.table) return; const card = c.table[p.table], options = c.choice.options.flatMap((o, k) => o.includes(card) ? [k] : []); if (options.includes(c.choice.selected)) scopa.take(c); else if (options.length) scopa.select(c, options[0]); return; } if (press.card !== undefined) { const p = press; press = null; if (running && game.closeup === p.state && p.card >= 0 && p.at(game.closeup, scenePoint(e)) === p.card) game.closeupPlay(p.card); return; } if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; } clearTimeout(press.timer); // Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità. if (!press.long) { const point = scenePoint(e), now = performance.now(); const hurry = !!lastTap && now - lastTap.time < DOUBLE_CLICK && Math.hypot(point[0] - lastTap.point[0], point[1] - lastTap.point[1]) < 24; command.clickScene(point, { hurry, reach: press.reach }); lastTap = hurry ? null : { time: now, point }; } press = null; }); canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); }); canvas.addEventListener('lostpointercapture', () => { if (press?.closeup || press?.card !== undefined || press?.table !== undefined) { press = null; game?.closeupCancel(); } }); canvas.addEventListener('contextmenu', e => { e.preventDefault(); if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true }); }); canvas.addEventListener('pointermove', e => { if (!running) return; mouse = e.pointerType === 'mouse' ? scenePoint(e) : null; // Al biliardo e alle bocce la mira segue il mouse finché un clic (o una regolazione fine) non la fissa: dopo, // attraversare il campo per raggiungere la forza, «Tira» o «Lancia» non cambia il colpo. Al tiro dopo torna libera. // (Bocce: scoperto girando il video «Come si gioca», la mira finiva a due passi andando verso il cursore.) const aiming = game.closeup && ['biliardo', 'bocce'].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; }); // 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.' }, }; 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) mini().setPower?.(c, Number(e.target.value) / 100); }); $('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); }); // Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco. const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt }; for (let i = 0; i < 3; i++) { const button = $('scopa-card-' + i); button.addEventListener('click', () => game?.closeupPlay(i)); button.addEventListener('focus', () => { if (game?.closeup?.id === 'scopa' && game.closeup.step === 'play') scopa.select(game.closeup, i); }); } $('scopa-options').addEventListener('focusin', e => { const k = e.target.dataset?.option; if (k !== undefined && game?.closeup?.step === 'choose') scopa.select(game.closeup, Number(k)); }); $('scopa-options').addEventListener('click', e => { const k = e.target.dataset?.option; if (k !== undefined && game?.closeup?.id === 'scopa') scopa.take(game.closeup, Number(k)); }); $('scopa-next').addEventListener('click', () => { if (game?.closeup?.id === 'scopa') scopa.advance(game.closeup); }); for (let i = 0; i < 10; i++) { const button = $('tressette-card-' + i); button.addEventListener('click', () => game?.closeupPlay(i)); button.addEventListener('focus', () => { if (game?.closeup?.id === 'tressette') tressette.select(game.closeup, i); }); } for (const signal of tressette.SIGNALS) $('tressette-' + signal).addEventListener('click', () => { if (game?.closeup?.id === 'tressette') tressette.setSignal(game.closeup, signal); }); $('tressette-next').addEventListener('click', () => { if (game?.closeup?.id === 'tressette') tressette.advance(game.closeup); }); for (let i = 0; i < 3; i++) { const button = $('briscola-card-' + i); button.addEventListener('click', () => game?.closeupPlay(i)); button.addEventListener('focus', () => { if (game?.closeup?.id === 'briscola') briscola.select(game.closeup, i); }); } for (const [id, dir] of [['bocce-left', -1], ['bocce-right', 1]]) $(id).addEventListener('click', () => { const c = miniState(); if (c && mini().nudge(c, dir)) c.aimLocked = true; }); addEventListener('blur', () => { if (press?.card !== undefined) press = null; game?.closeupCancel(); }); // --- Verbi. for (const verb of VERBS) { const b = document.createElement('button'); b.type = 'button'; b.dataset.verb = verb; b.textContent = VERB_LABELS[verb]; b.addEventListener('click', () => running && command.pickVerb(verb)); $('verbs').appendChild(b); } // --- Inventario: si ridisegna solo quando cambia. let inventoryKey = '', hoverItem = null; function updateInventory() { const key = game.state.inventory.join(',') + '|' + command.item; if (key === inventoryKey) return; const grew = game.state.inventory.length > inventoryKey.split('|')[0].split(',').filter(Boolean).length; inventoryKey = key; const box = $('inventory'); box.replaceChildren(...game.state.inventory.map(id => { const b = document.createElement('button'), item = story.items[id]; b.type = 'button'; b.dataset.item = id; b.setAttribute('aria-pressed', String(command.item === id)); // L'icona a pixel dell'oggetto (assets/icons); senza, un pallino del suo colore. let icon; if (item.icon?.src) { icon = document.createElement('img'); icon.src = versioned(item.icon.src); icon.alt = ''; } else { icon = document.createElement('i'); icon.style.background = item.icon?.fill ?? '#ccc'; } // Il nome in uno span: i nomi lunghi («stecco e gomma») vanno a capo su due righe strette, non su tre. const name = document.createElement('span'); // Dopo l'apostrofo si può andare a capo («chiave dell'» / «oratorio»): le parole restano intere. name.textContent = item.name.replace(/'/g, "'\u200b"); if (item.name.length > 10) name.className = 'long'; b.append(icon, name); let timer = null, long = false; b.addEventListener('pointerdown', () => { long = false; timer = setTimeout(() => { long = true; command.clickItem(id, { look: true }); }, LONG_PRESS); }); b.addEventListener('pointerup', () => clearTimeout(timer)); b.addEventListener('click', () => { if (!long && running) command.clickItem(id); }); b.addEventListener('contextmenu', e => { e.preventDefault(); clearTimeout(timer); if (running) command.clickItem(id, { look: true }); }); b.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') hoverItem = id; }); b.addEventListener('pointerleave', () => { if (hoverItem === id) hoverItem = null; }); return b; })); // Un oggetto nuovo finisce in fondo: si scorre fin lì, così si vede subito. if (grew) box.scrollTop = box.scrollHeight; } // --- Dialoghi: scelte numerate, anche da tastiera. let choicesKey = ''; function updateChoices() { const options = game.options(), inDialogue = !!game.dialogue; $('panel').hidden = inDialogue; $('choices').hidden = !inDialogue; const key = inDialogue + '|' + options.map(o => o.text).join('|'); if (key === choicesKey) return; choicesKey = key; $('choices').replaceChildren(...options.map((o, i) => { const li = document.createElement('li'); li.tabIndex = 0; li.textContent = o.text; li.addEventListener('click', () => game.choose(i)); li.addEventListener('keydown', e => { if (e.key === 'Enter') game.choose(i); }); return li; })); } window.addEventListener('keydown', e => { if (e.target 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; } 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()) { if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) 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 => { 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; box.style.color = story.characters[sp.who]?.color ?? '#fff'; // 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)); box.style.left = px + '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) b.setAttribute('aria-pressed', String(command.verb === b.dataset.verb)); const c = game.closeup, torch = c && story.closeups[c.id].type === 'torch'; const text = torch ? c.step === 'done' ? 'Gli occhi sono spariti. Continua o premi Invio.' : `${story.closeups[c.id].targets[c.found]?.hint ?? ''} Sposta la luce; Invio per cercare.` : !running || game.speech || game.dialogue || c ? '' : command.sentence(hoverTarget); if ($('sentence').textContent !== text) $('sentence').textContent = text; updateInventory(); updateChoices(); updateSpeech(); const mg = miniState() && mini(), def = mg && story.closeups[c.id]; const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type); $('bocce-controls').hidden = !mg || cards; $('bocce-controls').inert = !running; $('briscola-controls').hidden = def?.type !== 'briscola'; $('briscola-controls').inert = !running; $('scopa-controls').hidden = def?.type !== 'scopa'; $('scopa-controls').inert = !running; if (def?.type === 'scopa') { $('panel').hidden = $('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; button.disabled = !scopa.canPlay(c) || !card || !!game.speech; button.setAttribute('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; })); } box.hidden = !options.length; [...box.children].forEach((b, k) => b.setAttribute('aria-pressed', String(c.choice?.selected === k))); $('scopa-next').hidden = c.step !== 'smazzata'; } $('tressette-controls').hidden = def?.type !== 'tressette'; $('tressette-controls').inert = !running; if (def?.type === 'tressette') { $('panel').hidden = $('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; button.hidden = !card; button.disabled = !ok.includes(i); button.setAttribute('aria-pressed', String(c.selected === i && !!card)); } for (const signal of tressette.SIGNALS) { $('tressette-' + signal).disabled = !mine || c.table.length > 0; $('tressette-' + signal).setAttribute('aria-pressed', String(c.signal === signal)); } $('tressette-next').hidden = c.step !== 'smazzata'; } if (def?.type === 'briscola') { $('panel').hidden = $('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; button.disabled = !briscola.canPlay(c) || !card || !!game.speech; button.setAttribute('aria-pressed', String(c.selected === i && !!card)); } } if (mg && !cards) { $('bocce-controls').setAttribute('aria-label', def.type === 'biliardo' ? 'Comandi del biliardo' : 'Comandi delle bocce'); $('panel').hidden = $('choices').hidden = true; const line = mg.status(c, def.names); if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line; $('bocce-power').value = Math.round(c.power * 100); $('bocce-power-value').value = `${Math.round(c.power * 100)}%`; for (const id of ['bocce-power', 'bocce-left', 'bocce-right', 'bocce-throw']) $(id).disabled = !mg.canThrow(c) || c.holding || !!game.speech; $('bocce-throw').textContent = c.step === 'result' ? 'Finita' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? `Tira ${def.names.cpu}…` : def.type === 'bocce' ? 'Lancia' : 'Tira'; $('bocce-help').hidden = def.type !== 'bocce'; $('biliardo-help').hidden = def.type !== 'biliardo'; if (!mg.canThrow(c)) c.aimLocked = false; // a ogni nuovo tiro la mira torna libera } if (mg) for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power }); // Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare. $('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2; $('pause-button').hidden = !running; $('spots-button').hidden = !running || !!game.closeup; $('notebook-button').hidden = !running || !story.notebook || !!game.closeup; $('closeup-close').hidden = !running || !game.closeup; $('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : mg ? c.step === 'result' ? mg.back : 'Lascia stare' : 'Chiudi'; $('panel').inert = !!c; canvas.classList.toggle('torch', !!torch); canvas.classList.toggle('bocce', !!mg); $('stage').classList.toggle('biliardo', !!c && story.closeups[c.id].type === 'biliardo'); $('stage').classList.toggle('briscola', !!cards); canvas.setAttribute('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; $('ending').hidden = false; } } let last = performance.now(), drawnStill = false, fade = 0, fadeScene = null; const FADE = 0.4; // secondi della dissolvenza fra le scene function frame(now) { // Il primo timestamp di requestAnimationFrame può precedere `last`: un dt negativo darebbe alla musica un volume // sotto zero, il browser lancerebbe un errore e il ciclo dei fotogrammi si fermerebbe (gioco congelato). const dt = Math.max(0, Math.min(0.1, (now - last) / 1000)); last = now; const screen = !$('title').hidden ? 'title' : !$('splash').hidden ? 'splash' : !$('intro').hidden || !$('rione').hidden ? 'intro' : !$('ending').hidden ? 'ending' : 'game'; document.body.classList.toggle('full', screen === 'title' || screen === 'splash' || screen === 'intro' || !$('credits').hidden); // Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa. const searching = game?.closeup?.id === 'fessura'; music.duck = searching ? game.closeup.found >= 2 ? 0.05 : 0.25 : game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1; music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id }), dt); // Un minuto senza toccare niente, a gioco fermo: pausa da sola. if (running && now - idleSince > IDLE_PAUSE && !game.speech && !game.walking && !game.queue.length) openPause(); ambience.configure({ active: screen === 'game' && running && !document.hidden && $('credits').hidden, muted: music.muted, duck: voices.playing ? 0.4 : 1 }); ambience.update(soundscapeFor(game?.state.scene, game?.closeup?.id), game?.phase()?.id, dt); // A gioco fermo (titolo, pausa, fine) la scena non cambia: un ultimo disegno e poi niente, per la batteria del // telefono. Si ridisegna solo se nel frattempo arriva lo sfondo che mancava. const still = game && !running && `${game.state.scene}|${hasBackground(game.scene.background)}`; if (game && (!still || still !== drawnStill)) { if (running) game.update(dt); render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() }); // Cambiando scena, un attimo di nero che sfuma: la dissolvenza dei vecchi giochi, invece del taglio netto. if (game.state.scene !== fadeScene) { if (fadeScene) fade = 1; fadeScene = game.state.scene; } if (fade > 0) { ctx.fillStyle = `rgba(0, 0, 0, ${fade})`; ctx.fillRect(0, 0, WIDTH, HEIGHT); fade = Math.max(0, fade - dt / FADE); } updateUi(); } drawnStill = still; requestAnimationFrame(frame); } $('new-game').addEventListener('click', () => { if (storage) clearSave(storage); chooseRione(); }); $('continue').addEventListener('click', () => start(readSave(storage))); // Ricomincia: stessa scelta di rione della partita appena finita. $('restart').addEventListener('click', () => { if (storage) clearSave(storage); start(null, Object.keys(game.state.flags).find(f => f.startsWith('rione:'))?.slice(6) ?? rione); }); // Dietro il titolo si vede già la piazza. // --- Cartello d'apertura «Manzolo (Poca) Art Presents»: 5 secondi (un clic o un tasto lo salta), poi il titolo. // Il titolo compare sotto il cartello quando questo comincia a sfumare (#splash.out in style.css): dissolvenza // incrociata, senza intravedere la piazza nel mezzo. I tempi partono da qui, non dal primo disegno della pagina. const SPLASH_FADE = 4300, SPLASH_END = 5000; function showSplash() { let done = false, revealed = false; 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(); }; // La musichetta: senza un gesto del giocatore il browser può non lasciarla partire, e allora il cartello resta muto. const jingle = story.music?.splash ? new Audio(versioned(story.music.splash)) : null; if (jingle) { jingle.volume = music.muted ? 0 : music.volume; jingle.play().catch(() => {}); window.jingle = jingle; // per i test nel browser } setTimeout(reveal, SPLASH_FADE); setTimeout(end, SPLASH_END); $('splash').addEventListener('click', end); addEventListener('keydown', end, { once: true }); } 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.'); });