Musica, vecchietti con sprite, nuove scene del paese e generatore degli sfondi

Lavoro della sessione precedente: colonna sonora (titolo, paese, ricordi) con muto e volume,
vecchietta e vecchietto sulla panchina, scene di Pieve, oratorio, Via de' Medici, Villa Adami,
salita, campeggio, Bosco ai Frati e viale dei cipressi, panorama dell'introduzione,
tools/genera.py per gli sfondi dalle foto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdeKd62opy9PKgpQXtVctF
This commit is contained in:
2026-10-23 12:59:57 +02:00
co-authored by Claude Opus 5.5
parent 5eeb634ceb
commit 33e2598718
45 changed files with 1556 additions and 78 deletions
+3
View File
@@ -3,3 +3,6 @@ prompts/
_site/
__pycache__/
node_modules/
assets/generated/
.venv/
.env
+1
View File
@@ -6,6 +6,7 @@ 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
COPY assets/characters /usr/share/nginx/html/assets/characters
COPY assets/audio /usr/share/nginx/html/assets/audio
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
+74 -7
View File
@@ -1,4 +1,4 @@
# LA LEGGENDA DEL REGOLO — comandi di sviluppo, verifica e Docker.
# LA LEGGENDA DEL REGOLO — comandi di sviluppo, verifica, grafica e Docker.
# `make` o `make help` elenca i target.
SHELL := /bin/bash
@@ -6,27 +6,80 @@ SHELL := /bin/bash
PORT ?= 8000
DOCKER_PORT ?= 8080
IMAGE ?= leggenda-regolo
TAG ?= latest
COMPOSE ?= DOCKER_PORT=$(DOCKER_PORT) docker compose
PYTHON ?= python3
NODE ?= node
# Personaggio da ritagliare: make character NAME=andrea (foglio in assets/raw/$(NAME)-sheet.png, 4x2)
NAME ?= andrea
SHEET ?= assets/raw/$(NAME)-sheet.png
HEIGHT ?= 120
FRAMES ?= walk_1,walk_2,walk_3,walk_4,stand,talk,take,back
REF ?= stand
# Sfondo da preparare: make background SRC=assets/generated/pieve-dalla-salita.png (CROP=x,y,w,h facoltativo)
SRC ?=
DST ?= assets/scenes/$(notdir $(SRC))
CROP ?=
COLORS ?= 64
.PHONY: help serve test browser verify up down restart logs ps
.PHONY: help serve open test browser verify sfondi sfondi-genera sfondi-confronto background character \
build up down restart logs ps shell image-size clean distclean
## ------------------------------------------------------------ sviluppo
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}'
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
@echo; echo "Variabili: PORT=$(PORT) DOCKER_PORT=$(DOCKER_PORT) IMAGE=$(IMAGE):$(TAG)"
serve: ## Avvia il server statico locale (PORT=8000)
@echo "→ http://localhost:$(PORT)"
python3 -m http.server $(PORT) --bind 0.0.0.0
$(PYTHON) -m http.server $(PORT) --bind 0.0.0.0
test: ## Test della logica, della storia e della soluzione completa
node --test tests/*.test.mjs
open: ## Apre il gioco nel browser predefinito (serve già avviato)
@xdg-open "http://localhost:$(PORT)" >/dev/null 2>&1 || open "http://localhost:$(PORT)"
## ------------------------------------------------------------ verifica
test: ## Test della logica, della storia, della soluzione completa e del generatore
$(NODE) --test tests/*.test.mjs
$(PYTHON) -m unittest discover -s tests -p "test_*.py" -q
browser: ## Partita completa nel browser con Playwright
node tests/browser.mjs
$(NODE) tests/browser.mjs
verify: test browser ## Tutti i controlli
## ------------------------------------------------------------ grafica
sfondi: ## Prova a secco del generatore: quali foto di assets/raw/foto/ genererebbe (nessuna API)
$(PYTHON) tools/genera.py --dry-run
sfondi-genera: ## Genera davvero gli sfondi con l'API (serve OPENAI_API_KEY; ogni immagine si paga)
@test -n "$$OPENAI_API_KEY" || { echo "manca OPENAI_API_KEY nell'ambiente"; exit 2; }
$(PYTHON) tools/genera.py
sfondi-confronto: ## Rifà il confronto foto/immagini in assets/generated/index.html e lo apre
$(PYTHON) tools/genera.py --gallery-only
@xdg-open assets/generated/index.html >/dev/null 2>&1 || open assets/generated/index.html
background: ## Porta uno sfondo a 640x360 nel gioco (SRC=..., DST=..., CROP=x,y,w,h, COLORS=64)
@test -n "$(SRC)" || { echo "uso: make background SRC=assets/generated/nome.png [CROP=x,y,w,h]"; exit 2; }
$(PYTHON) tools/prepare_background.py $(SRC) $(DST) $(if $(CROP),--crop $(CROP)) --colors $(COLORS)
character: ## Ritaglia un foglio sprite 4x2 e prepara il personaggio (NAME=andrea, SHEET=..., HEIGHT=120, FRAMES=..., REF=stand)
$(PYTHON) tools/slice_sprites.py --sheet $(SHEET) --out /tmp/regolo-$(NAME) --cols 4 --rows 2 \
--despill --names $(FRAMES) --prefix $(NAME)_
$(PYTHON) tools/prepare_character.py /tmp/regolo-$(NAME) $(NAME) assets/characters --height $(HEIGHT) \
--frames $(FRAMES) --ref $(REF)
## ------------------------------------------------------------ docker
build: ## Costruisce l'immagine Docker
docker build -t $(IMAGE):$(TAG) .
up: ## Avvia il container (DOCKER_PORT=8080)
$(COMPOSE) up -d --build
@echo "→ http://localhost:$(DOCKER_PORT)"
@@ -41,3 +94,17 @@ logs: ## Segue i log del container
ps: ## Stato del container
$(COMPOSE) ps
shell: ## Shell dentro il container
$(COMPOSE) exec leggenda-regolo sh
image-size: ## Dimensione dell'immagine
@docker image ls $(IMAGE):$(TAG) --format '{{.Repository}}:{{.Tag}} {{.Size}}'
## ------------------------------------------------------------ pulizia
clean: ## Rimuove file temporanei
find . -name '__pycache__' -type d -prune -exec rm -rf {} +
distclean: clean down ## clean + container + immagine Docker
-docker image rm $(IMAGE):$(TAG)
+11
View File
@@ -8,6 +8,17 @@ Il palio, il quadro dipinto dai ragazzi delle medie, è sparito: un ragazzo del
Progetto in fase di ideazione: il documento di progetto è in [`docs/PROGETTO.md`](docs/PROGETTO.md).
Per ora c'è un prototipo del motore con due scene di prova e grafica provvisoria.
## Generare sfondi dalle fotografie
Il generatore Python è `tools/genera.py`; istruzioni e prompt delle due nuove vedute
sono descritti in [`docs/GENERAZIONE_SFONDI.md`](docs/GENERAZIONE_SFONDI.md).
Lavora sulle foto di **San Piero a Sieve**, conserva i master a 1536×864 e permette
di prepararne copie a 640×360 per il gioco.
```zsh
python3 tools/genera.py --dry-run # elenco delle foto, nessuna API e nessun file scritto
```
## Giocare in locale
```zsh
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20 -20
View File
@@ -5,66 +5,66 @@
"walk_1": {
"x": 0,
"y": 0,
"w": 65,
"w": 64,
"h": 111,
"ax": 36.6,
"ax": 36.3,
"ay": 111
},
"walk_2": {
"x": 67,
"x": 66,
"y": 0,
"w": 35,
"w": 34,
"h": 111,
"ax": 17.8,
"ay": 111
},
"walk_3": {
"x": 104,
"x": 102,
"y": 0,
"w": 64,
"h": 111,
"ax": 34.5,
"ax": 34.7,
"ay": 111
},
"walk_4": {
"x": 170,
"x": 168,
"y": 0,
"w": 38,
"h": 111,
"ax": 21.5,
"ax": 21.7,
"ay": 111
},
"stand": {
"x": 210,
"x": 208,
"y": 0,
"w": 34,
"w": 33,
"h": 120,
"ax": 15.1,
"ax": 14.9,
"ay": 120
},
"talk": {
"x": 246,
"x": 243,
"y": 0,
"w": 56,
"h": 121,
"ax": 20.5,
"ay": 121
"w": 59,
"h": 120,
"ax": 21.6,
"ay": 120
},
"take": {
"x": 304,
"y": 0,
"w": 68,
"h": 97,
"ax": 34.7,
"ax": 34.5,
"ay": 97
},
"back": {
"x": 374,
"y": 0,
"w": 43,
"h": 120,
"ax": 21.0,
"ay": 120
"h": 119,
"ax": 20.8,
"ay": 119
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

+70
View File
@@ -0,0 +1,70 @@
{
"image": "vecchietta.png",
"height": 96,
"frames": {
"sit": {
"x": 0,
"y": 0,
"w": 52,
"h": 96,
"ax": 20.3,
"ay": 96
},
"blink": {
"x": 54,
"y": 0,
"w": 52,
"h": 96,
"ax": 20.1,
"ay": 96
},
"talk": {
"x": 108,
"y": 0,
"w": 52,
"h": 96,
"ax": 20.1,
"ay": 96
},
"explain": {
"x": 162,
"y": 0,
"w": 56,
"h": 96,
"ax": 23.3,
"ay": 96
},
"point": {
"x": 220,
"y": 0,
"w": 63,
"h": 95,
"ax": 28.2,
"ay": 95
},
"laugh": {
"x": 285,
"y": 0,
"w": 54,
"h": 95,
"ax": 22.5,
"ay": 95
},
"look_down": {
"x": 341,
"y": 0,
"w": 58,
"h": 86,
"ax": 32.9,
"ay": 86
},
"arms_crossed": {
"x": 401,
"y": 0,
"w": 52,
"h": 96,
"ax": 16.3,
"ay": 96
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+70
View File
@@ -0,0 +1,70 @@
{
"image": "vecchietto.png",
"height": 96,
"frames": {
"sit": {
"x": 0,
"y": 0,
"w": 76,
"h": 96,
"ax": 31.4,
"ay": 96
},
"doze": {
"x": 78,
"y": 0,
"w": 76,
"h": 96,
"ax": 31.5,
"ay": 96
},
"talk": {
"x": 156,
"y": 0,
"w": 67,
"h": 96,
"ax": 25.1,
"ay": 96
},
"explain": {
"x": 225,
"y": 0,
"w": 62,
"h": 96,
"ax": 27.4,
"ay": 96
},
"point": {
"x": 289,
"y": 0,
"w": 74,
"h": 97,
"ax": 31.5,
"ay": 97
},
"laugh": {
"x": 365,
"y": 0,
"w": 70,
"h": 97,
"ax": 23.2,
"ay": 97
},
"cat": {
"x": 437,
"y": 0,
"w": 67,
"h": 95,
"ax": 24.1,
"ay": 95
},
"shrug": {
"x": 506,
"y": 0,
"w": 81,
"h": 97,
"ax": 38.9,
"ay": 97
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

+168
View File
@@ -0,0 +1,168 @@
# Sfondi dalle foto — La Leggenda del Regolo
Il prompt iniziale parlava di Calenzano: questo progetto è ambientato a **San
Piero a Sieve, Mugello, fine maggio 2007**. Lo stile è quello delle avventure VGA
del 1993, coerente con `prompts/STILE.md`: pixel nitidi, tavolozza calda limitata,
ambienti vuoti. La prospettiva e l'architettura della fotografia hanno priorità
rispetto alla vecchia indicazione generica «vista laterale come un teatro».
## Cartelle
| Percorso | Contenuto |
| --- | --- |
| `assets/raw/foto/` | Foto JPG, JPEG, PNG e WEBP; estensioni anche maiuscole; scansione non ricorsiva |
| `assets/raw/foto/da-fare/` | Oratorio, escluso dal batch per ora |
| `prompts/backgrounds/base.txt` | Stile comune; se assente si usa quello incluso in `tools/genera.py` |
| `prompts/backgrounds/<nome-foto>.txt` | Istruzioni della scena, aggiunte allo stile comune |
| `assets/generated/<nome-foto>.png` | Risultato 16:9, 1536×864, stesso nome base dell'originale |
| `assets/generated/.responses/` | Risposte originali conservate per riprendere senza ripagare una generazione |
| `assets/generated/registro.json` | Stato, prompt completo, hash foto, modello, tentativi, errori e uso quando disponibile |
| `assets/generated/index.html` | Confronto prima/dopo di tutte le foto in input, anche quelle ancora da generare |
| `assets/scenes/` | Sfondi del gioco a 640×360, preparati separatamente dopo revisione |
Foto, prompt e risultati intermedi sono esclusi da Git, secondo le convenzioni
del progetto. Per trasferire il lavoro su un altro computer servono anche queste
cartelle locali: il solo repository non include le foto e i prompt specifici.
## Installazione
Python 3.10 o successivo:
```zsh
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install -r requirements.txt
```
Per le chiamate reali il processo deve ricevere `OPENAI_API_KEY` nell'ambiente.
Non salvare la chiave nel codice, nei prompt o in Git. Il programma non carica
file `.env` e non stampa la chiave. In zsh puoi inserirla senza mostrarla o
registrarla nella cronologia:
```zsh
read -rs 'OPENAI_API_KEY?Chiave OpenAI: '
export OPENAI_API_KEY
```
## Comandi
Eseguire dalla radice del repository (i percorsi predefiniti sono comunque
relativi allo script, non alla directory da cui lo si lancia):
```zsh
python3 tools/genera.py --dry-run
python3 tools/genera.py --test --dry-run
python3 tools/genera.py --limit 10 --dry-run
python3 tools/genera.py --gallery-only
```
Questi comandi non chiamano l'API. `--dry-run` non scrive nulla e non richiede
SDK o chiave. `--gallery-only` scrive soltanto il confronto HTML.
Per generare, dopo aver controllato l'elenco:
```zsh
python3 tools/genera.py --test # prima foto non ancora completata, in ordine alfabetico
python3 tools/genera.py --limit 10 # al massimo dieci foto ancora da completare
python3 tools/genera.py # tutte le foto ancora da completare (ora solo le due nuove vedute)
```
Ogni esecuzione reale mostra il conteggio, il modello, la qualità e il massimo
numero di tentativi. Richiede di scrivere **GENERA** prima di contattare OpenAI.
Una risposta vuota, diversa o EOF annulla l'esecuzione. Non esiste conferma
automatica. `--test` genera davvero una foto: per simulare aggiungere `--dry-run`.
Per usare le cartelle del prompt originale:
```zsh
python3 tools/genera.py --input-dir originali --output-dir generate --prompt-dir prompt --dry-run
```
Opzioni: `--model gpt-image-2`, `--quality high` (anche `low`, `medium`, `auto`),
`--timeout 180` secondi, `--attempts 3` richieste massime per foto, prima inclusa.
Il batch è sequenziale; non usa il servizio Batch API. `--test` e `--limit` sono
alternativi. Foto con uguale nome base, anche con estensioni diverse, causano un
errore preventivo per evitare collisioni sui PNG.
## API e recupero errori
Usa l'OpenAI Python SDK e `client.images.edit`, modello predefinito
`gpt-image-2`, `size="1536x864"`, `output_format="png"`, `n=1`.
Questo formato è previsto dalla [documentazione ufficiale di Images Edit](https://developers.openai.com/api/reference/resources/images/methods/edit).
Non viene passato `input_fidelity`: per questo modello gli input hanno già alta
fedeltà, come indicato nella [guida ufficiale](https://developers.openai.com/api/docs/guides/image-prompting).
Nessun cambio automatico di modello in caso di errore.
Pillow applica l'orientamento EXIF e converte la foto in PNG per l'invio, senza
modificare l'originale. Il risultato viene controllato e, solo se già in 16:9,
normalizzato a 1536×864 con nearest-neighbor. Risposte con proporzioni diverse
restano nella cache per revisione: non vengono stirate o ritagliate di nascosto.
Timeout, errori di connessione, 408, 409, 429 e 5xx vengono ritentati con attesa
esponenziale limitata; gli altri errori HTTP non vengono ritentati. I retry
automatici dell'SDK sono disattivati per rispettare il numero richiesto.
**Un timeout può verificarsi dopo che il server ha elaborato l'immagine: riprovare
può comportare un altro addebito.** Non viene inventata una stima in euro;
il costo dipende da input, output, qualità e tariffa del modello.
I PNG esistenti vengono saltati anche senza registro. Prima di chiamare l'API
vengono verificati: se sono corrotti o di formato diverso, il programma si ferma
senza sovrascriverli. Una modifica al prompt non rigenera immagini completate:
per una variante usare un'altra `--output-dir`. Se manca il risultato finale ma
esiste una risposta nella cache, la finalizzazione riparte da quella senza API.
Non avviare due processi sulla stessa cartella di output contemporaneamente.
Il registro si aggiorna prima di ogni tentativo e dopo ogni foto, tramite
sostituzione atomica. Gli errori SDK sono registrati per tipo/codice, senza
corpo della risposta o intestazioni. Un errore su una foto non blocca le altre;
l'uscita è 1 se ci sono errori, 130 se si interrompe con Ctrl-C.
## Le due scene preparate
1. **`pieve-dalla-salita`**: la Pieve vista tornando con lo sguardo verso il basso
dalla salita. Conservare facciata, campanile, canonica, statua, cancello,
parapetti e pendenza. Togliere persone, auto e allestimenti temporanei della
cena. Villa Adami e Fortezza sono più su, dietro la camera: non inserirle
artificialmente nell'immagine.
2. **`salita-villa-adami`**: casa ocra, persiane, leone di terracotta, ringhiera,
muro di sostegno e bivio. Conservare la strada bassa a sinistra e la salita a
destra che curva lungo il muro. La foto verticale richiede estensione
laterale: le parti non fotografate saranno una ricostruzione da controllare.
Rimuovere le auto, lasciare leggibile il bivio. La Fortezza non è visibile
nella foto e non va inventata sullo sfondo.
L'oratorio resta per dopo. Il percorso indicato da Andrea è **Pieve → Villa
Adami → salita → Fortezza**. Questo materiale documenta la geografia corretta;
il prototipo conserva per ora i suoi collegamenti di prova.
Le prime due immagini sono state generate il 23 ottobre 2026 **con lo strumento
immagini integrato**, dopo aver completato la preparazione e i test senza rete.
Lo script Python non ha effettuato chiamate API reali. I prompt effettivamente
usati sono salvati nei due file `*.integrated.txt` e nel registro, con
`assets/scenes/piazza-colonna.png` come ulteriore riferimento di stile.
Il modello interno dello strumento non è dichiarato: nel registro non viene
attribuito a `gpt-image-2`. I file restituiti a 1672×941 sono conservati integri
in `.responses/`, con copie normalizzate a 1536×864 e copie per il gioco a
640×360/64 colori. Il generatore li riconosce come completati e li salta.
## Preparare i file del gioco
Dopo aver controllato visivamente i master, riusare lo strumento già presente:
```zsh
python3 tools/prepare_background.py assets/generated/pieve-dalla-salita.png assets/scenes/pieve-dalla-salita.png --colors 64
python3 tools/prepare_background.py assets/generated/salita-villa-adami.png assets/scenes/salita-villa-adami.png --colors 64
```
Le copie hanno 640×360 pixel e al massimo 64 colori, senza retinatura. Il motore
le ingrandisce senza sfocatura. Gli sfondi non vengono collegati automaticamente
alla storia: vanno definite aree calpestabili e uscite sulla composizione finale.
## Verifica senza rete
```zsh
python3 -m unittest discover -s tests -p 'test_genera.py'
```
I test usano foto sintetiche e un client simulato: nessuna chiave vera, nessuna
richiesta API, nessun costo. La verifica reale del modello resta distinta.
+23
View File
@@ -84,6 +84,29 @@ notare. Andrea lo convince a restituirlo… e il Regolo, per la prima volta, ass
| **Castello del Trebbio** | Il posto della pace e degli aiuti (vedi sotto) | villa-castello medicea, la più antica dei Medici in Mugello |
| **Fortezza di San Martino** | Chiusa fino al finale: sotterranei, buio, leggenda | voluta da Cosimo I nel 1569, finita sotto Ferdinando I; nove bastioni, Porta Fiorentina (sud) e Porta Bolognese (nord), un cassero vicino alla porta fiorentina; chiusa dopo il terremoto del 1762, abitata poi dai contadini |
### La mappa: il paese a gradini
Descritta da Andrea (ottobre 2026). San Piero sale a gradini dalla piazza alla Fortezza:
```
Livello 2 Fortezza (fuori dalle mura) ── viale dei cipressi ── Trebbio
│ sale
Salita di Villa Adami (casa col leone di terracotta) ── verso la scuola
│ scende
Livello 1 Villa Adami (villa gialla, persiane verdi, torretta)
╱ a sinistra: Via de' Medici ╲ a destra
Livello 0 Piazza Colonna ───────────────────── Pieve ── porta della canonica ── Oratorio
│ a diritto │ dietro, a destra: verso il Fiocco di Neve (gelateria)
Ingresso del campeggio ── a destra, sale ── Bosco ai Frati
```
- Da Villa Adami, scendendo, la Piazza è a sinistra e la Pieve a destra; Piazza e Pieve sono collegate in basso.
- Dalla Piazza Villa Adami è a sinistra; a diritto si va verso il campeggio e, dopo il campeggio, a destra si sale
verso il **Bosco ai Frati** (scene da fare: Andrea manda le foto). Per ora la strada del campeggio è chiusa con una battuta.
- Dalla salita di Villa Adami una strada scende verso la scuola: per ora è chiusa con una battuta.
- Il Trebbio si vede dal sentiero, lontano in cima al colle boscoso.
- Foto di riferimento in `assets/raw/foto/` (esclusa da git).
### Il Castello del Trebbio: "lo psicologo"
Per Andrea il Trebbio è il posto dove salire a cercare pace (scherzando: il suo psicologo). Nel gioco diventa
+32 -2
View File
@@ -9,8 +9,13 @@ Ultimo aggiornamento: 23 ottobre 2026. Da leggere insieme a `PROGETTO.md` all'in
- **Motore** (`src/engine/`): camminata con percorsi su aree concave, profondità, sette verbi + clic intelligente,
clic destro / pressione lunga = guarda, inventario con "usa… con" e "dai… a", dialoghi a scelte (anche una
tantum), salvataggio automatico con "Continua", fumetti sopra chi parla, impaginazione desktop e telefono.
- **Scene con sfondi veri** (`assets/scenes/`, preparati con `tools/prepare_background.py`):
Piazza Colonna → vicolo → Fortezza (fuori dalle mura) → salita → Castello del Trebbio.
- **Scene con sfondi veri** (`assets/scenes/`), collegate come il paese a gradini (`PROGETTO.md`, «La mappa»):
Piazza Colonna ↔ Pieve (livello 0) → Villa Adami (livello 1) → salita di
Villa Adami con la casa del leone → Fortezza (fuori dalle mura) → sentiero → Castello del Trebbio.
- **Altre scene** (ottobre 2026, dalle foto di Andrea con Codex): Via de' Medici, Oratorio, Ingresso del campeggio,
Bosco ai Frati, viale dei cipressi. Schermata del titolo (Fortezza al tramonto col Regolo) e introduzione a righe sul
panorama del Mugello.
- **Musica** di Andrea (Suno) in `assets/audio/`: titolo e introduzione, paese, ricordi (Trebbio e fine); volume e muto.
- **Andrea vero** dal foglio sprite (`assets/characters/`, `tools/slice_sprites.py` + `tools/prepare_character.py`):
cammina, parla, si china a prendere, di spalle davanti alle porte.
- **Storia di prova** (`src/story/story.js`): il palio è sparito, i vecchietti litigano sulle due leggende, la squama
@@ -33,8 +38,33 @@ Ultimo aggiornamento: 23 ottobre 2026. Da leggere insieme a `PROGETTO.md` all'in
I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso da git.
### Nuove fotografie e generatore
- Le tre foto aggiunte in `assets/raw/temp/` sono archiviate senza modifiche in
`assets/raw/foto/`: `pieve-dalla-salita.jpg`, `salita-villa-adami.jpg` e
`da-fare/oratorio-pieve.jpg` (oratorio rimandato).
- Geografia precisata da Andrea: dalla Pieve si sale lasciandola alle spalle,
si sbuca a Villa Adami e si prende la salita verso la Fortezza. La seconda
foto mostra il bivio presso Villa Adami, non le mura della Fortezza.
- `tools/genera.py` prepara batch via Images Edit (`gpt-image-2`), master 1536×864,
prompt in `prompts/backgrounds/`, registro e confronto in `assets/generated/`.
Guida: `docs/GENERAZIONE_SFONDI.md`. Preparazione verificata senza chiamate API,
con test offline e `--dry-run`; la generazione reale richiede conferma.
- Generate le due vedute con lo strumento immagini integrato, dopo la preparazione:
master in `assets/generated/`, copie a 640×360 e 64 colori in
`assets/scenes/pieve-dalla-salita.png` e `assets/scenes/salita-villa-adami.png`.
Prompt effettivi `prompts/backgrounds/*.integrated.txt`; provenienza nel registro.
Da valutare prima di collegarle alla storia e definire camminata e hotspot.
Il percorso API dello script è verificato con un client simulato, non con chiamate reali.
## Cosa manca
### Prossimi passi
- Vecchietta e vecchietto sono in gioco sulla panchina della Piazza (`assets/characters/`, da
`assets/raw/vecchiett?-sheet.png`): manca il consenso delle persone ritratte prima di pubblicare, e i loro nomi.
- Andrea rifatto dalla sua foto del 2007 (`assets/raw/andrea-sheet-prova-2.png`; il vecchio foglio resta in `andrea-sheet.png`).
- Strada del campeggio (poi Bosco ai Frati) e strada della scuola: per ora battute, scene da fare con le foto di Andrea.
### Domande per Andrea
1. Cosa c'era dipinto sul palio del 2007? (Un'immagine nuova: quadro ingenuo dei ragazzi delle medie, senza
cornice, su sfondo verde.)
+9
View File
@@ -11,12 +11,21 @@
<div id="stage">
<canvas id="scene" width="640" height="360" aria-label="La scena dell'avventura"></canvas>
<div id="speech" aria-live="polite"></div>
<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">
</div>
<section id="title" class="overlay">
<h1>La Leggenda<br>del <span>Regolo</span></h1>
<p class="subtitle">San Piero a Sieve · Palio della Fortezza · 2007</p>
<div class="buttons"><button id="continue" hidden>Continua</button><button id="new-game">Nuova partita</button></div>
<p class="dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
</section>
<section id="intro" class="overlay" hidden>
<p id="intro-text" aria-live="polite"></p>
<p class="hint">clic o spazio per continuare</p>
<button id="skip-intro">Salta</button>
</section>
<section id="ending" class="overlay" hidden>
<h2>Fine del prototipo</h2>
<p>Il Regolo è passato di qui. Il resto della storia è ancora da scrivere.</p>
+2
View File
@@ -0,0 +1,2 @@
openai>=2.0.0,<3
Pillow>=10.0.0,<13
+15 -1
View File
@@ -76,6 +76,7 @@ export class Game {
hotspots() { return (this.scene.hotspots ?? []).filter(h => check(h.when, this.state)); }
hotspot(id) { return this.hotspots().find(h => h.id === id); }
people() { return (this.scene.people ?? []).filter(p => check(p.when, this.state)); }
hotspotAt(p) {
const list = this.hotspots();
for (let i = list.length - 1; i >= 0; i--) if (inBox(list[i].box, p)) return list[i];
@@ -166,6 +167,19 @@ export class Game {
return this.back ? 'back' : 'stand';
}
// Il fotogramma di un personaggio della scena (scene.people): quando parla alterna `talk`, o tiene la posa
// chiesta dalla battuta ({ say, who, pose }); da fermo ogni `every` secondi fa `blink` per un attimo.
personPose(p) {
const idle = p.idle ?? 'sit';
if (this.speech?.who === p.who) {
if (this.speech.pose) return this.speech.pose;
const talk = p.talk ?? ['talk'];
return talk[Math.floor(this.time / 0.3) % talk.length];
}
const every = p.every ?? 4, t = (this.time + (p.offset ?? 0)) % every;
return p.blink && t > every - 0.25 ? p.blink : idle;
}
scaleAt(y) {
const d = this.scene.depth;
if (!d) return 1;
@@ -218,7 +232,7 @@ export class Game {
run(e) {
const s = this.state;
if (e.say != null) this.speech = { text: e.say, who: e.who ?? 'andrea', left: sayTime(e.say) };
if (e.say != null) this.speech = { text: e.say, who: e.who ?? 'andrea', pose: e.pose, left: sayTime(e.say) };
else if (e.walk) { this.task = e; this.startWalk(e.walk); }
// `go` prima di `face`: un cambio scena porta con sé il verso d'arrivo.
else if (e.go) this.go(e.go, e.at, e.face);
+68
View File
@@ -0,0 +1,68 @@
// Colonna sonora: un brano alla volta, in loop, con dissolvenza quando cambia.
// Quale brano suona lo decidono i dati (story.music e scene.music). Si prova a partire subito; se il browser non lo
// permette prima di un gesto del giocatore, il brano riparte al primo clic o tasto (unlock).
// Il brano del momento: titolo, fine, oppure quello della scena (o quello comune del gioco).
export function trackFor(story, { screen, scene }) {
const m = story.music ?? {};
if (screen === 'title' || screen === 'intro') return m.title ?? null;
if (screen === 'ending') return m.ending ?? m.game ?? null;
return story.scenes[scene]?.music ?? m.game ?? null;
}
const FADE = 1.2;
export class Music {
constructor({ muted = false, volume = 0.6 } = {}) {
this.muted = muted;
this.volume = volume;
this.blocked = null;
this.want = null;
this.current = null; // { src, audio }
this.old = [];
}
// Chiamata a ogni fotogramma con il brano voluto: avvia, cambia e sfuma.
update(src, dt) {
this.want = src;
if (src !== this.current?.src) {
if (this.current) this.old.push(this.current.audio);
this.current = src ? { src, audio: this.start(src) } : null;
}
// Il brano in corso sale (o scende) verso il volume scelto; quelli vecchi sfumano fino a zero.
const step = dt / FADE, target = this.muted ? 0 : this.volume;
if (this.current) {
const a = this.current.audio;
a.volume = a.volume < target ? Math.min(target, a.volume + step) : Math.max(target, a.volume - step);
}
for (const a of this.old) a.volume = Math.max(0, a.volume - step);
this.old = this.old.filter(a => a.volume > 0 || (a.pause(), false));
}
start(src) {
const audio = new Audio(src);
audio.loop = true;
audio.volume = 0;
audio.play().catch(() => { this.blocked = audio; }); // senza permesso si riprova al primo gesto
return audio;
}
unlock() {
const a = this.blocked;
this.blocked = null;
if (a && a === this.current?.audio) a.play().catch(() => {});
}
toggle() {
this.muted = !this.muted;
return this.muted;
}
setVolume(v) {
this.volume = Math.max(0, Math.min(1, v));
if (this.volume > 0) this.muted = false;
return this.volume;
}
settings() { return { volume: this.volume, muted: this.muted }; }
}
+12 -5
View File
@@ -84,10 +84,10 @@ export function loadAssets(story) {
return Promise.all([...scenes, ...people]);
}
// Il personaggio alto PERSON pixel a grandezza 1, con i piedi in (x, y); rivolto a sinistra si specchia.
// Il personaggio alto PERSON pixel a grandezza 1 (per `size` 1), con i piedi in (x, y); rivolto a sinistra si specchia.
const PERSON = 62;
function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing) {
const f = atlas.frames[frame] ?? atlas.frames.stand, k = PERSON * scale / atlas.height;
function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing, size = 1) {
const f = atlas.frames[frame] ?? atlas.frames.stand ?? Object.values(atlas.frames)[0], k = PERSON * scale * size / atlas.height;
ctx.save();
ctx.translate(Math.round(x), Math.round(y));
ctx.fillStyle = 'rgba(0,0,0,0.22)';
@@ -109,9 +109,16 @@ export function render(ctx, game, { hover } = {}) {
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);
// Andrea e i personaggi della scena, dal più lontano al più vicino.
const andrea = sprites.get('andrea');
if (andrea) drawSprite(ctx, andrea, game.pose(), s.x, s.y, game.scaleAt(s.y), s.facing);
else drawAndrea(ctx, s.x, s.y, game.scaleAt(s.y), s.facing, game.walking ? game.stride / 6 : null);
const drawAndreaNow = () => andrea ? drawSprite(ctx, andrea, game.pose(), s.x, s.y, game.scaleAt(s.y), s.facing)
: drawAndrea(ctx, s.x, s.y, game.scaleAt(s.y), s.facing, game.walking ? game.stride / 6 : null);
const queue = [{ y: s.y, draw: drawAndreaNow }];
for (const p of game.people()) {
const sheet = sprites.get(p.who);
if (sheet) queue.push({ y: p.at[1], draw: () => drawSprite(ctx, sheet, game.personPose(p), ...p.at, game.scaleAt(p.at[1]), p.facing ?? 1, p.size) });
}
for (const q of queue.sort((a, b) => a.y - b.y)) q.draw();
if (hover) {
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.setLineDash([3, 3]);
+21
View File
@@ -27,3 +27,24 @@ export function clearSave(storage) {
return false;
}
}
// Volume e muto della musica: si ricordano anche senza una partita salvata.
const AUDIO_KEY = 'leggenda-regolo-audio';
export function readAudio(storage) {
try {
const a = JSON.parse(storage.getItem(AUDIO_KEY));
return a && typeof a.volume === 'number' ? { volume: a.volume, muted: !!a.muted } : null;
} catch {
return null;
}
}
export function writeAudio(storage, settings) {
try {
storage.setItem(AUDIO_KEY, JSON.stringify(settings));
return true;
} catch {
return false;
}
}
+43 -2
View File
@@ -3,7 +3,8 @@ import { story } from './story/story.js';
import { Game, VERBS, VERB_LABELS } from './engine/game.js';
import { Command } from './engine/command.js';
import { render, loadAssets, WIDTH, HEIGHT } from './engine/render.js';
import { readSave, writeSave, clearSave } from './engine/storage.js';
import { readSave, writeSave, clearSave, readAudio, writeAudio } from './engine/storage.js';
import { Music, trackFor } from './engine/music.js';
const $ = id => document.getElementById(id);
const canvas = $('scene'), ctx = canvas.getContext('2d');
@@ -13,6 +14,20 @@ const debug = new URLSearchParams(location.search).has('debug');
let game = null, command = null, hoverTarget = null, mouse = null, running = false;
// --- Musica: parte subito se il browser lo permette, altrimenti al primo gesto; volume e muto si ricordano.
const music = new Music((storage && readAudio(storage)) ?? {});
const saveAudio = () => storage && writeAudio(storage, music.settings());
function showSound() {
$('mute').setAttribute('aria-pressed', String(music.muted));
$('mute').setAttribute('aria-label', music.muted ? "Musica: rimetti l'audio" : "Musica: togli l'audio");
$('volume').value = Math.round(music.volume * 100);
}
for (const ev of ['pointerdown', 'keydown']) addEventListener(ev, () => music.unlock(), { capture: true });
$('mute').addEventListener('click', () => { music.toggle(); saveAudio(); showSound(); });
$('volume').addEventListener('input', e => { music.setVolume(e.target.value / 100); saveAudio(); showSound(); });
showSound();
window.music = music; // per i test nel browser
function start(state) {
game = new Game(story, { state, onSave: data => storage && writeSave(storage, data) });
command = new Command(game);
@@ -23,6 +38,27 @@ function start(state) {
window.game = game; // per i test nel browser e per curiosare dalla console
}
// --- Introduzione: le righe di story.intro una alla volta sopra il panorama; poi comincia la partita.
let introLine = -1;
function showIntro() {
if (!story.intro?.length) return start(null);
$('title').hidden = true;
$('intro').hidden = false;
introLine = -1;
nextIntro();
}
function nextIntro() {
introLine++;
if (introLine >= story.intro.length) { $('intro').hidden = true; return start(null); }
const p = $('intro-text');
p.textContent = story.intro[introLine];
p.style.animation = 'none';
void p.offsetWidth; // riavvia la comparsa
p.style.animation = '';
}
$('intro').addEventListener('click', e => { if (e.target !== $('skip-intro')) nextIntro(); });
$('skip-intro').addEventListener('click', () => { introLine = story.intro.length; nextIntro(); });
function showTitle() {
running = false;
const saved = storage && readSave(storage);
@@ -113,6 +149,9 @@ function updateChoices() {
}
window.addEventListener('keydown', e => {
if (e.target === $('volume')) 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;
if ([' ', '.', 'Enter'].includes(e.key) && game.speech) { e.preventDefault(); game.skip(); return; }
if (e.key === 'Escape') command.reset();
@@ -150,6 +189,8 @@ let last = performance.now();
function frame(now) {
const dt = Math.min(0.1, (now - last) / 1000);
last = now;
const screen = !$('title').hidden ? 'title' : !$('intro').hidden ? 'intro' : !$('ending').hidden ? 'ending' : 'game';
music.update(trackFor(story, { screen, scene: game?.state.scene }), dt);
if (game) {
if (running) game.update(dt);
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null });
@@ -158,7 +199,7 @@ function frame(now) {
requestAnimationFrame(frame);
}
$('new-game').addEventListener('click', () => { if (storage) clearSave(storage); start(null); });
$('new-game').addEventListener('click', () => { if (storage) clearSave(storage); showIntro(); });
$('continue').addEventListener('click', () => start(readSave(storage)));
$('restart').addEventListener('click', () => { if (storage) clearSave(storage); start(null); });
+4 -1
View File
@@ -6,6 +6,9 @@ export const solution = [
['choose', 'Avete notato niente di strano stanotte?'],
['choose', 'Ci vediamo dopo.'],
['take', 'cosa_luccicante'],
['walk', 'vicolo'],
['walk', 'verso_villa_adami'],
['walk', 'su_villa_adami'],
['walk', 'salita'],
['walk', 'su_fortezza'],
['use', 'impronte', 'squama'],
];
+296 -25
View File
@@ -1,14 +1,23 @@
// Storia del prototipo: tre scene con gli sfondi veri (Piazza Colonna, Fortezza, Trebbio) per collaudare il motore.
// Storia del prototipo: le scene del paese a gradini (docs/PROGETTO.md, «La mappa») per collaudare il motore.
// Testi, personaggi ed enigmi sono provvisori e verranno sostituiti con i ricordi di Andrea (docs/PROGETTO.md).
// 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: [250, 320] },
// Le righe dell'introduzione, sopra il panorama del paese, prima della prima scena.
intro: [
'San Piero a Sieve, nel Mugello. Fine maggio 2007.',
'In paese si prepara il Palio della Fortezza: i quattro rioni si sfidano, e chi vince si porta a casa il palio, il quadro dipinto dai ragazzi delle medie.',
'Io corro per Campomigliaio, i blu.',
'E stamattina, in piazza, è successo qualcosa...',
],
// Colonna sonora di Andrea (fatta con Suno): titolo, paese, ricordi (fine e Trebbio).
music: { title: 'assets/audio/titolo.mp3', game: 'assets/audio/paese.mp3', ending: 'assets/audio/ricordi.mp3' },
characters: {
andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' },
vecchioA: { name: 'Vecchietto col cappello', color: '#ffd27a' },
vecchioB: { name: 'Vecchietto col bastone', color: '#9fe3ff' },
vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' },
vecchietto: { name: 'Vecchietto', color: '#9fe3ff', sprite: 'assets/characters/vecchietto.json' },
castello: { name: 'Il Castello', color: '#c8f0a0' },
},
items: {
@@ -28,12 +37,17 @@ export const story = {
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] },
speakers: { vecchietta: [16, 250], vecchietto: [52, 252] },
// I vecchietti seduti sulla panchina: fotogrammi in assets/characters (pose e battute in game.personPose).
people: [
{ who: 'vecchietta', at: [16, 300], size: 0.75, talk: ['talk', 'explain'], blink: 'blink', every: 3.7 },
{ who: 'vecchietto', at: [52, 302], size: 0.75, talk: ['talk', 'explain'], blink: 'doze', every: 5.3, offset: 2 },
],
hotspots: [
{
id: 'colonna', name: 'colonna', box: [492, 134, 36, 140], at: [505, 290], face: 'up',
actions: {
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.' }],
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 a sinistra, verso la salita di Villa Adami.' }],
take: [{ say: 'Se mi porto via la colonna, la piazza come la chiamano?' }],
use: [{ say: 'Arrampicarmi? Il palio lassù non c\'è comunque.' }],
},
@@ -46,6 +60,13 @@ export const story = {
talk: [{ say: 'Parlare con una porta. Neanche al Palio si arriva a tanto.' }],
},
},
{
id: 'verso_gelateria', name: 'strada verso il Fiocco di Neve', box: [570, 150, 28, 100], at: [580, 256], face: 'right', default: 'walk',
actions: {
walk: [{ say: 'Di là c\'è la gelateria del Fiocco di Neve.' }, { say: 'Con questo caldo, un bel gelatino ci starebbe proprio bene...' }, { say: 'No. Prima ritrovo il palio, poi mi premio.' }],
look: [{ say: 'La strada che porta al Fiocco di Neve. Già sento il profumo di cialda.' }],
},
},
{
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' }],
@@ -55,18 +76,14 @@ export const story = {
},
},
{
id: 'vecchietti', name: 'vecchietti', box: [2, 244, 62, 50], at: [100, 320], face: 'left', default: 'talk',
art: [
{ 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' },
],
id: 'vecchietti', name: 'vecchietti', box: [0, 250, 72, 52], at: [100, 320], face: 'left', default: 'talk',
actions: {
look: [{ say: 'I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.' }],
talk: [{ dialogue: 'vecchietti' }],
take: [{ say: 'Se li porto via, il paese resta senza notizie.' }],
},
give: {
squama: [{ say: 'Guardate cosa ho trovato.' }, { say: 'Bella! È una patatina?', who: 'vecchioA' }, { say: 'Ma che patatina, è il Regolo che fa la muta!', who: 'vecchioB' }, { say: 'Me la tengo io, grazie.' }],
squama: [{ say: 'Guardate cosa ho trovato.' }, { say: 'Bella! È una patatina?', who: 'vecchietta', pose: 'laugh' }, { say: 'Ma che patatina, è il Regolo che fa la muta!', who: 'vecchietto' }, { say: 'Me la tengo io, grazie.' }],
},
},
{
@@ -84,7 +101,13 @@ export const story = {
},
},
{
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_campeggio', name: 'strada verso il campeggio', box: [330, 190, 34, 48], at: [345, 234], exit: { scene: 'campeggio', at: [200, 345], face: 'right' },
},
{
id: 'verso_villa_adami', name: 'Via de\' Medici, verso Villa Adami', box: [0, 196, 90, 50], at: [92, 250], exit: { scene: 'via_de_medici', at: [300, 348], face: 'up' },
},
{
id: 'verso_pieve', name: 'verso la Pieve', box: [612, 292, 28, 68], at: [628, 330], exit: { scene: 'pieve', at: [30, 320], face: 'right' },
},
],
enter: [{ if: '!intro', then: [{ set: 'intro' }, { say: 'Il palio è sparito! Il quadro che hanno dipinto i ragazzi delle medie.' }, { say: 'La festa è domani: se nessuno lo ritrova, salta tutto. Tocca a me.' }] }],
@@ -131,16 +154,264 @@ export const story = {
},
},
{
id: 'sentiero_trebbio', name: 'salita per il Trebbio', box: [0, 300, 26, 60], at: [6, 340], exit: { scene: 'trebbio', at: [110, 345], face: 'right' },
id: 'sentiero_trebbio', name: 'sentiero per il Trebbio', box: [0, 200, 90, 160], at: [20, 330], exit: { scene: 'viale_cipressi', at: [300, 350], face: 'up' },
},
{
id: 'verso_piazza', name: 'verso la Piazza', box: [120, 344, 110, 16], at: [175, 356], exit: { scene: 'piazza', at: [345, 240], face: 'left' },
id: 'verso_villa_adami', name: 'discesa verso Villa Adami', box: [180, 326, 340, 34], at: [330, 352], exit: { scene: 'salita_villa_adami', at: [330, 300], face: 'left' },
},
],
},
// Livello 0: la Pieve vista dalla salita, con la salita per Villa Adami alle spalle.
pieve: {
name: 'La Pieve',
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]],
depth: [[215, 0.42], [360, 1.15]],
entry: [30, 320],
hotspots: [
{
id: 'chiesa', name: 'Pieve di San Pietro', box: [108, 78, 166, 132], at: [200, 290], face: 'up',
actions: {
look: [{ say: 'La Pieve di San Pietro. Il rione Pieve, quelli in bianco.' }],
open: [{ say: 'Il prete è a benedire le carrette del Palio. Torno dopo.' }],
},
},
{
id: 'campanile', name: 'campanile', box: [272, 8, 60, 140], at: [300, 300], face: 'up',
actions: {
look: [{ say: 'Il campanile della Pieve. Da lassù si vedrebbe tutto il paese. Anche un serpente con le ali.' }],
},
},
{
id: 'statua', name: 'statua', box: [334, 172, 18, 52], at: [330, 300], face: 'up',
actions: {
look: [{ say: 'Una statua sul piedistallo. Lei il palio l\'ha visto passare di sicuro, ma non parla.' }],
talk: [{ say: 'Buongiorno.' }, { say: '...' }, { say: 'Come i vecchietti quando gli chiedi dei debiti.' }],
},
},
{
id: 'oratorio', name: 'porta dell\'oratorio', box: [452, 180, 42, 56], at: [473, 240], face: 'up', exit: { scene: 'oratorio', at: [320, 345], face: 'right' },
},
{
id: 'verso_piazza', name: 'verso la Piazza', box: [0, 262, 26, 98], at: [8, 320], exit: { scene: 'piazza', at: [610, 320], face: 'left' },
},
{
id: 'salita_villa_adami', name: 'salita verso Villa Adami', box: [440, 330, 200, 30], at: [600, 350], exit: { scene: 'villa_adami', at: [595, 268], face: 'left' },
},
],
},
// Il cortile dell'oratorio, dietro la porta della canonica: palco, tettoia, tavolate della festa.
oratorio: {
name: 'Oratorio della Pieve',
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]],
entry: [320, 345],
hotspots: [
{
id: 'palco', name: 'palco', box: [52, 149, 134, 58], at: [120, 238], face: 'up',
actions: {
look: [{ say: 'Il palco dell\'oratorio. D\'estate ci fanno le recite, la tombola e i concerti.' }, { say: 'Adesso è vuoto. Peccato, avevo un numero di giocoleria pronto.' }],
use: [{ say: 'Salire sul palco senza pubblico? Mi vergogno pure da solo.' }],
},
},
{
id: 'tettoia', name: 'tettoia', box: [333, 119, 153, 72], at: [400, 236], face: 'up',
actions: {
look: [{ say: 'La tettoia, dove si mettono le cucine quando c\'è la festa.' }],
},
},
{
id: 'tavolate', name: 'tavolate', box: [500, 128, 140, 113], at: [560, 268], face: 'right',
actions: {
look: [{ say: 'Le tavolate della festa. Fra qualche giorno qui si mangia per tre ore di fila.' }],
use: [{ say: 'Mi siedo e aspetto che arrivi la pastasciutta? Ci vorrebbe una settimana.' }],
},
},
{
id: 'cancello', name: 'cancello', box: [477, 166, 23, 52], at: [488, 236], face: 'up',
actions: {
look: [{ say: 'Il cancello di dietro.' }],
open: [{ say: 'Chiuso. Si apre solo quando c\'è la festa.' }],
},
},
{
id: 'verso_pieve', name: 'torna alla Pieve', box: [0, 334, 640, 26], at: [320, 354], exit: { scene: 'pieve', at: [473, 240], face: 'down' },
},
],
},
// 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',
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]],
entry: [300, 348],
hotspots: [
{
id: 'portone', name: 'portone ad arco', box: [478, 30, 70, 270], at: [430, 290], face: 'right',
actions: {
look: [{ say: 'Un portone antico con l\'inferriata. Chissà cosa c\'è dietro: una cantina? Un tesoro? La cantina di un tesoro?' }],
open: [{ say: 'Chiuso. Come tutte le porte interessanti di questo paese.' }],
},
},
{
id: 'su_villa_adami', name: 'su verso Villa Adami', box: [272, 186, 60, 34], at: [300, 216], exit: { scene: 'villa_adami', at: [50, 252], face: 'right' },
},
{
id: 'giu_piazza', name: 'giù verso la Piazza', box: [0, 334, 560, 26], at: [300, 354], exit: { scene: 'piazza', at: [92, 250], face: 'right' },
},
],
},
// L'ingresso del campeggio: dalla Piazza a diritto; poco dopo, a destra, si sale al Bosco ai Frati.
campeggio: {
name: 'Ingresso del campeggio',
background: 'assets/scenes/campeggio.png',
walk: [[150, 360], [165, 262], [420, 266], [640, 286], [640, 360]],
depth: [[262, 0.6], [360, 1.15]],
entry: [200, 345],
hotspots: [
{
id: 'campeggio', name: 'campeggio', box: [190, 120, 220, 140], at: [300, 280], face: 'up',
actions: {
look: [{ say: 'L\'ingresso del campeggio. A giudicare dalle bandiere, ci dorme mezzo mondo.' }],
walk: [{ say: 'Dentro non conosco nessuno. E il Regolo, che io sappia, non va in campeggio.' }],
},
},
{
id: 'bandiere', name: 'bandierine', box: [115, 52, 340, 30], at: [300, 280], face: 'up',
actions: {
look: [{ say: 'Una fila di bandierine di tutti i paesi. Il Palio della Fortezza però è roba nostra.' }],
take: [{ say: 'Senza scala ci arrivo solo con la fantasia.' }],
},
},
{
id: 'bosco_ai_frati', name: 'salita verso il Bosco ai Frati', box: [570, 250, 70, 110], at: [628, 320], exit: { scene: 'bosco_ai_frati', at: [320, 345], face: 'right' },
},
{
id: 'verso_piazza', name: 'torna in Piazza', box: [150, 290, 100, 70], at: [175, 345], exit: { scene: 'piazza', at: [345, 240], face: 'left' },
},
],
},
// Il convento del Bosco ai Frati, nel bosco sopra il campeggio.
bosco_ai_frati: {
name: 'Bosco ai Frati',
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]],
entry: [320, 345],
hotspots: [
{
id: 'convento', name: 'chiesa del convento', box: [96, 60, 386, 236], at: [320, 310], face: 'up',
actions: {
look: [{ say: 'Il convento del Bosco ai Frati, in mezzo al bosco. Qui si viene a cercare il fresco e il silenzio.' }],
open: [{ say: 'Meglio non disturbare: i frati sono gli unici in tutto il Mugello che non parlano del Palio.' }],
},
},
{
id: 'porta_convento', name: 'porta del convento', box: [526, 218, 32, 78], at: [540, 304], face: 'up',
actions: {
look: [{ say: 'Una porta ad arco col cancelletto di ferro.' }],
open: [{ say: 'Chiusa. A quest\'ora i frati pregano. O pranzano. O tutte e due.' }],
},
},
{
id: 'tiglio', name: 'tiglio', box: [0, 0, 92, 296], at: [100, 320], face: 'left',
actions: {
look: [{ say: 'Un tiglio enorme. Profuma che sembra una pasticceria.' }],
use: [{ say: 'Arrampicarmi? Ho i pantaloni buoni.' }],
},
},
{
id: 'giu_campeggio', name: 'scendi verso il campeggio', box: [0, 334, 640, 26], at: [320, 354], exit: { scene: 'campeggio', at: [620, 320], face: 'left' },
},
],
},
// Livello 1: Villa Adami, vista dalla salita della Fortezza guardando in discesa.
villa_adami: {
name: 'Villa Adami',
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]],
entry: [300, 345],
hotspots: [
{
id: 'villa', name: 'Villa Adami', box: [69, 8, 459, 247], at: [300, 300], face: 'up',
actions: {
look: [{ say: 'Villa Adami. Gialla come un tuorlo, con le persiane verdi.' }],
open: [{ say: 'Mica è casa mia. E poi suonare e scappare non si fa più dalla quinta elementare.' }],
},
},
{
id: 'verso_piazza', name: 'Via de\' Medici, verso la Piazza', box: [20, 215, 50, 40], at: [45, 250], exit: { scene: 'via_de_medici', at: [300, 216], face: 'left' },
},
{
id: 'verso_pieve', name: 'discesa verso la Pieve', box: [560, 236, 60, 38], at: [600, 266], exit: { scene: 'pieve', at: [600, 350], face: 'left' },
},
{
id: 'salita', name: 'salita verso la Fortezza', box: [100, 330, 400, 30], at: [300, 354], exit: { scene: 'salita_villa_adami', at: [340, 352], face: 'right' },
},
],
},
// 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',
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]],
entry: [340, 352],
hotspots: [
{
id: 'leone', name: 'leone di terracotta', box: [412, 66, 34, 30], at: [400, 290], face: 'up',
actions: {
look: [{ say: 'Un leone di terracotta che fa la guardia alla salita. Di serpenti, dice, non ne ha visti passare.' }],
talk: [{ say: 'Ciao leone. Visto passare un serpente con le ali?' }, { say: 'Il leone fa finta di niente. Come tutti, in questo paese.' }],
take: [{ say: 'Pesa più di me. E poi ha un\'aria permalosa.' }],
},
},
{
id: 'verso_scuola', name: 'strada verso la scuola', box: [0, 286, 120, 44], at: [40, 336], face: 'left', default: 'walk',
actions: {
walk: [{ say: 'A scuola? A maggio? Nemmeno per sogno.' }],
look: [{ say: 'Di là si va alla scuola. Ci passo già abbastanza tempo durante l\'anno.' }],
},
},
{
id: 'su_fortezza', name: 'salita verso la Fortezza', box: [168, 196, 182, 46], at: [326, 259], face: 'left', exit: { scene: 'fortezza', at: [175, 352], face: 'right' },
},
{
id: 'verso_villa_adami', name: 'discesa verso Villa Adami', box: [200, 334, 280, 26], at: [340, 356], exit: { scene: 'villa_adami', at: [300, 345], face: 'right' },
},
],
},
// Il viale dei cipressi, la strada bianca tra la Fortezza e il Trebbio: in fondo c'è il castello.
viale_cipressi: {
name: 'Viale dei cipressi',
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]],
entry: [300, 350],
hotspots: [
{
id: 'cipressi', name: 'cipressi', box: [440, 0, 200, 270], at: [400, 300], face: 'right',
actions: {
look: [{ say: 'I cipressi in fila, dritti come soldatini. Da piccolo li contavo. Non sono mai arrivato in fondo.' }],
take: [{ say: 'Un cipresso in tasca? Neanche nei sogni.' }],
},
},
{
id: 'al_trebbio', name: 'in fondo, verso il Trebbio', box: [262, 214, 76, 50], at: [300, 256], exit: { scene: 'trebbio', at: [110, 345], face: 'right' },
},
{
id: 'alla_fortezza', name: 'indietro, verso la Fortezza', box: [0, 330, 640, 30], at: [300, 352], exit: { scene: 'fortezza', at: [20, 330], face: 'right' },
},
],
},
trebbio: {
name: 'Castello del Trebbio',
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]],
depth: [[306, 0.75], [360, 1.05]],
entry: [110, 345],
@@ -169,7 +440,7 @@ export const story = {
},
},
{
id: 'verso_fortezza', name: 'discesa verso la Fortezza', box: [80, 318, 26, 42], at: [92, 345], exit: { scene: 'fortezza', at: [20, 340], face: 'right' },
id: 'verso_fortezza', name: 'viale dei cipressi, verso la Fortezza', box: [0, 250, 110, 110], at: [92, 345], exit: { scene: 'viale_cipressi', at: [300, 256], face: 'left' },
},
],
},
@@ -182,29 +453,29 @@ export const story = {
{
text: 'Avete visto chi ha preso il palio?',
then: [
{ say: 'È stato il Regolo! Quello che cadde dal cielo quando scavavano le fondamenta.', who: 'vecchioA' },
{ say: 'Macché cielo! Mangiava le bestie, e lo presero i soldati di Napoleone.', who: 'vecchioB' },
{ say: 'Napoleone? A San Piero? Ma se un c\'è mai venuto!', who: 'vecchioA' },
{ say: 'E il Regolo sì?', who: 'vecchioB' },
{ say: 'È stato il Regolo! Quello che cadde dal cielo quando scavavano le fondamenta.', who: 'vecchietta' },
{ say: 'Macché cielo! Mangiava le bestie, e lo presero i soldati di Napoleone.', who: 'vecchietto', pose: 'point' },
{ say: 'Napoleone? A San Piero? Ma se un c\'è mai venuto!', who: 'vecchietta', pose: 'point' },
{ say: 'E il Regolo sì?', who: 'vecchietto', pose: 'shrug' },
],
},
{
id: 'stanotte', once: true, text: 'Avete notato niente di strano stanotte?',
then: [
{ say: 'Io dormivo. Alla mia età si dorme anche in piedi.', who: 'vecchioB' },
{ say: 'Io stamani ho visto luccicare qualcosa sotto la panchina.', who: 'vecchioA' },
{ say: 'Ma con la mia schiena, chi ci si china?', who: 'vecchioA' },
{ say: 'Io dormivo. Alla mia età si dorme anche in piedi.', who: 'vecchietto', pose: 'doze' },
{ say: 'Io stamani ho visto luccicare qualcosa sotto la panchina.', who: 'vecchietta' },
{ say: 'Ma con la mia schiena, chi ci si china?', who: 'vecchietta', pose: 'look_down' },
{ set: 'luccichio' },
],
},
{
text: 'Come vi sembra il Palio di quest\'anno?',
then: [
{ say: 'Senza il palio? Una festa così non s\'è mai vista.', who: 'vecchioB' },
{ say: 'Nel mio, di Palio, si correva con le carriole vere. Mica come voi.', who: 'vecchioA' },
{ say: 'Senza il palio? Una festa così non s\'è mai vista.', who: 'vecchietto', pose: 'shrug' },
{ say: 'Nel mio, di Palio, si correva con le carriole vere. Mica come voi.', who: 'vecchietta' },
],
},
{ text: 'Ci vediamo dopo.', next: 'end', then: [{ say: 'Salutaci i tuoi!', who: 'vecchioA' }] },
{ text: 'Ci vediamo dopo.', next: 'end', then: [{ say: 'Salutaci i tuoi!', who: 'vecchietta' }] },
],
},
},
+31 -4
View File
@@ -11,7 +11,7 @@
[hidden] { display: none !important; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: var(--font); }
body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-highlight-color: transparent; }
#app { width: min(100vw, 1280px, calc((100dvh - 250px) * 16 / 9)); min-width: min(100vw, 320px); display: flex; flex-direction: column; padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom); }
#app { width: min(100vw, 1280px, calc((100dvh - 250px) * 16 / 9)); min-width: min(100vw, 320px); margin-block: auto; display: flex; flex-direction: column; padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom); }
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
@@ -21,15 +21,28 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; }
#speech:empty { display: none; }
/* Musica: nota per il muto e cursore del volume, in alto a destra sopra la scena (anche sul titolo). */
#sound { position: absolute; top: 6px; right: 6px; z-index: 2; display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 4px;
background: rgba(27, 20, 32, .62); border-radius: 18px; }
#mute { width: 30px; height: 30px; padding: 0; font: bold 18px var(--font); color: var(--accent); background: none; border: 0; cursor: pointer; }
#mute[aria-pressed="true"] { color: var(--dim); text-decoration: line-through; }
#volume { width: clamp(60px, 10vw, 110px); accent-color: var(--accent); cursor: pointer; }
#sentence { min-height: 1.8em; padding: .25em .6em; text-align: center; color: var(--blue); font-size: clamp(15px, 2vw, 22px); font-weight: bold; background: var(--panel); }
#panel { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 8px; padding: 8px; background: var(--panel); }
#verbs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
/* Sette verbi su due righe piene: quattro sopra (3/12 ciascuno) e tre sotto (4/12). */
#verbs { display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; align-content: start; }
#verbs button { grid-column: span 4; }
#verbs button:nth-child(-n+4) { grid-column: span 3; }
#verbs button, #inventory button { font: inherit; color: var(--dim); background: #3a2d42; border: 2px solid #4b3a55; border-radius: 4px;
padding: .35em .2em; font-size: clamp(13px, 1.7vw, 19px); font-weight: bold; cursor: pointer; }
#verbs button:hover, #inventory button:hover { color: var(--ink); }
#verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); }
#inventory { display: grid; grid-template-columns: repeat(3, 1fr); grid-auto-rows: minmax(2.4em, auto); gap: 4px; align-content: start; }
#inventory { display: grid; grid-template-columns: repeat(3, 1fr); grid-auto-rows: minmax(2.4em, auto); gap: 4px; align-content: start;
min-height: 5.4em; padding: 4px; background: #231a29; border: 2px solid #4b3a55; border-radius: 4px; }
#inventory:empty::before { content: 'Inventario'; grid-column: 1 / -1; align-self: center; text-align: center; color: var(--dim); opacity: .45;
font-size: clamp(13px, 1.7vw, 19px); font-weight: bold; letter-spacing: .08em; }
#inventory button { display: flex; align-items: center; gap: .35em; justify-content: center; color: var(--ink); }
#inventory button[aria-pressed="true"] { border-color: var(--accent); }
#inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; }
@@ -41,6 +54,20 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .6em;
padding: 16px; text-align: center; background: radial-gradient(circle at 50% 35%, rgba(60, 40, 70, .82), rgba(15, 10, 20, .96)); }
.overlay[hidden] { display: none; }
/* Titolo e introduzione: le illustrazioni a pixel netti, con una velatura per leggere il testo. */
#title { justify-content: flex-start; padding-top: 6%; image-rendering: pixelated;
background: linear-gradient(rgba(20, 12, 30, .55), rgba(20, 12, 30, .05) 45%, rgba(20, 12, 30, .7)), url(assets/scenes/titolo.png) center / cover; }
#title .buttons { margin-top: auto; }
#title .dedication { margin-bottom: 2%; text-shadow: 2px 2px 0 #000; }
#title .subtitle { color: var(--ink); text-shadow: 2px 2px 0 #000; }
#intro { justify-content: flex-start; padding-top: 7%; cursor: pointer; image-rendering: pixelated;
background: linear-gradient(rgba(20, 12, 30, .6), rgba(20, 12, 30, .1) 55%), url(assets/scenes/panorama-intro.png) center / cover, #2a2030; }
#intro-text { min-height: 4.2em; max-width: 30em; font-size: clamp(16px, 2.6vw, 30px); font-weight: bold; line-height: 1.3; color: var(--ink);
text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000; animation: appear .8s ease-out; }
#intro .hint { margin-top: auto; color: var(--dim); font-size: clamp(12px, 1.5vw, 16px); text-shadow: 1px 1px 0 #000; }
#skip-intro { position: absolute; right: 10px; bottom: 10px; font: bold 14px var(--font); padding: .3em .9em; border-radius: 6px; border: 0;
color: var(--bg); background: var(--dim); cursor: pointer; }
@keyframes appear { from { opacity: 0; transform: translateY(6px); } }
.overlay h1 { margin: 0; font-size: clamp(30px, 7vw, 76px); line-height: 1; letter-spacing: .02em; color: var(--ink); text-shadow: 3px 3px 0 #000; }
.overlay h1 span { color: #7fe08a; }
.overlay h2 { margin: 0; font-size: clamp(24px, 5vw, 52px); color: var(--accent); }
@@ -62,7 +89,7 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
#sentence { font-size: 14px; }
#panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; }
#verbs, #inventory { grid-template-columns: repeat(2, 1fr); }
#inventory { grid-template-columns: repeat(2, 1fr); min-height: 0; }
#verbs button, #inventory button, #choices li { font-size: 14px; }
#choices { min-height: 0; padding: 6px 8px 6px 2em; }
}
+15 -2
View File
@@ -47,12 +47,23 @@ try {
await page.waitForSelector('#title:not([hidden])');
await page.screenshot({ path: `${OUT}/01-titolo.png` });
await page.click('#new-game');
// L'introduzione: una riga per clic, poi comincia la partita.
await page.waitForSelector('#intro:not([hidden])');
await page.screenshot({ path: `${OUT}/01b-intro.png` });
for (let i = 0; i < 10 && await page.isVisible('#intro'); i++) await page.click('#intro', { position: { x: 40, y: 40 } });
assert.ok(await page.isHidden('#intro'), 'l\'introduzione non finisce');
await page.waitForFunction(() => game.speech);
await page.screenshot({ path: `${OUT}/02-inizio.png` });
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
await settle(page);
// Musica: dopo il primo clic suona il brano del paese; muto e volume si ricordano.
assert.ok((await page.evaluate(() => music.current?.src)).endsWith('paese.mp3'), 'musica del paese');
await page.click('#mute');
await page.locator('#volume').fill('30');
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false });
for (const [verb, target, item] of solution) {
if (verb === 'choose') {
await page.locator('#choices li', { hasText: target }).click();
@@ -68,7 +79,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 === 'vicolo') await page.screenshot({ path: `${OUT}/05-fortezza.png` });
if (target === 'su_fortezza') await page.screenshot({ path: `${OUT}/05-fortezza.png` });
}
await page.waitForSelector('#ending:not([hidden])');
await page.screenshot({ path: `${OUT}/06-fine.png` });
@@ -78,7 +89,7 @@ try {
// Una partita a metà riprende da dove era rimasta.
await page.click('#restart');
await settle(page);
for (const exit of ['vicolo', 'sentiero_trebbio']) {
for (const exit of ['verso_villa_adami', 'su_villa_adami', 'salita', 'su_fortezza', 'sentiero_trebbio', 'al_trebbio']) {
await clickScene(page, await hotspotCenter(page, exit));
await settle(page);
}
@@ -98,12 +109,14 @@ try {
const phone = await open({ width: 390, height: 844 });
await phone.click('#new-game');
await phone.click('#skip-intro');
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}/08-telefono.png` });
const landscape = await open({ width: 844, height: 390 });
await landscape.click('#new-game');
await landscape.click('#skip-intro');
await settle(landscape);
assert.equal(await landscape.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
await landscape.screenshot({ path: `${OUT}/09-orizzontale.png`, fullPage: true });
+2 -2
View File
@@ -15,7 +15,7 @@ test('clic intelligente: parla con le persone, guarda le cose, attraversa le usc
const { g, c, at } = setup();
assert.equal(c.sentence('vecchietti'), 'Parla con vecchietti');
assert.equal(c.sentence('manifesto'), 'Guarda manifesto del Palio');
assert.equal(c.sentence('vicolo'), 'Vai a vicolo verso la Fortezza');
assert.equal(c.sentence('verso_villa_adami'), 'Vai a Via de\' Medici, verso Villa Adami');
assert.equal(c.clickScene(at('vecchietti')), 'act');
settle(g);
assert.equal(g.dialogue?.id, 'vecchietti');
@@ -59,7 +59,7 @@ test('oggetto dell\'inventario: usa con, dai a, guarda', () => {
c.clickItem('squama');
assert.equal(c.sentence('vecchietti'), 'Dai squama a vecchietti');
c.clickScene(at('vecchietti'));
assert.deepEqual(settle(g).map(s => s.who), ['andrea', 'vecchioA', 'vecchioB', 'andrea']);
assert.deepEqual(settle(g).map(s => s.who), ['andrea', 'vecchietta', 'vecchietto', 'andrea']);
assert.deepEqual(g.state.inventory, ['squama'], 'nessun vicolo cieco: la squama resta');
c.clickItem('squama', { look: true });
assert.match(settle(g)[0].text, /cantina/);
+19
View File
@@ -163,3 +163,22 @@ test('il fotogramma segue quello che fa Andrea', () => {
for (let i = 0; i < 10; i++) { g.time += 0.1; poses.add(g.pose()); }
assert.deepEqual([...poses].sort(), ['stand', 'talk']);
});
test('i personaggi della scena: posa da fermi, battito di ciglia, parlato e posa della battuta', () => {
const story = mini();
story.scenes.a.people = [{ who: 'tizio', at: [50, 30], talk: ['talk', 'explain'], blink: 'blink', every: 2 }];
const g = new Game(story);
g.go('a');
const p = g.people()[0];
g.time = 0.5;
assert.equal(g.personPose(p), 'sit');
g.time = 1.9;
assert.equal(g.personPose(p), 'blink');
g.speech = { who: 'tizio', text: '...', left: 1 };
g.time = 0.1;
assert.equal(g.personPose(p), 'talk');
g.time = 0.4;
assert.equal(g.personPose(p), 'explain');
g.speech.pose = 'point';
assert.equal(g.personPose(p), 'point');
});
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { trackFor, Music } from '../src/engine/music.js';
import { story } from '../src/story/story.js';
test('il brano giusto per titolo, fine e scena', () => {
const s = { music: { title: 't.mp3', game: 'g.mp3', ending: 'e.mp3' }, scenes: { a: {}, b: { music: 'b.mp3' } } };
assert.equal(trackFor(s, { screen: 'title', scene: 'a' }), 't.mp3');
assert.equal(trackFor(s, { screen: 'intro', scene: 'a' }), 't.mp3');
assert.equal(trackFor(s, { screen: 'ending', scene: 'a' }), 'e.mp3');
assert.equal(trackFor(s, { screen: 'game', scene: 'a' }), 'g.mp3');
assert.equal(trackFor(s, { screen: 'game', scene: 'b' }), 'b.mp3');
assert.equal(trackFor({ scenes: {} }, { screen: 'game', scene: 'x' }), null);
});
test('volume e muto', () => {
const m = new Music({ volume: 0.5, muted: true });
assert.equal(m.setVolume(1.4), 1);
assert.equal(m.muted, false, 'alzare il volume toglie il muto');
assert.equal(m.toggle(), true);
assert.equal(m.setVolume(-1), 0);
assert.deepEqual(m.settings(), { volume: 0, muted: true });
});
test('i brani della storia esistono', () => {
const files = [...Object.values(story.music ?? {}), ...Object.values(story.scenes).map(s => s.music).filter(Boolean)];
assert.ok(files.length);
for (const f of files) assert.ok(existsSync(new URL('../' + f, import.meta.url)), `manca ${f}`);
});
+10 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readSave, writeSave, clearSave } from '../src/engine/storage.js';
import { readSave, writeSave, clearSave, readAudio, writeAudio } from '../src/engine/storage.js';
import { memoryStorage } from './helpers.mjs';
test('scrive, rilegge e cancella il salvataggio', () => {
@@ -21,3 +21,12 @@ test('un archivio rotto o bloccato non fa cadere il gioco', () => {
garbage.setItem('leggenda-regolo-save', '{non è json');
assert.equal(readSave(garbage), null);
});
test('volume e muto della musica si ricordano', () => {
const s = memoryStorage();
assert.equal(readAudio(s), null);
assert.ok(writeAudio(s, { volume: 0.3, muted: true }));
assert.deepEqual(readAudio(s), { volume: 0.3, muted: true });
s.setItem('leggenda-regolo-audio', '{rotto');
assert.equal(readAudio(s), null);
});
+18 -2
View File
@@ -47,6 +47,8 @@ test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esist
for (const k of Object.keys(h.actions ?? {})) assert.ok(VERBS.includes(k), `${id}.${h.id}: verbo ${k}`);
for (const k of [...Object.keys(h.with ?? {}), ...Object.keys(h.give ?? {})]) assert.ok(story.items[k], `${id}.${h.id}: oggetto ${k}`);
if (h.exit) {
// Le uscite devono essere facili da prendere, anche col dito sul telefono.
assert.ok(Math.min(h.box[2], h.box[3]) >= 20 && h.box[2] * h.box[3] >= 1500, `${id}.${h.id}: uscita troppo piccola`);
const to = story.scenes[h.exit.scene];
assert.ok(to, `${id}.${h.id}: scena ${h.exit.scene}`);
assert.ok(inside(to.walk, h.exit.at ?? to.entry), `${id}.${h.id}: arrivo fuori dall'area`);
@@ -96,9 +98,23 @@ test('la soluzione si percorre tutta dall\'inizio alla fine', () => {
assert.ok(saves.at(-1).ended, 'la fine non è salvata');
});
test('i fogli dei personaggi hanno tutti i fotogrammi e l\'immagine', () => {
test('i personaggi in scena hanno il foglio e tutte le pose che usano', () => {
const frames = id => JSON.parse(readFileSync(new URL('../' + story.characters[id].sprite, import.meta.url), 'utf8')).frames;
for (const [id, scene] of Object.entries(story.scenes))
for (const p of scene.people ?? []) {
assert.ok(story.characters[p.who]?.sprite, `${id}: ${p.who} senza foglio`);
assert.ok(scene.speakers?.[p.who], `${id}: manca il punto del fumetto di ${p.who}`);
const f = frames(p.who);
for (const pose of [p.idle ?? 'sit', p.blink, ...(p.talk ?? ['talk'])].filter(Boolean)) assert.ok(f[pose], `${p.who}: manca ${pose}`);
}
for (const [where, list] of effectLists())
for (const e of flatten(list))
if (e.pose) assert.ok(frames(e.who ?? 'andrea')[e.pose], `${where}: ${e.who} non ha la posa ${e.pose}`);
});
test('il foglio di Andrea ha tutti i fotogrammi e l\'immagine', () => {
for (const [id, c] of Object.entries(story.characters)) {
if (!c.sprite) continue;
if (!c.sprite || id !== 'andrea') continue; // le pose degli altri dipendono dalla scena (test sopra)
const url = new URL('../' + c.sprite, import.meta.url);
const atlas = JSON.parse(readFileSync(url, 'utf8'));
assert.ok(existsSync(new URL(atlas.image, url)), `${id}: manca ${atlas.image}`);
+179
View File
@@ -0,0 +1,179 @@
"""Offline tests: every API interaction uses a fake client."""
import base64
from contextlib import redirect_stderr, redirect_stdout
import io
import json
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
import genera
class ConnectionErrorFake(Exception):
pass
class StatusErrorFake(Exception):
def __init__(self, status_code):
self.status_code = status_code
def png(size=genera.SIZE):
stream = io.BytesIO()
Image.new('RGB', size, '#ad8c63').save(stream, format='PNG')
return stream.getvalue()
class GeneratorTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.input = self.root / 'foto'
self.output = self.root / 'generate'
self.prompts = self.root / 'prompt'
self.input.mkdir()
self.prompts.mkdir()
self.photo('a.jpg')
self.photo('b.WEBP')
(self.input / 'da-fare').mkdir()
self.photo('da-fare/oratorio.png')
self.response = SimpleNamespace(data=[SimpleNamespace(b64_json=base64.b64encode(png()).decode())],
_request_id='fake-request', usage=None)
self.client = Mock()
self.client.images.edit.return_value = self.response
self.sdk = SimpleNamespace(OpenAI=Mock(return_value=self.client),
APIConnectionError=ConnectionErrorFake, APIStatusError=StatusErrorFake)
def photo(self, name):
Image.new('RGB', (48, 36), 'brown').save(self.input / name)
def run_cli(self, *args, answer='GENERA', eof=False):
with patch.dict(sys.modules, {'openai': self.sdk}), \
patch.dict('os.environ', {'OPENAI_API_KEY': 'offline-test-only'}), \
patch('builtins.input', return_value=answer, side_effect=EOFError if eof else None), \
redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
return genera.main(['--input-dir', str(self.input), '--output-dir', str(self.output),
'--prompt-dir', str(self.prompts), *args])
def records(self):
return json.loads((self.output / 'registro.json').read_text())
def test_dry_run_does_not_write_or_instantiate_sdk(self):
self.assertEqual(self.run_cli('--dry-run'), 0)
self.assertFalse(self.output.exists())
self.sdk.OpenAI.assert_not_called()
self.assertEqual([p.name for p in genera.photos_in(self.input)], ['a.jpg', 'b.WEBP'])
def test_confirmation_declined_or_eof_never_calls_api(self):
self.assertEqual(self.run_cli(answer='no'), 0)
self.sdk.OpenAI.assert_not_called()
self.assertFalse(self.output.exists())
self.assertEqual(self.run_cli(eof=True), 0)
self.client.images.edit.assert_not_called()
def test_missing_key_blocks_before_client(self):
args = SimpleNamespace(input_dir=self.input, output_dir=self.output, prompt_dir=self.prompts,
gallery_only=False, dry_run=False, test=False, limit=None,
model='gpt-image-2', quality='high')
with patch.dict('os.environ', {}, clear=True), redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(ValueError, 'OPENAI_API_KEY'):
genera.run(args)
self.sdk.OpenAI.assert_not_called()
def test_generate_then_resume_and_limit(self):
(self.prompts / 'a.txt').write_text('Preserve the lion.')
self.assertEqual(self.run_cli('--test'), 0)
self.assertEqual(self.client.images.edit.call_count, 1)
kwargs = self.client.images.edit.call_args.kwargs
self.assertEqual(kwargs['model'], 'gpt-image-2')
self.assertEqual(kwargs['size'], '1536x864')
self.assertIn('Preserve the lion.', kwargs['prompt'])
self.assertNotIn('input_fidelity', kwargs)
self.assertEqual(self.records()['a.jpg']['status'], 'completed')
self.assertEqual(self.run_cli('--limit', '1'), 0)
self.assertEqual(self.client.images.edit.call_count, 2)
self.assertEqual(self.run_cli(), 0)
self.assertEqual(self.client.images.edit.call_count, 2)
genera.validate_image(self.output / 'a.png', genera.SIZE)
genera.validate_image(self.output / 'b.png', genera.SIZE)
def test_completed_file_skipped_without_log(self):
self.output.mkdir()
(self.output / 'a.png').write_bytes(png())
self.assertEqual(self.run_cli('--test'), 0)
self.assertEqual(self.client.images.edit.call_count, 1)
self.assertIn('b.WEBP', self.records())
self.assertNotIn('a.jpg', self.records())
def test_corrupt_existing_output_blocks_paid_requests(self):
self.output.mkdir()
(self.output / 'a.png').write_bytes(b'not-an-image')
self.assertEqual(self.run_cli(), 1)
self.client.images.edit.assert_not_called()
self.assertEqual((self.output / 'a.png').read_bytes(), b'not-an-image')
def test_cached_response_requires_no_api_or_confirmation(self):
cache = self.output / '.responses'
cache.mkdir(parents=True)
(cache / 'a.png').write_bytes(png())
self.assertEqual(self.run_cli('--test', answer='no'), 0)
self.sdk.OpenAI.assert_not_called()
genera.validate_image(self.output / 'a.png', genera.SIZE)
def test_duplicate_stems_rejected_before_api(self):
self.photo('a.PNG')
self.assertEqual(self.run_cli('--dry-run'), 1)
self.sdk.OpenAI.assert_not_called()
def test_transient_errors_retry_with_bounded_attempts(self):
self.client.images.edit.side_effect = [ConnectionErrorFake(), StatusErrorFake(429), self.response]
with patch('genera.time.sleep') as sleep:
self.assertEqual(self.run_cli('--test'), 0)
self.assertEqual(sleep.call_count, 2)
self.assertEqual(self.records()['a.jpg']['attempts'], 3)
self.sdk.OpenAI.assert_called_once_with(timeout=180, max_retries=0)
def test_exhausted_timeouts_are_logged(self):
self.client.images.edit.side_effect = ConnectionErrorFake()
with patch('genera.time.sleep'):
self.assertEqual(self.run_cli('--test', '--attempts', '2'), 1)
self.assertEqual(self.client.images.edit.call_count, 2)
self.assertEqual(self.records()['a.jpg']['status'], 'error')
def test_permanent_error_not_retried_and_next_photo_processed(self):
self.client.images.edit.side_effect = [StatusErrorFake(400), self.response]
with patch('genera.time.sleep') as sleep:
self.assertEqual(self.run_cli(), 1)
sleep.assert_not_called()
self.assertEqual(self.client.images.edit.call_count, 2)
self.assertEqual(self.records()['a.jpg']['status'], 'error')
self.assertEqual(self.records()['b.WEBP']['status'], 'completed')
def test_bad_aspect_ratio_preserved_without_repeated_charge(self):
self.response.data[0].b64_json = base64.b64encode(png((100, 100))).decode()
self.assertEqual(self.run_cli('--test'), 1)
self.assertFalse((self.output / 'a.png').exists())
self.assertTrue((self.output / '.responses/a.png').exists())
self.assertEqual(self.run_cli('--test'), 1)
self.assertEqual(self.client.images.edit.call_count, 1)
def test_gallery_escapes_names_and_encodes_links(self):
self.photo('chiesa & "piazza".png')
self.assertEqual(self.run_cli('--gallery-only'), 0)
page = (self.output / 'index.html').read_text()
self.assertIn('chiesa &amp; &quot;piazza&quot;', page)
self.assertIn('chiesa%20%26%20%22piazza%22.png', page)
self.assertIn('Non ancora generata', page)
self.sdk.OpenAI.assert_not_called()
if __name__ == '__main__':
unittest.main()
+302
View File
@@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""Trasforma le foto del gioco con Images Edit; --dry-run non usa rete né scrive file."""
from __future__ import annotations
import argparse
import base64
import hashlib
import html
import io
import json
import os
from pathlib import Path
import sys
import tempfile
import time
from datetime import datetime, timezone
from urllib.parse import quote
ROOT = Path(__file__).resolve().parents[1]
EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
SIZE = (1536, 864)
DEFAULT_PROMPT = """Use case: style-transfer.
Create a clean background for La Leggenda del Regolo, a point-and-click adventure
set in San Piero a Sieve, Mugello, Tuscany, in late May 2007.
The input photograph is the architectural and composition reference.
Transform it into early-1990s (1993) VGA adventure pixel art: carefully placed
pixel clusters, limited warm palette of about 64 colors, crisp edges, no blur,
no smooth gradients, no photographic texture, no anti-aliasing.
Preserve the actual architecture, perspective, relative building positions,
historic details, openings, road layout and ground slope. Do not mirror the view.
Warm late-May afternoon light, consistent across the game's outdoor scenes.
Output one opaque 1536x864 image, 16:9, full bleed, no frame or letterboxing.
For a non-16:9 source, extend the framing conservatively without stretching
buildings or cropping essential architecture. Do not invent landmarks.
Keep existing foreground ground readable for walking without flattening slopes
or moving roads. Remove people and vehicles to leave an empty background; game
characters will be rendered separately. Remove readable lettering from signs.
No new characters, animals, text, logos, UI, inventory, cursor or watermark.
"""
def positive(value):
number = int(value)
if number <= 0:
raise argparse.ArgumentTypeError('deve essere maggiore di zero')
return number
def atomic_write(path, data):
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream:
temporary = Path(stream.name)
stream.write(data)
try:
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
def save_log(path, records):
atomic_write(path, (json.dumps(records, indent=2, ensure_ascii=False) + '\n').encode())
def read_log(path):
if not path.exists():
return {}
records = json.loads(path.read_text(encoding='utf-8'))
if not isinstance(records, dict):
raise ValueError(f'Registro non valido: {path}')
return records
def photos_in(folder):
if not folder.is_dir():
raise ValueError(f'Cartella originali non trovata: {folder}')
photos = sorted((p for p in folder.iterdir() if p.is_file() and
p.suffix.lower() in EXTENSIONS), key=lambda p: p.name.casefold())
names = set()
for photo in photos:
key = photo.stem.casefold()
if key in names:
raise ValueError(f'Nome duplicato: {photo.stem}. Rinominare le foto con lo stesso nome base.')
names.add(key)
return photos
def prompt_for(photo, folder):
common = folder / 'base.txt'
custom = folder / f'{photo.stem}.txt'
text = common.read_text(encoding='utf-8') if common.exists() else DEFAULT_PROMPT
if custom.exists():
text += '\n\nSCENE-SPECIFIC INSTRUCTIONS:\n' + custom.read_text(encoding='utf-8')
if not text.strip() or len(text) > 32000:
raise ValueError(f'Prompt vuoto o troppo lungo per {photo.name}')
return text
def validate_image(path, expected_size=None):
from PIL import Image
with Image.open(path) as image:
image.load()
if expected_size and (image.size != expected_size or image.format != 'PNG'):
raise ValueError(f'PNG {expected_size[0]}x{expected_size[1]} atteso: {path}')
def normalize_response(raw, destination):
from PIL import Image
with Image.open(raw) as image:
image.load()
if abs(image.width / image.height - 16 / 9) > 0.02:
raise ValueError('Risposta non 16:9: conservata in .responses; serve revisione, nessun ritaglio automatico.')
image = image.convert('RGB')
if image.size != SIZE:
image = image.resize(SIZE, Image.Resampling.NEAREST)
data = io.BytesIO()
image.save(data, format='PNG')
atomic_write(destination, data.getvalue())
def gallery(photos, output, records):
def link(path):
return html.escape(quote(os.path.relpath(path, output), safe='/'), quote=True)
cards = []
for photo in photos:
result = output / f'{photo.stem}.png'
state = records.get(photo.name, {}).get('status', 'da generare')
if result.exists() and state != 'completed':
state = 'file presente (verificare)'
after = (f'<img loading="lazy" src="{link(result)}" alt="{html.escape(photo.stem)} — risultato">'
if result.exists() else '<p class="pending">Non ancora generata</p>')
cards.append(f'<section><h2>{html.escape(photo.stem)}</h2><p>{html.escape(state)}</p>'
f'<div class="pair"><figure><img loading="lazy" src="{link(photo)}" '
f'alt="{html.escape(photo.stem)} — originale"><figcaption>Fotografia originale</figcaption>'
f'</figure><figure>{after}<figcaption>Pixel art · 1536×864</figcaption></figure></div></section>')
page = '''<!doctype html><html lang="it"><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>La Leggenda del Regolo — confronto sfondi</title>
<style>body{font:16px system-ui;background:#182027;color:#f6ebd8;margin:2rem auto;padding:0 1rem;max-width:1400px}
.pair{display:grid;grid-template-columns:1fr 1fr;gap:1rem}figure{margin:0;background:#26323b;padding:1rem}
img{width:100%;height:360px;object-fit:contain}figure+figure img{image-rendering:pixelated}
figcaption{padding-top:.5rem}.pending{height:328px;display:grid;place-items:center}
section{margin:2rem 0}a{color:#f4c47f}@media(max-width:700px){.pair{grid-template-columns:1fr}img{height:auto}}</style>
<h1>La Leggenda del Regolo — sfondi dalle fotografie</h1>
<p>San Piero a Sieve · originali e risultati. Le immagini mancanti sono indicate senza anteprime simulate.</p>
''' + '\n'.join(cards) + '</html>\n'
atomic_write(output / 'index.html', page.encode())
def run(args):
photos = photos_in(args.input_dir)
if args.input_dir.resolve() == args.output_dir.resolve():
raise ValueError('Originali e risultati devono avere cartelle diverse.')
log_path = args.output_dir / 'registro.json'
records = read_log(log_path)
if args.gallery_only:
gallery(photos, args.output_dir, records)
print(f'Confronto: {args.output_dir / "index.html"}')
return 0
pending = [p for p in photos if not (args.output_dir / f'{p.stem}.png').exists()]
limit = 1 if args.test else args.limit
selected = pending[:limit] if limit else pending
prompts = {p.name: prompt_for(p, args.prompt_dir) for p in selected}
cached = {p.name for p in selected if (args.output_dir / '.responses' / f'{p.stem}.png').exists()}
api_count = len(selected) - len(cached)
print(f'Foto trovate: {len(photos)}; PNG già presenti: {len(photos) - len(pending)}')
print(f'Da elaborare: {len(selected)}; da cache: {len(cached)}; immagini da inviare all’API: {api_count}')
print(f'Modello: {args.model}; formato: 1536x864; qualità: {args.quality}')
for photo in selected:
print(f' {photo.name} -> {args.output_dir / (photo.stem + ".png")} '
f'[{"cache locale" if photo.name in cached else "Images Edit"}]')
if args.dry_run:
print('DRY RUN: nessuna chiamata API e nessun file scritto.')
return 0
# Validate all existing results and selected sources before any paid request.
for photo in photos:
result = args.output_dir / f'{photo.stem}.png'
if result.exists():
validate_image(result, SIZE)
for photo in selected:
validate_image(photo)
client = None
if api_count:
if not os.environ.get('OPENAI_API_KEY'):
raise ValueError('Imposta OPENAI_API_KEY nell’ambiente. Non inserirla nel codice o nei prompt.')
try:
import openai
except ImportError as exc:
raise ValueError('Installa le dipendenze: python3 -m pip install -r requirements.txt') from exc
print(f'Possibili costi: {api_count} immagini, fino a {api_count * args.attempts} richieste con i retry.')
print('Un timeout può avvenire dopo l’elaborazione: un retry potrebbe comportare un altro addebito.')
try:
answer = input('Per procedere scrivi GENERA: ')
except EOFError:
answer = ''
if answer.strip() != 'GENERA':
print('Annullato: nessuna chiamata API.')
return 0
client = openai.OpenAI(timeout=args.timeout, max_retries=0)
failures = 0
try:
for photo in selected:
target = args.output_dir / f'{photo.stem}.png'
raw = args.output_dir / '.responses' / f'{photo.stem}.png'
previous = records.get(photo.name, {})
record = dict(previous) if raw.exists() else {
'source': str(photo.resolve()), 'output': str(target.resolve()),
'source_sha256': hashlib.sha256(photo.read_bytes()).hexdigest(),
'prompt': prompts[photo.name], 'model': args.model,
'size': '1536x864', 'quality': args.quality, 'attempts': 0,
}
records[photo.name] = record
try:
if not raw.exists():
for attempt in range(args.attempts):
record.update(status='processing', attempts=record['attempts'] + 1,
updated_at=datetime.now(timezone.utc).isoformat())
save_log(log_path, records)
try:
from PIL import Image, ImageOps
# Honor EXIF rotation and supply PNG regardless of source format.
with Image.open(photo) as source:
upload = io.BytesIO()
ImageOps.exif_transpose(source).convert('RGB').save(upload, format='PNG')
if upload.tell() >= 50 * 1024 * 1024:
raise ValueError('Immagine convertita oltre il limite di 50 MB.')
response = client.images.edit(
model=args.model, image=(photo.stem + '.png', upload.getvalue(), 'image/png'),
prompt=prompts[photo.name], size='1536x864', quality=args.quality,
output_format='png', n=1,
)
if not response.data or not response.data[0].b64_json:
raise ValueError('Risposta senza immagine base64.')
# Persist before post-processing, so local errors never require regeneration.
atomic_write(raw, base64.b64decode(response.data[0].b64_json, validate=True))
record['request_id'] = getattr(response, '_request_id', None)
usage = getattr(response, 'usage', None)
if usage is not None:
record['usage'] = usage.model_dump(mode='json')
break
except (openai.APIConnectionError, openai.APIStatusError) as exc:
status = getattr(exc, 'status_code', None)
transient = status is None or status in (408, 409, 429) or status >= 500
record['last_api_error'] = {'type': type(exc).__name__, 'status_code': status}
save_log(log_path, records)
if not transient or attempt + 1 == args.attempts:
raise
delay = min(2 ** (attempt + 1), 60)
print(f' {photo.name}: {type(exc).__name__}, nuovo tentativo tra {delay}s.')
time.sleep(delay)
normalize_response(raw, target)
record.update(status='completed', updated_at=datetime.now(timezone.utc).isoformat())
record.pop('error', None)
print(f' Completata: {target}')
except Exception as exc:
failures += 1
# Do not log SDK exception bodies, headers, credentials or base64 payloads.
message = str(exc) if isinstance(exc, (ValueError, OSError)) else type(exc).__name__
record.update(status='error', error=message, updated_at=datetime.now(timezone.utc).isoformat())
print(f' Errore {photo.name}: {message}', file=sys.stderr)
finally:
save_log(log_path, records)
gallery(photos, args.output_dir, records)
gallery(photos, args.output_dir, records)
finally:
if client is not None:
client.close()
return 1 if failures else 0
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--input-dir', type=Path, default=ROOT / 'assets/raw/foto')
parser.add_argument('--output-dir', type=Path, default=ROOT / 'assets/generated')
parser.add_argument('--prompt-dir', type=Path, default=ROOT / 'prompts/backgrounds')
parser.add_argument('--model', default='gpt-image-2')
parser.add_argument('--quality', choices=['low', 'medium', 'high', 'auto'], default='high')
parser.add_argument('--timeout', type=positive, default=180)
parser.add_argument('--attempts', type=positive, default=3, help='numero massimo di richieste per foto')
limit = parser.add_mutually_exclusive_group()
limit.add_argument('--test', action='store_true', help='prima foto non ancora completata')
limit.add_argument('--limit', type=positive)
mode = parser.add_mutually_exclusive_group()
mode.add_argument('--dry-run', action='store_true')
mode.add_argument('--gallery-only', action='store_true', help='scrive solo il confronto HTML, senza API')
args = parser.parse_args(argv)
try:
return run(args)
except (ValueError, OSError, ImportError) as exc:
print(f'Errore: {exc}', file=sys.stderr)
return 1
except KeyboardInterrupt:
print('\nInterrotto. I risultati già salvati verranno riutilizzati.', file=sys.stderr)
return 130
if __name__ == '__main__':
sys.exit(main())
+8 -4
View File
@@ -5,7 +5,9 @@
--despill --names walk_1,walk_2,walk_3,walk_4,stand,talk,take,back --prefix andrea_
python3 tools/prepare_character.py /tmp/andrea andrea assets/characters --height 120
Tutti i fotogrammi vengono ridotti con lo stesso fattore (l'altezza del fotogramma `stand` diventa --height) e
Per gli altri personaggi: --frames sit,talk,... --ref sit (i nomi dei fotogrammi e quello che dà l'altezza).
Tutti i fotogrammi vengono ridotti con lo stesso fattore (l'altezza del fotogramma --ref diventa --height) e
salvati in un'unica striscia PNG. Il JSON accanto dice per ogni fotogramma dove sta nella striscia e dove sono
i piedi: x è il centro del busto (la parte alta del corpo, che in una camminata resta ferma), y la base.
"""
@@ -32,11 +34,13 @@ def main():
p.add_argument('name')
p.add_argument('out')
p.add_argument('--height', type=int, default=120)
p.add_argument('--frames', default=','.join(FRAMES), help='nomi dei fotogrammi, separati da virgola')
p.add_argument('--ref', default='stand', help='fotogramma la cui altezza diventa --height')
a = p.parse_args()
frames = {f: Image.open(Path(a.src) / f'{a.name}_{f}.png').convert('RGBA') for f in FRAMES}
stand = frames['stand']
factor = a.height / (stand.getchannel('A').getbbox()[3] - stand.getchannel('A').getbbox()[1])
frames = {f: Image.open(Path(a.src) / f'{a.name}_{f}.png').convert('RGBA') for f in a.frames.split(',')}
ref = frames[a.ref].getchannel('A').getbbox()
factor = a.height / (ref[3] - ref[1])
strip, meta, x = [], {}, 0
for f, im in frames.items():
im = im.crop(im.getchannel('A').getbbox())