Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
426 lines
22 KiB
JavaScript
426 lines
22 KiB
JavaScript
// 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
|
||
}
|
||
}
|