From aec43e6ee44af28da799ccb0534919593f914a78 Mon Sep 17 00:00:00 2001 From: Andrea Manzi Date: Sun, 25 Oct 2026 00:55:39 +0200 Subject: [PATCH] Effetti ambientali nelle altre 35 scene (lavoro di Codex): piccioni, merli, gatto, farfalle, libellula, falena, acqua che scorre, fontanella, tende, ombre delle fronde e delle nuvole Controllato: test, partita completa e il giro di schermate degli effetti (npm run browser:effects). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ApqNBvNbNCwXqR2RLo8QJh --- docs/STATO.md | 17 +++- package.json | 1 + src/engine/ambient.js | 159 ++++++++++++++++++++++++++++++++++++++ src/engine/render.js | 52 +++++++++---- src/story/ambience.js | 71 +++++++++++++++++ src/story/story.js | 4 + tests/ambient.browser.mjs | 104 +++++++++++++++++++++++++ tests/render.test.mjs | 17 ++++ 8 files changed, 411 insertions(+), 14 deletions(-) create mode 100644 src/engine/ambient.js create mode 100644 src/story/ambience.js create mode 100644 tests/ambient.browser.mjs 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)); +});