Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bda52d9ac | ||
|
|
ec008b5ea1 | ||
|
|
73f3418bab | ||
|
|
ce4e47a2ed | ||
|
|
f6fd2fdfdc | ||
|
|
9d9dc81bcb | ||
|
|
bc19d6be53 | ||
|
|
c54023083a | ||
|
|
af6861350d | ||
|
|
53da1e48f8 | ||
|
|
019c3110f9 | ||
|
|
17a4a01f6d |
@@ -12,6 +12,7 @@ COPY assets/fonts /usr/share/nginx/html/assets/fonts
|
||||
COPY assets/voices /usr/share/nginx/html/assets/voices
|
||||
COPY assets/credits /usr/share/nginx/html/assets/credits
|
||||
COPY assets/effects /usr/share/nginx/html/assets/effects
|
||||
COPY assets/closeups /usr/share/nginx/html/assets/closeups
|
||||
|
||||
# Le immagini e i font citati da index.html e style.css prendono ?v=<versione>, come quelli caricati dal codice.
|
||||
RUN cd /usr/share/nginx/html \
|
||||
|
||||
@@ -143,6 +143,9 @@ deploy: ## Pubblica l'ultimo commit: test, immagine costruita e provata qui, spe
|
||||
scp -q $(DEPLOY_TMP)/docker-compose.homeserver.yml $(DEPLOY_HOST):$(DEPLOY_DIR)/docker-compose.yml
|
||||
docker save $(IMAGE):latest | gzip | ssh $(DEPLOY_HOST) 'gunzip | docker load -q && cd $(DEPLOY_DIR) && docker compose up -d && docker image prune -f >/dev/null'
|
||||
rm -rf $(DEPLOY_TMP)
|
||||
@# Sul server il prune toglie l'immagine vecchia; qui restano solo latest e quella appena pubblicata.
|
||||
@docker image ls $(IMAGE) --format '{{.Tag}}' | grep -v -x -e latest -e $$(git rev-parse --short HEAD) | \
|
||||
xargs -r -I{} docker image rm -f $(IMAGE):{} >/dev/null
|
||||
@echo "→ $(DEPLOY_URL) ($$(git log -1 --format='%h %s'))"
|
||||
|
||||
deploy-logs: ## Log del container sul server di deploy
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"image": "arcu.png",
|
||||
"height": 120,
|
||||
"frames": {
|
||||
"stand": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 42,
|
||||
"h": 120,
|
||||
"ax": 20.9,
|
||||
"ay": 120
|
||||
},
|
||||
"blink": {
|
||||
"x": 44,
|
||||
"y": 0,
|
||||
"w": 42,
|
||||
"h": 120,
|
||||
"ax": 20.3,
|
||||
"ay": 120
|
||||
},
|
||||
"talk": {
|
||||
"x": 88,
|
||||
"y": 0,
|
||||
"w": 42,
|
||||
"h": 120,
|
||||
"ax": 20.9,
|
||||
"ay": 120
|
||||
},
|
||||
"explain": {
|
||||
"x": 132,
|
||||
"y": 0,
|
||||
"w": 66,
|
||||
"h": 120,
|
||||
"ax": 24.3,
|
||||
"ay": 120
|
||||
},
|
||||
"laugh": {
|
||||
"x": 200,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 121,
|
||||
"ax": 17.8,
|
||||
"ay": 121
|
||||
},
|
||||
"point": {
|
||||
"x": 248,
|
||||
"y": 0,
|
||||
"w": 65,
|
||||
"h": 121,
|
||||
"ax": 24.9,
|
||||
"ay": 121
|
||||
},
|
||||
"shrug": {
|
||||
"x": 315,
|
||||
"y": 0,
|
||||
"w": 78,
|
||||
"h": 121,
|
||||
"ax": 36.9,
|
||||
"ay": 121
|
||||
},
|
||||
"arms_crossed": {
|
||||
"x": 395,
|
||||
"y": 0,
|
||||
"w": 43,
|
||||
"h": 121,
|
||||
"ax": 19.4,
|
||||
"ay": 121
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"image": "barondeduke.png",
|
||||
"height": 120,
|
||||
"frames": {
|
||||
"stand": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 47,
|
||||
"h": 120,
|
||||
"ax": 21.7,
|
||||
"ay": 120
|
||||
},
|
||||
"blink": {
|
||||
"x": 49,
|
||||
"y": 0,
|
||||
"w": 47,
|
||||
"h": 120,
|
||||
"ax": 21.5,
|
||||
"ay": 120
|
||||
},
|
||||
"talk": {
|
||||
"x": 98,
|
||||
"y": 0,
|
||||
"w": 48,
|
||||
"h": 120,
|
||||
"ax": 21.8,
|
||||
"ay": 120
|
||||
},
|
||||
"explain": {
|
||||
"x": 148,
|
||||
"y": 0,
|
||||
"w": 61,
|
||||
"h": 120,
|
||||
"ax": 23.9,
|
||||
"ay": 120
|
||||
},
|
||||
"laugh": {
|
||||
"x": 211,
|
||||
"y": 0,
|
||||
"w": 53,
|
||||
"h": 117,
|
||||
"ax": 26.6,
|
||||
"ay": 117
|
||||
},
|
||||
"cards": {
|
||||
"x": 266,
|
||||
"y": 0,
|
||||
"w": 56,
|
||||
"h": 118,
|
||||
"ax": 24.1,
|
||||
"ay": 118
|
||||
},
|
||||
"shrug": {
|
||||
"x": 324,
|
||||
"y": 0,
|
||||
"w": 73,
|
||||
"h": 117,
|
||||
"ax": 35.7,
|
||||
"ay": 117
|
||||
},
|
||||
"facepalm": {
|
||||
"x": 399,
|
||||
"y": 0,
|
||||
"w": 53,
|
||||
"h": 117,
|
||||
"ax": 27.3,
|
||||
"ay": 117
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"image": "tozzino.png",
|
||||
"height": 120,
|
||||
"frames": {
|
||||
"stand": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 40,
|
||||
"h": 120,
|
||||
"ax": 20.6,
|
||||
"ay": 120
|
||||
},
|
||||
"blink": {
|
||||
"x": 42,
|
||||
"y": 0,
|
||||
"w": 41,
|
||||
"h": 120,
|
||||
"ax": 20.5,
|
||||
"ay": 120
|
||||
},
|
||||
"talk": {
|
||||
"x": 85,
|
||||
"y": 0,
|
||||
"w": 41,
|
||||
"h": 120,
|
||||
"ax": 20.6,
|
||||
"ay": 120
|
||||
},
|
||||
"explain": {
|
||||
"x": 128,
|
||||
"y": 0,
|
||||
"w": 65,
|
||||
"h": 120,
|
||||
"ax": 24.4,
|
||||
"ay": 120
|
||||
},
|
||||
"laugh": {
|
||||
"x": 195,
|
||||
"y": 0,
|
||||
"w": 40,
|
||||
"h": 120,
|
||||
"ax": 20.1,
|
||||
"ay": 120
|
||||
},
|
||||
"watch": {
|
||||
"x": 237,
|
||||
"y": 0,
|
||||
"w": 51,
|
||||
"h": 119,
|
||||
"ax": 25.5,
|
||||
"ay": 119
|
||||
},
|
||||
"look_far": {
|
||||
"x": 290,
|
||||
"y": 0,
|
||||
"w": 56,
|
||||
"h": 120,
|
||||
"ax": 27.9,
|
||||
"ay": 120
|
||||
},
|
||||
"bounce": {
|
||||
"x": 348,
|
||||
"y": 0,
|
||||
"w": 67,
|
||||
"h": 117,
|
||||
"ax": 21.1,
|
||||
"ay": 117
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+98
-22
@@ -427,13 +427,17 @@
|
||||
"file": "andrea-42e270af3d.mp3",
|
||||
"sec": 6.21
|
||||
},
|
||||
"andrea|Me lo prendo. Se è suo, il proprietario lo ritrova... prima o poi.": {
|
||||
"file": "andrea-78a0a84b84.mp3",
|
||||
"sec": 5.95
|
||||
"andrea|Ricordo di aver visto il film da piccolo... era stupendo!": {
|
||||
"file": "andrea-5528e4c7b0.mp3",
|
||||
"sec": 4.06
|
||||
},
|
||||
"andrea|Il cancello di dietro.": {
|
||||
"file": "andrea-a2f0124f4d.mp3",
|
||||
"sec": 1.41
|
||||
"andrea|Il cancello di dietro. Se chiudo gli occhi vedo ancora l'A. Bettini, il campo da calcio di quando eravamo piccoli: lassù ci sono ancora i riflettori.": {
|
||||
"file": "andrea-bc60e0ce13.mp3",
|
||||
"sec": 9.9
|
||||
},
|
||||
"andrea|Il primo vero campo del paese: inaugurato nel 1964 e intitolato ad Alberto Bettini, un giovane consigliere della società scomparso troppo presto.": {
|
||||
"file": "andrea-f035c38acc.mp3",
|
||||
"sec": 11.19
|
||||
},
|
||||
"andrea|Chiuso. Si apre solo quando c'è la festa.": {
|
||||
"file": "andrea-5e1cd26d46.mp3",
|
||||
@@ -623,6 +627,38 @@
|
||||
"file": "andrea-e47b3cebb5.mp3",
|
||||
"sec": 4.87
|
||||
},
|
||||
"andrea|Il BaronDeDuke, col cappello da cowboy. Ha l'aria di uno che aspetta qualcuno, e che spera non lo veda nessuno.": {
|
||||
"file": "andrea-0bd1b0f32e.mp3",
|
||||
"sec": 8.7
|
||||
},
|
||||
"andrea|Barone! Che ci fai qui?": {
|
||||
"file": "andrea-83abae1419.mp3",
|
||||
"sec": 2.91
|
||||
},
|
||||
"barondeduke|Aspetto uno che mi deve riportare le mutande.": {
|
||||
"file": "barondeduke-3ea60b7083.mp3",
|
||||
"sec": 4.12
|
||||
},
|
||||
"andrea|Le... mutande?": {
|
||||
"file": "andrea-f83ef038f8.mp3",
|
||||
"sec": 3.94
|
||||
},
|
||||
"barondeduke|Le ho perse a poker, ieri sera.": {
|
||||
"file": "barondeduke-5542a06624.mp3",
|
||||
"sec": 1.65
|
||||
},
|
||||
"barondeduke|Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».": {
|
||||
"file": "barondeduke-d4ff967179.mp3",
|
||||
"sec": 7.64
|
||||
},
|
||||
"andrea|E invece?": {
|
||||
"file": "andrea-435306c242.mp3",
|
||||
"sec": 0.67
|
||||
},
|
||||
"barondeduke|L'altro aveva colore.": {
|
||||
"file": "barondeduke-238f525535.mp3",
|
||||
"sec": 1.65
|
||||
},
|
||||
"andrea|La tribuna bianca e rossa. La domenica qui c'è più tifo che al Palio. Quasi.": {
|
||||
"file": "andrea-a50071085e.mp3",
|
||||
"sec": 6.84
|
||||
@@ -767,6 +803,10 @@
|
||||
"file": "andrea-bb70dea2a6.mp3",
|
||||
"sec": 2.15
|
||||
},
|
||||
"andrea|L'Arcu, il mio amico di sempre. Davanti al circolino, come se ci fosse nato.": {
|
||||
"file": "andrea-8ddab2d024.mp3",
|
||||
"sec": 5.79
|
||||
},
|
||||
"andrea|Il ponte vecchio, coi suoi due archi. Chissà quante piene ha visto passare: io al primo temporale cerco riparo.": {
|
||||
"file": "andrea-01ec1d03ec.mp3",
|
||||
"sec": 7.67
|
||||
@@ -935,6 +975,30 @@
|
||||
"file": "andrea-f5d55187e8.mp3",
|
||||
"sec": 5.11
|
||||
},
|
||||
"andrea|Il Tozzino, vestito di bianco da capo a piedi, con la racchetta. Guarda l'orologio ogni dieci secondi.": {
|
||||
"file": "andrea-3c32888a15.mp3",
|
||||
"sec": 8.1
|
||||
},
|
||||
"andrea|Tozzino! Che fai lì davanti al cancello?": {
|
||||
"file": "andrea-1fe1ee6236.mp3",
|
||||
"sec": 3.1
|
||||
},
|
||||
"tozzino|Aspetto Federer. Oggi si fa una partita.": {
|
||||
"file": "tozzino-c3898e9bdb.mp3",
|
||||
"sec": 3.23
|
||||
},
|
||||
"andrea|Federer? Roger Federer?": {
|
||||
"file": "andrea-70f150bdec.mp3",
|
||||
"sec": 2.67
|
||||
},
|
||||
"tozzino|E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.": {
|
||||
"file": "tozzino-d51159c364.mp3",
|
||||
"sec": 4.08
|
||||
},
|
||||
"tozzino|Se non arriva, la vinco a tavolino.": {
|
||||
"file": "tozzino-c06cc01612.mp3",
|
||||
"sec": 2.06
|
||||
},
|
||||
"andrea|La Carza. A maggio c'è poca acqua: d'estate si scendeva a fare le dighe coi sassi.": {
|
||||
"file": "andrea-91be1de49b.mp3",
|
||||
"sec": 6.99
|
||||
@@ -1115,22 +1179,6 @@
|
||||
"file": "andrea-23e40a2495.mp3",
|
||||
"sec": 5.74
|
||||
},
|
||||
"andrea|Vediamo da dove comincia...": {
|
||||
"file": "andrea-239bf733a6.mp3",
|
||||
"sec": 1.26
|
||||
},
|
||||
"andrea|C'è una pagina sola. Con scritto: «FINE».": {
|
||||
"file": "andrea-1d80123a55.mp3",
|
||||
"sec": 3.93
|
||||
},
|
||||
"andrea|Infinita, dicevano.": {
|
||||
"file": "andrea-77f1348d3a.mp3",
|
||||
"sec": 2.13
|
||||
},
|
||||
"andrea|Leggere adesso? Prima il palio. E poi, a quanto pare, si fa presto.": {
|
||||
"file": "andrea-3a6af3897f.mp3",
|
||||
"sec": 6.06
|
||||
},
|
||||
"andrea|Un ghiacciolo al limone. Si scioglie a guardarlo.": {
|
||||
"file": "andrea-8c9713081a.mp3",
|
||||
"sec": 3.44
|
||||
@@ -1383,6 +1431,26 @@
|
||||
"file": "vecchietto-c8eae2e59e.mp3",
|
||||
"sec": 4.88
|
||||
},
|
||||
"andrea|Ma che è sempre là, Borgo?": {
|
||||
"file": "andrea-73c12c835b.mp3",
|
||||
"sec": 2.34
|
||||
},
|
||||
"andrea|Come va alla Casa dell'Armando?": {
|
||||
"file": "andrea-413cf7673b.mp3",
|
||||
"sec": 1.56
|
||||
},
|
||||
"andrea|Ci si becca da i' Trappola, ai' Barrettino!": {
|
||||
"file": "andrea-fca6f59a70.mp3",
|
||||
"sec": 3.41
|
||||
},
|
||||
"arcu|E chi lo sposta???": {
|
||||
"file": "arcu-64735a5925.mp3",
|
||||
"sec": 0.97
|
||||
},
|
||||
"arcu|Mah... un si fa una sega...": {
|
||||
"file": "arcu-5d07c15776.mp3",
|
||||
"sec": 2.89
|
||||
},
|
||||
"andrea|Sono bloccato. Mi dai un consiglio?": {
|
||||
"file": "andrea-42b02eb9ea.mp3",
|
||||
"sec": 3.9
|
||||
@@ -1459,6 +1527,14 @@
|
||||
"file": "castello-dc8a873d86.mp3",
|
||||
"sec": 4.36
|
||||
},
|
||||
"andrea|Mi ricordavo un finale diverso.": {
|
||||
"file": "andrea-d9ff79ed54.mp3",
|
||||
"sec": 1.73
|
||||
},
|
||||
"andrea|Il «Nulla» sta viaggiando troppo velocemente nell'animo umano, devo darmi una mossa!": {
|
||||
"file": "andrea-71dc4628cc.mp3",
|
||||
"sec": 4.81
|
||||
},
|
||||
"andrea|Niente di speciale.": {
|
||||
"file": "andrea-6ebcf8e5de.mp3",
|
||||
"sec": 1.04
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -90,3 +90,9 @@ si segna qui con la data e il punto di `src/story/story.js` dove sta; le persone
|
||||
gioco, come i nove bastioni.
|
||||
- **Regole perché resti divertente**: le chiavi importanti non si buttano, una porta aperta resta aperta, al Trebbio
|
||||
c'è sempre l'aiuto.
|
||||
- **Le mutande del Barone** (Andrea, 25 ottobre; ci pensa ancora): si ritrovano e gli si riportano, e lui in cambio
|
||||
dà qualcosa che serve alla storia. Proposta: appese come trofeo all'insegna del circolino di Campomigliaio
|
||||
(l'Arcu ci scherza), e in cambio due pile per la torcia della casetta, scarica.
|
||||
- **La racchetta del Tozzino** (Andrea, 25 ottobre): non la trova, Federer sta arrivando, ha paura di perdere e chiede
|
||||
aiuto ad Andrea. Si potrebbe legare al poker del Barone (persa la stessa sera?). Serve un foglio sprite del Tozzino
|
||||
senza racchetta, più una posa con la racchetta ritrovata.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<div id="stage">
|
||||
<canvas id="scene" width="640" height="360" aria-label="La scena dell'avventura"></canvas>
|
||||
<div id="speech" aria-live="polite"></div>
|
||||
<button id="closeup-close" hidden title="Chiudi (Esc)">Chiudi</button>
|
||||
<div id="corner">
|
||||
<button id="pause-button" hidden title="Pausa (Esc)">Pausa</button>
|
||||
<button id="map-button" hidden title="Mappa (P)">Mappa</button>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "leggenda-del-regolo",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+40
-2
@@ -22,6 +22,9 @@ const SPEED = 95;
|
||||
// Solo profilo, come i cicli di camminata: 'up' e 'down' lasciano il verso com'è.
|
||||
const FACING = { left: -1, right: 1 };
|
||||
const WALK = ['walk_1', 'walk_2', 'walk_3', 'walk_4'], STEP = 15, REACH = 0.45;
|
||||
// Il primo piano di un libro (story.closeups, effetto `closeup`): copertina → frontespizio → una pagina girata →
|
||||
// l'ultima pagina, poi dopo una pausa il commento. Tempi in secondi.
|
||||
export const PAGE_TURN = 0.7, CLOSEUP_PAUSE = 1.4;
|
||||
|
||||
// Condizioni: 'flag', '!flag', 'has:oggetto', '!has:oggetto', oppure un elenco (tutte vere).
|
||||
export function check(cond, state) {
|
||||
@@ -115,15 +118,16 @@ export class Game {
|
||||
this.hurry = false; // doppio clic: si cammina al doppio della velocità
|
||||
this.reach = 0; // secondi di braccio teso quando prende qualcosa
|
||||
this.back = false; // di spalle davanti a una porta o a una bacheca
|
||||
this.closeup = null; // il primo piano aperto: { id, step: 'cover' | 'title' | 'turn' | 'end', t, age }
|
||||
}
|
||||
|
||||
get scene() { return this.story.scenes[this.state.scene]; }
|
||||
// Occupato: c'è qualcosa in corso che il giocatore non può interrompere. La camminata verso un punto
|
||||
// o verso un oggetto si può sempre cambiare con un altro clic: la coda che la seguiva viene scartata.
|
||||
get busy() { return !!this.speech || this.reach > 0 || (this.task ? !this.task.interruptible : this.queue.length > 0); }
|
||||
get busy() { return !!this.speech || !!this.closeup || this.reach > 0 || (this.task ? !this.task.interruptible : this.queue.length > 0); }
|
||||
get idle() { return !this.busy && !this.task && !this.dialogue; }
|
||||
// Fermo: nessuna battuta, nessun effetto in coda, nessuna camminata che fa parte di una sequenza.
|
||||
get steady() { return !this.speech && this.reach <= 0 && !this.queue.length && !(this.task && !this.task.interruptible); }
|
||||
get steady() { return !this.speech && !this.closeup && this.reach <= 0 && !this.queue.length && !(this.task && !this.task.interruptible); }
|
||||
// Lo stato da salvare adesso: quello attuale se è fermo, altrimenti l'ultimo punto fermo.
|
||||
savable() { return this.steady ? this.snapshot() : this.checkpoint; }
|
||||
// A sequenza finita, se qualcosa è cambiato, nuovo punto fermo e salvataggio.
|
||||
@@ -221,6 +225,27 @@ export class Game {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Il primo piano: un tocco sfoglia (copertina → frontespizio → si gira la pagina); all'ultima pagina non fa niente.
|
||||
closeupTap() {
|
||||
const c = this.closeup;
|
||||
if (!c || this.speech) return false;
|
||||
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
|
||||
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Chiuso il libro si torna alla scena, e gli effetti che seguivano il primo piano ripartono.
|
||||
closeupClose() {
|
||||
const c = this.closeup;
|
||||
if (!c) return false;
|
||||
this.closeup = null;
|
||||
this.speech = null;
|
||||
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
|
||||
if (c.step === 'end') this.queue.unshift(...(this.story.closeups?.[c.id]?.closing ?? []));
|
||||
return true;
|
||||
}
|
||||
|
||||
options() {
|
||||
if (!this.dialogue || this.busy) return [];
|
||||
const node = this.story.dialogues[this.dialogue.id].nodes[this.dialogue.node];
|
||||
@@ -271,6 +296,8 @@ export class Game {
|
||||
}
|
||||
|
||||
speakerPoint(who) {
|
||||
// Nel primo piano Andrea parla da sopra il libro, non dalla scena oscurata dietro.
|
||||
if (this.closeup && (!who || who === 'andrea')) return [320, 34];
|
||||
if (!who || who === 'andrea') return [this.state.x, this.state.y - 62 * this.scaleAt(this.state.y)];
|
||||
if (this.scene.speakers?.[who]) return this.scene.speakers[who];
|
||||
const h = this.hotspot(who) ?? (this.scene.hotspots ?? []).find(x => x.id === who);
|
||||
@@ -280,6 +307,7 @@ export class Game {
|
||||
|
||||
update(dt) {
|
||||
this.time += dt;
|
||||
if (this.closeup) this.closeup.age += dt;
|
||||
if (this.speech) {
|
||||
this.speech.left -= dt;
|
||||
if (this.speech.left > 0) return;
|
||||
@@ -310,6 +338,15 @@ export class Game {
|
||||
this.walking = false;
|
||||
this.task = null;
|
||||
}
|
||||
// Nel primo piano il tempo scorre solo per la pagina che gira e per la pausa prima del commento.
|
||||
const c = this.closeup;
|
||||
if (c) {
|
||||
c.t += dt;
|
||||
if (c.step === 'turn' && c.t >= PAGE_TURN) Object.assign(c, { step: 'end', t: 0 });
|
||||
if (c.step === 'end' && !c.lines && c.t >= CLOSEUP_PAUSE) c.lines = [...(this.story.closeups?.[c.id]?.comment ?? [])];
|
||||
if (c.lines?.length) this.run(c.lines.shift());
|
||||
return;
|
||||
}
|
||||
// Esegue gli effetti istantanei di fila e si ferma al primo che richiede tempo.
|
||||
while (!this.speech && !this.walking && this.queue.length) this.run(this.queue.shift());
|
||||
this.commit();
|
||||
@@ -335,6 +372,7 @@ export class Game {
|
||||
else if (e.node) this.dialogue.node = e.node;
|
||||
else if (e.endDialogue) this.dialogue = null;
|
||||
else if (e.end) s.ended = true;
|
||||
else if (e.closeup) this.closeup = { id: e.closeup, step: 'cover', t: 0, age: 0 };
|
||||
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
|
||||
}
|
||||
|
||||
|
||||
+80
-1
@@ -1,6 +1,6 @@
|
||||
// Disegno della scena su un canvas 640×360, ingrandito dal CSS senza sfocatura.
|
||||
// Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art.
|
||||
import { check } from './game.js';
|
||||
import { check, PAGE_TURN } from './game.js';
|
||||
import { ambientEffects, ambientHash } from './ambient.js';
|
||||
import { versioned } from '../version.js';
|
||||
export const WIDTH = 640, HEIGHT = 360;
|
||||
@@ -170,6 +170,7 @@ export function render(ctx, game, { hover, spots, debug, time = performance.now(
|
||||
drawLights(ctx, game, time);
|
||||
for (const shape of art) if (shape.top) drawShape(ctx, shape);
|
||||
if (spots) drawSpots(ctx, game);
|
||||
if (game.closeup) drawCloseup(ctx, game, time);
|
||||
if (debug) drawDebug(ctx, game, debug);
|
||||
if (hover) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
||||
@@ -644,3 +645,81 @@ function drawDebug(ctx, game, { mouse, points = [], help }) {
|
||||
label(`${game.state.scene} ${where} ${help ?? ''}`, 2, HEIGHT - 3, '#fff');
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// --- Il primo piano di un libro (story.closeups, game.closeup): la scena si oscura, le mani alzano il libro dal basso.
|
||||
// Le immagini hanno etichetta e pagine vuote: i testi si scrivono qui, nitidi. Una pagina sola gira, a fisarmonica
|
||||
// sulla piega; sotto c'è già l'ultima pagina.
|
||||
const ink = '#3a1f12', FONT = '"Press Start 2P", monospace';
|
||||
function pageText(ctx, lines, [x, y, w, h], size, at = 0.5, color = ink) {
|
||||
ctx.font = `${size}px ${FONT}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = color;
|
||||
const gap = size * 1.7, top = y + h * at - (lines.length - 1) * gap / 2;
|
||||
lines.forEach((line, i) => ctx.fillText(line, Math.round(x + w / 2), Math.round(top + i * gap)));
|
||||
}
|
||||
const twoLines = title => { const i = title.lastIndexOf(' '); return i > 0 ? [title.slice(0, i), title.slice(i + 1)] : [title]; };
|
||||
|
||||
function drawCloseup(ctx, game, time) {
|
||||
const c = game.closeup, def = game.story.closeups?.[c.id];
|
||||
if (!def) return;
|
||||
ctx.save();
|
||||
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
const src = c.step === 'cover' ? def.closed : def.open, img = backgrounds.get(src);
|
||||
if (!img) { loadBackground(src); loadBackground(c.step === 'cover' ? def.open : def.closed); ctx.restore(); return; }
|
||||
// Il libro sale dal basso (solo all'inizio), con le braccia che entrano dal bordo.
|
||||
const rise = 1 - Math.min(1, c.age / 0.45), ox = Math.round((WIDTH - img.width) / 2);
|
||||
const oy = Math.round(HEIGHT - img.height + 140 * rise * rise);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(img, ox, oy);
|
||||
ctx.translate(ox, oy);
|
||||
const lines = twoLines(def.title), { left, right, spine } = def.pages;
|
||||
if (c.step === 'cover') pageText(ctx, lines, def.label, 8);
|
||||
else {
|
||||
const flip = c.step === 'turn' ? Math.min(1, c.t / PAGE_TURN) : c.step === 'end' ? 1 : 0;
|
||||
// Sotto la pagina che gira c'è già l'ultima: «FINE», e nient'altro.
|
||||
if (flip > 0) pageText(ctx, [def.last], right, 20, 0.46);
|
||||
else pageText(ctx, lines, right, 8, 0.32);
|
||||
if (flip > 0 && flip < 1) {
|
||||
// La pagina che gira: si stringe sulla piega e passa a sinistra, col dorso un po' più scuro.
|
||||
const e = (1 - Math.cos(Math.PI * flip)) / 2, k = Math.cos(Math.PI * e);
|
||||
const lift = Math.round(Math.sin(Math.PI * e) * 10), [, y, , h] = right, pw = right[0] + right[2] - spine;
|
||||
ctx.save();
|
||||
ctx.translate(spine, 0);
|
||||
ctx.scale(k || 0.001, 1);
|
||||
ctx.fillStyle = k > 0 ? def.paper : '#e6cc9c';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y); ctx.lineTo(pw, y - lift); ctx.lineTo(pw, y + h - lift); ctx.lineTo(0, y + h);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(58, 31, 18, 0.55)';
|
||||
ctx.lineWidth = 1 / Math.max(0.2, Math.abs(k));
|
||||
ctx.stroke();
|
||||
if (k > 0) pageText(ctx, lines, [right[0] - spine, y - lift, right[2], h], 8, 0.32);
|
||||
ctx.restore();
|
||||
}
|
||||
// Dove toccare per sfogliare: l'angolo della pagina che pulsa.
|
||||
if (c.step === 'title' && !game.speech) {
|
||||
const pulse = 0.5 + 0.5 * Math.sin(time / 260), cx = right[0] + right[2] - 2, cy = right[1] + right[3] - 2, d = 12 + 3 * pulse;
|
||||
ctx.fillStyle = `rgba(232, 194, 90, ${0.55 + 0.4 * pulse})`;
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx - d, cy); ctx.lineTo(cx, cy - d); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
// Il suggerimento in alto, finché c'è qualcosa da sfogliare.
|
||||
const hint = c.step === 'cover' ? 'Clic o tocco: apri il libro' : c.step === 'title' ? 'Clic o tocco: gira la pagina' : null;
|
||||
if (hint && !game.speech && c.age > 0.5) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.65 + 0.35 * Math.sin(time / 300);
|
||||
ctx.font = `7px ${FONT}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
|
||||
const tw = ctx.measureText(hint).width;
|
||||
ctx.fillRect(WIDTH / 2 - tw / 2 - 6, 8, tw + 12, 14);
|
||||
ctx.fillStyle = '#ffd27a';
|
||||
ctx.fillText(hint, WIDTH / 2, 15);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export function voiceLines(story) {
|
||||
visit(story.scenes);
|
||||
visit(story.items);
|
||||
visit(story.dialogues);
|
||||
visit(story.closeups);
|
||||
for (const text of ENGINE_LINES) add('andrea', text);
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
+48
-6
@@ -217,6 +217,7 @@ function filterMap() {
|
||||
$('map-none').hidden = shown > 0;
|
||||
$('map-none').textContent = `Nessun posto con «${query.trim()}»`;
|
||||
drawRoads();
|
||||
$('map-scroll').scrollTo(0, 0); // i posti rimasti stanno in cima: niente mappa vuota scorsa in fondo
|
||||
}
|
||||
$('map-search').addEventListener('input', filterMap);
|
||||
$('map-search').addEventListener('keydown', e => {
|
||||
@@ -226,7 +227,11 @@ $('map-search').addEventListener('keydown', e => {
|
||||
});
|
||||
// Le strade fra le schede, misurate dopo l'impaginazione (e di nuovo se la finestra cambia misura).
|
||||
function drawRoads() {
|
||||
const board = $('map-board'), svg = $('map-roads'), origin = board.getBoundingClientRect();
|
||||
const board = $('map-board'), svg = $('map-roads');
|
||||
// Prima si toglie la misura vecchia: il disegno delle strade, grande quanto la mappa intera, la terrebbe lunga
|
||||
// anche dopo un filtro.
|
||||
svg.style.width = svg.style.height = '0';
|
||||
const origin = board.getBoundingClientRect();
|
||||
const center = id => {
|
||||
const card = board.querySelector(`[data-scene="${id}"]`);
|
||||
const r = card && !card.hidden && !card.parentElement.hidden && card.querySelector('img').getBoundingClientRect();
|
||||
@@ -380,11 +385,14 @@ let press = null;
|
||||
canvas.addEventListener('pointerdown', e => {
|
||||
if (debug && e.shiftKey && e.button === 0) { debugPoint(scenePoint(e)); return; }
|
||||
if (!running || e.button > 0) return;
|
||||
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
|
||||
if (game.closeup) { press = { closeup: true }; return; }
|
||||
const point = scenePoint(e), reach = reachOf(e);
|
||||
press = { point, reach, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true, reach }); }, LONG_PRESS) };
|
||||
});
|
||||
canvas.addEventListener('pointerup', e => {
|
||||
if (!press) return;
|
||||
if (press.closeup) { press = null; if (!game.skip()) game.closeupTap(); return; }
|
||||
clearTimeout(press.timer);
|
||||
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
|
||||
if (!press.long) {
|
||||
@@ -398,7 +406,7 @@ canvas.addEventListener('pointerup', e => {
|
||||
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; });
|
||||
canvas.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
if (running) command.clickScene(scenePoint(e), { look: true });
|
||||
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
|
||||
});
|
||||
canvas.addEventListener('pointermove', e => {
|
||||
if (!running) return;
|
||||
@@ -484,6 +492,13 @@ window.addEventListener('keydown', e => {
|
||||
if (!$('pause').hidden) { if (e.key === 'Escape') closePause(); return; }
|
||||
if (!$('notebook').hidden) { if (['Escape', 'n', 'N'].includes(e.key)) $('notebook').hidden = true; return; }
|
||||
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
|
||||
// Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
|
||||
if (running && game.closeup) {
|
||||
if (e.key === 'Escape') game.closeupClose();
|
||||
else if ([' ', 'Enter', 'ArrowRight'].includes(e.key)) { e.preventDefault(); if (!game.skip()) game.closeupTap(); }
|
||||
else if (e.key === 'm' || e.key === 'M') toggleMute();
|
||||
return;
|
||||
}
|
||||
if ((e.key === 'p' || e.key === 'P') && running) { openMap(); return; }
|
||||
if ((e.key === 'h' || e.key === 'H') && running) { showSpots(); return; }
|
||||
if ((e.key === 'n' || e.key === 'N') && running) { openNotebook(); return; }
|
||||
@@ -497,6 +512,28 @@ window.addEventListener('keydown', e => {
|
||||
if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1);
|
||||
});
|
||||
|
||||
// --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio).
|
||||
$('closeup-close').addEventListener('click', () => game?.closeupClose());
|
||||
let turning = null, audioCtx = null;
|
||||
function rustle() {
|
||||
if (music.muted || voices.volume <= 0) return;
|
||||
try {
|
||||
audioCtx ??= new AudioContext();
|
||||
const sr = audioCtx.sampleRate, len = Math.floor(sr * 0.42), buf = audioCtx.createBuffer(1, len, sr), d = buf.getChannelData(0);
|
||||
// Un fruscio a strappi: rumore che si accende e si spegne, come la carta che striscia.
|
||||
for (let i = 0; i < len; i++) { const t = i / len; d[i] = (Math.random() * 2 - 1) * Math.sin(Math.PI * t) * (0.55 + 0.45 * Math.sin(t * 38)); }
|
||||
const src = audioCtx.createBufferSource(), filter = audioCtx.createBiquadFilter(), gain = audioCtx.createGain();
|
||||
src.buffer = buf;
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(2400, audioCtx.currentTime);
|
||||
filter.frequency.linearRampToValueAtTime(5200, audioCtx.currentTime + 0.42);
|
||||
filter.Q.value = 0.8;
|
||||
gain.gain.value = 0.5 * voices.volume;
|
||||
src.connect(filter).connect(gain).connect(audioCtx.destination);
|
||||
src.start();
|
||||
} catch { /* senza audio si gira la pagina in silenzio */ }
|
||||
}
|
||||
|
||||
// --- Fumetto sopra chi parla, tenuto dentro i bordi della scena.
|
||||
function updateSpeech() {
|
||||
const box = $('speech'), sp = game.speech;
|
||||
@@ -523,16 +560,21 @@ function updateUi() {
|
||||
// Il bersaglio si ricalcola a ogni fotogramma: la scena cambia anche se il mouse sta fermo.
|
||||
hoverTarget = (game.state.inventory.includes(hoverItem) && hoverItem) || (mouse && game.hotspotAt(mouse)?.id) || null;
|
||||
for (const b of $('verbs').children) b.setAttribute('aria-pressed', String(command.verb === b.dataset.verb));
|
||||
const text = !running || game.speech || game.dialogue ? '' : command.sentence(hoverTarget);
|
||||
const text = !running || game.speech || game.dialogue || game.closeup ? '' : command.sentence(hoverTarget);
|
||||
if ($('sentence').textContent !== text) $('sentence').textContent = text;
|
||||
updateInventory();
|
||||
updateChoices();
|
||||
updateSpeech();
|
||||
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
|
||||
$('map-button').hidden = !running || !!game.dialogue || game.mapPlaces().length < 2;
|
||||
$('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
|
||||
$('pause-button').hidden = !running;
|
||||
$('spots-button').hidden = !running;
|
||||
$('notebook-button').hidden = !running || !story.notebook;
|
||||
$('spots-button').hidden = !running || !!game.closeup;
|
||||
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
|
||||
$('closeup-close').hidden = !running || !game.closeup;
|
||||
// La pagina che comincia a girare fa il suo fruscio.
|
||||
const step = game.closeup?.step ?? null;
|
||||
if (step === 'turn' && turning !== 'turn') rustle();
|
||||
turning = step;
|
||||
if (game.state.ended && !game.speech && !game.queue.length) {
|
||||
running = false;
|
||||
$('ending').hidden = false;
|
||||
|
||||
+85
-8
@@ -104,6 +104,23 @@ export const story = {
|
||||
{ role: 'Carattere', names: ['Press Start 2P di CodeMan38 (licenza OFL)'] },
|
||||
{ role: 'Programmazione', names: ['Manzolo, con Claude e Codex'] },
|
||||
],
|
||||
// I primi piani a tutto schermo (effetto `closeup`, disegnati da render.js): per ora il libro dell'oratorio.
|
||||
// Le immagini (Codex, assets/closeups/) hanno l'etichetta e le pagine vuote: titolo e «FINE» li scrive il codice.
|
||||
closeups: {
|
||||
libro: {
|
||||
closed: 'assets/closeups/libro-chiuso.png',
|
||||
open: 'assets/closeups/libro-aperto.png',
|
||||
title: 'La storia infinita',
|
||||
last: 'FINE',
|
||||
comment: [{ say: 'Mi ricordavo un finale diverso.' }],
|
||||
// Chiuso dopo aver visto l'ultima pagina (idea di Andrea, 25 ottobre).
|
||||
closing: [{ say: 'Il «Nulla» sta viaggiando troppo velocemente nell\'animo umano, devo darmi una mossa!' }],
|
||||
// Dove scrivere, in pixel delle due immagini: l'etichetta della copertina, le due pagine, la piega.
|
||||
label: [78, 48, 104, 54],
|
||||
pages: { left: [56, 26, 96, 194], right: [163, 12, 101, 204], spine: 157 },
|
||||
paper: '#fbe6bc',
|
||||
},
|
||||
},
|
||||
characters: {
|
||||
andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' },
|
||||
vecchietta: { name: 'Vecchietta', color: '#ffd27a', sprite: 'assets/characters/vecchietta.json' },
|
||||
@@ -112,6 +129,9 @@ export const story = {
|
||||
leone: { name: 'Il leone', color: '#f0a070' },
|
||||
san_pietro: { name: 'San Pietro', color: '#e8e0c8' },
|
||||
tosca: { name: 'La Tosca', color: '#ffb0d0' },
|
||||
arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' },
|
||||
tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' },
|
||||
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
|
||||
},
|
||||
items: {
|
||||
squama: {
|
||||
@@ -130,12 +150,9 @@ export const story = {
|
||||
actions: {
|
||||
drop: [{ say: 'Una pagina sola, e pure con la fine. Lo lascio qui per il prossimo lettore.' }, { drop: 'libro' }],
|
||||
look: [{ say: '«La storia infinita». Bello spesso: questo mi tiene compagnia fino a settembre.' }],
|
||||
open: [
|
||||
{ say: 'Vediamo da dove comincia...' },
|
||||
{ say: 'C\'è una pagina sola. Con scritto: «FINE».' },
|
||||
{ say: 'Infinita, dicevano.' },
|
||||
],
|
||||
use: [{ say: 'Leggere adesso? Prima il palio. E poi, a quanto pare, si fa presto.' }],
|
||||
// Il primo piano (story.closeups.libro): la battuta la scopre chi guarda, nessuno la racconta.
|
||||
open: [{ closeup: 'libro' }],
|
||||
use: [{ closeup: 'libro' }],
|
||||
},
|
||||
},
|
||||
// La catena del ghiacciolo (idea di Andrea, 2026-10-24): la Tosca dà ghiacciolo e cedrata; il ghiacciolo inzuppato
|
||||
@@ -653,13 +670,17 @@ export const story = {
|
||||
art: [{ rect: [565, 203, 11, 5], fill: '#c46a3a' }, { rect: [565, 207, 11, 1], fill: '#f4efe4' }, { rect: [569, 204, 3, 3], fill: '#e8c25a' }],
|
||||
actions: {
|
||||
look: [{ say: 'Un libro dimenticato sulle tavolate. Sulla copertina c\'è scritto «La storia infinita».' }],
|
||||
take: [{ give: 'libro' }, { set: 'libro_preso' }, { say: 'Me lo prendo. Se è suo, il proprietario lo ritrova... prima o poi.' }],
|
||||
take: [{ give: 'libro' }, { set: 'libro_preso' }, { say: 'Ricordo di aver visto il film da piccolo... era stupendo!' }, { closeup: 'libro' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cancello', name: 'cancello', box: [477, 166, 23, 52], at: [488, 236], face: 'up',
|
||||
actions: {
|
||||
look: [{ say: 'Il cancello di dietro.' }],
|
||||
// Il vecchio campo «Alberto Bettini» (Andrea, 25 ottobre; www.polisportivasanpieroasieve.it).
|
||||
look: [
|
||||
{ say: 'Il cancello di dietro. Se chiudo gli occhi vedo ancora l\'A. Bettini, il campo da calcio di quando eravamo piccoli: lassù ci sono ancora i riflettori.' },
|
||||
{ say: 'Il primo vero campo del paese: inaugurato nel 1964 e intitolato ad Alberto Bettini, un giovane consigliere della società scomparso troppo presto.' },
|
||||
],
|
||||
open: [{ say: 'Chiuso. Si apre solo quando c\'è la festa.' }],
|
||||
},
|
||||
},
|
||||
@@ -974,6 +995,9 @@ export const story = {
|
||||
walk: [[0, 360], [0, 330], [300, 262], [430, 222], [600, 210], [640, 212], [640, 360]],
|
||||
depth: [[210, 0.4], [360, 1.15]],
|
||||
entry: [100, 340],
|
||||
// Il BaronDeDuke nella discesa verso la polisportiva: aspetta chi gli riporta le mutande perse a poker.
|
||||
speakers: { barondeduke: [230, 243] },
|
||||
people: [{ who: 'barondeduke', at: [230, 300], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.6 }],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'campo', name: 'campo secondario', box: [140, 160, 240, 60], at: [330, 254], face: 'up',
|
||||
@@ -1007,6 +1031,21 @@ export const story = {
|
||||
{
|
||||
id: 'al_principale', name: 'indietro, verso il campo principale', box: [0, 330, 640, 30], at: [100, 354], exit: { scene: 'polisportiva_principale', at: [620, 330], face: 'left' },
|
||||
},
|
||||
{
|
||||
id: 'barondeduke', name: 'il BaronDeDuke', box: [212, 245, 36, 55], at: [190, 316], face: 'right', default: 'talk',
|
||||
actions: {
|
||||
look: [{ say: 'Il BaronDeDuke, col cappello da cowboy. Ha l\'aria di uno che aspetta qualcuno, e che spera non lo veda nessuno.' }],
|
||||
talk: [
|
||||
{ say: 'Barone! Che ci fai qui?' },
|
||||
{ say: 'Aspetto uno che mi deve riportare le mutande.', who: 'barondeduke' },
|
||||
{ say: 'Le... mutande?' },
|
||||
{ say: 'Le ho perse a poker, ieri sera.', who: 'barondeduke', pose: 'facepalm' },
|
||||
{ say: 'Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».', who: 'barondeduke', pose: 'cards' },
|
||||
{ say: 'E invece?' },
|
||||
{ say: 'L\'altro aveva colore.', who: 'barondeduke', pose: 'shrug' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
polisportiva_principale: {
|
||||
@@ -1248,6 +1287,9 @@ export const story = {
|
||||
walk: [[0, 360], [0, 335], [60, 300], [400, 280], [640, 250], [640, 360]],
|
||||
depth: [[250, 1.0], [360, 1.5]],
|
||||
entry: [320, 330],
|
||||
// L'Arcu davanti al circolino, accanto alla fontanella (sprite di Codex, 25 ottobre).
|
||||
speakers: { arcu: [418, 214] },
|
||||
people: [{ who: 'arcu', at: [418, 292], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.2 }],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'circolo', name: 'il circolino', box: [100, 0, 380, 205], at: [300, 290], face: 'up',
|
||||
@@ -1293,6 +1335,13 @@ export const story = {
|
||||
look: [{ say: 'La strada va avanti verso Tagliaferro.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'arcu', name: 'l\'Arcu', box: [400, 216, 38, 76], at: [362, 300], face: 'right', default: 'talk',
|
||||
actions: {
|
||||
look: [{ say: 'L\'Arcu, il mio amico di sempre. Davanti al circolino, come se ci fosse nato.' }],
|
||||
talk: [{ dialogue: 'arcu' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
campomigliaio_ponte: {
|
||||
@@ -1557,6 +1606,9 @@ export const story = {
|
||||
walk: [[60, 360], [250, 262], [300, 218], [350, 218], [420, 262], [600, 360]],
|
||||
depth: [[218, 0.55], [360, 1.65]],
|
||||
entry: [300, 350],
|
||||
// Il Tozzino davanti al cancello del tennis, in tenuta, che aspetta Federer.
|
||||
speakers: { tozzino: [440, 222] },
|
||||
people: [{ who: 'tozzino', at: [440, 300], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 3.9 }],
|
||||
hotspots: [
|
||||
{
|
||||
id: 'muro', name: 'casa dell\'ing. Tozzi', box: [0, 120, 200, 140], at: [190, 300], face: 'left',
|
||||
@@ -1576,6 +1628,19 @@ export const story = {
|
||||
{
|
||||
id: 'alla_pieve', name: 'su verso la Pieve', box: [0, 330, 640, 30], at: [300, 354], exit: { scene: 'pieve', at: [95, 250], face: 'right' },
|
||||
},
|
||||
{
|
||||
id: 'tozzino', name: 'il Tozzino', box: [422, 224, 36, 76], at: [392, 312], face: 'right', default: 'talk',
|
||||
actions: {
|
||||
look: [{ say: 'Il Tozzino, vestito di bianco da capo a piedi, con la racchetta. Guarda l\'orologio ogni dieci secondi.' }],
|
||||
talk: [
|
||||
{ say: 'Tozzino! Che fai lì davanti al cancello?' },
|
||||
{ say: 'Aspetto Federer. Oggi si fa una partita.', who: 'tozzino', pose: 'explain' },
|
||||
{ say: 'Federer? Roger Federer?' },
|
||||
{ say: 'E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.', who: 'tozzino', pose: 'watch' },
|
||||
{ say: 'Se non arriva, la vinco a tavolino.', who: 'tozzino', pose: 'look_far' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ponticino: {
|
||||
@@ -1895,6 +1960,18 @@ export const story = {
|
||||
},
|
||||
},
|
||||
},
|
||||
// L'Arcu davanti al circolino (battute di Andrea, 25 ottobre). Lavora alla Casa dell'Armadio: «Armando» è apposta.
|
||||
arcu: {
|
||||
nodes: {
|
||||
start: {
|
||||
options: [
|
||||
{ text: 'Ma che è sempre là, Borgo?', then: [{ say: 'E chi lo sposta???', who: 'arcu', pose: 'shrug' }] },
|
||||
{ text: 'Come va alla Casa dell\'Armando?', then: [{ say: 'Mah... un si fa una sega...', who: 'arcu', pose: 'explain' }] },
|
||||
{ text: 'Ci si becca da i\' Trappola, ai\' Barrettino!', next: 'end' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca.
|
||||
castello: {
|
||||
nodes: {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||
export const VERSION = '0.1.1';
|
||||
export const VERSION = '0.1.4';
|
||||
|
||||
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
||||
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
||||
|
||||
@@ -30,6 +30,9 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
||||
font-size: clamp(10.08px, 1.66vw, 18.72px); font-weight: bold; line-height: 1.2; text-align: center;
|
||||
text-shadow: 2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000, 2px 2px #000, -2px -2px #000; }
|
||||
#speech:empty { display: none; }
|
||||
/* Il primo piano del libro si chiude da qui (o con Esc). */
|
||||
#closeup-close { position: absolute; right: 10px; bottom: 10px; z-index: 2; font: bold clamp(10px, 1.5vw, 15px) var(--font);
|
||||
padding: .45em 1.2em; border-radius: 6px; border: 0; cursor: pointer; color: var(--bg); background: var(--accent); }
|
||||
|
||||
/* Audio: nota per il muto, cursore della musica e cursore delle voci («bla»), 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;
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
// ritagli dei nuovi effetti, pause degli animali e nessuna modifica alla partita.
|
||||
// npm run browser:effects — immagini in /tmp/regolo-effects (oppure $OUT).
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { staticServer } from './helpers.mjs';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const out = process.env.OUT ?? '/tmp/regolo-effects';
|
||||
mkdirSync(out, { recursive: true });
|
||||
const port = 9000 + Math.floor(Math.random() * 900);
|
||||
const server = spawn('python3', ['-m', 'http.server', String(port), '--bind', '127.0.0.1'], { stdio: 'ignore' });
|
||||
const server = await staticServer(), port = server.port;
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||||
|
||||
+59
-3
@@ -2,14 +2,14 @@
|
||||
// fino alla fine, salvataggio e impaginazione su telefono. Schermate in $OUT (predefinito: /tmp/regolo-shots).
|
||||
// npm run browser
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { staticServer } from './helpers.mjs';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { solution, route } from '../src/story/solution.js';
|
||||
|
||||
const OUT = process.env.OUT ?? '/tmp/regolo-shots', PORT = 8000 + Math.floor(Math.random() * 900);
|
||||
const OUT = process.env.OUT ?? '/tmp/regolo-shots';
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const server = spawn('python3', ['-m', 'http.server', String(PORT), '--bind', '127.0.0.1'], { stdio: 'ignore' });
|
||||
const server = await staticServer(), PORT = server.port;
|
||||
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||||
const errors = [];
|
||||
|
||||
@@ -276,6 +276,62 @@ try {
|
||||
await page.keyboard.press('Enter');
|
||||
assert.ok(await page.isHidden('#map'));
|
||||
assert.equal(await page.evaluate(() => game.state.scene), 'piazza');
|
||||
// Con tutti i posti visti e la finestra piccola la mappa si scorre: filtrando da in fondo, i posti trovati si vedono
|
||||
// (prima la mappa restava lunga quanto quella intera, scorsa su un fondo vuoto).
|
||||
await page.setViewportSize({ width: 560, height: 340 });
|
||||
await page.evaluate(() => { for (const id of Object.keys(game.story.scenes)) game.state.flags['visto:' + id] = true; });
|
||||
await page.click('#map-button');
|
||||
assert.ok(await page.evaluate(() => { const s = document.getElementById('map-scroll'); s.scrollTop = s.scrollHeight; return s.scrollTop > 0; }), 'la mappa piena si scorre');
|
||||
await page.fill('#map-search', 'campo');
|
||||
const found = await page.evaluate(() => {
|
||||
const s = document.getElementById('map-scroll'), r = s.getBoundingClientRect();
|
||||
const c = [...document.querySelectorAll('#map-board button')].find(b => !b.hidden && !b.parentElement.hidden).getBoundingClientRect();
|
||||
return { inside: c.top >= r.top && c.bottom <= r.bottom, scrollTop: s.scrollTop, h: s.scrollHeight, ch: s.clientHeight, top: c.top - r.top, q: document.getElementById('map-search').value };
|
||||
});
|
||||
assert.ok(found.inside, `filtrando, il primo posto trovato non si vede: ${JSON.stringify(found)}`);
|
||||
await page.screenshot({ path: `${OUT}/07d-mappa-ricerca-piccola.png` });
|
||||
await page.click('#close-map');
|
||||
await page.setViewportSize({ width: 1280, height: 860 });
|
||||
|
||||
// Il libro dell'oratorio: preso, il primo piano; si sfoglia coi clic veri, «FINE» senza che nessuno lo dica,
|
||||
// Esc chiude; dall'inventario «Apri» lo riapre e il pulsante Chiudi lo chiude. Intanto la scena dietro non risponde.
|
||||
{
|
||||
const book = await open({ width: 1280, height: 860 });
|
||||
await book.click('#splash');
|
||||
await book.click('#new-game');
|
||||
await book.click('#rione [data-rione="campomigliaio"]');
|
||||
await book.click('#skip-intro');
|
||||
await settle(book);
|
||||
await book.evaluate(() => game.go('oratorio', [540, 300]));
|
||||
await settle(book);
|
||||
await book.click('#verbs [data-verb="take"]');
|
||||
await clickScene(book, await hotspotCenter(book, 'libro_dimenticato'));
|
||||
await book.waitForFunction(() => game.closeup?.step === 'cover', null, { timeout: 15000 });
|
||||
await book.waitForTimeout(600);
|
||||
await book.screenshot({ path: `${OUT}/03a-libro-copertina.png` });
|
||||
const where = await book.evaluate(() => [game.state.x, game.state.y]);
|
||||
await clickScene(book, [320, 200]);
|
||||
assert.equal(await book.evaluate(() => game.closeup.step), 'title', 'un clic apre il libro');
|
||||
await clickScene(book, [320, 200]);
|
||||
assert.equal(await book.evaluate(() => game.closeup.step), 'turn', 'un altro gira la pagina');
|
||||
await book.waitForFunction(() => game.closeup?.step === 'end');
|
||||
assert.equal(await book.evaluate(() => game.speech), null, '«FINE» non lo dice nessuno');
|
||||
await book.screenshot({ path: `${OUT}/03b-libro-fine.png` });
|
||||
await book.waitForFunction(() => game.speech?.text === 'Mi ricordavo un finale diverso.', null, { timeout: 5000 });
|
||||
assert.deepEqual(await book.evaluate(() => [game.state.x, game.state.y]), where, 'i clic sul libro non muovono Andrea');
|
||||
await book.keyboard.press('Space');
|
||||
await book.keyboard.press('Escape');
|
||||
assert.equal(await book.evaluate(() => game.closeup), null, 'Esc chiude il libro');
|
||||
await book.waitForSelector('#closeup-close', { state: 'hidden' });
|
||||
await settle(book);
|
||||
await book.click('#verbs [data-verb="open"]');
|
||||
await book.click('#inventory button');
|
||||
await book.waitForFunction(() => game.closeup?.step === 'cover');
|
||||
assert.ok(await book.isVisible('#closeup-close'), 'si vede il pulsante Chiudi');
|
||||
await book.click('#closeup-close');
|
||||
assert.equal(await book.evaluate(() => game.closeup), null, 'Chiudi chiude il libro');
|
||||
await book.close();
|
||||
}
|
||||
|
||||
const phone = await open({ width: 390, height: 844 });
|
||||
await phone.click('#splash'); // un clic salta il cartello
|
||||
|
||||
@@ -6,6 +6,8 @@ export function settle(game, { maxSteps = 20000 } = {}) {
|
||||
said.push(game.speech);
|
||||
game.skip();
|
||||
}
|
||||
// Un primo piano aperto aspetta il giocatore (sfogliare, chiudere): qui ci si ferma e decide il test.
|
||||
if (game.closeup && !game.speech && game.reach <= 0) return said;
|
||||
// Un ultimo fotogramma, come nel gioco vero: a sequenza finita il motore prende il punto fermo e salva.
|
||||
if (!game.busy && !game.walking && !game.queue.length && !game.task) { game.update(0); return said; }
|
||||
game.update(0.05);
|
||||
@@ -16,3 +18,16 @@ export function settle(game, { maxSteps = 20000 } = {}) {
|
||||
export function memoryStorage() {
|
||||
return { data: {}, getItem(k) { return this.data[k] ?? null; }, setItem(k, v) { this.data[k] = String(v); }, removeItem(k) { delete this.data[k]; } };
|
||||
}
|
||||
|
||||
// Il server statico delle prove nel browser, su una porta libera scelta dal sistema: con una porta a caso poteva
|
||||
// capitare la 8080 del gioco in Docker (make up), e la prova girava sul codice vecchio del container.
|
||||
export async function staticServer() {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const server = spawn('python3', ['-u', '-m', 'http.server', '0', '--bind', '127.0.0.1'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const port = await new Promise((resolve, reject) => {
|
||||
let out = '';
|
||||
server.stdout.on('data', d => { out += d; const m = out.match(/port (\d+)/); if (m) resolve(Number(m[1])); });
|
||||
server.on('exit', () => reject(new Error('il server di prova non è partito')));
|
||||
});
|
||||
return { port, kill: () => server.kill() };
|
||||
}
|
||||
|
||||
+54
-5
@@ -8,7 +8,7 @@ import { inside } from '../src/engine/geometry.js';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { settle } from './helpers.mjs';
|
||||
|
||||
const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end'];
|
||||
const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end', 'closeup'];
|
||||
|
||||
// Visita ogni elenco di effetti della storia, dentro scene, oggetti, dialoghi e rami `if`.
|
||||
function* effectLists() {
|
||||
@@ -63,6 +63,11 @@ test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esist
|
||||
if (e.give || e.drop) assert.ok(story.items[e.give ?? e.drop], `${where}: oggetto ${e.give ?? e.drop}`);
|
||||
if (e.dialogue) assert.ok(story.dialogues[e.dialogue], `${where}: dialogo ${e.dialogue}`);
|
||||
if (e.go) assert.ok(story.scenes[e.go], `${where}: scena ${e.go}`);
|
||||
if (e.closeup) {
|
||||
const c = story.closeups?.[e.closeup];
|
||||
assert.ok(c, `${where}: primo piano ${e.closeup}`);
|
||||
for (const src of [c.closed, c.open]) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`);
|
||||
}
|
||||
if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`);
|
||||
}
|
||||
for (const [id, d] of Object.entries(story.dialogues)) {
|
||||
@@ -192,18 +197,46 @@ test('il foglio di Andrea ha tutti i fotogrammi e l\'immagine', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('il libro dell\'oratorio: «La storia infinita» ha una pagina sola', () => {
|
||||
test('il libro dell\'oratorio: il primo piano si sfoglia, l\'ultima pagina è «FINE» e nessuno la dice prima', () => {
|
||||
const g = new Game(story);
|
||||
g.go('oratorio', [320, 345]);
|
||||
settle(g);
|
||||
assert.ok(g.hotspotAt([570, 205])?.id === 'libro_dimenticato', 'sulle tavolate il clic prende il libro, non le tavolate');
|
||||
assert.ok(g.act('take', 'libro_dimenticato'));
|
||||
settle(g);
|
||||
const before = settle(g).map(s => s.text);
|
||||
assert.deepEqual(before, ['Ricordo di aver visto il film da piccolo... era stupendo!']);
|
||||
assert.ok(g.state.inventory.includes('libro'));
|
||||
assert.equal(g.hotspot('libro_dimenticato'), undefined, 'preso, il libro non resta sul tavolo');
|
||||
assert.equal(g.closeup?.step, 'cover', 'preso in mano, il primo piano sulla copertina');
|
||||
// Durante il primo piano gli oggetti dell'oratorio non rispondono.
|
||||
assert.equal(g.act('take', 'gomma_terra'), false);
|
||||
assert.equal(g.walkTo([300, 340]), false);
|
||||
assert.ok(g.closeupTap());
|
||||
assert.equal(g.closeup.step, 'title');
|
||||
assert.ok(g.closeupTap());
|
||||
assert.equal(g.closeup.step, 'turn');
|
||||
assert.equal(g.closeupTap(), false, 'una pagina sola: mentre gira non se ne gira un\'altra');
|
||||
for (let i = 0; i < 20; i++) g.update(0.05);
|
||||
assert.equal(g.closeup.step, 'end');
|
||||
assert.equal(g.speech, null, '«FINE» si scopre guardando: nessuna battuta subito');
|
||||
for (let i = 0; i < 40 && !g.speech; i++) g.update(0.05);
|
||||
assert.equal(g.speech?.text, 'Mi ricordavo un finale diverso.');
|
||||
assert.deepEqual(g.speakerPoint('andrea'), [320, 34], 'nel primo piano Andrea parla da sopra il libro');
|
||||
for (let i = 0; i < 200; i++) g.update(0.05);
|
||||
assert.equal(g.closeup?.step, 'end', '«FINE» resta finché non si chiude');
|
||||
assert.equal(g.closeupTap(), false);
|
||||
assert.ok(g.closeupClose());
|
||||
assert.deepEqual(settle(g).map(s => s.text), ['Il «Nulla» sta viaggiando troppo velocemente nell\'animo umano, devo darmi una mossa!']);
|
||||
assert.equal(g.closeup, null);
|
||||
// Dall'inventario si riapre; chiuso prima della fine, nessun commento.
|
||||
assert.ok(g.act('open', 'libro'));
|
||||
const said = settle(g).map(s => s.text);
|
||||
assert.ok(said.some(t => t.includes('FINE')), 'aperto, dice FINE');
|
||||
settle(g);
|
||||
assert.equal(g.closeup?.step, 'cover');
|
||||
assert.ok(g.closeupClose());
|
||||
assert.deepEqual(settle(g), []);
|
||||
// Nessuna battuta racconta la gag: «FINE» c'è solo nel disegno.
|
||||
const lines = JSON.stringify([story.items.libro, story.scenes.oratorio]);
|
||||
assert.ok(!lines.includes('FINE'), 'nessuna battuta anticipa «FINE»');
|
||||
});
|
||||
|
||||
test('la mappa: ogni scena ci sta, i posti compaiono solo visti, il viaggio rispetta i dialoghi', () => {
|
||||
@@ -470,3 +503,19 @@ test('i quattro rioni: ognuno ha la sua riga nell\'introduzione, e le battute su
|
||||
assert.doesNotMatch(look('pieve', 'piazza', 'colonna'), /noi rossi/);
|
||||
assert.match(look(null, 'pieve', 'chiesa'), /quelli in bianco/, 'un salvataggio di prima dei rioni funziona');
|
||||
});
|
||||
|
||||
test('gli amici in giro: l\'Arcu al circolino, il Tozzino al cancello del tennis, il BaronDeDuke alla polisportiva', () => {
|
||||
const g = new Game(story);
|
||||
settle(g);
|
||||
const talk = (scene, who) => { g.go(scene); settle(g); g.act('talk', who); return settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`); };
|
||||
talk('campomigliaio_circolo', 'arcu');
|
||||
const ask = text => { g.choose(g.options().findIndex(o => o.text === text)); return settle(g).map(s => `${s.who ?? 'andrea'}: ${s.text}`); };
|
||||
assert.deepEqual(ask('Ma che è sempre là, Borgo?'), ['andrea: Ma che è sempre là, Borgo?', 'arcu: E chi lo sposta???']);
|
||||
assert.deepEqual(ask('Come va alla Casa dell\'Armando?'), ['andrea: Come va alla Casa dell\'Armando?', 'arcu: Mah... un si fa una sega...']);
|
||||
ask('Ci si becca da i\' Trappola, ai\' Barrettino!');
|
||||
assert.equal(g.dialogue, null);
|
||||
assert.ok(talk('carza', 'tozzino').includes('tozzino: E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.'));
|
||||
const baron = talk('polisportiva', 'barondeduke');
|
||||
assert.ok(baron.includes('barondeduke: Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».'));
|
||||
assert.equal(baron.at(-1), 'barondeduke: L\'altro aveva colore.');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""I primi piani (story.closeups) da un foglio di Codex su magenta pieno con due disegni affiancati: toglie il
|
||||
magenta, separa i due disegni sulla colonna vuota fra loro e le riduce di SCALA. Stampa i riquadri dei disegni ritagliati, per
|
||||
misurare dove vanno i testi.
|
||||
python3 tools/primo_piano.py assets/generated/libro-storia-infinita.png assets/closeups/libro-chiuso.png \\
|
||||
assets/closeups/libro-aperto.png 0.42"""
|
||||
import sys
|
||||
from PIL import Image
|
||||
|
||||
src, left_out, right_out, scale = sys.argv[1], sys.argv[2], sys.argv[3], float(sys.argv[4])
|
||||
im = Image.open(src).convert('RGBA')
|
||||
px = im.load()
|
||||
W, H = im.size
|
||||
for y in range(H):
|
||||
for x in range(W):
|
||||
r, g, b, a = px[x, y]
|
||||
# Il magenta e i bordi rosati che lascia intorno alle figure.
|
||||
if r > 150 and b > 150 and g < 120 and abs(r - b) < 90: px[x, y] = (0, 0, 0, 0)
|
||||
# Si taglia sulla colonna vuota più vicina al centro: i due disegni non stanno per forza nelle due metà esatte.
|
||||
alpha = im.getchannel('A').load()
|
||||
empty = [x for x in range(W) if not any(alpha[x, y] for y in range(H))]
|
||||
cut = min(empty, key=lambda x: abs(x - W // 2))
|
||||
for half, out in ((im.crop((0, 0, cut, H)), left_out), (im.crop((cut, 0, W, H)), right_out)):
|
||||
box = half.getchannel('A').getbbox()
|
||||
part = half.crop(box)
|
||||
small = part.resize((round(part.width * scale), round(part.height * scale)), Image.LANCZOS)
|
||||
small.save(out, optimize=True)
|
||||
print(out, 'ritaglio', box, '→', small.size)
|
||||
@@ -2,5 +2,7 @@
|
||||
"_nota": "Parole che XTTS legge male in italiano: a sinistra come sono scritte nella storia (e nel fumetto), a destra come vanno lette dalla voce. Si cerca la parola intera, senza badare alle maiuscole. Cambiare una riga rigenera solo le battute che la contengono (make voci). Esempio: «celo» (ce l'ho, delle figurine) XTTS lo legge «cielo».",
|
||||
"celo": "ce l'ho",
|
||||
"Cosimo I": "Cosimo primo",
|
||||
"padel": "pàdel"
|
||||
"padel": "pàdel",
|
||||
"l'A. Bettini": "l'Abbettini",
|
||||
"da i' Trappola, ai' Barrettino": "dai Trappola, ai Barrettino"
|
||||
}
|
||||
|
||||
+4
-1
@@ -6,5 +6,8 @@
|
||||
"castello": { "speaker": "Damien Black", "filtro": "asetrate=24000*0.9,aresample=24000,atempo=0.95,aecho=0.8:0.7:90|180:0.25|0.15" },
|
||||
"leone": { "speaker": "Baldur Sanjin", "filtro": "asetrate=24000*0.84,aresample=24000,atempo=1.08" },
|
||||
"san_pietro": { "speaker": "Wulf Carlevaro", "filtro": "asetrate=24000*0.92,aresample=24000,atempo=0.95,aecho=0.8:0.6:60:0.2" },
|
||||
"tosca": { "speaker": "Gitta Nikolina" }
|
||||
"tosca": { "speaker": "Gitta Nikolina" },
|
||||
"arcu": { "speaker": "Luis Moray" },
|
||||
"tozzino": { "speaker": "Xavier Hayasaka", "filtro": "asetrate=24000*1.12,aresample=24000,atempo=0.92" },
|
||||
"barondeduke": { "speaker": "Craig Gutsy" }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user