Prototipo del motore: scena, camminata, verbi, inventario, dialoghi, salvataggio

Motore guidato dai dati con due scene di prova (Piazza e salita alla Fortezza),
percorsi su area concava, clic intelligente e pressione lunga, test della soluzione
completa in node e nel browser con Playwright. Nel documento la leggenda del nonno.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 00:08:00 +02:00
co-authored by Claude Opus 5.5
parent 0c5cd240e4
commit 826f5f99ec
22 changed files with 1572 additions and 1 deletions
+229
View File
@@ -0,0 +1,229 @@
// 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, nearestInside } from './geometry.js';
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give'];
export const VERB_LABELS = { walk: 'Vai a', look: 'Guarda', take: 'Prendi', use: 'Usa', talk: 'Parla con', open: 'Apri', give: 'Dai' };
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.',
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 };
// 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;
}
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 };
}
// 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 {
constructor(story, { state, onSave } = {}) {
this.story = story;
this.state = state ? restoreState(story, state) : newState(story);
this.onSave = onSave ?? (() => {});
this.queue = [];
this.task = null;
this.path = [];
this.speech = null;
this.dialogue = null;
this.walking = false;
this.time = 0;
}
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.task ? !this.task.interruptible : this.queue.length > 0); }
get idle() { return !this.busy && !this.task && !this.dialogue; }
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); }
hotspotAt(p) {
const list = this.hotspots();
for (let i = list.length - 1; i >= 0; i--) if (inBox(list[i].box, p)) return list[i];
return 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à.' }];
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;
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;
}
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)];
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.walking) {
let step = SPEED * this.scaleAt(this.state.y) * dt;
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());
}
run(e) {
const s = this.state;
if (e.say != null) this.speech = { text: e.say, who: e.who ?? 'andrea', left: sayTime(e.say) };
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;
else if (e.give) { if (!s.inventory.includes(e.give)) s.inventory.push(e.give); }
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; this.onSave(this.snapshot()); }
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
}
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.queue.unshift(...(this.scene.enter ?? []));
this.onSave(this.snapshot());
}
}