Mappa del paese per spostarsi veloci; Codex nei crediti
Il pulsante «Mappa» (o il tasto P) apre la pianta del paese a gradini: ci sono i posti già visti, le strade sono le uscite fra loro, un clic porta lì (game.travel, mai durante un dialogo o una battuta). Ogni scena ha la sua posizione in `map`; un posto nuovo entra con una riga. Test: posti solo visti, strade, viaggio rifiutato in un dialogo; nel browser dal Trebbio alla Piazza. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UdeKd62opy9PKgpQXtVctF
This commit is contained in:
@@ -25,6 +25,10 @@ Ultimo aggiornamento: 23 ottobre 2026. Da leggere insieme a `PROGETTO.md` all'in
|
||||
- **Voci** (`make voci`, XTTS-v2 sulla GeForce): 112 battute, cinque voci (Andrea, vecchietta, vecchietto, castello,
|
||||
leone); il fumetto dura quanto la voce, la musica si abbassa mentre si parla, il muto spegne anche le voci.
|
||||
Controllate tutte con Whisper il 2026-10-23. Il leone ora parla (battute provvisorie).
|
||||
- **Mappa** (pulsante in alto a sinistra o tasto P): i posti già visti, con le strade fra loro; un clic ci porta.
|
||||
Un posto nuovo entra con `map: [x, y]` nella sua scena (percentuali della pianta, disposta come «il paese a gradini»).
|
||||
- **Crediti** dal titolo e dalla fine (`story.credits`), dedica al Lele in testa; musichetta a 8 bit del cartello
|
||||
(`tools/jingle.py`).
|
||||
- **Il libro della storia infinita** sulle tavolate dell'oratorio; le idee in attesa in `docs/IDEE.md`.
|
||||
- **Test**: `make test` (29 test, compresa la soluzione completa da `src/story/solution.js`) e `make browser`
|
||||
(partita completa con clic veri in Chromium, ripresa del salvataggio, telefono).
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<div id="stage">
|
||||
<canvas id="scene" width="640" height="360" aria-label="La scena dell'avventura"></canvas>
|
||||
<div id="speech" aria-live="polite"></div>
|
||||
<button id="map-button" hidden title="Mappa (P)">Mappa</button>
|
||||
<div id="sound">
|
||||
<button id="mute" aria-label="Musica: togli l'audio" title="Musica (M)">♪</button>
|
||||
<input id="volume" type="range" min="0" max="100" step="5" aria-label="Volume della musica">
|
||||
@@ -37,6 +38,11 @@
|
||||
<p class="dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
|
||||
<div class="buttons"><button id="restart">Ricomincia</button><button class="open-credits secondary">Crediti</button></div>
|
||||
</section>
|
||||
<section id="map" class="overlay" hidden aria-label="Mappa del paese">
|
||||
<h2>San Piero a Sieve</h2>
|
||||
<div id="map-board"><svg id="map-roads" viewBox="0 0 100 100" preserveAspectRatio="none"></svg></div>
|
||||
<div class="buttons"><button id="close-map">Chiudi</button></div>
|
||||
</section>
|
||||
<section id="credits" class="overlay" hidden aria-label="Crediti">
|
||||
<h2>Crediti</h2>
|
||||
<p class="dedication credits-dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
|
||||
|
||||
@@ -255,6 +255,28 @@ export class Game {
|
||||
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
|
||||
}
|
||||
|
||||
// La mappa: i posti già visti, con la posizione sulla pianta, e le strade che li collegano (uscite fra due posti visti).
|
||||
mapPlaces() {
|
||||
return Object.entries(this.story.scenes)
|
||||
.filter(([id, sc]) => sc.map && this.state.flags['visto:' + id])
|
||||
.map(([id, sc]) => ({ id, name: sc.name, at: sc.map, here: id === this.state.scene }));
|
||||
}
|
||||
mapRoads() {
|
||||
const seen = new Set(this.mapPlaces().map(p => p.id)), roads = new Map();
|
||||
for (const id of seen)
|
||||
for (const h of this.story.scenes[id].hotspots ?? [])
|
||||
if (h.exit && seen.has(h.exit.scene) && h.exit.scene !== id) roads.set([id, h.exit.scene].sort().join('|'), [id, h.exit.scene].sort());
|
||||
return [...roads.values()];
|
||||
}
|
||||
// Viaggio dalla mappa: solo verso un posto già visto, e non mentre si parla, si cammina o si sceglie in un dialogo.
|
||||
travel(scene) {
|
||||
const sc = this.story.scenes[scene];
|
||||
if (!sc?.map || !this.state.flags['visto:' + scene] || scene === this.state.scene) return false;
|
||||
if (this.busy || this.walking || this.dialogue || this.state.ended) return false;
|
||||
this.go(scene, sc.entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
go(scene, at, face) {
|
||||
if (!this.story.scenes[scene]) throw new Error('Scena sconosciuta: ' + scene);
|
||||
this.state.scene = scene;
|
||||
@@ -263,6 +285,7 @@ export class Game {
|
||||
this.path = [];
|
||||
this.walking = false;
|
||||
this.back = false;
|
||||
this.state.flags['visto:' + scene] = true; // per la mappa
|
||||
this.queue.unshift(...(this.scene.enter ?? []));
|
||||
this.onSave(this.snapshot());
|
||||
}
|
||||
|
||||
+29
@@ -78,6 +78,31 @@ $('credits-list').replaceChildren(...(story.credits ?? []).flatMap(({ role, name
|
||||
for (const b of document.querySelectorAll('.open-credits')) b.addEventListener('click', () => { $('credits').hidden = false; });
|
||||
$('close-credits').addEventListener('click', () => { $('credits').hidden = true; });
|
||||
|
||||
// --- Mappa: i posti visti e le strade fra loro; un clic su un posto ci porta (game.travel).
|
||||
function openMap() {
|
||||
if (!running || game.dialogue) return;
|
||||
const board = $('map-board'), svg = $('map-roads'), places = game.mapPlaces(), at = Object.fromEntries(places.map(p => [p.id, p.at]));
|
||||
svg.replaceChildren(...game.mapRoads().map(([a, b]) => {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
[['x1', at[a][0]], ['y1', at[a][1]], ['x2', at[b][0]], ['y2', at[b][1]]].forEach(([k, v]) => line.setAttribute(k, v));
|
||||
return line;
|
||||
}));
|
||||
board.replaceChildren(svg, ...places.map(p => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.dataset.scene = p.id;
|
||||
b.textContent = p.name;
|
||||
b.classList.toggle('here', p.here);
|
||||
b.style.left = p.at[0] + '%';
|
||||
b.style.top = p.at[1] + '%';
|
||||
b.addEventListener('click', () => { if (game.travel(p.id)) $('map').hidden = true; });
|
||||
return b;
|
||||
}));
|
||||
$('map').hidden = false;
|
||||
}
|
||||
$('map-button').addEventListener('click', openMap);
|
||||
$('close-map').addEventListener('click', () => { $('map').hidden = true; });
|
||||
|
||||
function showTitle() {
|
||||
running = false;
|
||||
const saved = storage && readSave(storage);
|
||||
@@ -170,6 +195,8 @@ function updateChoices() {
|
||||
window.addEventListener('keydown', e => {
|
||||
if (e.target === $('volume')) return;
|
||||
if (!$('credits').hidden) { if (e.key === 'Escape') $('credits').hidden = true; return; }
|
||||
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
|
||||
if ((e.key === 'p' || e.key === 'P') && running) { openMap(); return; }
|
||||
if (!$('intro').hidden && [' ', 'Enter'].includes(e.key)) { e.preventDefault(); nextIntro(); return; }
|
||||
if (e.key === 'm' || e.key === 'M') { music.toggle(); saveAudio(); showSound(); return; }
|
||||
if (!running) return;
|
||||
@@ -204,6 +231,8 @@ function updateUi() {
|
||||
updateInventory();
|
||||
updateChoices();
|
||||
updateSpeech();
|
||||
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
|
||||
$('map-button').hidden = !running || !!game.dialogue || game.mapPlaces().length < 2;
|
||||
if (game.state.ended && !game.speech && !game.queue.length) {
|
||||
running = false;
|
||||
$('ending').hidden = false;
|
||||
|
||||
+14
-1
@@ -4,6 +4,8 @@
|
||||
|
||||
export const story = {
|
||||
title: 'La Leggenda del Regolo',
|
||||
// La mappa (pulsante «Mappa»): ogni scena con `map: [x, y]` (percentuali della pianta) ci compare quando è
|
||||
// stata visitata; le strade sono le uscite fra scene. Un posto nuovo entra con la sua riga `map`.
|
||||
start: { scene: 'piazza', at: [250, 320] },
|
||||
// Le righe dell'introduzione, sopra il panorama del paese, prima della prima scena.
|
||||
intro: [
|
||||
@@ -23,7 +25,7 @@ export const story = {
|
||||
{ role: 'Musica', names: ['Andrea Manzi, con Suno'] },
|
||||
{ role: 'Sfondi', names: ['dalle fotografie di San Piero di Andrea'] },
|
||||
{ role: 'Voci', names: ['XTTS-v2 di Coqui'] },
|
||||
{ role: 'Programmazione', names: ['Andrea Manzi, con Claude'] },
|
||||
{ role: 'Programmazione', names: ['Andrea Manzi, con Claude e Codex'] },
|
||||
],
|
||||
characters: {
|
||||
andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' },
|
||||
@@ -59,6 +61,7 @@ export const story = {
|
||||
scenes: {
|
||||
piazza: {
|
||||
name: 'Piazza Colonna',
|
||||
map: [16, 70],
|
||||
background: 'assets/scenes/piazza-colonna.png',
|
||||
walk: [[84, 244], [330, 238], [345, 230], [362, 238], [462, 246], [466, 276], [544, 276], [548, 248], [588, 250], [600, 290], [640, 300], [640, 360], [0, 360], [0, 330], [84, 318]],
|
||||
depth: [[236, 0.5], [360, 1.35]],
|
||||
@@ -140,6 +143,7 @@ export const story = {
|
||||
},
|
||||
fortezza: {
|
||||
name: 'Sotto le mura della Fortezza',
|
||||
map: [30, 15],
|
||||
background: 'assets/scenes/fortezza-esterno.png',
|
||||
walk: [[110, 284], [300, 280], [330, 272], [348, 274], [400, 292], [442, 294], [640, 352], [640, 360], [0, 360], [0, 315], [60, 310], [105, 295]],
|
||||
depth: [[272, 0.55], [360, 1.15]],
|
||||
@@ -190,6 +194,7 @@ export const story = {
|
||||
// Livello 0: la Pieve vista dalla salita, con la salita per Villa Adami alle spalle.
|
||||
pieve: {
|
||||
name: 'La Pieve',
|
||||
map: [46, 70],
|
||||
background: 'assets/scenes/pieve-dalla-salita.png',
|
||||
// La rampa in primo piano e, dietro il muretto, il sagrato (ci si arriva da sinistra).
|
||||
walk: [[0, 270], [80, 266], [86, 252], [100, 222], [270, 215], [300, 224], [480, 234], [560, 242], [600, 252], [610, 268], [276, 240], [276, 270], [292, 278], [640, 352], [640, 360], [0, 360]],
|
||||
@@ -230,6 +235,7 @@ export const story = {
|
||||
// Il cortile dell'oratorio, dietro la porta della canonica: palco, tettoia, tavolate della festa.
|
||||
oratorio: {
|
||||
name: 'Oratorio della Pieve',
|
||||
map: [74, 70],
|
||||
background: 'assets/scenes/oratorio.png',
|
||||
walk: [[0, 236], [300, 226], [480, 222], [500, 232], [640, 254], [640, 360], [0, 360]],
|
||||
depth: [[222, 0.5], [360, 1.15]],
|
||||
@@ -279,6 +285,7 @@ export const story = {
|
||||
// Via de' Medici: dalla Piazza sale verso Villa Adami (la Piazza è alle spalle, in fondo si va su).
|
||||
via_de_medici: {
|
||||
name: 'Via de\' Medici',
|
||||
map: [14, 52],
|
||||
background: 'assets/scenes/via-de-medici.png',
|
||||
walk: [[0, 360], [0, 336], [160, 290], [262, 240], [288, 212], [312, 208], [340, 222], [470, 300], [545, 360]],
|
||||
depth: [[208, 0.32], [360, 1.25]],
|
||||
@@ -302,6 +309,7 @@ export const story = {
|
||||
// L'ingresso del campeggio: dalla Piazza a diritto; poco dopo, a destra, si sale al Bosco ai Frati.
|
||||
campeggio: {
|
||||
name: 'Ingresso del campeggio',
|
||||
map: [16, 88],
|
||||
background: 'assets/scenes/campeggio.png',
|
||||
walk: [[150, 360], [165, 262], [420, 266], [640, 286], [640, 360]],
|
||||
depth: [[262, 0.6], [360, 1.15]],
|
||||
@@ -332,6 +340,7 @@ export const story = {
|
||||
// Il convento del Bosco ai Frati, nel bosco sopra il campeggio.
|
||||
bosco_ai_frati: {
|
||||
name: 'Bosco ai Frati',
|
||||
map: [42, 88],
|
||||
background: 'assets/scenes/bosco-ai-frati.png',
|
||||
walk: [[0, 304], [100, 298], [480, 292], [640, 300], [640, 360], [0, 360]],
|
||||
depth: [[292, 0.78], [360, 1.1]],
|
||||
@@ -366,6 +375,7 @@ export const story = {
|
||||
// Livello 1: Villa Adami, vista dalla salita della Fortezza guardando in discesa.
|
||||
villa_adami: {
|
||||
name: 'Villa Adami',
|
||||
map: [34, 48],
|
||||
background: 'assets/scenes/villa-adami.png',
|
||||
walk: [[0, 272], [30, 246], [60, 244], [110, 256], [190, 268], [360, 296], [440, 288], [520, 276], [560, 268], [612, 262], [614, 272], [520, 288], [500, 292], [500, 360], [0, 360]],
|
||||
depth: [[244, 0.5], [360, 1.2]],
|
||||
@@ -392,6 +402,7 @@ export const story = {
|
||||
// Tra il livello 1 e il 2: la salita di Villa Adami, con la casa del leone. Si sale alla Fortezza.
|
||||
salita_villa_adami: {
|
||||
name: 'Salita di Villa Adami',
|
||||
map: [30, 32],
|
||||
background: 'assets/scenes/salita-villa-adami.png',
|
||||
walk: [[0, 318], [100, 312], [282, 318], [300, 300], [318, 262], [336, 252], [420, 266], [640, 296], [640, 360], [0, 360]],
|
||||
depth: [[252, 0.5], [360, 1.1]],
|
||||
@@ -424,6 +435,7 @@ export const story = {
|
||||
// Il viale dei cipressi, la strada bianca tra la Fortezza e il Trebbio: in fondo c'è il castello.
|
||||
viale_cipressi: {
|
||||
name: 'Viale dei cipressi',
|
||||
map: [55, 15],
|
||||
background: 'assets/scenes/viale-cipressi.png',
|
||||
walk: [[30, 360], [200, 300], [288, 252], [312, 250], [420, 300], [600, 360]],
|
||||
depth: [[250, 0.16], [360, 1.2]],
|
||||
@@ -446,6 +458,7 @@ export const story = {
|
||||
},
|
||||
trebbio: {
|
||||
name: 'Castello del Trebbio',
|
||||
map: [80, 15],
|
||||
background: 'assets/scenes/trebbio.png',
|
||||
music: 'assets/audio/ricordi.mp3',
|
||||
walk: [[86, 308], [300, 306], [356, 308], [470, 312], [500, 324], [486, 360], [88, 360], [80, 330]],
|
||||
|
||||
@@ -95,6 +95,20 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
||||
#continue { background: #7fe08a; }
|
||||
.buttons button.secondary { color: var(--ink); background: rgba(42, 32, 48, .85); box-shadow: inset 0 0 0 2px var(--dim); }
|
||||
/* Crediti: sopra titolo e fine (z-index), una voce per ruolo; scorrono se non ci stanno. */
|
||||
/* Mappa: la pianta del paese a gradini, i posti visti come cartellini, le strade come linee. */
|
||||
#map-button { position: absolute; top: 6px; left: 6px; z-index: 2; font: bold clamp(12px, 1.6vw, 16px) var(--font); padding: .35em .8em;
|
||||
color: var(--accent); background: rgba(27, 20, 32, .62); border: 0; border-radius: 18px; cursor: pointer; }
|
||||
#map { z-index: 3; justify-content: flex-start; padding-top: 2.5%; background: rgba(15, 10, 20, .94); }
|
||||
#map h2 { font-size: clamp(18px, 3vw, 32px); }
|
||||
#map-board { position: relative; width: 92%; flex: 1; min-height: 0; margin: .3em 0;
|
||||
background: repeating-linear-gradient(transparent 0 calc(25% - 1px), rgba(185, 169, 194, .12) calc(25% - 1px) 25%), radial-gradient(circle at 50% 60%, #3a2d42, #231a29); border-radius: 8px; }
|
||||
#map-roads { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
#map-roads line { stroke: #b9a9c2; stroke-width: 2.5; stroke-dasharray: 5 4; vector-effect: non-scaling-stroke; opacity: .7; }
|
||||
#map-board button { position: absolute; transform: translate(-50%, -50%); font: bold clamp(10px, 1.5vw, 15px) var(--font); padding: .3em .6em;
|
||||
color: var(--bg); background: var(--ink); border: 2px solid #000; border-radius: 6px; cursor: pointer; white-space: nowrap; }
|
||||
#map-board button:hover { background: var(--accent); }
|
||||
#map-board button.here { color: var(--ink); background: #2f5fb3; cursor: default; }
|
||||
#map-board button.here::after { content: ' · sei qui'; font-weight: normal; }
|
||||
#credits { z-index: 3; justify-content: flex-start; overflow-y: auto; padding-top: 4%; background: rgba(15, 10, 20, .96); }
|
||||
#credits .credits-dedication { margin: .2em 0 .5em !important; font-size: clamp(16px, 2.6vw, 28px); }
|
||||
/* Due colonne: il ruolo a destra della sua colonna, il nome a sinistra della sua, allineati sulla riga. */
|
||||
|
||||
@@ -136,6 +136,15 @@ try {
|
||||
await page.waitForSelector('#continue:not([hidden])');
|
||||
await page.click('#continue');
|
||||
assert.equal(await page.evaluate(() => game.state.scene), 'trebbio');
|
||||
// La mappa: dal Trebbio alla Piazza con due clic; i posti mai visti (la Pieve) non ci sono.
|
||||
await settle(page);
|
||||
await page.click('#map-button');
|
||||
assert.equal(await page.locator('#map-board button[data-scene="pieve"]').count(), 0, 'la Pieve non è ancora vista');
|
||||
assert.ok(await page.isVisible('#map-board button.here[data-scene="trebbio"]'));
|
||||
await page.screenshot({ path: `${OUT}/07b-mappa.png` });
|
||||
await page.click('#map-board button[data-scene="piazza"]');
|
||||
assert.ok(await page.isHidden('#map'));
|
||||
assert.equal(await page.evaluate(() => game.state.scene), 'piazza');
|
||||
|
||||
const phone = await open({ width: 390, height: 844 });
|
||||
await phone.click('#splash'); // un clic salta il cartello
|
||||
|
||||
@@ -145,3 +145,27 @@ test('il libro dell\'oratorio: «La storia infinita» ha una pagina sola', () =>
|
||||
const said = settle(g).map(s => s.text);
|
||||
assert.ok(said.some(t => t.includes('FINE')), 'aperto, dice FINE');
|
||||
});
|
||||
|
||||
test('la mappa: ogni scena ci sta, i posti compaiono solo visti, il viaggio rispetta i dialoghi', () => {
|
||||
for (const [id, sc] of Object.entries(story.scenes)) {
|
||||
assert.ok(Array.isArray(sc.map), `${id} non è sulla mappa`);
|
||||
assert.ok(sc.map.every(v => v >= 0 && v <= 100), `${id} fuori dalla pianta`);
|
||||
}
|
||||
const g = new Game(story);
|
||||
g.go('piazza', story.start.at);
|
||||
settle(g);
|
||||
assert.deepEqual(g.mapPlaces().map(p => p.id), ['piazza']);
|
||||
assert.equal(g.travel('trebbio'), false, 'non si va dove non si è mai stati');
|
||||
g.go('pieve', story.scenes.pieve.entry);
|
||||
settle(g);
|
||||
assert.deepEqual(g.mapRoads(), [['piazza', 'pieve']], 'la strada fra due posti visti');
|
||||
assert.ok(g.mapPlaces().find(p => p.id === 'pieve').here);
|
||||
assert.ok(g.travel('piazza'));
|
||||
settle(g);
|
||||
assert.equal(g.state.scene, 'piazza');
|
||||
assert.equal(g.travel('piazza'), false, 'già qui');
|
||||
g.act('talk', 'vecchietti');
|
||||
settle(g);
|
||||
assert.ok(g.dialogue);
|
||||
assert.equal(g.travel('pieve'), false, 'non si scappa da un dialogo');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user