Sfondi veri di Piazza Colonna, Fortezza e Trebbio, aiuti del castello, Docker

Tre scene con aree calpestabili e punti cliccabili ricavati dagli sfondi,
tools/prepare_background.py per ridurli a 640x360, il castello del Trebbio
che dà indizi in base ai progressi, container nginx sulla porta 8080.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 00:32:32 +02:00
co-authored by Claude Opus 5.5
parent 826f5f99ec
commit a855b5805f
18 changed files with 325 additions and 94 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
node_modules
assets/raw
prompts
tests
docs
_site
+10
View File
@@ -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
+43
View File
@@ -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
+9 -1
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

+8
View File
@@ -0,0 +1,8 @@
services:
leggenda-regolo:
build: .
image: leggenda-regolo:latest
container_name: leggenda-regolo
ports:
- "${DOCKER_PORT:-8080}:80"
restart: unless-stopped
+4 -4
View File
@@ -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.
+40
View File
@@ -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";
}
}
+14 -1
View File
@@ -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;
+2 -1
View File
@@ -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.'); });
+3 -3
View File
@@ -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'],
];
+113 -74
View File
@@ -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' }] },
],
},
},
},
},
};
+14 -5
View File
@@ -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}`);
+5 -5
View File
@@ -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', () => {
+2
View File
@@ -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`);
+51
View File
@@ -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()