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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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' },
|
||||||
|
|||||||
@@ -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
@@ -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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user