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
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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) {
|
||||||
|
|||||||
@@ -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]],
|
||||||
|
|||||||
@@ -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)
|
||||||