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/
|
_site/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
node_modules/
|
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 src /usr/share/nginx/html/src
|
||||||
COPY assets/scenes /usr/share/nginx/html/assets/scenes
|
COPY assets/scenes /usr/share/nginx/html/assets/scenes
|
||||||
COPY assets/characters /usr/share/nginx/html/assets/characters
|
COPY assets/characters /usr/share/nginx/html/assets/characters
|
||||||
|
COPY assets/audio /usr/share/nginx/html/assets/audio
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
|
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.
|
# `make` o `make help` elenca i target.
|
||||||
|
|
||||||
SHELL := /bin/bash
|
SHELL := /bin/bash
|
||||||
@@ -6,27 +6,80 @@ SHELL := /bin/bash
|
|||||||
|
|
||||||
PORT ?= 8000
|
PORT ?= 8000
|
||||||
DOCKER_PORT ?= 8080
|
DOCKER_PORT ?= 8080
|
||||||
|
IMAGE ?= leggenda-regolo
|
||||||
|
TAG ?= latest
|
||||||
COMPOSE ?= DOCKER_PORT=$(DOCKER_PORT) docker compose
|
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
|
help: ## Mostra questo aiuto
|
||||||
@echo "LA LEGGENDA DEL REGOLO — target disponibili:"; echo
|
@echo "LA LEGGENDA DEL REGOLO — target disponibili:"; echo
|
||||||
@grep -hE '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | \
|
@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)
|
serve: ## Avvia il server statico locale (PORT=8000)
|
||||||
@echo "→ http://localhost:$(PORT)"
|
@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
|
open: ## Apre il gioco nel browser predefinito (serve già avviato)
|
||||||
node --test tests/*.test.mjs
|
@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
|
browser: ## Partita completa nel browser con Playwright
|
||||||
node tests/browser.mjs
|
$(NODE) tests/browser.mjs
|
||||||
|
|
||||||
verify: test browser ## Tutti i controlli
|
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)
|
up: ## Avvia il container (DOCKER_PORT=8080)
|
||||||
$(COMPOSE) up -d --build
|
$(COMPOSE) up -d --build
|
||||||
@echo "→ http://localhost:$(DOCKER_PORT)"
|
@echo "→ http://localhost:$(DOCKER_PORT)"
|
||||||
@@ -41,3 +94,17 @@ logs: ## Segue i log del container
|
|||||||
|
|
||||||
ps: ## Stato del container
|
ps: ## Stato del container
|
||||||
$(COMPOSE) ps
|
$(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).
|
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.
|
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
|
## Giocare in locale
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
|
|||||||
@@ -5,66 +5,66 @@
|
|||||||
"walk_1": {
|
"walk_1": {
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
"w": 65,
|
"w": 64,
|
||||||
"h": 111,
|
"h": 111,
|
||||||
"ax": 36.6,
|
"ax": 36.3,
|
||||||
"ay": 111
|
"ay": 111
|
||||||
},
|
},
|
||||||
"walk_2": {
|
"walk_2": {
|
||||||
"x": 67,
|
"x": 66,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
"w": 35,
|
"w": 34,
|
||||||
"h": 111,
|
"h": 111,
|
||||||
"ax": 17.8,
|
"ax": 17.8,
|
||||||
"ay": 111
|
"ay": 111
|
||||||
},
|
},
|
||||||
"walk_3": {
|
"walk_3": {
|
||||||
"x": 104,
|
"x": 102,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
"w": 64,
|
"w": 64,
|
||||||
"h": 111,
|
"h": 111,
|
||||||
"ax": 34.5,
|
"ax": 34.7,
|
||||||
"ay": 111
|
"ay": 111
|
||||||
},
|
},
|
||||||
"walk_4": {
|
"walk_4": {
|
||||||
"x": 170,
|
"x": 168,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
"w": 38,
|
"w": 38,
|
||||||
"h": 111,
|
"h": 111,
|
||||||
"ax": 21.5,
|
"ax": 21.7,
|
||||||
"ay": 111
|
"ay": 111
|
||||||
},
|
},
|
||||||
"stand": {
|
"stand": {
|
||||||
"x": 210,
|
"x": 208,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
"w": 34,
|
"w": 33,
|
||||||
"h": 120,
|
"h": 120,
|
||||||
"ax": 15.1,
|
"ax": 14.9,
|
||||||
"ay": 120
|
"ay": 120
|
||||||
},
|
},
|
||||||
"talk": {
|
"talk": {
|
||||||
"x": 246,
|
"x": 243,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
"w": 56,
|
"w": 59,
|
||||||
"h": 121,
|
"h": 120,
|
||||||
"ax": 20.5,
|
"ax": 21.6,
|
||||||
"ay": 121
|
"ay": 120
|
||||||
},
|
},
|
||||||
"take": {
|
"take": {
|
||||||
"x": 304,
|
"x": 304,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
"w": 68,
|
"w": 68,
|
||||||
"h": 97,
|
"h": 97,
|
||||||
"ax": 34.7,
|
"ax": 34.5,
|
||||||
"ay": 97
|
"ay": 97
|
||||||
},
|
},
|
||||||
"back": {
|
"back": {
|
||||||
"x": 374,
|
"x": 374,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
"w": 43,
|
"w": 43,
|
||||||
"h": 120,
|
"h": 119,
|
||||||
"ax": 21.0,
|
"ax": 20.8,
|
||||||
"ay": 120
|
"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 |
|
| **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 |
|
| **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"
|
### Il Castello del Trebbio: "lo psicologo"
|
||||||
|
|
||||||
Per Andrea il Trebbio è il posto dove salire a cercare pace (scherzando: il suo psicologo). Nel gioco diventa
|
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,
|
- **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
|
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.
|
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`):
|
- **Scene con sfondi veri** (`assets/scenes/`), collegate come il paese a gradini (`PROGETTO.md`, «La mappa»):
|
||||||
Piazza Colonna → vicolo → Fortezza (fuori dalle mura) → salita → Castello del Trebbio.
|
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`):
|
- **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.
|
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
|
- **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.
|
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
|
## 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
|
### Domande per Andrea
|
||||||
1. Cosa c'era dipinto sul palio del 2007? (Un'immagine nuova: quadro ingenuo dei ragazzi delle medie, senza
|
1. Cosa c'era dipinto sul palio del 2007? (Un'immagine nuova: quadro ingenuo dei ragazzi delle medie, senza
|
||||||
cornice, su sfondo verde.)
|
cornice, su sfondo verde.)
|
||||||
|
|||||||
@@ -11,12 +11,21 @@
|
|||||||
<div id="stage">
|
<div id="stage">
|
||||||
<canvas id="scene" width="640" height="360" aria-label="La scena dell'avventura"></canvas>
|
<canvas id="scene" width="640" height="360" aria-label="La scena dell'avventura"></canvas>
|
||||||
<div id="speech" aria-live="polite"></div>
|
<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">
|
<section id="title" class="overlay">
|
||||||
<h1>La Leggenda<br>del <span>Regolo</span></h1>
|
<h1>La Leggenda<br>del <span>Regolo</span></h1>
|
||||||
<p class="subtitle">San Piero a Sieve · Palio della Fortezza · 2007</p>
|
<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>
|
<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>
|
<p class="dedication">Al Lele, che mi ha fatto conoscere Monkey Island.</p>
|
||||||
</section>
|
</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>
|
<section id="ending" class="overlay" hidden>
|
||||||
<h2>Fine del prototipo</h2>
|
<h2>Fine del prototipo</h2>
|
||||||
<p>Il Regolo è passato di qui. Il resto della storia è ancora da scrivere.</p>
|
<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)); }
|
hotspots() { return (this.scene.hotspots ?? []).filter(h => check(h.when, this.state)); }
|
||||||
hotspot(id) { return this.hotspots().find(h => h.id === id); }
|
hotspot(id) { return this.hotspots().find(h => h.id === id); }
|
||||||
|
people() { return (this.scene.people ?? []).filter(p => check(p.when, this.state)); }
|
||||||
hotspotAt(p) {
|
hotspotAt(p) {
|
||||||
const list = this.hotspots();
|
const list = this.hotspots();
|
||||||
for (let i = list.length - 1; i >= 0; i--) if (inBox(list[i].box, p)) return list[i];
|
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';
|
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) {
|
scaleAt(y) {
|
||||||
const d = this.scene.depth;
|
const d = this.scene.depth;
|
||||||
if (!d) return 1;
|
if (!d) return 1;
|
||||||
@@ -218,7 +232,7 @@ export class Game {
|
|||||||
|
|
||||||
run(e) {
|
run(e) {
|
||||||
const s = this.state;
|
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); }
|
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.
|
// `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);
|
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]);
|
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;
|
const PERSON = 62;
|
||||||
function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing) {
|
function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing, size = 1) {
|
||||||
const f = atlas.frames[frame] ?? atlas.frames.stand, k = PERSON * scale / atlas.height;
|
const f = atlas.frames[frame] ?? atlas.frames.stand ?? Object.values(atlas.frames)[0], k = PERSON * scale * size / atlas.height;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(Math.round(x), Math.round(y));
|
ctx.translate(Math.round(x), Math.round(y));
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.22)';
|
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);
|
if (bg) ctx.drawImage(bg, 0, 0, WIDTH, HEIGHT);
|
||||||
for (const shape of scene.art ?? []) drawShape(ctx, shape);
|
for (const shape of scene.art ?? []) drawShape(ctx, shape);
|
||||||
for (const h of game.hotspots()) for (const shape of h.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');
|
const andrea = sprites.get('andrea');
|
||||||
if (andrea) drawSprite(ctx, andrea, game.pose(), s.x, s.y, game.scaleAt(s.y), s.facing);
|
const drawAndreaNow = () => 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);
|
: 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) {
|
if (hover) {
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
||||||
ctx.setLineDash([3, 3]);
|
ctx.setLineDash([3, 3]);
|
||||||
|
|||||||
@@ -27,3 +27,24 @@ export function clearSave(storage) {
|
|||||||
return false;
|
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 { Game, VERBS, VERB_LABELS } from './engine/game.js';
|
||||||
import { Command } from './engine/command.js';
|
import { Command } from './engine/command.js';
|
||||||
import { render, loadAssets, WIDTH, HEIGHT } from './engine/render.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 $ = id => document.getElementById(id);
|
||||||
const canvas = $('scene'), ctx = canvas.getContext('2d');
|
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;
|
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) {
|
function start(state) {
|
||||||
game = new Game(story, { state, onSave: data => storage && writeSave(storage, data) });
|
game = new Game(story, { state, onSave: data => storage && writeSave(storage, data) });
|
||||||
command = new Command(game);
|
command = new Command(game);
|
||||||
@@ -23,6 +38,27 @@ function start(state) {
|
|||||||
window.game = game; // per i test nel browser e per curiosare dalla console
|
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() {
|
function showTitle() {
|
||||||
running = false;
|
running = false;
|
||||||
const saved = storage && readSave(storage);
|
const saved = storage && readSave(storage);
|
||||||
@@ -113,6 +149,9 @@ function updateChoices() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', e => {
|
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 (!running) return;
|
||||||
if ([' ', '.', 'Enter'].includes(e.key) && game.speech) { e.preventDefault(); game.skip(); return; }
|
if ([' ', '.', 'Enter'].includes(e.key) && game.speech) { e.preventDefault(); game.skip(); return; }
|
||||||
if (e.key === 'Escape') command.reset();
|
if (e.key === 'Escape') command.reset();
|
||||||
@@ -150,6 +189,8 @@ let last = performance.now();
|
|||||||
function frame(now) {
|
function frame(now) {
|
||||||
const dt = Math.min(0.1, (now - last) / 1000);
|
const dt = Math.min(0.1, (now - last) / 1000);
|
||||||
last = now;
|
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 (game) {
|
||||||
if (running) game.update(dt);
|
if (running) game.update(dt);
|
||||||
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null });
|
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null });
|
||||||
@@ -158,7 +199,7 @@ function frame(now) {
|
|||||||
requestAnimationFrame(frame);
|
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)));
|
$('continue').addEventListener('click', () => start(readSave(storage)));
|
||||||
$('restart').addEventListener('click', () => { if (storage) clearSave(storage); start(null); });
|
$('restart').addEventListener('click', () => { if (storage) clearSave(storage); start(null); });
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export const solution = [
|
|||||||
['choose', 'Avete notato niente di strano stanotte?'],
|
['choose', 'Avete notato niente di strano stanotte?'],
|
||||||
['choose', 'Ci vediamo dopo.'],
|
['choose', 'Ci vediamo dopo.'],
|
||||||
['take', 'cosa_luccicante'],
|
['take', 'cosa_luccicante'],
|
||||||
['walk', 'vicolo'],
|
['walk', 'verso_villa_adami'],
|
||||||
|
['walk', 'su_villa_adami'],
|
||||||
|
['walk', 'salita'],
|
||||||
|
['walk', 'su_fortezza'],
|
||||||
['use', 'impronte', 'squama'],
|
['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).
|
// 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.
|
// Le forme in `art` sono segnaposto disegnati sopra lo sfondo (vecchietti, squama, catena) in attesa degli sprite.
|
||||||
|
|
||||||
export const story = {
|
export const story = {
|
||||||
title: 'La Leggenda del Regolo',
|
title: 'La Leggenda del Regolo',
|
||||||
start: { scene: 'piazza', at: [250, 320] },
|
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: {
|
characters: {
|
||||||
andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' },
|
andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' },
|
||||||
vecchioA: { name: 'Vecchietto col cappello', color: '#ffd27a' },
|
vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' },
|
||||||
vecchioB: { name: 'Vecchietto col bastone', color: '#9fe3ff' },
|
vecchietto: { name: 'Vecchietto', color: '#9fe3ff', sprite: 'assets/characters/vecchietto.json' },
|
||||||
castello: { name: 'Il Castello', color: '#c8f0a0' },
|
castello: { name: 'Il Castello', color: '#c8f0a0' },
|
||||||
},
|
},
|
||||||
items: {
|
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]],
|
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]],
|
depth: [[236, 0.5], [360, 1.35]],
|
||||||
entry: [345, 240],
|
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: [
|
hotspots: [
|
||||||
{
|
{
|
||||||
id: 'colonna', name: 'colonna', box: [492, 134, 36, 140], at: [505, 290], face: 'up',
|
id: 'colonna', name: 'colonna', box: [492, 134, 36, 140], at: [505, 290], face: 'up',
|
||||||
actions: {
|
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?' }],
|
take: [{ say: 'Se mi porto via la colonna, la piazza come la chiamano?' }],
|
||||||
use: [{ say: 'Arrampicarmi? Il palio lassù non c\'è comunque.' }],
|
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.' }],
|
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',
|
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' }],
|
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',
|
id: 'vecchietti', name: 'vecchietti', box: [0, 250, 72, 52], 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' },
|
|
||||||
],
|
|
||||||
actions: {
|
actions: {
|
||||||
look: [{ say: 'I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.' }],
|
look: [{ say: 'I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.' }],
|
||||||
talk: [{ dialogue: 'vecchietti' }],
|
talk: [{ dialogue: 'vecchietti' }],
|
||||||
take: [{ say: 'Se li porto via, il paese resta senza notizie.' }],
|
take: [{ say: 'Se li porto via, il paese resta senza notizie.' }],
|
||||||
},
|
},
|
||||||
give: {
|
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.' }] }],
|
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: {
|
trebbio: {
|
||||||
name: 'Castello del Trebbio',
|
name: 'Castello del Trebbio',
|
||||||
background: 'assets/scenes/trebbio.png',
|
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]],
|
walk: [[86, 308], [300, 306], [356, 308], [470, 312], [500, 324], [486, 360], [88, 360], [80, 330]],
|
||||||
depth: [[306, 0.75], [360, 1.05]],
|
depth: [[306, 0.75], [360, 1.05]],
|
||||||
entry: [110, 345],
|
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?',
|
text: 'Avete visto chi ha preso il palio?',
|
||||||
then: [
|
then: [
|
||||||
{ say: 'È stato il Regolo! Quello che cadde dal cielo quando scavavano le fondamenta.', who: 'vecchioA' },
|
{ 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: 'vecchioB' },
|
{ 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: 'vecchioA' },
|
{ say: 'Napoleone? A San Piero? Ma se un c\'è mai venuto!', who: 'vecchietta', pose: 'point' },
|
||||||
{ say: 'E il Regolo sì?', who: 'vecchioB' },
|
{ say: 'E il Regolo sì?', who: 'vecchietto', pose: 'shrug' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stanotte', once: true, text: 'Avete notato niente di strano stanotte?',
|
id: 'stanotte', once: true, text: 'Avete notato niente di strano stanotte?',
|
||||||
then: [
|
then: [
|
||||||
{ say: 'Io dormivo. Alla mia età si dorme anche in piedi.', who: 'vecchioB' },
|
{ 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: 'vecchioA' },
|
{ say: 'Io stamani ho visto luccicare qualcosa sotto la panchina.', who: 'vecchietta' },
|
||||||
{ say: 'Ma con la mia schiena, chi ci si china?', who: 'vecchioA' },
|
{ say: 'Ma con la mia schiena, chi ci si china?', who: 'vecchietta', pose: 'look_down' },
|
||||||
{ set: 'luccichio' },
|
{ set: 'luccichio' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Come vi sembra il Palio di quest\'anno?',
|
text: 'Come vi sembra il Palio di quest\'anno?',
|
||||||
then: [
|
then: [
|
||||||
{ say: 'Senza il palio? Una festa così non s\'è mai vista.', who: 'vecchioB' },
|
{ 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: 'vecchioA' },
|
{ 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; }
|
[hidden] { display: none !important; }
|
||||||
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: var(--font); }
|
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; }
|
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; }
|
#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; }
|
#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; }
|
text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; }
|
||||||
#speech:empty { display: none; }
|
#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); }
|
#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); }
|
#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;
|
#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; }
|
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:hover, #inventory button:hover { color: var(--ink); }
|
||||||
#verbs button[aria-pressed="true"] { color: var(--bg); background: var(--accent); border-color: var(--accent); }
|
#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 { display: flex; align-items: center; gap: .35em; justify-content: center; color: var(--ink); }
|
||||||
#inventory button[aria-pressed="true"] { border-color: var(--accent); }
|
#inventory button[aria-pressed="true"] { border-color: var(--accent); }
|
||||||
#inventory i { width: .8em; height: .8em; border-radius: 50% 50% 50% 0; flex: none; }
|
#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;
|
.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)); }
|
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; }
|
.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 { 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 h1 span { color: #7fe08a; }
|
||||||
.overlay h2 { margin: 0; font-size: clamp(24px, 5vw, 52px); color: var(--accent); }
|
.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); }
|
#side { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--panel); }
|
||||||
#sentence { font-size: 14px; }
|
#sentence { font-size: 14px; }
|
||||||
#panel { grid-template-columns: 1fr; gap: 6px; padding: 6px; }
|
#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; }
|
#verbs button, #inventory button, #choices li { font-size: 14px; }
|
||||||
#choices { min-height: 0; padding: 6px 8px 6px 2em; }
|
#choices { min-height: 0; padding: 6px 8px 6px 2em; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,12 +47,23 @@ try {
|
|||||||
await page.waitForSelector('#title:not([hidden])');
|
await page.waitForSelector('#title:not([hidden])');
|
||||||
await page.screenshot({ path: `${OUT}/01-titolo.png` });
|
await page.screenshot({ path: `${OUT}/01-titolo.png` });
|
||||||
await page.click('#new-game');
|
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.waitForFunction(() => game.speech);
|
||||||
await page.screenshot({ path: `${OUT}/02-inizio.png` });
|
await page.screenshot({ path: `${OUT}/02-inizio.png` });
|
||||||
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
|
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
|
||||||
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
|
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
|
||||||
await settle(page);
|
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) {
|
for (const [verb, target, item] of solution) {
|
||||||
if (verb === 'choose') {
|
if (verb === 'choose') {
|
||||||
await page.locator('#choices li', { hasText: target }).click();
|
await page.locator('#choices li', { hasText: target }).click();
|
||||||
@@ -68,7 +79,7 @@ try {
|
|||||||
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
|
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
|
||||||
await settle(page);
|
await settle(page);
|
||||||
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
|
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.waitForSelector('#ending:not([hidden])');
|
||||||
await page.screenshot({ path: `${OUT}/06-fine.png` });
|
await page.screenshot({ path: `${OUT}/06-fine.png` });
|
||||||
@@ -78,7 +89,7 @@ try {
|
|||||||
// Una partita a metà riprende da dove era rimasta.
|
// Una partita a metà riprende da dove era rimasta.
|
||||||
await page.click('#restart');
|
await page.click('#restart');
|
||||||
await settle(page);
|
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 clickScene(page, await hotspotCenter(page, exit));
|
||||||
await settle(page);
|
await settle(page);
|
||||||
}
|
}
|
||||||
@@ -98,12 +109,14 @@ try {
|
|||||||
|
|
||||||
const phone = await open({ width: 390, height: 844 });
|
const phone = await open({ width: 390, height: 844 });
|
||||||
await phone.click('#new-game');
|
await phone.click('#new-game');
|
||||||
|
await phone.click('#skip-intro');
|
||||||
await settle(phone);
|
await settle(phone);
|
||||||
const overflow = await phone.evaluate(() => document.documentElement.scrollWidth > innerWidth);
|
const overflow = await phone.evaluate(() => document.documentElement.scrollWidth > innerWidth);
|
||||||
assert.equal(overflow, false, 'scorrimento orizzontale su telefono');
|
assert.equal(overflow, false, 'scorrimento orizzontale su telefono');
|
||||||
await phone.screenshot({ path: `${OUT}/08-telefono.png` });
|
await phone.screenshot({ path: `${OUT}/08-telefono.png` });
|
||||||
const landscape = await open({ width: 844, height: 390 });
|
const landscape = await open({ width: 844, height: 390 });
|
||||||
await landscape.click('#new-game');
|
await landscape.click('#new-game');
|
||||||
|
await landscape.click('#skip-intro');
|
||||||
await settle(landscape);
|
await settle(landscape);
|
||||||
assert.equal(await landscape.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
assert.equal(await landscape.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||||||
await landscape.screenshot({ path: `${OUT}/09-orizzontale.png`, fullPage: true });
|
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();
|
const { g, c, at } = setup();
|
||||||
assert.equal(c.sentence('vecchietti'), 'Parla con vecchietti');
|
assert.equal(c.sentence('vecchietti'), 'Parla con vecchietti');
|
||||||
assert.equal(c.sentence('manifesto'), 'Guarda manifesto del Palio');
|
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');
|
assert.equal(c.clickScene(at('vecchietti')), 'act');
|
||||||
settle(g);
|
settle(g);
|
||||||
assert.equal(g.dialogue?.id, 'vecchietti');
|
assert.equal(g.dialogue?.id, 'vecchietti');
|
||||||
@@ -59,7 +59,7 @@ test('oggetto dell\'inventario: usa con, dai a, guarda', () => {
|
|||||||
c.clickItem('squama');
|
c.clickItem('squama');
|
||||||
assert.equal(c.sentence('vecchietti'), 'Dai squama a vecchietti');
|
assert.equal(c.sentence('vecchietti'), 'Dai squama a vecchietti');
|
||||||
c.clickScene(at('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');
|
assert.deepEqual(g.state.inventory, ['squama'], 'nessun vicolo cieco: la squama resta');
|
||||||
c.clickItem('squama', { look: true });
|
c.clickItem('squama', { look: true });
|
||||||
assert.match(settle(g)[0].text, /cantina/);
|
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()); }
|
for (let i = 0; i < 10; i++) { g.time += 0.1; poses.add(g.pose()); }
|
||||||
assert.deepEqual([...poses].sort(), ['stand', 'talk']);
|
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 test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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';
|
import { memoryStorage } from './helpers.mjs';
|
||||||
|
|
||||||
test('scrive, rilegge e cancella il salvataggio', () => {
|
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');
|
garbage.setItem('leggenda-regolo-save', '{non è json');
|
||||||
assert.equal(readSave(garbage), null);
|
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.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}`);
|
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) {
|
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];
|
const to = story.scenes[h.exit.scene];
|
||||||
assert.ok(to, `${id}.${h.id}: scena ${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`);
|
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');
|
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)) {
|
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 url = new URL('../' + c.sprite, import.meta.url);
|
||||||
const atlas = JSON.parse(readFileSync(url, 'utf8'));
|
const atlas = JSON.parse(readFileSync(url, 'utf8'));
|
||||||
assert.ok(existsSync(new URL(atlas.image, url)), `${id}: manca ${atlas.image}`);
|
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_
|
--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
|
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
|
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.
|
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('name')
|
||||||
p.add_argument('out')
|
p.add_argument('out')
|
||||||
p.add_argument('--height', type=int, default=120)
|
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()
|
a = p.parse_args()
|
||||||
|
|
||||||
frames = {f: Image.open(Path(a.src) / f'{a.name}_{f}.png').convert('RGBA') for f in FRAMES}
|
frames = {f: Image.open(Path(a.src) / f'{a.name}_{f}.png').convert('RGBA') for f in a.frames.split(',')}
|
||||||
stand = frames['stand']
|
ref = frames[a.ref].getchannel('A').getbbox()
|
||||||
factor = a.height / (stand.getchannel('A').getbbox()[3] - stand.getchannel('A').getbbox()[1])
|
factor = a.height / (ref[3] - ref[1])
|
||||||
strip, meta, x = [], {}, 0
|
strip, meta, x = [], {}, 0
|
||||||
for f, im in frames.items():
|
for f, im in frames.items():
|
||||||
im = im.crop(im.getchannel('A').getbbox())
|
im = im.crop(im.getchannel('A').getbbox())
|
||||||
|
|||||||