diff --git a/src/engine/render.js b/src/engine/render.js index 94d02b5..e5adf31 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -200,6 +200,55 @@ function drawApparitions(ctx, game, time) { } } +// Il cielo di uno sfondo: i pixel azzurri (blu ben più forte del rosso), come maschera; si calcola una volta sola. +const skyMasks = new WeakMap(); +function skyMask(bg) { + if (skyMasks.has(bg)) return skyMasks.get(bg); + let mask = null; + try { + const c = document.createElement('canvas'); + c.width = WIDTH; c.height = HEIGHT; + const x = c.getContext('2d'); + x.drawImage(bg, 0, 0, WIDTH, HEIGHT); + const img = x.getImageData(0, 0, WIDTH, HEIGHT), d = img.data; + for (let p = 0; p < d.length; p += 4) { + const sky = d[p + 2] > 150 && d[p + 2] - d[p] > 60 && d[p + 2] - d[p + 1] > 15; + d[p + 3] = sky ? 255 : 0; + } + x.putImageData(img, 0, 0); + mask = c; + } catch { /* senza canvas (test) niente nuvole */ } + skyMasks.set(bg, mask); + return mask; +} +let scratchCanvas = null; +const scratch = () => { + if (!scratchCanvas) { scratchCanvas = document.createElement('canvas'); scratchCanvas.width = WIDTH; scratchCanvas.height = HEIGHT; } + return scratchCanvas; +}; +// Una nuvola a pixel: qualche cerchio pieno uno accanto all'altro, la cima color panna e la pancia azzurrina. +const cloudSprites = new Map(); +function cloudSprite(n) { + if (cloudSprites.has(n)) return cloudSprites.get(n); + const w = 34 + Math.floor(hash(n * 13 + 1) * 30), h = 18, c = document.createElement('canvas'); + c.width = w; c.height = h; + const x = c.getContext('2d'), blobs = []; + const count = 3 + Math.floor(hash(n * 3) * 3); + for (let b = 0; b < count; b++) { + const r = 4 + hash(n * 17 + b) * 5, cx = 5 + (w - 10) * (b + 0.5) / count + (hash(n + b * 3) - 0.5) * 4; + blobs.push([cx, h - 4 - r * 0.8, r]); + } + for (let py = 0; py < h; py++) for (let px = 0; px < w; px++) { + const inside = blobs.some(([cx, cy, r]) => (px - cx) ** 2 + (py - cy) ** 2 <= r * r) && py <= h - 3; + if (!inside) continue; + const top = !blobs.some(([cx, cy, r]) => (px - cx) ** 2 + (py - 2 - cy) ** 2 <= r * r); + x.fillStyle = py >= h - 6 ? '#c7d4ec' : top ? '#ffffff' : py < h - 9 ? '#fbf3dc' : '#eae6dc'; + x.fillRect(px, py, 1, 1); + } + cloudSprites.set(n, c); + return c; +} + // Un effetto o una luce vale adesso? `when` (condizione sui flag) e `phase` (una fase del giorno o un elenco). function onNow(e, game) { if (!check(e.when, game.state)) return false; @@ -316,19 +365,25 @@ export const EFFECTS = { 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. + // 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 ?? 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); + 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; + for (let i = 0; i < (e.count ?? 4); i++) { + const lag = i * 0.05, kk = k - lag; + if (kk < 0) continue; + const x0 = rtl ? ax + aw + 10 - kk * (aw + 30) : ax - 10 + kk * (aw + 30); + const x = Math.round(x0 + (hash(run * 5 + i) - 0.5) * 16); + const y = Math.round(ay + hash(run * 3 + i) * ah - kk * 20 + Math.sin(kk * 14 + i) * 4); + const up = Math.floor(time / 90 + i * 2) % 3; + ctx.fillStyle = '#1b1722'; + ctx.fillRect(x, y, 2, 2); + const wy = up === 0 ? -2 : up === 1 ? -1 : 1; + ctx.fillRect(x - 2, y + (wy < 0 ? -1 : 0), 2, 1); + ctx.fillRect(x - 4, y + wy, 2, 1); + ctx.fillRect(x + 2, y + (wy < 0 ? -1 : 0), 2, 1); + ctx.fillRect(x + 4, y + wy, 2, 1); } }, // Alberi al vento (i cipressi): il pezzo di sfondo si piega tutto insieme dalla base, di frazioni di pixel e con @@ -336,7 +391,7 @@ export const EFFECTS = { sway(ctx, e, time, game) { const bg = backgrounds.get(game.scene.background); if (!bg) return; - const k = bg.width / WIDTH, amount = e.amount ?? 1.2; + const k = bg.width / WIDTH, amount = e.amount ?? 3.2; for (const [i, [x, y, w, h]] of e.rects.entries()) { const a = amount * (0.65 * Math.sin(time / 1900 + i * 1.7) + 0.35 * Math.sin(time / 830 + i * 2.9)); ctx.save(); @@ -347,6 +402,25 @@ export const EFFECTS = { ctx.restore(); } }, + // Nuvole a pixel che attraversano il cielo, lentissime, ognuna a una sua altezza e velocità; uscita da un lato + // rientra dall'altro. Si vedono solo dove lo sfondo è cielo (skyMask): passano dietro alberi, torri e tetti. + clouds(ctx, e, time, game) { + const bg = backgrounds.get(game.scene.background), mask = bg && skyMask(bg); + if (!mask) return; + const layer = scratch(), lc = layer.getContext('2d'), [ax, ay, aw, ah] = e.area; + lc.clearRect(0, 0, WIDTH, HEIGHT); + for (let i = 0; i < (e.count ?? 3); i++) { + const sprite = cloudSprite(i + (e.seed ?? 0)), speed = (e.speed ?? 3) * (0.6 + hash(i * 7) * 0.8); + const span = aw + sprite.width + 20, x = ax - sprite.width - 10 + ((hash(i * 11) * span + time / 1000 * speed) % span); + lc.drawImage(sprite, Math.round(x), ay + Math.floor(hash(i * 5 + 2) * ah)); + } + lc.globalCompositeOperation = 'destination-in'; + lc.drawImage(mask, 0, 0); + lc.globalCompositeOperation = 'source-over'; + ctx.globalAlpha = e.alpha ?? 0.92; + ctx.drawImage(layer, 0, 0); + ctx.globalAlpha = 1; + }, // Lampioni: un alone caldo che si accende (di solito solo di sera, con `phase`). lamps(ctx, e, time) { ctx.save(); @@ -362,7 +436,7 @@ export const EFFECTS = { ctx.restore(); }, }; -const BACKGROUND_EFFECTS = new Set(['sway']); +const BACKGROUND_EFFECTS = new Set(['sway', 'clouds']); 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); diff --git a/src/story/story.js b/src/story/story.js index d368d4c..22e27be 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -249,7 +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] }], + effects: [{ type: 'clouds', area: [180, 10, 420, 90], count: 3 }, { 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], @@ -1107,7 +1107,7 @@ export const story = { name: 'Via Provinciale, il viale alberato', map: [72, 68], background: 'assets/scenes/provinciale-viale.png', - effects: [{ type: 'leaves', area: [20, 40, 360, 250], count: 7 }], + effects: [{ type: 'clouds', area: [300, 10, 220, 120], count: 2 }, { type: 'leaves', area: [20, 40, 360, 250], count: 7 }], walk: [[0, 360], [0, 330], [400, 232], [462, 232], [620, 360]], depth: [[232, 0.4], [360, 1.7]], entry: [300, 350], @@ -1144,7 +1144,7 @@ export const story = { name: 'La Provinciale lungo la Carza', map: [66, 73], background: 'assets/scenes/verso-luna.png', - 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]] }], + effects: [{ type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [200, 30, 400, 90] }, { type: 'clouds', area: [180, 10, 460, 120], count: 3 }, { 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], @@ -1278,7 +1278,7 @@ export const story = { name: 'Campomigliaio, il ponte vecchio', map: [50, 86], background: 'assets/scenes/campomigliaio-ponte.png', - 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] }], + effects: [{ type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [100, 10, 300, 60] }, { type: 'clouds', area: [80, 5, 300, 50], count: 2 }, { 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], @@ -1434,7 +1434,7 @@ export const story = { name: 'Villa Adami', map: [46, 32], background: 'assets/scenes/villa-adami.png', - 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'] }], + effects: [{ type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [300, 20, 330, 60] }, { type: 'clouds', area: [300, 5, 340, 60], count: 2 }, { 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], @@ -1695,7 +1695,7 @@ export const story = { map: [14, 19], background: 'assets/scenes/scuola.png', // Il tricolore che sventola in cima all'asta. - effects: [{ type: 'flag', at: [122, 117], w: 13, h: 9, colors: ['#2f9a4a', '#f4f1e8', '#d2393a'] }], + effects: [{ type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [150, 30, 400, 70] }, { type: 'clouds', area: [130, 10, 380, 100], count: 3 }, { type: 'flag', at: [122, 117], w: 13, h: 9, colors: ['#2f9a4a', '#f4f1e8', '#d2393a'] }], walk: [[110, 360], [118, 226], [140, 222], [268, 220], [290, 232], [470, 330], [620, 300], [640, 300], [640, 360]], depth: [[220, 0.42], [360, 1.32]], entry: [300, 350], @@ -1740,7 +1740,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]] }], + effects: [{ type: 'clouds', area: [230, 10, 140, 120], count: 2 }, { 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], @@ -1764,7 +1764,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 }], + effects: [{ type: 'clouds', area: [200, 10, 440, 120], count: 3 }, { 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, 30, 400, 80] }], 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]],