diff --git a/assets/characters/betino.json b/assets/characters/betino.json
new file mode 100644
index 0000000..7614d34
--- /dev/null
+++ b/assets/characters/betino.json
@@ -0,0 +1,14 @@
+{
+ "image": "betino.png",
+ "height": 104,
+ "frames": {
+ "idle": {
+ "x": 0,
+ "y": 0,
+ "w": 161,
+ "h": 104,
+ "ax": 80.5,
+ "ay": 104
+ }
+ }
+}
diff --git a/assets/characters/betino.png b/assets/characters/betino.png
new file mode 100644
index 0000000..c3b2eda
Binary files /dev/null and b/assets/characters/betino.png differ
diff --git a/assets/characters/ghiacciomobile.json b/assets/characters/ghiacciomobile.json
new file mode 100644
index 0000000..aae96fe
--- /dev/null
+++ b/assets/characters/ghiacciomobile.json
@@ -0,0 +1,14 @@
+{
+ "image": "ghiacciomobile.png",
+ "height": 144,
+ "frames": {
+ "idle": {
+ "x": 0,
+ "y": 0,
+ "w": 324,
+ "h": 144,
+ "ax": 162,
+ "ay": 144
+ }
+ }
+}
diff --git a/assets/characters/ghiacciomobile.png b/assets/characters/ghiacciomobile.png
new file mode 100644
index 0000000..d04decc
Binary files /dev/null and b/assets/characters/ghiacciomobile.png differ
diff --git a/assets/voices/andrea-2acf574bfa.mp3 b/assets/voices/andrea-2acf574bfa.mp3
new file mode 100644
index 0000000..29f4085
Binary files /dev/null and b/assets/voices/andrea-2acf574bfa.mp3 differ
diff --git a/assets/voices/andrea-34aad1ffe3.mp3 b/assets/voices/andrea-34aad1ffe3.mp3
new file mode 100644
index 0000000..efb5c62
Binary files /dev/null and b/assets/voices/andrea-34aad1ffe3.mp3 differ
diff --git a/assets/voices/andrea-3e85200509.mp3 b/assets/voices/andrea-3e85200509.mp3
new file mode 100644
index 0000000..fc206de
Binary files /dev/null and b/assets/voices/andrea-3e85200509.mp3 differ
diff --git a/assets/voices/andrea-57375fe2af.mp3 b/assets/voices/andrea-57375fe2af.mp3
new file mode 100644
index 0000000..a3a4cb7
Binary files /dev/null and b/assets/voices/andrea-57375fe2af.mp3 differ
diff --git a/assets/voices/andrea-6c6e2fd461.mp3 b/assets/voices/andrea-6c6e2fd461.mp3
new file mode 100644
index 0000000..d1424b2
Binary files /dev/null and b/assets/voices/andrea-6c6e2fd461.mp3 differ
diff --git a/assets/voices/andrea-7be29434e0.mp3 b/assets/voices/andrea-7be29434e0.mp3
new file mode 100644
index 0000000..ba95726
Binary files /dev/null and b/assets/voices/andrea-7be29434e0.mp3 differ
diff --git a/assets/voices/andrea-c79d2d1c81.mp3 b/assets/voices/andrea-c79d2d1c81.mp3
new file mode 100644
index 0000000..e14a500
Binary files /dev/null and b/assets/voices/andrea-c79d2d1c81.mp3 differ
diff --git a/assets/voices/andrea-def44809bc.mp3 b/assets/voices/andrea-def44809bc.mp3
new file mode 100644
index 0000000..d429cbf
Binary files /dev/null and b/assets/voices/andrea-def44809bc.mp3 differ
diff --git a/assets/voices/index.json b/assets/voices/index.json
index 728fd24..3300bfc 100644
--- a/assets/voices/index.json
+++ b/assets/voices/index.json
@@ -943,6 +943,22 @@
"file": "andrea-00f2e70a4d.mp3",
"sec": 2.95
},
+ "andrea|Nessuno ha mai capito davvero il tuo immenso valore.": {
+ "file": "andrea-3e85200509.mp3",
+ "sec": 2.93
+ },
+ "andrea|O via, lascialo riposare. A pedalare oggi ci penso io.": {
+ "file": "andrea-34aad1ffe3.mp3",
+ "sec": 4.81
+ },
+ "andrea|La Ghiacciomobile nera. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.": {
+ "file": "andrea-7be29434e0.mp3",
+ "sec": 7.25
+ },
+ "andrea|Con questo sole, di ghiaccio c'è rimasto giusto il nome. Vo a piedi.": {
+ "file": "andrea-def44809bc.mp3",
+ "sec": 5.08
+ },
"andrea|Il circolino: il Circolo SMS di Campomigliaio. Un'istituzione, qui i volontari hanno sempre fatto miracoli.": {
"file": "andrea-90bbad0de0.mp3",
"sec": 9.59
@@ -1523,6 +1539,22 @@
"file": "andrea-60cdae2e35.mp3",
"sec": 5.1
},
+ "andrea|Mi ricordi come avete fatto quella volta a tornare illesi dopo aver lasciato una fiancata a Campolungo?": {
+ "file": "andrea-2acf574bfa.mp3",
+ "sec": 5.36
+ },
+ "andrea|Ho sentito dire dal Trappola che pensavi di poter attraversare i muri.": {
+ "file": "andrea-57375fe2af.mp3",
+ "sec": 3.45
+ },
+ "andrea|Riposa, Ghiacciomobile.": {
+ "file": "andrea-c79d2d1c81.mp3",
+ "sec": 2.19
+ },
+ "andrea|Ho sentito dire dal Trappola che pensavi di poter attraversare i muri. Ti stimo tanto.": {
+ "file": "andrea-6c6e2fd461.mp3",
+ "sec": 5.69
+ },
"andrea|Una briscola?": {
"file": "andrea-d2f285f610.mp3",
"sec": 1.04
diff --git a/src/story/story.js b/src/story/story.js
index aa3fb5f..bfd660c 100644
--- a/src/story/story.js
+++ b/src/story/story.js
@@ -230,6 +230,8 @@ export const story = {
// Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea.
fra_peppino: { name: 'Fra Peppino', color: '#e8c89a', sprite: 'assets/characters/fra-peppino.json' },
macchina: { name: 'La macchina del tempo', color: '#c8d8e8', sprite: 'assets/characters/macchina-tempo.json' },
+ betino: { name: 'il Betino', sprite: 'assets/characters/betino.json' },
+ ghiacciomobile: { name: 'la Ghiacciomobile', sprite: 'assets/characters/ghiacciomobile.json' },
},
items: {
squama: {
@@ -1535,8 +1537,31 @@ export const story = {
entry: [320, 330],
// L'Arcu davanti al circolino, accanto alla fontanella (sprite di Codex, 25 ottobre).
speakers: { arcu: [418, 214] },
- people: [{ who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 }],
+ people: [
+ { who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 },
+ // Oggetti statici: ordinati per profondità come la macchina del tempo, senza voce.
+ // Con la depth della scena misurano circa 78 e 160 px; restano sopra il bordo di walk.
+ { who: 'betino', at: [480, 263], size: 0.767, idle: 'idle', talk: [] },
+ { who: 'ghiacciomobile', at: [176, 286], size: 0.986, idle: 'idle', talk: [] },
+ ],
hotspots: [
+ {
+ id: 'betino', name: 'il Betino', box: [441, 212, 78, 51], at: [480, 282], face: 'up', default: 'look',
+ // Il motorino di Andrea (sprite di Codex, 26 ottobre); la battuta è sua.
+ actions: {
+ look: [{ say: 'Nessuno ha mai capito davvero il tuo immenso valore.' }],
+ use: [{ say: 'O via, lascialo riposare. A pedalare oggi ci penso io.' }],
+ },
+ },
+ {
+ id: 'ghiacciomobile', name: 'la Ghiacciomobile', box: [96, 215, 160, 71], at: [240, 308], face: 'up', default: 'look',
+ // La Corsa nera degli amici (sprite di Codex, 26 ottobre). Parlandole, Andrea ricorda; lei non risponde.
+ actions: {
+ look: [{ say: 'La Ghiacciomobile nera. Hai visto cose che noi poveri umani non potremmo neppur lontanamente immaginare.' }],
+ talk: [{ dialogue: 'ghiacciomobile' }],
+ use: [{ say: 'Con questo sole, di ghiaccio c\'è rimasto giusto il nome. Vo a piedi.' }],
+ },
+ },
{
id: 'circolo', name: 'il circolino', box: [100, 0, 380, 205], at: [300, 290], face: 'up',
actions: {
@@ -2168,6 +2193,16 @@ export const story = {
},
},
dialogues: {
+ // Andrea parla alla Ghiacciomobile (battute di Andrea, 26 ottobre): la macchina resta zitta, e va bene così.
+ ghiacciomobile: {
+ nodes: {
+ start: { options: [
+ { text: 'Mi ricordi come avete fatto quella volta a tornare illesi dopo aver lasciato una fiancata a Campolungo?', then: [{ say: 'Mi ricordi come avete fatto quella volta a tornare illesi dopo aver lasciato una fiancata a Campolungo?' }, { say: '...' }] },
+ { text: 'Ho sentito dire dal Trappola che pensavi di poter attraversare i muri.', then: [{ say: 'Ho sentito dire dal Trappola che pensavi di poter attraversare i muri. Ti stimo tanto.' }, { say: '...' }] },
+ { text: 'Riposa, Ghiacciomobile.', next: 'end' },
+ ] },
+ },
+ },
giocatore_carte: {
nodes: {
start: { options: [
diff --git a/tests/story.test.mjs b/tests/story.test.mjs
index e56999d..e0271ad 100644
--- a/tests/story.test.mjs
+++ b/tests/story.test.mjs
@@ -182,7 +182,7 @@ test('i personaggi in scena hanno il foglio e tutte le pose che usano', () => {
for (const [id, scene] of Object.entries(story.scenes))
for (const p of scene.people ?? []) {
assert.ok(story.characters[p.who]?.sprite, `${id}: ${p.who} senza foglio`);
- assert.ok(scene.speakers?.[p.who], `${id}: manca il punto del fumetto di ${p.who}`);
+ if ((p.talk ?? ['talk']).length) assert.ok(scene.speakers?.[p.who], `${id}: manca il punto del fumetto di ${p.who}`);
const f = frames(p.who);
for (const pose of [p.idle ?? 'sit', p.blink, ...(p.talk ?? ['talk'])].filter(Boolean)) assert.ok(f[pose], `${p.who}: manca ${pose}`);
}
diff --git a/tests/veicoli.browser.mjs b/tests/veicoli.browser.mjs
new file mode 100644
index 0000000..48c1f7d
--- /dev/null
+++ b/tests/veicoli.browser.mjs
@@ -0,0 +1,39 @@
+// Verifica visiva mirata: node tests/veicoli.browser.mjs
+// Usa lo stesso Chromium/SwiftShader di browser.mjs, senza titolo e audio.
+import { chromium } from 'playwright';
+import { mkdirSync } from 'node:fs';
+import assert from 'node:assert/strict';
+import { staticServer } from './helpers.mjs';
+
+const out = process.env.OUT ?? '/tmp/regolo-shots';
+mkdirSync(out, { recursive: true });
+const server = await staticServer();
+let browser;
+try {
+ browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
+ const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ page.on('response', r => { if (r.status() >= 400) errors.push(`${r.status()} ${r.url()}`); });
+ await page.route('**/__veicoli', route => route.fulfill({ contentType: 'text/html', body:
+ '' }));
+ await page.goto(`http://127.0.0.1:${server.port}/__veicoli`);
+ await page.evaluate(async () => {
+ const [{ story }, { Game }, renderer] = await Promise.all([
+ import('/src/story/story.js'), import('/src/engine/game.js'), import('/src/engine/render.js'),
+ ]);
+ const loaded = await renderer.loadAssets(story, 'campomigliaio_circolo');
+ if (loaded.some(ok => !ok)) throw new Error('Uno sfondo/personaggio non è arrivato');
+ const game = new Game(story);
+ game.go('campomigliaio_circolo');
+ game.time = 8;
+ const ctx = document.querySelector('canvas').getContext('2d');
+ renderer.render(ctx, game, { time: 8000 });
+ });
+ assert.deepEqual(errors, []);
+ await page.screenshot({ path: `${out}/campomigliaio-veicoli.png` });
+ console.log(`Schermata: ${out}/campomigliaio-veicoli.png`);
+} finally {
+ await browser?.close();
+ server.kill();
+}
diff --git a/tests/veicoli.test.mjs b/tests/veicoli.test.mjs
new file mode 100644
index 0000000..5a12bc0
--- /dev/null
+++ b/tests/veicoli.test.mjs
@@ -0,0 +1,57 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync, existsSync } from 'node:fs';
+import { story } from '../src/story/story.js';
+import { Game } from '../src/engine/game.js';
+import { inside } from '../src/engine/geometry.js';
+import { settle } from './helpers.mjs';
+
+test('i veicoli del circolino restano fuori dal percorso e lasciano liberi gli altri hotspot', () => {
+ const g = new Game(story);
+ g.go('campomigliaio_circolo');
+ settle(g);
+ const vehicles = ['betino', 'ghiacciomobile'];
+ for (const id of vehicles) {
+ const person = g.people().find(p => p.who === id), h = g.hotspot(id);
+ const url = new URL('../' + story.characters[id].sprite, import.meta.url);
+ const atlas = JSON.parse(readFileSync(url, 'utf8')), frame = atlas.frames.idle;
+ assert.ok(existsSync(new URL(atlas.image, url)));
+ assert.deepEqual(Object.keys(atlas.frames), ['idle']);
+ const k = 62 * g.scaleAt(person.at[1]) * person.size / atlas.height;
+ const width = frame.w * k;
+ assert.ok(id === 'betino' ? width >= 70 && width <= 80 : width >= 150 && width <= 170);
+ // Tutto l'ingombro (non soltanto l'ancora) deve restare fuori dall'area calpestabile.
+ for (let x = h.box[0]; x <= h.box[0] + h.box[2]; x++)
+ for (let y = h.box[1]; y <= h.box[1] + h.box[3]; y++)
+ assert.ok(!inside(g.scene.walk, [x, y]), `${id}: invade walk in ${x},${y}`);
+ for (const other of g.hotspots().filter(o => !vehicles.includes(o.id))) {
+ const [x, y, w, hgt] = h.box, [ox, oy, ow, oh] = other.box;
+ assert.ok(x + w <= ox || ox + ow <= x || y + hgt <= oy || oy + oh <= y,
+ `${id}: copre ${other.id}`);
+ }
+ for (const time of [0, 3.9, 4.1, 20]) {
+ g.time = time;
+ assert.equal(g.personPose(person), 'idle');
+ }
+ }
+});
+
+test('guarda e usa i veicoli: parla solo Andrea e non cambia il progresso', () => {
+ const g = new Game(story);
+ g.go('campomigliaio_circolo');
+ settle(g);
+ const flags = { ...g.state.flags }, inventory = [...g.state.inventory];
+ for (const id of ['betino', 'ghiacciomobile']) {
+ const h = g.hotspot(id);
+ assert.equal(g.hotspotAt([h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]).id, id);
+ for (const verb of ['look', 'use']) {
+ assert.ok(g.act(verb, id));
+ const said = settle(g);
+ assert.ok(said.length && said.every(s => s.who === 'andrea'));
+ assert.deepEqual([g.state.x, g.state.y], h.at);
+ assert.deepEqual(g.state.flags, flags);
+ assert.deepEqual(g.state.inventory, inventory);
+ assert.equal(g.state.scene, 'campomigliaio_circolo');
+ }
+ }
+});
diff --git a/tools/pronuncia.json b/tools/pronuncia.json
index 2f526fd..1b9a588 100644
--- a/tools/pronuncia.json
+++ b/tools/pronuncia.json
@@ -5,5 +5,6 @@
"padel": "pàdel",
"l'A. Bettini": "l'Abbettini",
"da i' Trappola, ai' Barrettino": "dai Trappola, ai Barrettino",
- "S'ha andà a fallo": "Si va a farlo"
+ "S'ha andà a fallo": "Si va a farlo",
+ "Ghiacciomobile": "Ghiacciomòbile"
}