Le nuvole che scorrono sono quelle disegnate da Codex nello stile degli sfondi (tools/ritaglia_sprite.py)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApqNBvNbNCwXqR2RLo8QJh
This commit is contained in:
2026-10-24 23:56:07 +02:00
co-authored by Claude Opus 5.5
parent fd0fddae5d
commit a9528f7ed7
12 changed files with 57 additions and 32 deletions
+1
View File
@@ -11,6 +11,7 @@ COPY assets/icons /usr/share/nginx/html/assets/icons
COPY assets/fonts /usr/share/nginx/html/assets/fonts COPY assets/fonts /usr/share/nginx/html/assets/fonts
COPY assets/voices /usr/share/nginx/html/assets/voices COPY assets/voices /usr/share/nginx/html/assets/voices
COPY assets/credits /usr/share/nginx/html/assets/credits COPY assets/credits /usr/share/nginx/html/assets/credits
COPY assets/effects /usr/share/nginx/html/assets/effects
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+10 -29
View File
@@ -226,28 +226,7 @@ const scratch = () => {
if (!scratchCanvas) { scratchCanvas = document.createElement('canvas'); scratchCanvas.width = WIDTH; scratchCanvas.height = HEIGHT; } if (!scratchCanvas) { scratchCanvas = document.createElement('canvas'); scratchCanvas.width = WIDTH; scratchCanvas.height = HEIGHT; }
return scratchCanvas; return scratchCanvas;
}; };
// Una nuvola a pixel: qualche cerchio pieno uno accanto all'altro, la cima color panna e la pancia azzurrina. const CLOUDS = Array.from({ length: 8 }, (_, i) => `assets/effects/nuvola-${i + 1}.png`);
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). // Un effetto o una luce vale adesso? `when` (condizione sui flag) e `phase` (una fase del giorno o un elenco).
function onNow(e, game) { function onNow(e, game) {
@@ -402,24 +381,26 @@ export const EFFECTS = {
ctx.restore(); ctx.restore();
} }
}, },
// Nuvole a pixel che attraversano il cielo, lentissime, ognuna a una sua altezza e velocità; uscita da un lato // Nuvole che attraversano il cielo, lentissime (disegnate da Codex nello stile degli sfondi, assets/effects/):
// rientra dall'altro. Si vedono solo dove lo sfondo è cielo (skyMask): passano dietro alberi, torri e tetti. // 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) { clouds(ctx, e, time, game) {
const bg = backgrounds.get(game.scene.background), mask = bg && skyMask(bg); const bg = backgrounds.get(game.scene.background), mask = bg && skyMask(bg);
if (!mask) return; if (!mask) return;
const layer = scratch(), lc = layer.getContext('2d'), [ax, ay, aw, ah] = e.area; 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); lc.clearRect(0, 0, WIDTH, HEIGHT);
for (let i = 0; i < (e.count ?? 3); i++) { 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 src = pool[Math.floor(hash(i * 19 + (e.seed ?? 0) + game.state.scene.length) * pool.length)], img = backgrounds.get(src);
const span = aw + sprite.width + 20, x = ax - sprite.width - 10 + ((hash(i * 11) * span + time / 1000 * speed) % span); if (!img) { loadBackground(src); continue; }
lc.drawImage(sprite, Math.round(x), ay + Math.floor(hash(i * 5 + 2) * ah)); 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.globalCompositeOperation = 'destination-in';
lc.drawImage(mask, 0, 0); lc.drawImage(mask, 0, 0);
lc.globalCompositeOperation = 'source-over'; lc.globalCompositeOperation = 'source-over';
ctx.globalAlpha = e.alpha ?? 0.92;
ctx.drawImage(layer, 0, 0); ctx.drawImage(layer, 0, 0);
ctx.globalAlpha = 1;
}, },
// Lampioni: un alone caldo che si accende (di solito solo di sera, con `phase`). // Lampioni: un alone caldo che si accende (di solito solo di sera, con `phase`).
lamps(ctx, e, time) { lamps(ctx, e, time) {
+3 -3
View File
@@ -249,7 +249,7 @@ export const story = {
name: 'Piazza Colonna', name: 'Piazza Colonna',
map: [32, 45], map: [32, 45],
background: 'assets/scenes/piazza-colonna.png', 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]], 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]], depth: [[236, 0.5], [360, 1.35]],
entry: [345, 240], entry: [345, 240],
@@ -1144,7 +1144,7 @@ export const story = {
name: 'La Provinciale lungo la Carza', name: 'La Provinciale lungo la Carza',
map: [66, 73], map: [66, 73],
background: 'assets/scenes/verso-luna.png', 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]], walk: [[0, 360], [0, 200], [168, 190], [182, 196], [330, 360]],
depth: [[192, 0.45], [360, 1.7]], depth: [[192, 0.45], [360, 1.7]],
entry: [150, 206], entry: [150, 206],
@@ -1764,7 +1764,7 @@ export const story = {
name: 'Castello del Trebbio', name: 'Castello del Trebbio',
map: [84, 6], map: [84, 6],
background: 'assets/scenes/trebbio.png', 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', music: 'assets/audio/ricordi.mp3',
walk: [[86, 308], [300, 306], [356, 308], [470, 312], [500, 324], [486, 360], [88, 360], [80, 330]], walk: [[86, 308], [300, 306], [356, 308], [470, 312], [500, 324], [486, 360], [88, 360], [80, 330]],
depth: [[306, 0.75], [360, 1.05]], depth: [[306, 0.75], [360, 1.05]],
+43
View File
@@ -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)