Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApqNBvNbNCwXqR2RLo8QJh
538 lines
27 KiB
JavaScript
538 lines
27 KiB
JavaScript
// 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';
|
|
|
|
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);
|
|
// --- 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 });
|
|
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);
|
|
$('voice-volume').value = Math.round(voices.volume * 100);
|
|
}
|
|
for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, () => music.unlock(), { capture: true });
|
|
// Il muto zittisce subito anche la voce che sta parlando.
|
|
const toggleMute = () => { if (music.toggle()) voices.stop(); 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(); });
|
|
showSound();
|
|
window.music = music; // per i test nel browser
|
|
|
|
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);
|
|
|
|
// Una partita salvata (state) o nuova; la nuova col rione scelto, che resta nei flag (`rione:<id>`).
|
|
function start(state, rione = null) {
|
|
game = new Game(story, { state, onSave: data => storage && writeSave(storage, data), voiceTime: (who, text) => voices.seconds(who, text) });
|
|
command = new Command(game);
|
|
if (!state && rione) game.state.flags['rione:' + rione] = true;
|
|
if (!state) game.go(story.start.scene, story.start.at);
|
|
$('title').hidden = true;
|
|
$('ending').hidden = true;
|
|
running = true;
|
|
window.game = game; // per i test nel browser e per curiosare dalla console
|
|
}
|
|
|
|
// --- Introduzione: le righe di story.intro una alla volta sopra il panorama; poi comincia la partita.
|
|
// --- Il rione: dopo «Nuova partita», per chi corre Andrea (story.rioni); poi l'introduzione con la sua riga.
|
|
let rione = null;
|
|
$('rione-buttons').replaceChildren(...Object.entries(story.rioni ?? {}).map(([id, r]) => {
|
|
const b = document.createElement('button');
|
|
b.type = 'button';
|
|
b.dataset.rione = id;
|
|
b.style.setProperty('--rione', r.color);
|
|
b.innerHTML = '<span class="rione-name"></span><span class="rione-colors"></span>';
|
|
b.querySelector('.rione-name').textContent = r.name;
|
|
b.querySelector('.rione-colors').textContent = r.locked ? 'prossimamente' : r.colors;
|
|
// Un rione chiuso si vede ma non si sceglie (per ora si corre solo per Campomigliaio).
|
|
if (r.locked) { b.disabled = true; b.classList.add('locked'); b.title = 'Prossimamente'; }
|
|
b.addEventListener('click', () => { rione = id; $('rione').hidden = true; showIntro(); });
|
|
return b;
|
|
}));
|
|
function chooseRione() {
|
|
if (!story.rioni) return showIntro();
|
|
$('title').hidden = true;
|
|
$('rione').hidden = false;
|
|
}
|
|
|
|
let introLine = -1;
|
|
function showIntro() {
|
|
if (!story.intro?.length) return start(null, rione);
|
|
$('title').hidden = true;
|
|
$('intro').hidden = false;
|
|
introLine = -1;
|
|
nextIntro();
|
|
}
|
|
function nextIntro() {
|
|
introLine++;
|
|
if (introLine >= story.intro.length) { voices.stop(); $('intro').hidden = true; return start(null, rione); }
|
|
const p = $('intro-text'), text = introText(story.intro[introLine], rione);
|
|
p.textContent = text;
|
|
say('andrea', text);
|
|
p.style.animation = 'none';
|
|
void p.offsetWidth; // riavvia la comparsa
|
|
p.style.animation = '';
|
|
}
|
|
$('intro').addEventListener('click', e => { if (e.target !== $('skip-intro')) nextIntro(); });
|
|
$('skip-intro').addEventListener('click', () => { introLine = story.intro.length; nextIntro(); });
|
|
|
|
// --- Crediti: costruiti da story.credits, con i nomi di ogni voce rimescolati a ogni apertura; si aprono dal titolo
|
|
// e dalla fine, Chiudi o Esc tornano indietro.
|
|
function openCredits() {
|
|
$('credits-list').replaceChildren(...shuffledCredits(story.credits ?? []).flatMap(({ role, names }) => {
|
|
const dt = document.createElement('dt'), dd = document.createElement('dd');
|
|
dt.textContent = role;
|
|
dd.replaceChildren(...names.map(n => Object.assign(document.createElement('span'), { textContent: n })));
|
|
return [dt, dd];
|
|
}));
|
|
$('credits').hidden = false;
|
|
}
|
|
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) 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 = 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-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'), 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;
|
|
music.hold = true;
|
|
voices.pause();
|
|
command.reset();
|
|
$('pause-map').hidden = 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);
|
|
// 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) {
|
|
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; });
|
|
|
|
// --- 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;
|
|
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;
|
|
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;
|
|
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; });
|
|
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;
|
|
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 = item.icon.src;
|
|
icon.alt = '';
|
|
} else {
|
|
icon = document.createElement('i');
|
|
icon.style.background = item.icon?.fill ?? '#ccc';
|
|
}
|
|
b.append(icon, 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;
|
|
}));
|
|
// 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 === $('volume') || e.target === $('voice-volume')) 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 (!$('credits').hidden) { if (e.key === 'Escape') $('credits').hidden = true; return; }
|
|
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; }
|
|
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
|
|
if ((e.key === 'p' || e.key === 'P') && running) { openMap(); return; }
|
|
if ((e.key === 'h' || e.key === 'H') && running) { showSpots(); 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);
|
|
});
|
|
|
|
// --- 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 text = !running || game.speech || game.dialogue ? '' : command.sentence(hoverTarget);
|
|
if ($('sentence').textContent !== text) $('sentence').textContent = text;
|
|
updateInventory();
|
|
updateChoices();
|
|
updateSpeech();
|
|
// 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.mapPlaces().length < 2;
|
|
$('pause-button').hidden = !running;
|
|
$('spots-button').hidden = !running;
|
|
if (game.state.ended && !game.speech && !game.queue.length) {
|
|
running = false;
|
|
$('ending').hidden = false;
|
|
}
|
|
}
|
|
|
|
let last = performance.now(), drawnStill = false;
|
|
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);
|
|
music.duck = voices.playing ? 0.45 : 1;
|
|
music.update(trackFor(story, { screen, scene: game?.state.scene }), 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();
|
|
// 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() });
|
|
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 (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, (storage && readSave(storage))?.scene ?? story.start.scene).then(ok => { if (ok.includes(false)) console.warn('Alcune immagini non sono state caricate.'); });
|