sprout-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sprout/__init__.py +3 -0
- sprout/assets/fonts/DejaVuSansMono-Bold.LICENSE.txt +78 -0
- sprout/assets/fonts/DejaVuSansMono-Bold.ttf +0 -0
- sprout/autotile.py +57 -0
- sprout/cli.py +518 -0
- sprout/exporter.py +687 -0
- sprout/generators/__init__.py +35 -0
- sprout/generators/base.py +31 -0
- sprout/generators/blob_walk.py +71 -0
- sprout/generators/font.py +79 -0
- sprout/generators/particles.py +136 -0
- sprout/generators/props.py +189 -0
- sprout/generators/terrain.py +179 -0
- sprout/generators/ui.py +169 -0
- sprout/sksl.py +139 -0
- sprout/spec.py +193 -0
- sprout_toolkit-0.1.0.dist-info/METADATA +208 -0
- sprout_toolkit-0.1.0.dist-info/RECORD +22 -0
- sprout_toolkit-0.1.0.dist-info/WHEEL +5 -0
- sprout_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- sprout_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- sprout_toolkit-0.1.0.dist-info/top_level.txt +1 -0
sprout/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
|
2
|
+
Upstream-Name: DejaVu fonts
|
|
3
|
+
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (original author),
|
|
4
|
+
see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list
|
|
5
|
+
Source: https://dejavu-fonts.github.io/
|
|
6
|
+
|
|
7
|
+
Files: *
|
|
8
|
+
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
|
|
9
|
+
Bitstream Vera is a trademark of Bitstream, Inc.
|
|
10
|
+
DejaVu changes are in public domain.
|
|
11
|
+
License: bitstream-vera
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of the fonts accompanying this license ("Fonts") and associated
|
|
14
|
+
documentation files (the "Font Software"), to reproduce and distribute the
|
|
15
|
+
Font Software, including without limitation the rights to use, copy, merge,
|
|
16
|
+
publish, distribute, and/or sell copies of the Font Software, and to permit
|
|
17
|
+
persons to whom the Font Software is furnished to do so, subject to the
|
|
18
|
+
following conditions:
|
|
19
|
+
.
|
|
20
|
+
The above copyright and trademark notices and this permission notice shall
|
|
21
|
+
be included in all copies of one or more of the Font Software typefaces.
|
|
22
|
+
.
|
|
23
|
+
The Font Software may be modified, altered, or added to, and in particular
|
|
24
|
+
the designs of glyphs or characters in the Fonts may be modified and
|
|
25
|
+
additional glyphs or characters may be added to the Fonts, only if the fonts
|
|
26
|
+
are renamed to names not containing either the words "Bitstream" or the word
|
|
27
|
+
"Vera".
|
|
28
|
+
.
|
|
29
|
+
This License becomes null and void to the extent applicable to Fonts or Font
|
|
30
|
+
Software that has been modified and is distributed under the "Bitstream
|
|
31
|
+
Vera" names.
|
|
32
|
+
.
|
|
33
|
+
The Font Software may be sold as part of a larger software package but no
|
|
34
|
+
copy of one or more of the Font Software typefaces may be sold by itself.
|
|
35
|
+
.
|
|
36
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
37
|
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
|
38
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
|
39
|
+
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
|
40
|
+
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
|
41
|
+
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
|
42
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
|
43
|
+
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
|
44
|
+
FONT SOFTWARE.
|
|
45
|
+
.
|
|
46
|
+
Except as contained in this notice, the names of Gnome, the Gnome
|
|
47
|
+
Foundation, and Bitstream Inc., shall not be used in advertising or
|
|
48
|
+
otherwise to promote the sale, use or other dealings in this Font Software
|
|
49
|
+
without prior written authorization from the Gnome Foundation or Bitstream
|
|
50
|
+
Inc., respectively. For further information, contact: fonts at gnome dot
|
|
51
|
+
org.
|
|
52
|
+
|
|
53
|
+
Files: debian/*
|
|
54
|
+
Copyright: (C) 2005-2006 Peter Cernak <pce@users.sourceforge.net>
|
|
55
|
+
(C) 2006-2011 Davide Viti <zinosat@tiscali.it>
|
|
56
|
+
(C) 2011-2013 Christian Perrier <bubulle@debian.org>
|
|
57
|
+
(C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
|
|
58
|
+
License: GPL-2+
|
|
59
|
+
This program is free software; you can redistribute it
|
|
60
|
+
and/or modify it under the terms of the GNU General Public
|
|
61
|
+
License as published by the Free Software Foundation; either
|
|
62
|
+
version 2 of the License, or (at your option) any later
|
|
63
|
+
version.
|
|
64
|
+
.
|
|
65
|
+
This program is distributed in the hope that it will be
|
|
66
|
+
useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
67
|
+
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
|
68
|
+
PURPOSE. See the GNU General Public License for more
|
|
69
|
+
details.
|
|
70
|
+
.
|
|
71
|
+
You should have received a copy of the GNU General Public
|
|
72
|
+
License along with this package; if not, write to the Free
|
|
73
|
+
Software Foundation, Inc., 51 Franklin St, Fifth Floor,
|
|
74
|
+
Boston, MA 02110-1301 USA
|
|
75
|
+
.
|
|
76
|
+
On Debian systems, the full text of the GNU General Public
|
|
77
|
+
License version 2 can be found in the file
|
|
78
|
+
/usr/share/common-licenses/GPL-2'.
|
|
Binary file
|
sprout/autotile.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Autotiling 16 / 47 — reducción canónica de máscaras de vecindad 8-bit.
|
|
2
|
+
|
|
3
|
+
Bits (convención canónica sprout, ver INVESTIGACION.md §5.3)::
|
|
4
|
+
|
|
5
|
+
N=1, NE=2, E=4, SE=8, S=16, SW=32, W=64, NW=128
|
|
6
|
+
|
|
7
|
+
El bit diagonal solo cuenta si **ambos** cardinales adyacentes están presentes:
|
|
8
|
+
así 2**8 = 256 vecindades colapsan a 47. Un "corner bit" describe el cuadrante
|
|
9
|
+
donde se juntan sus dos lados; si falta un lado, ese cuadrante ya es borde
|
|
10
|
+
exterior y la diagonal no aporta una silueta distinta.
|
|
11
|
+
|
|
12
|
+
Referencia verificada contra Godot 4.7 (Blobsmith Autotile Wirer, MIT):
|
|
13
|
+
https://github.com/leobaray/blobsmith-autotile-wirer/blob/master/docs/why-47-tiles-not-256.md
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
N, NE, E, SE, S, SW, W, NW = 1, 2, 4, 8, 16, 32, 64, 128
|
|
18
|
+
|
|
19
|
+
SIDES = N | E | S | W # 0b01010101 == 85
|
|
20
|
+
CORNERS = NE | SE | SW | NW
|
|
21
|
+
|
|
22
|
+
BITMASK = "N1,E4,S16,W64,NE2,SE8,SW32,NW128"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def canonical_47(mask: int) -> int:
|
|
26
|
+
"""Máscara canónica del sistema 8-bit (47 clases)."""
|
|
27
|
+
m = mask & SIDES
|
|
28
|
+
if (mask & NE) and (mask & N) and (mask & E):
|
|
29
|
+
m |= NE
|
|
30
|
+
if (mask & SE) and (mask & S) and (mask & E):
|
|
31
|
+
m |= SE
|
|
32
|
+
if (mask & SW) and (mask & S) and (mask & W):
|
|
33
|
+
m |= SW
|
|
34
|
+
if (mask & NW) and (mask & N) and (mask & W):
|
|
35
|
+
m |= NW
|
|
36
|
+
return m
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def canonical_16(mask: int) -> int:
|
|
40
|
+
"""Máscara canónica del sistema cardinal (16 clases, Match Sides)."""
|
|
41
|
+
return mask & SIDES
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def canonical(mask: int, size: int) -> int:
|
|
45
|
+
if size == 16:
|
|
46
|
+
return canonical_16(mask)
|
|
47
|
+
if size == 47:
|
|
48
|
+
return canonical_47(mask)
|
|
49
|
+
raise ValueError(f"autotile no soportado: {size!r} (esperado 16 | 47)")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def masks(size: int) -> list[int]:
|
|
53
|
+
"""Máscaras canónicas en orden ascendente = orden de la hoja (cols=8)."""
|
|
54
|
+
fn = {16: canonical_16, 47: canonical_47}.get(size)
|
|
55
|
+
if fn is None:
|
|
56
|
+
raise ValueError(f"autotile no soportado: {size!r} (esperado 16 | 47)")
|
|
57
|
+
return [m for m in range(256) if fn(m) == m]
|
sprout/cli.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""CLI `sprout`: generación procedural determinista de assets 2D para Expo.
|
|
2
|
+
|
|
3
|
+
Uso:
|
|
4
|
+
sprout generate specs/demo.json --out ../demo/assets/procgen
|
|
5
|
+
sprout generate specs/demo.json --png-mode png8 --texturepacker --mipmaps
|
|
6
|
+
sprout batch specs/ --out ../demo/assets/procgen
|
|
7
|
+
sprout watch specs/ --out ../demo/assets/procgen
|
|
8
|
+
sprout info specs/demo.json [--json]
|
|
9
|
+
sprout lint specs/demo.json [--json]
|
|
10
|
+
sprout diff <a> <b> [--json]
|
|
11
|
+
sprout validate specs/demo.json
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
import zlib
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .exporter import (
|
|
25
|
+
apply_png_mode,
|
|
26
|
+
build_autotile_map,
|
|
27
|
+
build_font_map,
|
|
28
|
+
build_manifest,
|
|
29
|
+
build_shader,
|
|
30
|
+
build_sheet,
|
|
31
|
+
build_texturepacker,
|
|
32
|
+
compute_mipmap_meta,
|
|
33
|
+
emit_index_ts,
|
|
34
|
+
render_items,
|
|
35
|
+
shader_filename,
|
|
36
|
+
texturepacker_filename,
|
|
37
|
+
write_manifest,
|
|
38
|
+
write_mipmap_files,
|
|
39
|
+
write_png,
|
|
40
|
+
write_texturepacker,
|
|
41
|
+
)
|
|
42
|
+
from .generators.base import FrameData
|
|
43
|
+
from .spec import Spec, SpecError, load_spec
|
|
44
|
+
|
|
45
|
+
app = typer.Typer(add_completion=False, no_args_is_help=True,
|
|
46
|
+
help="sprout — assets 2D procedurales deterministas para Expo + react-native-skia.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
PNG_MODES = ("rgba", "png8", "png24")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _generate(spec_path: Path, out_dir: Path | None, seed: int | None,
|
|
53
|
+
skip_existing: bool, png_mode: str = "rgba",
|
|
54
|
+
texturepacker: bool = False, mipmaps: bool = False,
|
|
55
|
+
mip_levels: int = 3) -> dict:
|
|
56
|
+
spec = load_spec(spec_path)
|
|
57
|
+
if seed is not None:
|
|
58
|
+
spec.seed = seed
|
|
59
|
+
|
|
60
|
+
frames = render_items(spec)
|
|
61
|
+
sheet, records = build_sheet(spec, frames)
|
|
62
|
+
out = out_dir if out_dir is not None else spec_path.parent
|
|
63
|
+
atlas_name = spec.filename
|
|
64
|
+
atlas_path = out / atlas_name
|
|
65
|
+
manifest_path = out / "manifest.json"
|
|
66
|
+
index_path = out / "index.ts"
|
|
67
|
+
tp_path = out / texturepacker_filename(spec) if texturepacker else None
|
|
68
|
+
|
|
69
|
+
autotile_map = build_autotile_map(spec, frames)
|
|
70
|
+
font_map = build_font_map(spec, frames)
|
|
71
|
+
shader_source, shader_block = build_shader(spec)
|
|
72
|
+
shader_path = out / shader_filename(spec) if shader_block else None
|
|
73
|
+
mip_meta = compute_mipmap_meta(sheet, spec, mip_levels) if mipmaps else []
|
|
74
|
+
manifest = build_manifest(spec, records, atlas_name, sheet, str(spec_path),
|
|
75
|
+
autotile_map, shader_block, font_map,
|
|
76
|
+
{"levels": mip_meta} if mip_meta else None)
|
|
77
|
+
|
|
78
|
+
if skip_existing and atlas_path.is_file() and manifest_path.is_file() and index_path.is_file():
|
|
79
|
+
missing_extra = (
|
|
80
|
+
(shader_block and not (shader_path and shader_path.is_file()))
|
|
81
|
+
or (texturepacker and not tp_path.is_file())
|
|
82
|
+
or (mipmaps and not all((out / lvl["file"]).is_file() for lvl in mip_meta))
|
|
83
|
+
)
|
|
84
|
+
if missing_extra:
|
|
85
|
+
pass # falta un artefacto opcional -> regenerar
|
|
86
|
+
else:
|
|
87
|
+
current = zlib.crc32(atlas_path.read_bytes()) & 0xFFFFFFFF
|
|
88
|
+
probe = io.BytesIO()
|
|
89
|
+
apply_png_mode(sheet, png_mode).save(probe, format="PNG")
|
|
90
|
+
fresh = zlib.crc32(probe.getvalue()) & 0xFFFFFFFF
|
|
91
|
+
same_manifest = manifest_path.read_bytes() == (
|
|
92
|
+
json.dumps(manifest, indent=2) + "\n"
|
|
93
|
+
).encode()
|
|
94
|
+
same_shader = (
|
|
95
|
+
shader_source is None
|
|
96
|
+
or (shader_path is not None
|
|
97
|
+
and shader_path.read_bytes() == shader_source.encode())
|
|
98
|
+
)
|
|
99
|
+
if current == fresh and same_manifest and same_shader:
|
|
100
|
+
return {"spec": spec, "out": out, "atlas": atlas_path, "crc": current,
|
|
101
|
+
"skipped": True}
|
|
102
|
+
|
|
103
|
+
crc = write_png(sheet, atlas_path, png_mode)
|
|
104
|
+
write_manifest(manifest, manifest_path)
|
|
105
|
+
if shader_source is not None and shader_path is not None:
|
|
106
|
+
shader_path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
shader_path.write_text(shader_source)
|
|
108
|
+
emit_index_ts(manifest, index_path)
|
|
109
|
+
if texturepacker:
|
|
110
|
+
tp = build_texturepacker(spec, records, atlas_name, sheet, png_mode)
|
|
111
|
+
write_texturepacker(tp, tp_path)
|
|
112
|
+
if mipmaps:
|
|
113
|
+
write_mipmap_files(sheet, out, png_mode, mip_meta)
|
|
114
|
+
return {"spec": spec, "out": out, "atlas": atlas_path, "crc": crc, "skipped": False}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command()
|
|
118
|
+
def generate(
|
|
119
|
+
spec: Path = typer.Argument(..., help="spec.json a generar"),
|
|
120
|
+
out: Path = typer.Option(None, "--out", "-o", help="directorio de salida (default: junto a la spec)"),
|
|
121
|
+
seed: int = typer.Option(None, "--seed", "-s", help=f"sobreescribe el seed de la spec"),
|
|
122
|
+
skip_existing: bool = typer.Option(False, "--skip-existing", help="no reescribe si el atlas ya existe con el mismo crc"),
|
|
123
|
+
png_mode: str = typer.Option("rgba", "--png-mode", help="formato del atlas: rgba | png8 | png24"),
|
|
124
|
+
texturepacker: bool = typer.Option(False, "--texturepacker", help="emite además <name>.tpsheet.json (formato TexturePacker JSON Hash)"),
|
|
125
|
+
mipmaps: bool = typer.Option(False, "--mipmaps", help="genera una cadena de mip levels del atlas (@0.5x, @0.25x, ...)"),
|
|
126
|
+
mipmap_levels: int = typer.Option(3, "--mipmap-levels", help="cantidad máxima de niveles de mip (con --mipmaps)"),
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Genera spritesheet + manifest.json + index.ts desde una spec."""
|
|
129
|
+
if png_mode not in PNG_MODES:
|
|
130
|
+
typer.secho(f"--png-mode inválido '{png_mode}' (disponibles: {', '.join(PNG_MODES)})",
|
|
131
|
+
fg=typer.colors.RED, err=True)
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
try:
|
|
134
|
+
r = _generate(spec, out, seed, skip_existing, png_mode, texturepacker, mipmaps, mipmap_levels)
|
|
135
|
+
except SpecError as e:
|
|
136
|
+
typer.secho(f"error en {spec}: {e}", fg=typer.colors.RED, err=True)
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
|
|
139
|
+
s: Spec = r["spec"]
|
|
140
|
+
if r["skipped"]:
|
|
141
|
+
typer.secho(f"[{s.name}] sin cambios (crc {r['crc']:08x}) — skip", fg=typer.colors.YELLOW)
|
|
142
|
+
return
|
|
143
|
+
typer.secho(
|
|
144
|
+
f"[{s.name}] seed={s.seed} frames={s.total_frames} "
|
|
145
|
+
f"sheet={s.layout.cols}x{s.layout.resolve_rows(s.total_frames)} grid"
|
|
146
|
+
f" -> {r['out'].resolve()}",
|
|
147
|
+
fg=typer.colors.GREEN,
|
|
148
|
+
)
|
|
149
|
+
typer.secho(f" atlas.png crc=0x{r['crc']:08x}", fg=typer.colors.BRIGHT_BLACK)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.command()
|
|
153
|
+
def batch(
|
|
154
|
+
specs_dir: Path = typer.Argument(..., help="directorio (o glob) con specs"),
|
|
155
|
+
out: Path = typer.Option(None, "--out", "-o", help="directorio de salida común"),
|
|
156
|
+
skip_existing: bool = typer.Option(False, "--skip-existing", help="no reescribe atlases sin cambios"),
|
|
157
|
+
png_mode: str = typer.Option("rgba", "--png-mode", help="formato del atlas: rgba | png8 | png24"),
|
|
158
|
+
texturepacker: bool = typer.Option(False, "--texturepacker", help="emite además <name>.tpsheet.json (formato TexturePacker JSON Hash)"),
|
|
159
|
+
mipmaps: bool = typer.Option(False, "--mipmaps", help="genera una cadena de mip levels del atlas (@0.5x, @0.25x, ...)"),
|
|
160
|
+
mipmap_levels: int = typer.Option(3, "--mipmap-levels", help="cantidad máxima de niveles de mip (con --mipmaps)"),
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Genera todas las specs de un directorio (patrón *.json)."""
|
|
163
|
+
if png_mode not in PNG_MODES:
|
|
164
|
+
typer.secho(f"--png-mode inválido '{png_mode}' (disponibles: {', '.join(PNG_MODES)})",
|
|
165
|
+
fg=typer.colors.RED, err=True)
|
|
166
|
+
raise typer.Exit(1)
|
|
167
|
+
files = sorted(specs_dir.glob("*.json"))
|
|
168
|
+
if not files:
|
|
169
|
+
typer.secho(f"no hay specs (*.json) en {specs_dir}", fg=typer.colors.RED, err=True)
|
|
170
|
+
raise typer.Exit(1)
|
|
171
|
+
failed = 0
|
|
172
|
+
for f in files:
|
|
173
|
+
try:
|
|
174
|
+
_generate(f, out, None, skip_existing, png_mode, texturepacker, mipmaps, mipmap_levels)
|
|
175
|
+
except SpecError as e:
|
|
176
|
+
typer.secho(f"error en {f}: {e}", fg=typer.colors.RED, err=True)
|
|
177
|
+
failed += 1
|
|
178
|
+
typer.echo(f"[batch] {len(files) - failed}/{len(files)} specs ok")
|
|
179
|
+
if failed:
|
|
180
|
+
raise typer.Exit(1)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command()
|
|
184
|
+
def validate(spec: Path = typer.Argument(..., help="spec.json a validar")) -> None:
|
|
185
|
+
"""Valida la estructura y los plug-ins de una spec."""
|
|
186
|
+
try:
|
|
187
|
+
s = load_spec(spec)
|
|
188
|
+
except SpecError as e:
|
|
189
|
+
typer.secho(f"inválida: {e}", fg=typer.colors.RED, err=True)
|
|
190
|
+
raise typer.Exit(1)
|
|
191
|
+
typer.secho(
|
|
192
|
+
f"[{s.name}] OK — items={[i.id for i in s.items]} "
|
|
193
|
+
f"frames={s.total_frames} seed={s.seed}",
|
|
194
|
+
fg=typer.colors.GREEN,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command()
|
|
199
|
+
def info(
|
|
200
|
+
spec: Path = typer.Argument(..., help="spec.json a inspeccionar"),
|
|
201
|
+
as_json: bool = typer.Option(False, "--json", help="salida machine-readable"),
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Reporte de una spec: items, frames, layout y tamaño estimado del atlas."""
|
|
204
|
+
try:
|
|
205
|
+
s = load_spec(spec)
|
|
206
|
+
except SpecError as e:
|
|
207
|
+
typer.secho(f"inválida: {e}", fg=typer.colors.RED, err=True)
|
|
208
|
+
raise typer.Exit(1)
|
|
209
|
+
|
|
210
|
+
cols = s.layout.cols
|
|
211
|
+
rows = s.layout.resolve_rows(s.total_frames)
|
|
212
|
+
fpx = s.layout.frame_px
|
|
213
|
+
atlas_w, atlas_h = cols * fpx, rows * fpx
|
|
214
|
+
|
|
215
|
+
if as_json:
|
|
216
|
+
typer.echo(json.dumps({
|
|
217
|
+
"name": s.name,
|
|
218
|
+
"seed": s.seed,
|
|
219
|
+
"target": s.target,
|
|
220
|
+
"spec": str(spec),
|
|
221
|
+
"layout": {
|
|
222
|
+
"framePx": fpx, "cols": cols, "rows": rows,
|
|
223
|
+
"tileLogical": s.layout.tile_logical, "sample": s.layout.sample,
|
|
224
|
+
},
|
|
225
|
+
"atlas": {
|
|
226
|
+
"file": s.filename, "width": atlas_w, "height": atlas_h,
|
|
227
|
+
"frames": s.total_frames,
|
|
228
|
+
},
|
|
229
|
+
"items": [
|
|
230
|
+
{"id": it.id, "generator": it.generator, "frames": it.frames,
|
|
231
|
+
"autotile": it.autotile, "params": it.params}
|
|
232
|
+
for it in s.items
|
|
233
|
+
],
|
|
234
|
+
"animations": {
|
|
235
|
+
n: {"frames": a.frames, "fps": a.fps, "loop": a.loop}
|
|
236
|
+
for n, a in s.animations.items()
|
|
237
|
+
},
|
|
238
|
+
"runtime": bool(s.runtime),
|
|
239
|
+
}, indent=2))
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
typer.secho(f"[{s.name}] {spec}", fg=typer.colors.GREEN, bold=True)
|
|
243
|
+
typer.echo(f" seed : {s.seed}")
|
|
244
|
+
typer.echo(f" target : {s.target}")
|
|
245
|
+
typer.echo(f" layout : framePx={fpx} cols={cols} rows={rows} "
|
|
246
|
+
f"tileLogical={s.layout.tile_logical} sample={s.layout.sample}")
|
|
247
|
+
typer.echo(f" atlas : {s.filename} {atlas_w}x{atlas_h} ({s.total_frames} frames)")
|
|
248
|
+
typer.echo(f" runtime : {'sí' if s.runtime else 'no'}")
|
|
249
|
+
typer.echo(" items:")
|
|
250
|
+
for it in s.items:
|
|
251
|
+
extra = ""
|
|
252
|
+
if it.autotile:
|
|
253
|
+
extra += f" autotile={it.autotile}"
|
|
254
|
+
keys = sorted(k for k in it.params if k != "autotile")
|
|
255
|
+
if keys:
|
|
256
|
+
extra += f" params[{','.join(keys)}]"
|
|
257
|
+
typer.echo(f" - {it.id:<14} {it.generator:<10} frames={it.frames}{extra}")
|
|
258
|
+
if s.animations:
|
|
259
|
+
typer.echo(" anim:")
|
|
260
|
+
for name, a in s.animations.items():
|
|
261
|
+
typer.echo(f" - {name:<14} frames={a.frames} fps={a.fps} loop={a.loop}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
PADDING_WARN_RATIO = 0.25
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _lint_warnings(spec: Spec, items_frames: list[list[FrameData]]) -> list[dict]:
|
|
268
|
+
"""Advertencias de calidad de una spec ya renderizada: padding de atlas
|
|
269
|
+
(celdas de grilla sin usar) y frames completamente transparentes."""
|
|
270
|
+
warnings: list[dict] = []
|
|
271
|
+
|
|
272
|
+
cols = spec.layout.cols
|
|
273
|
+
rows = spec.layout.resolve_rows(spec.total_frames)
|
|
274
|
+
capacity = cols * rows
|
|
275
|
+
unused = capacity - spec.total_frames
|
|
276
|
+
if capacity and unused / capacity > PADDING_WARN_RATIO:
|
|
277
|
+
warnings.append({
|
|
278
|
+
"check": "padding",
|
|
279
|
+
"message": f"{unused}/{capacity} celdas del atlas sin usar ({unused / capacity:.0%})",
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
empty_ids = [
|
|
283
|
+
fr.id
|
|
284
|
+
for item, frames in zip(spec.items, items_frames, strict=True)
|
|
285
|
+
# el generador `font` produce glifos vacíos a propósito (el espacio
|
|
286
|
+
# no pinta ningún píxel) — no es un frame desperdiciado.
|
|
287
|
+
if item.generator != "font"
|
|
288
|
+
for fr in frames
|
|
289
|
+
# los tiles RGB (p. ej. terrain sin autotile) son opacos por diseño
|
|
290
|
+
# y no tienen canal alpha que consultar.
|
|
291
|
+
if fr.image.mode == "RGBA" and fr.image.getchannel("A").getbbox() is None
|
|
292
|
+
]
|
|
293
|
+
if empty_ids:
|
|
294
|
+
warnings.append({
|
|
295
|
+
"check": "empty_frames",
|
|
296
|
+
"message": f"{len(empty_ids)} frame(s) completamente transparentes",
|
|
297
|
+
"ids": empty_ids,
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
return warnings
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@app.command()
|
|
304
|
+
def lint(
|
|
305
|
+
spec: Path = typer.Argument(..., help="spec.json a analizar"),
|
|
306
|
+
as_json: bool = typer.Option(False, "--json", help="salida machine-readable"),
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Analiza una spec: padding de atlas y frames vacíos (renderiza para verificar)."""
|
|
309
|
+
try:
|
|
310
|
+
s = load_spec(spec)
|
|
311
|
+
items_frames = render_items(s)
|
|
312
|
+
except SpecError as e:
|
|
313
|
+
typer.secho(f"inválida: {e}", fg=typer.colors.RED, err=True)
|
|
314
|
+
raise typer.Exit(1)
|
|
315
|
+
|
|
316
|
+
warnings = _lint_warnings(s, items_frames)
|
|
317
|
+
if as_json:
|
|
318
|
+
typer.echo(json.dumps({"spec": str(spec), "warnings": warnings}, indent=2))
|
|
319
|
+
elif not warnings:
|
|
320
|
+
typer.secho(f"[{s.name}] OK — sin advertencias", fg=typer.colors.GREEN)
|
|
321
|
+
else:
|
|
322
|
+
typer.secho(f"[{s.name}] {len(warnings)} advertencia(s):", fg=typer.colors.YELLOW)
|
|
323
|
+
for w in warnings:
|
|
324
|
+
typer.echo(f" - [{w['check']}] {w['message']}")
|
|
325
|
+
raise typer.Exit(1 if warnings else 0)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _diff_specs(a: Spec, b: Spec) -> list[dict]:
|
|
329
|
+
"""Diferencias estructurales entre dos specs: `[{"field", "a", "b"}, ...]`."""
|
|
330
|
+
diffs: list[dict] = []
|
|
331
|
+
|
|
332
|
+
for field in ("name", "seed", "target"):
|
|
333
|
+
av, bv = getattr(a, field), getattr(b, field)
|
|
334
|
+
if av != bv:
|
|
335
|
+
diffs.append({"field": field, "a": av, "b": bv})
|
|
336
|
+
|
|
337
|
+
for f in ("frame_px", "cols", "tile_logical", "sample"):
|
|
338
|
+
av, bv = getattr(a.layout, f), getattr(b.layout, f)
|
|
339
|
+
if av != bv:
|
|
340
|
+
diffs.append({"field": f"layout.{f}", "a": av, "b": bv})
|
|
341
|
+
|
|
342
|
+
a_items = {i.id: i for i in a.items}
|
|
343
|
+
b_items = {i.id: i for i in b.items}
|
|
344
|
+
for iid in sorted(set(b_items) - set(a_items)):
|
|
345
|
+
diffs.append({"field": f"items.{iid}", "a": None, "b": "added"})
|
|
346
|
+
for iid in sorted(set(a_items) - set(b_items)):
|
|
347
|
+
diffs.append({"field": f"items.{iid}", "a": "removed", "b": None})
|
|
348
|
+
for iid in sorted(set(a_items) & set(b_items)):
|
|
349
|
+
ia, ib = a_items[iid], b_items[iid]
|
|
350
|
+
if (ia.generator, ia.frames, ia.params) != (ib.generator, ib.frames, ib.params):
|
|
351
|
+
diffs.append({
|
|
352
|
+
"field": f"items.{iid}",
|
|
353
|
+
"a": {"generator": ia.generator, "frames": ia.frames, "params": ia.params},
|
|
354
|
+
"b": {"generator": ib.generator, "frames": ib.frames, "params": ib.params},
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
a_anim, b_anim = a.animations, b.animations
|
|
358
|
+
for name in sorted(set(b_anim) - set(a_anim)):
|
|
359
|
+
diffs.append({"field": f"animations.{name}", "a": None, "b": "added"})
|
|
360
|
+
for name in sorted(set(a_anim) - set(b_anim)):
|
|
361
|
+
diffs.append({"field": f"animations.{name}", "a": "removed", "b": None})
|
|
362
|
+
for name in sorted(set(a_anim) & set(b_anim)):
|
|
363
|
+
ia, ib = a_anim[name], b_anim[name]
|
|
364
|
+
if (ia.frames, ia.fps, ia.loop) != (ib.frames, ib.fps, ib.loop):
|
|
365
|
+
diffs.append({
|
|
366
|
+
"field": f"animations.{name}",
|
|
367
|
+
"a": {"frames": ia.frames, "fps": ia.fps, "loop": ia.loop},
|
|
368
|
+
"b": {"frames": ib.frames, "fps": ib.fps, "loop": ib.loop},
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
return diffs
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _diff_outputs(a: Path, b: Path) -> list[dict]:
|
|
375
|
+
"""Diferencias entre dos directorios de salida: CRC del atlas + manifest.json."""
|
|
376
|
+
diffs: list[dict] = []
|
|
377
|
+
|
|
378
|
+
atlas_a, atlas_b = a / "atlas.png", b / "atlas.png"
|
|
379
|
+
if atlas_a.is_file() and atlas_b.is_file():
|
|
380
|
+
crc_a = zlib.crc32(atlas_a.read_bytes()) & 0xFFFFFFFF
|
|
381
|
+
crc_b = zlib.crc32(atlas_b.read_bytes()) & 0xFFFFFFFF
|
|
382
|
+
if crc_a != crc_b:
|
|
383
|
+
diffs.append({"field": "atlas.crc", "a": f"{crc_a:08x}", "b": f"{crc_b:08x}"})
|
|
384
|
+
else:
|
|
385
|
+
diffs.append({"field": "atlas.png", "a": atlas_a.is_file(), "b": atlas_b.is_file()})
|
|
386
|
+
|
|
387
|
+
man_a_p, man_b_p = a / "manifest.json", b / "manifest.json"
|
|
388
|
+
if not (man_a_p.is_file() and man_b_p.is_file()):
|
|
389
|
+
diffs.append({"field": "manifest.json", "a": man_a_p.is_file(), "b": man_b_p.is_file()})
|
|
390
|
+
return diffs
|
|
391
|
+
|
|
392
|
+
man_a = json.loads(man_a_p.read_text())
|
|
393
|
+
man_b = json.loads(man_b_p.read_text())
|
|
394
|
+
|
|
395
|
+
for field in ("name", "seed"):
|
|
396
|
+
if man_a.get(field) != man_b.get(field):
|
|
397
|
+
diffs.append({"field": field, "a": man_a.get(field), "b": man_b.get(field)})
|
|
398
|
+
|
|
399
|
+
size_a = (man_a["files"]["atlasW"], man_a["files"]["atlasH"])
|
|
400
|
+
size_b = (man_b["files"]["atlasW"], man_b["files"]["atlasH"])
|
|
401
|
+
if size_a != size_b:
|
|
402
|
+
diffs.append({"field": "atlas.size", "a": list(size_a), "b": list(size_b)})
|
|
403
|
+
|
|
404
|
+
ids_a = {f["id"] for f in man_a["frames"]}
|
|
405
|
+
ids_b = {f["id"] for f in man_b["frames"]}
|
|
406
|
+
if ids_b - ids_a:
|
|
407
|
+
diffs.append({"field": "frames.added", "a": None, "b": sorted(ids_b - ids_a)})
|
|
408
|
+
if ids_a - ids_b:
|
|
409
|
+
diffs.append({"field": "frames.removed", "a": sorted(ids_a - ids_b), "b": None})
|
|
410
|
+
|
|
411
|
+
for block in ("autotile", "shader", "font"):
|
|
412
|
+
if (block in man_a) != (block in man_b):
|
|
413
|
+
diffs.append({"field": f"{block}.present", "a": block in man_a, "b": block in man_b})
|
|
414
|
+
|
|
415
|
+
return diffs
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@app.command()
|
|
419
|
+
def diff(
|
|
420
|
+
a: Path = typer.Argument(..., help="spec.json o directorio de salida A"),
|
|
421
|
+
b: Path = typer.Argument(..., help="spec.json o directorio de salida B"),
|
|
422
|
+
as_json: bool = typer.Option(False, "--json", help="salida machine-readable"),
|
|
423
|
+
) -> None:
|
|
424
|
+
"""Compara dos specs (.json) o dos directorios de salida (atlas+manifest)."""
|
|
425
|
+
a_is_spec, b_is_spec = a.suffix == ".json", b.suffix == ".json"
|
|
426
|
+
if a_is_spec != b_is_spec:
|
|
427
|
+
typer.secho("no se puede comparar una spec (.json) con un directorio de salida",
|
|
428
|
+
fg=typer.colors.RED, err=True)
|
|
429
|
+
raise typer.Exit(1)
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
if a_is_spec:
|
|
433
|
+
kind, diffs = "spec", _diff_specs(load_spec(a), load_spec(b))
|
|
434
|
+
else:
|
|
435
|
+
if not a.is_dir() or not b.is_dir():
|
|
436
|
+
typer.secho(f"directorio no encontrado: {a if not a.is_dir() else b}",
|
|
437
|
+
fg=typer.colors.RED, err=True)
|
|
438
|
+
raise typer.Exit(1)
|
|
439
|
+
kind, diffs = "output", _diff_outputs(a, b)
|
|
440
|
+
except SpecError as e:
|
|
441
|
+
typer.secho(f"inválida: {e}", fg=typer.colors.RED, err=True)
|
|
442
|
+
raise typer.Exit(1)
|
|
443
|
+
|
|
444
|
+
if as_json:
|
|
445
|
+
typer.echo(json.dumps({"a": str(a), "b": str(b), "kind": kind, "diffs": diffs}, indent=2))
|
|
446
|
+
elif not diffs:
|
|
447
|
+
typer.secho(f"sin diferencias ({kind})", fg=typer.colors.GREEN)
|
|
448
|
+
else:
|
|
449
|
+
typer.secho(f"{len(diffs)} diferencia(s) ({kind}):", fg=typer.colors.YELLOW)
|
|
450
|
+
for d in diffs:
|
|
451
|
+
typer.echo(f" - {d['field']}: {d['a']!r} -> {d['b']!r}")
|
|
452
|
+
raise typer.Exit(1 if diffs else 0)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@app.command()
|
|
456
|
+
def watch(
|
|
457
|
+
specs_dir: Path = typer.Argument(..., help="directorio con specs (*.json)"),
|
|
458
|
+
out: Path = typer.Option(None, "--out", "-o", help="directorio de salida (default: junto a cada spec)"),
|
|
459
|
+
interval: float = typer.Option(1.0, "--interval", "-i", help="sondaje de cambios en segundos"),
|
|
460
|
+
) -> None:
|
|
461
|
+
"""Vigila specs/ y regenera assets cuando cambian (Ctrl-C para detener)."""
|
|
462
|
+
if not specs_dir.is_dir():
|
|
463
|
+
typer.secho(f"no existe el directorio: {specs_dir}", fg=typer.colors.RED, err=True)
|
|
464
|
+
raise typer.Exit(1)
|
|
465
|
+
files = sorted(specs_dir.glob("*.json"))
|
|
466
|
+
if not files:
|
|
467
|
+
typer.secho(f"no hay specs (*.json) en {specs_dir}", fg=typer.colors.RED, err=True)
|
|
468
|
+
raise typer.Exit(1)
|
|
469
|
+
|
|
470
|
+
typer.secho(
|
|
471
|
+
f"[watch] {specs_dir} -> {out or specs_dir} cada {interval}s "
|
|
472
|
+
f"({len(files)} specs) — Ctrl-C para salir",
|
|
473
|
+
fg=typer.colors.CYAN,
|
|
474
|
+
)
|
|
475
|
+
mtimes: dict[Path, float] = {}
|
|
476
|
+
primera = True
|
|
477
|
+
try:
|
|
478
|
+
while True:
|
|
479
|
+
for f in sorted(specs_dir.glob("*.json")):
|
|
480
|
+
try:
|
|
481
|
+
mt = f.stat().st_mtime
|
|
482
|
+
except OSError:
|
|
483
|
+
continue
|
|
484
|
+
if mtimes.get(f, 0.0) >= mt:
|
|
485
|
+
continue
|
|
486
|
+
mtimes[f] = mt
|
|
487
|
+
if not primera:
|
|
488
|
+
typer.secho(f" [cambio] {f.name}", fg=typer.colors.BLUE)
|
|
489
|
+
try:
|
|
490
|
+
r = _generate(f, out, None, True)
|
|
491
|
+
except SpecError as e:
|
|
492
|
+
typer.secho(f" [error] {f.name}: {e}", fg=typer.colors.RED, err=True)
|
|
493
|
+
continue
|
|
494
|
+
s: Spec = r["spec"]
|
|
495
|
+
if r["skipped"]:
|
|
496
|
+
typer.secho(f" [skip] {s.name} sin cambios (crc {r['crc']:08x})",
|
|
497
|
+
fg=typer.colors.YELLOW)
|
|
498
|
+
else:
|
|
499
|
+
typer.secho(
|
|
500
|
+
f" [ok] {s.name} seed={s.seed} frames={s.total_frames} "
|
|
501
|
+
f"-> {r['out'].resolve()} (crc {r['crc']:08x})",
|
|
502
|
+
fg=typer.colors.GREEN,
|
|
503
|
+
)
|
|
504
|
+
primera = False
|
|
505
|
+
time.sleep(interval)
|
|
506
|
+
except KeyboardInterrupt:
|
|
507
|
+
typer.secho("\n[watch] detenido.", fg=typer.colors.CYAN)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@app.callback(invoke_without_command=True)
|
|
511
|
+
def _version(version: bool = typer.Option(False, "--version", help="muestra la versión")) -> None:
|
|
512
|
+
if version:
|
|
513
|
+
typer.echo(f"sprout {__version__}")
|
|
514
|
+
raise typer.Exit()
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
if __name__ == "__main__":
|
|
518
|
+
app()
|