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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApqNBvNbNCwXqR2RLo8QJh
This commit is contained in:
+16
-1
@@ -1,9 +1,24 @@
|
|||||||
# Stato dei lavori
|
# 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
|
## 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,
|
**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.
|
**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
|
Si aggiorna con `make deploy`: pubblica l'ultimo commit, immagine costruita e provata sul PC e spedita al container
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test tests/*.test.mjs",
|
"test": "node --test tests/*.test.mjs",
|
||||||
"browser": "node tests/browser.mjs",
|
"browser": "node tests/browser.mjs",
|
||||||
|
"browser:effects": "node tests/ambient.browser.mjs",
|
||||||
"serve": "python3 -m http.server 8000"
|
"serve": "python3 -m http.server 8000"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
+39
-13
@@ -1,6 +1,7 @@
|
|||||||
// Disegno della scena su un canvas 640×360, ingrandito dal CSS senza sfocatura.
|
// 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.
|
// Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art.
|
||||||
import { check } from './game.js';
|
import { check } from './game.js';
|
||||||
|
import { ambientEffects, ambientHash } from './ambient.js';
|
||||||
export const WIDTH = 640, HEIGHT = 360;
|
export const WIDTH = 640, HEIGHT = 360;
|
||||||
|
|
||||||
function drawShape(ctx, s) {
|
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.
|
// Gli effetti delle scene (`effects`), ognuno col suo `type`: piccole animazioni disegnate sopra lo sfondo.
|
||||||
export const EFFECTS = {
|
export const EFFECTS = {
|
||||||
|
...ambientEffects,
|
||||||
// Lucine dei festoni: ognuna si accende e si spegne per conto suo, coi suoi colori.
|
// Lucine dei festoni: ognuna si accende e si spegne per conto suo, coi suoi colori.
|
||||||
bulbs(ctx, e, time) {
|
bulbs(ctx, e, time) {
|
||||||
const colors = e.colors ?? ['#ff5a5a', '#ffd25a', '#5ad2ff', '#7dff6a'];
|
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.
|
// Le finestre che si accendono di sera, una dopo l'altra, appena si entra nella scena.
|
||||||
windows(ctx, e, time, game) {
|
windows(ctx, e, time, game) {
|
||||||
const since = ((game.time ?? 0) - (game.enteredAt ?? 0)) * 1000;
|
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) => {
|
e.rects.forEach(([x, y, w, h], i) => {
|
||||||
if (since < 400 + hash(i * 7 + 3) * (e.over ?? 3500)) return;
|
if (since < 400 + hash(i * 7 + 3) * (e.over ?? 3500)) return;
|
||||||
ctx.fillStyle = 'rgba(255, 196, 100, 0.72)';
|
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.
|
// Rondini: ogni pochi secondi un gruppetto attraversa il cielo in diagonale, a «V» con le ali che battono.
|
||||||
birds(ctx, e, time) {
|
birds(ctx, e, time) {
|
||||||
const [ax, ay, aw, ah] = e.area, every = e.every ?? 6000, run = Math.floor(time / every), t = (time % every) / every;
|
const [ax, ay, aw, ah] = e.area, every = e.every ?? 6000, duration = e.duration ?? every * 0.6;
|
||||||
if (t > 0.6) return;
|
const elapsed = (time + (e.offset ?? 0)) % every, run = Math.floor((time + (e.offset ?? 0)) / every);
|
||||||
const k = t / 0.6, rtl = hash(run * 13) < 0.5;
|
if (elapsed >= duration) return;
|
||||||
|
const k = elapsed / duration, rtl = hash(run * 13) < 0.5;
|
||||||
for (let i = 0; i < (e.count ?? 4); i++) {
|
for (let i = 0; i < (e.count ?? 4); i++) {
|
||||||
const lag = i * 0.05, kk = k - lag;
|
const lag = i * 0.05, kk = k - lag;
|
||||||
if (kk < 0) continue;
|
if (kk < 0) continue;
|
||||||
@@ -411,16 +416,23 @@ export const EFFECTS = {
|
|||||||
lc.globalCompositeOperation = 'source-over';
|
lc.globalCompositeOperation = 'source-over';
|
||||||
ctx.drawImage(layer, 0, 0);
|
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) {
|
leafshadow(ctx, e, time) {
|
||||||
const [ax, ay, aw, ah] = e.area, t = time / 1000;
|
const [ax, ay, aw, ah] = e.area, t = time / 1000;
|
||||||
|
ctx.globalAlpha = e.opacity ?? 1;
|
||||||
for (let i = 0; i < (e.count ?? 14); i++) {
|
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 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) * 2, y = by + Math.sin(t * 0.7 + i * 3) * 1;
|
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);
|
||||||
ctx.fillStyle = i % 3 ? 'rgba(30, 20, 45, 0.24)' : 'rgba(255, 235, 170, 0.18)';
|
for (let leaf = 0; leaf < 4; leaf++) {
|
||||||
for (let dy = -r; dy <= r; dy += 1) {
|
const n = i * 31 + leaf * 7, dx = (ambientHash(n + 3) - 0.5) * 22, dy = (ambientHash(n + 9) - 0.5) * 9;
|
||||||
const half = Math.round(Math.sqrt(r * r - dy * dy) * 1.6);
|
const w = 3 + ambientHash(n + 21) * (e.size ?? 7), h = 1.5 + ambientHash(n + 4) * 2.5;
|
||||||
ctx.fillRect(Math.round(x - half), Math.round(y + dy / 2), half * 2, 1);
|
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();
|
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) {
|
function drawEffects(ctx, game, time, background = false) {
|
||||||
for (const e of game.scene.effects ?? [])
|
for (const e of game.scene.effects ?? []) {
|
||||||
if (BACKGROUND_EFFECTS.has(e.type) === background && onNow(e, game)) EFFECTS[e.type]?.(ctx, e, time, game);
|
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).
|
// Le scene buie (`shade`: il velo scuro sopra tutto, Andrea compreso) e le fiammelle (`lights`: candele accese).
|
||||||
|
|||||||
@@ -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)],
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Storia del prototipo: le scene del paese a gradini (docs/PROGETTO.md, «La mappa») per collaudare il motore.
|
// Storia del prototipo: le scene del paese a gradini (docs/PROGETTO.md, «La mappa») per collaudare il motore.
|
||||||
// Testi, personaggi ed enigmi sono provvisori e verranno sostituiti con i ricordi di Andrea (docs/PROGETTO.md).
|
// Testi, personaggi ed enigmi sono provvisori e verranno sostituiti con i ricordi di Andrea (docs/PROGETTO.md).
|
||||||
// Le forme in `art` sono segnaposto disegnati sopra lo sfondo (vecchietti, squama, catena) in attesa degli sprite.
|
// Le forme in `art` sono segnaposto disegnati sopra lo sfondo (vecchietti, squama, catena) in attesa degli sprite.
|
||||||
|
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,
|
// 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.
|
// 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];
|
||||||
|
|||||||
@@ -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: '<style>body{margin:0}canvas{display:block}</style><canvas width="640" height="360"></canvas>' }));
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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}`);
|
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))];
|
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`);
|
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));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user