diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..789e872 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +node_modules +assets/raw +prompts +tests +docs +_site diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c0716a0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +# LA LEGGENDA DEL REGOLO: gioco statico servito da nginx. +FROM nginx:1.27-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY index.html style.css /usr/share/nginx/html/ +COPY src /usr/share/nginx/html/src +COPY assets/scenes /usr/share/nginx/html/assets/scenes + +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cd26e94 --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +# LA LEGGENDA DEL REGOLO — comandi di sviluppo, verifica e Docker. +# `make` o `make help` elenca i target. + +SHELL := /bin/bash +.DEFAULT_GOAL := help + +PORT ?= 8000 +DOCKER_PORT ?= 8080 +COMPOSE ?= DOCKER_PORT=$(DOCKER_PORT) docker compose + +.PHONY: help serve test browser verify up down restart logs ps + +help: ## Mostra questo aiuto + @echo "LA LEGGENDA DEL REGOLO — target disponibili:"; echo + @grep -hE '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}' + +serve: ## Avvia il server statico locale (PORT=8000) + @echo "→ http://localhost:$(PORT)" + python3 -m http.server $(PORT) --bind 0.0.0.0 + +test: ## Test della logica, della storia e della soluzione completa + node --test tests/*.test.mjs + +browser: ## Partita completa nel browser con Playwright + node tests/browser.mjs + +verify: test browser ## Tutti i controlli + +up: ## Avvia il container (DOCKER_PORT=8080) + $(COMPOSE) up -d --build + @echo "→ http://localhost:$(DOCKER_PORT)" + +down: ## Ferma e rimuove il container + $(COMPOSE) down + +restart: down up ## Riavvia il container + +logs: ## Segue i log del container + $(COMPOSE) logs -f + +ps: ## Stato del container + $(COMPOSE) ps diff --git a/README.md b/README.md index 0a27b7b..a36d19b 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,20 @@ 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. +## In Docker + +```zsh +make up # http://localhost:8080 (DOCKER_PORT=… per cambiarla) +make down +``` + ## 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. +- `src/main.js`: collega tutto alla pagina; +- `assets/scenes/`: sfondi a 640×360, preparati con `tools/prepare_background.py` dagli originali in `assets/raw/`. ## Test diff --git a/assets/scenes/fortezza-esterno.png b/assets/scenes/fortezza-esterno.png new file mode 100644 index 0000000..15c1b17 Binary files /dev/null and b/assets/scenes/fortezza-esterno.png differ diff --git a/assets/scenes/piazza-colonna.png b/assets/scenes/piazza-colonna.png new file mode 100644 index 0000000..06a0668 Binary files /dev/null and b/assets/scenes/piazza-colonna.png differ diff --git a/assets/scenes/trebbio.png b/assets/scenes/trebbio.png new file mode 100644 index 0000000..5e25b5b Binary files /dev/null and b/assets/scenes/trebbio.png differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b0605b2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + leggenda-regolo: + build: . + image: leggenda-regolo:latest + container_name: leggenda-regolo + ports: + - "${DOCKER_PORT:-8080}:80" + restart: unless-stopped diff --git a/docs/PROGETTO.md b/docs/PROGETTO.md index 3d64c15..3615efd 100644 --- a/docs/PROGETTO.md +++ b/docs/PROGETTO.md @@ -75,7 +75,7 @@ notare. Andrea lo convince a restituirlo… e il Regolo, per la prima volta, ass | Zona | Ruolo nel gioco | Spunti veri | | --- | --- | --- | -| **Piazza** (rossi) | Centro di tutto: notizie, personaggi principali, inizio e finale | il bar [DA ANDREA], la piazza della festa | +| **Piazza Colonna** (rossi) | Centro di tutto: notizie, personaggi principali, inizio e finale | la colonna con la croce al centro, il bar [DA ANDREA: «La Colonna»?], la piazza della festa | | **Pieve** (bianchi) | Primo rione da conquistare | la Pieve di San Pietro [DA ANDREA: luoghi e persone] | | **Cardetole** (verdi) | Secondo rione; qui vive Andrea | nel 1758 la piena della Sieve arrivò fino a Cardetole | | **Campomigliaio** (blu) | Il rione per cui corrono Andrea e l'Arcu, a circa 4 km | chiesa di Santo Stefano a Cornetole (documentata dal 1123, rifatta nel 1848–50, con una Madonna dell'Umiltà in terracotta del '400); il Circolo SMS di Campomigliaio | @@ -109,8 +109,8 @@ Storia vera da usare per battute e segreti: | Personaggio | Dove | Idea | Stato | | --- | --- | --- | --- | -| **Andrea** (ragazzo) | ovunque | il protagonista: curioso, pigro quando serve, testardo quando conta | da definire aspetto e battute [DA ANDREA] | -| L'Arcu (Marco Arcuri) | ovunque | il migliore amico di Andrea, compagno di rione nei blu: la spalla dell'avventura? | persona vera: [DA ANDREA: consenso di Marco] | +| **Andrea** (ragazzo) | ovunque | il protagonista: curioso, pigro quando serve, testardo quando conta | aspetto dal riferimento di Andrea: capelli castani ricci, maglietta blu con il triangolo azzurro, jeans chiari, scarpe da ginnastica bianche; battute [DA ANDREA] | +| L'Arcu (Marco Arcuri) | ovunque | il migliore amico di Andrea, compagno di rione nei blu: la spalla dell'avventura? | aspetto dal riferimento: capelli lunghi raccolti, maglietta blu, guanti bianchi, jeans scuri; persona vera: [DA ANDREA: consenso di Marco] | | Il barista | Piazza | sa tutto di tutti, non dice niente gratis | [DA ANDREA: chi c'era nel 2007?] | | I vecchietti della panchina | Piazza | il "coro" del paese: commentano tutto, litigano sulla leggenda | [DA ANDREA] | | Paleo | Piazza | figura dei rossi nel 2007 | persona vera: [DA ANDREA: chi era, consenso] | @@ -122,7 +122,7 @@ Storia vera da usare per battute e segreti: | Amerigo Vespucci (ritratto/fantasma) | Trebbio | sicurissimo e sempre sbagliato | | | Il soldato Regolo (fantasma) | Fortezza | passi nelle notti di luna piena; aiuta o spaventa | | | **Il Regolo** | Fortezza | il "piccolo re" permaloso: il finale | | -| [Il Lele?] | | solo se e come vuole Andrea | [DA ANDREA] | +| Il Lele | [DA ANDREA] | Andrea ha mandato un riferimento con lui accanto ad Andrea e all'Arcu: tuta grigia con la fascia in vita, casco in mano, ginocchiere colorate | come e quanto farlo comparire lo decide Andrea, con calma | Persone vere nel gioco: solo con il loro consenso (come in Manzolo Rush). Per i personaggi "tipici" del paese meglio tipi riconoscibili che persone identificabili, salvo accordi. diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..6546376 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,40 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Compressione per i moduli JS e i fogli di stile. + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + # I moduli ES richiedono il MIME type corretto. + types { + text/html html; + text/css css; + application/javascript js mjs; + application/json json map; + image/png png; + image/webp webp; + audio/mpeg mp3; + image/svg+xml svg; + image/x-icon ico; + font/woff2 woff2; + } + + location / { + try_files $uri $uri/ =404; + } + + # Gli script e i fogli di stile non hanno hash nel nome: rivalidali a ogni avvio. + location ~* \.(css|js|mjs|json)$ { + add_header Cache-Control "no-cache"; + } + location ~* \.(png|webp|mp3)$ { + expires 7d; + add_header Cache-Control "public"; + } + location = /index.html { + add_header Cache-Control "no-cache"; + } +} diff --git a/src/engine/render.js b/src/engine/render.js index b89170d..b6e8771 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -56,9 +56,22 @@ export function drawAndrea(ctx, x, y, scale, facing, walkPhase) { ctx.restore(); } +// Sfondi delle scene, caricati una volta sola. Finché un'immagine non c'è si vedono le forme `art`. +const backgrounds = new Map(); +export function loadBackgrounds(story) { + return Promise.all(Object.values(story.scenes).filter(s => s.background).map(s => new Promise(resolve => { + const img = new Image(); + img.onload = () => { backgrounds.set(s.background, img); resolve(true); }; + img.onerror = () => resolve(false); + img.src = s.background; + }))); +} + export function render(ctx, game, { hover } = {}) { - const scene = game.scene, s = game.state; + const scene = game.scene, s = game.state, bg = backgrounds.get(scene.background); + ctx.imageSmoothingEnabled = false; ctx.clearRect(0, 0, WIDTH, HEIGHT); + if (bg) ctx.drawImage(bg, 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; diff --git a/src/main.js b/src/main.js index f8248e9..370e6c7 100644 --- a/src/main.js +++ b/src/main.js @@ -2,7 +2,7 @@ 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 { render, loadBackgrounds, WIDTH, HEIGHT } from './engine/render.js'; import { readSave, writeSave, clearSave } from './engine/storage.js'; const $ = id => document.getElementById(id); @@ -167,3 +167,4 @@ game = new Game(story); command = new Command(game); showTitle(); requestAnimationFrame(frame); +loadBackgrounds(story).then(ok => { if (ok.includes(false)) console.warn('Alcuni sfondi non sono stati caricati.'); }); diff --git a/src/story/solution.js b/src/story/solution.js index cd653fc..80b29a6 100644 --- a/src/story/solution.js +++ b/src/story/solution.js @@ -1,11 +1,11 @@ -// La soluzione completa, passo per passo: la percorre tests/solution.test.mjs ad ogni modifica della storia. +// La soluzione completa, passo per passo: la percorre tests/story.test.mjs a ogni modifica della storia. // Passi: [verbo, bersaglio, oggetto?] oppure ['choose', testo dell'opzione]. export const solution = [ - ['look', 'asta'], + ['look', 'colonna'], ['talk', 'vecchietti'], ['choose', 'Avete notato niente di strano stanotte?'], ['choose', 'Ci vediamo dopo.'], ['take', 'cosa_luccicante'], - ['walk', 'verso_salita'], + ['walk', 'vicolo'], ['use', 'impronte', 'squama'], ]; diff --git a/src/story/story.js b/src/story/story.js index 0a04811..31f5215 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -1,20 +1,15 @@ -// Storia del prototipo: due scene di prova per collaudare il motore. +// Storia del prototipo: tre scene con gli sfondi veri (Piazza Colonna, Fortezza, Trebbio) 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', -}; +// Le forme in `art` sono segnaposto disegnati sopra lo sfondo (vecchietti, squama, catena) in attesa degli sprite. export const story = { title: 'La Leggenda del Regolo', - start: { scene: 'piazza', at: [120, 300] }, + start: { scene: 'piazza', at: [250, 320] }, characters: { andrea: { name: 'Andrea', color: '#ffffff' }, vecchioA: { name: 'Vecchietto col cappello', color: '#ffd27a' }, vecchioB: { name: 'Vecchietto col bastone', color: '#9fe3ff' }, + castello: { name: 'Il Castello', color: '#c8f0a0' }, }, items: { squama: { @@ -28,43 +23,23 @@ export const story = { }, 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 }, - ], + name: 'Piazza Colonna', + 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]], + entry: [345, 240], + speakers: { vecchioA: [18, 244], vecchioB: [47, 244] }, 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 }], + id: 'colonna', name: 'colonna', box: [492, 134, 36, 140], at: [505, 290], face: 'up', 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.' }], + look: [{ say: 'La colonna, il centro del paese. Da qui passa tutto: le notizie, il Palio, i pettegolezzi.' }, { say: 'Ai piedi c\'è una strana scia luccicante che va verso il vicolo della Fortezza.' }], + take: [{ say: 'Se mi porto via la colonna, la piazza come la chiamano?' }], + use: [{ say: 'Arrampicarmi? Il drappo lassù non c\'è comunque.' }], }, }, { - 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 }], + id: 'bar', name: 'La Colonna', box: [428, 208, 30, 40], at: [443, 252], face: 'up', 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.' }], @@ -72,20 +47,18 @@ export const story = { }, }, { - 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' }], + id: 'manifesto', name: 'manifesto del Palio', box: [256, 196, 18, 24], at: [265, 250], face: 'up', + art: [{ rect: [256, 196, 18, 24], fill: '#f4efe4' }, { rect: [258, 199, 14, 5], fill: '#b7342e' }, { rect: [258, 207, 12, 2], fill: '#3b3b44' }, { rect: [258, 211, 10, 2], fill: '#3b3b44' }, { rect: [258, 215, 12, 2], fill: '#2f5fb3' }], 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.' }], + look: [{ say: 'Il programma del Palio della Fortezza. Qualcuno ci ha scritto sopra col pennarello: «IL REGOLO SI È RIPRESO IL DRAPPO».' }], + take: [{ say: 'Il programma lo so a memoria. Il problema è il drappo.' }], }, }, { - id: 'vecchietti', name: 'vecchietti', box: [430, 196, 104, 60], at: [480, 290], face: 'up', default: 'talk', + id: 'vecchietti', name: 'vecchietti', box: [2, 244, 62, 50], at: [100, 320], face: 'left', 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' }, + { rect: [8, 264, 20, 26], fill: '#5d6b8a' }, { rect: [11, 250, 14, 14], fill: '#e7b58e' }, { rect: [8, 247, 20, 5], fill: '#3e3227' }, { rect: [10, 290, 7, 14], fill: '#3a3f52' }, { rect: [19, 290, 7, 14], fill: '#3a3f52' }, + { rect: [37, 264, 20, 26], fill: '#8a6a4f' }, { rect: [40, 250, 14, 14], fill: '#e7b58e' }, { rect: [40, 248, 14, 4], fill: '#d8d8d8' }, { rect: [38, 290, 7, 14], fill: '#4a4036' }, { rect: [47, 290, 7, 14], fill: '#4a4036' }, { rect: [58, 262, 3, 44], fill: '#4a3424' }, ], actions: { look: [{ say: 'I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.' }], @@ -97,44 +70,46 @@ export const story = { }, }, { - id: 'panchina', name: 'sotto la panchina', box: [440, 256, 84, 16], at: [482, 292], face: 'up', + id: 'panchina', name: 'sotto la panchina', box: [0, 304, 64, 18], at: [100, 330], face: 'left', 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' }], + id: 'cosa_luccicante', name: 'cosa luccicante', box: [26, 308, 14, 10], at: [100, 330], face: 'left', when: ['luccichio', '!has:squama', '!squama_presa'], + art: [{ rect: [29, 311, 8, 5], fill: '#7fe08a' }, { rect: [31, 311, 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 }], + id: 'vicolo', name: 'vicolo verso la Fortezza', box: [330, 190, 34, 48], at: [345, 234], exit: { scene: 'fortezza', at: [175, 352], face: 'right' }, + }, + { + id: 'verso_trebbio', name: 'strada per il Trebbio', box: [604, 290, 36, 70], at: [632, 335], exit: { scene: 'trebbio', at: [110, 345], face: 'right' }, }, ], 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 }, - ], + fortezza: { + name: 'Sotto le mura della Fortezza', + 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]], + entry: [175, 352], 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' }], + id: 'mura', name: 'bastione', box: [420, 0, 220, 290], + actions: { + look: [{ say: 'Nove bastioni, li volle Cosimo I. Per tenere fuori i nemici, dicono. Secondo me per tenere dentro il Regolo.' }], + use: [{ say: 'Arrampicarmi sul bastione? Ho visto film che finivano meglio.' }], + }, + }, + { + id: 'porta', name: 'porta della Fortezza', box: [325, 226, 26, 46], at: [338, 278], face: 'up', + // La catena: la Fortezza è chiusa da anni. + art: [{ rect: [327, 249, 22, 2], fill: '#9a9aa4' }, { rect: [330, 252, 16, 2], fill: '#7c7c86' }, { rect: [335, 251, 6, 6], 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.' }], @@ -142,10 +117,10 @@ export const story = { }, }, { - 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' }], + id: 'impronte', name: 'strane impronte', box: [246, 304, 84, 18], at: [288, 330], + art: [{ poly: [[248, 318], [266, 310], [288, 314], [308, 306], [328, 304], [329, 307], [309, 310], [288, 318], [266, 315], [249, 321]], fill: 'rgba(70, 52, 30, 0.75)' }, { rect: [289, 314, 3, 2], fill: '#9dffa8' }], actions: { - look: [{ say: 'Niente scarpe, niente zampe. Qualcosa è strisciato su per la salita.' }, { say: 'Qualcosa di grosso.' }], + look: [{ say: 'Niente scarpe, niente zampe. Qualcosa è strisciato nell\'erba fino alla porta.' }, { say: 'Qualcosa di grosso.' }], take: [{ say: 'Le impronte non si prendono. Si seguono.' }], }, with: { @@ -159,7 +134,42 @@ export const story = { }, }, { - id: 'verso_piazza', name: 'verso la Piazza', box: [0, 270, 30, 90], at: [6, 320], exit: { scene: 'piazza', at: [600, 300], face: 'left' }, + id: 'verso_piazza', name: 'verso la Piazza', box: [120, 344, 110, 16], at: [175, 356], exit: { scene: 'piazza', at: [345, 240], face: 'left' }, + }, + ], + }, + trebbio: { + name: 'Castello del Trebbio', + background: 'assets/scenes/trebbio.png', + walk: [[86, 308], [300, 306], [356, 308], [470, 312], [500, 324], [486, 360], [88, 360], [80, 330]], + depth: [[306, 0.75], [360, 1.05]], + entry: [110, 345], + enter: [{ if: '!trebbio_visto', then: [{ set: 'trebbio_visto' }, { say: 'Il Trebbio. Quassù anche i pensieri vanno più piano.' }] }], + hotspots: [ + { + id: 'torre', name: 'torre del castello', box: [345, 26, 88, 196], at: [330, 312], face: 'up', default: 'talk', + actions: { + look: [{ say: 'La torre del Trebbio. Da ragazzo ci si rifugiò Amerigo Vespucci, per scappare dalla peste di Firenze.' }, { say: 'Poi ha trovato l\'America. Anche lui partendo da un posto tranquillo.' }], + talk: [{ dialogue: 'castello' }], + open: [{ say: 'È un castello, mica una scatola di biscotti.' }], + }, + }, + { + id: 'pergolato', name: 'pergolato', box: [278, 222, 122, 52], at: [328, 312], face: 'up', + actions: { + look: [{ say: 'Il pergolato, coi suoi pilastri. Un giorno li conto tutti.' }], + take: [{ say: 'Una vite? Ci vuole la vendemmia, non un ragazzo.' }], + }, + }, + { + id: 'scalinata', name: 'scalinata', box: [304, 276, 50, 32], at: [328, 312], face: 'up', + actions: { + look: [{ say: 'La scala che sale al pergolato.' }], + use: [{ say: 'Più tardi salgo. Per ora mi godo il silenzio.' }], + }, + }, + { + id: 'verso_paese', name: 'verso San Piero', box: [80, 318, 26, 42], at: [92, 345], exit: { scene: 'piazza', at: [628, 335], face: 'left' }, }, ], }, @@ -199,5 +209,34 @@ export const story = { }, }, }, + // Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca. + castello: { + nodes: { + start: { + options: [ + { + text: 'Sono bloccato. Mi dai un consiglio?', + then: [ + { say: 'Siediti. Guarda il Mugello. Respira.', who: 'castello' }, + { + if: '!luccichio', + then: [{ say: 'Chi sa tutto del paese di solito sta seduto. Prova a chiedere a chi passa le giornate in piazza.', who: 'castello' }], + else: [{ + if: ['!has:squama', '!squama_presa'], + then: [{ say: 'Le cose che luccicano non stanno mai in alto. Guarda in basso, vicino a chi te ne ha parlato.', who: 'castello' }], + else: [{ say: 'Una squama da sola non dice niente. Ma vicino alle sue impronte, sotto la Fortezza, magari sì.', who: 'castello' }], + }], + }, + ], + }, + { + text: 'Ma tu ce l\'hai mai visto, il Regolo?', + then: [{ say: 'Io ho visto passare i Medici, la peste e Vespucci. Un serpente in più non mi spaventa.', who: 'castello' }], + }, + { text: 'Niente, volevo solo un po\' di pace.', next: 'end', then: [{ say: 'Quella qui non manca mai. Torna quando vuoi.', who: 'castello' }] }, + ], + }, + }, + }, }, }; diff --git a/tests/browser.mjs b/tests/browser.mjs index a48d20b..b69a2e1 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -68,7 +68,7 @@ try { 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` }); + if (target === 'vicolo') await page.screenshot({ path: `${OUT}/05-fortezza.png` }); } await page.waitForSelector('#ending:not([hidden])'); await page.screenshot({ path: `${OUT}/06-fine.png` }); @@ -78,24 +78,33 @@ try { // 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 clickScene(page, await hotspotCenter(page, 'verso_trebbio')); + await settle(page); + await page.click('#verbs [data-verb="talk"]'); + await clickScene(page, await hotspotCenter(page, 'torre')); + await page.locator('#choices li', { hasText: 'bloccato' }).click(); + await page.waitForFunction(() => game.speech?.who === 'castello'); + await page.waitForTimeout(100); + await page.screenshot({ path: `${OUT}/07-trebbio.png` }); + await settle(page); + await page.locator('#choices li', { hasText: 'pace' }).click(); 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'); + assert.equal(await page.evaluate(() => game.state.scene), 'trebbio'); 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` }); + await phone.screenshot({ path: `${OUT}/08-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 }); + await landscape.screenshot({ path: `${OUT}/09-orizzontale.png`, fullPage: true }); assert.deepEqual(errors, [], 'errori nella console'); console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`); diff --git a/tests/command.test.mjs b/tests/command.test.mjs index 8c1b5ef..a767971 100644 --- a/tests/command.test.mjs +++ b/tests/command.test.mjs @@ -14,8 +14,8 @@ const setup = () => { 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.sentence('manifesto'), 'Guarda manifesto del Palio'); + assert.equal(c.sentence('vicolo'), 'Vai a vicolo verso la Fortezza'); assert.equal(c.clickScene(at('vecchietti')), 'act'); settle(g); assert.equal(g.dialogue?.id, 'vecchietti'); @@ -25,10 +25,10 @@ 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.sentence('colonna'), 'Prendi colonna'); + c.clickScene(at('colonna')); assert.equal(c.verb, null); - assert.match(settle(g)[0].text, /rincorrerebbero/); + assert.match(settle(g)[0].text, /come la chiamano/); }); test('pressione lunga o clic destro guarda', () => { diff --git a/tests/story.test.mjs b/tests/story.test.mjs index 1fa7a99..91aa9e4 100644 --- a/tests/story.test.mjs +++ b/tests/story.test.mjs @@ -4,6 +4,7 @@ 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 { existsSync } from 'node:fs'; import { settle } from './helpers.mjs'; const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end']; @@ -34,6 +35,7 @@ test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esist 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`); + if (scene.background) assert.ok(existsSync(new URL('../' + scene.background, import.meta.url)), `${id}: manca ${scene.background}`); const ids = new Set(); for (const h of scene.hotspots ?? []) { assert.ok(!ids.has(h.id), `${id}.${h.id} doppio`); diff --git a/tools/prepare_background.py b/tools/prepare_background.py new file mode 100644 index 0000000..74fa280 --- /dev/null +++ b/tools/prepare_background.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Porta uno sfondo generato alla risoluzione del gioco (640x360, o il doppio in larghezza per le panoramiche). + + python3 tools/prepare_background.py assets/raw/fortezza-esterno.png assets/scenes/fortezza-esterno.png \ + --clone 625,332,12,12,-14,0 + +--crop x,y,w,h ritaglio nel file originale prima di ridurre (per immagini non in 16:9) +--clone x,y,w,h,dx,dy copia sopra il riquadro x,y,w,h i pixel spostati di dx,dy (coordinate finali): + serve a coprire filigrane o difetti con un pezzo di sfondo vicino; si può ripetere +--colors N riduce la tavolozza a N colori senza retinatura (0 = lascia i colori come sono) +""" +import argparse +from PIL import Image + + +def box(text, n): + values = [int(v) for v in text.split(',')] + if len(values) != n: + raise argparse.ArgumentTypeError(f'servono {n} numeri separati da virgola') + return values + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument('src') + p.add_argument('dst') + p.add_argument('--size', default='640x360') + p.add_argument('--crop', type=lambda t: box(t, 4)) + p.add_argument('--clone', type=lambda t: box(t, 6), action='append', default=[]) + p.add_argument('--colors', type=int, default=0) + a = p.parse_args() + + w, h = (int(v) for v in a.size.split('x')) + im = Image.open(a.src).convert('RGB') + if a.crop: + x, y, cw, ch = a.crop + im = im.crop((x, y, x + cw, y + ch)) + if abs(im.width / im.height - w / h) > 0.02: + raise SystemExit(f'proporzioni {im.width}x{im.height} diverse da {w}x{h}: usa --crop') + # BOX fa la media dei pixel di ogni blocco: niente aloni e niente sfocatura in più. + im = im.resize((w, h), Image.BOX) + for x, y, cw, ch, dx, dy in a.clone: + im.paste(im.crop((x + dx, y + dy, x + dx + cw, y + dy + ch)), (x, y)) + if a.colors: + im = im.quantize(a.colors, dither=Image.Dither.NONE).convert('RGB') + im.save(a.dst, optimize=True) + print(f'{a.dst}: {w}x{h}') + + +if __name__ == '__main__': + main()