import logging import os from datetime import datetime from typing import Dict, List from PIL import Image, ImageDraw from BaseClasses import Direction, OWEdge from source.overworld.LayoutGenerator import Screen def get_edge_lists(grid: List[List[List[int]]], overworld_screens: Dict[int, Screen], large_screen_quadrant_info: Dict[int, Dict]) -> Dict: """ Get list of edges for each cell and direction. Args: grid: 3D list [world][row][col] containing screen IDs overworld_screens: Dict of screen_id -> Screen objects large_screen_quadrant_info: Dict of screen_id -> quadrant info for large screens Returns: Dict mapping (world, row, col, direction) -> list of edges Each edge has a .dest property (None if unconnected) """ GRID_SIZE = 8 edge_lists = {} # Large screen base IDs large_screen_base_ids = [0x00, 0x03, 0x05, 0x18, 0x1B, 0x1E, 0x30, 0x35, 0x40, 0x43, 0x45, 0x58, 0x5B, 0x5E, 0x70, 0x75] for world_idx in range(2): # Build a map of screen_id -> list of (row, col) positions for large screens large_screen_positions = {} for row in range(GRID_SIZE): for col in range(GRID_SIZE): screen_id = grid[world_idx][row][col] if screen_id != -1 and screen_id in large_screen_base_ids: if screen_id not in large_screen_positions: large_screen_positions[screen_id] = [] large_screen_positions[screen_id].append((row, col)) for row in range(GRID_SIZE): for col in range(GRID_SIZE): screen_id = grid[world_idx][row][col] if screen_id == -1: # Empty cell - no edges for direction in [Direction.North, Direction.South, Direction.East, Direction.West]: edge_lists[(world_idx, row, col, direction)] = [] continue screen = overworld_screens.get(screen_id) if not screen: for direction in [Direction.North, Direction.South, Direction.East, Direction.West]: edge_lists[(world_idx, row, col, direction)] = [] continue is_large = screen_id in large_screen_base_ids if is_large: # For large screens, determine which quadrant this cell is # Find all positions of this large screen and determine quadrant positions = large_screen_positions.get(screen_id, [(row, col)]) # Determine quadrant by finding relative position # The quadrant is determined by which cells are adjacent quadrant = determine_large_screen_quadrant(row, col, positions, GRID_SIZE) # Get edges for this quadrant if screen_id in large_screen_quadrant_info: quad_info = large_screen_quadrant_info[screen_id] for direction in [Direction.North, Direction.South, Direction.East, Direction.West]: edges = quad_info.get(quadrant, {}).get(direction, []) edge_lists[(world_idx, row, col, direction)] = edges else: # No quadrant info - no edges for direction in [Direction.North, Direction.South, Direction.East, Direction.West]: edge_lists[(world_idx, row, col, direction)] = [] else: # Small screen - get edges directly for direction in [Direction.North, Direction.South, Direction.East, Direction.West]: edges_in_dir = [e for e in screen.edges.values() if e.direction == direction] edge_lists[(world_idx, row, col, direction)] = edges_in_dir return edge_lists def determine_large_screen_quadrant(row: int, col: int, positions: List[tuple], grid_size: int) -> str: """ Determine which quadrant (NW, NE, SW, SE) a cell is in for a large screen. Handles wrapping correctly by checking adjacency patterns. Args: row: Current cell row col: Current cell column positions: List of all (row, col) positions for this large screen grid_size: Size of the grid (8) Returns: Quadrant string: "NW", "NE", "SW", or "SE" """ positions_set = set(positions) # Check which adjacent cells also belong to this large screen has_right = ((row, (col + 1) % grid_size) in positions_set) has_below = (((row + 1) % grid_size, col) in positions_set) has_left = ((row, (col - 1) % grid_size) in positions_set) has_above = (((row - 1) % grid_size, col) in positions_set) # Determine quadrant based on adjacency # NW: has right and below neighbors # NE: has left and below neighbors # SW: has right and above neighbors # SE: has left and above neighbors if has_right and has_below: return "NW" elif has_left and has_below: return "NE" elif has_right and has_above: return "SW" elif has_left and has_above: return "SE" else: raise Exception("?") def is_crossed_edge(edge: OWEdge, overworld_screens: Dict[int, Screen]) -> bool: if edge.dest is None: return False source_screen = overworld_screens.get(edge.owIndex) dest_screen = overworld_screens.get(edge.dest.owIndex) return source_screen.dark_world != dest_screen.dark_world def visualize_layout(grid: List[List[List[int]]], output_dir: str, overworld_screens: Dict[int, Screen], large_screen_quadrant_info: Dict[int, Dict]) -> None: # Constants GRID_SIZE = 8 BORDER_WIDTH = 1 OUTPUT_CELL_SIZE = 64 # Each cell in output is always 64x64 pixels # Load the world images try: lightworld_img = Image.open("data/overworld/lightworld.png") darkworld_img = Image.open("data/overworld/darkworld.png") except FileNotFoundError as e: raise FileNotFoundError(f"World image not found: {e}. Ensure lightworld.png and darkworld.png are in the data/overworld directory.") # Calculate source cell size from the base images # Each world image is 8x8 screens, so divide by 8 to get source cell size img_width, _ = lightworld_img.size SOURCE_CELL_SIZE = img_width // GRID_SIZE # Size of each cell in the source image # Calculate dimensions for the output (always based on 64x64 cells) world_width = GRID_SIZE * OUTPUT_CELL_SIZE world_height = GRID_SIZE * OUTPUT_CELL_SIZE # Create output image (two worlds side by side with a small gap) gap = 32 output_width = world_width * 2 + gap output_height = world_height output_img = Image.new('RGB', (output_width, output_height), color='black') # Large screen base IDs (defined once for reuse) large_screen_base_ids = [0x00, 0x03, 0x05, 0x18, 0x1B, 0x1E, 0x30, 0x35, 0x40, 0x43, 0x45, 0x58, 0x5B, 0x5E, 0x70, 0x75] # Process both worlds for world_idx in range(2): x_offset = 0 if world_idx == 0 else (world_width + gap) # Build a map of screen_id -> list of (row, col) positions for large screens large_screen_positions = {} for row in range(GRID_SIZE): for col in range(GRID_SIZE): screen_id = grid[world_idx][row][col] if screen_id != -1 and screen_id in large_screen_base_ids: if screen_id not in large_screen_positions: large_screen_positions[screen_id] = [] large_screen_positions[screen_id].append((row, col)) # Process each cell in the grid individually # This handles wrapped large screens correctly by drawing each quadrant separately for row in range(GRID_SIZE): for col in range(GRID_SIZE): screen_id = grid[world_idx][row][col] if screen_id == -1: # Empty cell - fill with black (already black from initialization) continue is_large = screen_id in large_screen_base_ids # Calculate source position in the world image source_row = (screen_id % 0x40) >> 3 source_col = screen_id % 0x08 world_img = lightworld_img if screen_id < 0x40 else darkworld_img if is_large: # For large screens, determine which quadrant this cell represents positions = large_screen_positions.get(screen_id, [(row, col)]) quadrant = determine_large_screen_quadrant(row, col, positions, GRID_SIZE) # Map quadrant to source offset within the 2x2 large screen quadrant_offsets = { "NW": (0, 0), "NE": (1, 0), "SW": (0, 1), "SE": (1, 1) } q_col_offset, q_row_offset = quadrant_offsets[quadrant] # Calculate source position for this quadrant source_x = (source_col + q_col_offset) * SOURCE_CELL_SIZE source_y = (source_row + q_row_offset) * SOURCE_CELL_SIZE # Crop single cell from source (the specific quadrant) cropped = world_img.crop(( source_x, source_y, source_x + SOURCE_CELL_SIZE, source_y + SOURCE_CELL_SIZE )) else: # Small screen (1x1) source_x = source_col * SOURCE_CELL_SIZE source_y = source_row * SOURCE_CELL_SIZE # Crop single cell from source cropped = world_img.crop(( source_x, source_y, source_x + SOURCE_CELL_SIZE, source_y + SOURCE_CELL_SIZE )) # Resize to output size (64x64 pixels) resized = cropped.resize( (OUTPUT_CELL_SIZE, OUTPUT_CELL_SIZE), Image.LANCZOS ) # Paste into output at grid position dest_x = x_offset + col * OUTPUT_CELL_SIZE dest_y = row * OUTPUT_CELL_SIZE output_img.paste(resized, (dest_x, dest_y)) edge_lists = get_edge_lists(grid, overworld_screens, large_screen_quadrant_info) # Draw borders and edge connection indicators after all screens are placed draw = ImageDraw.Draw(output_img) # Size of the indicator squares INDICATOR_SIZE = 12 for world_idx in range(2): x_offset = 0 if world_idx == 0 else (world_width + gap) # Build large screen positions map for this world large_screen_positions = {} for row in range(GRID_SIZE): for col in range(GRID_SIZE): screen_id = grid[world_idx][row][col] if screen_id != -1 and screen_id in large_screen_base_ids: if screen_id not in large_screen_positions: large_screen_positions[screen_id] = [] large_screen_positions[screen_id].append((row, col)) # Draw borders for each cell for row in range(GRID_SIZE): for col in range(GRID_SIZE): screen_id = grid[world_idx][row][col] if screen_id == -1: continue is_large = screen_id in large_screen_base_ids dest_x = x_offset + col * OUTPUT_CELL_SIZE dest_y = row * OUTPUT_CELL_SIZE if is_large: # For large screens, determine which quadrant this cell is positions = large_screen_positions.get(screen_id, [(row, col)]) quadrant = determine_large_screen_quadrant(row, col, positions, GRID_SIZE) # Draw border only on the outer edges of the large screen # (not on internal edges between quadrants) # NW: draw top and left borders # NE: draw top and right borders # SW: draw bottom and left borders # SE: draw bottom and right borders if quadrant in ["NW", "NE"]: # Draw top border draw.line([(dest_x, dest_y), (dest_x + OUTPUT_CELL_SIZE - 1, dest_y)], fill='black', width=BORDER_WIDTH) if quadrant in ["SW", "SE"]: # Draw bottom border draw.line([(dest_x, dest_y + OUTPUT_CELL_SIZE - 1), (dest_x + OUTPUT_CELL_SIZE - 1, dest_y + OUTPUT_CELL_SIZE - 1)], fill='black', width=BORDER_WIDTH) if quadrant in ["NW", "SW"]: # Draw left border draw.line([(dest_x, dest_y), (dest_x, dest_y + OUTPUT_CELL_SIZE - 1)], fill='black', width=BORDER_WIDTH) if quadrant in ["NE", "SE"]: # Draw right border draw.line([(dest_x + OUTPUT_CELL_SIZE - 1, dest_y), (dest_x + OUTPUT_CELL_SIZE - 1, dest_y + OUTPUT_CELL_SIZE - 1)], fill='black', width=BORDER_WIDTH) else: # Small screen - draw border around single cell draw.rectangle( [dest_x, dest_y, dest_x + OUTPUT_CELL_SIZE - 1, dest_y + OUTPUT_CELL_SIZE - 1], outline='black', width=BORDER_WIDTH ) # Draw edge connection indicators for each cell for row in range(GRID_SIZE): for col in range(GRID_SIZE): screen_id = grid[world_idx][row][col] if screen_id == -1: continue dest_x = x_offset + col * OUTPUT_CELL_SIZE dest_y = row * OUTPUT_CELL_SIZE # Draw indicator for each direction (only if edges exist) # Use bright colors for visibility GREEN = (0, 255, 0) # Bright green YELLOW = (255, 255, 0) # Bright yellow RED = (255, 0, 0) # Bright red # North indicators - positioned based on edge midpoint north_edges = edge_lists.get((world_idx, row, col, Direction.North), []) if north_edges: north_y = dest_y # Touch the top border for edge in north_edges: # For north/south edges, midpoint gives the X coordinate # Take midpoint modulo 0x0200, range 0-0x01FF maps to full side midpoint = edge.midpoint % 0x0200 # Map from game coordinate range to pixel position edge_x_offset = (midpoint * OUTPUT_CELL_SIZE) // 0x0200 edge_x = dest_x + edge_x_offset - INDICATOR_SIZE // 2 edge_color = GREEN if edge.dest is not None else YELLOW if any(e for e in north_edges if e.dest) else RED draw.rectangle( [edge_x, north_y, edge_x + INDICATOR_SIZE - 1, north_y + INDICATOR_SIZE - 1], fill=edge_color, outline='black' ) # Draw diagonal cross if edge crosses between worlds if edge.dest is not None and is_crossed_edge(edge, overworld_screens): draw.line( [edge_x, north_y, edge_x + INDICATOR_SIZE - 1, north_y + INDICATOR_SIZE - 1], fill='black', width=1 ) draw.line( [edge_x + INDICATOR_SIZE - 1, north_y, edge_x, north_y + INDICATOR_SIZE - 1], fill='black', width=1 ) # South indicators - positioned based on edge midpoint south_edges = edge_lists.get((world_idx, row, col, Direction.South), []) if south_edges: south_y = dest_y + OUTPUT_CELL_SIZE - INDICATOR_SIZE # Touch the bottom border for edge in south_edges: # For north/south edges, midpoint gives the X coordinate # Take midpoint modulo 0x0200, range 0-0x01FF maps to full side midpoint = edge.midpoint % 0x0200 # Map from game coordinate range to pixel position edge_x_offset = (midpoint * OUTPUT_CELL_SIZE) // 0x0200 edge_x = dest_x + edge_x_offset - INDICATOR_SIZE // 2 edge_color = GREEN if edge.dest is not None else YELLOW if any(e for e in south_edges if e.dest) else RED draw.rectangle( [edge_x, south_y, edge_x + INDICATOR_SIZE - 1, south_y + INDICATOR_SIZE - 1], fill=edge_color, outline='black' ) # Draw diagonal cross if edge crosses between worlds if edge.dest is not None and is_crossed_edge(edge, overworld_screens): draw.line( [edge_x, south_y, edge_x + INDICATOR_SIZE - 1, south_y + INDICATOR_SIZE - 1], fill='black', width=1 ) draw.line( [edge_x + INDICATOR_SIZE - 1, south_y, edge_x, south_y + INDICATOR_SIZE - 1], fill='black', width=1 ) # West indicators - positioned based on edge midpoint west_edges = edge_lists.get((world_idx, row, col, Direction.West), []) if west_edges: west_x = dest_x # Touch the left border for edge in west_edges: # For west/east edges, midpoint gives the Y coordinate # Take midpoint modulo 0x0200, range 0-0x01FF maps to full side midpoint = edge.midpoint % 0x0200 # Map from game coordinate range to pixel position edge_y_offset = (midpoint * OUTPUT_CELL_SIZE) // 0x0200 edge_y = dest_y + edge_y_offset - INDICATOR_SIZE // 2 edge_color = GREEN if edge.dest is not None else YELLOW if any(e for e in west_edges if e.dest) else RED draw.rectangle( [west_x, edge_y, west_x + INDICATOR_SIZE - 1, edge_y + INDICATOR_SIZE - 1], fill=edge_color, outline='black' ) # Draw diagonal cross if edge crosses between worlds if edge.dest is not None and is_crossed_edge(edge, overworld_screens): draw.line( [west_x, edge_y, west_x + INDICATOR_SIZE - 1, edge_y + INDICATOR_SIZE - 1], fill='black', width=1 ) draw.line( [west_x + INDICATOR_SIZE - 1, edge_y, west_x, edge_y + INDICATOR_SIZE - 1], fill='black', width=1 ) # East indicators - positioned based on edge midpoint east_edges = edge_lists.get((world_idx, row, col, Direction.East), []) if east_edges: east_x = dest_x + OUTPUT_CELL_SIZE - INDICATOR_SIZE # Touch the right border for edge in east_edges: # For west/east edges, midpoint gives the Y coordinate # Take midpoint modulo 0x0200, range 0-0x01FF maps to full side midpoint = edge.midpoint % 0x0200 # Map from game coordinate range to pixel position edge_y_offset = (midpoint * OUTPUT_CELL_SIZE) // 0x0200 edge_y = dest_y + edge_y_offset - INDICATOR_SIZE // 2 edge_color = GREEN if edge.dest is not None else YELLOW if any(e for e in east_edges if e.dest) else RED draw.rectangle( [east_x, edge_y, east_x + INDICATOR_SIZE - 1, edge_y + INDICATOR_SIZE - 1], fill=edge_color, outline='black' ) # Draw diagonal cross if edge crosses between worlds if edge.dest is not None and is_crossed_edge(edge, overworld_screens): draw.line( [east_x, edge_y, east_x + INDICATOR_SIZE - 1, edge_y + INDICATOR_SIZE - 1], fill='black', width=1 ) draw.line( [east_x + INDICATOR_SIZE - 1, edge_y, east_x, edge_y + INDICATOR_SIZE - 1], fill='black', width=1 ) # Create output directory if it doesn't exist os.makedirs(output_dir, exist_ok=True) # Generate filename with timestamp timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"layout_{timestamp}.png" filepath = os.path.join(output_dir, filename) # Save the image output_img.save(filepath, "PNG") logging.getLogger('').info(f"Layout visualization saved to {filepath}")