Andrea vero: camminata, parlato, prendere e di spalle dal foglio sprite

tools/slice_sprites.py da Manzolo Rush e tools/prepare_character.py, che allinea
i fotogrammi sul busto e sui piedi; il motore sceglie il fotogramma da quello
che Andrea sta facendo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 01:00:36 +02:00
co-authored by Claude Opus 5.5
parent 7133c46891
commit f7f65a74fd
12 changed files with 803 additions and 19 deletions
+1
View File
@@ -5,6 +5,7 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html style.css /usr/share/nginx/html/ COPY index.html style.css /usr/share/nginx/html/
COPY src /usr/share/nginx/html/src COPY src /usr/share/nginx/html/src
COPY assets/scenes /usr/share/nginx/html/assets/scenes COPY assets/scenes /usr/share/nginx/html/assets/scenes
COPY assets/characters /usr/share/nginx/html/assets/characters
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
+2
View File
@@ -31,6 +31,8 @@ make down
- `src/story/`: la storia come dati (`story.js`) e la sua soluzione completa (`solution.js`); - `src/story/`: la storia come dati (`story.js`) e la sua soluzione completa (`solution.js`);
- `src/main.js`: collega tutto alla pagina; - `src/main.js`: collega tutto alla pagina;
- `assets/scenes/`: sfondi a 640×360, preparati con `tools/prepare_background.py` dagli originali in `assets/raw/`. - `assets/scenes/`: sfondi a 640×360, preparati con `tools/prepare_background.py` dagli originali in `assets/raw/`.
- `assets/characters/`: fogli dei personaggi, tagliati con `tools/slice_sprites.py` e allineati con
`tools/prepare_character.py` (istruzioni in testa al file).
## Test ## Test
+70
View File
@@ -0,0 +1,70 @@
{
"image": "andrea.png",
"height": 120,
"frames": {
"walk_1": {
"x": 0,
"y": 0,
"w": 65,
"h": 111,
"ax": 36.6,
"ay": 111
},
"walk_2": {
"x": 67,
"y": 0,
"w": 35,
"h": 111,
"ax": 17.8,
"ay": 111
},
"walk_3": {
"x": 104,
"y": 0,
"w": 64,
"h": 111,
"ax": 34.5,
"ay": 111
},
"walk_4": {
"x": 170,
"y": 0,
"w": 38,
"h": 111,
"ax": 21.5,
"ay": 111
},
"stand": {
"x": 210,
"y": 0,
"w": 34,
"h": 120,
"ax": 15.1,
"ay": 120
},
"talk": {
"x": 246,
"y": 0,
"w": 56,
"h": 121,
"ax": 20.5,
"ay": 121
},
"take": {
"x": 304,
"y": 0,
"w": 68,
"h": 97,
"ax": 34.7,
"ay": 97
},
"back": {
"x": 374,
"y": 0,
"w": 43,
"h": 120,
"ax": 21.0,
"ay": 120
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

+24 -4
View File
@@ -17,6 +17,7 @@ const DEFAULT_LINES = {
const SPEED = 95; const SPEED = 95;
// Solo profilo, come i cicli di camminata: 'up' e 'down' lasciano il verso com'è. // Solo profilo, come i cicli di camminata: 'up' e 'down' lasciano il verso com'è.
const FACING = { left: -1, right: 1 }; const FACING = { left: -1, right: 1 };
const WALK = ['walk_1', 'walk_2', 'walk_3', 'walk_4'], STEP = 15, REACH = 0.45;
// Condizioni: 'flag', '!flag', 'has:oggetto', '!has:oggetto', oppure un elenco (tutte vere). // Condizioni: 'flag', '!flag', 'has:oggetto', '!has:oggetto', oppure un elenco (tutte vere).
export function check(cond, state) { export function check(cond, state) {
@@ -57,12 +58,15 @@ export class Game {
this.dialogue = null; this.dialogue = null;
this.walking = false; this.walking = false;
this.time = 0; this.time = 0;
this.stride = 0; // strada fatta, per scegliere il passo della camminata
this.reach = 0; // secondi di braccio teso quando prende qualcosa
this.back = false; // di spalle davanti a una porta o a una bacheca
} }
get scene() { return this.story.scenes[this.state.scene]; } get scene() { return this.story.scenes[this.state.scene]; }
// Occupato: c'è qualcosa in corso che il giocatore non può interrompere. La camminata verso un punto // Occupato: c'è qualcosa in corso che il giocatore non può interrompere. La camminata verso un punto
// o verso un oggetto si può sempre cambiare con un altro clic: la coda che la seguiva viene scartata. // o verso un oggetto si può sempre cambiare con un altro clic: la coda che la seguiva viene scartata.
get busy() { return !!this.speech || (this.task ? !this.task.interruptible : this.queue.length > 0); } get busy() { return !!this.speech || this.reach > 0 || (this.task ? !this.task.interruptible : this.queue.length > 0); }
get idle() { return !this.busy && !this.task && !this.dialogue; } get idle() { return !this.busy && !this.task && !this.dialogue; }
snapshot() { snapshot() {
@@ -151,6 +155,15 @@ export class Game {
startWalk(p) { startWalk(p) {
this.path = findPath(this.scene.walk, [this.state.x, this.state.y], p); this.path = findPath(this.scene.walk, [this.state.x, this.state.y], p);
this.walking = this.path.length > 0; this.walking = this.path.length > 0;
this.back = false;
}
// Il fotogramma di Andrea in questo istante: un passo ogni STEP pixel percorsi (a grandezza piena).
pose() {
if (this.reach > 0) return 'take';
if (this.walking) return WALK[Math.floor(this.stride / STEP) % WALK.length];
if (this.speech?.who === 'andrea') return Math.floor(this.time / 0.2) % 2 ? 'talk' : 'stand';
return this.back ? 'back' : 'stand';
} }
scaleAt(y) { scaleAt(y) {
@@ -174,8 +187,14 @@ export class Game {
if (this.speech.left > 0) return; if (this.speech.left > 0) return;
this.speech = null; this.speech = null;
} }
if (this.reach > 0) {
this.reach -= dt;
if (this.reach > 0) return;
}
if (this.walking) { if (this.walking) {
let step = SPEED * this.scaleAt(this.state.y) * dt; const scale = this.scaleAt(this.state.y);
let step = SPEED * scale * dt;
this.stride += step / scale;
while (step > 0 && this.path.length) { while (step > 0 && this.path.length) {
const target = this.path[0], d = dist([this.state.x, this.state.y], target); const target = this.path[0], d = dist([this.state.x, this.state.y], target);
if (Math.abs(target[0] - this.state.x) > 0.5) this.state.facing = target[0] > this.state.x ? 1 : -1; if (Math.abs(target[0] - this.state.x) > 0.5) this.state.facing = target[0] > this.state.x ? 1 : -1;
@@ -203,8 +222,8 @@ export class Game {
else if (e.walk) { this.task = e; this.startWalk(e.walk); } else if (e.walk) { this.task = e; this.startWalk(e.walk); }
// `go` prima di `face`: un cambio scena porta con sé il verso d'arrivo. // `go` prima di `face`: un cambio scena porta con sé il verso d'arrivo.
else if (e.go) this.go(e.go, e.at, e.face); else if (e.go) this.go(e.go, e.at, e.face);
else if (e.face) s.facing = FACING[e.face] ?? s.facing; else if (e.face) { s.facing = FACING[e.face] ?? s.facing; this.back = e.face === 'up'; }
else if (e.give) { if (!s.inventory.includes(e.give)) s.inventory.push(e.give); } else if (e.give) { if (!s.inventory.includes(e.give)) s.inventory.push(e.give); this.reach = REACH; this.back = false; }
else if (e.drop) s.inventory = s.inventory.filter(i => i !== e.drop); else if (e.drop) s.inventory = s.inventory.filter(i => i !== e.drop);
else if (e.set) s.flags[e.set] = true; else if (e.set) s.flags[e.set] = true;
else if (e.unset) delete s.flags[e.unset]; else if (e.unset) delete s.flags[e.unset];
@@ -223,6 +242,7 @@ export class Game {
this.state.facing = FACING[face] ?? this.state.facing; this.state.facing = FACING[face] ?? this.state.facing;
this.path = []; this.path = [];
this.walking = false; this.walking = false;
this.back = false;
this.queue.unshift(...(this.scene.enter ?? [])); this.queue.unshift(...(this.scene.enter ?? []));
this.onSave(this.snapshot()); this.onSave(this.snapshot());
} }
+47 -11
View File
@@ -56,15 +56,50 @@ export function drawAndrea(ctx, x, y, scale, facing, walkPhase) {
ctx.restore(); ctx.restore();
} }
// Sfondi delle scene, caricati una volta sola. Finché un'immagine non c'è si vedono le forme `art`. // Sfondi e fogli dei personaggi, caricati una volta sola. Finché un'immagine non c'è si vedono i segnaposto.
const backgrounds = new Map(); const backgrounds = new Map(), sprites = new Map();
export function loadBackgrounds(story) { const loadImage = src => new Promise(resolve => {
return Promise.all(Object.values(story.scenes).filter(s => s.background).map(s => new Promise(resolve => { const img = new Image();
const img = new Image(); img.onload = () => resolve(img);
img.onload = () => { backgrounds.set(s.background, img); resolve(true); }; img.onerror = () => resolve(null);
img.onerror = () => resolve(false); img.src = src;
img.src = s.background; });
})));
export function loadAssets(story) {
const scenes = Object.values(story.scenes).filter(s => s.background).map(async s => {
const img = await loadImage(s.background);
if (img) backgrounds.set(s.background, img);
return !!img;
});
const people = Object.entries(story.characters).filter(([, c]) => c.sprite).map(async ([id, c]) => {
try {
const atlas = await (await fetch(c.sprite)).json();
const img = await loadImage(new URL(atlas.image, new URL(c.sprite, location.href)).href);
if (img) sprites.set(id, { atlas, img });
return !!img;
} catch {
return false;
}
});
return Promise.all([...scenes, ...people]);
}
// Il personaggio alto PERSON pixel a grandezza 1, con i piedi in (x, y); rivolto a sinistra si specchia.
const PERSON = 62;
function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing) {
const f = atlas.frames[frame] ?? atlas.frames.stand, k = PERSON * scale / atlas.height;
ctx.save();
ctx.translate(Math.round(x), Math.round(y));
ctx.fillStyle = 'rgba(0,0,0,0.22)';
ctx.beginPath();
ctx.ellipse(0, 0, 13 * scale, 3 * scale, 0, 0, Math.PI * 2);
ctx.fill();
// La pagina è ingrandita a pixel netti: qui invece si rimpicciolisce, e la media dei pixel evita lo sfarfallio.
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
if (frame !== 'back') ctx.scale(facing, 1);
ctx.drawImage(img, f.x, f.y, f.w, f.h, -f.ax * k, -f.ay * k, f.w * k, f.h * k);
ctx.restore();
} }
export function render(ctx, game, { hover } = {}) { export function render(ctx, game, { hover } = {}) {
@@ -74,8 +109,9 @@ export function render(ctx, game, { hover } = {}) {
if (bg) ctx.drawImage(bg, 0, 0, WIDTH, HEIGHT); if (bg) ctx.drawImage(bg, 0, 0, WIDTH, HEIGHT);
for (const shape of scene.art ?? []) drawShape(ctx, shape); for (const shape of scene.art ?? []) drawShape(ctx, shape);
for (const h of game.hotspots()) for (const shape of h.art ?? []) drawShape(ctx, shape); for (const h of game.hotspots()) for (const shape of h.art ?? []) drawShape(ctx, shape);
const phase = game.walking ? (s.x + s.y) / 6 : null; const andrea = sprites.get('andrea');
drawAndrea(ctx, s.x, s.y, game.scaleAt(s.y), s.facing, phase); if (andrea) drawSprite(ctx, andrea, game.pose(), s.x, s.y, game.scaleAt(s.y), s.facing);
else drawAndrea(ctx, s.x, s.y, game.scaleAt(s.y), s.facing, game.walking ? game.stride / 6 : null);
if (hover) { if (hover) {
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.setLineDash([3, 3]); ctx.setLineDash([3, 3]);
+2 -2
View File
@@ -2,7 +2,7 @@
import { story } from './story/story.js'; import { story } from './story/story.js';
import { Game, VERBS, VERB_LABELS } from './engine/game.js'; import { Game, VERBS, VERB_LABELS } from './engine/game.js';
import { Command } from './engine/command.js'; import { Command } from './engine/command.js';
import { render, loadBackgrounds, WIDTH, HEIGHT } from './engine/render.js'; import { render, loadAssets, WIDTH, HEIGHT } from './engine/render.js';
import { readSave, writeSave, clearSave } from './engine/storage.js'; import { readSave, writeSave, clearSave } from './engine/storage.js';
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
@@ -167,4 +167,4 @@ game = new Game(story);
command = new Command(game); command = new Command(game);
showTitle(); showTitle();
requestAnimationFrame(frame); requestAnimationFrame(frame);
loadBackgrounds(story).then(ok => { if (ok.includes(false)) console.warn('Alcuni sfondi non sono stati caricati.'); }); loadAssets(story).then(ok => { if (ok.includes(false)) console.warn('Alcune immagini non sono state caricate.'); });
+1 -1
View File
@@ -6,7 +6,7 @@ export const story = {
title: 'La Leggenda del Regolo', title: 'La Leggenda del Regolo',
start: { scene: 'piazza', at: [250, 320] }, start: { scene: 'piazza', at: [250, 320] },
characters: { characters: {
andrea: { name: 'Andrea', color: '#ffffff' }, andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' },
vecchioA: { name: 'Vecchietto col cappello', color: '#ffd27a' }, vecchioA: { name: 'Vecchietto col cappello', color: '#ffd27a' },
vecchioB: { name: 'Vecchietto col bastone', color: '#9fe3ff' }, vecchioB: { name: 'Vecchietto col bastone', color: '#9fe3ff' },
castello: { name: 'Il Castello', color: '#c8f0a0' }, castello: { name: 'Il Castello', color: '#c8f0a0' },
+23
View File
@@ -140,3 +140,26 @@ test('lo stato salvato si ricarica uguale', () => {
const again = new Game(mini(), { state: JSON.parse(JSON.stringify(g.snapshot())) }); const again = new Game(mini(), { state: JSON.parse(JSON.stringify(g.snapshot())) });
assert.deepEqual(again.snapshot(), g.snapshot()); assert.deepEqual(again.snapshot(), g.snapshot());
}); });
test('il fotogramma segue quello che fa Andrea', () => {
const g = new Game(mini());
assert.equal(g.pose(), 'stand');
g.walkTo([150, 50]);
const seen = new Set();
for (let i = 0; i < 40; i++) { g.update(0.05); if (g.walking) seen.add(g.pose()); }
assert.deepEqual([...seen].sort(), ['walk_1', 'walk_2', 'walk_3', 'walk_4']);
g.queue.push({ face: 'up' });
settle(g);
assert.equal(g.pose(), 'back');
g.queue.push({ give: 'sasso' });
g.update(0.01);
assert.equal(g.pose(), 'take');
assert.ok(g.busy, 'mentre prende qualcosa non si interrompe');
settle(g);
assert.equal(g.pose(), 'stand');
g.queue.push({ say: 'Ciao a tutti.' });
g.update(0.01);
const poses = new Set();
for (let i = 0; i < 10; i++) { g.time += 0.1; poses.add(g.pose()); }
assert.deepEqual([...poses].sort(), ['stand', 'talk']);
});
+11 -1
View File
@@ -4,7 +4,7 @@ import { story } from '../src/story/story.js';
import { solution } from '../src/story/solution.js'; import { solution } from '../src/story/solution.js';
import { Game, VERBS } from '../src/engine/game.js'; import { Game, VERBS } from '../src/engine/game.js';
import { inside } from '../src/engine/geometry.js'; import { inside } from '../src/engine/geometry.js';
import { existsSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { settle } from './helpers.mjs'; import { settle } from './helpers.mjs';
const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end']; const EFFECTS = ['say', 'walk', 'face', 'give', 'drop', 'set', 'unset', 'if', 'dialogue', 'node', 'endDialogue', 'go', 'end'];
@@ -95,3 +95,13 @@ test('la soluzione si percorre tutta dall\'inizio alla fine', () => {
assert.ok(g.state.flags.indizio_regolo); assert.ok(g.state.flags.indizio_regolo);
assert.ok(saves.at(-1).ended, 'la fine non è salvata'); assert.ok(saves.at(-1).ended, 'la fine non è salvata');
}); });
test('i fogli dei personaggi hanno tutti i fotogrammi e l\'immagine', () => {
for (const [id, c] of Object.entries(story.characters)) {
if (!c.sprite) continue;
const url = new URL('../' + c.sprite, import.meta.url);
const atlas = JSON.parse(readFileSync(url, 'utf8'));
assert.ok(existsSync(new URL(atlas.image, url)), `${id}: manca ${atlas.image}`);
for (const f of ['walk_1', 'walk_2', 'walk_3', 'walk_4', 'stand', 'talk', 'take', 'back']) assert.ok(atlas.frames[f], `${id}: manca ${f}`);
}
});
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Prepara i fotogrammi di un personaggio (già ritagliati da slice_sprites.py) per il motore.
python3 tools/slice_sprites.py --sheet assets/raw/andrea-sheet.png --out /tmp/andrea --cols 4 --rows 2 \
--despill --names walk_1,walk_2,walk_3,walk_4,stand,talk,take,back --prefix andrea_
python3 tools/prepare_character.py /tmp/andrea andrea assets/characters --height 120
Tutti i fotogrammi vengono ridotti con lo stesso fattore (l'altezza del fotogramma `stand` diventa --height) e
salvati in un'unica striscia PNG. Il JSON accanto dice per ogni fotogramma dove sta nella striscia e dove sono
i piedi: x è il centro del busto (la parte alta del corpo, che in una camminata resta ferma), y la base.
"""
import argparse
import json
from pathlib import Path
from PIL import Image
FRAMES = ['walk_1', 'walk_2', 'walk_3', 'walk_4', 'stand', 'talk', 'take', 'back']
def torso_center(im):
"""Centro orizzontale dei pixel opachi nel 40% alto della figura (testa e spalle)."""
alpha = im.getchannel('A')
l, t, r, b = alpha.getbbox()
limit = t + (b - t) * 0.4
xs = [x for y in range(t, int(limit)) for x in range(l, r) if alpha.getpixel((x, y)) > 128]
return sum(xs) / len(xs), b
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument('src', help='cartella con <nome>_<fotogramma>.png')
p.add_argument('name')
p.add_argument('out')
p.add_argument('--height', type=int, default=120)
a = p.parse_args()
frames = {f: Image.open(Path(a.src) / f'{a.name}_{f}.png').convert('RGBA') for f in FRAMES}
stand = frames['stand']
factor = a.height / (stand.getchannel('A').getbbox()[3] - stand.getchannel('A').getbbox()[1])
strip, meta, x = [], {}, 0
for f, im in frames.items():
im = im.crop(im.getchannel('A').getbbox())
cx, base = torso_center(im)
w, h = max(1, round(im.width * factor)), max(1, round(im.height * factor))
im = im.resize((w, h), Image.LANCZOS)
strip.append(im)
meta[f] = {'x': x, 'y': 0, 'w': w, 'h': h, 'ax': round(cx * factor, 1), 'ay': h}
x += w + 2
sheet = Image.new('RGBA', (x, max(im.height for im in strip)), (0, 0, 0, 0))
for im, m in zip(strip, meta.values()):
sheet.paste(im, (m['x'], 0))
out = Path(a.out)
out.mkdir(parents=True, exist_ok=True)
sheet.save(out / f'{a.name}.png', optimize=True)
(out / f'{a.name}.json').write_text(json.dumps({'image': f'{a.name}.png', 'height': a.height, 'frames': meta}, indent=1) + '\n')
print(f'{out / a.name}.png: {sheet.width}x{sheet.height}, {len(meta)} fotogrammi')
if __name__ == '__main__':
main()
+562
View File
@@ -0,0 +1,562 @@
#!/usr/bin/env python3
"""Ritaglia lo sprite sheet su sfondo verde chroma in singoli PNG trasparenti.
Uso:
python3 tools/slice_sprites.py [--sheet assets/raw/spritesheet.png] [--names main]
[--out assets/sprites] [--cols 4 --rows 5]
[--thresh 200] [--pad 4] [--no-key --inset 6]
Esempi (vedi PROMPT_SPRITES.md per i tre sheet aggiuntivi):
python3 tools/slice_sprites.py --sheet assets/raw/sheet_hero_rear.png --names hero_rear --cols 4 --rows 5
python3 tools/slice_sprites.py --sheet assets/raw/sheet_textures.png --names textures --cols 4 --rows 4 --no-key
python3 tools/slice_sprites.py --sheet assets/raw/sheet_props.png --names props --cols 4 --rows 4
Dipendenze: solo Pillow. La griglia viene rilevata cercando righe/colonne
interamente verdi (gutter); --cols/--rows forzano una griglia fissa se il
rilevamento non torna.
"""
import argparse
import json
import sys
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageFilter
# Nomi delle celle di ogni sheet, riga per riga, da sinistra a destra (vedi PROMPT_SPRITES.md).
SHEETS = {
"main": [
"hero_run_1", "hero_run_2", "hero_run_3", "hero_run_4",
"hero_jump", "hero_slide", "hero_shoot", "weapon_blaster",
"enemy_grunt", "enemy_dodger", "enemy_drone", "enemy_tank",
"coin", "powerup_triple", "powerup_shield", "powerup_magnet",
"powerup_multiplier", "bullet", "explosion", "boss_head",
],
"hero_rear": [
"hero_back_run_1", "hero_back_run_2", "hero_back_run_3", "hero_back_run_4",
"hero_back_jump", "hero_back_slide", "hero_back_shoot", "hero_back_lean",
"enemy_front_grunt", "enemy_front_dodger", "enemy_front_drone", "enemy_front_tank",
"hero_front_idle", "hero_front_run", "hero_front_victory", "hero_portrait",
"hero_front_hurt", "hero_front_tired", "hero_front_thumbs", "boss_head_front",
],
"textures": [
"city_facade_a", "city_facade_b", "city_road", "city_sidewalk",
"desert_facade_a", "desert_facade_b", "desert_road", "desert_ground",
"neon_facade_a", "neon_facade_b", "neon_road", "neon_plates",
"crate", "barrier", "train_side", "container",
],
"props": [
"prop_city_lamp", "prop_city_tree", "prop_city_busstop", "prop_city_sign",
"prop_desert_cactus", "prop_desert_tree", "prop_desert_barrels", "prop_desert_sign",
"prop_neon_antenna", "prop_neon_holo", "prop_neon_pylon", "prop_neon_canisters",
"prop_cone", "prop_block", "prop_bush", "prop_rocks",
],
"nature_textures": [
"forest_facade_a", "forest_facade_b", "forest_road", "forest_ground",
"volcano_facade_a", "volcano_facade_b", "volcano_road", "volcano_ground",
"snow_facade_a", "snow_facade_b", "snow_road", "snow_ground",
],
"nature_props": [
"prop_forest_tree", "prop_forest_arch", "prop_forest_pillar", "prop_forest_ferns",
"prop_volcano_basalt", "prop_volcano_vent", "prop_volcano_tree", "prop_volcano_crystals",
"prop_snow_fir", "prop_snow_crystals", "prop_snow_sign", "prop_snow_rocks",
],
# Personaggio giocabile (sezione 7 di PROMPT_SPRITES.md), da usare con --prefix <id>_
"character": [
"back_run_1", "back_run_2", "back_run_3", "back_run_4",
"back_jump", "back_slide", "back_shoot", "back_lean",
"front_idle", "portrait", "front_thumbs", "front_tired",
],
# Secondo boss: la nuvola temporalesca (pose, colpi e impatti).
"storm": [
"storm_front", "storm_angry", "storm_left", "storm_back", "storm_right", "storm_front_2",
"storm_glare", "storm_roar", "storm_blow", "storm_charge", "storm_tornado", "storm_hurt",
"storm_drop", "storm_drops", "storm_bolt", "storm_strike", "storm_twister", "storm_gust",
"storm_splash", "storm_splash_big", "storm_bolt_impact", "storm_burst", "storm_rain", "storm_twister_impact",
],
# Nemico demone (al posto dei robot): righe da 7, 6, 6, 6, 6 celle.
"demon": [
"demon_front", "demon_turn_1", "demon_left", "demon_back", "demon_turn_4", "demon_right", "demon_angry",
"demon_run_1", "demon_run_2", "demon_run_3", "demon_run_4", "demon_run_5", "demon_run_6",
"demon_leap", "demon_flail", "demon_breath", "demon_slash", "demon_pound", "demon_ready",
"demon_hit", "demon_knock", "demon_fall", "demon_ko", "demon_dazed", "demon_dead",
"demon_fireball_1", "demon_fireball_2", "demon_fireball_3", "demon_ring", "demon_blast", "demon_pillar",
],
# Nemico di roccia: stessa disposizione del demone.
"rock": [
"rock_front", "rock_turn_1", "rock_left", "rock_back", "rock_turn_4", "rock_right", "rock_angry",
"rock_run_1", "rock_run_2", "rock_run_3", "rock_run_4", "rock_run_5", "rock_run_6",
"rock_windup", "rock_throw", "rock_lift", "rock_pound", "rock_swing", "rock_ready",
"rock_hit", "rock_knock", "rock_fall", "rock_down", "rock_dazed", "rock_dead",
"rock_boulder_1", "rock_boulders", "rock_boulder_2", "rock_blast", "rock_spikes", "rock_ring",
],
# Nemico di ferro: righe da 7, 6, 5, 6, 6 celle.
"iron": [
"iron_front", "iron_turn_1", "iron_left", "iron_back", "iron_turn_4", "iron_right", "iron_angry",
"iron_run_1", "iron_run_2", "iron_run_3", "iron_run_4", "iron_run_5", "iron_run_6",
"iron_windup", "iron_punch", "iron_pound", "iron_charge", "iron_spin",
"iron_dazed", "iron_hit", "iron_knock", "iron_fall", "iron_down", "iron_dead",
"iron_spikeball", "iron_spikeballs", "iron_drill", "iron_blast", "iron_pillar", "iron_ring",
],
# Camaleonte che si mimetizza (pose "ghost" semitrasparenti): righe da 8, 7, 5, 6, 7 celle.
"chameleon": [
"cham_front", "cham_turn_1", "cham_ghost_front", "cham_back", "cham_turn_4", "cham_ghost_side", "cham_ghost_eyes", "cham_angry",
"cham_sneak_1", "cham_sneak_2", "cham_sneak_3", "cham_sneak_4", "cham_sneak_5", "cham_sneak_6", "cham_sneak_7",
"cham_leap", "cham_swipe", "cham_slash", "cham_emerge", "cham_spin",
"cham_fade_1", "cham_fade_2", "cham_fade_3", "cham_fade_4", "cham_appear", "cham_fade_5",
"cham_smoke", "cham_leafburst", "cham_tornado", "cham_leaves", "cham_ring", "cham_blast", "cham_dart",
],
# Boss di magma: righe da 8, 8, 6, 6 celle (--row-cols 8,8,6,6).
"magma": [
"magma_front", "magma_turn_1", "magma_turn_2", "magma_turn_3", "magma_back", "magma_turn_5", "magma_turn_6", "magma_front_2",
"magma_glare", "magma_roar", "magma_blow", "magma_charge", "magma_tornado", "magma_erupt", "magma_dash", "magma_hurt",
"magma_rock", "magma_rocks", "magma_flame", "magma_boulder", "magma_spikes", "magma_wave",
"magma_splash", "magma_splash_big", "magma_pillar", "magma_burst", "magma_ring", "magma_crystals",
],
# Boss di ghiaccio: quattro righe da 7 celle (--row-cols 7,7,7,7).
"ice": [
"ice_front", "ice_turn_1", "ice_turn_2", "ice_back", "ice_turn_4", "ice_angry", "ice_front_2",
"ice_glare", "ice_roar", "ice_blow", "ice_stomp", "ice_charge", "ice_dash", "ice_hurt",
"ice_shard", "ice_shards", "ice_volley", "ice_tornado", "ice_spike", "ice_boulder", "ice_spikes",
"ice_splash", "ice_splash_big", "ice_pillar", "ice_burst", "ice_twister_impact", "ice_crystals", "ice_debris",
],
"weapons": [
"weapon_scatter", "weapon_plasma", "weapon_laser", "weapon_rocket",
"shot_scatter", "shot_plasma", "shot_laser", "shot_rocket",
"impact_scatter", "impact_plasma", "impact_laser", "impact_rocket",
],
}
NAMES = SHEETS["main"]
SENTINEL = (255, 0, 255) # colore usato dal flood fill per marcare lo sfondo
def anchor_for(name: str) -> str:
return "bottom-center" if name.startswith(("hero_", "enemy_", "prop_")) else "center"
def sample_background(im: Image.Image) -> tuple:
"""Colore verde dominante più frequente nello sheet (lo sfondo delle celle,
non le linee della griglia che possono essere di un verde più scuro)."""
small = im.convert("RGB").resize((max(1, im.width // 4), max(1, im.height // 4)))
counts = {}
for p in small.get_flattened_data() if hasattr(small, "get_flattened_data") else small.getdata():
if p[1] > p[0] + 100 and p[1] > p[2] + 100:
counts[p] = counts.get(p, 0) + 1
if not counts:
sys.exit("nessuno sfondo verde trovato nello sheet")
return max(counts, key=counts.get)
def find_runs(flags):
"""Trasforma una lista di booleani in intervalli [start, end) di True."""
runs, start = [], None
for i, f in enumerate(flags):
if f and start is None:
start = i
elif not f and start is not None:
runs.append((start, i))
start = None
if start is not None:
runs.append((start, len(flags)))
return runs
def is_screen(p) -> bool:
"""Verde "da schermo" per tinta: sfondo chiaro e linee della griglia verde scuro
(es. (0,28,0)) contano entrambi; i verdi degli oggetti hanno rosso/blu più alti."""
r, g, b = p[0], p[1], p[2]
m = max(r, b)
return m < 45 and g > 2 * m + 18
def screen_mask(im: Image.Image) -> Image.Image:
"""Maschera L: 255 dove il pixel è verde da schermo."""
rgb = im.convert("RGB")
mask = Image.new("L", rgb.size)
data = rgb.get_flattened_data() if hasattr(rgb, "get_flattened_data") else rgb.getdata()
mask.putdata([255 if is_screen(p) else 0 for p in data])
return mask
def detect_grid(im: Image.Image, bg: tuple, min_frac: float = .995, min_cell: int = 20):
"""Restituisce (colonne, righe) come liste di intervalli di celle.
Una colonna/riga è gutter se almeno `min_frac` dei suoi pixel è verde da schermo."""
mask = screen_mask(im)
w, h = mask.size
col_is_bg = [mask.crop((x, 0, x + 1, h)).histogram()[255] >= min_frac * h for x in range(w)]
row_is_bg = [mask.crop((0, y, w, y + 1)).histogram()[255] >= min_frac * w for y in range(h)]
cols = [r for r in find_runs([not c for c in col_is_bg]) if r[1] - r[0] >= min_cell]
rows = [r for r in find_runs([not c for c in row_is_bg]) if r[1] - r[0] >= min_cell]
return cols, rows
def fixed_grid(size: int, n: int):
step = size / n
return [(round(i * step), round((i + 1) * step)) for i in range(n)]
def snapped_grid(im: Image.Image, cols: int, rows: int, window: float = .06):
"""Griglia "agganciata": per ogni linea attesa (i * lato / n) cerca entro ±window
la fascia di colonne/righe più verdi (gutter + linea scura) e taglia le celle ai
suoi bordi. Regge gutter irregolari e oggetti che sfiorano la linea."""
mask = screen_mask(im)
w, h = mask.size
col_frac = [mask.crop((x, 0, x + 1, h)).histogram()[255] / h for x in range(w)]
row_frac = [mask.crop((0, y, w, y + 1)).histogram()[255] / w for y in range(h)]
def axis(frac, size, n):
edges = [(0, 0)]
for i in range(1, n):
c = round(i * size / n)
lo, hi = max(1, c - round(window * size)), min(size - 1, c + round(window * size))
best = max(range(lo, hi), key=lambda k: frac[k])
# allargo la fascia finché resta quasi tutta verde
limit = max(.9, frac[best] * .97)
a = b = best
while a - 1 > lo and frac[a - 1] >= limit: a -= 1
while b + 1 < hi and frac[b + 1] >= limit: b += 1
edges.append((a, b + 1))
edges.append((size, size))
return [(edges[i][1], edges[i + 1][0]) for i in range(n)]
return axis(col_frac, w, cols), axis(row_frac, h, rows)
def line_cuts(im: Image.Image, n: int, axis: int, min_frac: float = .3):
"""Intervalli delle n celle lungo un asse (0 = colonne, 1 = righe) usando le sottili
linee scure (nere o verde scuro) di separazione; None se non se ne trovano esattamente n - 1."""
rgb = im.convert("RGB")
gray = Image.new("L", rgb.size)
data = rgb.get_flattened_data() if hasattr(rgb, "get_flattened_data") else rgb.getdata()
gray.putdata([255 if r < 8 and b < 14 and g < 170 else 0 for r, g, b in data])
w, h = gray.size
size, other = (w, h) if axis == 0 else (h, w)
def frac(k):
box = (k, 0, k + 1, h) if axis == 0 else (0, k, w, k + 1)
return gray.crop(box).histogram()[255] / other
lines = find_runs([frac(k) >= min_frac for k in range(size)])
lines = [r for r in lines if 0 < r[0] and r[1] < size and r[1] - r[0] < 12]
if len(lines) != n - 1:
return None
edges = [0] + [x for r in lines for x in r] + [size]
return [(edges[2 * i], edges[2 * i + 1]) for i in range(n)]
def key_out_background(cell: Image.Image, bg: tuple, thresh: int, band: int = 8,
min_hole: int = 60, despill: bool = False) -> Image.Image:
"""Chroma key "green excess" in tre passi:
1. maschera dura: verde puro e brillante ovunque (anche nelle sacche chiuse,
es. tra le gambe) + verde puro più scuro (linee della griglia) solo se
collegato al bordo della cella, così i verdi interni di un oggetto
(es. la capsula del magnete) sopravvivono;
2. in una fascia di `band` px attorno alla maschera, alpha morbida stimata
dall'eccesso di verde e colore "smiscelato" dallo sfondo (niente frangia);
3. tutto il resto resta opaco.
"""
rgb = cell.convert("RGB")
w, h = rgb.size
px = rgb.load()
bg_g = max(bg[1], 1)
def excess(p): # quanto il pixel è "schermo verde": 0 = per niente, ~bg_g = sfondo puro
return p[1] - max(p[0], p[2])
hard = Image.new("L", (w, h), 0) # 255 = sfondo certo
cand = Image.new("L", (w, h), 0) # 255 = verde puro scuro, da confermare via flood fill
hp, cp = hard.load(), cand.load()
for y in range(h):
for x in range(w):
p = px[x, y]
d, s = excess(p), max(p[0], p[2])
if d > thresh:
hp[x, y] = 255
elif is_screen(p): # verde scuro delle linee di griglia: sfondo solo se collegato al bordo
cp[x, y] = 255
# flood fill dei candidati collegati al bordo
for x in range(w):
for y in (0, h - 1):
if cp[x, y] == 255:
ImageDraw.floodfill(cand, (x, y), 128)
for y in range(h):
for x in (0, w - 1):
if cp[x, y] == 255:
ImageDraw.floodfill(cand, (x, y), 128)
for y in range(h):
for x in range(w):
if cp[x, y] == 128 or (cp[x, y] == 255 and hp[x, y] == 255):
hp[x, y] = 255
# i candidati scuri adiacenti alla maschera dura sono comunque sfondo
grown = hard.filter(ImageFilter.MaxFilter(3))
gp = grown.load()
for y in range(h):
for x in range(w):
if cp[x, y] == 255 and gp[x, y] == 255:
hp[x, y] = 255
# componenti di sfondo isolate e minuscole (riflessi verdi dentro un oggetto)
# non sono buchi veri: le riporto a opaco
for y in range(h):
for x in range(w):
if hp[x, y] == 255:
ImageDraw.floodfill(hard, (x, y), 100)
area = hard.histogram()[100]
ImageDraw.floodfill(hard, (x, y), 0 if area < min_hole else 200)
for y in range(h):
for x in range(w):
if hp[x, y] == 200:
hp[x, y] = 255
# fascia di transizione attorno alla maschera dura
ring = hard.filter(ImageFilter.MaxFilter(2 * band + 1))
rp = ring.load()
out = rgb.copy()
op = out.load()
alpha = Image.new("L", (w, h), 255)
ap = alpha.load()
for y in range(h):
for x in range(w):
if hp[x, y] == 255:
ap[x, y] = 0
continue
if rp[x, y] != 255:
continue
p = px[x, y]
d = excess(p)
if d <= 0:
continue
a = max(0.0, min(1.0, 1.0 - d / bg_g))
if a < 0.04:
ap[x, y] = 0
continue
# smiscelo il colore dallo sfondo: c = (p - (1-a)*bg) / a
c = [max(0, min(255, round((p[i] - (1 - a) * bg[i]) / a))) for i in range(3)]
if despill and c[1] > max(c[0], c[2]): # solo per sheet senza oggetti verdi
c[1] = max(c[0], c[2])
op[x, y] = tuple(c)
ap[x, y] = round(a * 255)
if despill:
# Passata globale (solo sheet senza oggetti verdi): i pixel ancora "verde schermo",
# anche fuori dalla fascia di bordo (tra i ricci, nel fumo), diventano trasparenti
# in proporzione al verde e perdono la dominante verde.
for y in range(h):
for x in range(w):
if ap[x, y] == 0:
continue
p = px[x, y]
e = p[1] - max(p[0], p[2])
if e > 18:
ap[x, y] = round(ap[x, y] * max(0.0, 1 - (e - 18) / (bg_g * .5)))
r, g, b = op[x, y]
op[x, y] = (r, min(g, max(r, b)), b)
out.putalpha(alpha)
return out
def drop_border_slivers(img: Image.Image, max_frac: float = 0.015) -> Image.Image:
"""Elimina i frammenti piccoli attaccati al bordo della cella (pezzi di
sprite vicini finiti nel ritaglio quando la griglia non è perfetta)."""
alpha = img.getchannel("A")
w, h = alpha.size
solid = alpha.point(lambda v: 255 if v > 32 else 0)
sp = solid.load()
limit = max_frac * w * h
border = [(x, y) for x in range(w) for y in (0, h - 1)] + [(x, y) for y in range(h) for x in (0, w - 1)]
cleared = False
for x, y in border:
if sp[x, y] == 255:
ImageDraw.floodfill(solid, (x, y), 100)
area = solid.histogram()[100]
if area < limit:
ImageDraw.floodfill(solid, (x, y), 50) # da cancellare
cleared = True
else:
ImageDraw.floodfill(solid, (x, y), 200) # da tenere
if not cleared:
return img
ap = alpha.load()
for y in range(h):
for x in range(w):
if sp[x, y] == 50:
ap[x, y] = 0
out = img.copy()
out.putalpha(alpha)
return out
def trim(img: Image.Image, pad: int, min_alpha: int = 1) -> Image.Image:
# Ignora l'alone quasi invisibile nel calcolo dei margini, preservando l'alpha
# originale dentro il ritaglio (nessun chroma key sugli sprite già trasparenti).
bbox = img.getchannel("A").point(lambda a: 255 if a >= min_alpha else 0).getbbox()
if not bbox:
return img
l, t, r, b = bbox
l, t = max(0, l - pad), max(0, t - pad)
r, b = min(img.width, r + pad), min(img.height, b + pad)
return img.crop((l, t, r, b))
def checkerboard(size, tile=16):
board = Image.new("RGBA", size, (200, 200, 200, 255))
d = ImageDraw.Draw(board)
for y in range(0, size[1], tile):
for x in range(0, size[0], tile):
if (x // tile + y // tile) % 2:
d.rectangle((x, y, x + tile - 1, y + tile - 1), fill=(150, 150, 150, 255))
return board
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--sheet", default="assets/raw/spritesheet.png")
ap.add_argument("--out", default="assets/sprites")
ap.add_argument("--cols", type=int, default=0, help="forza il numero di colonne")
ap.add_argument("--rows", type=int, default=0, help="forza il numero di righe")
ap.add_argument("--fixed-grid", action="store_true", help="celle esattamente uguali, senza cercare separatori verdi")
ap.add_argument("--alpha", action="store_true", help="preserva l'alpha originale e rifila i margini trasparenti, senza chroma key")
ap.add_argument("--row-cols", default="",
help="celle per riga quando le righe hanno larghezze diverse (es. 8,8,6,6)")
ap.add_argument("--thresh", type=int, default=200,
help="eccesso di verde (g - max(r,b)) oltre cui un pixel è sfondo certo (0-255)")
ap.add_argument("--pad", type=int, default=4)
ap.add_argument("--band", type=int, default=8, help="larghezza in px della fascia di smiscelamento ai bordi")
ap.add_argument("--despill", action="store_true",
help="toglie il verde residuo dai bordi (solo per sheet senza oggetti verdi)")
ap.add_argument("--min-hole", type=int, default=60, help="area minima (px) di un buco interno per essere tenuto")
ap.add_argument("--small", type=int, default=128, help="lato della versione ridotta per l'HUD")
ap.add_argument("--webp-quality", type=int, default=88, help="qualità WebP (0-100)")
ap.add_argument("--names", default="main",
help="preset (" + ", ".join(SHEETS) + ") oppure elenco di nomi separati da virgola")
ap.add_argument("--prefix", default="", help="prefisso dei nomi (es. erika_ per un personaggio)")
ap.add_argument("--same-box", action="store_true",
help="stesso ritaglio per tutte le celle (cicli di animazione: il corpo non salta di lato)")
ap.add_argument("--no-key", action="store_true",
help="niente chroma key: ritaglia le celle piene (texture) con un bordo interno di --inset px")
ap.add_argument("--inset", type=int, default=6, help="pixel scartati sul bordo di ogni cella con --no-key")
args = ap.parse_args()
if args.fixed_grid and (not args.cols or not args.rows or args.row_cols):
ap.error("--fixed-grid richiede --cols e --rows, senza --row-cols")
if args.alpha and args.no_key:
ap.error("usa --alpha per sprite trasparenti oppure --no-key per texture piene")
names = SHEETS[args.names] if args.names in SHEETS else [n.strip() for n in args.names.split(",") if n.strip()]
names = [args.prefix + n for n in names]
preset = args.names if args.names in SHEETS else "custom"
sheet = Image.open(args.sheet).convert("RGBA")
bg = (0, 255, 0) if args.fixed_grid and (args.alpha or args.no_key) else sample_background(sheet)
print(f"sheet {sheet.size[0]}x{sheet.size[1]}, sfondo campionato {bg}")
if args.fixed_grid:
cols = [(round(i * sheet.width / args.cols), round((i + 1) * sheet.width / args.cols)) for i in range(args.cols)]
rows = [(round(i * sheet.height / args.rows), round((i + 1) * sheet.height / args.rows)) for i in range(args.rows)]
cells = [(x0, y0, x1, y1) for y0, y1 in rows for x0, x1 in cols]
ncols = args.cols
print(f"griglia esatta: {args.cols}x{args.rows}")
elif args.row_cols:
# Righe con celle di larghezza diversa: prima le linee orizzontali, poi le
# verticali di ogni riga, entrambe agganciate alle fasce più verdi.
counts = [int(c) for c in args.row_cols.split(",")]
rows = line_cuts(sheet, len(counts), 1) or snapped_grid(sheet, 1, len(counts))[1]
cells = []
for (y0, y1), n in zip(rows, counts):
band = sheet.crop((0, y0, sheet.width, y1))
band_cols = line_cuts(band, n, 0) or snapped_grid(band, n, 1)[0]
cells += [(x0, y0, x1, y1) for x0, x1 in band_cols]
ncols = max(counts)
print(f"griglia per righe: {counts}")
else:
cols, rows = detect_grid(sheet, bg)
if args.cols and args.rows and (len(cols) != args.cols or len(rows) != args.rows):
print(f"griglia rilevata {len(cols)}x{len(rows)} diversa da quella richiesta: aggancio le linee attese")
cols, rows = snapped_grid(sheet, args.cols, args.rows)
print(f"griglia: {len(cols)} colonne x {len(rows)} righe")
cells = [(x0, y0, x1, y1) for (y0, y1) in rows for (x0, x1) in cols]
ncols = len(cols)
if len(cells) != len(names):
sys.exit(f"attese {len(names)} celle, trovate {len(cells)}: usa --cols/--rows o --row-cols")
out_dir = Path(args.out)
small_dir = out_dir / str(args.small)
out_dir.mkdir(parents=True, exist_ok=True)
small_dir.mkdir(parents=True, exist_ok=True)
# L'atlas accumula gli sheet: si aggiornano solo le voci di questo sheet.
atlas_path = out_dir / "atlas.json"
atlas = json.loads(atlas_path.read_text()) if atlas_path.exists() else {}
sprites = []
# --same-box: un unico riquadro (unione dei contenuti di tutte le celle, in coordinate di cella)
# per ogni frame, così personaggio e linea dei piedi restano nello stesso punto.
keyed, box = {}, None
if args.same_box:
for name, (x0, y0, x1, y1) in zip(names, cells):
keyed[name] = drop_border_slivers(key_out_background(sheet.crop((x0, y0, x1, y1)), bg, args.thresh, args.band, args.min_hole, args.despill))
b = keyed[name].getchannel("A").point(lambda a: 255 if a >= 1 else 0).getbbox()
if b:
box = b if box is None else (min(box[0], b[0]), min(box[1], b[1]), max(box[2], b[2]), max(box[3], b[3]))
for name, (x0, y0, x1, y1) in zip(names, cells):
if True:
cell = sheet.crop((x0, y0, x1, y1))
if args.alpha:
sprite = trim(cell, args.pad, min_alpha=32)
elif args.no_key and args.fixed_grid:
k = args.inset
sprite = cell.crop((k, k, cell.width - k, cell.height - k))
elif args.no_key:
# Texture piena: rifilo righe/colonne di bordo con più del 2% di verde
# da schermo, poi scarto `inset` px di sicurezza.
mask = screen_mask(cell)
l, t, r, b = 0, 0, cell.width, cell.height
mx, my = cell.width // 8, cell.height // 8 # mai più di 1/8 per lato
green = lambda box, n, f: mask.crop(box).histogram()[255] > f * n
# prima i margini quasi interamente verdi, poi i residui sottili
for f in (.5, .02):
while l < mx and green((l, t, l + 1, b), b - t, f): l += 1
while r > cell.width - mx and green((r - 1, t, r, b), b - t, f): r -= 1
while t < my and green((l, t, r, t + 1), r - l, f): t += 1
while b > cell.height - my and green((l, b - 1, r, b), r - l, f): b -= 1
k = args.inset
sprite = cell.crop((l + k, t + k, r - k, b - k))
elif args.same_box and box:
k = keyed[name]
sprite = k.crop((max(0, box[0] - args.pad), max(0, box[1] - args.pad), min(k.width, box[2] + args.pad), min(k.height, box[3] + args.pad)))
else:
sprite = key_out_background(cell, bg, args.thresh, args.band, args.min_hole, args.despill)
sprite = trim(drop_border_slivers(sprite), args.pad)
alpha_bbox = sprite.getchannel("A").getbbox()
coverage = sum(sprite.getchannel("A").histogram()[33:]) / (sprite.width * sprite.height)
if not alpha_bbox or coverage < 0.02:
print(f" ATTENZIONE: {name} quasi vuoto (copertura {coverage:.1%})")
sprite.save(out_dir / f"{name}.png")
# WebP con alpha: ~10x più leggero del PNG, è quello che carica il gioco.
sprite.save(out_dir / f"{name}.webp", quality=args.webp_quality, method=6)
small = sprite.copy()
small.thumbnail((args.small, args.small), Image.LANCZOS)
small.save(small_dir / f"{name}.png")
small.save(small_dir / f"{name}.webp", quality=args.webp_quality, method=6)
atlas[name] = {
"file": f"{name}.webp",
"png": f"{name}.png",
"small": f"{args.small}/{name}.webp",
"width": sprite.width,
"height": sprite.height,
"anchor": anchor_for(name),
"cell": [x0, y0, x1, y1],
}
sprites.append((name, sprite))
print(f" {name:20s} {sprite.width:4d}x{sprite.height:<4d} copertura {coverage:.0%}")
atlas_path.write_text(json.dumps(atlas, indent=2) + "\n")
# Anteprima su scacchiera per controllo visivo.
tile = max(max(s.width, s.height) for _, s in sprites)
nrows = -(-len(sprites) // ncols)
prev = checkerboard((tile * ncols, tile * nrows))
for k, (_, s) in enumerate(sprites):
px = (k % ncols) * tile + (tile - s.width) // 2
py = (k // ncols) * tile + (tile - s.height) // 2
prev.alpha_composite(s, (px, py))
preview = "preview.png" if preset == "main" and not args.prefix else f"preview_{args.prefix}{preset}.png"
prev.save(out_dir / preview)
print(f"scritti {len(sprites)} sprite in {out_dir}/, atlas.json e {preview}")
if __name__ == "__main__":
main()