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
@@ -3,3 +3,6 @@ prompts/
|
||||
_site/
|
||||
__pycache__/
|
||||
node_modules/
|
||||
assets/generated/
|
||||
.venv/
|
||||
.env
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 75 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 411 KiB |
|
After Width: | Height: | Size: 235 KiB |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
openai>=2.0.0,<3
|
||||
Pillow>=10.0.0,<13
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }; }
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); });
|
||||
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
|
||||
@@ -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' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 & "piazza"', 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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||