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:
@@ -2,3 +2,4 @@ assets/raw/
|
|||||||
prompts/
|
prompts/
|
||||||
_site/
|
_site/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
node_modules/
|
||||||
|
|||||||
@@ -6,3 +6,29 @@ Il drappo con il Regolo è sparito: un ragazzo del paese deve ritrovarlo prima d
|
|||||||
*Al Lele, che mi ha fatto conoscere Monkey Island.*
|
*Al Lele, che mi ha fatto conoscere Monkey Island.*
|
||||||
|
|
||||||
Progetto in fase di ideazione: il documento di progetto è in [`docs/PROGETTO.md`](docs/PROGETTO.md).
|
Progetto in fase di ideazione: il documento di progetto è in [`docs/PROGETTO.md`](docs/PROGETTO.md).
|
||||||
|
Per ora c'è un prototipo del motore con due scene di prova e grafica provvisoria.
|
||||||
|
|
||||||
|
## Giocare in locale
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
python3 -m http.server 8000 # poi http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Clic = azione più sensata (parla, guarda, vai); clic destro o pressione lunga = guarda; un verbo scelto vale per il
|
||||||
|
clic successivo. Spazio salta la battuta, i numeri scelgono nel dialogo, Esc annulla il verbo.
|
||||||
|
|
||||||
|
## Struttura
|
||||||
|
|
||||||
|
- `src/engine/`: motore senza storia (`game.js` logica e coda delle azioni, `geometry.js` area calpestabile e
|
||||||
|
percorsi, `command.js` verbi e clic, `render.js` disegno, `storage.js` salvataggio);
|
||||||
|
- `src/story/`: la storia come dati (`story.js`) e la sua soluzione completa (`solution.js`);
|
||||||
|
- `src/main.js`: collega tutto alla pagina.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
npm test # logica, storia coerente, soluzione percorsa dall'inizio alla fine
|
||||||
|
npm install && npx playwright install chromium-headless-shell
|
||||||
|
npm run browser # partita completa con clic veri in Chromium, salvataggio, telefono
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -44,7 +44,10 @@ Andrea vive a **Cardetole** (i verdi), ma nel 2007 corre per **Campomigliaio** (
|
|||||||
amico, l'**Arcu**. Decide di risolvere il mistero prima della festa. Per arrivare alla
|
amico, l'**Arcu**. Decide di risolvere il mistero prima della festa. Per arrivare alla
|
||||||
Fortezza, chiusa da anni, deve farsi aiutare da tutti e quattro i rioni: e ognuno vuole qualcosa in cambio.
|
Fortezza, chiusa da anni, deve farsi aiutare da tutti e quattro i rioni: e ognuno vuole qualcosa in cambio.
|
||||||
|
|
||||||
**Finale (idea):** nei sotterranei il Regolo esiste davvero, ma non è il mostro della leggenda. È un "piccolo re"
|
**Finale (idea 2, dal ricordo del nonno):** vedi «La voce di famiglia» qui sotto: la leggenda l'avrebbe messa in
|
||||||
|
giro il nonno di Andrea per tenersi i funghi della Fortezza. Le due idee possono stare insieme.
|
||||||
|
|
||||||
|
**Finale (idea 1):** nei sotterranei il Regolo esiste davvero, ma non è il mostro della leggenda. È un "piccolo re"
|
||||||
permaloso che da secoli compare sul drappo e non è mai stato invitato alla festa. Si è preso il drappo per farsi
|
permaloso che da secoli compare sul drappo e non è mai stato invitato alla festa. Si è preso il drappo per farsi
|
||||||
notare. Andrea lo convince a restituirlo… e il Regolo, per la prima volta, assiste al Palio dalle mura.
|
notare. Andrea lo convince a restituirlo… e il Regolo, per la prima volta, assiste al Palio dalle mura.
|
||||||
[Da rivedere: è solo una proposta, il colpo di scena giusto può venire dai ricordi di Andrea.]
|
[Da rivedere: è solo una proposta, il colpo di scena giusto può venire dai ricordi di Andrea.]
|
||||||
@@ -56,6 +59,13 @@ notare. Andrea lo convince a restituirlo… e il Regolo, per la prima volta, ass
|
|||||||
catturarono; un'altra che cadde dal cielo mentre si scavavano le fondamenta. → Due versioni che si
|
catturarono; un'altra che cadde dal cielo mentre si scavavano le fondamenta. → Due versioni che si
|
||||||
contraddicono: ottime per personaggi che litigano su "come è andata veramente".
|
contraddicono: ottime per personaggi che litigano su "come è andata veramente".
|
||||||
- **Il Regolo della sfilata**: nel corteo sfila un Regolo costruito dai ragazzi a scuola (ricordo di Andrea).
|
- **Il Regolo della sfilata**: nel corteo sfila un Regolo costruito dai ragazzi a scuola (ricordo di Andrea).
|
||||||
|
- **La voce di famiglia** (ricordo di Andrea, mai verificato ma riferito da tanti): il nonno di Andrea,
|
||||||
|
**Settimo Villani** [DA ANDREA: confermare il nome], che visse nella Fortezza, avrebbe inventato lui la leggenda
|
||||||
|
del serpente pericoloso per tenere lontana la gente e prendersi i funghi da solo.
|
||||||
|
→ Possibile **colpo di scena** del finale: nei sotterranei Andrea scopre le tracce del nonno (un cesto, un
|
||||||
|
cartello «ATTENTI AL REGOLO» scritto a mano, il posto dei funghi) e capisce chi ha "inventato" il Regolo.
|
||||||
|
Da decidere [DA ANDREA]: se il Regolo resta comunque vero (il nonno l'ha inventato… o l'aveva incontrato?),
|
||||||
|
se il nonno compare (ricordo, fantasma bonario, voce) o solo i suoi segni.
|
||||||
- **Il soldato Regolo**: un giovane soldato di guardia, tradito e murato dai compagni; nelle notti di luna piena si
|
- **Il soldato Regolo**: un giovane soldato di guardia, tradito e murato dai compagni; nelle notti di luna piena si
|
||||||
sentono i suoi passi. → Il fantasma che aiuta Andrea nei sotterranei (o che lo spaventa a morte, per ridere).
|
sentono i suoi passi. → Il fantasma che aiuta Andrea nei sotterranei (o che lo spaventa a morte, per ridere).
|
||||||
|
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#1b1420"><meta name="description" content="La Leggenda del Regolo: un'avventura punta-e-clicca a San Piero a Sieve, durante il Palio della Fortezza del 2007.">
|
||||||
|
<title>La Leggenda del Regolo</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="stage">
|
||||||
|
<canvas id="scene" width="640" height="360" aria-label="La scena dell'avventura"></canvas>
|
||||||
|
<div id="speech" aria-live="polite"></div>
|
||||||
|
<section id="title" class="overlay">
|
||||||
|
<h1>La Leggenda<br>del <span>Regolo</span></h1>
|
||||||
|
<p class="subtitle">San Piero a Sieve · Palio della Fortezza · 2007</p>
|
||||||
|
<div class="buttons"><button id="continue" hidden>Continua</button><button id="new-game">Nuova partita</button></div>
|
||||||
|
<p class="dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
|
||||||
|
</section>
|
||||||
|
<section id="ending" class="overlay" hidden>
|
||||||
|
<h2>Fine del prototipo</h2>
|
||||||
|
<p>Il Regolo è passato di qui. Il resto della storia è ancora da scrivere.</p>
|
||||||
|
<p class="dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
|
||||||
|
<div class="buttons"><button id="restart">Ricomincia</button></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div id="side">
|
||||||
|
<div id="sentence" aria-live="polite"></div>
|
||||||
|
<div id="panel">
|
||||||
|
<div id="verbs"></div>
|
||||||
|
<div id="inventory" aria-label="Inventario"></div>
|
||||||
|
</div>
|
||||||
|
<ol id="choices" hidden></ol>
|
||||||
|
</div>
|
||||||
|
<p id="rotate">Gira il telefono: in orizzontale si gioca meglio.</p>
|
||||||
|
</div>
|
||||||
|
<div id="error" hidden role="alert"></div>
|
||||||
|
<script type="module">
|
||||||
|
import('./src/main.js').catch(error => {
|
||||||
|
const panel = document.getElementById('error');
|
||||||
|
panel.textContent = 'Avvio non riuscito: apri il gioco da un server statico (python3 -m http.server) e ricarica. ' + error.message;
|
||||||
|
panel.hidden = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+44
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "leggenda-del-regolo",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "leggenda-del-regolo",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"playwright": "^1.64.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.64.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "leggenda-del-regolo",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
|
"browser": "node tests/browser.mjs",
|
||||||
|
"serve": "python3 -m http.server 8000"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"playwright": "^1.64.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// La "frase" in costruzione sotto la scena (Usa squama con…) e cosa fa ogni clic.
|
||||||
|
// Senza verbo scelto vale il clic intelligente: l'azione più sensata per quel punto.
|
||||||
|
import { VERB_LABELS } from './game.js';
|
||||||
|
|
||||||
|
export class Command {
|
||||||
|
constructor(game) {
|
||||||
|
this.game = game;
|
||||||
|
this.verb = null;
|
||||||
|
this.item = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() { this.verb = null; this.item = null; }
|
||||||
|
|
||||||
|
pickVerb(verb) {
|
||||||
|
this.verb = this.verb === verb ? null : verb;
|
||||||
|
this.item = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Testo della riga sotto la scena, con l'eventuale bersaglio sotto il puntatore.
|
||||||
|
sentence(target = null) {
|
||||||
|
const g = this.game, name = target && g.targetName(target);
|
||||||
|
if (this.item) return `${VERB_LABELS[this.verb]} ${g.itemName(this.item)} ${this.verb === 'give' ? 'a' : 'con'}${name ? ' ' + name : '…'}`;
|
||||||
|
if (this.verb) return `${VERB_LABELS[this.verb]}${name ? ' ' + name : '…'}`;
|
||||||
|
if (!target) return '';
|
||||||
|
const isItem = !!g.story.items[target];
|
||||||
|
return isItem ? `Usa ${name} con…` : `${VERB_LABELS[g.defaultVerb(target)]} ${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clic sulla scena: `look` per il clic destro o la pressione lunga.
|
||||||
|
clickScene(point, { look = false } = {}) {
|
||||||
|
const g = this.game;
|
||||||
|
if (g.skip()) return 'skip';
|
||||||
|
if (g.dialogue || g.busy) return null;
|
||||||
|
const h = g.hotspotAt(point);
|
||||||
|
let done;
|
||||||
|
if (look && h) done = g.act('look', h.id);
|
||||||
|
else if (!h) done = g.walkTo(point);
|
||||||
|
else if (this.item) done = g.act(this.verb, h.id, this.item);
|
||||||
|
else if (this.verb) done = g.act(this.verb, h.id);
|
||||||
|
else done = g.act(g.defaultVerb(h.id), h.id);
|
||||||
|
this.reset();
|
||||||
|
return done ? (h ? 'act' : 'walk') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
clickItem(id, { look = false } = {}) {
|
||||||
|
const g = this.game;
|
||||||
|
if (g.skip()) return 'skip';
|
||||||
|
if (g.dialogue || g.busy) return null;
|
||||||
|
if (look) { this.reset(); return g.act('look', id) && 'act'; }
|
||||||
|
if (this.item) {
|
||||||
|
const ok = this.item !== id && g.act('use', id, this.item);
|
||||||
|
this.reset();
|
||||||
|
return ok ? 'act' : null;
|
||||||
|
}
|
||||||
|
if (!this.verb || this.verb === 'use' || this.verb === 'give') {
|
||||||
|
this.verb ??= 'use';
|
||||||
|
this.item = id;
|
||||||
|
return 'select';
|
||||||
|
}
|
||||||
|
const ok = g.act(this.verb, id);
|
||||||
|
this.reset();
|
||||||
|
return ok && 'act';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Area calpestabile: un poligono semplice (anche concavo) in coordinate di scena.
|
||||||
|
// Il percorso passa per i vertici concavi (grafo di visibilità + A*), così il personaggio
|
||||||
|
// gira attorno agli angoli invece di attraversare i muri.
|
||||||
|
const EPS = 1e-6;
|
||||||
|
|
||||||
|
export const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||||
|
|
||||||
|
function onSegment(p, a, b, tol = 0.5) {
|
||||||
|
const [px, py] = p, [ax, ay] = a, [bx, by] = b;
|
||||||
|
const len2 = (bx - ax) ** 2 + (by - ay) ** 2;
|
||||||
|
const t = len2 ? Math.max(0, Math.min(1, ((px - ax) * (bx - ax) + (py - ay) * (by - ay)) / len2)) : 0;
|
||||||
|
return dist(p, [ax + t * (bx - ax), ay + t * (by - ay)]) <= tol;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dentro o sul bordo (con tolleranza di mezzo pixel).
|
||||||
|
export function inside(poly, p) {
|
||||||
|
let hit = false;
|
||||||
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||||
|
const a = poly[i], b = poly[j];
|
||||||
|
if (onSegment(p, a, b)) return true;
|
||||||
|
if ((a[1] > p[1]) !== (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0]) hit = !hit;
|
||||||
|
}
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||||
|
|
||||||
|
// Incrocio proprio: i due segmenti si tagliano in un punto interno a entrambi.
|
||||||
|
function crosses(p1, p2, q1, q2) {
|
||||||
|
const d1 = cross(q1, q2, p1), d2 = cross(q1, q2, p2), d3 = cross(p1, p2, q1), d4 = cross(p1, p2, q2);
|
||||||
|
return ((d1 > EPS && d2 < -EPS) || (d1 < -EPS && d2 > EPS)) && ((d3 > EPS && d4 < -EPS) || (d3 < -EPS && d4 > EPS));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visible(poly, a, b) {
|
||||||
|
if (!inside(poly, a) || !inside(poly, b)) return false;
|
||||||
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++)
|
||||||
|
if (crosses(a, b, poly[i], poly[j])) return false;
|
||||||
|
// Un segmento che passa per due vertici può uscire senza incrociare nessun lato: si campiona.
|
||||||
|
for (let k = 1; k < 8; k++) {
|
||||||
|
const t = k / 8;
|
||||||
|
if (!inside(poly, [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nearestInside(poly, p) {
|
||||||
|
if (inside(poly, p)) return [p[0], p[1]];
|
||||||
|
let best = null, bestD = Infinity;
|
||||||
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||||
|
const [ax, ay] = poly[j], [bx, by] = poly[i];
|
||||||
|
const len2 = (bx - ax) ** 2 + (by - ay) ** 2;
|
||||||
|
const t = len2 ? Math.max(0, Math.min(1, ((p[0] - ax) * (bx - ax) + (p[1] - ay) * (by - ay)) / len2)) : 0;
|
||||||
|
const q = [ax + t * (bx - ax), ay + t * (by - ay)], d = dist(p, q);
|
||||||
|
if (d < bestD) { bestD = d; best = q; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedArea(poly) {
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) s += poly[j][0] * poly[i][1] - poly[i][0] * poly[j][1];
|
||||||
|
return s / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// I vertici concavi (svolta contraria al verso del poligono) sono gli unici dove un percorso minimo può piegare.
|
||||||
|
export function concaveVertices(poly) {
|
||||||
|
const sign = Math.sign(signedArea(poly)), n = poly.length;
|
||||||
|
return poly.filter((v, i) => sign * cross(poly[(i - 1 + n) % n], v, poly[(i + 1) % n]) < -EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPath(poly, from, to) {
|
||||||
|
const start = nearestInside(poly, from), goal = nearestInside(poly, to);
|
||||||
|
if (visible(poly, start, goal)) return [goal];
|
||||||
|
const nodes = [start, goal, ...concaveVertices(poly)];
|
||||||
|
const g = new Map([[0, 0]]), prev = new Map(), open = new Set([0]), done = new Set();
|
||||||
|
while (open.size) {
|
||||||
|
let cur = -1, curF = Infinity;
|
||||||
|
for (const i of open) {
|
||||||
|
const f = g.get(i) + dist(nodes[i], goal);
|
||||||
|
if (f < curF) { curF = f; cur = i; }
|
||||||
|
}
|
||||||
|
if (cur === 1) break;
|
||||||
|
open.delete(cur);
|
||||||
|
done.add(cur);
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
if (i === cur || done.has(i) || !visible(poly, nodes[cur], nodes[i])) continue;
|
||||||
|
const cost = g.get(cur) + dist(nodes[cur], nodes[i]);
|
||||||
|
if (cost < (g.get(i) ?? Infinity)) { g.set(i, cost); prev.set(i, cur); open.add(i); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!prev.has(1)) return [start];
|
||||||
|
const path = [];
|
||||||
|
for (let i = 1; i !== 0; i = prev.get(i)) path.unshift(nodes[i]);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inBox = ([x, y, w, h], p) => p[0] >= x && p[0] <= x + w && p[1] >= y && p[1] <= y + h;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Disegno della scena su un canvas 640×360, ingrandito dal CSS senza sfocatura.
|
||||||
|
// Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art.
|
||||||
|
export const WIDTH = 640, HEIGHT = 360;
|
||||||
|
|
||||||
|
function drawShape(ctx, s) {
|
||||||
|
if (s.rect) {
|
||||||
|
ctx.fillStyle = s.fill;
|
||||||
|
ctx.fillRect(...s.rect);
|
||||||
|
} else if (s.poly) {
|
||||||
|
ctx.fillStyle = s.fill;
|
||||||
|
ctx.beginPath();
|
||||||
|
s.poly.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
} else if (s.lines) {
|
||||||
|
// Righe di riempimento: fughe del selciato o sbarre di un cancello.
|
||||||
|
const { from: [x0, y0], to: [x1, y1], step, vertical } = s.lines;
|
||||||
|
ctx.fillStyle = s.stroke;
|
||||||
|
if (vertical) for (let x = x0; x <= x1; x += step) ctx.fillRect(x, y0, 2, y1 - y0);
|
||||||
|
else for (let y = y0; y <= y1; y += step) ctx.fillRect(x0, y, x1 - x0, 1);
|
||||||
|
} else if (s.text) {
|
||||||
|
ctx.fillStyle = s.fill;
|
||||||
|
ctx.font = 'bold 11px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(s.text, ...s.at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Andrea provvisorio, disegnato a blocchi: maglia blu (il rione di Campomigliaio), gambe che si alternano.
|
||||||
|
const BODY = [
|
||||||
|
// [x, y, w, h, colore] con l'origine ai piedi, rivolto a destra.
|
||||||
|
[-6, -60, 12, 4, '#4a2e1c'], [-6, -56, 12, 12, '#e8b48c'], [3, -53, 2, 2, '#2a1a10'], [-7, -60, 4, 10, '#4a2e1c'],
|
||||||
|
[-8, -44, 16, 20, '#2f5fb3'], [-4, -42, 8, 3, '#f4efe4'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function drawAndrea(ctx, x, y, scale, facing, walkPhase) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(Math.round(x), Math.round(y));
|
||||||
|
ctx.scale(scale * facing, scale);
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.22)';
|
||||||
|
ctx.fillRect(-10, -2, 20, 4);
|
||||||
|
const swing = walkPhase == null ? 0 : Math.round(Math.sin(walkPhase) * 5);
|
||||||
|
ctx.fillStyle = '#3a3f52';
|
||||||
|
ctx.fillRect(-6 + swing, -24, 5, 22);
|
||||||
|
ctx.fillRect(1 - swing, -24, 5, 22);
|
||||||
|
ctx.fillStyle = '#e8e3d6';
|
||||||
|
ctx.fillRect(-7 + swing, -3, 7, 3);
|
||||||
|
ctx.fillRect(0 - swing, -3, 7, 3);
|
||||||
|
for (const [bx, by, w, h, c] of BODY) {
|
||||||
|
ctx.fillStyle = c;
|
||||||
|
ctx.fillRect(bx, by, w, h);
|
||||||
|
}
|
||||||
|
ctx.fillStyle = '#e8b48c';
|
||||||
|
ctx.fillRect(-2 - swing, -40, 4, 16);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render(ctx, game, { hover } = {}) {
|
||||||
|
const scene = game.scene, s = game.state;
|
||||||
|
ctx.clearRect(0, 0, WIDTH, HEIGHT);
|
||||||
|
for (const shape of scene.art ?? []) drawShape(ctx, shape);
|
||||||
|
for (const h of game.hotspots()) for (const shape of h.art ?? []) drawShape(ctx, shape);
|
||||||
|
const phase = game.walking ? (s.x + s.y) / 6 : null;
|
||||||
|
drawAndrea(ctx, s.x, s.y, game.scaleAt(s.y), s.facing, phase);
|
||||||
|
if (hover) {
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
||||||
|
ctx.setLineDash([3, 3]);
|
||||||
|
ctx.strokeRect(...hover.box);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Salvataggio nel browser: ogni accesso può fallire (navigazione privata, spazio pieno), e allora si gioca senza.
|
||||||
|
const KEY = 'leggenda-regolo-save';
|
||||||
|
|
||||||
|
export function readSave(storage) {
|
||||||
|
try {
|
||||||
|
const raw = storage.getItem(KEY);
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSave(storage, data) {
|
||||||
|
try {
|
||||||
|
storage.setItem(KEY, JSON.stringify(data));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSave(storage) {
|
||||||
|
try {
|
||||||
|
storage.removeItem(KEY);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
// Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti).
|
||||||
|
import { story } from './story/story.js';
|
||||||
|
import { Game, VERBS, VERB_LABELS } from './engine/game.js';
|
||||||
|
import { Command } from './engine/command.js';
|
||||||
|
import { render, WIDTH, HEIGHT } from './engine/render.js';
|
||||||
|
import { readSave, writeSave, clearSave } from './engine/storage.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;
|
||||||
|
const debug = new URLSearchParams(location.search).has('debug');
|
||||||
|
|
||||||
|
let game = null, command = null, hoverTarget = null, mouse = null, running = false;
|
||||||
|
|
||||||
|
function start(state) {
|
||||||
|
game = new Game(story, { state, onSave: data => storage && writeSave(storage, data) });
|
||||||
|
command = new Command(game);
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTitle() {
|
||||||
|
running = false;
|
||||||
|
const saved = storage && readSave(storage);
|
||||||
|
$('continue').hidden = !saved || saved.ended;
|
||||||
|
$('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];
|
||||||
|
};
|
||||||
|
let press = null;
|
||||||
|
canvas.addEventListener('pointerdown', e => {
|
||||||
|
if (!running || e.button > 0) return;
|
||||||
|
const point = scenePoint(e);
|
||||||
|
press = { point, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true }); }, LONG_PRESS) };
|
||||||
|
});
|
||||||
|
canvas.addEventListener('pointerup', e => {
|
||||||
|
if (!press) return;
|
||||||
|
clearTimeout(press.timer);
|
||||||
|
if (!press.long) command.clickScene(scenePoint(e));
|
||||||
|
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;
|
||||||
|
inventoryKey = key;
|
||||||
|
const box = $('inventory');
|
||||||
|
box.replaceChildren(...game.state.inventory.map(id => {
|
||||||
|
const b = document.createElement('button'), item = story.items[id], dot = document.createElement('i');
|
||||||
|
b.type = 'button';
|
||||||
|
b.dataset.item = id;
|
||||||
|
b.setAttribute('aria-pressed', String(command.item === id));
|
||||||
|
dot.style.background = item.icon?.fill ?? '#ccc';
|
||||||
|
b.append(dot, 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;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 (!running) return;
|
||||||
|
if ([' ', '.', 'Enter'].includes(e.key) && game.speech) { e.preventDefault(); game.skip(); return; }
|
||||||
|
if (e.key === 'Escape') command.reset();
|
||||||
|
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;
|
||||||
|
if (!sp) { if (box.textContent) box.textContent = ''; return; }
|
||||||
|
const [x, y] = game.speakerPoint(sp.who);
|
||||||
|
box.textContent = sp.text;
|
||||||
|
box.style.color = story.characters[sp.who]?.color ?? '#fff';
|
||||||
|
box.style.left = Math.min(77, Math.max(23, x / WIDTH * 100)) + '%';
|
||||||
|
box.style.top = Math.max(18, y / HEIGHT * 100) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
if (game.state.ended && !game.speech && !game.queue.length) {
|
||||||
|
running = false;
|
||||||
|
$('ending').hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let last = performance.now();
|
||||||
|
function frame(now) {
|
||||||
|
const dt = Math.min(0.1, (now - last) / 1000);
|
||||||
|
last = now;
|
||||||
|
if (game) {
|
||||||
|
if (running) game.update(dt);
|
||||||
|
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null });
|
||||||
|
updateUi();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('new-game').addEventListener('click', () => { if (storage) clearSave(storage); start(null); });
|
||||||
|
$('continue').addEventListener('click', () => start(readSave(storage)));
|
||||||
|
$('restart').addEventListener('click', () => { if (storage) clearSave(storage); start(null); });
|
||||||
|
|
||||||
|
// Dietro il titolo si vede già la piazza.
|
||||||
|
game = new Game(story);
|
||||||
|
command = new Command(game);
|
||||||
|
showTitle();
|
||||||
|
requestAnimationFrame(frame);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// La soluzione completa, passo per passo: la percorre tests/solution.test.mjs ad ogni modifica della storia.
|
||||||
|
// Passi: [verbo, bersaglio, oggetto?] oppure ['choose', testo dell'opzione].
|
||||||
|
export const solution = [
|
||||||
|
['look', 'asta'],
|
||||||
|
['talk', 'vecchietti'],
|
||||||
|
['choose', 'Avete notato niente di strano stanotte?'],
|
||||||
|
['choose', 'Ci vediamo dopo.'],
|
||||||
|
['take', 'cosa_luccicante'],
|
||||||
|
['walk', 'verso_salita'],
|
||||||
|
['use', 'impronte', 'squama'],
|
||||||
|
];
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Storia del prototipo: due scene di prova per collaudare il motore.
|
||||||
|
// Testi, personaggi ed enigmi sono provvisori e verranno sostituiti con i ricordi di Andrea (docs/PROGETTO.md).
|
||||||
|
// La grafica è fatta di forme semplici (`art`): verrà rimpiazzata dagli sfondi in pixel art.
|
||||||
|
|
||||||
|
const C = {
|
||||||
|
sky: '#9fd0e8', skyLow: '#d9ecd9', hill: '#6f9b55', hillDark: '#557a43', fort: '#a08a6a', fortDark: '#7d6a50',
|
||||||
|
wall: '#d9b98c', wallDark: '#b8946a', roof: '#a5533b', stone: '#c9b79a', stoneDark: '#ad9b7f', door: '#5b3a28',
|
||||||
|
wood: '#7a5132', green: '#3f7d3a', iron: '#3b3b44', white: '#f4efe4', red: '#b7342e',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const story = {
|
||||||
|
title: 'La Leggenda del Regolo',
|
||||||
|
start: { scene: 'piazza', at: [120, 300] },
|
||||||
|
characters: {
|
||||||
|
andrea: { name: 'Andrea', color: '#ffffff' },
|
||||||
|
vecchioA: { name: 'Vecchietto col cappello', color: '#ffd27a' },
|
||||||
|
vecchioB: { name: 'Vecchietto col bastone', color: '#9fe3ff' },
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
squama: {
|
||||||
|
name: 'squama',
|
||||||
|
icon: { fill: '#4fae5e', shape: 'scale' },
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Una squama verde, grande come una moneta da due euro. Puzza di cantina.' }],
|
||||||
|
use: [{ say: 'Potrei usarla come plettro. Ma prima scopro di chi è.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenes: {
|
||||||
|
piazza: {
|
||||||
|
name: 'La Piazza',
|
||||||
|
walk: [[20, 222], [405, 222], [405, 276], [555, 276], [555, 222], [620, 222], [640, 250], [640, 352], [0, 352], [0, 250]],
|
||||||
|
depth: [[222, 0.82], [352, 1.12]],
|
||||||
|
entry: [600, 300],
|
||||||
|
speakers: { vecchioA: [455, 214], vecchioB: [512, 214] },
|
||||||
|
art: [
|
||||||
|
{ rect: [0, 0, 640, 130], fill: C.sky },
|
||||||
|
{ rect: [0, 100, 640, 40], fill: C.skyLow },
|
||||||
|
{ rect: [0, 140, 640, 82], fill: C.wallDark },
|
||||||
|
{ poly: [[380, 140], [470, 70], [640, 60], [640, 140]], fill: C.hill },
|
||||||
|
// La Fortezza sulla collina, sullo sfondo: chiusa, come dice la leggenda.
|
||||||
|
{ poly: [[470, 92], [490, 74], [600, 70], [625, 90], [610, 104], [480, 106]], fill: C.fort },
|
||||||
|
{ rect: [530, 58, 26, 18], fill: C.fortDark },
|
||||||
|
{ rect: [0, 40, 180, 182], fill: C.wall },
|
||||||
|
{ rect: [0, 32, 186, 12], fill: C.roof },
|
||||||
|
{ rect: [180, 70, 110, 152], fill: C.wallDark },
|
||||||
|
{ rect: [176, 62, 118, 10], fill: C.roof },
|
||||||
|
{ rect: [340, 110, 260, 112], fill: C.wall },
|
||||||
|
{ rect: [334, 100, 272, 12], fill: C.roof },
|
||||||
|
{ rect: [20, 70, 30, 40], fill: C.green }, { rect: [80, 70, 30, 40], fill: C.green },
|
||||||
|
{ rect: [360, 130, 26, 34], fill: C.green }, { rect: [560, 130, 26, 34], fill: C.green },
|
||||||
|
{ rect: [0, 222, 640, 138], fill: C.stone },
|
||||||
|
{ lines: { from: [0, 240], to: [640, 360], step: 36 }, stroke: C.stoneDark },
|
||||||
|
],
|
||||||
|
hotspots: [
|
||||||
|
{
|
||||||
|
id: 'bacheca', name: 'bacheca', box: [62, 140, 70, 52], at: [97, 246], face: 'up',
|
||||||
|
art: [{ rect: [62, 140, 70, 52], fill: C.wood }, { rect: [68, 146, 58, 40], fill: C.white }, { rect: [74, 152, 46, 6], fill: C.red }, { rect: [74, 164, 40, 3], fill: C.iron }, { rect: [74, 172, 30, 3], fill: C.iron }],
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Il programma del Palio della Fortezza. Qualcuno ci ha scritto sopra col pennarello: «IL REGOLO SI È RIPRESO IL DRAPPO».' }],
|
||||||
|
take: [{ say: 'È avvitata al muro. E poi il programma lo so a memoria.' }],
|
||||||
|
open: [{ say: 'È una bacheca, non un frigorifero.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bar', name: 'porta del bar', box: [206, 140, 46, 82], at: [229, 240], face: 'up',
|
||||||
|
art: [{ rect: [206, 140, 46, 82], fill: C.door }, { rect: [200, 120, 58, 16], fill: C.iron }, { text: 'BAR', at: [229, 132], fill: C.white }],
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Il bar della piazza. Chi c\'era dietro il bancone nel 2007? Questo lo sa solo Andrea.' }],
|
||||||
|
open: [{ say: 'Ci torno quando ho qualche soldo in tasca.' }],
|
||||||
|
talk: [{ say: 'Parlare con una porta. Neanche al Palio si arriva a tanto.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'asta', name: 'asta del drappo', box: [296, 60, 14, 172], at: [303, 262], face: 'up',
|
||||||
|
art: [{ rect: [300, 60, 6, 172], fill: C.iron }, { rect: [290, 224, 26, 10], fill: C.stoneDark }, { rect: [296, 56, 14, 6], fill: '#d8b23a' }],
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Qui doveva sventolare il drappo del Palio, quello col Regolo. Ora c\'è solo l\'asta.' }, { say: 'E una strana scia luccicante che va verso la salita.' }],
|
||||||
|
take: [{ say: 'L\'asta del Palio? Mi rincorrerebbero tutti e quattro i rioni.' }],
|
||||||
|
use: [{ say: 'Arrampicarmi? Il drappo non c\'è comunque.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vecchietti', name: 'vecchietti', box: [430, 196, 104, 60], at: [480, 290], face: 'up', default: 'talk',
|
||||||
|
art: [
|
||||||
|
{ rect: [420, 244, 124, 10], fill: C.wood }, { rect: [424, 254, 6, 18], fill: C.wood }, { rect: [534, 254, 6, 18], fill: C.wood },
|
||||||
|
{ rect: [440, 214, 28, 32], fill: '#5d6b8a' }, { rect: [444, 198, 20, 18], fill: '#e7b58e' }, { rect: [440, 194, 28, 7], fill: '#3e3227' },
|
||||||
|
{ rect: [498, 214, 28, 32], fill: '#8a6a4f' }, { rect: [502, 198, 20, 18], fill: '#e7b58e' }, { rect: [522, 210, 4, 50], fill: '#4a3424' },
|
||||||
|
],
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.' }],
|
||||||
|
talk: [{ dialogue: 'vecchietti' }],
|
||||||
|
take: [{ say: 'Se li porto via, il paese resta senza notizie.' }],
|
||||||
|
},
|
||||||
|
give: {
|
||||||
|
squama: [{ say: 'Guardate cosa ho trovato.' }, { say: 'Bella! È una patatina?', who: 'vecchioA' }, { say: 'Ma che patatina, è il Regolo che fa la muta!', who: 'vecchioB' }, { say: 'Me la tengo io, grazie.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'panchina', name: 'sotto la panchina', box: [440, 256, 84, 16], at: [482, 292], face: 'up',
|
||||||
|
actions: {
|
||||||
|
look: [{ if: 'luccichio', then: [{ say: 'Qualcosa luccica, proprio sotto i piedi dei vecchietti.' }], else: [{ say: 'Polvere, un tappo e una cicca. La solita archeologia di piazza.' }] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cosa_luccicante', name: 'cosa luccicante', box: [474, 260, 16, 10], at: [482, 292], face: 'up', when: ['luccichio', '!has:squama', '!squama_presa'],
|
||||||
|
art: [{ rect: [478, 262, 8, 5], fill: '#7fe08a' }, { rect: [480, 262, 3, 2], fill: '#e6ffe0' }],
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Una cosa verde e luccicante. Non è un tappo.' }],
|
||||||
|
take: [{ give: 'squama' }, { set: 'squama_presa' }, { say: 'Una squama! E bella grossa.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'verso_salita', name: 'verso la Fortezza', box: [600, 190, 40, 120], at: [632, 290], exit: { scene: 'salita', at: [40, 320], face: 'right' },
|
||||||
|
art: [{ poly: [[610, 222], [640, 206], [640, 250]], fill: C.stoneDark }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
enter: [{ if: '!intro', then: [{ set: 'intro' }, { say: 'Il drappo del Palio è sparito! E la festa è domani.' }, { say: 'Se nessuno lo ritrova, il Palio salta. Tocca a me.' }] }],
|
||||||
|
},
|
||||||
|
salita: {
|
||||||
|
name: 'La salita alla Fortezza',
|
||||||
|
walk: [[0, 290], [300, 240], [470, 218], [560, 222], [560, 260], [640, 300], [640, 352], [0, 352]],
|
||||||
|
depth: [[218, 0.7], [352, 1.1]],
|
||||||
|
entry: [40, 320],
|
||||||
|
art: [
|
||||||
|
{ rect: [0, 0, 640, 360], fill: C.sky },
|
||||||
|
{ poly: [[0, 200], [240, 150], [640, 120], [640, 360], [0, 360]], fill: C.hill },
|
||||||
|
{ poly: [[0, 290], [300, 240], [470, 218], [560, 222], [640, 300], [640, 360], [0, 360]], fill: '#b8a27c' },
|
||||||
|
// Il bastione e la porta della Fortezza, in cima alla salita.
|
||||||
|
{ poly: [[400, 222], [420, 60], [640, 40], [640, 230]], fill: C.fort },
|
||||||
|
{ rect: [400, 52, 240, 14], fill: C.fortDark },
|
||||||
|
{ poly: [[0, 360], [0, 300], [90, 280], [60, 360]], fill: C.hillDark },
|
||||||
|
],
|
||||||
|
hotspots: [
|
||||||
|
{
|
||||||
|
id: 'cancello', name: 'porta della Fortezza', box: [470, 120, 76, 102], at: [508, 236], face: 'up',
|
||||||
|
art: [{ rect: [470, 120, 76, 102], fill: '#2b2420' }, { rect: [474, 124, 68, 98], fill: C.iron }, { lines: { from: [474, 124], to: [542, 222], step: 10, vertical: true }, stroke: '#1d1d22' }, { rect: [500, 170, 16, 12], fill: '#c9a23a' }],
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'La porta della Fortezza. Chiusa con una catena grossa come il mio braccio.' }],
|
||||||
|
open: [{ say: 'Niente da fare. La Fortezza è chiusa da anni, e il lucchetto lo sa.' }],
|
||||||
|
talk: [{ say: 'Regolo? Ci sei?' }, { say: '...' }, { say: 'Ho sentito qualcosa. O era la mia pancia.' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'impronte', name: 'strane impronte', box: [240, 262, 120, 30], at: [300, 300],
|
||||||
|
art: [{ poly: [[244, 286], [270, 274], [300, 280], [330, 268], [356, 262], [358, 266], [332, 274], [300, 286], [270, 280], [246, 290]], fill: '#8e7a58' }, { rect: [300, 279, 3, 2], fill: '#7fe08a' }],
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Niente scarpe, niente zampe. Qualcosa è strisciato su per la salita.' }, { say: 'Qualcosa di grosso.' }],
|
||||||
|
take: [{ say: 'Le impronte non si prendono. Si seguono.' }],
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
squama: [
|
||||||
|
{ say: 'Le metto vicine...' },
|
||||||
|
{ say: 'Stesso luccichio, stessa puzza di cantina. Il Regolo è passato di qui!' },
|
||||||
|
{ set: 'indizio_regolo' },
|
||||||
|
{ say: 'Per stavolta finisce qui: il resto della storia lo dobbiamo ancora scrivere.' },
|
||||||
|
{ end: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'verso_piazza', name: 'verso la Piazza', box: [0, 270, 30, 90], at: [6, 320], exit: { scene: 'piazza', at: [600, 300], face: 'left' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dialogues: {
|
||||||
|
vecchietti: {
|
||||||
|
nodes: {
|
||||||
|
start: {
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
text: 'Avete visto chi ha preso il drappo?',
|
||||||
|
then: [
|
||||||
|
{ say: 'È stato il Regolo! Quello che cadde dal cielo quando scavavano le fondamenta.', who: 'vecchioA' },
|
||||||
|
{ say: 'Macché cielo! Mangiava le bestie, e lo presero i soldati di Napoleone.', who: 'vecchioB' },
|
||||||
|
{ say: 'Napoleone? A San Piero? Ma se un c\'è mai venuto!', who: 'vecchioA' },
|
||||||
|
{ say: 'E il Regolo sì?', who: 'vecchioB' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stanotte', once: true, text: 'Avete notato niente di strano stanotte?',
|
||||||
|
then: [
|
||||||
|
{ say: 'Io dormivo. Alla mia età si dorme anche in piedi.', who: 'vecchioB' },
|
||||||
|
{ say: 'Io stamani ho visto luccicare qualcosa sotto la panchina.', who: 'vecchioA' },
|
||||||
|
{ say: 'Ma con la mia schiena, chi ci si china?', who: 'vecchioA' },
|
||||||
|
{ set: 'luccichio' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Come vi sembra il Palio di quest\'anno?',
|
||||||
|
then: [
|
||||||
|
{ say: 'Senza drappo? Un Palio così non s\'è mai visto.', who: 'vecchioB' },
|
||||||
|
{ say: 'Nel mio, di Palio, si correva con le carriole vere. Mica come voi.', who: 'vecchioA' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ text: 'Ci vediamo dopo.', next: 'end', then: [{ say: 'Salutaci i tuoi!', who: 'vecchioA' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #1b1420;
|
||||||
|
--panel: #2a2030;
|
||||||
|
--ink: #f4efe4;
|
||||||
|
--dim: #b9a9c2;
|
||||||
|
--accent: #ffd27a;
|
||||||
|
--blue: #6fa8ff;
|
||||||
|
--font: "Trebuchet MS", "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: var(--font); }
|
||||||
|
body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-highlight-color: transparent; }
|
||||||
|
#app { width: min(100vw, 1280px, calc((100dvh - 250px) * 16 / 9)); min-width: min(100vw, 320px); display: flex; flex-direction: column; padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom); }
|
||||||
|
|
||||||
|
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
|
||||||
|
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
|
||||||
|
|
||||||
|
#speech { position: absolute; left: 0; top: 0; max-width: 46%; transform: translate(-50%, -100%); pointer-events: none;
|
||||||
|
font-size: clamp(14px, 2.3vw, 26px); font-weight: bold; line-height: 1.2; text-align: center;
|
||||||
|
text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; }
|
||||||
|
#speech:empty { display: none; }
|
||||||
|
|
||||||
|
#sentence { min-height: 1.8em; padding: .25em .6em; text-align: center; color: var(--blue); font-size: clamp(15px, 2vw, 22px); font-weight: bold; background: var(--panel); }
|
||||||
|
|
||||||
|
#panel { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 8px; padding: 8px; background: var(--panel); }
|
||||||
|
#verbs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
|
||||||
|
#verbs button, #inventory button { font: inherit; color: var(--dim); background: #3a2d42; border: 2px solid #4b3a55; border-radius: 4px;
|
||||||
|
padding: .35em .2em; font-size: clamp(13px, 1.7vw, 19px); font-weight: bold; cursor: pointer; }
|
||||||
|
#verbs button:hover, #inventory button:hover { color: var(--ink); }
|
||||||
|
#verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); }
|
||||||
|
#inventory { display: grid; grid-template-columns: repeat(3, 1fr); grid-auto-rows: minmax(2.4em, auto); gap: 4px; align-content: start; }
|
||||||
|
#inventory button { display: flex; align-items: center; gap: .35em; justify-content: center; color: var(--ink); }
|
||||||
|
#inventory button[aria-pressed="true"] { border-color: var(--accent); }
|
||||||
|
#inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; }
|
||||||
|
|
||||||
|
#choices { margin: 0; padding: 10px 14px 12px 2.4em; background: var(--panel); min-height: 9.5em; }
|
||||||
|
#choices li { padding: .25em 0; font-size: clamp(15px, 2vw, 22px); font-weight: bold; color: var(--dim); cursor: pointer; }
|
||||||
|
#choices li:hover, #choices li:focus { color: var(--accent); outline: none; }
|
||||||
|
|
||||||
|
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .6em;
|
||||||
|
padding: 16px; text-align: center; background: radial-gradient(circle at 50% 35%, rgba(60, 40, 70, .82), rgba(15, 10, 20, .96)); }
|
||||||
|
.overlay[hidden] { display: none; }
|
||||||
|
.overlay h1 { margin: 0; font-size: clamp(30px, 7vw, 76px); line-height: 1; letter-spacing: .02em; color: var(--ink); text-shadow: 3px 3px 0 #000; }
|
||||||
|
.overlay h1 span { color: #7fe08a; }
|
||||||
|
.overlay h2 { margin: 0; font-size: clamp(24px, 5vw, 52px); color: var(--accent); }
|
||||||
|
.overlay p { margin: 0; max-width: 34em; font-size: clamp(13px, 2vw, 20px); }
|
||||||
|
.subtitle { color: var(--dim); }
|
||||||
|
.dedication { margin-top: .8em !important; font-style: italic; color: var(--accent); }
|
||||||
|
.buttons { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-top: .4em; }
|
||||||
|
.buttons button { font: inherit; font-weight: bold; font-size: clamp(15px, 2.2vw, 22px); padding: .45em 1.2em; border-radius: 6px; cursor: pointer;
|
||||||
|
color: var(--bg); background: var(--accent); border: 0; }
|
||||||
|
#continue { background: #7fe08a; }
|
||||||
|
|
||||||
|
#rotate { display: none; padding: 1em; text-align: center; color: var(--dim); }
|
||||||
|
@media (orientation: portrait) and (max-width: 700px) { #rotate { display: block; } }
|
||||||
|
|
||||||
|
/* Telefono in orizzontale: la scena prende tutta l'altezza, verbi e inventario vanno di lato. */
|
||||||
|
@media (orientation: landscape) and (max-height: 520px) {
|
||||||
|
#app { width: 100vw; flex-direction: row; }
|
||||||
|
#stage { flex: none; width: min(calc(100dvh * 16 / 9), 68vw); align-self: flex-start; }
|
||||||
|
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
|
||||||
|
#sentence { font-size: 14px; }
|
||||||
|
#panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; }
|
||||||
|
#verbs, #inventory { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
#verbs button, #inventory button, #choices li { font-size: 14px; }
|
||||||
|
#choices { min-height: 0; padding: 6px 8px 6px 2em; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#error { position: fixed; inset: auto 16px 16px; padding: 12px; background: #7a1f1f; color: #fff; border-radius: 6px; }
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Prova nel browser vero (Chromium headless con swiftshader): titolo, partita giocata con clic veri
|
||||||
|
// fino alla fine, salvataggio e impaginazione su telefono. Schermate in $OUT (predefinito: /tmp/regolo-shots).
|
||||||
|
// npm run browser
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { solution } from '../src/story/solution.js';
|
||||||
|
|
||||||
|
const OUT = process.env.OUT ?? '/tmp/regolo-shots', PORT = 8000 + Math.floor(Math.random() * 900);
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
const server = spawn('python3', ['-m', 'http.server', String(PORT), '--bind', '127.0.0.1'], { stdio: 'ignore' });
|
||||||
|
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
async function open(viewport) {
|
||||||
|
const page = await browser.newPage({ viewport });
|
||||||
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
page.on('console', m => m.type() === 'error' && errors.push(m.text()));
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
try { await page.goto(`http://127.0.0.1:${PORT}/`); break; } catch { await page.waitForTimeout(100); }
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clic su un punto della scena (coordinate 640×360).
|
||||||
|
async function clickScene(page, [x, y]) {
|
||||||
|
const r = await page.locator('#scene').boundingBox();
|
||||||
|
await page.mouse.click(r.x + x / 640 * r.width, r.y + y / 360 * r.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aspetta che il gioco sia fermo, saltando le battute con la barra spaziatrice.
|
||||||
|
async function settle(page) {
|
||||||
|
for (let i = 0; i < 400; i++) {
|
||||||
|
const s = await page.evaluate(() => ({ speech: !!game.speech, idle: !game.busy && !game.walking && !game.queue.length && !game.task }));
|
||||||
|
if (s.speech) await page.keyboard.press('Space');
|
||||||
|
else if (s.idle) return;
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
}
|
||||||
|
throw new Error('il gioco non si ferma');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotspotCenter = (page, id) => page.evaluate(id => { const h = game.hotspot(id); return [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await open({ width: 1280, height: 860 });
|
||||||
|
await page.waitForSelector('#title:not([hidden])');
|
||||||
|
await page.screenshot({ path: `${OUT}/01-titolo.png` });
|
||||||
|
await page.click('#new-game');
|
||||||
|
await page.waitForFunction(() => game.speech);
|
||||||
|
await page.screenshot({ path: `${OUT}/02-inizio.png` });
|
||||||
|
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
|
||||||
|
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
|
||||||
|
await settle(page);
|
||||||
|
|
||||||
|
for (const [verb, target, item] of solution) {
|
||||||
|
if (verb === 'choose') {
|
||||||
|
await page.locator('#choices li', { hasText: target }).click();
|
||||||
|
} else {
|
||||||
|
const smart = await page.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
|
||||||
|
if (item) {
|
||||||
|
await page.click(`#verbs [data-verb="${verb}"]`);
|
||||||
|
await page.click(`#inventory [data-item="${item}"]`);
|
||||||
|
} else if (!smart) await page.click(`#verbs [data-verb="${verb}"]`);
|
||||||
|
await clickScene(page, await hotspotCenter(page, target));
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(80);
|
||||||
|
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
|
||||||
|
await settle(page);
|
||||||
|
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
|
||||||
|
if (target === 'verso_salita') await page.screenshot({ path: `${OUT}/05-salita.png` });
|
||||||
|
}
|
||||||
|
await page.waitForSelector('#ending:not([hidden])');
|
||||||
|
await page.screenshot({ path: `${OUT}/06-fine.png` });
|
||||||
|
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')));
|
||||||
|
assert.ok(saved.ended && saved.inventory.includes('squama'), 'salvataggio finale');
|
||||||
|
|
||||||
|
// Una partita a metà riprende da dove era rimasta.
|
||||||
|
await page.click('#restart');
|
||||||
|
await settle(page);
|
||||||
|
await clickScene(page, await hotspotCenter(page, 'verso_salita'));
|
||||||
|
await settle(page);
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('#continue:not([hidden])');
|
||||||
|
await page.click('#continue');
|
||||||
|
assert.equal(await page.evaluate(() => game.state.scene), 'salita');
|
||||||
|
|
||||||
|
const phone = await open({ width: 390, height: 844 });
|
||||||
|
await phone.click('#new-game');
|
||||||
|
await settle(phone);
|
||||||
|
const overflow = await phone.evaluate(() => document.documentElement.scrollWidth > innerWidth);
|
||||||
|
assert.equal(overflow, false, 'scorrimento orizzontale su telefono');
|
||||||
|
await phone.screenshot({ path: `${OUT}/07-telefono.png` });
|
||||||
|
const landscape = await open({ width: 844, height: 390 });
|
||||||
|
await landscape.click('#new-game');
|
||||||
|
await settle(landscape);
|
||||||
|
assert.equal(await landscape.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||||||
|
await landscape.screenshot({ path: `${OUT}/08-orizzontale.png`, fullPage: true });
|
||||||
|
|
||||||
|
assert.deepEqual(errors, [], 'errori nella console');
|
||||||
|
console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
server.kill();
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { story } from '../src/story/story.js';
|
||||||
|
import { Game } from '../src/engine/game.js';
|
||||||
|
import { Command } from '../src/engine/command.js';
|
||||||
|
import { settle } from './helpers.mjs';
|
||||||
|
|
||||||
|
const center = h => [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2];
|
||||||
|
const setup = () => {
|
||||||
|
const g = new Game(story);
|
||||||
|
return { g, c: new Command(g), at: id => center(g.hotspot(id)) };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('clic intelligente: parla con le persone, guarda le cose, attraversa le uscite', () => {
|
||||||
|
const { g, c, at } = setup();
|
||||||
|
assert.equal(c.sentence('vecchietti'), 'Parla con vecchietti');
|
||||||
|
assert.equal(c.sentence('bacheca'), 'Guarda bacheca');
|
||||||
|
assert.equal(c.sentence('verso_salita'), 'Vai a verso la Fortezza');
|
||||||
|
assert.equal(c.clickScene(at('vecchietti')), 'act');
|
||||||
|
settle(g);
|
||||||
|
assert.equal(g.dialogue?.id, 'vecchietti');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verbo scelto, poi bersaglio; il verbo si azzera dopo il comando', () => {
|
||||||
|
const { g, c, at } = setup();
|
||||||
|
c.pickVerb('take');
|
||||||
|
assert.equal(c.sentence(), 'Prendi…');
|
||||||
|
assert.equal(c.sentence('asta'), 'Prendi asta del drappo');
|
||||||
|
c.clickScene(at('asta'));
|
||||||
|
assert.equal(c.verb, null);
|
||||||
|
assert.match(settle(g)[0].text, /rincorrerebbero/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pressione lunga o clic destro guarda', () => {
|
||||||
|
const { g, c, at } = setup();
|
||||||
|
c.clickScene(at('vecchietti'), { look: true });
|
||||||
|
assert.match(settle(g)[0].text, /panchina/);
|
||||||
|
assert.equal(g.dialogue, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un clic sul vuoto cammina; un clic mentre si parla salta la battuta', () => {
|
||||||
|
const { g, c } = setup();
|
||||||
|
assert.equal(c.clickScene([300, 330]), 'walk');
|
||||||
|
settle(g);
|
||||||
|
assert.deepEqual([g.state.x, g.state.y], [300, 330]);
|
||||||
|
g.queue.push({ say: 'Una battuta lunga.' });
|
||||||
|
g.update(0.01);
|
||||||
|
assert.equal(c.clickScene([10, 330]), 'skip');
|
||||||
|
assert.equal(g.speech, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('oggetto dell\'inventario: usa con, dai a, guarda', () => {
|
||||||
|
const { g, c, at } = setup();
|
||||||
|
g.state.inventory.push('squama');
|
||||||
|
assert.equal(c.clickItem('squama'), 'select');
|
||||||
|
assert.equal(c.sentence('vecchietti'), 'Usa squama con vecchietti');
|
||||||
|
c.reset();
|
||||||
|
c.pickVerb('give');
|
||||||
|
c.clickItem('squama');
|
||||||
|
assert.equal(c.sentence('vecchietti'), 'Dai squama a vecchietti');
|
||||||
|
c.clickScene(at('vecchietti'));
|
||||||
|
assert.deepEqual(settle(g).map(s => s.who), ['andrea', 'vecchioA', 'vecchioB', 'andrea']);
|
||||||
|
assert.deepEqual(g.state.inventory, ['squama'], 'nessun vicolo cieco: la squama resta');
|
||||||
|
c.clickItem('squama', { look: true });
|
||||||
|
assert.match(settle(g)[0].text, /cantina/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { inside, visible, nearestInside, concaveVertices, findPath } from '../src/engine/geometry.js';
|
||||||
|
|
||||||
|
// Una "L": il passaggio dall'alto a destra deve girare attorno all'angolo interno (100,100).
|
||||||
|
const L = [[0, 0], [100, 0], [100, 100], [300, 100], [300, 200], [0, 200]];
|
||||||
|
|
||||||
|
test('punto dentro, fuori e sul bordo', () => {
|
||||||
|
assert.ok(inside(L, [50, 50]));
|
||||||
|
assert.ok(!inside(L, [200, 50]));
|
||||||
|
assert.ok(inside(L, [100, 50]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la visibilità non attraversa i muri', () => {
|
||||||
|
assert.ok(visible(L, [50, 50], [50, 150]));
|
||||||
|
assert.ok(!visible(L, [50, 50], [250, 150]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un clic fuori finisce sul bordo più vicino', () => {
|
||||||
|
assert.deepEqual(nearestInside(L, [200, 50]), [200, 100]);
|
||||||
|
assert.deepEqual(nearestInside(L, [50, -20]), [50, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('solo l\'angolo interno è concavo', () => {
|
||||||
|
assert.deepEqual(concaveVertices(L), [[100, 100]]);
|
||||||
|
assert.deepEqual(concaveVertices([...L].reverse()), [[100, 100]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('il percorso gira attorno all\'angolo', () => {
|
||||||
|
assert.deepEqual(findPath(L, [50, 50], [250, 150]), [[100, 100], [250, 150]]);
|
||||||
|
assert.deepEqual(findPath(L, [10, 10], [90, 190]), [[90, 190]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('una destinazione fuori dall\'area viene avvicinata', () => {
|
||||||
|
const path = findPath(L, [50, 50], [250, 20]);
|
||||||
|
assert.deepEqual(path.at(-1), [250, 100]);
|
||||||
|
for (let i = 1; i < path.length; i++) assert.ok(visible(L, path[i - 1], path[i]));
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Fa avanzare il gioco come farebbe un giocatore impaziente: tempo che scorre e battute saltate.
|
||||||
|
export function settle(game, { maxSteps = 20000 } = {}) {
|
||||||
|
const said = [];
|
||||||
|
for (let i = 0; i < maxSteps; i++) {
|
||||||
|
if (game.speech) {
|
||||||
|
said.push(game.speech);
|
||||||
|
game.skip();
|
||||||
|
}
|
||||||
|
if (!game.busy && !game.walking && !game.queue.length && !game.task) return said;
|
||||||
|
game.update(0.05);
|
||||||
|
}
|
||||||
|
throw new Error('Il gioco non si ferma: ' + JSON.stringify(game.snapshot()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memoryStorage() {
|
||||||
|
return { data: {}, getItem(k) { return this.data[k] ?? null; }, setItem(k, v) { this.data[k] = String(v); }, removeItem(k) { delete this.data[k]; } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { Game, check, restoreState, newState } from '../src/engine/game.js';
|
||||||
|
import { settle } from './helpers.mjs';
|
||||||
|
|
||||||
|
// Una storia minima, indipendente da quella vera: il motore si collauda con i suoi dati.
|
||||||
|
const mini = () => ({
|
||||||
|
start: { scene: 'a', at: [10, 50] },
|
||||||
|
items: {
|
||||||
|
chiave: { name: 'chiave', actions: { look: [{ say: 'Una chiave.' }] }, with: { sasso: [{ say: 'Chiave sul sasso.' }] } },
|
||||||
|
sasso: { name: 'sasso' },
|
||||||
|
},
|
||||||
|
scenes: {
|
||||||
|
a: {
|
||||||
|
walk: [[0, 0], [200, 0], [200, 100], [0, 100]],
|
||||||
|
entry: [100, 50],
|
||||||
|
hotspots: [
|
||||||
|
{ id: 'chiave_a_terra', name: 'chiave a terra', box: [90, 40, 10, 10], at: [95, 50], when: '!has:chiave', actions: { take: [{ give: 'chiave' }] } },
|
||||||
|
{ id: 'porta', name: 'porta', box: [180, 20, 20, 60], at: [190, 50], actions: { open: [{ if: 'has:chiave', then: [{ go: 'b' }], else: [{ say: 'Chiusa.' }] }] } },
|
||||||
|
{ id: 'uscita', name: 'uscita', box: [0, 0, 5, 100], at: [2, 50], exit: { scene: 'b', at: [20, 20] } },
|
||||||
|
{ id: 'tizio', name: 'tizio', box: [50, 0, 10, 20], at: [55, 30], default: 'talk', actions: { talk: [{ dialogue: 'd' }] }, give: { sasso: [{ say: 'Grazie del sasso.', who: 'tizio' }, { drop: 'sasso' }] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
b: { walk: [[0, 0], [100, 0], [100, 100], [0, 100]], entry: [50, 50], enter: [{ set: 'visto_b' }] },
|
||||||
|
},
|
||||||
|
dialogues: {
|
||||||
|
d: { nodes: { start: { options: [
|
||||||
|
{ id: 'uno', once: true, text: 'Una volta sola', then: [{ give: 'sasso' }] },
|
||||||
|
{ text: 'Dimmi altro', next: 'altro' },
|
||||||
|
{ text: 'Ciao', next: 'end' },
|
||||||
|
] }, altro: { options: [{ text: 'Torna', next: 'start' }, { text: 'Segreto', when: 'has:sasso', then: [{ set: 'segreto' }], next: 'end' }] } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test('condizioni su flag e inventario', () => {
|
||||||
|
const s = { flags: { a: true }, inventory: ['x'] };
|
||||||
|
assert.ok(check('a', s) && check('!b', s) && check('has:x', s) && check('!has:y', s));
|
||||||
|
assert.ok(check(['a', 'has:x'], s) && !check(['a', 'b'], s) && check(undefined, s));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cammina fino all\'oggetto, lo prende e lo toglie dalla scena', () => {
|
||||||
|
const g = new Game(mini());
|
||||||
|
assert.ok(g.act('take', 'chiave_a_terra'));
|
||||||
|
settle(g);
|
||||||
|
assert.deepEqual([g.state.x, g.state.y], [95, 50]);
|
||||||
|
assert.deepEqual(g.state.inventory, ['chiave']);
|
||||||
|
assert.equal(g.hotspot('chiave_a_terra'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le risposte di ripiego non lasciano mai il giocatore senza una frase', () => {
|
||||||
|
const g = new Game(mini());
|
||||||
|
for (const verb of ['look', 'take', 'use', 'talk', 'open', 'give']) assert.ok(g.resolve(verb, 'porta').length, verb);
|
||||||
|
assert.deepEqual(g.resolve('use', 'porta', 'sasso'), [{ say: 'Non funziona.' }]);
|
||||||
|
assert.deepEqual(g.resolve('take', 'sasso'), [{ say: 'Ce l\'ho già.' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('usa oggetto con oggetto in entrambi i versi', () => {
|
||||||
|
const g = new Game(mini());
|
||||||
|
assert.deepEqual(g.resolve('use', 'sasso', 'chiave'), [{ say: 'Chiave sul sasso.' }]);
|
||||||
|
assert.deepEqual(g.resolve('use', 'chiave', 'sasso'), [{ say: 'Chiave sul sasso.' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('porta chiusa, poi aperta con la chiave; il cambio scena salva ed esegue l\'ingresso', () => {
|
||||||
|
const saves = [];
|
||||||
|
const g = new Game(mini(), { onSave: s => saves.push(s) });
|
||||||
|
g.act('open', 'porta');
|
||||||
|
assert.deepEqual(settle(g).map(s => s.text), ['Chiusa.']);
|
||||||
|
g.act('take', 'chiave_a_terra'); settle(g);
|
||||||
|
g.act('open', 'porta'); settle(g);
|
||||||
|
assert.equal(g.state.scene, 'b');
|
||||||
|
assert.deepEqual([g.state.x, g.state.y], [50, 50]);
|
||||||
|
assert.ok(g.state.flags.visto_b);
|
||||||
|
assert.equal(saves.at(-1).scene, 'b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('l\'uscita porta nell\'altra scena', () => {
|
||||||
|
const g = new Game(mini());
|
||||||
|
g.act('walk', 'uscita'); settle(g);
|
||||||
|
assert.equal(g.state.scene, 'b');
|
||||||
|
assert.deepEqual([g.state.x, g.state.y], [20, 20]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un clic durante la camminata cambia destinazione e scarta l\'azione', () => {
|
||||||
|
const g = new Game(mini());
|
||||||
|
g.act('take', 'chiave_a_terra');
|
||||||
|
g.update(0.1);
|
||||||
|
assert.ok(g.walking);
|
||||||
|
assert.ok(g.walkTo([10, 10]));
|
||||||
|
settle(g);
|
||||||
|
assert.deepEqual(g.state.inventory, []);
|
||||||
|
assert.deepEqual([g.state.x, g.state.y], [10, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mentre parla un personaggio i comandi sono ignorati', () => {
|
||||||
|
const g = new Game(mini());
|
||||||
|
g.queue.push({ say: 'Aspetta.' });
|
||||||
|
g.update(0.01);
|
||||||
|
assert.ok(g.speech && g.busy);
|
||||||
|
assert.equal(g.walkTo([10, 10]), false);
|
||||||
|
g.update(10);
|
||||||
|
assert.equal(g.speech, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dialogo: opzioni una tantum, nodi, condizioni e uscita', () => {
|
||||||
|
const g = new Game(mini());
|
||||||
|
g.act('talk', 'tizio'); settle(g);
|
||||||
|
assert.ok(g.dialogue);
|
||||||
|
assert.deepEqual(g.options().map(o => o.text), ['Una volta sola', 'Dimmi altro', 'Ciao']);
|
||||||
|
g.choose(0); settle(g);
|
||||||
|
assert.deepEqual(g.state.inventory, ['sasso']);
|
||||||
|
assert.deepEqual(g.options().map(o => o.text), ['Dimmi altro', 'Ciao']);
|
||||||
|
g.choose(0); settle(g);
|
||||||
|
assert.deepEqual(g.options().map(o => o.text), ['Torna', 'Segreto']);
|
||||||
|
g.choose(1); settle(g);
|
||||||
|
assert.equal(g.dialogue, null);
|
||||||
|
assert.ok(g.state.flags.segreto);
|
||||||
|
assert.equal(g.walkTo([20, 20]), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dare un oggetto a un personaggio', () => {
|
||||||
|
const g = new Game(mini());
|
||||||
|
g.state.inventory.push('sasso');
|
||||||
|
g.act('give', 'tizio', 'sasso');
|
||||||
|
const said = settle(g);
|
||||||
|
assert.deepEqual(said.map(s => [s.who, s.text]), [['tizio', 'Grazie del sasso.']]);
|
||||||
|
assert.deepEqual(g.state.inventory, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un salvataggio rovinato o vecchio non rompe la partita', () => {
|
||||||
|
const story = mini();
|
||||||
|
assert.deepEqual(restoreState(story, null), newState(story));
|
||||||
|
assert.deepEqual(restoreState(story, { scene: 'sparita' }), newState(story));
|
||||||
|
const s = restoreState(story, { scene: 'b', x: 500, y: 50, inventory: ['chiave', 'fantasma', 'chiave'], flags: { ok: true, strano: 3 }, used: 'x' });
|
||||||
|
assert.deepEqual(s, { scene: 'b', x: 100, y: 50, facing: 1, inventory: ['chiave'], flags: { ok: true }, used: {}, ended: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lo stato salvato si ricarica uguale', () => {
|
||||||
|
const g = new Game(mini());
|
||||||
|
g.act('take', 'chiave_a_terra'); settle(g);
|
||||||
|
const again = new Game(mini(), { state: JSON.parse(JSON.stringify(g.snapshot())) });
|
||||||
|
assert.deepEqual(again.snapshot(), g.snapshot());
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readSave, writeSave, clearSave } from '../src/engine/storage.js';
|
||||||
|
import { memoryStorage } from './helpers.mjs';
|
||||||
|
|
||||||
|
test('scrive, rilegge e cancella il salvataggio', () => {
|
||||||
|
const s = memoryStorage();
|
||||||
|
assert.equal(readSave(s), null);
|
||||||
|
assert.ok(writeSave(s, { scene: 'piazza' }));
|
||||||
|
assert.deepEqual(readSave(s), { scene: 'piazza' });
|
||||||
|
assert.ok(clearSave(s));
|
||||||
|
assert.equal(readSave(s), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un archivio rotto o bloccato non fa cadere il gioco', () => {
|
||||||
|
const broken = { getItem() { throw new Error('bloccato'); }, setItem() { throw new Error('pieno'); }, removeItem() { throw new Error('no'); } };
|
||||||
|
assert.equal(readSave(broken), null);
|
||||||
|
assert.equal(writeSave(broken, {}), false);
|
||||||
|
assert.equal(clearSave(broken), false);
|
||||||
|
const garbage = memoryStorage();
|
||||||
|
garbage.setItem('leggenda-regolo-save', '{non è json');
|
||||||
|
assert.equal(readSave(garbage), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { story } from '../src/story/story.js';
|
||||||
|
import { solution } from '../src/story/solution.js';
|
||||||
|
import { Game, VERBS } from '../src/engine/game.js';
|
||||||
|
import { inside } from '../src/engine/geometry.js';
|
||||||
|
import { settle } from './helpers.mjs';
|
||||||
|
|
||||||
|
const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end'];
|
||||||
|
|
||||||
|
// Visita ogni elenco di effetti della storia, dentro scene, oggetti, dialoghi e rami `if`.
|
||||||
|
function* effectLists() {
|
||||||
|
for (const [id, scene] of Object.entries(story.scenes)) {
|
||||||
|
yield [`${id}.enter`, scene.enter ?? []];
|
||||||
|
for (const h of scene.hotspots ?? [])
|
||||||
|
for (const table of ['actions', 'with', 'give'])
|
||||||
|
for (const [k, list] of Object.entries(h[table] ?? {})) yield [`${id}.${h.id}.${table}.${k}`, list];
|
||||||
|
}
|
||||||
|
for (const [id, item] of Object.entries(story.items))
|
||||||
|
for (const table of ['actions', 'with'])
|
||||||
|
for (const [k, list] of Object.entries(item[table] ?? {})) yield [`${id}.${table}.${k}`, list];
|
||||||
|
for (const [id, d] of Object.entries(story.dialogues))
|
||||||
|
for (const [n, node] of Object.entries(d.nodes))
|
||||||
|
for (const o of node.options) yield [`${id}.${n}.${o.text}`, o.then ?? []];
|
||||||
|
}
|
||||||
|
function* flatten(list) {
|
||||||
|
for (const e of list) {
|
||||||
|
yield e;
|
||||||
|
if (e.if !== undefined) { yield* flatten(e.then ?? []); yield* flatten(e.else ?? []); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esistono', () => {
|
||||||
|
assert.ok(story.scenes[story.start.scene] && inside(story.scenes[story.start.scene].walk, story.start.at));
|
||||||
|
for (const [id, scene] of Object.entries(story.scenes)) {
|
||||||
|
assert.ok(inside(scene.walk, scene.entry), `${id}: ingresso fuori dall'area`);
|
||||||
|
const ids = new Set();
|
||||||
|
for (const h of scene.hotspots ?? []) {
|
||||||
|
assert.ok(!ids.has(h.id), `${id}.${h.id} doppio`);
|
||||||
|
ids.add(h.id);
|
||||||
|
assert.ok(!story.items[h.id], `${id}.${h.id}: stesso nome di un oggetto dell'inventario`);
|
||||||
|
assert.ok(h.name && h.box?.length === 4, `${id}.${h.id}: nome o riquadro mancante`);
|
||||||
|
if (h.at) assert.ok(inside(scene.walk, h.at), `${id}.${h.id}: punto d'arrivo fuori dall'area`);
|
||||||
|
if (h.default) assert.ok(VERBS.includes(h.default));
|
||||||
|
for (const k of Object.keys(h.actions ?? {})) assert.ok(VERBS.includes(k), `${id}.${h.id}: verbo ${k}`);
|
||||||
|
for (const k of [...Object.keys(h.with ?? {}), ...Object.keys(h.give ?? {})]) assert.ok(story.items[k], `${id}.${h.id}: oggetto ${k}`);
|
||||||
|
if (h.exit) {
|
||||||
|
const to = story.scenes[h.exit.scene];
|
||||||
|
assert.ok(to, `${id}.${h.id}: scena ${h.exit.scene}`);
|
||||||
|
assert.ok(inside(to.walk, h.exit.at ?? to.entry), `${id}.${h.id}: arrivo fuori dall'area`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [where, list] of effectLists())
|
||||||
|
for (const e of flatten(list)) {
|
||||||
|
const kind = EFFECTS.find(k => e[k] !== undefined);
|
||||||
|
assert.ok(kind, `${where}: effetto sconosciuto ${JSON.stringify(e)}`);
|
||||||
|
if (e.give || e.drop) assert.ok(story.items[e.give ?? e.drop], `${where}: oggetto ${e.give ?? e.drop}`);
|
||||||
|
if (e.dialogue) assert.ok(story.dialogues[e.dialogue], `${where}: dialogo ${e.dialogue}`);
|
||||||
|
if (e.go) assert.ok(story.scenes[e.go], `${where}: scena ${e.go}`);
|
||||||
|
if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`);
|
||||||
|
}
|
||||||
|
for (const [id, d] of Object.entries(story.dialogues)) {
|
||||||
|
assert.ok(d.nodes.start, `${id}: manca il nodo start`);
|
||||||
|
for (const node of Object.values(d.nodes)) {
|
||||||
|
assert.ok(node.options.some(o => o.next === 'end' && !o.when && !o.once), `${id}: ogni nodo deve avere un'uscita sempre disponibile`);
|
||||||
|
for (const o of node.options) {
|
||||||
|
if (o.once) assert.ok(o.id, `${id}: opzione una tantum senza id`);
|
||||||
|
if (o.next && o.next !== 'end') assert.ok(d.nodes[o.next], `${id}: nodo ${o.next}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la soluzione si percorre tutta dall\'inizio alla fine', () => {
|
||||||
|
const saves = [];
|
||||||
|
const g = new Game(story, { onSave: s => saves.push(s) });
|
||||||
|
g.go(story.start.scene, story.start.at);
|
||||||
|
settle(g);
|
||||||
|
for (const [verb, target, item] of solution) {
|
||||||
|
if (verb === 'choose') {
|
||||||
|
const i = g.options().findIndex(o => o.text === target);
|
||||||
|
assert.ok(i >= 0, `opzione non disponibile: ${target}`);
|
||||||
|
g.choose(i);
|
||||||
|
} else {
|
||||||
|
if (!story.items[target]) assert.ok(g.hotspot(target), `non c'è ${target} in ${g.state.scene}`);
|
||||||
|
if (item) assert.ok(g.state.inventory.includes(item), `manca ${item}`);
|
||||||
|
assert.ok(g.act(verb, target, item), `comando rifiutato: ${verb} ${target}`);
|
||||||
|
}
|
||||||
|
settle(g);
|
||||||
|
}
|
||||||
|
assert.ok(g.state.ended, 'la partita non è finita');
|
||||||
|
assert.ok(g.state.flags.indizio_regolo);
|
||||||
|
assert.ok(saves.at(-1).ended, 'la fine non è salvata');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user