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
160 lines
7.6 KiB
JavaScript
160 lines
7.6 KiB
JavaScript
// 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);
|
|
},
|
|
};
|