Files
leggenda-del-regolo/src/engine/game.js
T

365 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 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;
// 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 };
});
}
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
}
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.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.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: 'Ce l\'ho già.' }];
// Si butta solo quello che non serve alla storia (`droppable`): gli oggetti indispensabili restano.
if (verb === 'drop' && !h) return def.droppable ? [{ say: 'Questo non mi serve: via.' }, { drop: target }] : [{ say: 'Meglio tenerlo: potrebbe servirmi.' }];
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;
}
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) {
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.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;
}
// 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 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).
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.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
}
}