Files
alttpr-python/source/overworld/LayoutVisualizer.py

464 lines
22 KiB
Python

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, get_screen_id_from_cell
def get_quadrant_from_cell_id(cell_id: int, screen_id: int) -> str:
offset = (cell_id & 0xBF) - (screen_id & 0xBF)
if offset == 0x00:
return "NW"
elif offset == 0x01:
return "NE"
elif offset == 0x08:
return "SW"
else:
return "SE"
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 cell 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 = {}
for world_idx in range(2):
for row in range(GRID_SIZE):
for col in range(GRID_SIZE):
cell_id = grid[world_idx][row][col]
if cell_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_id = get_screen_id_from_cell(cell_id)
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
if screen.big:
# For large screens, determine quadrant from cell ID
quadrant = get_quadrant_from_cell_id(cell_id, screen_id)
# 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 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 are_large_screen_cells_connected(cell_id1: int, cell_id2: int, quadrant1: str, quadrant2: str, direction: str) -> bool:
"""
Check if two cells of a large screen are connected (should have no border between them).
For cells to be connected:
1. They must be from the same large screen (same base screen ID)
2. Their quadrants must be adjacent in the expected direction
Args:
cell_id1: Cell ID of the first cell
cell_id2: Cell ID of the second cell
quadrant1: Quadrant of the first cell ("NW", "NE", "SW", "SE")
quadrant2: Quadrant of the second cell
direction: Direction from cell1 to cell2 ("east", "south")
Returns:
True if the cells should have no border between them
"""
# Must be from the same large screen
screen_id1 = get_screen_id_from_cell(cell_id1)
screen_id2 = get_screen_id_from_cell(cell_id2)
if screen_id1 != screen_id2:
return False
# Check if quadrants are properly adjacent
if direction == "east":
# For east connection: NW->NE or SW->SE
return (quadrant1 == "NW" and quadrant2 == "NE") or (quadrant1 == "SW" and quadrant2 == "SE")
elif direction == "south":
# For south connection: NW->SW or NE->SE
return (quadrant1 == "NW" and quadrant2 == "SW") or (quadrant1 == "NE" and quadrant2 == "SE")
return False
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')
# Process both worlds
for world_idx in range(2):
x_offset = 0 if world_idx == 0 else (world_width + gap)
# Process each cell in the grid individually
for row in range(GRID_SIZE):
for col in range(GRID_SIZE):
cell_id = grid[world_idx][row][col]
if cell_id == -1:
# Empty cell - fill with black (already black from initialization)
continue
screen_id = get_screen_id_from_cell(cell_id)
screen = overworld_screens.get(screen_id)
if not screen:
continue
is_large = screen.big
# Calculate source position in the world image based on cell_id
# For large screens, cell_id already encodes the quadrant position
source_row = (cell_id % 0x40) >> 3
source_col = cell_id % 0x08
world_img = lightworld_img if cell_id < 0x40 else darkworld_img
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)
# Draw borders for each cell
# For large screens, only draw borders where cells are not connected
for row in range(GRID_SIZE):
for col in range(GRID_SIZE):
cell_id = grid[world_idx][row][col]
if cell_id == -1:
continue
screen_id = get_screen_id_from_cell(cell_id)
screen = overworld_screens.get(screen_id)
if not screen:
continue
is_large = screen.big
dest_x = x_offset + col * OUTPUT_CELL_SIZE
dest_y = row * OUTPUT_CELL_SIZE
if is_large:
quadrant = get_quadrant_from_cell_id(cell_id, screen_id)
# Check each border direction
# Top border: draw if this is a north quadrant OR if the cell above is not connected
draw_top = True
if quadrant in ["SW", "SE"]:
# Check if cell above is connected
above_row = (row - 1) % GRID_SIZE
above_cell_id = grid[world_idx][above_row][col]
if above_cell_id != -1:
above_screen_id = get_screen_id_from_cell(above_cell_id)
above_screen = overworld_screens.get(above_screen_id)
if above_screen and above_screen.big:
above_quadrant = get_quadrant_from_cell_id(above_cell_id, above_screen_id)
if are_large_screen_cells_connected(above_cell_id, cell_id, above_quadrant, quadrant, "south"):
draw_top = False
# Bottom border: draw if this is a south quadrant OR if the cell below is not connected
draw_bottom = True
if quadrant in ["NW", "NE"]:
# Check if cell below is connected
below_row = (row + 1) % GRID_SIZE
below_cell_id = grid[world_idx][below_row][col]
if below_cell_id != -1:
below_screen_id = get_screen_id_from_cell(below_cell_id)
below_screen = overworld_screens.get(below_screen_id)
if below_screen and below_screen.big:
below_quadrant = get_quadrant_from_cell_id(below_cell_id, below_screen_id)
if are_large_screen_cells_connected(cell_id, below_cell_id, quadrant, below_quadrant, "south"):
draw_bottom = False
# Left border: draw if this is a west quadrant OR if the cell to the left is not connected
draw_left = True
if quadrant in ["NE", "SE"]:
# Check if cell to the left is connected
left_col = (col - 1) % GRID_SIZE
left_cell_id = grid[world_idx][row][left_col]
if left_cell_id != -1:
left_screen_id = get_screen_id_from_cell(left_cell_id)
left_screen = overworld_screens.get(left_screen_id)
if left_screen and left_screen.big:
left_quadrant = get_quadrant_from_cell_id(left_cell_id, left_screen_id)
if are_large_screen_cells_connected(left_cell_id, cell_id, left_quadrant, quadrant, "east"):
draw_left = False
# Right border: draw if this is an east quadrant OR if the cell to the right is not connected
draw_right = True
if quadrant in ["NW", "SW"]:
# Check if cell to the right is connected
right_col = (col + 1) % GRID_SIZE
right_cell_id = grid[world_idx][row][right_col]
if right_cell_id != -1:
right_screen_id = get_screen_id_from_cell(right_cell_id)
right_screen = overworld_screens.get(right_screen_id)
if right_screen and right_screen.big:
right_quadrant = get_quadrant_from_cell_id(right_cell_id, right_screen_id)
if are_large_screen_cells_connected(cell_id, right_cell_id, quadrant, right_quadrant, "east"):
draw_right = False
# Draw the borders
if draw_top:
draw.line([(dest_x, dest_y), (dest_x + OUTPUT_CELL_SIZE - 1, dest_y)], fill='black', width=BORDER_WIDTH)
if draw_bottom:
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 draw_left:
draw.line([(dest_x, dest_y), (dest_x, dest_y + OUTPUT_CELL_SIZE - 1)], fill='black', width=BORDER_WIDTH)
if draw_right:
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):
cell_id = grid[world_idx][row][col]
if cell_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}")