Skip to content

Commit 471754b

Browse files
neparijNikolay Laptevcopyrat90
authored
Add manual palette & tileset ignore support. (#4)
* Manual palette works via `bpp_4_manual`, so the LDtk tileset image must use indexed palette mode and pre-quantized when it is enabled * Always ignore LDtk's `internal_icons` tileset if present Co-authored-by: Nikolay Laptev <n.laptev@gemini.ninja> Co-authored-by: copyrat90 <copyrat90@gmail.com>
1 parent 68aca37 commit 471754b

3 files changed

Lines changed: 167 additions & 47 deletions

File tree

tools/butano_ldtk.py

Lines changed: 155 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,52 @@ def ensure_no_more_than_4_visible_layers(ldtk_project: LdtkJson.LdtkJSON):
223223
raise TooManyVisibleLayersException(visible_layers_count, level.identifier)
224224

225225

226+
def purge_ignore_tilesets(
227+
ldtk_project: LdtkJson.LdtkJSON,
228+
additional_ignore_tilesets: Optional[List[str]] = None,
229+
) -> None:
230+
# Purge LDtk's `internal_icons` tileset if present
231+
ignored_set = {
232+
tileset_def.identifier
233+
for tileset_def in ldtk_project.defs.tilesets
234+
if isinstance(tileset_def.embed_atlas, LdtkJson.EmbedAtlas)
235+
}
236+
237+
# Purge additional ignore tilesets
238+
if additional_ignore_tilesets is not None:
239+
for ignore_tileset_name in additional_ignore_tilesets:
240+
ignore_tileset_name = ignore_tileset_name.strip()
241+
if ignore_tileset_name:
242+
ignored_set.add(ignore_tileset_name)
243+
244+
if not ignored_set:
245+
return
246+
247+
ignored_uids = {
248+
tileset.uid
249+
for tileset in ldtk_project.defs.tilesets
250+
if tileset.identifier in ignored_set
251+
}
252+
253+
ldtk_project.defs.tilesets = [
254+
tileset
255+
for tileset in ldtk_project.defs.tilesets
256+
if tileset.identifier not in ignored_set
257+
]
258+
259+
if not ignored_uids:
260+
return
261+
262+
for level in ldtk_project.levels:
263+
if level.layer_instances is None:
264+
continue
265+
for layer in level.layer_instances:
266+
if layer.tileset_def_uid in ignored_uids:
267+
layer.tileset_def_uid = None
268+
layer.auto_layer_tiles = []
269+
layer.grid_tiles = []
270+
271+
226272
def ensure_no_unsupported_features(ldtk_project: LdtkJson.LdtkJSON):
227273
ensure_identifier_style_lowercase(ldtk_project)
228274
ensure_no_tileset_without_image(ldtk_project)
@@ -239,6 +285,7 @@ def generate_tilesets_bg_items(
239285
ldtk_project: LdtkJson.LdtkJSON,
240286
ldtk_project_folder_path: Path,
241287
build_folder_path: Path,
288+
tileset_palette_manual: bool,
242289
):
243290
TRANSPARENT_COLOR: Final[str] = "#00FF0000"
244291

@@ -277,61 +324,89 @@ def generate_tilesets_bg_items(
277324
(1 + tiles_count) / tiles_count_per_height_unit
278325
)
279326

280-
with Image.new(
281-
"RGBA", (TEMP_LARGE_WIDTH, tileset_bg_height), color=TRANSPARENT_COLOR
282-
) as tileset_bg:
327+
use_palette_manual = tileset_palette_manual and tileset_src_path is not None
328+
bpp_mode = "bpp_4_manual" if use_palette_manual else "bpp_4_auto"
329+
330+
def paste_used_tiles_into(tileset_bg: Image.Image, tileset_src: Image.Image):
283331
# Start from after the first transparent tile
284332
paste_x, paste_y = ((tile_size >> 3) ** 2) * 8, 0
285333
while paste_x >= TILESET_BG_WIDTH:
286334
paste_x -= TILESET_BG_WIDTH
287335
paste_y += 8
288336

289-
# Internal icons are not copied via this check
290-
if tileset_src_path is not None:
291-
with Image.open(tileset_src_path) as tileset_src:
292-
for i in range(
293-
tileset_infos.get_tileset_used_tiles_count(tileset_def.uid)
294-
):
295-
src = tileset_infos.get_tileset_used_tile_src(
296-
tileset_def.uid, i
337+
for i in range(tileset_infos.get_tileset_used_tiles_count(tileset_def.uid)):
338+
src = tileset_infos.get_tileset_used_tile_src(tileset_def.uid, i)
339+
for y in range(tile_size >> 3):
340+
for x in range(tile_size >> 3):
341+
sub_x = src.x + x * 8
342+
sub_y = src.y + y * 8
343+
tile = tileset_src.crop((sub_x, sub_y, sub_x + 8, sub_y + 8))
344+
tileset_bg.paste(tile, (paste_x, paste_y))
345+
346+
paste_x += 8
347+
if paste_x >= TILESET_BG_WIDTH:
348+
assert paste_x == TILESET_BG_WIDTH
349+
paste_x = 0
350+
paste_y += 8
351+
352+
if use_palette_manual:
353+
assert tileset_src_path is not None
354+
with Image.open(tileset_src_path) as tileset_src:
355+
if tileset_src.mode != "P":
356+
raise TilesetPaletteManualRequiresIndexedImageException(
357+
tileset_def.identifier, tileset_src.mode
358+
)
359+
360+
palette = tileset_src.getpalette()
361+
if palette is None:
362+
raise TilesetPaletteManualRequiresIndexedImageException(
363+
tileset_def.identifier, tileset_src.mode
364+
)
365+
366+
tileset_src.load() # pyright: ignore[reportUnknownMemberType]
367+
368+
with Image.new(
369+
"P", (TILESET_BG_WIDTH, tileset_bg_height)
370+
) as tileset_bg:
371+
tileset_bg.putpalette(palette)
372+
tileset_bg.paste(0, (0, 0, TILESET_BG_WIDTH, tileset_bg_height))
373+
paste_used_tiles_into(tileset_bg, tileset_src)
374+
tileset_bg.save(tileset_out_path.with_suffix(".bmp"))
375+
376+
else: # Use palette auto
377+
with Image.new(
378+
"RGBA", (TEMP_LARGE_WIDTH, tileset_bg_height), color=TRANSPARENT_COLOR
379+
) as tileset_bg:
380+
if tileset_src_path is not None:
381+
with Image.open(tileset_src_path) as tileset_src:
382+
paste_used_tiles_into(tileset_bg, tileset_src)
383+
384+
# Start finalizing the tileset BG
385+
tileset_bg = tileset_bg.quantize(256)
386+
tileset_bg = tileset_bg.crop(
387+
(0, 0, TILESET_BG_WIDTH, tileset_bg_height)
388+
)
389+
390+
# Sort the palette in RGB descending order (keeping transparent one)
391+
tileset_palette = tileset_bg.palette
392+
if tileset_palette:
393+
palette_order = [
394+
color[-1]
395+
for color in sorted(
396+
tileset_palette.colors.items(), reverse=True
297397
)
298-
for y in range(tile_size >> 3):
299-
for x in range(tile_size >> 3):
300-
sub_x = src.x + x * 8
301-
sub_y = src.y + y * 8
302-
tile = tileset_src.crop(
303-
(sub_x, sub_y, sub_x + 8, sub_y + 8)
304-
)
305-
tileset_bg.paste(tile, (paste_x, paste_y))
306-
307-
paste_x += 8
308-
if paste_x >= TILESET_BG_WIDTH:
309-
assert paste_x == TILESET_BG_WIDTH
310-
paste_x = 0
311-
paste_y += 8
312-
313-
# Start finalizing the tileset BG
314-
tileset_bg = tileset_bg.quantize(256)
315-
tileset_bg = tileset_bg.crop((0, 0, TILESET_BG_WIDTH, tileset_bg_height))
316-
317-
# Sort the palette in RGB descending order (keeping transparent one)
318-
tileset_palette = tileset_bg.palette
319-
if tileset_palette:
320-
palette_order = [
321-
color[-1]
322-
for color in sorted(tileset_palette.colors.items(), reverse=True)
323-
]
324-
palette_order.remove(0)
325-
palette_order.insert(0, 0)
326-
tileset_bg = tileset_bg.remap_palette(palette_order)
327-
328-
# Save it
329-
tileset_bg.save(tileset_out_path.with_suffix(".bmp"))
398+
]
399+
palette_order.remove(0)
400+
palette_order.insert(0, 0)
401+
tileset_bg = tileset_bg.remap_palette(palette_order)
402+
403+
# Save it
404+
tileset_bg.save(tileset_out_path.with_suffix(".bmp"))
330405

331406
with tileset_out_path.with_suffix(".json").open(
332407
"w", encoding="utf-8"
333408
) as tileset_json:
334-
tileset_json.write('{"type":"regular_bg","bpp_mode":"bpp_4_auto"}')
409+
tileset_json.write(f'{{"type":"regular_bg","bpp_mode":"{bpp_mode}"}}')
335410

336411

337412
def generate_tileset_definitions(
@@ -611,7 +686,12 @@ def generate_levels_headers(
611686
levels_header.write(build_folder_path)
612687

613688

614-
def process_ldtk(ldtk_project_file_path: Path, build_folder_path: Path) -> bool:
689+
def process_ldtk(
690+
ldtk_project_file_path: Path,
691+
build_folder_path: Path,
692+
tileset_palette_manual: bool = False,
693+
additional_ignore_tilesets: Optional[List[str]] = None,
694+
) -> bool:
615695
"""Returns `False` if the process is skipped, because there's no modification"""
616696
try:
617697
create_folder(build_folder_path.joinpath("include"))
@@ -626,6 +706,8 @@ def process_ldtk(ldtk_project_file_path: Path, build_folder_path: Path) -> bool:
626706

627707
print("Start converting LDtk project...")
628708

709+
purge_ignore_tilesets(ldtk_project, additional_ignore_tilesets)
710+
629711
remove_built_files(build_folder_path)
630712
ensure_no_unsupported_features(ldtk_project)
631713

@@ -634,7 +716,11 @@ def process_ldtk(ldtk_project_file_path: Path, build_folder_path: Path) -> bool:
634716
enum_infos = EnumInfos(ldtk_project)
635717
tileset_infos = TilesetInfos(ldtk_project)
636718
generate_tilesets_bg_items(
637-
tileset_infos, ldtk_project, ldtk_project_folder_path, build_folder_path
719+
tileset_infos,
720+
ldtk_project,
721+
ldtk_project_folder_path,
722+
build_folder_path,
723+
tileset_palette_manual,
638724
)
639725
generate_definitions_headers(
640726
enum_infos, tileset_infos, ldtk_project, build_folder_path
@@ -662,13 +748,35 @@ def process_ldtk(ldtk_project_file_path: Path, build_folder_path: Path) -> bool:
662748
parser = argparse.ArgumentParser(description="LDtk converter for Butano.")
663749
parser.add_argument("--input", required=True, help="input LDtk project file")
664750
parser.add_argument("--build", required=True, help="build folder path")
751+
parser.add_argument(
752+
"--tileset-palette-manual",
753+
action="store_true",
754+
help=(
755+
"Keep tileset global palette and pixels from the source image (mode P); "
756+
"No quantization. Writes `bpp_4_manual` in tileset JSON."
757+
),
758+
)
759+
parser.add_argument(
760+
"--ignore-tilesets",
761+
nargs="*",
762+
default=[],
763+
help=(
764+
"Tileset identifiers to ignore completely (service/debug tilesets). "
765+
"Example: --ignore-tilesets ldtk_only debug_tiles"
766+
),
767+
)
665768

666769
try:
667770
args = parser.parse_args()
668771
ldtk_project_file_path = Path(args.input)
669772
build_folder_path = Path(args.build)
670773

671-
if process_ldtk(ldtk_project_file_path, build_folder_path):
774+
if process_ldtk(
775+
ldtk_project_file_path,
776+
build_folder_path,
777+
tileset_palette_manual=args.tileset_palette_manual,
778+
additional_ignore_tilesets=args.ignore_tilesets,
779+
):
672780
print(
673781
f'Successfully converted LDtk project "{ldtk_project_file_path}" to "{build_folder_path}"'
674782
)

tools/convert_exceptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,13 @@ def __init__(self, visible_layers_count: int, level: str):
102102
)
103103
self.visible_layers_count = visible_layers_count
104104
self.level = level
105+
106+
107+
class TilesetPaletteManualRequiresIndexedImageException(Exception):
108+
def __init__(self, tileset: str, mode: str):
109+
super().__init__(
110+
f'Tileset "{tileset}" must be a palette-indexed image (mode P), not {mode}, '
111+
f"when using --tileset-palette-manual"
112+
)
113+
self.tileset = tileset
114+
self.mode = mode

tools/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ def __init__(self, ldtk_project: LdtkJson.LdtkJSON):
204204
tiles = layer.grid_tiles
205205
if tiles:
206206
assert layer.tileset_def_uid is not None
207+
if layer.tileset_def_uid not in tileset_uid_to_idx:
208+
continue
207209
used_tiles: Set[Point] = tilesets_used_tiles[
208210
tileset_uid_to_idx[layer.tileset_def_uid]
209211
]

0 commit comments

Comments
 (0)