@@ -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+
226272def 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
337412def 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 )
0 commit comments