diff --git a/Dockerfile b/Dockerfile index 1f9eb20..16aaacb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ COPY assets/icons /usr/share/nginx/html/assets/icons COPY assets/fonts /usr/share/nginx/html/assets/fonts COPY assets/voices /usr/share/nginx/html/assets/voices COPY assets/credits /usr/share/nginx/html/assets/credits +COPY assets/effects /usr/share/nginx/html/assets/effects EXPOSE 80 HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 diff --git a/assets/effects/nuvola-1.png b/assets/effects/nuvola-1.png new file mode 100644 index 0000000..e8a102f Binary files /dev/null and b/assets/effects/nuvola-1.png differ diff --git a/assets/effects/nuvola-2.png b/assets/effects/nuvola-2.png new file mode 100644 index 0000000..d8ad717 Binary files /dev/null and b/assets/effects/nuvola-2.png differ diff --git a/assets/effects/nuvola-3.png b/assets/effects/nuvola-3.png new file mode 100644 index 0000000..1a85557 Binary files /dev/null and b/assets/effects/nuvola-3.png differ diff --git a/assets/effects/nuvola-4.png b/assets/effects/nuvola-4.png new file mode 100644 index 0000000..8ce62e6 Binary files /dev/null and b/assets/effects/nuvola-4.png differ diff --git a/assets/effects/nuvola-5.png b/assets/effects/nuvola-5.png new file mode 100644 index 0000000..c20a553 Binary files /dev/null and b/assets/effects/nuvola-5.png differ diff --git a/assets/effects/nuvola-6.png b/assets/effects/nuvola-6.png new file mode 100644 index 0000000..1ef7098 Binary files /dev/null and b/assets/effects/nuvola-6.png differ diff --git a/assets/effects/nuvola-7.png b/assets/effects/nuvola-7.png new file mode 100644 index 0000000..faeb2bd Binary files /dev/null and b/assets/effects/nuvola-7.png differ diff --git a/assets/effects/nuvola-8.png b/assets/effects/nuvola-8.png new file mode 100644 index 0000000..6a48ec9 Binary files /dev/null and b/assets/effects/nuvola-8.png differ diff --git a/src/engine/render.js b/src/engine/render.js index e5adf31..0636e24 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -226,28 +226,7 @@ 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; -} +const CLOUDS = Array.from({ length: 8 }, (_, i) => `assets/effects/nuvola-${i + 1}.png`); // Un effetto o una luce vale adesso? `when` (condizione sui flag) e `phase` (una fase del giorno o un elenco). function onNow(e, game) { @@ -402,24 +381,26 @@ 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. + // Nuvole che attraversano il cielo, lentissime (disegnate da Codex nello stile degli sfondi, assets/effects/): + // 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. Le due grandi solo coi cieli larghi (`big`). 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; + const pool = e.big ? CLOUDS : CLOUDS.slice(2); 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)); + const src = pool[Math.floor(hash(i * 19 + (e.seed ?? 0) + game.state.scene.length) * pool.length)], img = backgrounds.get(src); + if (!img) { loadBackground(src); continue; } + const speed = (e.speed ?? 2.5) * (0.6 + hash(i * 7) * 0.8), span = aw + img.width + 20; + const x = ax - img.width - 10 + ((hash(i * 11) * span + time / 1000 * speed) % span); + lc.drawImage(img, 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) { diff --git a/src/story/story.js b/src/story/story.js index 22e27be..9403dd1 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: '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] }], + effects: [{ type: 'clouds', area: [160, 0, 440, 80], count: 3, big: true }, { 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], @@ -1144,7 +1144,7 @@ export const story = { name: 'La Provinciale lungo la Carza', map: [66, 73], background: 'assets/scenes/verso-luna.png', - 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]] }], + effects: [{ type: 'birds', phase: ['pomeriggio', 'tramonto'], area: [200, 30, 400, 90] }, { type: 'clouds', area: [160, 0, 480, 110], count: 3, big: true }, { 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], @@ -1764,7 +1764,7 @@ export const story = { name: 'Castello del Trebbio', map: [84, 6], background: 'assets/scenes/trebbio.png', - 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] }], + effects: [{ type: 'clouds', area: [180, 0, 460, 110], count: 3, big: true }, { 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]], diff --git a/tools/ritaglia_sprite.py b/tools/ritaglia_sprite.py new file mode 100644 index 0000000..cff240a --- /dev/null +++ b/tools/ritaglia_sprite.py @@ -0,0 +1,43 @@ +"""Ritaglia un foglio di sprite su fondo magenta pieno (#ff00ff) nei suoi pezzi separati: ogni macchia (con i suoi +pixel staccati vicini) diventa un PNG trasparente, rimpicciolito di SCALA, in ordine da sinistra a destra e +dall'alto in basso. + python3 tools/ritaglia_sprite.py assets/generated/nuvole.png assets/effects/nuvola 0.24""" +import sys +from PIL import Image, ImageFilter + +src, prefix, scale = sys.argv[1], sys.argv[2], float(sys.argv[3]) +im = Image.open(src).convert('RGBA') +px = im.load() +W, H = im.size +for y in range(H): + for x in range(W): + r, g, b, a = px[x, y] + if r > 200 and g < 90 and b > 200: px[x, y] = (0, 0, 0, 0) +# Le macchie: si uniscono i pixel vicini (dilatando l'alfa) e si cercano i pezzi connessi. +mask = im.getchannel('A').point(lambda v: 255 if v else 0).filter(ImageFilter.MaxFilter(15)) +m = mask.load() +seen = [[False] * W for _ in range(H)] +boxes = [] +for y in range(0, H, 2): + for x in range(0, W, 2): + if m[x, y] and not seen[y][x]: + stack, l, t, r, b = [(x, y)], x, y, x, y + seen[y][x] = True + while stack: + cx, cy = stack.pop() + l, t, r, b = min(l, cx), min(t, cy), max(r, cx), max(b, cy) + for nx, ny in ((cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)): + if 0 <= nx < W and 0 <= ny < H and not seen[ny][nx] and m[nx, ny]: + seen[ny][nx] = True + stack.append((nx, ny)) + if (r - l) * (b - t) > 400: boxes.append((l, t, r + 1, b + 1)) +boxes.sort(key=lambda bx: (bx[1] // 150, bx[0])) +for i, bx in enumerate(boxes, 1): + piece = im.crop(bx) + piece = piece.crop(piece.getbbox()) + w, h = max(1, round(piece.width * scale)), max(1, round(piece.height * scale)) + piece = piece.resize((w, h), Image.BOX) + a = piece.getchannel('A').point(lambda v: 255 if v > 110 else 0) + piece.putalpha(a) + piece.save(f'{prefix}-{i}.png') + print(f'{prefix}-{i}.png', w, h)