diff --git a/docs/STATO.md b/docs/STATO.md index f3504ec..e197105 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -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). diff --git a/index.html b/index.html index 2d7f640..9858ccd 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,7 @@
Al Lele, che mi ha fatto conoscere Monkey Island.
+Al Lele, che mi ha fatto conoscere Monkey Island.
diff --git a/src/engine/game.js b/src/engine/game.js index cd9de47..9e27e83 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -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()); } diff --git a/src/main.js b/src/main.js index fe25d78..1cea6b2 100644 --- a/src/main.js +++ b/src/main.js @@ -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; diff --git a/src/story/story.js b/src/story/story.js index 34f9327..af2977d 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -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]], diff --git a/style.css b/style.css index 2804f21..0b092b6 100644 --- a/style.css +++ b/style.css @@ -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. */ diff --git a/tests/browser.mjs b/tests/browser.mjs index 89fbfd1..cf90377 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -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 diff --git a/tests/story.test.mjs b/tests/story.test.mjs index 98edfc4..5daede8 100644 --- a/tests/story.test.mjs +++ b/tests/story.test.mjs @@ -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'); +});