#!/usr/bin/env python3 """ Script para migrar tilemaps de archivos TMX (Tiled) a archivos YAML. Uso: python tools/tmx_to_yaml.py Busca todos los archivos .tmx en data/room/ y actualiza el tilemap en el archivo .yaml correspondiente. """ import xml.etree.ElementTree as ET from pathlib import Path import re def parse_layer_csv(data_element, width: int, height: int, offset: int) -> list[list[int]]: """ Parsea los datos CSV de una capa y convierte los indices. Args: data_element: Elemento XML con los datos CSV width: Ancho del mapa en tiles height: Alto del mapa en tiles offset: Valor a restar de los indices (excepto 0 que pasa a -1) Returns: Lista de filas, cada fila es una lista de indices """ encoding = data_element.attrib.get('encoding', '') if encoding != 'csv': raise ValueError(f"Encoding no soportado: {encoding}. Solo se soporta CSV.") csv_text = data_element.text.strip() all_values = [] for val in csv_text.replace('\n', ',').split(','): val = val.strip() if val: all_values.append(int(val)) # Convertir indices: 0 -> -1, N -> N - offset result = [] for y in range(height): row = [] for x in range(width): idx = y * width + x tmx_val = all_values[idx] if tmx_val == 0: yaml_val = -1 else: yaml_val = tmx_val - offset row.append(yaml_val) result.append(row) return result def parse_tmx(tmx_path: Path) -> tuple[list[list[int]], list[list[int]] | None, int, int]: """ Parsea un archivo TMX y extrae el tilemap y collisionmap. Returns: tilemap: Lista de filas para el tilemap (layer id=1) collisionmap: Lista de filas para el collisionmap (layer id=2), o None si no existe width: Ancho del mapa en tiles height: Alto del mapa en tiles """ tree = ET.parse(tmx_path) root = tree.getroot() width = int(root.attrib['width']) height = int(root.attrib['height']) # Obtener firstgid del primer tileset (para tilemap) tileset = root.find('tileset') firstgid = int(tileset.attrib['firstgid']) if tileset is not None else 1 # Buscar las capas por id layers = root.findall('layer') layer1 = None layer2 = None for layer in layers: layer_id = layer.attrib.get('id') if layer_id == '1': layer1 = layer elif layer_id == '2': layer2 = layer # Parsear layer 1 (tilemap) - obligatoria if layer1 is None: raise ValueError(f"No se encontro layer id=1 en {tmx_path}") data1 = layer1.find('data') if data1 is None: raise ValueError(f"No se encontraron datos en layer 1 de {tmx_path}") tilemap = parse_layer_csv(data1, width, height, firstgid) # Parsear layer 2 (collisionmap) - opcional collisionmap = None if layer2 is not None: data2 = layer2.find('data') if data2 is not None: # Para collisionmap: 0 -> -1, N -> N - 576 collisionmap = parse_layer_csv(data2, width, height, 576) return tilemap, collisionmap, width, height def format_tilemap_yaml(tilemap: list[list[int]], width: int, height: int) -> str: """ Formatea el tilemap como YAML. """ lines = [] lines.append(f"# Tilemap: {height} filas x {width} columnas ({width*8}x{height*8} pixeles @ 8px/tile)") lines.append("# Indices de tiles (-1 = vacio)") lines.append("tilemap:") for row in tilemap: row_str = ", ".join(str(v) for v in row) lines.append(f" - [{row_str}]") return "\n".join(lines) def format_collisionmap_yaml(collisionmap: list[list[int]], width: int, height: int) -> str: """ Formatea el collisionmap como YAML. """ lines = [] lines.append(f"# Collisionmap: {height} filas x {width} columnas") lines.append("# Indices de colision (-1 = vacio, 1 = solido, 2 = plataforma)") lines.append("collisionmap:") for row in collisionmap: row_str = ", ".join(str(v) for v in row) lines.append(f" - [{row_str}]") return "\n".join(lines) def update_yaml_tilemap(yaml_path: Path, tilemap: list[list[int]], collisionmap: list[list[int]] | None, width: int, height: int) -> bool: """ Actualiza la seccion tilemap y collisionmap de un archivo YAML. Returns: True si se actualizo correctamente, False si hubo error """ if not yaml_path.exists(): print(f" ADVERTENCIA: {yaml_path} no existe, saltando...") return False content = yaml_path.read_text(encoding='utf-8') # Buscar donde empieza la seccion tilemap (incluyendo comentarios previos) # Capturar cualquier bloque de comentarios seguidos justo antes de tilemap: tilemap_pattern = re.compile( r'(#[^\n]*\n)*' # Cualquier comentario antes de tilemap r'tilemap:\s*\n' # Inicio de tilemap r'( - \[.*\]\n?)+' # Filas del tilemap ) match = tilemap_pattern.search(content) if not match: print(f" ERROR: No se encontro seccion tilemap en {yaml_path}") return False # Verificar que capturamos comentarios relacionados con tilemap # Si el match incluye comentarios no relacionados, ajustar el inicio matched_text = match.group(0) lines = matched_text.split('\n') # Buscar donde empiezan los comentarios de tilemap (los que queremos reemplazar) start_idx = 0 for i, line in enumerate(lines): if line.startswith('#') and ('tilemap' in line.lower() or 'indice' in line.lower() or 'tile' in line.lower()): start_idx = i break elif line.startswith('tilemap:'): start_idx = i break # Ajustar el inicio del match si hay comentarios que no queremos eliminar if start_idx > 0: skip_chars = sum(len(lines[i]) + 1 for i in range(start_idx)) actual_start = match.start() + skip_chars else: actual_start = match.start() # Generar nuevo tilemap new_tilemap = format_tilemap_yaml(tilemap, width, height) # Reemplazar desde actual_start (que puede ser ajustado si hay comentarios no relacionados) new_content = content[:actual_start] + new_tilemap + "\n" + content[match.end():] # Procesar collisionmap si existe if collisionmap is not None: new_collisionmap = format_collisionmap_yaml(collisionmap, width, height) # Buscar si ya existe seccion collisionmap collisionmap_pattern = re.compile( r'(#[^\n]*\n)*' # Cualquier comentario antes de collisionmap r'collisionmap:\s*\n' # Inicio de collisionmap r'( - \[.*\]\n?)+' # Filas del collisionmap ) collision_match = collisionmap_pattern.search(new_content) if collision_match: # Reemplazar collisionmap existente collision_matched_text = collision_match.group(0) collision_lines = collision_matched_text.split('\n') # Buscar donde empiezan los comentarios de collisionmap collision_start_idx = 0 for i, line in enumerate(collision_lines): if line.startswith('#') and ('collision' in line.lower()): collision_start_idx = i break elif line.startswith('collisionmap:'): collision_start_idx = i break if collision_start_idx > 0: skip_chars = sum(len(collision_lines[i]) + 1 for i in range(collision_start_idx)) collision_actual_start = collision_match.start() + skip_chars else: collision_actual_start = collision_match.start() new_content = new_content[:collision_actual_start] + new_collisionmap + "\n" + new_content[collision_match.end():] else: # Insertar collisionmap despues del tilemap # Buscar el fin del tilemap recien insertado tilemap_end_pattern = re.compile(r'tilemap:\s*\n( - \[.*\]\n?)+') tilemap_end_match = tilemap_end_pattern.search(new_content) if tilemap_end_match: insert_pos = tilemap_end_match.end() new_content = new_content[:insert_pos] + "\n" + new_collisionmap + "\n" + new_content[insert_pos:] yaml_path.write_text(new_content, encoding='utf-8') return True def main(): # Directorio de habitaciones script_dir = Path(__file__).parent room_dir = script_dir.parent / "data" / "room" if not room_dir.exists(): print(f"ERROR: No se encontro directorio {room_dir}") return 1 # Buscar archivos TMX tmx_files = sorted(room_dir.glob("*.tmx")) if not tmx_files: print("No se encontraron archivos .tmx en data/room/") return 0 print(f"Encontrados {len(tmx_files)} archivos TMX") print() success_count = 0 error_count = 0 for tmx_path in tmx_files: yaml_path = tmx_path.with_suffix('.yaml') print(f"Procesando: {tmx_path.name} -> {yaml_path.name}") try: tilemap, collisionmap, width, height = parse_tmx(tmx_path) print(f" TMX: {width}x{height} tiles") if collisionmap is not None: print(f" Collisionmap detectado") if update_yaml_tilemap(yaml_path, tilemap, collisionmap, width, height): print(f" OK: Tilemap actualizado") if collisionmap is not None: print(f" OK: Collisionmap actualizado") success_count += 1 else: error_count += 1 except Exception as e: print(f" ERROR: {e}") error_count += 1 print() print(f"Completado: {success_count} exitosos, {error_count} errores") return 0 if error_count == 0 else 1 if __name__ == "__main__": exit(main())