diff --git a/docs/STATO.md b/docs/STATO.md
index d1da11b..1bdc026 100644
--- a/docs/STATO.md
+++ b/docs/STATO.md
@@ -1,9 +1,24 @@
# Stato dei lavori
-Ultimo aggiornamento: 24 ottobre 2026. Da leggere insieme a `PROGETTO.md` all'inizio di ogni sessione.
+Ultimo aggiornamento: 25 ottobre 2026. Da leggere insieme a `PROGETTO.md` all'inizio di ogni sessione.
## Dove siamo arrivati
+- **Ambienti animati (25 ottobre)**: alle prove già presenti (bandiere del campeggio, fascio di luce nella Pieve,
+ ombre al Bosco ai Frati) si aggiungono 51 dettagli nelle altre 35 scene. Configurazione in
+ `src/story/ambience.js`, richiamata da `story.js`; nuovi disegni in `src/engine/ambient.js`:
+ piccioni in piazza, merli sui prati, gatto nel vicolo, farfalle, libellula sul ponticino, falena alla festa,
+ increspature nelle pozze e nel torrente, gocce della fontanella, tende, passaggi dietro le vetrate,
+ ombre di nuvole sul sentiero e sulle vigne. Riutilizzati anche ombre delle fronde, stoffe, finestre e lampioni.
+ Le ombre delle fronde ora sono piccoli gruppi irregolari, senza gli ovali in fila che sembravano sassi.
+ Tutte le 38 scene hanno almeno un effetto; quelli diurni si spengono di sera e gli animali fanno lunghe pause.
+ Acqua, tende e vetrate usano ritagli locali; nessun asset aggiunto, nessun nuovo hotspot o flag narrativo.
+ Le animazioni complesse di altalene/reti e il movimento isolato di grandi fronde restano da fare con elementi
+ grafici separati: per ora quei luoghi hanno ombre e piccoli animali, senza deformare il fondale.
+ Verificati `npm test`, `npm run browser` (partita completa e telefono), `npm run browser:effects`
+ (38 scene × 3 fasi × 4 istanti, ritagli, giorno/sera, pause degli animali, salvataggio invariato).
+ Le immagini di controllo sono in `/tmp/regolo-effects`; si rigenerano con `npm run browser:effects`.
+
**Si gioca** in Docker su http://localhost:8080 (`make up`; Manzolo Rush è stato fermato per liberare la porta) e,
**online dal 2026-10-23**, su https://50037.manzolo.it (50037 è il CAP di San Piero), finché non va su GitHub Pages.
Si aggiorna con `make deploy`: pubblica l'ultimo commit, immagine costruita e provata sul PC e spedita al container
diff --git a/package.json b/package.json
index 915c39b..241f448 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"scripts": {
"test": "node --test tests/*.test.mjs",
"browser": "node tests/browser.mjs",
+ "browser:effects": "node tests/ambient.browser.mjs",
"serve": "python3 -m http.server 8000"
},
"devDependencies": {
diff --git a/src/engine/ambient.js b/src/engine/ambient.js
new file mode 100644
index 0000000..e74bc34
--- /dev/null
+++ b/src/engine/ambient.js
@@ -0,0 +1,159 @@
+// Piccoli movimenti ambientali: nessun timer, stato di gioco o asset aggiuntivo.
+// L'orologio arriva dal renderer; lo stesso istante produce sempre lo stesso fotogramma.
+const TAU = Math.PI * 2;
+export const ambientHash = n => {
+ let x = Math.imul((n | 0) ^ 0x9e3779b9, 0x21f0aaad);
+ x = Math.imul(x ^ (x >>> 16), 0x735a2d97);
+ return ((x ^ (x >>> 15)) >>> 0) / 4294967296;
+};
+const hash = ambientHash;
+const mix = (a, b, t) => a + (b - a) * t;
+
+// Un evento breve seguito da una vera pausa. Gli offset evitano che tutti gli animali
+// entrino insieme; la dissolvenza serve solo ai bordi della loro piccola area.
+export function ambientMoment(time, { every = 32000, duration = 10000, offset = 0 } = {}) {
+ const elapsed = ((time + offset) % every + every) % every;
+ if (elapsed >= duration) return null;
+ const t = elapsed / duration;
+ return { t, alpha: Math.min(1, t * 12, (1 - t) * 12) };
+}
+
+function pixel(ctx, x, y, w = 1, h = 1) { ctx.fillRect(Math.round(x), Math.round(y), w, h); }
+
+export const ambientEffects = {
+ // Tratti orizzontali morbidi che seguono la corrente. Il poligono `clip` della
+ // scena lascia asciutti sassi, rive e tronchi: nessun luccichio da oggetto raccoglibile.
+ ripples(ctx, e, time) {
+ const [ax, ay, w, h] = e.area, life = e.life ?? 4200;
+ for (let i = 0; i < (e.count ?? 10); i++) {
+ const t = ((time + hash(i * 7 + 3) * life) % life) / life;
+ const x = ax + hash(i * 13 + 5) * w + t * (e.flow?.[0] ?? 6);
+ const y = ay + hash(i * 19 + 1) * h + t * (e.flow?.[1] ?? 3);
+ const length = 3 + hash(i * 11) * 10;
+ ctx.globalAlpha = Math.sin(t * Math.PI) * 0.32;
+ ctx.fillStyle = '#d3e4d1';
+ pixel(ctx, x, y, Math.round(length));
+ ctx.fillStyle = '#344c40';
+ pixel(ctx, x + 2, y + 1, Math.max(2, Math.round(length - 3)));
+ }
+ },
+ // Farfalla, libellula o falena: una sola alla volta, pochi pixel, con soste lunghe.
+ insect(ctx, e, time) {
+ const moment = ambientMoment(time, e);
+ if (!moment) return;
+ const { t, alpha } = moment, [ax, ay, w, h] = e.area;
+ const moth = e.kind === 'moth', dragonfly = e.kind === 'dragonfly';
+ const x = ax + w * (moth ? 0.5 + 0.34 * Math.sin(t * TAU * 3) : 0.08 + t * 0.84);
+ const y = ay + h * (0.5 + 0.3 * Math.sin(t * TAU * (moth ? 2 : 1.5)));
+ const wing = Math.sin(time / (dragonfly ? 24 : moth ? 38 : 85));
+ ctx.globalAlpha = alpha * (moth ? 0.7 : 0.9);
+ ctx.fillStyle = e.fill ?? (moth ? '#d6bd8a' : dragonfly ? '#9cb6ac' : '#e5cb80');
+ if (dragonfly) {
+ pixel(ctx, x - 3, y + (wing > 0 ? -1 : 1), 7);
+ ctx.fillStyle = '#416c6b';
+ pixel(ctx, x, y - 1, 1, 4);
+ } else {
+ const span = wing > 0 ? 2 : 1;
+ pixel(ctx, x - span, y - 1, span, 2);
+ pixel(ctx, x + 1, y - 1, span, 2);
+ ctx.fillStyle = '#60513b';
+ pixel(ctx, x, y, 1, 2);
+ }
+ },
+ // Piccioni/merli: pochi passi, una beccata, una sosta. La dimensione resta quella
+ // di un animale lontano, senza trasformarlo in un nuovo personaggio interattivo.
+ groundbird(ctx, e, time) {
+ const moment = ambientMoment(time, e);
+ if (!moment) return;
+ const [ax, ay, w, h] = e.area, { t, alpha } = moment;
+ const step = t * 8, segment = Math.floor(step), within = step % 1;
+ const walk = Math.min(1, within / 0.42), peck = within > 0.65 && within < 0.85;
+ const x = ax + w * (0.1 + (segment + walk) / 10), y = ay + h * 0.55;
+ ctx.save();
+ ctx.globalAlpha = alpha;
+ ctx.translate(Math.round(x), Math.round(y));
+ ctx.scale(e.facing ?? 1, 1);
+ ctx.fillStyle = 'rgba(25,25,20,0.18)';
+ ctx.fillRect(-4, 0, 9, 2);
+ ctx.fillStyle = e.kind === 'blackbird' ? '#393c32' : '#69717b';
+ ctx.fillRect(-3, -5, 6, 4);
+ ctx.fillRect(-5, -5, 3, 1);
+ ctx.fillRect(1, peck ? -3 : -8, 3, 3);
+ ctx.fillStyle = e.kind === 'blackbird' ? '#bca455' : '#b5b5a6';
+ ctx.fillRect(4, peck ? -1 : -6, 2, 1);
+ ctx.fillStyle = e.kind === 'blackbird' ? '#53564b' : '#9299a0';
+ ctx.fillRect(-2, -4, 3, 2);
+ ctx.fillStyle = '#665249';
+ const stride = within < 0.42 ? Math.round(Math.sin(time / 95)) : 0;
+ ctx.fillRect(-2 + stride, -1, 1, 2);
+ ctx.fillRect(2 - stride, -1, 1, 2);
+ ctx.restore();
+ },
+ // Il gatto attraversa il fondo del vicolo, poi lascia la scena vuota per quasi un minuto.
+ cat(ctx, e, time) {
+ const moment = ambientMoment(time, e);
+ if (!moment) return;
+ const { t, alpha } = moment, [a, b] = e.points;
+ ctx.save();
+ ctx.globalAlpha = alpha;
+ ctx.translate(Math.round(mix(a[0], b[0], t)), Math.round(mix(a[1], b[1], t)));
+ ctx.scale(b[0] < a[0] ? -1 : 1, 1);
+ ctx.fillStyle = 'rgba(20,20,20,0.16)';
+ ctx.fillRect(-8, 0, 18, 2);
+ ctx.fillStyle = '#50493e';
+ ctx.fillRect(-7, -7, 13, 5);
+ ctx.fillRect(4, -10, 5, 5);
+ ctx.fillRect(4, -12, 1, 3); ctx.fillRect(8, -12, 1, 3);
+ const step = Math.round(Math.sin(time / 110) * 2);
+ ctx.fillRect(-5 + step, -3, 2, 4); ctx.fillRect(4 - step, -3, 2, 4);
+ ctx.fillRect(-10, -9, 2, 5); ctx.fillRect(-9, -6, 3, 2);
+ ctx.fillStyle = '#8a7d65'; ctx.fillRect(-5, -7, 8, 1);
+ ctx.restore();
+ },
+ // Una goccia dalla fontanella, poi due piccoli archi nel punto d'impatto.
+ drip(ctx, e, time) {
+ const [x, y] = e.at, height = e.height ?? 20, t = (time % (e.every ?? 2600)) / (e.every ?? 2600);
+ ctx.fillStyle = 'rgba(190,219,217,0.8)';
+ if (t < 0.5) pixel(ctx, x, y + height * (t * 2) ** 2, 1, 2);
+ else if (t < 0.8) {
+ const k = (t - 0.5) / 0.3;
+ ctx.globalAlpha = 1 - k;
+ pixel(ctx, x - 1 - k * 3, y + height, 2);
+ pixel(ctx, x + k * 3, y + height + 1, 2);
+ }
+ },
+ // Tende dentro il vano della finestra: il clip le tiene lontane da cornici e persiane.
+ curtain(ctx, e, time) {
+ const [x, y, w, h] = e.area, wave = Math.sin(time / 1600 + x) * 1.2;
+ ctx.fillStyle = 'rgba(235,223,192,0.52)';
+ for (let row = 0; row < h; row++) {
+ const hem = Math.sin(row / h * Math.PI) * wave;
+ pixel(ctx, x, y + row, Math.max(1, Math.round(w * 0.2 + hem)));
+ pixel(ctx, x + w * 0.78 + hem, y + row, Math.max(1, Math.round(w * 0.22 - hem)));
+ }
+ },
+ // Vita dietro un vetro: luce tenue e un passaggio occasionale, sempre ritagliato
+ // all'interno della vetrata. Nessun nuovo personaggio o interazione nella storia.
+ windowlife(ctx, e, time) {
+ const [x, y, w, h] = e.area;
+ ctx.fillStyle = e.fill ?? 'rgba(255,192,105,0.12)';
+ ctx.fillRect(x, y, w, h);
+ const moment = ambientMoment(time, e);
+ if (!moment) return;
+ const px = x - 8 + moment.t * (w + 16), py = y + h * 0.3;
+ ctx.globalAlpha = moment.alpha * 0.3;
+ ctx.fillStyle = '#382e28';
+ pixel(ctx, px, py, 4, 5);
+ pixel(ctx, px - 2, py + 5, 8, Math.round(h * 0.6));
+ },
+ // Un'ombra larga di nuvola che attraversa lentamente il sentiero, con margini sfumati.
+ cloudshadow(ctx, e, time) {
+ const [x, y, w, h] = e.area, t = ((time + (e.offset ?? 0)) % 52000) / 52000;
+ const cx = x - w * 0.5 + t * w * 2;
+ const g = ctx.createRadialGradient(cx, y + h * 0.5, 0, cx, y + h * 0.5, w * 0.55);
+ g.addColorStop(0, 'rgba(30,37,48,0.13)');
+ g.addColorStop(1, 'rgba(30,37,48,0)');
+ ctx.fillStyle = g;
+ ctx.fillRect(x, y, w, h);
+ },
+};
diff --git a/src/engine/render.js b/src/engine/render.js
index ae076cd..9f6f7d1 100644
--- a/src/engine/render.js
+++ b/src/engine/render.js
@@ -1,6 +1,7 @@
// 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 { ambientEffects, ambientHash } from './ambient.js';
export const WIDTH = 640, HEIGHT = 360;
function drawShape(ctx, s) {
@@ -258,6 +259,7 @@ const hash = n => (((n | 0) * 2654435761) >>> 0) / 4294967296;
// Gli effetti delle scene (`effects`), ognuno col suo `type`: piccole animazioni disegnate sopra lo sfondo.
export const EFFECTS = {
+ ...ambientEffects,
// Lucine dei festoni: ognuna si accende e si spegne per conto suo, coi suoi colori.
bulbs(ctx, e, time) {
const colors = e.colors ?? ['#ff5a5a', '#ffd25a', '#5ad2ff', '#7dff6a'];
@@ -345,6 +347,8 @@ export const EFFECTS = {
// Le finestre che si accendono di sera, una dopo l'altra, appena si entra nella scena.
windows(ctx, e, time, game) {
const since = ((game.time ?? 0) - (game.enteredAt ?? 0)) * 1000;
+ ctx.globalAlpha = e.opacity ?? 1;
+ ctx.globalCompositeOperation = e.blend ?? 'source-over';
e.rects.forEach(([x, y, w, h], i) => {
if (since < 400 + hash(i * 7 + 3) * (e.over ?? 3500)) return;
ctx.fillStyle = 'rgba(255, 196, 100, 0.72)';
@@ -355,9 +359,10 @@ export const EFFECTS = {
},
// Rondini: ogni pochi secondi un gruppetto attraversa il cielo in diagonale, a «V» con le ali che battono.
birds(ctx, e, time) {
- const [ax, ay, aw, ah] = e.area, every = e.every ?? 6000, run = Math.floor(time / every), t = (time % every) / every;
- if (t > 0.6) return;
- const k = t / 0.6, rtl = hash(run * 13) < 0.5;
+ const [ax, ay, aw, ah] = e.area, every = e.every ?? 6000, duration = e.duration ?? every * 0.6;
+ const elapsed = (time + (e.offset ?? 0)) % every, run = Math.floor((time + (e.offset ?? 0)) / every);
+ if (elapsed >= duration) return;
+ const k = elapsed / duration, rtl = hash(run * 13) < 0.5;
for (let i = 0; i < (e.count ?? 4); i++) {
const lag = i * 0.05, kk = k - lag;
if (kk < 0) continue;
@@ -411,16 +416,23 @@ export const EFFECTS = {
lc.globalCompositeOperation = 'source-over';
ctx.drawImage(layer, 0, 0);
},
- // Le ombre delle fronde sul terreno: macchie scure e chiare che ondeggiano piano col vento, come sotto un albero.
+ // Ombre di foglie in piccoli gruppi irregolari. Il seme mescolato evita file di
+ // ovali equidistanti (che sembravano sassi); niente rettangoli di sfondo deformati.
leafshadow(ctx, e, time) {
const [ax, ay, aw, ah] = e.area, t = time / 1000;
+ ctx.globalAlpha = e.opacity ?? 1;
for (let i = 0; i < (e.count ?? 14); i++) {
- const bx = ax + hash(i * 7) * aw, by = ay + hash(i * 13) * ah, r = 3 + hash(i * 3) * (e.size ?? 6);
- const x = bx + Math.sin(t * 0.9 + i) * 2 + Math.sin(t * 0.37 + i * 2) * 2, y = by + Math.sin(t * 0.7 + i * 3) * 1;
- ctx.fillStyle = i % 3 ? 'rgba(30, 20, 45, 0.24)' : 'rgba(255, 235, 170, 0.18)';
- for (let dy = -r; dy <= r; dy += 1) {
- const half = Math.round(Math.sqrt(r * r - dy * dy) * 1.6);
- ctx.fillRect(Math.round(x - half), Math.round(y + dy / 2), half * 2, 1);
+ const bx = ax + ambientHash(i * 7 + ax) * aw, by = ay + ambientHash(i * 13 + ay) * ah;
+ const x = bx + Math.sin(t * 0.9 + i) * 2 + Math.sin(t * 0.37 + i * 2), y = by + Math.sin(t * 0.7 + i * 3);
+ for (let leaf = 0; leaf < 4; leaf++) {
+ const n = i * 31 + leaf * 7, dx = (ambientHash(n + 3) - 0.5) * 22, dy = (ambientHash(n + 9) - 0.5) * 9;
+ const w = 3 + ambientHash(n + 21) * (e.size ?? 7), h = 1.5 + ambientHash(n + 4) * 2.5;
+ const px = x + dx, py = y + dy;
+ ctx.fillStyle = leaf === 3 ? 'rgba(255,235,170,0.10)' : 'rgba(30,29,40,0.15)';
+ ctx.beginPath();
+ ctx.moveTo(px - w, py); ctx.lineTo(px - w * 0.2, py - h);
+ ctx.lineTo(px + w * 0.6, py - h * 0.5); ctx.lineTo(px + w, py + h * 0.3);
+ ctx.lineTo(px, py + h); ctx.closePath(); ctx.fill();
}
}
},
@@ -468,10 +480,24 @@ export const EFFECTS = {
ctx.restore();
},
};
-const BACKGROUND_EFFECTS = new Set(['sway', 'clouds', 'cloth', 'leafshadow']);
+const BACKGROUND_EFFECTS = new Set(['sway', 'clouds', 'cloth', 'leafshadow', ...Object.keys(ambientEffects)]);
function drawEffects(ctx, game, time, background = false) {
- for (const e of game.scene.effects ?? [])
- if (BACKGROUND_EFFECTS.has(e.type) === background && onNow(e, game)) EFFECTS[e.type]?.(ctx, e, time, game);
+ for (const e of game.scene.effects ?? []) {
+ if (BACKGROUND_EFFECTS.has(e.type) !== background || !onNow(e, game)) continue;
+ ctx.save();
+ // Acqua solo nell'acqua, tende e sagome solo dietro il vetro; il ritaglio può
+ // seguire una riva obliqua senza toccare il terreno o gli oggetti degli enigmi.
+ if (e.clip) {
+ ctx.beginPath();
+ e.clip.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
+ ctx.closePath();
+ ctx.clip();
+ }
+ // Gli eventi nuovi seguono il tempo giocato: pausa e scheda nascosta non li fanno saltare.
+ const at = ambientEffects[e.type] ? (game.time ?? time / 1000) * 1000 : time;
+ EFFECTS[e.type]?.(ctx, e, at, game);
+ ctx.restore();
+ }
}
// Le scene buie (`shade`: il velo scuro sopra tutto, Andrea compreso) e le fiammelle (`lights`: candele accese).
diff --git a/src/story/ambience.js b/src/story/ambience.js
new file mode 100644
index 0000000..38144e2
--- /dev/null
+++ b/src/story/ambience.js
@@ -0,0 +1,71 @@
+// Dettagli ambientali, separati dagli enigmi. Coordinate nello sfondo 640×360.
+// Le tre prove (Bosco ai Frati, campeggio, interno Pieve) restano in story.js.
+const day = ['pomeriggio', 'tramonto'];
+const rect = ([x, y, w, h]) => [[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
+const shade = (area, count = 10) => ({ type: 'leafshadow', phase: day, area, count, opacity: 0.55, clip: rect(area) });
+const butterfly = (area, offset = 0) => ({ type: 'insect', phase: day, area, every: 34000, duration: 10500, offset });
+const curtain = area => ({ type: 'curtain', phase: day, area, clip: rect(area) });
+const bird = (area, offset = 0, kind = 'pigeon') => ({ type: 'groundbird', phase: day, area, every: 42000, duration: 17000, offset, kind });
+
+export const ambience = {
+ piazza: [bird([165, 277, 58, 20], 7000), bird([245, 286, 38, 20], 2000)],
+ fortezza: [shade([25, 295, 135, 44], 9)],
+ pieve: [{ type: 'birds', phase: day, area: [195, 16, 95, 30], count: 2, every: 27000, duration: 4300, offset: 9000,
+ clip: [[160, 0], [268, 0], [268, 66], [160, 66]] }],
+ oratorio: [bird([366, 139, 28, 12], 11000), shade([430, 278, 150, 45], 8)],
+ via_de_medici: [{ type: 'cat', phase: day, points: [[264, 250], [399, 255]], every: 57000, duration: 9500, offset: 3000 }],
+ comune: [shade([20, 268, 240, 70], 16)],
+ giardini_poste: [butterfly([80, 222, 145, 42], 6000), shade([340, 262, 150, 60], 8)],
+ via_calimara: [shade([52, 263, 190, 64], 14)],
+ via_provinciale: [
+ { type: 'cloth', pole: 'left', rects: [[168, 198, 55, 5], [230, 201, 49, 5]], amount: 0.8,
+ clip: [[166, 197], [282, 197], [282, 208], [166, 208]] },
+ { type: 'lamps', phase: 'sera', points: [[384, 198]], glow: 7 },
+ ],
+ area_feste: [butterfly([280, 225, 150, 40], 9000), shade([460, 260, 130, 35], 7)],
+ ponte_sieve: [shade([20, 280, 175, 55], 11), { type: 'lamps', phase: 'sera', points: [[62, 39], [271, 62], [293, 136], [533, 115]], glow: 12 }],
+ polisportiva: [shade([220, 280, 135, 55], 10), butterfly([380, 251, 100, 28], 18000)],
+ polisportiva_principale: [bird([230, 237, 76, 18], 12000, 'blackbird')],
+ forno: [{ type: 'windowlife', area: [162, 164, 66, 55], every: 47000, duration: 7000, offset: 1000,
+ clip: [[162, 164], [228, 164], [228, 218], [162, 218]] }],
+ stazione: [shade([20, 270, 200, 55], 12),
+ { type: 'windowlife', area: [353, 219, 15, 20], every: 51000, duration: 6500, offset: 14000,
+ clip: rect([353, 219, 15, 20]), fill: 'rgba(255,192,105,0.16)' }],
+ bivio_provinciale: [shade([320, 300, 80, 35], 6), butterfly([335, 232, 105, 30], 20000)],
+ provinciale_viale: [shade([55, 274, 240, 62], 14)],
+ verso_luna: [
+ { type: 'ripples', area: [375, 287, 225, 42], count: 15, flow: [8, 2],
+ clip: [[366, 303], [402, 296], [429, 286], [479, 288], [519, 307], [597, 324], [500, 330], [456, 321], [400, 312]] },
+ { type: 'ripples', area: [428, 222, 42, 18], count: 4, flow: [2, 2],
+ clip: [[428, 223], [449, 222], [469, 235], [445, 240]] },
+ ],
+ campomigliaio_strada: [shade([458, 296, 140, 42], 10)],
+ campomigliaio_circolo: [{ type: 'drip', at: [379, 232], height: 27, every: 2900 }],
+ campomigliaio_ponte: [
+ { type: 'ripples', area: [157, 209, 180, 87], count: 15, flow: [12, 6],
+ clip: [[159, 211], [231, 213], [286, 226], [310, 247], [329, 280], [310, 294], [259, 281], [213, 253], [169, 234]] },
+ { type: 'ripples', area: [402, 321, 163, 39], count: 9, flow: [12, 4],
+ clip: [[430, 323], [467, 337], [494, 337], [566, 359], [402, 359], [409, 342]] },
+ ],
+ tennis_chiesa: [butterfly([410, 294, 120, 32], 4000), shade([490, 305, 140, 36], 8)],
+ casa_maestro_bacci: [{ type: 'cloudshadow', phase: day, area: [172, 215, 450, 140], offset: 19000,
+ clip: [[172, 215], [370, 234], [622, 309], [622, 355], [315, 296]] }],
+ area_feste_festa: [
+ { type: 'cloth', pole: 'left', amount: 0.9, rects: [[190, 109, 8, 9], [224, 118, 7, 9], [262, 124, 7, 9], [598, 120, 9, 10], [610, 117, 9, 11]] },
+ { type: 'insect', kind: 'moth', phase: 'sera', area: [148, 80, 28, 24], every: 29000, duration: 9000, offset: 4500 },
+ ],
+ villa_adami: [curtain([300, 120, 12, 26])],
+ salita_fortezza: [shade([500, 308, 135, 36], 9),
+ { type: 'leaves', phase: day, area: [560, 242, 55, 70], count: 1, colors: ['#8a9e48'] }],
+ salita_villa_adami: [shade([460, 298, 170, 45], 10)],
+ carza: [shade([55, 275, 210, 60], 12)],
+ ponticino: [{ type: 'insect', kind: 'dragonfly', phase: day, area: [90, 240, 100, 44], every: 37000, duration: 8500, offset: 7000 }],
+ area_camper: [shade([30, 242, 200, 65], 14), butterfly([245, 240, 90, 30], 21000)],
+ campo_giannini: [bird([220, 262, 100, 25], 6000, 'blackbird')],
+ cardetole: [curtain([450, 121, 10, 18]),
+ { type: 'windows', phase: 'sera', rects: [[450, 121, 10, 18], [496, 111, 12, 15], [450, 169, 10, 12]], over: 6500, opacity: 0.5, blend: 'screen' }],
+ scuola: [shade([191, 260, 130, 47], 9)],
+ viale_cipressi: [{ type: 'cloudshadow', phase: day, area: [95, 225, 435, 135], offset: 11000,
+ clip: [[300, 225], [337, 225], [530, 360], [95, 360]] }],
+ trebbio: [shade([278, 277, 112, 23], 6)],
+};
diff --git a/src/story/story.js b/src/story/story.js
index b529f1c..bd3b290 100644
--- a/src/story/story.js
+++ b/src/story/story.js
@@ -1,6 +1,7 @@
// Storia del prototipo: le scene del paese a gradini (docs/PROGETTO.md, «La mappa») per collaudare il motore.
// Testi, personaggi ed enigmi sono provvisori e verranno sostituiti con i ricordi di Andrea (docs/PROGETTO.md).
// Le forme in `art` sono segnaposto disegnati sopra lo sfondo (vecchietti, squama, catena) in attesa degli sprite.
+import { ambience } from './ambience.js';
// Gli indizi del Trebbio per la catena della torcia (dopo le impronte, finché la torcia non c'è): uno per tappa,
// dalla più avanti alla prima.
@@ -1928,3 +1929,6 @@ export const story = {
},
},
};
+
+// Solo decorazione: nessun hotspot, flag o modifica ai salvataggi.
+for (const [id, effects] of Object.entries(ambience)) story.scenes[id].effects = [...(story.scenes[id].effects ?? []), ...effects];
diff --git a/tests/ambient.browser.mjs b/tests/ambient.browser.mjs
new file mode 100644
index 0000000..745bf6c
--- /dev/null
+++ b/tests/ambient.browser.mjs
@@ -0,0 +1,104 @@
+// Controllo grafico degli ambienti, senza titolo/audio: tutte le scene nelle tre fasi,
+// 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 { 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' });
+let browser;
+try {
+ browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
+ const page = await browser.newPage({ viewport: { width: 640, height: 360 } });
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ page.on('response', r => { if (r.status() >= 400) errors.push(`${r.status()} ${r.url()}`); });
+ // La pagina vive sullo stesso server del gioco, ma non avvia main.js e il suo RAF.
+ await page.route('**/__effects', route => route.fulfill({ contentType: 'text/html', body: '' }));
+ await page.goto(`http://127.0.0.1:${port}/__effects`);
+ await page.evaluate(async () => {
+ const [{ story }, { Game }, renderer, { ambience }] = await Promise.all([
+ import('/src/story/story.js'), import('/src/engine/game.js'), import('/src/engine/render.js'), import('/src/story/ambience.js'),
+ ]);
+ // Attende davvero il server prima delle immagini, senza dipendere da una pausa fissa.
+ const loaded = await renderer.loadAssets(story);
+ if (loaded.some(ok => !ok)) throw new Error('Uno sfondo/personaggio non è arrivato');
+ await Promise.all(Array.from({ length: 8 }, (_, i) => renderer.loadBackground(`assets/effects/nuvola-${i + 1}.png`)));
+ Object.assign(window, { story, Game, renderer, ambience, ctx: document.querySelector('canvas').getContext('2d') });
+ });
+ const scenes = await page.evaluate(() => Object.keys(story.scenes));
+ for (const phase of ['pomeriggio', 'tramonto', 'sera']) {
+ for (const id of scenes) {
+ const result = await page.evaluate(({ id, phase }) => {
+ const g = new Game(story), sc = story.scenes[id];
+ g.state.scene = id;
+ [g.state.x, g.state.y] = sc.entry;
+ g.state.flags = phase === 'sera' ? { casetta_aperta: true } : phase === 'tramonto' ? { indizio_regolo: true } : {};
+ g.enteredAt = 0;
+ const saved = JSON.stringify(g.snapshot()), times = [8, 15, 28, 48];
+ let first;
+ for (const time of times) {
+ g.time = time;
+ renderer.render(ctx, g, { time: time * 1000 });
+ if (time === 8) first = ctx.canvas.toDataURL();
+ if (ctx.globalAlpha !== 1 || !ctx.getTransform().isIdentity) throw new Error(`${id}: stato canvas non ripristinato`);
+ }
+ return { first, last: ctx.canvas.toDataURL(), unchanged: JSON.stringify(g.snapshot()) === saved };
+ }, { id, phase });
+ assert.ok(result.unchanged, `${id}: gli effetti modificano il salvataggio`);
+ writeFileSync(`${out}/${id}-${phase}.png`, Buffer.from(result.first.split(',')[1], 'base64'));
+ if (phase === 'pomeriggio') writeFileSync(`${out}/${id}-later.png`, Buffer.from(result.last.split(',')[1], 'base64'));
+ }
+ }
+ const checked = await page.evaluate(() => {
+ let clips = 0, phases = 0, pauses = 0;
+ const scene = { entry: [620, 355], effects: [], hotspots: [] };
+ const testStory = { ...story, start: { scene: 'test', at: scene.entry }, scenes: { test: scene } };
+ const g = new Game(testStory);
+ const pixels = effects => {
+ scene.effects = effects;
+ renderer.render(ctx, g, { time: g.time * 1000 });
+ return ctx.getImageData(0, 0, 640, 360).data;
+ };
+ const changed = (a, b) => { for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return true; return false; };
+ const mask = document.createElement('canvas'); mask.width = 640; mask.height = 360;
+ const mc = mask.getContext('2d');
+ for (const [id, effects] of Object.entries(ambience)) for (const e of effects) {
+ g.time = ((e.every ?? 0) + (e.duration ?? 12000) * 0.5 - (e.offset ?? 0)) / 1000;
+ g.state.flags = e.phase === 'sera' ? { casetta_aperta: true } : {};
+ const before = pixels([]), after = pixels([e]);
+ if (e.clip) {
+ mc.clearRect(0, 0, 640, 360); mc.beginPath();
+ e.clip.forEach(([x, y], i) => i ? mc.lineTo(x, y) : mc.moveTo(x, y));
+ mc.closePath(); mc.fill();
+ const allowed = mc.getImageData(0, 0, 640, 360).data;
+ for (let i = 0; i < before.length; i += 4) {
+ if (allowed[i + 3]) continue;
+ for (let c = 0; c < 4; c++) if (before[i + c] !== after[i + c]) throw new Error(`${id}/${e.type}: disegna fuori dal ritaglio`);
+ }
+ clips++;
+ }
+ if (['cat', 'groundbird', 'insect'].includes(e.type)) {
+ if (!changed(before, after)) throw new Error(`${id}/${e.type}: animale invisibile`);
+ g.time = (e.every + e.duration + 100 - (e.offset ?? 0)) / 1000;
+ if (changed(pixels([]), pixels([e]))) throw new Error(`${id}/${e.type}: manca la pausa`);
+ pauses++;
+ }
+ if (e.phase) {
+ g.state.flags = e.phase === 'sera' ? {} : { casetta_aperta: true };
+ if (changed(pixels([]), pixels([e]))) throw new Error(`${id}/${e.type}: appare nella fase sbagliata`);
+ phases++;
+ }
+ }
+ return { clips, phases, pauses };
+ });
+ assert.deepEqual(errors, []);
+ console.log(`${scenes.length} scene × 3 fasi × 4 istanti; ${checked.clips} ritagli, ${checked.phases} condizioni giorno/sera, ${checked.pauses} pause animali. Immagini: ${out}`);
+} finally {
+ await browser?.close();
+ server.kill();
+}
diff --git a/tests/render.test.mjs b/tests/render.test.mjs
index cdb4b2f..c6cd1cf 100644
--- a/tests/render.test.mjs
+++ b/tests/render.test.mjs
@@ -85,5 +85,22 @@ test('gli effetti delle scene: tipi che il motore conosce, e dentro la scena', a
for (const ph of [e.phase ?? []].flat()) assert.ok(phases.has(ph), `${id}: fase sconosciuta ${ph}`);
const pts = [...(e.points ?? []), ...(e.at ? [e.at] : []), ...(e.area ? [e.area.slice(0, 2), [e.area[0] + e.area[2], e.area[1] + e.area[3]]] : []), ...(e.rects ?? []).map(r => r.slice(0, 2))];
for (const [x, y] of pts) assert.ok(x >= 0 && x <= 640 && y >= 0 && y <= 360, `${id}: ${e.type} fuori scena`);
+ if (e.clip) {
+ assert.ok(e.clip.length >= 3, `${id}: ritaglio senza area`);
+ for (const [x, y] of e.clip) assert.ok(Number.isFinite(x) && Number.isFinite(y) && x >= 0 && x <= 640 && y >= 0 && y <= 360, `${id}: ritaglio fuori scena`);
+ }
+ if (e.duration) assert.ok(e.duration > 0 && e.duration < e.every, `${id}: manca la pausa fra gli eventi`);
}
});
+
+test('gli animali hanno vere pause e ingressi graduali, anche con offset diversi', async () => {
+ const { ambientMoment } = await import('../src/engine/ambient.js');
+ const timing = { every: 40000, duration: 10000, offset: 3000 };
+ assert.equal(ambientMoment(8000, timing), null);
+ assert.equal(ambientMoment(36000, timing), null);
+ assert.deepEqual(ambientMoment(37000, timing), { t: 0, alpha: 0 });
+ assert.equal(ambientMoment(42000, timing).alpha, 1);
+ assert.ok(ambientMoment(46900, timing).alpha < 0.2);
+ assert.equal(ambientMoment(47000, timing), null);
+ assert.deepEqual(ambientMoment(42000, timing), ambientMoment(82000, timing));
+});