diff --git a/src/engine/game.js b/src/engine/game.js index 13b0b96..87d34c9 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -374,6 +374,7 @@ export class Game { go(scene, at, face) { if (!this.story.scenes[scene]) throw new Error('Scena sconosciuta: ' + scene); this.state.scene = scene; + this.enteredAt = this.time; // per gli effetti che cominciano all'ingresso (le finestre che si accendono) [this.state.x, this.state.y] = at ?? this.scene.entry; this.state.facing = FACING[face] ?? this.state.facing; this.path = []; diff --git a/src/engine/render.js b/src/engine/render.js index 8fd4a14..6f1bdbb 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -145,6 +145,8 @@ export function render(ctx, game, { hover, spots, debug, time = performance.now( ctx.imageSmoothingEnabled = false; ctx.clearRect(0, 0, WIDTH, HEIGHT); if (bg) ctx.drawImage(bg, 0, 0, WIDTH, HEIGHT); + // Gli effetti che muovono pezzi di sfondo (i cipressi al vento) vanno subito sopra lo sfondo, sotto tutto il resto. + drawEffects(ctx, game, time, true); for (const shape of scene.art ?? []) drawShape(ctx, shape); // Una forma può avere la sua condizione (`when`): la chiavetta nella griglia sparisce quando la si prende. // I primi piani (`top`) invece vanno sopra a tutto, anche al buio e alle candele: si disegnano dopo. @@ -290,6 +292,58 @@ export const EFFECTS = { ctx.fillStyle = e.fill; for (const [x, y, w, h] of e.rects) closed ? ctx.fillRect(x, y + Math.floor(h / 2), w, 1) : ctx.fillRect(x, y, w, h); }, + // Lucciole di sera: puntini gialli che vagano piano e si accendono e si spengono. + fireflies(ctx, e, time) { + const [ax, ay, aw, ah] = e.area; + for (let i = 0; i < (e.count ?? 8); i++) { + const t = time / 1000, glow = Math.sin(t * (1.3 + hash(i) * 1.2) + i * 2); + if (glow < 0.15) continue; + const x = ax + aw * (0.5 + 0.45 * Math.sin(t * 0.21 + i * 2.3) * Math.cos(t * 0.13 + i)), y = ay + ah * (0.5 + 0.45 * Math.sin(t * 0.17 + i * 1.7)); + ctx.fillStyle = `rgba(255, 240, 120, ${0.25 * glow})`; + ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 3, 3); + ctx.fillStyle = `rgba(230, 255, 140, ${glow})`; + ctx.fillRect(Math.round(x), Math.round(y), 1, 1); + } + }, + // 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; + 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)'; + ctx.fillRect(x, y, w, h); + ctx.fillStyle = 'rgba(255, 236, 170, 0.5)'; + ctx.fillRect(x + 1, y + 1, Math.max(1, w - 2), Math.max(1, Math.floor(h / 3))); + }); + }, + // Rondini: ogni tanto un gruppetto attraversa il cielo, da destra a sinistra. + birds(ctx, e, time) { + const [ax, ay, aw, ah] = e.area, every = e.every ?? 9000, t = (time % every) / every, run = Math.floor(time / every); + if (t > 0.45) return; + const k = t / 0.45; + for (let i = 0; i < (e.count ?? 3); i++) { + const x = Math.round(ax + aw - k * (aw + 40) + i * 9 + hash(run * 5 + i) * 10); + const y = Math.round(ay + hash(run * 3 + i) * ah + Math.sin(k * 12 + i) * 3); + const up = Math.floor(time / 120 + i) % 2; + ctx.fillStyle = '#1e1a24'; + ctx.fillRect(x, y, 2, 1); + ctx.fillRect(x - 2, y - up, 2, 1); + ctx.fillRect(x + 2, y - up, 2, 1); + } + }, + // Alberi al vento (i cipressi): un pezzo di sfondo ridisegnato inclinato appena, più in cima che alla base. + sway(ctx, e, time, game) { + const bg = backgrounds.get(game.scene.background); + if (!bg) return; + const k = bg.width / WIDTH; + for (const [i, [x, y, w, h]] of e.rects.entries()) { + const a = Math.sin(time / (e.speed ?? 900) + i * 1.3) * (e.amount ?? 1.6); + for (let r = 0; r < h; r += 2) { + const dx = Math.round(a * (1 - r / h)); + if (dx) ctx.drawImage(bg, x * k, (y + r) * k, w * k, 2 * k, x + dx, y + r, w, 2); + } + } + }, // Lampioni: un alone caldo che si accende (di solito solo di sera, con `phase`). lamps(ctx, e, time) { ctx.save(); @@ -305,8 +359,10 @@ export const EFFECTS = { ctx.restore(); }, }; -function drawEffects(ctx, game, time) { - for (const e of game.scene.effects ?? []) if (onNow(e, game)) EFFECTS[e.type]?.(ctx, e, time); +const BACKGROUND_EFFECTS = new Set(['sway']); +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); } // Le scene buie (`shade`: il velo scuro sopra tutto, Andrea compreso) e le fiammelle (`lights`: candele accese). diff --git a/src/main.js b/src/main.js index e8a2bcb..bdc5bdc 100644 --- a/src/main.js +++ b/src/main.js @@ -538,7 +538,8 @@ function updateUi() { } } -let last = performance.now(), drawnStill = false; +let last = performance.now(), drawnStill = false, fade = 0, fadeScene = null; +const FADE = 0.4; // secondi della dissolvenza fra le scene function frame(now) { // Il primo timestamp di requestAnimationFrame può precedere `last`: un dt negativo darebbe alla musica un volume // sotto zero, il browser lancerebbe un errore e il ciclo dei fotogrammi si fermerebbe (gioco congelato). @@ -556,6 +557,13 @@ function frame(now) { if (game && (!still || still !== drawnStill)) { if (running) game.update(dt); render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null, spots: running && now < spotsUntil, debug: debugView() }); + // Cambiando scena, un attimo di nero che sfuma: la dissolvenza dei vecchi giochi, invece del taglio netto. + if (game.state.scene !== fadeScene) { if (fadeScene) fade = 1; fadeScene = game.state.scene; } + if (fade > 0) { + ctx.fillStyle = `rgba(0, 0, 0, ${fade})`; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + fade = Math.max(0, fade - dt / FADE); + } updateUi(); } drawnStill = still; diff --git a/src/story/story.js b/src/story/story.js index 55a8c44..6503db4 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -249,6 +249,7 @@ export const story = { name: 'Piazza Colonna', map: [32, 45], background: 'assets/scenes/piazza-colonna.png', + effects: [{ type: 'windows', phase: 'sera', rects: [[16, 37, 8, 18], [48, 42, 8, 18], [50, 110, 8, 16], [97, 77, 8, 12], [122, 80, 8, 12], [170, 90, 8, 12], [204, 103, 8, 12], [239, 111, 8, 12], [268, 117, 7, 14], [287, 125, 6, 14], [97, 130, 9, 14], [127, 133, 9, 14], [170, 142, 9, 14], [204, 150, 8, 12], [239, 155, 8, 12], [268, 160, 7, 14], [287, 163, 6, 14], [386, 163, 14, 16], [424, 160, 14, 16], [472, 157, 14, 16], [524, 155, 14, 16], [390, 212, 14, 16], [526, 208, 14, 16]] }, { type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [150, 20, 460, 80] }], walk: [[84, 244], [330, 238], [345, 230], [362, 238], [462, 246], [466, 276], [544, 276], [548, 248], [588, 250], [600, 290], [640, 300], [640, 360], [0, 360], [0, 330], [84, 318]], depth: [[236, 0.5], [360, 1.35]], entry: [345, 240], @@ -1141,7 +1142,7 @@ export const story = { name: 'La Provinciale lungo la Carza', map: [66, 73], background: 'assets/scenes/verso-luna.png', - effects: [{ type: 'sparkle', area: [380, 288, 200, 30], count: 5 }, { type: 'sparkle', area: [430, 210, 40, 30], count: 2 }, { type: 'smoke', at: [113, 116] }, { type: 'lamps', phase: 'sera', points: [[165, 93]] }], + effects: [{ type: 'fireflies', phase: 'sera', area: [200, 210, 220, 80], count: 8 }, { type: 'sparkle', area: [380, 288, 200, 30], count: 5 }, { type: 'sparkle', area: [430, 210, 40, 30], count: 2 }, { type: 'smoke', at: [113, 116] }, { type: 'lamps', phase: 'sera', points: [[165, 93]] }], walk: [[0, 360], [0, 200], [168, 190], [182, 196], [330, 360]], depth: [[192, 0.45], [360, 1.7]], entry: [150, 206], @@ -1220,6 +1221,7 @@ export const story = { name: 'Campomigliaio, il circolino', map: [55, 82], background: 'assets/scenes/campomigliaio-circolo.png', + effects: [{ type: 'windows', phase: 'sera', rects: [[55, 186, 50, 28], [110, 186, 50, 28], [165, 186, 36, 28], [316, 180, 16, 44]] }], walk: [[0, 360], [0, 335], [60, 300], [400, 280], [640, 250], [640, 360]], depth: [[250, 1.0], [360, 1.5]], entry: [320, 330], @@ -1274,7 +1276,7 @@ export const story = { name: 'Campomigliaio, il ponte vecchio', map: [50, 86], background: 'assets/scenes/campomigliaio-ponte.png', - effects: [{ type: 'sparkle', area: [150, 205, 160, 90], count: 7 }, { type: 'sparkle', area: [330, 300, 110, 40], count: 3 }, { type: 'smoke', at: [605, 50] }], + effects: [{ type: 'fireflies', phase: 'sera', area: [0, 220, 150, 120], count: 7 }, { type: 'sparkle', area: [150, 205, 160, 90], count: 7 }, { type: 'sparkle', area: [330, 300, 110, 40], count: 3 }, { type: 'smoke', at: [605, 50] }], walk: [[0, 360], [0, 232], [100, 225], [140, 240], [170, 275], [260, 330], [300, 360]], depth: [[225, 0.6], [360, 1.5]], entry: [80, 330], @@ -1430,7 +1432,7 @@ export const story = { name: 'Villa Adami', map: [46, 32], background: 'assets/scenes/villa-adami.png', - effects: [{ type: 'flag', at: [251, 4], w: 9, h: 6, colors: ['#f4f1e8', '#2a2a2a'] }], + effects: [{ type: 'windows', phase: 'sera', rects: [[148, 104, 10, 24], [205, 110, 10, 22], [300, 120, 12, 26], [372, 128, 10, 20], [412, 130, 10, 20], [448, 134, 10, 18], [150, 186, 12, 22], [205, 190, 12, 22], [372, 190, 10, 20], [412, 190, 10, 20], [448, 192, 10, 18]] }, { type: 'flag', at: [251, 4], w: 9, h: 6, colors: ['#f4f1e8', '#2a2a2a'] }], walk: [[0, 272], [30, 246], [60, 244], [110, 256], [190, 268], [360, 296], [440, 288], [520, 276], [560, 268], [612, 262], [614, 272], [520, 288], [500, 292], [500, 360], [0, 360]], depth: [[244, 0.5], [360, 1.2]], entry: [300, 345], @@ -1528,6 +1530,7 @@ export const story = { name: 'Il posticino della Carza', map: [60, 58], background: 'assets/scenes/carza.png', + effects: [{ type: 'fireflies', phase: 'sera', area: [80, 200, 480, 100], count: 9 }], walk: [[60, 360], [250, 262], [300, 218], [350, 218], [420, 262], [600, 360]], depth: [[218, 0.55], [360, 1.65]], entry: [300, 350], @@ -1735,6 +1738,7 @@ export const story = { name: 'Viale dei cipressi', map: [60, 6], background: 'assets/scenes/viale-cipressi.png', + effects: [{ type: 'sway', rects: [[322, 58, 30, 150], [346, 8, 32, 190], [312, 108, 18, 110], [244, 118, 26, 100]] }], walk: [[30, 360], [200, 300], [288, 252], [312, 250], [420, 300], [600, 360]], depth: [[250, 0.16], [360, 1.2]], entry: [300, 350], @@ -1758,6 +1762,7 @@ export const story = { name: 'Castello del Trebbio', map: [84, 6], background: 'assets/scenes/trebbio.png', + effects: [{ type: 'sway', rects: [[237, 140, 20, 60], [165, 160, 20, 48], [305, 132, 14, 30], [560, 120, 22, 80], [600, 120, 20, 80], [620, 100, 20, 100], [530, 160, 20, 40]] }, { type: 'fireflies', phase: 'sera', area: [80, 290, 440, 60], count: 10 }, { type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [200, 20, 400, 90], every: 12000 }], music: 'assets/audio/ricordi.mp3', walk: [[86, 308], [300, 306], [356, 308], [470, 312], [500, 324], [486, 360], [88, 360], [80, 330]], depth: [[306, 0.75], [360, 1.05]],