diff --git a/Dockerfile b/Dockerfile index c0716a0..d5c144b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf COPY index.html style.css /usr/share/nginx/html/ COPY src /usr/share/nginx/html/src COPY assets/scenes /usr/share/nginx/html/assets/scenes +COPY assets/characters /usr/share/nginx/html/assets/characters EXPOSE 80 HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 diff --git a/README.md b/README.md index a36d19b..e8a490a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ make down - `src/story/`: la storia come dati (`story.js`) e la sua soluzione completa (`solution.js`); - `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/characters/`: fogli dei personaggi, tagliati con `tools/slice_sprites.py` e allineati con + `tools/prepare_character.py` (istruzioni in testa al file). ## Test diff --git a/assets/characters/andrea.json b/assets/characters/andrea.json new file mode 100644 index 0000000..0b900b8 --- /dev/null +++ b/assets/characters/andrea.json @@ -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 + } + } +} diff --git a/assets/characters/andrea.png b/assets/characters/andrea.png new file mode 100644 index 0000000..22bc5a2 Binary files /dev/null and b/assets/characters/andrea.png differ diff --git a/src/engine/game.js b/src/engine/game.js index 13fc7d0..2e41a88 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -17,6 +17,7 @@ const DEFAULT_LINES = { const SPEED = 95; // Solo profilo, come i cicli di camminata: 'up' e 'down' lasciano il verso com'è. 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). export function check(cond, state) { @@ -57,12 +58,15 @@ export class Game { this.dialogue = null; this.walking = false; 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]; } // 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. - 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; } snapshot() { @@ -151,6 +155,15 @@ export class Game { startWalk(p) { this.path = findPath(this.scene.walk, [this.state.x, this.state.y], p); 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) { @@ -174,8 +187,14 @@ export class Game { if (this.speech.left > 0) return; this.speech = null; } + if (this.reach > 0) { + this.reach -= dt; + if (this.reach > 0) return; + } 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) { 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; @@ -203,8 +222,8 @@ export class Game { 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. 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.give) { if (!s.inventory.includes(e.give)) s.inventory.push(e.give); } + 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); this.reach = REACH; this.back = false; } 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.unset) delete s.flags[e.unset]; @@ -223,6 +242,7 @@ export class Game { this.state.facing = FACING[face] ?? this.state.facing; this.path = []; this.walking = false; + this.back = false; this.queue.unshift(...(this.scene.enter ?? [])); this.onSave(this.snapshot()); } diff --git a/src/engine/render.js b/src/engine/render.js index b6e8771..d2f5c86 100644 --- a/src/engine/render.js +++ b/src/engine/render.js @@ -56,15 +56,50 @@ export function drawAndrea(ctx, x, y, scale, facing, walkPhase) { ctx.restore(); } -// Sfondi delle scene, caricati una volta sola. Finché un'immagine non c'è si vedono le forme `art`. -const backgrounds = new Map(); -export function loadBackgrounds(story) { - return Promise.all(Object.values(story.scenes).filter(s => s.background).map(s => new Promise(resolve => { - const img = new Image(); - img.onload = () => { backgrounds.set(s.background, img); resolve(true); }; - img.onerror = () => resolve(false); - img.src = s.background; - }))); +// Sfondi e fogli dei personaggi, caricati una volta sola. Finché un'immagine non c'è si vedono i segnaposto. +const backgrounds = new Map(), sprites = new Map(); +const loadImage = src => new Promise(resolve => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = src; +}); + +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 } = {}) { @@ -74,8 +109,9 @@ export function render(ctx, game, { hover } = {}) { if (bg) ctx.drawImage(bg, 0, 0, WIDTH, HEIGHT); for (const shape of scene.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; - drawAndrea(ctx, s.x, s.y, game.scaleAt(s.y), s.facing, phase); + const andrea = sprites.get('andrea'); + 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) { ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.setLineDash([3, 3]); diff --git a/src/main.js b/src/main.js index 370e6c7..46e9a3d 100644 --- a/src/main.js +++ b/src/main.js @@ -2,7 +2,7 @@ import { story } from './story/story.js'; import { Game, VERBS, VERB_LABELS } from './engine/game.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'; const $ = id => document.getElementById(id); @@ -167,4 +167,4 @@ game = new Game(story); command = new Command(game); showTitle(); 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.'); }); diff --git a/src/story/story.js b/src/story/story.js index 2ce055f..688be81 100644 --- a/src/story/story.js +++ b/src/story/story.js @@ -6,7 +6,7 @@ export const story = { title: 'La Leggenda del Regolo', start: { scene: 'piazza', at: [250, 320] }, characters: { - andrea: { name: 'Andrea', color: '#ffffff' }, + andrea: { name: 'Andrea', color: '#ffffff', sprite: 'assets/characters/andrea.json' }, vecchioA: { name: 'Vecchietto col cappello', color: '#ffd27a' }, vecchioB: { name: 'Vecchietto col bastone', color: '#9fe3ff' }, castello: { name: 'Il Castello', color: '#c8f0a0' }, diff --git a/tests/logic.test.mjs b/tests/logic.test.mjs index 12f7bc1..c82b66b 100644 --- a/tests/logic.test.mjs +++ b/tests/logic.test.mjs @@ -140,3 +140,26 @@ test('lo stato salvato si ricarica uguale', () => { const again = new Game(mini(), { state: JSON.parse(JSON.stringify(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']); +}); diff --git a/tests/story.test.mjs b/tests/story.test.mjs index 91aa9e4..91a62ea 100644 --- a/tests/story.test.mjs +++ b/tests/story.test.mjs @@ -4,7 +4,7 @@ import { story } from '../src/story/story.js'; import { solution } from '../src/story/solution.js'; import { Game, VERBS } from '../src/engine/game.js'; import { inside } from '../src/engine/geometry.js'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { settle } from './helpers.mjs'; 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(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}`); + } +}); diff --git a/tools/prepare_character.py b/tools/prepare_character.py new file mode 100644 index 0000000..da4f47d --- /dev/null +++ b/tools/prepare_character.py @@ -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 _.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() diff --git a/tools/slice_sprites.py b/tools/slice_sprites.py new file mode 100644 index 0000000..2054ec8 --- /dev/null +++ b/tools/slice_sprites.py @@ -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 _ + "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()