// Il cuore dell'avventura, senza DOM: stato, comandi, coda delle azioni, dialoghi. // La storia (scene, oggetti, dialoghi) arriva tutta dai dati: qui non c'è nessun enigma scritto a mano. import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js'; export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop']; export const VERB_LABELS = { walk: 'Vai a', look: 'Guarda', take: 'Prendi', use: 'Usa', talk: 'Parla con', open: 'Apri', give: 'Dai', drop: 'Butta' }; const DEFAULT_LINES = { walk: null, look: 'Niente di speciale.', take: 'Non credo di poterlo prendere.', use: 'Non so proprio come usarlo.', talk: 'Non mi sembra un gran chiacchierone.', open: 'Non si apre.', give: 'Non credo che lo voglia.', drop: 'Non è roba mia da buttare.', with: 'Non funziona.', }; const HAVE_IT = 'Ce l\'ho già.', DROPPED = 'Questo non mi serve: via.', KEEP_IT = 'Meglio tenerlo: potrebbe servirmi.'; // Le battute che il motore dice da sé (quando la storia non ne ha una): anche loro hanno la voce (make voci). export const ENGINE_LINES = [...Object.values(DEFAULT_LINES).filter(Boolean), HAVE_IT, DROPPED, KEEP_IT]; const SPEED = 95; // Solo profilo, come i cicli di camminata: 'up' e 'down' lasciano il verso com'è. const FACING = { left: -1, right: 1 }; const WALK = ['walk_1', 'walk_2', 'walk_3', 'walk_4'], STEP = 15, REACH = 0.45; // Il primo piano di un libro (story.closeups, effetto `closeup`): copertina → frontespizio → una pagina girata → // l'ultima pagina, poi dopo una pausa il commento. Tempi in secondi. export const PAGE_TURN = 0.7, CLOSEUP_PAUSE = 1.4; // Condizioni: 'flag', '!flag', 'has:oggetto', '!has:oggetto', oppure un elenco (tutte vere). export function check(cond, state) { if (cond == null) return true; if (Array.isArray(cond)) return cond.every(c => check(c, state)); const not = cond.startsWith('!'), key = not ? cond.slice(1) : cond; const value = key.startsWith('has:') ? state.inventory.includes(key.slice(4)) : !!state.flags[key]; return not ? !value : value; } // Un oggetto è «piccolo» per il dito sotto i 24×24 pixel di scena: col tocco ha la precedenza sui vicini grandi. const SMALL_AREA = 24 * 24; // La miniatura di uno sfondo per la mappa (tools/miniature.py): assets/scenes/x.png -> assets/scenes/mini/x.png. export const miniature = src => src?.replace(/([^/]+)$/, 'mini/$1') ?? null; // Una riga dell'introduzione: un testo, oppure { rione: { id: testo } } con la riga del rione scelto. export const introText = (line, rione) => typeof line === 'string' ? line : line.rione[rione] ?? Object.values(line.rione)[0]; // I crediti con i nomi di ogni voce in ordine casuale: nessuno viene sempre per primo. export function shuffledCredits(credits, random = Math.random) { return credits.map(({ role, names }) => { const list = [...names]; for (let i = list.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [list[i], list[j]] = [list[j], list[i]]; } return { role, names: list }; }); } // La ricerca della mappa: ogni parola scritta deve stare nel nome del posto, senza badare a maiuscole, accenti e // apostrofi («camp» trova Campomigliaio, «de medici» trova Via de' Medici). const plain = text => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, ' ').trim(); export function matchPlace(name, query) { const words = plain(query).split(' ').filter(Boolean), hay = plain(name); return words.every(w => hay.includes(w)); } export const sayTime = text => Math.max(1.6, 0.9 + text.length * 0.055); export function newState(story) { const { scene, at } = story.start; return { scene, x: at[0], y: at[1], facing: 1, inventory: [], flags: {}, used: {}, ended: false }; } // Un salvataggio portato da fuori (Carica) deve tornare in tutto con la storia attuale, prima di prendere il posto di // quello del browser: a differenza di restoreState non si aggiusta niente. Restituisce cosa non va, o null. export function checkSave(story, data) { if (!data || typeof data !== 'object') return 'non è una partita'; if (!Object.hasOwn(story.scenes, data.scene)) return `scena sconosciuta: ${data.scene}`; if (!Number.isFinite(data.x) || !Number.isFinite(data.y)) return 'posizione mancante'; if (data.facing !== 1 && data.facing !== -1) return 'verso sbagliato'; if (!Array.isArray(data.inventory) || data.inventory.some(i => typeof i !== 'string' || !Object.hasOwn(story.items, i))) return 'oggetti sconosciuti'; for (const key of ['flags', 'used']) if (!data[key] || typeof data[key] !== 'object' || Array.isArray(data[key]) || Object.values(data[key]).some(v => v !== true)) return `${key} non validi`; if (typeof data.ended !== 'boolean') return 'fine non valida'; return null; } // Ripristina un salvataggio scartando tutto quello che la storia attuale non conosce più. export function restoreState(story, data) { const fresh = newState(story); if (!data || typeof data !== 'object' || !story.scenes[data.scene]) return fresh; const scene = story.scenes[data.scene]; const [x, y] = Number.isFinite(data.x) && Number.isFinite(data.y) ? nearestInside(scene.walk, [data.x, data.y]) : scene.entry ?? story.start.at; const inventory = Array.isArray(data.inventory) ? [...new Set(data.inventory.filter(i => story.items[i]))] : []; const pick = obj => Object.fromEntries(Object.entries(obj && typeof obj === 'object' ? obj : {}).filter(([, v]) => v === true)); return { scene: data.scene, x, y, facing: data.facing === -1 ? -1 : 1, inventory, flags: pick(data.flags), used: pick(data.used), ended: data.ended === true }; } export class Game { // voiceTime(who, text): secondi della voce registrata per la battuta, se c'è (il fumetto non finisce prima). constructor(story, { state, onSave, voiceTime } = {}) { this.story = story; this.voiceTime = voiceTime ?? (() => undefined); this.state = state ? restoreState(story, state) : newState(story); this.onSave = onSave ?? (() => {}); // Ultimo stato coerente: preso solo a sequenza finita, è quello che si salva e da cui si riprende. // Uscire a metà di una sequenza (una risposta, la porta che si apre) riporta qui, mai a un enigma risolto a metà. this.checkpoint = state ? this.snapshot() : null; this.dirty = false; this.queue = []; this.task = null; this.path = []; this.speech = null; this.dialogue = null; this.walking = false; this.time = 0; this.stride = 0; // strada fatta, per scegliere il passo della camminata this.hurry = false; // doppio clic: si cammina al doppio della velocità this.reach = 0; // secondi di braccio teso quando prende qualcosa this.back = false; // di spalle davanti a una porta o a una bacheca this.closeup = null; // il primo piano aperto: { id, step: 'cover' | 'title' | 'turn' | 'end', t, age } } get scene() { return this.story.scenes[this.state.scene]; } // Occupato: c'è qualcosa in corso che il giocatore non può interrompere. La camminata verso un punto // o verso un oggetto si può sempre cambiare con un altro clic: la coda che la seguiva viene scartata. get busy() { return !!this.speech || !!this.closeup || this.reach > 0 || (this.task ? !this.task.interruptible : this.queue.length > 0); } get idle() { return !this.busy && !this.task && !this.dialogue; } // Fermo: nessuna battuta, nessun effetto in coda, nessuna camminata che fa parte di una sequenza. get steady() { return !this.speech && !this.closeup && this.reach <= 0 && !this.queue.length && !(this.task && !this.task.interruptible); } // Lo stato da salvare adesso: quello attuale se è fermo, altrimenti l'ultimo punto fermo. savable() { return this.steady ? this.snapshot() : this.checkpoint; } // A sequenza finita, se qualcosa è cambiato, nuovo punto fermo e salvataggio. commit() { if (!this.dirty || !this.steady) return false; this.dirty = false; this.checkpoint = this.snapshot(); this.onSave(this.checkpoint); return true; } snapshot() { const { scene, x, y, facing, inventory, flags, used, ended } = this.state; return { scene, x: Math.round(x), y: Math.round(y), facing, inventory: [...inventory], flags: { ...flags }, used: { ...used }, ended }; } hotspots() { return (this.scene.hotspots ?? []).filter(h => check(h.when, this.state)); } hotspot(id) { return this.hotspots().find(h => h.id === id); } people() { return (this.scene.people ?? []).filter(p => check(p.when, this.state)); } // Col mouse vale il punto preciso (l'ultimo della lista sta sopra). Col dito, `reach` allarga l'area: prima gli // oggetti piccoli (la squama sotto la panchina) entro quella distanza, anche se il dito cade sull'oggetto grande // che li contiene; poi il punto preciso; poi l'oggetto più vicino entro `reach`. hotspotAt(p, reach = 0) { const list = this.hotspots(); const nearest = keep => { let best = null, bestD = Infinity; for (let i = list.length - 1; i >= 0; i--) { const d = keep(list[i]) ? boxDistance(list[i].box, p) : Infinity; if (d <= reach && d < bestD) { best = list[i]; bestD = d; } } return best; }; if (reach > 0) { const small = nearest(h => h.box[2] * h.box[3] <= SMALL_AREA); if (small) return small; } for (let i = list.length - 1; i >= 0; i--) if (inBox(list[i].box, p)) return list[i]; return reach > 0 ? nearest(() => true) : null; } itemName(id) { return this.story.items[id]?.name ?? id; } targetName(id) { return this.story.items[id] ? this.itemName(id) : this.hotspot(id)?.name ?? id; } defaultVerb(target) { if (this.story.items[target]) return 'look'; const h = this.hotspot(target); return h?.default ?? (h?.exit ? 'walk' : 'look'); } // Quale elenco di effetti produce "verbo + bersaglio (+ oggetto)": il punto unico della logica d'avventura. resolve(verb, target, item = null) { const items = this.story.items, h = items[target] ? null : this.hotspot(target); const def = h ?? items[target]; if (!def) return []; if (item) { const table = verb === 'give' ? 'give' : 'with'; const found = def[table]?.[item] ?? items[item]?.with?.[target]; if (found) return found; return [{ say: verb === 'give' ? DEFAULT_LINES.give : DEFAULT_LINES.with }]; } if (def.actions?.[verb]) return def.actions[verb]; if (verb === 'walk') return h?.exit ? [{ go: h.exit.scene, at: h.exit.at, face: h.exit.face }] : []; if (verb === 'take' && !h) return [{ say: HAVE_IT }]; // Si butta solo quello che non serve alla storia (`droppable`): gli oggetti indispensabili restano. if (verb === 'drop' && !h) return def.droppable ? [{ say: DROPPED }, { drop: target }] : [{ say: KEEP_IT }]; return DEFAULT_LINES[verb] ? [{ say: DEFAULT_LINES[verb] }] : []; } // Comandi dal giocatore. Restituiscono false se in quel momento vanno ignorati. walkTo(p) { if (this.busy || this.dialogue || this.state.ended) return false; this.queue = []; this.task = { walk: p, interruptible: true }; this.startWalk(p); return true; } act(verb, target, item = null) { if (this.busy || this.dialogue || this.state.ended) return false; const effects = this.resolve(verb, target, item); const h = this.story.items[target] ? null : this.hotspot(target); const steps = []; if (h) { const at = h.at ?? [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3]]; steps.push({ walk: at, interruptible: true }); if (h.face) steps.push({ face: h.face }); } this.task = null; this.path = []; this.walking = false; this.queue = [...steps, ...effects]; return true; } skip() { if (this.speech) { this.speech = null; return true; } return false; } // Il primo piano: un tocco sfoglia (copertina → frontespizio → si gira la pagina); all'ultima pagina non fa niente. closeupTap() { const c = this.closeup; if (!c || this.speech) return false; if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 }); else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 }); else return false; return true; } // Chiuso il libro si torna alla scena, e gli effetti che seguivano il primo piano ripartono. closeupClose() { const c = this.closeup; if (!c) return false; this.closeup = null; this.speech = null; // Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`). if (c.step === 'end') this.queue.unshift(...(this.story.closeups?.[c.id]?.closing ?? [])); return true; } options() { if (!this.dialogue || this.busy) return []; const node = this.story.dialogues[this.dialogue.id].nodes[this.dialogue.node]; return node.options.filter(o => check(o.when, this.state) && !(o.once && this.state.used[o.id])); } choose(index) { const option = this.options()[index]; if (!option) return false; if (option.once) { this.state.used[option.id] = true; this.dirty = true; } const next = option.next ?? this.dialogue.node; this.queue.push({ say: option.say ?? option.text, who: 'andrea' }, ...(option.then ?? []), next === 'end' ? { endDialogue: true } : { node: next }); return true; } startWalk(p) { this.path = findPath(this.scene.walk, [this.state.x, this.state.y], p); this.walking = this.path.length > 0; this.back = false; } // Il fotogramma di Andrea in questo istante: un passo ogni STEP pixel percorsi (a grandezza piena). pose() { if (this.reach > 0) return 'take'; if (this.walking) return WALK[Math.floor(this.stride / STEP) % WALK.length]; if (this.speech?.who === 'andrea') return Math.floor(this.time / 0.2) % 2 ? 'talk' : 'stand'; return this.back ? 'back' : 'stand'; } // Il fotogramma di un personaggio della scena (scene.people): quando parla alterna `talk`, o tiene la posa // chiesta dalla battuta ({ say, who, pose }); da fermo ogni `every` secondi fa `blink` per un attimo. personPose(p) { const idle = p.idle ?? 'sit'; if (this.speech?.who === p.who) { if (this.speech.pose) return this.speech.pose; const talk = p.talk ?? ['talk']; return talk[Math.floor(this.time / 0.3) % talk.length]; } const every = p.every ?? 4, t = (this.time + (p.offset ?? 0)) % every; return p.blink && t > every - 0.25 ? p.blink : idle; } scaleAt(y) { const d = this.scene.depth; if (!d) return 1; const [[y0, s0], [y1, s1]] = d, t = Math.max(0, Math.min(1, (y - y0) / (y1 - y0))); return s0 + (s1 - s0) * t; } speakerPoint(who) { // Nel primo piano Andrea parla da sopra il libro, non dalla scena oscurata dietro. if (this.closeup && (!who || who === 'andrea')) return [320, 34]; if (!who || who === 'andrea') return [this.state.x, this.state.y - 62 * this.scaleAt(this.state.y)]; if (this.scene.speakers?.[who]) return this.scene.speakers[who]; const h = this.hotspot(who) ?? (this.scene.hotspots ?? []).find(x => x.id === who); if (!h) return [this.state.x, this.state.y - 62]; return h.talkAt ?? [h.box[0] + h.box[2] / 2, h.box[1] - 4]; } update(dt) { this.time += dt; if (this.closeup) this.closeup.age += dt; if (this.speech) { this.speech.left -= dt; if (this.speech.left > 0) return; this.speech = null; } if (this.reach > 0) { this.reach -= dt; if (this.reach > 0) return; } if (this.walking) { const scale = this.scaleAt(this.state.y); let step = SPEED * (this.hurry ? 2 : 1) * scale * dt; this.stride += step / scale; while (step > 0 && this.path.length) { const target = this.path[0], d = dist([this.state.x, this.state.y], target); if (Math.abs(target[0] - this.state.x) > 0.5) this.state.facing = target[0] > this.state.x ? 1 : -1; if (d <= step) { [this.state.x, this.state.y] = target; this.path.shift(); step -= d; } else { this.state.x += (target[0] - this.state.x) / d * step; this.state.y += (target[1] - this.state.y) / d * step; step = 0; } } if (this.path.length) return; this.walking = false; this.task = null; } // Nel primo piano il tempo scorre solo per la pagina che gira e per la pausa prima del commento. const c = this.closeup; if (c) { c.t += dt; if (c.step === 'turn' && c.t >= PAGE_TURN) Object.assign(c, { step: 'end', t: 0 }); if (c.step === 'end' && !c.lines && c.t >= CLOSEUP_PAUSE) c.lines = [...(this.story.closeups?.[c.id]?.comment ?? [])]; if (c.lines?.length) this.run(c.lines.shift()); return; } // Esegue gli effetti istantanei di fila e si ferma al primo che richiede tempo. while (!this.speech && !this.walking && this.queue.length) this.run(this.queue.shift()); this.commit(); } run(e) { const s = this.state; this.dirty = true; if (e.say != null) { const who = e.who ?? 'andrea', voice = this.voiceTime(who, e.say); this.speech = { text: e.say, who, pose: e.pose, left: Math.max(sayTime(e.say), voice ? voice + 0.35 : 0) }; } else if (e.walk) { this.task = e; this.startWalk(e.walk); } // `go` prima di `face`: un cambio scena porta con sé il verso d'arrivo. else if (e.go) this.go(e.go, e.at, e.face); else if (e.face) { s.facing = FACING[e.face] ?? s.facing; this.back = e.face === 'up'; } else if (e.give) { if (!s.inventory.includes(e.give)) s.inventory.push(e.give); this.reach = REACH; this.back = false; } else if (e.drop) s.inventory = s.inventory.filter(i => i !== e.drop); else if (e.set) s.flags[e.set] = true; else if (e.unset) delete s.flags[e.unset]; else if (e.if !== undefined) this.queue.unshift(...(check(e.if, s) ? e.then ?? [] : e.else ?? [])); else if (e.dialogue) this.dialogue = { id: e.dialogue, node: 'start' }; else if (e.node) this.dialogue.node = e.node; else if (e.endDialogue) this.dialogue = null; else if (e.end) s.ended = true; else if (e.closeup) this.closeup = { id: e.closeup, step: 'cover', t: 0, age: 0 }; else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e)); } // La mappa: i posti già visti, con la posizione sulla pianta, e le strade che li collegano (uscite fra due posti visti). // La fase del giorno: l'ultima di story.phases la cui condizione è vera (la prima non ne ha: è quella di partenza). // Segue la storia, non l'orologio: il pomeriggio diventa tramonto con le impronte, e sera con la torcia. phase() { const phases = this.story.phases ?? []; return [...phases].reverse().find(p => check(p.when, this.state)) ?? phases[0] ?? null; } // Il taccuino: le tappe fatte e quella di adesso (la prima non fatta). notebook() { const steps = this.story.notebook ?? [], at = steps.findIndex(s => !check(s.done, this.state)); return { done: (at < 0 ? steps : steps.slice(0, at)).map(s => s.text), now: at < 0 ? null : steps[at].text }; } mapPlaces() { return Object.entries(this.story.scenes) .filter(([id, sc]) => sc.map && this.state.flags['visto:' + id]) .map(([id, sc]) => ({ id, name: sc.name, at: sc.map, here: id === this.state.scene, mini: miniature(sc.background) })); } mapRoads() { const seen = new Set(this.mapPlaces().map(p => p.id)), roads = new Map(); for (const id of seen) for (const h of this.story.scenes[id].hotspots ?? []) if (h.exit && seen.has(h.exit.scene) && h.exit.scene !== id) roads.set([id, h.exit.scene].sort().join('|'), [id, h.exit.scene].sort()); return [...roads.values()]; } // Viaggio dalla mappa: solo verso un posto già visto, e non mentre si parla, si cammina o si sceglie in un dialogo. travel(scene) { const sc = this.story.scenes[scene]; if (!sc?.map || !this.state.flags['visto:' + scene] || scene === this.state.scene) return false; if (this.busy || this.walking || this.dialogue || this.state.ended) return false; this.go(scene, sc.entry); return true; } go(scene, at, face) { if (!this.story.scenes[scene]) throw new Error('Scena sconosciuta: ' + scene); this.state.scene = scene; this.enteredAt = this.time; // per gli effetti che cominciano all'ingresso (le finestre che si accendono) [this.state.x, this.state.y] = at ?? this.scene.entry; this.state.facing = FACING[face] ?? this.state.facing; this.path = []; this.walking = false; this.back = false; this.state.flags['visto:' + scene] = true; // per la mappa this.queue.unshift(...(this.scene.enter ?? [])); this.dirty = true; // si salva quando l'ingresso nella scena è finito } }