L'Ingorgo Sonoro: dal manifesto in piazza, ingrandito e toccato, «Quanto darei per essere all'Ingorgo stasera?!?» e l'effetto speciale; il centro di notte pieno zeppo di gente (sfondi di Codex), le console di Andrea (Suno) che si mescolano camminando, Andrea più grande alla festa, uscendo si torna a maggio. Baldoria al campo sportivo col bandierone della Sampdoria (Codex) (Andrea)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9Quo9nEdXyjXYF8ma
This commit is contained in:
2026-10-30 22:17:21 +01:00
co-authored by Claude Opus 5.5
parent 9cd8027256
commit c132e94ed8
47 changed files with 962 additions and 39 deletions
+2 -2
View File
@@ -87,11 +87,11 @@ background: ## Porta uno sfondo a 640x360 nel gioco (SRC=..., DST=..., CROP=x,y,
@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)
character: ## Ritaglia un foglio sprite 4x2 e prepara il personaggio (NAME=andrea, SHEET=..., HEIGHT=120, FRAMES=..., REF=stand, FIX=wave=1.3)
$(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)
--frames $(FRAMES) --ref $(REF) $(if $(FIX),--fix $(FIX))
## ------------------------------------------------------------ docker
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+70
View File
@@ -0,0 +1,70 @@
{
"image": "baldoria.png",
"height": 120,
"frames": {
"stand": {
"x": 0,
"y": 0,
"w": 59,
"h": 120,
"ax": 28.6,
"ay": 120
},
"blink": {
"x": 61,
"y": 0,
"w": 59,
"h": 120,
"ax": 28.4,
"ay": 120
},
"talk": {
"x": 122,
"y": 0,
"w": 59,
"h": 120,
"ax": 28.6,
"ay": 120
},
"explain": {
"x": 183,
"y": 0,
"w": 67,
"h": 122,
"ax": 29.4,
"ay": 122
},
"wave": {
"x": 252,
"y": 0,
"w": 101,
"h": 156,
"ax": 48.9,
"ay": 156
},
"wave2": {
"x": 355,
"y": 0,
"w": 127,
"h": 156,
"ax": 81.8,
"ay": 156
},
"cheer": {
"x": 484,
"y": 0,
"w": 70,
"h": 120,
"ax": 33.6,
"ay": 120
},
"laugh": {
"x": 556,
"y": 0,
"w": 78,
"h": 113,
"ax": 31.1,
"ay": 113
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 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.
+60
View File
@@ -119,6 +119,10 @@
"file": "andrea-559fbc00c4.mp3",
"sec": 4.2
},
"andrea|Lo lascio dov'è: è l'unica cosa che ricorda luglio, qui, a maggio.": {
"file": "andrea-9f315e91e5.mp3",
"sec": 4.4
},
"andrea|I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.": {
"file": "andrea-af4d717814.mp3",
"sec": 6.44
@@ -1095,6 +1099,14 @@
"file": "barondeduke-238f525535.mp3",
"sec": 1.65
},
"andrea|Baldoria, con la maglia blucerchiata e un bandierone della Sampdoria grande come lui. Qui a San Piero.": {
"file": "andrea-a85f0e0a9a.mp3",
"sec": 6.47
},
"andrea|Il bandierone? Prima dovrei passare sul suo cadavere. E non è un modo di dire.": {
"file": "andrea-4faef889c8.mp3",
"sec": 5.63
},
"andrea|La tribuna bianca e rossa. La domenica qui c'è più tifo che al Palio. Quasi.": {
"file": "andrea-a50071085e.mp3",
"sec": 6.84
@@ -2827,6 +2839,38 @@
"file": "pierino_parigi-8104163151.mp3",
"sec": 3.3
},
"andrea|Baldoria! Ma che ci fai col bandierone al campo?": {
"file": "andrea-3433db09f3.mp3",
"sec": 3.79
},
"andrea|Guarda che qui la Sampdoria non ci gioca.": {
"file": "andrea-408197f33d.mp3",
"sec": 3.85
},
"andrea|Hai visto passare il palio, per caso?": {
"file": "andrea-a0ac302eb3.mp3",
"sec": 1.76
},
"andrea|Ciao, Baldoria.": {
"file": "andrea-6697e2ce25.mp3",
"sec": 0.97
},
"baldoria|Mi alleno. Il bandierone va sventolato tutti i giorni, sennò si arrugginisce il braccio.": {
"file": "baldoria-954f26beb7.mp3",
"sec": 6.08
},
"baldoria|Forza Doria!": {
"file": "baldoria-97a6835622.mp3",
"sec": 1.03
},
"baldoria|La Samp gioca dovunque ci sia un blucerchiato. E oggi ci sono io.": {
"file": "baldoria-1eb6f19203.mp3",
"sec": 5.54
},
"baldoria|L'unico palio che conosco è blu, bianco, rosso e nero. E ce l'ho in mano.": {
"file": "baldoria-0accd6c002.mp3",
"sec": 6.1
},
"andrea|Taglia! Si fa una gara con la Condor?": {
"file": "andrea-99f683209e.mp3",
"sec": 3.41
@@ -3387,6 +3431,22 @@
"file": "andrea-356a1afe8d.mp3",
"sec": 5.91
},
"andrea|Il manifesto dell'Ingorgo Sonoro dell'anno scorso. Da piazza fino a Cambray Digny, una console in ogni angolo.": {
"file": "andrea-0c26e34815.mp3",
"sec": 7.53
},
"andrea|Quanto darei per essere all'Ingorgo stasera?!?": {
"file": "andrea-366c066d68.mp3",
"sec": 2.28
},
"andrea|Ma... è l'Ingorgo! Le console, la gente, le spine della birra!": {
"file": "andrea-bc7d5f4dc0.mp3",
"sec": 7.5
},
"andrea|Il palio può aspettare una sera. O no?": {
"file": "andrea-24e5ac53da.mp3",
"sec": 3.07
},
"chiaru|Hai vinto te. Ma la mia aveva le ruote piene di gesso, sia chiaro.": {
"file": "chiaru-91a4b23b22.mp3",
"sec": 4.44
+2
View File
@@ -39,6 +39,8 @@ ne è un'opera derivata. Le foto scattate da Andrea non hanno problemi; quelle p
| `closeups/macchina-dietro.png`, `macchina-lampi.png`, `macchina-davanti.png` | Codex, dallo sprite della macchina del tempo | nessuna | — | sì |
| `closeups/campone-condor.png` | Codex, dallo sfondo del campone delle poste | nessuna | — | sì |
| `closeups/campone-campana.png` | Codex, solo testo (stile dal campone delle poste) | nessuna | — | sì |
| `closeups/manifesto-ingorgo.png` | Codex, dal manifesto vero (2014) senza sponsor, loghi e data | `assets/raw/riferimenti/manifesto-ingorgo-2014.png` | Pro Loco San Piero a Sieve (manifesto), Andrea | sì |
| `ingorgo-piazza.png`, `ingorgo-antica-posta.png`, `ingorgo-piazzetta-comune.png`, `ingorgo-antica-posta-alta.png`, `ingorgo-piazzetta.png` | Codex, dagli sfondi di giorno delle stesse scene | foto della festa di Andrea (`assets/raw/foto/ingorgo-*.png`), solo per l'atmosfera | Andrea | sì |
| `barrettino-1997.png`, `closeups/cruscotto-tempo.png` | Codex, solo testo | nessuna | — | sì |
| `characters/vannino.png`, `characters/pierino_parigi.png` | Codex, dalla descrizione di Andrea (niente foto) | nessuna | — | sì (Pierino: consenso del figlio Marco) |
| `cardetole.png` | Codex | `cardetole.jpg` | Andrea (EXIF) | **da decidere**: è la casa vera di Andrea, pubblicarla dice dove abita |
+17
View File
@@ -18,6 +18,23 @@ Online su 50037.manzolo.it tutto fino agli effetti ambientali di Codex. Aperto,
## Dove siamo arrivati
- **L'Ingorgo Sonoro (30 ottobre, extra)**: in piazza il vecchio manifesto dell'Ingorgo (`manifesto_ingorgo`); guardandolo
si apre ingrandito (primo piano `poster`, `manifesto-ingorgo.png`: Codex dal manifesto vero del 2014, senza sponsor né
data); toccandolo, «Quanto darei per essere all'Ingorgo stasera?!?», poi l'effetto speciale (primo piano `incanto`, `incanto.js`: fasci di
luce, cassa che accelera, lampo bianco, coriandoli) e il centro diventa la notte della festa (flag `ingorgo`, fase
`ingorgo`, sfondi di notte di Codex in `backdrops`). La zona (`story.zones`): Piazza, Via dell'Antica Posta, Piazzetta
del Comune, cima di Via dell'Antica Posta, Piazzetta Cambray Digny; uscendo (a piedi o con la mappa) si torna a maggio.
Le console (`stages` delle scene, `music.js` `mix`): più brani insieme, ognuno più forte vicino alla sua console, anche
da fuori quadro (la piazza accanto). Generi provvisori: house in Piazza, latino al Comune, underground in cima a Via
dell'Antica Posta, tecno a Cambray Digny; i brani sono di Andrea (Suno), portati al volume del gioco. Gli sfondi sono
pieni zeppi di gente fino in primo piano (Andrea); la prima versione, con la piazza vuota davanti, in
`assets/raw/ingorgo-v1/`. La voce legge «console» come «consòl» (`tools/pronuncia.json`).
[DA ANDREA] i generi veri per piazza, le battute, i bar a tema; da fare: spillare la birra (minigioco) e bere.
- **Baldoria al campo sportivo (30 ottobre)**: Yuri Baldi, dei Ringraziamenti, al Campo principale con la maglia
blucerchiata e un bandierone della Sampdoria grande come lui, che ogni tanto sventola (`flourish` delle persone).
Sprite di Codex (`assets/raw/baldoria-sheet.png`; `make character … FIX=wave=1.2,…` perché Codex ha disegnato più
piccole le figure col bandierone in alto). Un dialogo breve. [DA ANDREA: le battute; la somiglianza va rivista con
una sua foto]
- **La campana al campone delle poste (30 ottobre)**: dal Taglia, «E una partita a campana, Taglia?», poi il livello.
Primo piano `campana` visto dall'alto sul vialetto (`campone-campana.png`, Codex); la campana col gesso la disegna il
codice (`campana.js`, `campana-render.js`): 1-2-3, coppia 4-5, 6, coppia 7-8 e in cima il mezzo cerchio con scritto
+1
View File
@@ -12,6 +12,7 @@
"browser:cerbottana": "node tests/cerbottana.browser.mjs",
"browser:condor": "node tests/condor.browser.mjs",
"browser:campana": "node tests/campana.browser.mjs",
"browser:ingorgo": "node tests/ingorgo.browser.mjs",
"browser:tressette": "node tests/tressette.browser.mjs",
"browser:scopa": "node tests/scopa.browser.mjs",
"browser:briscola": "node tests/briscola.browser.mjs",
+43 -3
View File
@@ -13,6 +13,7 @@ import * as gara from './gara.js';
import * as micro from './micro.js';
import * as cerbottana from './cerbottana.js';
import { newCorsa, updateCorsa } from './corsa.js';
import { newIncanto, updateIncanto } from './incanto.js';
import * as condor from './condor.js';
import * as campana from './campana.js';
import * as poker from './poker.js';
@@ -148,6 +149,9 @@ export function restoreState(story, data) {
return { scene: data.scene, x, y, facing: data.facing === -1 ? -1 : 1, inventory, flags: pick(data.flags), used: pick(data.used), ended: data.ended === true };
}
// Il manifesto ingrandito si apre con uno zoom: un tocco prima che sia aperto non conta.
export const POSTER_READY = 0.35;
export class Game {
// voiceTime(who, text): secondi della voce registrata per la battuta, se c'è (il fumetto non finisce prima).
constructor(story, { state, onSave, voiceTime } = {}) {
@@ -295,6 +299,13 @@ export class Game {
closeupTap() {
const c = this.closeup;
if (!c || this.speech) return false;
// Un manifesto ingrandito (type 'poster'): toccarlo lo chiude e fa partire `tap` (il desiderio dell'Ingorgo).
if (this.story.closeups[c.id].type === 'poster') {
if (c.t < POSTER_READY) return false;
this.closeup = null;
this.queue.unshift(...(this.story.closeups[c.id].tap ?? []));
return true;
}
if (this.story.closeups[c.id].type) return false; // torcia e minigiochi non si sfogliano
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
@@ -329,6 +340,12 @@ export class Game {
}
// Il viaggio si chiude da solo alla fine (o uscendo prima): si arriva comunque.
if (def?.type === 'viaggio' || def?.type === 'corsa') { this.queue.unshift(...def.complete); return true; }
// L'incantesimo, chiuso prima del lampo, fa comunque il suo cambio.
if (def?.type === 'incanto') {
if (!c.swapped) for (const e of def.swap ?? []) this.run(e);
this.queue.unshift(...(def.complete ?? []));
return true;
}
// I minigiochi: a partita finita (risultato deciso, anche se il conteggio si sta ancora mostrando) la battuta
// di chi ha vinto; uscendo prima, quella di chi lascia.
if (MINIGAMES[def?.type]) {
@@ -430,15 +447,23 @@ export class Game {
const talk = p.talk ?? ['talk'];
return talk[Math.floor(this.time / 0.3) % talk.length];
}
// `flourish`: ogni tanto un gesto lungo a più fotogrammi (Baldoria che sventola il bandierone).
const f = p.flourish;
if (f) {
const ft = (this.time + (p.offset ?? 0)) % f.every;
if (ft < f.for) return f.poses[Math.floor(ft / (f.step ?? 0.35)) % f.poses.length];
}
const every = p.every ?? 4, t = (this.time + (p.offset ?? 0)) % every;
return p.blink && t > every - 0.25 ? p.blink : idle;
}
// La grandezza a quella altezza della scena; una fase può ingrandire tutti (`scale`: alla notte dell'Ingorgo la folla
// dipinta è grande, e Andrea ci si perdeva in mezzo).
scaleAt(y) {
const d = this.scene.depth;
if (!d) return 1;
const d = this.scene.depth, k = this.phase()?.scale ?? 1;
if (!d) return k;
const [[y0, s0], [y1, s1]] = d, t = Math.max(0, Math.min(1, (y - y0) / (y1 - y0)));
return s0 + (s1 - s0) * t;
return (s0 + (s1 - s0) * t) * k;
}
speakerPoint(who) {
@@ -490,6 +515,12 @@ export class Game {
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
if (this.story.closeups[c.id].type === 'viaggio') { this.updateTrip(dt); return; }
if (this.story.closeups[c.id].type === 'corsa') { if (updateCorsa(c, this.story.closeups[c.id], dt)) this.closeupClose(); return; }
if (this.story.closeups[c.id].type === 'incanto') {
const now = updateIncanto(c, dt);
if (now === 'swap') for (const e of this.story.closeups[c.id].swap ?? []) this.run(e);
if (now === 'end') this.closeupClose();
return;
}
const m = this.minigame();
if (m) {
m.update(c, dt);
@@ -533,6 +564,8 @@ export class Game {
this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
: type === 'viaggio' ? { id: e.closeup, t: 0, age: 0, events: [], typed: 0 }
: type === 'corsa' ? newCorsa(e.closeup)
: type === 'incanto' ? newIncanto(e.closeup)
: type === 'poster' ? { id: e.closeup, step: 'poster', t: 0, age: 0 }
// `level`: «Alla bona», «Seria» o «A fassi male», scelto nel dialogo con l'avversario.
// `decks`: il mazzo da usare quando il suo flag è acceso (briscola: le napoletane al posto delle toscane).
: MINIGAMES[type] ? { id: e.closeup, ...MINIGAMES[type].create(Math.floor(Math.random() * 4294967296), e.level,
@@ -586,6 +619,13 @@ export class Game {
this.back = false;
this.state.flags['visto:' + scene] = true; // per la mappa
this.queue.unshift(...(this.scene.enter ?? []));
// Le zone (story.zones): un flag che vale solo in certi posti, come la festa dell'Ingorgo. Uscendo (a piedi o
// con la mappa) il flag si spegne e partono le battute del risveglio.
for (const z of this.story.zones ?? []) {
if (!this.state.flags[z.flag] || z.scenes.includes(scene)) continue;
delete this.state.flags[z.flag];
this.queue.unshift(...(z.leave ?? []));
}
this.dirty = true; // si salva quando l'ingresso nella scena è finito
}
}
+83
View File
@@ -0,0 +1,83 @@
// L'incantesimo (story.closeups, type 'incanto'; Andrea, 30 ottobre: «Quanto darei per essere all'Ingorgo stasera?!?
// Effetto speciale e compare»). Sopra la scena di adesso, con Andrea dentro: i fasci delle luci da palco che girano
// sempre più svelti, i colpi di cassa che fanno pulsare l'aria, il lampo bianco; nel bianco cambia tutto (`swap`, di
// solito il flag della festa: lo sfondo diventa quello della notte), poi il bianco sfuma e piovono i coriandoli.
// Unità: i pixel della scena 640×360.
// I tempi, in secondi: i fasci, il lampo che sale, il cambio (a bianco pieno), il bianco che sfuma, la fine.
export const T = { beams: 0.2, flash: 1.9, swap: 2.2, clear: 3.0, end: 3.8 };
const COLORS = ['255,60,200', '60,220,255', '120,255,90', '255,190,40', '170,90,255'];
const clamp01 = k => Math.max(0, Math.min(1, k));
// Un numero a caso ma sempre lo stesso per lo stesso indice: i coriandoli non tremano da un fotogramma all'altro.
const hash = n => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
export function newIncanto(id) { return { id, t: 0, age: 0, events: [], beats: 0, swapped: false }; }
// Il tempo che passa e i suoni (un colpo di cassa a ogni battito, sempre più fitti; il lampo). Torna 'swap' nel
// momento del cambio, 'end' alla fine.
export function updateIncanto(c, dt) {
c.t += dt;
const beats = Math.floor(beatAt(Math.min(c.t, T.flash)));
for (; c.beats < beats; c.beats++) c.events.push({ kind: 'incanto-cassa', power: 0.5 + 0.5 * c.beats / 8 });
if (!c.boom && c.t >= T.flash) { c.boom = true; c.events.push({ kind: 'incanto-lampo', power: 1 }); }
if (!c.swapped && c.t >= T.swap) { c.swapped = true; return 'swap'; }
return c.t >= T.end ? 'end' : null;
}
// Quanti battiti sono passati: accelerano, da uno ogni mezzo secondo a uno ogni decimo.
export const beatAt = t => t <= 0 ? 0 : 2 * t + 1.6 * t * t;
// Quanto è bianco lo schermo: sale fino al cambio, resta pieno un attimo, poi sfuma.
export function whiteness(t) {
if (t < T.flash) return 0;
if (t < T.swap) return clamp01((t - T.flash) / (T.swap - T.flash));
return clamp01(1 - (t - T.swap) / (T.clear - T.swap));
}
export function drawIncanto(ctx, c) {
const t = c.t, before = t < T.swap;
ctx.save();
// Il colpo di cassa: un velo scuro che si apre e si chiude, l'aria che pulsa.
if (before) {
const b = beatAt(t), pulse = 1 - (b - Math.floor(b));
ctx.fillStyle = `rgba(10,0,30,${0.25 * clamp01(t / 0.6) * pulse})`;
ctx.fillRect(0, 0, 640, 360);
}
// I fasci: partono da sotto, ai lati, e spazzano il cielo; più passa il tempo, più sono e più girano svelti.
const strength = before ? clamp01((t - T.beams) / 0.5) : clamp01(1 - (t - T.swap) / 0.6);
if (strength > 0) {
ctx.globalCompositeOperation = 'lighter';
const n = 2 + Math.min(6, Math.floor(t * 3));
for (let i = 0; i < n; i++) {
const from = [i % 2 ? 560 : 80, 372], speed = 0.9 + i * 0.35 + t * 0.9;
const a = -Math.PI / 2 + Math.sin(t * speed + i * 1.7) * 0.9, len = 520, half = 0.07;
ctx.fillStyle = `rgba(${COLORS[i % COLORS.length]},${0.22 * strength})`;
ctx.beginPath();
ctx.moveTo(...from);
ctx.lineTo(from[0] + Math.cos(a - half) * len, from[1] + Math.sin(a - half) * len);
ctx.lineTo(from[0] + Math.cos(a + half) * len, from[1] + Math.sin(a + half) * len);
ctx.closePath();
ctx.fill();
}
// Le scintille che salgono da terra, a quadratini.
for (let i = 0; i < 40; i++) {
const life = (t * (0.5 + hash(i)) + hash(i + 50)) % 1, x = 20 + hash(i + 100) * 600, y = 360 - life * 300;
ctx.fillStyle = `rgba(${COLORS[i % COLORS.length]},${0.9 * strength * (1 - life)})`;
ctx.fillRect(Math.round(x), Math.round(y), 2, 2);
}
ctx.globalCompositeOperation = 'source-over';
}
// Il lampo.
const white = whiteness(t);
if (white > 0) { ctx.fillStyle = `rgba(255,255,250,${white})`; ctx.fillRect(0, 0, 640, 360); }
// Dopo il cambio, i coriandoli che cadono sulla festa.
if (!before) {
const k = (t - T.swap) / (T.end - T.swap);
for (let i = 0; i < 70; i++) {
const x = (hash(i) * 640 + Math.sin(t * 3 + i) * 8) % 640, y = -10 + (hash(i + 7) * 120 + k * 420 * (0.6 + hash(i + 3) * 0.6));
if (y > 360) continue;
ctx.fillStyle = `rgba(${COLORS[i % COLORS.length]},${clamp01(1.3 - k)})`;
ctx.fillRect(Math.round(x), Math.round(y), 3, 2);
}
}
ctx.restore();
}
+44 -21
View File
@@ -9,22 +9,39 @@ import { versioned } from '../version.js';
// Quale brano: quello della scena se ce l'ha; altrimenti, all'aperto, quello della fase del giorno (la sera ha il suo);
// altrimenti quello generale del paese.
// Un primo piano con la sua musica (i tavoli da carte, col valzer) la porta con sé finché resta aperto.
export function trackFor(story, { screen, scene, phase, closeup }) {
// Una scena con le console accese (`stages`, ognuna col suo `when`) non ha un brano solo ma una miscela: ogni console
// suona il suo, più forte quanto più Andrea (`at`) le è vicino (mix). Le console possono stare anche fuori dal quadro:
// è la musica della piazza accanto, che arriva da lontano.
// `ok(cond)` dice se una condizione della storia vale adesso (game.js, check).
export function trackFor(story, { screen, scene, phase, closeup, at, ok = () => false }) {
const m = story.music ?? {};
if (screen === 'splash') return null;
if (screen === 'title' || screen === 'intro') return m.title ?? null;
if (screen === 'ending') return m.ending ?? m.game ?? null;
if (closeup && story.closeups?.[closeup]?.music) return story.closeups[closeup].music;
const sc = story.scenes[scene];
const stages = (sc?.stages ?? []).filter(st => ok(st.when));
if (stages.length) return mix(stages, at ?? sc.entry ?? [320, 300]);
return sc?.music ?? (sc?.indoor ? null : phase?.music) ?? m.game ?? null;
}
// Il volume di ogni console: pieno fino a `near` pixel, poi cala fino a `far` (non sotto un filo, `floor`: la festa
// si sente sempre). La più forte per prima.
export function mix(stages, [x, y]) {
return stages.map(st => {
const d = Math.hypot(x - st.at[0], (y - st.at[1]) * 1.5), near = st.near ?? 90, far = st.far ?? 520;
const k = Math.max(0, Math.min(1, (d - near) / (far - near)));
return { src: st.music, gain: Math.max(st.floor ?? 0.08, (1 - k) ** 1.6) };
}).sort((a, b) => b.gain - a.gain);
}
const FADE = 1.2;
// Dove il volume non si può cambiare (iOS), almeno il muto e lo zero funzionano: `muted` lì si rispetta.
function setLevel(a) {
// Dove il volume non si può cambiare (iOS), almeno il muto e lo zero funzionano: `muted` lì si rispetta. Lì, nella
// miscela delle console, suona solo la più forte (`alone`): le altre a volume pieno coprirebbero tutto.
function setLevel(a, alone = false) {
a.volume = a.level;
a.muted = a.level === 0;
a.muted = a.level === 0 || (alone && a.level < 1 && Math.abs(a.volume - a.level) > 0.01);
}
export class Music {
@@ -33,40 +50,46 @@ export class Music {
this.volume = volume;
this.blocked = null;
this.want = null;
this.current = null; // { src, audio }
this.old = [];
this.tracks = new Map(); // src -> { src, audio, gain }: quelli voluti e quelli che sfumano
this.wanted = []; // i src voluti adesso, il più forte per primo
this.duck = 1; // 1 = pieno; meno di 1 mentre parla qualcuno
this.hold = false; // in pausa: il brano sfuma e si ferma, poi riprende da dov'era
this.away = false; // pagina nascosta (sul telefono si è passati a un'altra app): tutto fermo subito
}
// Il brano principale (il più forte) e quelli che stanno sfumando.
get current() { return this.tracks.get(this.wanted[0]) ?? null; }
get old() { return [...this.tracks.values()].filter(t => !this.wanted.includes(t.src)).map(t => t.audio); }
// Nascosta la pagina il browser non chiama più update (niente fotogrammi): i brani si fermano qui, di colpo.
// Tornati, update li fa ripartire da dov'erano (se non c'è la pausa).
setAway(away) {
this.away = away;
if (away) for (const a of [this.current?.audio, ...this.old]) a?.pause();
if (away) for (const t of this.tracks.values()) t.audio.pause();
}
// 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;
// Chiamata a ogni fotogramma con il brano voluto (o la miscela delle console, [{ src, gain }]): avvia, cambia e sfuma.
update(want, dt) {
this.want = want;
const list = want == null ? [] : typeof want === 'string' ? [{ src: want, gain: 1 }] : want;
this.wanted = list.map(w => w.src);
for (const w of list) {
const t = this.tracks.get(w.src) ?? { src: w.src, audio: this.start(w.src) };
t.gain = w.gain;
this.tracks.set(w.src, t);
}
// Il brano in corso sale (o scende) verso il volume scelto; quelli vecchi sfumano fino a zero, e lì si fermano.
// Ogni brano voluto sale (o scende) verso il suo volume; quelli lasciati sfumano fino a zero, e lì si fermano.
// Il livello si tiene in `level`, non si rilegge da audio.volume: su iPhone e iPad quello è di sola lettura e
// resta sempre 1, e un brano vecchio non arrivava mai a zero (in chiesa l'organo suonava insieme al paese).
const step = dt / FADE, target = this.muted || this.hold ? 0 : this.volume * this.duck;
if (this.current) {
const a = this.current.audio;
const step = dt / FADE, full = this.muted || this.hold ? 0 : this.volume * this.duck;
for (const t of [...this.tracks.values()]) {
const a = t.audio, on = this.wanted.includes(t.src), target = on ? full * t.gain : 0;
a.level = a.level < target ? Math.min(target, a.level + step) : Math.max(target, a.level - step);
setLevel(a);
setLevel(a, list.length > 1 && t.src !== this.wanted[0]);
if (!on) { if (a.level === 0) { a.pause(); this.tracks.delete(t.src); } continue; }
if (this.hold && a.level === 0 && !a.paused) a.pause();
else if (!this.hold && !this.away && a.paused && a !== this.blocked) a.play().catch(() => { this.blocked = a; });
}
for (const a of this.old) { a.level = Math.max(0, a.level - step); setLevel(a); }
this.old = this.old.filter(a => a.level > 0 || (a.pause(), false));
}
start(src) {
@@ -81,7 +104,7 @@ export class Music {
unlock() {
const a = this.blocked;
this.blocked = null;
if (a && a === this.current?.audio) a.play().catch(() => {});
if (a && [...this.tracks.values()].some(t => t.audio === a && this.wanted.includes(t.src))) a.play().catch(() => {});
}
toggle() {
+28 -2
View File
@@ -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, PAGE_TURN } from './game.js';
import { check, PAGE_TURN, POSTER_READY } from './game.js';
import { ambientEffects, ambientHash } from './ambient.js';
import { drawTorch } from './torch.js';
import { drawBocce } from './bocce-render.js';
@@ -16,6 +16,7 @@ import { drawCondor } from './condor-render.js';
import { drawCampana } from './campana-render.js';
import { drawPoker } from './poker-render.js';
import { drawViaggio } from './viaggio.js';
import { drawIncanto } from './incanto.js';
import { drawBriscola } from './briscola-render.js';
import { drawTressette } from './tressette-render.js';
import { drawScopa } from './scopa-render.js';
@@ -131,13 +132,15 @@ export async function loadAssets(story, first = story.start.scene, { together =
}
// Gli sfondi in ordine di distanza dalla scena di partenza, seguendo le uscite; in fondo quelle non raggiungibili.
// Prima tutti gli sfondi veri, poi le varianti (`backdrops`, come la notte dell'Ingorgo): quando servono prima, la
// scena se le carica da sé.
export function backgroundOrder(story, first) {
const seen = new Set([first]), queue = [first];
for (let i = 0; i < queue.length; i++)
for (const h of story.scenes[queue[i]]?.hotspots ?? [])
if (h.exit && story.scenes[h.exit.scene] && !seen.has(h.exit.scene)) { seen.add(h.exit.scene); queue.push(h.exit.scene); }
for (const id of Object.keys(story.scenes)) if (!seen.has(id)) queue.push(id);
return [...new Set(queue.flatMap(id => [story.scenes[id].background, ...(story.scenes[id].backdrops ?? []).map(b => b.src)]).filter(Boolean))];
return [...new Set([...queue.map(id => story.scenes[id].background), ...queue.flatMap(id => (story.scenes[id].backdrops ?? []).map(b => b.src))].filter(Boolean))];
}
// Il personaggio alto PERSON pixel a grandezza 1 (per `size` 1), con i piedi in (x, y); rivolto a sinistra si specchia.
@@ -698,6 +701,29 @@ function drawCloseup(ctx, game, time) {
drawCorsa(ctx, c, def, { bg: backgrounds.get(bg), frames, tint: game.phase()?.tint });
return;
}
// L'incantesimo va sopra la scena (con Andrea dentro), già disegnata.
if (def.type === 'incanto') { drawIncanto(ctx, c); return; }
// Il manifesto ingrandito: la scena si scurisce e lui viene avanti, poi l'invito a toccarlo.
if (def.type === 'poster') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
const img = backgrounds.get(def.picture), k = Math.min(1, c.t / POSTER_READY), ease = 1 - (1 - k) ** 3;
ctx.fillStyle = `rgba(8, 4, 16, ${0.75 * ease})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
if (img) {
const fit = Math.min(600 / img.width, 300 / img.height) * (0.55 + 0.45 * ease), w = img.width * fit, h = img.height * fit;
ctx.globalAlpha = ease;
ctx.drawImage(img, Math.round((WIDTH - w) / 2), Math.round((HEIGHT - 26 - h) / 2), Math.round(w), Math.round(h));
ctx.globalAlpha = 1;
}
if (k >= 1 && Math.floor(time / 600) % 2 === 0) {
ctx.font = '8px "Press Start 2P", monospace';
ctx.textAlign = 'center';
ctx.fillStyle = '#ffe9b0';
ctx.fillText(def.hint ?? 'Tocca il manifesto', WIDTH / 2, HEIGHT - 12);
ctx.textAlign = 'left';
}
return;
}
if (def.type === 'torch') {
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
drawTorch(ctx, c, def, backgrounds.get(def.picture));
+3
View File
@@ -148,6 +148,9 @@ export class Soundscape {
// La macchina del tempo: il tuono del fulmine e lo sfrigolio delle sgommate di fuoco.
else if (kind === 'corsa-tuono') { this.noiseSound(0.9, 400, 0.16, { pan }); this.tone(90, 40, 0.8, 0.1, 0, pan); }
else if (kind === 'corsa-fuoco') this.noiseSound(1.6, 2400, 0.05, { pan });
// L'incantesimo dell'Ingorgo: i colpi di cassa che accelerano, poi il lampo con lo spazzolone del piatto.
else if (kind === 'incanto-cassa') this.tone(150, 45, 0.22, 0.12 + strength * 0.1, 0, pan);
else if (kind === 'incanto-lampo') { this.noiseSound(1.4, 5200, 0.12, { pan }); this.tone(60, 35, 1.2, 0.16, 0, pan); this.tone(880, 1760, 0.6, 0.05, 0.05, pan); }
// La campana: il sassolino che vola e batte sull'asfalto, il salto, il piede giù.
else if (kind === 'campana-lancio') this.noiseSound(0.1, 1500, 0.025, { pan });
else if (kind === 'campana-sasso') { this.tone(1900, 1100, 0.04, 0.05 * strength + 0.02, 0, pan); this.tone(1500, 900, 0.03, 0.03, 0.06, pan); this.noiseSound(0.04, 3000, 0.03, { pan }); }
+3 -2
View File
@@ -1,6 +1,6 @@
// Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti).
import { story } from './story/story.js';
import { Game, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js';
import { Game, check, VERBS, VERB_LABELS, checkSave, introText, shuffledCredits, matchPlace, reelPictures, facePicture } from './engine/game.js';
import { Command } from './engine/command.js';
import { render, loadAssets, hasBackground, WIDTH, HEIGHT } from './engine/render.js';
import { readSave, writeSave, clearSave, readAudio, writeAudio, saveFile, saveFileName, readSaveFile } from './engine/storage.js';
@@ -1127,7 +1127,8 @@ function frame(now) {
// Mentre si parla la musica si abbassa; quando compaiono gli occhi del Regolo quasi si spegne, per la sorpresa.
const searching = game?.closeup?.id === 'fessura';
music.duck = searching ? game.closeup.found >= 2 ? 0.05 : 0.25 : game?.state.flags.occhi_regolo ? 0.12 : voices.playing ? 0.45 : 1;
music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id }), dt);
music.update(trackFor(story, { screen, scene: game?.state.scene, phase: game?.phase(), closeup: game?.closeup?.id,
at: game && [game.state.x, game.state.y], ok: cond => !!game && check(cond, game.state) }), dt);
// Un minuto senza toccare niente, a gioco fermo: pausa da sola.
if (running && now - idleSince > IDLE_PAUSE && !game.speech && !game.walking && !game.queue.length) openPause();
ambience.configure({ active: screen === 'game' && running && !document.hidden && $('credits').hidden, muted: music.muted, duck: voices.playing ? 0.4 : 1 });
+125 -5
View File
@@ -72,6 +72,21 @@ export const story = {
{ id: 'pomeriggio' },
{ id: 'tramonto', when: 'indizio_regolo', tint: 'rgba(255, 110, 40, 0.16)' },
{ id: 'sera', when: 'casetta_aperta', tint: 'rgba(24, 28, 90, 0.42)', music: 'assets/audio/sera.mp3' },
// La notte dell'Ingorgo Sonoro (Andrea, 30 ottobre): gli sfondi sono già di notte, il velo serve solo ad Andrea.
// La musica la fanno le console delle scene (`stages`).
{ id: 'ingorgo', when: 'ingorgo', tint: 'rgba(40, 20, 90, 0.16)', scale: 1.6 },
],
// Le zone: un flag che vale solo in certi posti. L'Ingorgo Sonoro (Andrea, 30 ottobre: «era la festa del paese
// fatta una delle prime sere di luglio, da piazza fino a Cambray Digny, compresa piazza del Comune») si accende
// col manifesto in piazza e si spegne uscendo dal centro. [DA ANDREA: le battute sono provvisorie]
zones: [
{
flag: 'ingorgo', scenes: ['piazza', 'via_antica_posta', 'piazzetta_comune', 'antica_posta_alta', 'piazzetta'],
leave: [
{ say: 'Mi stropiccio gli occhi. La musica non c\'è più: è di nuovo maggio.' },
{ say: 'E il palio è sempre sparito. Però che serata.' },
],
},
],
// Il taccuino (pulsante «Taccuino», tasto N): le tappe del capitolo in ordine. Una tappa è fatta quando la sua
// condizione `done` è vera; la prima non fatta è l'obiettivo di adesso. Solo testo: non si sente a voce.
@@ -312,6 +327,27 @@ export const story = {
draw: [],
quit: [{ say: 'Il gesso lo lascio lì. Ci giocherà qualche bambino, che è più in allenamento di me.' }],
},
// Il manifesto dell'Ingorgo, ingrandito (Andrea, 30 ottobre: «se mi avvicino compaia ingrandito, e poi parta
// l'Ingorgo se ci clicco»). [DA ANDREA: il manifesto vero; questo è di Codex, provvisorio]
manifesto_ingorgo: {
type: 'poster',
picture: 'assets/closeups/manifesto-ingorgo.png',
hint: 'Tocca il manifesto',
tap: [
{ say: 'Il manifesto dell\'Ingorgo Sonoro dell\'anno scorso. Da piazza fino a Cambray Digny, una console in ogni angolo.' },
{ say: 'Quanto darei per essere all\'Ingorgo stasera?!?' },
{ closeup: 'ingorgo' },
],
},
// L'incantesimo dell'Ingorgo Sonoro, dal manifesto in piazza: luci, cassa, lampo, ed è la notte della festa.
ingorgo: {
type: 'incanto',
swap: [{ set: 'ingorgo' }],
complete: [
{ say: 'Ma... è l\'Ingorgo! Le console, la gente, le spine della birra!' },
{ say: 'Il palio può aspettare una sera. O no?' },
],
},
// Le macchinine sulla pista col gesso, sul parcheggio di Cardetole, contro il Chiaru (Andrea, 28 e 29 ottobre).
// [DA ANDREA: le battute sono provvisorie]
micro: {
@@ -402,6 +438,8 @@ export const story = {
// Andrea Tagliaferri, il Taglia (consenso suo, 29 ottobre): sprite di Codex dalla sua foto.
taglia: { name: 'Il Taglia', color: '#ffe36a', sprite: 'assets/characters/taglia.json' },
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
// Baldoria (Yuri Baldi, nei Ringraziamenti), tifoso della Sampdoria col bandierone (Andrea, 30 ottobre; sprite di Codex).
baldoria: { name: 'Baldoria', color: '#7fb2ff', sprite: 'assets/characters/baldoria.json' },
mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' },
// Non parla: è una cosa, disegnata come un personaggio per stare davanti o dietro ad Andrea.
fra_peppino: { name: 'Fra Peppino', color: '#e8c89a', sprite: 'assets/characters/fra-peppino.json' },
@@ -543,15 +581,22 @@ export const story = {
name: 'Piazza Colonna',
map: [32, 45],
background: 'assets/scenes/piazza-colonna.png',
effects: [{ type: 'clouds', area: [160, 0, 440, 80], count: 3, big: true }, { type: 'windows', phase: 'sera', rects: [[16, 37, 8, 18], [48, 42, 8, 18], [50, 110, 8, 16], [97, 77, 8, 12], [122, 80, 8, 12], [170, 90, 8, 12], [204, 103, 8, 12], [239, 111, 8, 12], [268, 117, 7, 14], [287, 125, 6, 14], [97, 130, 9, 14], [127, 133, 9, 14], [170, 142, 9, 14], [204, 150, 8, 12], [239, 155, 8, 12], [268, 160, 7, 14], [287, 163, 6, 14], [386, 163, 14, 16], [424, 160, 14, 16], [472, 157, 14, 16], [524, 155, 14, 16], [390, 212, 14, 16], [526, 208, 14, 16]] }, { type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [150, 20, 460, 80] }],
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-piazza.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
// La console grande davanti al palazzo; da Via dell'Antica Posta, a sinistra, arriva il latino del Comune.
stages: [
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [195, 235] },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [-160, 250], far: 600 },
],
effects: [{ type: 'clouds', area: [160, 0, 440, 80], count: 3, big: true, phase: ['pomeriggio', 'tramonto', 'sera'] }, { type: 'windows', phase: 'sera', rects: [[16, 37, 8, 18], [48, 42, 8, 18], [50, 110, 8, 16], [97, 77, 8, 12], [122, 80, 8, 12], [170, 90, 8, 12], [204, 103, 8, 12], [239, 111, 8, 12], [268, 117, 7, 14], [287, 125, 6, 14], [97, 130, 9, 14], [127, 133, 9, 14], [170, 142, 9, 14], [204, 150, 8, 12], [239, 155, 8, 12], [268, 160, 7, 14], [287, 163, 6, 14], [386, 163, 14, 16], [424, 160, 14, 16], [472, 157, 14, 16], [524, 155, 14, 16], [390, 212, 14, 16], [526, 208, 14, 16]] }, { type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [150, 20, 460, 80] }],
walk: [[84, 244], [330, 238], [345, 230], [362, 238], [462, 246], [466, 276], [544, 276], [548, 248], [588, 250], [600, 290], [640, 300], [640, 360], [0, 360], [0, 330], [84, 318]],
depth: [[236, 0.5], [360, 1.35]],
entry: [345, 240],
speakers: { vecchietta: [24, 250], vecchietto: [56, 244] },
// I vecchietti seduti sulla panchina: fotogrammi in assets/characters (pose e battute in game.personPose).
people: [
{ who: 'vecchietta', at: [24, 317], size: 0.92, talk: ['talk', 'explain'], blink: 'blink', every: 3.7 },
{ who: 'vecchietto', at: [56, 304], size: 0.88, talk: ['talk', 'explain'], blink: 'doze', every: 5.3, offset: 2 },
{ who: 'vecchietta', at: [24, 317], when: '!ingorgo', size: 0.92, talk: ['talk', 'explain'], blink: 'blink', every: 3.7 },
{ who: 'vecchietto', at: [56, 304], when: '!ingorgo', size: 0.88, talk: ['talk', 'explain'], blink: 'doze', every: 5.3, offset: 2 },
],
hotspots: [
{
@@ -614,7 +659,7 @@ export const story = {
exit: { scene: 'circolo_4_maggio', at: [590, 202], face: 'left' },
},
{
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', when: '!ingorgo',
art: [{ rect: [256, 196, 18, 24], fill: '#f4efe4' }, { rect: [258, 199, 14, 5], fill: '#b7342e' }, { rect: [258, 207, 12, 2], fill: '#3b3b44' }, { rect: [258, 211, 10, 2], fill: '#3b3b44' }, { rect: [258, 215, 12, 2], fill: '#2f5fb3' }],
actions: {
look: [
@@ -625,7 +670,20 @@ export const story = {
},
},
{
id: 'vecchietti', name: 'vecchietti', box: [0, 250, 72, 52], at: [100, 320], face: 'left', default: 'talk',
// Il manifesto dell'Ingorgo Sonoro dell'estate prima, mezzo strappato: guardandolo, Andrea esprime il
// desiderio, e la festa arriva (Andrea, 30 ottobre). [DA ANDREA: le battute sono provvisorie]
id: 'manifesto_ingorgo', name: 'vecchio manifesto dell\'Ingorgo Sonoro', box: [226, 198, 16, 22], at: [234, 250], face: 'up', when: '!ingorgo',
// Come quello vero (assets/raw/riferimenti/manifesto-ingorgo-2014.png): blu, il cartello bianco e rosso col DJ.
art: [{ rect: [226, 198, 16, 22], fill: '#1c2050' }, { rect: [233, 201, 2, 2], fill: '#d8262e' }, { rect: [231, 203, 6, 2], fill: '#d8262e' },
{ rect: [229, 205, 10, 4], fill: '#d8262e' }, { rect: [232, 205, 4, 3], fill: '#f4f1e8' }, { rect: [228, 211, 12, 2], fill: '#f4f1e8' },
{ rect: [229, 215, 7, 2], fill: '#ffd23a' }, { rect: [238, 198, 4, 4], fill: '#cdbb9a' }],
actions: {
look: [{ closeup: 'manifesto_ingorgo' }],
take: [{ say: 'Lo lascio dov\'è: è l\'unica cosa che ricorda luglio, qui, a maggio.' }],
},
},
{
id: 'vecchietti', name: 'vecchietti', box: [0, 250, 72, 52], at: [100, 320], face: 'left', default: 'talk', when: '!ingorgo',
actions: {
look: [{ say: 'I vecchietti della panchina. Sanno tutto di tutti, soprattutto quello che non è successo.' }],
talk: [
@@ -1563,6 +1621,13 @@ export const story = {
name: 'Piazzetta Cambray Digny',
map: [40, 38],
background: 'assets/scenes/piazzetta-cambray-digny.png',
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-piazzetta.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
// La tecno davanti alla casetta bianca; l'underground arriva da giù, da Via dell'Antica Posta.
stages: [
{ when: 'ingorgo', music: 'assets/audio/ingorgo-techno.mp3', at: [495, 245] },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [700, 300], far: 650 },
],
walk: [[0, 360], [0, 288], [300, 284], [470, 276], [495, 268], [540, 268], [600, 300], [640, 330], [640, 360]],
depth: [[268, 0.55], [360, 1.15]],
entry: [320, 330],
@@ -1600,6 +1665,14 @@ export const story = {
name: 'Via dell\'Antica Posta',
map: [35, 41],
background: 'assets/scenes/via-antica-posta.png',
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-antica-posta.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
// Il bar della birra sui gradini; il latino dal Comune a sinistra, la house giù dalla piazza, l'underground su in cima.
stages: [
{ when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [-110, 250] },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [320, 560] },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [384, 40], far: 600 },
],
walk: [[0, 360], [0, 300], [120, 290], [220, 252], [300, 240], [372, 226], [396, 226], [470, 252], [630, 300], [640, 360]],
depth: [[226, 0.35], [360, 1.15]],
entry: [320, 345],
@@ -1639,6 +1712,13 @@ export const story = {
name: 'Piazzetta del Comune',
map: [32, 39],
background: 'assets/scenes/piazzetta-comune.png',
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-piazzetta-comune.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
// La console del latino davanti al Comune; la house arriva da giù, dalla piazza.
stages: [
{ when: 'ingorgo', music: 'assets/audio/ingorgo-latino.mp3', at: [290, 235] },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [260, 640], far: 700 },
],
walk: [[0, 360], [0, 318], [130, 292], [470, 288], [540, 300], [520, 360]],
depth: [[288, 0.55], [360, 1.15]],
entry: [260, 350],
@@ -1686,6 +1766,14 @@ export const story = {
name: 'In cima a Via dell\'Antica Posta',
map: [38, 39],
background: 'assets/scenes/via-antica-posta-alta.png',
backdrops: [{ when: 'ingorgo', src: 'assets/scenes/ingorgo-antica-posta-alta.png' }], // Codex, la notte dell'Ingorgo (30 ottobre)
// Le console dell'Ingorgo Sonoro (music.js, mix). [DA ANDREA: quale genere suonava in quale piazza]
// L'underground sotto il muro della villa; su, in fondo, la tecno di Cambray Digny; giù, lontano, la house.
stages: [
{ when: 'ingorgo', music: 'assets/audio/ingorgo-underground.mp3', at: [565, 235] },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-techno.mp3', at: [270, 120], far: 600 },
{ when: 'ingorgo', music: 'assets/audio/ingorgo-house.mp3', at: [240, 640], far: 750 },
],
walk: [[0, 360], [0, 322], [190, 226], [283, 222], [470, 360]],
depth: [[222, 0.35], [360, 1.15]],
entry: [240, 350],
@@ -1998,7 +2086,19 @@ export const story = {
walk: [[0, 298], [640, 298], [640, 360], [0, 360]],
depth: [[298, 1.0], [360, 1.2]],
entry: [40, 330],
// Baldoria sotto la tribuna col bandierone della Sampdoria: ogni tanto lo sventola (Andrea, 30 ottobre).
speakers: { baldoria: [490, 196] },
people: [{ who: 'baldoria', at: [490, 318], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.3,
flourish: { poses: ['wave', 'wave2'], every: 9, for: 2.1, step: 0.35 } }],
hotspots: [
{
id: 'baldoria', name: 'Baldoria', box: [462, 200, 56, 124], at: [430, 326], face: 'right', default: 'talk',
actions: {
look: [{ say: 'Baldoria, con la maglia blucerchiata e un bandierone della Sampdoria grande come lui. Qui a San Piero.' }],
talk: [{ dialogue: 'baldoria' }],
take: [{ say: 'Il bandierone? Prima dovrei passare sul suo cadavere. E non è un modo di dire.' }],
},
},
{
id: 'tribuna', name: 'tribuna', box: [254, 172, 116, 40], at: [310, 320], face: 'up',
actions: {
@@ -3974,6 +4074,26 @@ export const story = {
},
},
// Il Taglia al campone delle poste, con la Condor (Andrea, 29 ottobre). [DA ANDREA: battute provvisorie]
// Baldoria al campo sportivo. [DA ANDREA: le battute sono provvisorie]
baldoria: {
nodes: {
start: {
options: [
{ text: 'Baldoria! Ma che ci fai col bandierone al campo?', then: [
{ say: 'Mi alleno. Il bandierone va sventolato tutti i giorni, sennò si arrugginisce il braccio.', who: 'baldoria', pose: 'explain' },
{ say: 'Forza Doria!', who: 'baldoria', pose: 'wave' },
] },
{ text: 'Guarda che qui la Sampdoria non ci gioca.', then: [
{ say: 'La Samp gioca dovunque ci sia un blucerchiato. E oggi ci sono io.', who: 'baldoria', pose: 'cheer' },
] },
{ text: 'Hai visto passare il palio, per caso?', then: [
{ say: 'L\'unico palio che conosco è blu, bianco, rosso e nero. E ce l\'ho in mano.', who: 'baldoria', pose: 'laugh' },
] },
{ text: 'Ciao, Baldoria.', next: 'end' },
],
},
},
},
taglia: {
nodes: {
start: {
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { Game } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
test('Baldoria al campo principale: ogni tanto sventola il bandierone, sennò sta fermo', () => {
const g = new Game(story); g.go('polisportiva_principale'); settle(g);
const p = g.people().find(p => p.who === 'baldoria');
assert.ok(p, 'Baldoria è in scena');
const poses = new Set();
for (let t = 0; t < 9; t += 0.05) { g.time = t; poses.add(g.personPose(p)); }
assert.ok(poses.has('wave') && poses.has('wave2') && poses.has('stand') && poses.has('blink'));
g.time = 3; assert.equal(g.personPose(p), 'stand', 'fuori dallo sventolio, fermo');
const sheet = JSON.parse(readFileSync(new URL('../assets/characters/baldoria.json', import.meta.url)));
for (const f of ['stand', 'blink', 'talk', 'explain', 'wave', 'wave2', 'cheer', 'laugh']) assert.ok(sheet.frames[f], f);
assert.ok(existsSync(new URL('../assets/characters/baldoria.png', import.meta.url)));
});
test('si parla con Baldoria: le risposte e il saluto', () => {
const g = new Game(story); g.go('polisportiva_principale'); settle(g);
g.act('talk', 'baldoria'); settle(g);
assert.equal(g.dialogue?.id, 'baldoria');
const said = [];
for (const text of ['Baldoria! Ma che ci fai col bandierone al campo?', 'Guarda che qui la Sampdoria non ci gioca.', 'Hai visto passare il palio, per caso?']) {
g.choose(g.options().findIndex(o => o.text === text));
said.push(...settle(g).filter(s => s.who === 'baldoria').map(s => s.text));
}
assert.ok(said.includes('Forza Doria!'));
assert.equal(said.length, 4);
g.choose(g.options().findIndex(o => o.text === 'Ciao, Baldoria.')); settle(g);
assert.equal(g.dialogue, null);
});
+58
View File
@@ -0,0 +1,58 @@
// L'Ingorgo Sonoro nel browser vero: il manifesto in piazza, l'effetto speciale, la festa nelle cinque scene con le
// console che si mescolano, e il ritorno a maggio uscendo dal centro.
// npm run browser:ingorgo — schermate in /tmp/regolo-ingorgo.
import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { staticServer } from './helpers.mjs';
const out = process.env.OUT ?? '/tmp/regolo-ingorgo';
mkdirSync(out, { recursive: true });
const server = await staticServer();
let browser;
const errors = [];
try {
browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } });
page.on('pageerror', e => { errors.push(e.message); console.log('ERR', e.message); });
await page.goto(`http://127.0.0.1:${server.port}/`);
await page.click('#splash'); await page.click('#new-game'); await page.click('#rione [data-rione="campomigliaio"]'); await page.click('#skip-intro');
await page.evaluate(async () => {
const { settle } = await import('/tests/helpers.mjs');
window.settle = settle;
settle(game); game.go('piazza', [250, 300]); settle(game);
game.act('look', 'manifesto_ingorgo');
});
// Il manifesto si apre ingrandito; un clic sopra, e parte il desiderio.
await page.waitForFunction(() => game.closeup?.id === 'manifesto_ingorgo' && game.closeup.t > 0.6, null, { timeout: 15000 }).catch(async e => { console.log(await page.evaluate(() => JSON.stringify({ c: game.closeup, sp: game.speech, q: game.queue, task: game.task, walking: game.walking, xy: [game.state.x, game.state.y] }))); throw e; });
await page.waitForFunction(() => game.closeup.t > 1.4, null, { timeout: 5000 });
await page.screenshot({ path: `${out}/0-manifesto.png` });
const box = await page.locator('#scene').boundingBox();
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
// Le battute davanti al manifesto, poi l'effetto: a metà i fasci, poi il lampo, poi i coriandoli sulla festa.
await page.waitForFunction(() => game.speech?.text.startsWith('Quanto darei'), null, { timeout: 15000 });
await page.screenshot({ path: `${out}/1-desiderio.png` });
await page.waitForFunction(() => game.closeup?.id === 'ingorgo' && game.closeup.t > 1.2, null, { timeout: 15000 });
await page.screenshot({ path: `${out}/2-fasci.png` });
await page.waitForFunction(() => game.closeup?.t > 2.5, null, { timeout: 5000 });
await page.screenshot({ path: `${out}/3-coriandoli.png` });
await page.waitForFunction(() => !game.closeup && game.state.flags.ingorgo && game.speech?.text.includes('Ingorgo'), null, { timeout: 5000 });
await page.screenshot({ path: `${out}/3b-festa.png` });
await page.evaluate(() => settle(game));
// La festa, scena per scena: lo sfondo di notte, e la musica di più console insieme.
for (const [scene, at] of [['piazza', [250, 300]], ['via_antica_posta', [300, 300]], ['piazzetta_comune', [300, 330]], ['antica_posta_alta', [330, 300]], ['piazzetta', [330, 320]]]) {
await page.evaluate(([s, a]) => { game.go(s, a); settle(game); }, [scene, at]);
await page.waitForFunction(async () => (await import('/src/engine/render.js')).hasBackground(game.backdrop()), null, { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(1500);
const mixNow = await page.evaluate(() => [...music.tracks.values()].map(t => [t.src.split('/').pop(), Math.round(t.gain * 100)]));
console.log(scene, JSON.stringify(mixNow));
assert.ok(mixNow.length >= 2, `${scene}: più console insieme`);
await page.screenshot({ path: `${out}/4-${scene}.png` });
}
// Uscendo dal centro si torna a maggio.
await page.evaluate(() => { game.go('via_de_medici'); });
await page.waitForFunction(() => !game.state.flags.ingorgo && game.speech?.text.startsWith('Mi stropiccio'), null, { timeout: 5000 });
await page.screenshot({ path: `${out}/5-risveglio.png` });
assert.deepEqual(errors, []);
console.log(`ok: schermate in ${out}`);
} finally { await browser?.close(); server.kill(); }
+115
View File
@@ -0,0 +1,115 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { trackFor, mix, Music } from '../src/engine/music.js';
import { newIncanto, updateIncanto, drawIncanto, whiteness, T } from '../src/engine/incanto.js';
import { Game, check, POSTER_READY } from '../src/engine/game.js';
import { story } from '../src/story/story.js';
import { settle } from './helpers.mjs';
const ZONE = story.zones.find(z => z.flag === 'ingorgo');
// Fa scorrere il tempo finché l'incantesimo non è finito.
const spell = g => { for (let i = 0; i < 600 && g.closeup?.id === 'ingorgo'; i++) g.update(1 / 60); settle(g); };
const fromPiazza = () => {
const g = new Game(story); g.go('piazza'); settle(g);
g.act('look', 'manifesto_ingorgo'); settle(g);
assert.equal(g.closeup?.id, 'manifesto_ingorgo', 'il manifesto ingrandito');
assert.ok(!g.closeupTap(), 'mentre si apre, un tocco non conta');
g.update(POSTER_READY); assert.ok(g.closeupTap()); settle(g);
assert.equal(g.closeup?.id, 'ingorgo', 'l\'effetto speciale');
spell(g);
return g;
};
test('la miscela delle console: vicino a una si sente quella, fra due tutte e due, mai il silenzio', () => {
const stages = [{ at: [100, 300], music: 'a.mp3' }, { at: [600, 300], music: 'b.mp3' }];
const near = mix(stages, [110, 300]);
assert.equal(near[0].src, 'a.mp3'); assert.equal(near[0].gain, 1);
assert.ok(near[1].gain < 0.3 && near[1].gain >= 0.08, 'l\'altra arriva da lontano, ma c\'è');
const mid = mix(stages, [350, 300]);
assert.ok(Math.abs(mid[0].gain - mid[1].gain) < 1e-9, 'a metà strada, pari');
// Le console contano solo quando vale la loro condizione; spente, la scena torna al suo brano.
const s = { music: { game: 'g.mp3' }, scenes: { p: { stages: stages.map(st => ({ ...st, when: 'festa' })) } } };
assert.equal(trackFor(s, { screen: 'game', scene: 'p', at: [110, 300] }), 'g.mp3');
const on = trackFor(s, { screen: 'game', scene: 'p', at: [110, 300], ok: cond => cond === 'festa' });
assert.deepEqual(on.map(t => t.src), ['a.mp3', 'b.mp3']);
});
test('la musica suona più brani insieme e li fa sfumare quando si cambia piazza', () => {
globalThis.Audio = class { constructor(src) { this.src = src; this.volume = 0; this.paused = true; } play() { this.paused = false; return Promise.resolve(); } pause() { this.paused = true; } };
const m = new Music({ volume: 0.5 });
for (let i = 0; i < 40; i++) m.update([{ src: 'a.mp3', gain: 1 }, { src: 'b.mp3', gain: 0.2 }], 0.1);
const [a, b] = [m.tracks.get('a.mp3').audio, m.tracks.get('b.mp3').audio];
assert.equal(a.volume, 0.5); assert.ok(Math.abs(b.volume - 0.1) < 1e-9);
assert.equal(m.current.src, 'a.mp3', 'la più forte è quella principale');
for (let i = 0; i < 40; i++) m.update('c.mp3', 0.1);
assert.ok(a.paused && b.paused, 'le console lasciate sfumano e si fermano');
assert.deepEqual([...m.tracks.keys()], ['c.mp3']);
delete globalThis.Audio;
});
test('l\'incantesimo: i battiti accelerano, il lampo, il cambio a bianco pieno, i coriandoli', () => {
const c = newIncanto('ingorgo'), seen = [];
let swapAt = null;
for (let i = 0; i < 400; i++) {
const now = updateIncanto(c, 1 / 60);
if (now === 'swap') swapAt = c.t;
if (now) seen.push(now);
if (now === 'end') break;
}
assert.deepEqual(seen, ['swap', 'end']);
assert.ok(Math.abs(swapAt - T.swap) < 0.02);
assert.equal(whiteness(T.swap), 1, 'si cambia quando non si vede niente');
assert.equal(whiteness(T.end), 0);
const kicks = c.events.filter(e => e.kind === 'incanto-cassa');
assert.ok(kicks.length >= 6 && c.events.some(e => e.kind === 'incanto-lampo'));
const calls = [], ctx = new Proxy({}, { get: (o, k) => k in o ? o[k] : (...args) => calls.push([k, ...args]), set: (o, k, v) => { o[k] = v; return true; } });
for (const t of [0.5, 2.0, 3.2]) { calls.length = 0; drawIncanto(ctx, { ...c, t }); assert.ok(calls.some(([op]) => op === 'fillRect')); }
});
test('dal manifesto in piazza all\'Ingorgo: la battuta, l\'effetto, la festa; uscendo dalla zona si torna a maggio', () => {
const g = fromPiazza();
assert.ok(g.state.flags.ingorgo, 'la festa c\'è');
assert.equal(g.phase().id, 'ingorgo');
assert.notEqual(g.backdrop(), story.scenes.piazza.background, 'la piazza della festa');
assert.ok(!g.hotspots().some(h => h.id === 'manifesto_ingorgo'), 'il manifesto, alla festa, non serve');
// Si gira la festa: in ogni posto della zona la festa resta, la musica è la miscela delle console.
for (const scene of ZONE.scenes) {
g.go(scene); settle(g);
assert.ok(g.state.flags.ingorgo, scene);
assert.notEqual(g.backdrop(), story.scenes[scene].background, `${scene}: lo sfondo della festa`);
const track = trackFor(story, { screen: 'game', scene, at: [g.state.x, g.state.y], ok: c => check(c, g.state) });
assert.ok(Array.isArray(track) && track.length >= 2, `${scene}: più console insieme`);
}
g.go('via_cipriani'); settle(g);
assert.ok(!g.state.flags.ingorgo, 'fuori dalla zona la festa sparisce');
assert.equal(g.backdrop(), story.scenes.via_cipriani.background);
});
test('chiudendo l\'effetto prima del lampo, la festa arriva lo stesso', () => {
const g = new Game(story); g.go('piazza'); settle(g);
g.act('look', 'manifesto_ingorgo'); settle(g);
g.update(POSTER_READY); g.closeupTap(); settle(g);
assert.equal(g.closeup?.id, 'ingorgo');
g.closeupClose(); settle(g);
assert.ok(g.state.flags.ingorgo);
});
test('il manifesto ingrandito si chiude senza fare niente: niente festa', () => {
const g = new Game(story); g.go('piazza'); settle(g);
g.act('look', 'manifesto_ingorgo'); settle(g);
g.update(1); g.closeupClose(); settle(g);
assert.ok(!g.state.flags.ingorgo && !g.closeup);
});
test('le musiche e gli sfondi della festa esistono', () => {
const files = new Set();
for (const id of ZONE.scenes) {
const sc = story.scenes[id];
for (const st of sc.stages ?? []) files.add(st.music);
for (const b of sc.backdrops ?? []) if (b.when === 'ingorgo') files.add(b.src);
}
files.add(story.closeups.manifesto_ingorgo.picture);
assert.ok(files.size >= 10);
for (const f of files) assert.ok(existsSync(new URL('../' + f, import.meta.url)), `manca ${f}`);
});
+2 -1
View File
@@ -135,7 +135,8 @@ test('le fasi del giorno seguono la storia: pomeriggio, poi tramonto, poi sera,
const g = new Game(story);
g.go(story.start.scene, story.start.at);
settle(g);
const ids = story.phases.map(p => p.id), seen = [g.phase().id];
// La notte dell'Ingorgo è un extra fuori dalla soluzione: la storia non ci passa.
const ids = story.phases.map(p => p.id).filter(id => id !== 'ingorgo'), seen = [g.phase().id];
for (const [verb, target, item] of solution) {
if (verb === 'choose') g.choose(g.options().findIndex(o => o.text === target)); else g.act(verb, target, item);
settle(g, { explore: true });
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""I segnaposto delle console dell'Ingorgo Sonoro, sintetizzati da zero: quattro loop da sedici battute, uno per genere.
Nel gioco ora suonano i brani di Andrea (Suno, 30 ottobre: assets/raw/musica/ingorgo-*-suno.mp3, portati al volume
del gioco in assets/audio/ingorgo-*.mp3); questi restano in assets/raw/musica/ingorgo-*-sintesi.mp3, per prova.
python3 tools/ingorgo_musiche.py # tutti e quattro (serve ffmpeg)
python3 tools/ingorgo_musiche.py house # uno solo
Generi: latino (dembow e piano montuno), house (cassa dritta, accordi d'organo in levare), techno (cassa dura,
linea acida che si apre), underground (minimale scuro, basso che rotola e colpi lontani).
"""
import math
import random
import subprocess
import sys
import tempfile
import wave
from array import array
from pathlib import Path
SR = 22050
AUDIO = Path(__file__).resolve().parent.parent / "assets" / "raw" / "musica"
NAMES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
def hz(note: str) -> float:
semis = NAMES[note[0]] + (1 if "#" in note else -1 if len(note) > 2 and note[1] == "b" else 0)
return 440 * 2 ** ((semis + 12 * (int(note[-1]) + 1) - 69) / 12)
class Track:
def __init__(self, bpm: int, bars: int = 16, seed: int = 1):
self.beat = 60 / bpm
self.buf = [0.0] * int(SR * bars * 4 * self.beat)
self.rng = random.Random(seed)
self.cache = {}
def add(self, beat: float, samples):
# Il loop gira: quello che sborda oltre la fine rientra dall'inizio.
n, i0 = len(self.buf), int(beat * self.beat * SR)
for i, x in enumerate(samples):
self.buf[(i0 + i) % n] += x
def once(self, key, make):
if key not in self.cache:
self.cache[key] = make()
return self.cache[key]
# --- I suoni: la batteria si calcola una volta e si timbra, le note una per una.
def kick(vol=0.9, top=150.0, bottom=45.0, length=0.32, punch=0.03):
out, ph = [], 0.0
for i in range(int(length * SR)):
t = i / SR
f = bottom + (top - bottom) * math.exp(-t / punch)
ph += 2 * math.pi * f / SR
out.append(vol * math.exp(-t / (length / 3.2)) * math.sin(ph))
return out
def noise(rng, vol, length, decay, bright=True):
out, prev = [], 0.0
for i in range(int(length * SR)):
x = rng.uniform(-1, 1)
y = x - prev if bright else (prev + x) * 0.5 # differenza: solo alti; media: più corpo
prev = x
out.append(vol * y * math.exp(-i / SR / decay))
return out
def clap(rng, vol=0.35):
# Tre colpi ravvicinati di rumore e una coda: il battimani.
out = [0.0] * int(0.25 * SR)
for k, d in ((0, 0.006), (0.011, 0.006), (0.022, 0.07)):
for i, x in enumerate(noise(rng, vol, 0.2, d, bright=False)):
j = int(k * SR) + i
if j < len(out):
out[j] += x
return out
def drum(freq, vol, decay, bend=0.0):
# Bongo, conga, tom: un seno che cala un poco.
out, ph = [], 0.0
for i in range(int(decay * 5 * SR)):
t = i / SR
ph += 2 * math.pi * freq * (1 + bend * math.exp(-t / 0.02)) / SR
out.append(vol * math.exp(-t / decay) * math.sin(ph))
return out
def synth(freq, length, vol, wave_="saw", cutoff=1200.0, sweep=0.0, decay=9.0, attack=0.004, release=0.03, detune=0.0, res=0.0):
"""Un oscillatore (sega o quadra, o due appena stonati) dentro un filtro passa basso a due poli, che può aprirsi
e chiudersi (`sweep`, in Hz verso cui va il taglio durante la nota). `res` dà il fischio dell'acido."""
n = int((length + release) * SR)
out, p1, p2 = [], 0.0, 0.0
lo = bp = 0.0
for i in range(n):
t = i / SR
env = min(1.0, t / attack) * math.exp(-t / decay)
if t > length:
env *= max(0.0, 1 - (t - length) / release)
p1 = (p1 + freq / SR) % 1
p2 = (p2 + freq * (1 + detune) / SR) % 1
if wave_ == "square":
x = (1 if p1 < 0.5 else -1) * 0.7
else:
x = (2 * p1 - 1) + ((2 * p2 - 1) if detune else 0)
x *= 0.5 if detune else 1
cut = cutoff + sweep * min(1.0, t / max(length, 1e-3))
f = 2 * math.sin(math.pi * min(cut, SR / 6) / SR)
q = 1 - res
lo += f * bp
hi = x - lo - q * bp
bp += f * hi
out.append(vol * env * lo)
return out
def sine(freq, length, vol, decay=9.0, attack=0.004, release=0.05, partials=((1, 1.0),)):
n = int((length + release) * SR)
out = []
for i in range(n):
t = i / SR
env = min(1.0, t / attack) * math.exp(-t / decay)
if t > length:
env *= max(0.0, 1 - (t - length) / release)
out.append(vol * env * sum(a * math.sin(2 * math.pi * freq * k * t) for k, a in partials))
return out
# --- I quattro generi.
def house():
tr = Track(124, seed=2)
K, H, C = kick(), tr.once("h", lambda: noise(tr.rng, 0.16, 0.08, 0.025)), clap(tr.rng, 0.32)
OH = tr.once("oh", lambda: noise(tr.rng, 0.12, 0.25, 0.09))
chords = [["A3", "C4", "E4", "G4"], ["F3", "A3", "C4", "E4"], ["D3", "F3", "A3", "C4"], ["E3", "G#3", "B3", "D4"]]
roots = ["A1", "F1", "D2", "E1"]
for bar in range(16):
b0, ch, root = bar * 4, chords[bar // 2 % 4], roots[bar // 2 % 4]
for k in range(4):
tr.add(b0 + k, K)
tr.add(b0 + k + 0.5, OH)
for s in (0.25, 0.75):
tr.add(b0 + k + s, H)
for k in (1, 3):
tr.add(b0 + k, C)
# Gli accordi d'organo in levare, corti, il cuore della house.
for s in (0.5, 1.5, 2.75, 3.5):
for note in ch:
tr.add(b0 + s, synth(hz(note), tr.beat * 0.22, 0.05, cutoff=2600, decay=0.25, detune=0.006))
# Il basso: in levare, che salta d'ottava.
for s, octv in ((0.5, 1), (1.5, 2), (2.5, 1), (3.25, 1), (3.5, 2)):
tr.add(b0 + s, synth(hz(root) * octv, tr.beat * 0.3, 0.32, wave_="square", cutoff=500, decay=0.3))
return tr
def techno():
tr = Track(132, seed=3)
K = kick(1.0, top=170, bottom=48, length=0.36, punch=0.022)
H = tr.once("h", lambda: noise(tr.rng, 0.13, 0.05, 0.012))
R = tr.once("r", lambda: noise(tr.rng, 0.1, 0.6, 0.25, bright=False)) # il ride lungo, nella seconda metà
line = ["A1", "A1", "A2", "A1", "C2", "A1", "G2", "A1", "A1", "E2", "A1", "A2", "A1", "C2", "D2", "A1"]
for bar in range(16):
b0 = bar * 4
for k in range(4):
tr.add(b0 + k, K)
tr.add(b0 + k + 0.5, H)
if bar >= 8:
tr.add(b0 + k + 0.5, R)
if bar % 4 == 3:
tr.add(b0 + 3.75, clap(tr.rng, 0.25))
# La linea acida: sedicesimi, il filtro che si apre battuta dopo battuta e poi richiude.
open_ = 300 + 1700 * (0.5 - 0.5 * math.cos(2 * math.pi * bar / 16))
for s, note in enumerate(line):
if s % 4 == 0 or tr.rng.random() < 0.8:
tr.add(b0 + s / 4, synth(hz(note), tr.beat * 0.2, 0.2, wave_="saw", cutoff=open_, sweep=-open_ * 0.6, decay=0.15, res=0.85))
return tr
def underground():
tr = Track(122, seed=4)
K = kick(0.95, top=110, bottom=42, length=0.42, punch=0.04)
H = tr.once("h", lambda: noise(tr.rng, 0.09, 0.04, 0.01))
RIM = drum(1700, 0.12, 0.012)
for bar in range(16):
b0 = bar * 4
for k in range(4):
tr.add(b0 + k, K)
for s in (0.5, 0.75):
tr.add(b0 + k + s, H)
for s in (1.75, 3.25) if bar % 2 else (2.75,):
tr.add(b0 + s, RIM)
# Il basso che rotola sui sedicesimi in levare, sempre la stessa nota: ipnotico.
for k in range(4):
for s in (0.25, 0.5, 0.75):
tr.add(b0 + k + s, synth(hz("F1"), tr.beat * 0.16, 0.24, cutoff=260, decay=0.12))
# Un blip lontano ogni tanto, con l'eco.
if bar % 2 == 1:
note = ["C5", "Ab4", "Eb5", "F4"][bar // 2 % 4]
for rep, v in ((0, 0.07), (0.75, 0.04), (1.5, 0.02)):
tr.add(b0 + 2.5 + rep, sine(hz(note), 0.06, v, decay=0.08, partials=((1, 1), (3, 0.2))))
# Il pad scuro che respira sotto, ogni quattro battute.
if bar % 4 == 0:
for note in ("F2", "Ab2", "C3"):
tr.add(b0, synth(hz(note), tr.beat * 15, 0.03, cutoff=420, sweep=300, decay=99, attack=1.5, release=1.0, detune=0.004))
return tr
def latino():
tr = Track(98, seed=5)
K = kick(0.85, top=130, bottom=50, length=0.3)
SN = tr.once("sn", lambda: [a + b for a, b in zip(noise(tr.rng, 0.22, 0.15, 0.05, bright=False), drum(220, 0.18, 0.05) + [0.0] * 9999)])
H = tr.once("h", lambda: noise(tr.rng, 0.08, 0.05, 0.015))
BONGO = (drum(380, 0.16, 0.06, bend=0.3), drum(520, 0.13, 0.05, bend=0.3))
CONGA = drum(210, 0.2, 0.12, bend=0.2)
chords = [["A3", "C4", "E4"], ["D4", "F4", "A4"], ["E3", "G#3", "B3"], ["A3", "C4", "E4"]]
roots = ["A1", "D2", "E1", "A1"]
for bar in range(16):
b0, ch, root = bar * 4, chords[bar % 4], roots[bar % 4]
# Il dembow: cassa sull'uno e sul tre, rullante sul «tre sedicesimi» e sul due e mezzo.
for k in (0, 2):
tr.add(b0 + k, K)
tr.add(b0 + k + 0.75, SN)
tr.add(b0 + k + 1.5, SN)
for k in range(8):
tr.add(b0 + k / 2, H)
for s, b in ((0.5, 0), (1.0, 1), (2.5, 0), (3.0, 1), (3.5, 1)):
tr.add(b0 + s, BONGO[b])
tr.add(b0 + 3.75, CONGA)
# Il piano montuno: accordi sincopati, voce alta raddoppiata all'ottava.
for s in (0, 0.75, 1.5, 2.5, 3.25):
for note in ch:
tr.add(b0 + s, sine(hz(note), tr.beat * 0.3, 0.05, decay=0.35, partials=((1, 1), (2, 0.4), (3, 0.15), (4, 0.08))))
# Il basso del tumbao: in anticipo sul due e mezzo e sul quattro.
for s, f in ((0, 1), (1.5, 1.5), (3, 1)):
tr.add(b0 + s, synth(hz(root) * f, tr.beat * 0.8, 0.3, wave_="square", cutoff=380, decay=0.6))
return tr
GENRES = {"house": house, "techno": techno, "underground": underground, "latino": latino}
def write(name: str, tr: Track):
peak = max(abs(x) for x in tr.buf) or 1
AUDIO.mkdir(parents=True, exist_ok=True)
out = AUDIO / f"ingorgo-{name}-sintesi.mp3"
with tempfile.TemporaryDirectory() as tmp:
w = Path(tmp) / "loop.wav"
with wave.open(str(w), "wb") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(SR)
f.writeframes(array("h", (int(x / peak * 0.9 * 32767) for x in tr.buf)).tobytes())
# Una piazza all'aperto: un filo d'eco dai muri, e il compressore delle casse.
af = "acompressor=threshold=0.25:ratio=3:attack=5:release=80,aecho=0.85:0.4:90:0.12,loudnorm=I=-18:TP=-2:LRA=7"
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(w), "-af", af, "-c:a", "libmp3lame", "-b:a", "96k", str(out)], check=True)
print(f"-> {out} ({len(tr.buf) / SR:.0f} s)")
if __name__ == "__main__":
for name in sys.argv[1:] or GENRES:
write(name, GENRES[name]())
+6 -2
View File
@@ -36,7 +36,10 @@ def main():
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')
p.add_argument('--fix', default='', help='correzioni di scala per fotogramma, quando nel foglio la figura è disegnata '
'più piccola o più grande: wave=1.3,cheer=1.05')
a = p.parse_args()
fix = {k: float(v) for k, v in (kv.split('=') for kv in a.fix.split(',') if kv)}
frames = {f: Image.open(Path(a.src) / f'{a.name}_{f}.png').convert('RGBA') for f in a.frames.split(',')}
ref = frames[a.ref].getchannel('A').getbbox()
@@ -45,10 +48,11 @@ def main():
for f, im in frames.items():
im = im.crop(im.getchannel('A').getbbox())
cx, base = torso_center(im)
w, h = max(1, round(im.width * factor)), max(1, round(im.height * factor))
k = factor * fix.get(f, 1)
w, h = max(1, round(im.width * k)), max(1, round(im.height * k))
im = im.resize((w, h), Image.LANCZOS)
strip.append(im)
meta[f] = {'x': x, 'y': 0, 'w': w, 'h': h, 'ax': round(cx * factor, 1), 'ay': h}
meta[f] = {'x': x, 'y': 0, 'w': w, 'h': h, 'ax': round(cx * k, 1), 'ay': h}
x += w + 2
sheet = Image.new('RGBA', (x, max(im.height for im in strip)), (0, 0, 0, 0))
for im, m in zip(strip, meta.values()):
+2 -1
View File
@@ -19,5 +19,6 @@
"bluffava": "bleffava",
"Imposto": "Impòsto",
"Libici": "Lìbici",
"mancano": "màncano"
"mancano": "màncano",
"console": "consòl"
}
+3
View File
@@ -73,5 +73,8 @@
},
"taglia": {
"speaker": "Aaron Dreschner"
},
"baldoria": {
"speaker": "Marcos Rudaski"
}
}