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