diff --git a/src/mars_patcher/constants/game_data.py b/src/mars_patcher/constants/game_data.py index 5359485..0e580ac 100644 --- a/src/mars_patcher/constants/game_data.py +++ b/src/mars_patcher/constants/game_data.py @@ -302,3 +302,17 @@ def minimap_count(rom: Rom) -> int: elif rom.game == Game.ZM: return 11 raise ValueError(rom.game) + + +def minimap_graphics(rom: Rom) -> int: + """Returns the address of the minimap tile graphics.""" + if rom.game == Game.MF: + if rom.region == Region.U: + return 0x561FA8 + elif rom.region == Region.E: + return 0x55B6E4 + elif rom.region == Region.J: + return 0x55D764 + elif rom.region == Region.C: + return 0x561FA8 + raise ValueError(rom.game, rom.region) diff --git a/src/mars_patcher/constants/minimap_tiles.py b/src/mars_patcher/constants/minimap_tiles.py new file mode 100644 index 0000000..f3034a7 --- /dev/null +++ b/src/mars_patcher/constants/minimap_tiles.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from enum import Enum +from typing import NamedTuple + +from typing_extensions import Self + +# String Format +# __ + +# Chunk 1 (tile edges) +# - see Edge and ColoredDoor for values + +# Chunk 2 (tile corners) +# - C: corner pixel +# - x: none + +# Chunk 3 (tile content) +# - see Content for values + + +class Edge(Enum): + EMPTY = "x" + WALL = "W" + SHORTCUT = "S" + DOOR = "D" + + @property + def is_door(self) -> bool: + return self == Edge.DOOR + + +class ColoredDoor(Enum): + BLUE = "B" + GREEN = "G" + YELLOW = "Y" + RED = "R" + + @property + def is_door(self) -> bool: + return True + + # Aliases + L1 = BLUE + L2 = GREEN + L3 = YELLOW + L4 = RED + + +class TileEdges(NamedTuple): + top: Edge = Edge.WALL + left: Edge | ColoredDoor = Edge.WALL + right: Edge | ColoredDoor = Edge.WALL + bottom: Edge = Edge.WALL + + @property + def as_str(self) -> str: + return f"{self.top.value}{self.left.value}{self.right.value}{self.bottom.value}" + + @classmethod + def from_str(cls, value: str) -> Self: + if len(value) != 4: + raise ValueError(f"'{value}' is not a valid TileEdges string") + top, left, right, bottom = tuple(value) + + def any_edge_from_value(v: str) -> Edge | ColoredDoor: + try: + return Edge(v) + except ValueError: + pass + + try: + return ColoredDoor(v) + except ValueError: + raise ValueError(f"{repr(v)} is not a valid Edge or ColoredDoor") + + return cls( + top=Edge(top), + left=any_edge_from_value(left), + right=any_edge_from_value(right), + bottom=Edge(bottom), + ) + + def h_flip(self) -> TileEdges: + return TileEdges( + top=self.top, + left=self.right, + right=self.left, + bottom=self.bottom, + ) + + def v_flip(self) -> TileEdges: + return TileEdges( + top=self.bottom, + left=self.left, + right=self.right, + bottom=self.top, + ) + + +class TileCorners(NamedTuple): + top_left: bool = False + top_right: bool = False + bottom_left: bool = False + bottom_right: bool = False + + @property + def as_str(self) -> str: + def s(corner: bool) -> str: + return "C" if corner else "x" + + return f"{s(self.top_left)}{s(self.top_right)}{s(self.bottom_left)}{s(self.bottom_right)}" + + @classmethod + def from_str(cls, value: str) -> Self: + if len(value) != 4: + raise ValueError(f"'{value}' is not a valid TileCorners string") + tl, tr, bl, br = tuple(value) + return cls( + top_left=(tl == "C"), + top_right=(tr == "C"), + bottom_left=(bl == "C"), + bottom_right=(br == "C"), + ) + + def h_flip(self) -> TileCorners: + return TileCorners( + top_left=self.top_right, + top_right=self.top_left, + bottom_left=self.bottom_right, + bottom_right=self.bottom_left, + ) + + def v_flip(self) -> TileCorners: + return TileCorners( + top_left=self.bottom_left, + top_right=self.bottom_right, + bottom_left=self.top_left, + bottom_right=self.top_right, + ) + + +class Content(Enum): + EMPTY = "x" + EMPTY_RED_WALLS = "w" + NAVIGATION = "N" + SAVE = "S" + RECHARGE = "R" + HIDDEN_RECHARGE = "H" + DATA = "D" + ITEM = "I" + OBTAINED_ITEM = "O" + BOSS_RIGHT_DOWNLOADED = "B-R-D" # Serris skeleton + BOSS_BOTTOM_LEFT_EXPLORED = "B-BL-E" # Serris + BOSS_TOP_LEFT_DOWNLOADED = "B-TL-D" # BOX 1 + BOSS_LEFT_EXPLORED = "B-L-E" # Mega-X + BOSS_TOP_RIGHT_BOTH = "B-TR-B" # Nightmare + BOSS_TOP_RIGHT_EXPLORED = "B-TR-E" # BOX 2 + GUNSHIP = "G" + GUNSHIP_EDGE = "P" + SECURITY = "K" + AUXILLARY_POWER = "Y" + ANIMALS = "A" + TUNNEL = "T" + BOILER_PAD = "L" + + @property + def can_h_flip(self) -> bool: + exclude = { + Content.NAVIGATION, + Content.SAVE, + Content.RECHARGE, + Content.HIDDEN_RECHARGE, + Content.DATA, + Content.GUNSHIP, + Content.GUNSHIP_EDGE, + Content.SECURITY, + Content.AUXILLARY_POWER, + Content.BOILER_PAD, + } + return self not in exclude + + @property + def can_v_flip(self) -> bool: + exclude = { + Content.NAVIGATION, + Content.SAVE, + Content.RECHARGE, + Content.HIDDEN_RECHARGE, + Content.BOSS_RIGHT_DOWNLOADED, + Content.BOSS_BOTTOM_LEFT_EXPLORED, + Content.BOSS_TOP_LEFT_DOWNLOADED, + Content.BOSS_LEFT_EXPLORED, + Content.BOSS_TOP_RIGHT_BOTH, + Content.BOSS_TOP_RIGHT_EXPLORED, + Content.GUNSHIP, + Content.GUNSHIP_EDGE, + Content.SECURITY, + Content.AUXILLARY_POWER, + Content.TUNNEL, + Content.BOILER_PAD, + } + return self not in exclude + + +class MapTile(NamedTuple): + edges: TileEdges = TileEdges() + corners: TileCorners = TileCorners() + content: Content = Content.EMPTY + transparent: bool = False + + @property + def as_str(self) -> str: + return f"{self.edges.as_str}_{self.corners.as_str}_{self.content.value}" + + @classmethod + def from_str(cls, value: str) -> Self: + edges, corners, content = value.split("_") + return cls( + edges=TileEdges.from_str(edges), + corners=TileCorners.from_str(corners), + content=Content(content), + transparent=False, + ) + + def h_flip(self) -> MapTile: + if not self.content.can_h_flip: + raise ValueError(f"Cannot h_flip tile with contents {self.content}") + + return MapTile( + edges=self.edges.h_flip(), + corners=self.corners.h_flip(), + content=self.content, + transparent=self.transparent, + ) + + def v_flip(self) -> MapTile: + if not self.content.can_v_flip: + raise ValueError(f"Cannot v_flip tile with contents {self.content}") + + return MapTile( + edges=self.edges.v_flip(), + corners=self.corners.v_flip(), + content=self.content, + transparent=self.transparent, + ) diff --git a/src/mars_patcher/mf/constants/minimap_tiles.py b/src/mars_patcher/mf/constants/minimap_tiles.py index 481409b..26410a1 100644 --- a/src/mars_patcher/mf/constants/minimap_tiles.py +++ b/src/mars_patcher/mf/constants/minimap_tiles.py @@ -1,236 +1,5 @@ -from __future__ import annotations +from mars_patcher.constants.minimap_tiles import MapTile -from enum import Enum -from typing import NamedTuple - -from typing_extensions import Self - -# String Format -# __ - -# Chunk 1 (tile edges) -# - see Edge and ColoredDoor for values - -# Chunk 2 (tile corners) -# - C: corner pixel -# - x: none - -# Chunk 3 (tile content) -# - see Content for values - - -# Edges -class Edge(Enum): - EMPTY = "x" - WALL = "W" - SHORTCUT = "S" - DOOR = "D" - - @property - def is_door(self) -> bool: - return self == Edge.DOOR - - -class ColoredDoor(Enum): - BLUE = "B" - GREEN = "G" - YELLOW = "Y" - RED = "R" - - @property - def is_door(self) -> bool: - return True - - # Aliases - L1 = BLUE - L2 = GREEN - L3 = YELLOW - L4 = RED - - -class TileEdges(NamedTuple): - top: Edge = Edge.WALL - left: Edge | ColoredDoor = Edge.WALL - right: Edge | ColoredDoor = Edge.WALL - bottom: Edge = Edge.WALL - - @property - def as_str(self) -> str: - return f"{self.top.value}{self.left.value}{self.right.value}{self.bottom.value}" - - @classmethod - def from_str(cls, value: str) -> Self: - if len(value) != 4: - raise ValueError(f"'{value}' is not a valid TileEdges string") - top, left, right, bottom = tuple(value) - - def any_edge_from_value(v: str) -> Edge | ColoredDoor: - try: - return Edge(v) - except ValueError: - pass - - try: - return ColoredDoor(v) - except ValueError: - raise ValueError(f"{repr(v)} is not a valid Edge or ColoredDoor") - - return cls( - top=Edge(top), - left=any_edge_from_value(left), - right=any_edge_from_value(right), - bottom=Edge(bottom), - ) - - def h_flip(self) -> TileEdges: - return TileEdges( - top=self.top, - left=self.right, - right=self.left, - bottom=self.bottom, - ) - - def v_flip(self) -> TileEdges: - return TileEdges( - top=self.bottom, - left=self.left, - right=self.right, - bottom=self.top, - ) - - -# Corners -class TileCorners(NamedTuple): - top_left: bool = False - top_right: bool = False - bottom_left: bool = False - bottom_right: bool = False - - @property - def as_str(self) -> str: - def s(corner: bool) -> str: - return "C" if corner else "x" - - return f"{s(self.top_left)}{s(self.top_right)}{s(self.bottom_left)}{s(self.bottom_right)}" - - @classmethod - def from_str(cls, value: str) -> Self: - if len(value) != 4: - raise ValueError(f"'{value}' is not a valid TileCorners string") - tl, tr, bl, br = tuple(value) - return cls( - top_left=(tl == "C"), - top_right=(tr == "C"), - bottom_left=(bl == "C"), - bottom_right=(br == "C"), - ) - - def h_flip(self) -> TileCorners: - return TileCorners( - top_left=self.top_right, - top_right=self.top_left, - bottom_left=self.bottom_right, - bottom_right=self.bottom_left, - ) - - def v_flip(self) -> TileCorners: - return TileCorners( - top_left=self.bottom_left, - top_right=self.bottom_right, - bottom_left=self.top_left, - bottom_right=self.top_right, - ) - - -# Contents -class Content(Enum): - EMPTY = "x" - NAVIGATION = "N" - SAVE = "S" - RECHARGE = "R" - HIDDEN_RECHARGE = "H" - DATA = "D" - ITEM = "I" - OBTAINED_ITEM = "O" - BOSS = "B" - GUNSHIP = "G" - GUNSHIP_EDGE = "P" - SECURITY = "K" - AUXILLARY_POWER = "X" - ANIMALS = "A" - BOILER_PAD = "b" - TUNNEL = "T" - - @property - def can_h_flip(self) -> bool: - exclude = { - Content.NAVIGATION, - Content.SAVE, - Content.RECHARGE, - Content.HIDDEN_RECHARGE, - Content.DATA, - Content.GUNSHIP, - Content.GUNSHIP_EDGE, - } - return self not in exclude - - @property - def can_v_flip(self) -> bool: - exclude = { - Content.NAVIGATION, - Content.SAVE, - Content.RECHARGE, - Content.HIDDEN_RECHARGE, - Content.GUNSHIP, - Content.GUNSHIP_EDGE, - Content.BOSS, - } - return self not in exclude - - -# Tile -class MapTile(NamedTuple): - edges: TileEdges = TileEdges() - corners: TileCorners = TileCorners() - content: Content = Content.EMPTY - - @property - def as_str(self) -> str: - return f"{self.edges.as_str}_{self.corners.as_str}_{self.content.value}" - - @classmethod - def from_str(cls, value: str) -> Self: - if len(value) != 11: - raise ValueError(f"'{value}' is not a valid MapTile string") - edges, corners, content = value.split("_") - return cls( - edges=TileEdges.from_str(edges), - corners=TileCorners.from_str(corners), - content=Content(content), - ) - - def h_flip(self) -> MapTile: - if not self.content.can_h_flip: - raise ValueError(f"Cannot h_flip tile with contents {self.content}") - - return MapTile( - edges=self.edges.h_flip(), - corners=self.corners.h_flip(), - content=self.content, - ) - - def v_flip(self) -> MapTile: - if not self.content.can_v_flip: - raise ValueError(f"Cannot v_flip tile with contents {self.content}") - - return MapTile( - edges=self.edges.v_flip(), - corners=self.corners.v_flip(), - content=self.content, - ) - - -# Constants COLORED_DOOR_TILES = { 0x005: MapTile.from_str("WBxx_xxxx_x"), 0x006: MapTile.from_str("DBxx_xxxx_x"), @@ -424,7 +193,6 @@ def v_flip(self) -> MapTile: 0x1AC: MapTile.from_str("WYYW_xxxx_I"), 0x1AD: MapTile.from_str("WYYW_xxxx_O"), # New Tiles - 0x10B: MapTile.from_str("xxGW_Cxxx_B"), 0x16C: MapTile.from_str("xWBW_xxxx_K"), 0x16D: MapTile.from_str("WWGW_xxxx_K"), 0x16E: MapTile.from_str("WWRW_xxxx_K"), @@ -462,6 +230,7 @@ def v_flip(self) -> MapTile: 0x084: MapTile.from_str("WDDW_xxxx_x"), 0x087: MapTile.from_str("WWWW_xxxx_x"), 0x0B4: MapTile.from_str("WSDW_xxxx_I"), + 0x0B5: MapTile.from_str("WSDW_xxxx_O"), 0x0B6: MapTile.from_str("WSDW_xxxx_x"), 0x0E0: MapTile.from_str("DDxW_xxxx_x"), 0x0E1: MapTile.from_str("DDxD_xxxx_x"), @@ -474,10 +243,9 @@ def v_flip(self) -> MapTile: 0x102: MapTile.from_str("DDxx_xxxC_x"), 0x104: MapTile.from_str("WDxx_xxxC_x"), 0x105: MapTile.from_str("xDxx_xCxx_x"), - 0x108: MapTile.from_str("xxDW_xxxx_B"), - 0x109: MapTile.from_str("WDxW_xxxx_B"), - 0x10D: MapTile.from_str("xxDx_xxxx_B"), - 0x10F: MapTile.from_str("xDxW_xxxx_B"), + 0x109: MapTile.from_str("WDxW_xxxx_B-R-D"), + 0x10D: MapTile.from_str("xxDx_xxxx_B-L-E"), + 0x10F: MapTile.from_str("xDxW_xxxx_B-TR-B"), 0x120: MapTile.from_str("DDWW_xxxx_x"), 0x121: MapTile.from_str("DDDW_xxxx_x"), 0x122: MapTile.from_str("DDWD_xxxx_x"), @@ -487,7 +255,6 @@ def v_flip(self) -> MapTile: 0x126: MapTile.from_str("xxxx_xxxx_x"), 0x127: MapTile.from_str("Wxxx_xxxC_x"), 0x129: MapTile.from_str("WDxW_xxxx_P"), - 0x12A: MapTile.from_str("WxWW_xxxx_G"), 0x12B: MapTile.from_str("WDxW_xxxx_x"), 0x12C: MapTile.from_str("DDxx_xxxx_x"), 0x138: MapTile.from_str("WDWW_xxxx_H"), @@ -545,15 +312,13 @@ def v_flip(self) -> MapTile: 0x1A9: MapTile.from_str("xWWx_xxxx_O"), 0x1AA: MapTile.from_str("DDWW_xxxx_I"), 0x1AB: MapTile.from_str("DDWW_xxxx_O"), - 0x0B5: MapTile.from_str("WSDW_xxxx_O"), # New Tiles - 0x0A4: MapTile.from_str("WDxW_xxxx_T"), - 0x0D4: MapTile.from_str("WSDW_xxxx_I"), - 0x0D5: MapTile.from_str("WSDW_xxxx_O"), - 0x10A: MapTile.from_str("WxDx_xxxx_B"), - 0x10C: MapTile.from_str("xDxW_xCxx_B"), - 0x170: MapTile.from_str("WWDx_xxxx_x"), - 0x172: MapTile.from_str("WDDW_xxxx_X"), + 0x0A4: MapTile.from_str("WDSW_xxxx_T"), + 0x108: MapTile.from_str("xxDW_Cxxx_B-TL-D"), + 0x10A: MapTile.from_str("WxDx_xxxx_B-BL-E"), + 0x10C: MapTile.from_str("xDxW_xCxx_B-TR-E"), + 0x170: MapTile.from_str("WWDx_xxxx_w"), + 0x172: MapTile.from_str("WDDW_xxxx_Y"), } COLORED_DOOR_TILE_IDS = {tile: id for id, tile in COLORED_DOOR_TILES.items()} @@ -561,3 +326,92 @@ def v_flip(self) -> MapTile: ALL_DOOR_TILES = COLORED_DOOR_TILES | NORMAL_DOOR_TILES ALL_DOOR_TILE_IDS = COLORED_DOOR_TILE_IDS | NORMAL_DOOR_TILE_IDS + +# IDs of blank minimap tiles that can be used for creating new tiles +BLANK_TILE_IDS = [ + 0x067, + 0x071, + 0x076, + 0x087, + 0x08C, + 0x091, + 0x0C0, + 0x0C1, + 0x0D2, + 0x0D3, + 0x0D8, + 0x0D9, + 0x0DA, + 0x0DB, + 0x0DC, + 0x0DD, + 0x0DE, + 0x0E9, + 0x0EE, + 0x0F3, + 0x0F9, + 0x0FA, + 0x0FB, + 0x0FC, + 0x0FD, + 0x0FE, + 0x0FF, + 0x110, + 0x111, + 0x112, + 0x113, + 0x114, + 0x115, + 0x116, + 0x117, + 0x118, + 0x119, + 0x11A, + 0x11B, + 0x11C, + 0x11D, + 0x11E, + 0x11F, + 0x12D, + 0x12E, + 0x12F, + 0x130, + 0x131, + 0x132, + 0x133, + 0x134, + 0x135, + 0x136, + 0x137, + 0x13B, + 0x13C, + 0x13D, + 0x13E, + 0x13F, + 0x15F, + 0x173, + 0x174, + 0x175, + 0x176, + 0x177, + 0x1B0, + 0x1B1, + 0x1B2, + 0x1B3, + 0x1B4, + 0x1B5, + 0x1B6, + 0x1B7, + 0x1B8, + 0x1B9, + 0x1BA, + 0x1BB, + 0x1BC, + 0x1BD, + 0x1BE, + 0x1BF, +] + +# IDs of blank minimap tiles that can be used for creating new tiles +# that require an additional transparent version +BLANK_TRANSPARENT_TILE_IDS = [0x0A1, 0x0B2, 0x0B3, 0x0B8, 0x0B9, 0x0BA, 0x0BB, 0x0BC, 0x0BD, 0x0BE] diff --git a/src/mars_patcher/mf/door_locks.py b/src/mars_patcher/mf/door_locks.py index 36d3f9d..37d277e 100644 --- a/src/mars_patcher/mf/door_locks.py +++ b/src/mars_patcher/mf/door_locks.py @@ -4,16 +4,18 @@ from typing import Annotated, TypedDict from mars_patcher.common_types import AreaId, AreaRoomPair, RoomId -from mars_patcher.constants.game_data import area_doors_ptrs +from mars_patcher.constants.game_data import area_doors_ptrs, minimap_graphics +from mars_patcher.constants.minimap_tiles import ColoredDoor, Content, Edge from mars_patcher.mf.auto_generated_types import MarsschemamfDoorlocksItem from mars_patcher.mf.constants.game_data import hatch_lock_event_count, hatch_lock_events from mars_patcher.mf.constants.minimap_tiles import ( ALL_DOOR_TILE_IDS, ALL_DOOR_TILES, - ColoredDoor, - Edge, + BLANK_TILE_IDS, + BLANK_TRANSPARENT_TILE_IDS, ) from mars_patcher.minimap import Minimap +from mars_patcher.minimap_tile_creator import create_tile from mars_patcher.rom import Rom from mars_patcher.room_entry import BlockLayer, RoomEntry @@ -196,7 +198,7 @@ def factory() -> dict: minimap_areas = [area] if area == 0: - minimap_areas = [0, 9] # Main deck seemingly has two maps? + minimap_areas = [0, 9] # Main Deck has two maps for minimap_area in minimap_areas: map_tile = minimap_changes[minimap_area][minimap_x, minimap_y, room] if facing_right: @@ -280,6 +282,10 @@ def change_minimap_tiles( # HatchLock.LOCKED: Edge.WALL, } + all_door_tile_ids = dict(ALL_DOOR_TILE_IDS) + remaining_blank_tile_ids = list(BLANK_TILE_IDS) + remaining_blank_transparent_tile_ids = list(BLANK_TRANSPARENT_TILE_IDS) + for area, area_map in minimap_changes.items(): with Minimap(rom, area) as minimap: for (x, y, room), tile_changes in area_map.items(): @@ -299,21 +305,21 @@ def change_minimap_tiles( edges = edges._replace(left=MAP_EDGES[left]) if right is not None: edges = edges._replace(right=MAP_EDGES[right]) - og_new_tile_data = tile_data._replace(edges=edges) - new_tile_data = og_new_tile_data + orig_new_tile_data = tile_data._replace(edges=edges) + new_tile_data = orig_new_tile_data def tile_exists() -> bool: - return new_tile_data in ALL_DOOR_TILE_IDS + return new_tile_data in all_door_tile_ids if new_tile_data.content.can_h_flip and not tile_exists(): # Try flipping horizontally - new_tile_data = og_new_tile_data.h_flip() + new_tile_data = orig_new_tile_data.h_flip() if tile_exists(): h_flip = not h_flip if new_tile_data.content.can_v_flip and not tile_exists(): # Try flipping vertically - new_tile_data = og_new_tile_data.v_flip() + new_tile_data = orig_new_tile_data.v_flip() if tile_exists(): v_flip = not v_flip @@ -323,7 +329,7 @@ def tile_exists() -> bool: and not tile_exists() ): # Try flipping it both ways - new_tile_data = og_new_tile_data.v_flip() + new_tile_data = orig_new_tile_data.v_flip() new_tile_data = new_tile_data.h_flip() if tile_exists(): v_flip = not v_flip @@ -331,30 +337,95 @@ def tile_exists() -> bool: if not tile_exists(): logging.debug( - "Could not edit map tile door icons for " + "Could not reuse existing map tile for " f"area {area} room {room:X}. ({x:X}, {y:X})." ) - logging.debug(f" Desired tile: {og_new_tile_data.as_str}") - logging.debug(" Falling back to unlocked doors.") + logging.debug(f" Desired tile: {orig_new_tile_data.as_str}") - # Try replacing with open doors - if (left is not None) and tile_data.edges.left.is_door: - edges = edges._replace(left=Edge.DOOR) - if (right is not None) and tile_data.edges.right.is_door: - edges = edges._replace(right=Edge.DOOR) - new_tile_data = og_new_tile_data._replace(edges=edges) + # Try getting a blank tile ID + requires_transparent_tile = orig_new_tile_data.content == Content.TUNNEL or any( + isinstance(e, Edge) and e == Edge.SHORTCUT for e in orig_new_tile_data.edges + ) + blank_tile_ids = ( + remaining_blank_transparent_tile_ids + if requires_transparent_tile + else remaining_blank_tile_ids + ) + is_item = orig_new_tile_data.content == Content.ITEM + new_tile_id = get_blank_minimap_tile_id(blank_tile_ids, is_item) + + if new_tile_id is not None: + # Create new graphics for the tile + gfx = create_tile(orig_new_tile_data) + new_tiles = [(new_tile_id, gfx, orig_new_tile_data)] + + # If the tile has an item, add another tile for the obtained item + if is_item: + data = orig_new_tile_data._replace(content=Content.OBTAINED_ITEM) + gfx = create_tile(data) + new_tiles.append((new_tile_id + 1, gfx, data)) + + # If the tile doesn't fill the whole square, add another tile with + # transparency + if requires_transparent_tile: + for tile_id, gfx, data in list(new_tiles): + data = data._replace(transparent=True) + gfx = create_tile(data) + new_tiles.append((tile_id + 0x20, gfx, data)) + + for tile_id, gfx, data in new_tiles: + addr = minimap_graphics(rom) + tile_id * 32 + rom.write_bytes(addr, gfx) + + all_door_tile_ids[data] = tile_id + logging.debug(f" Created new tile: 0x{tile_id:X}.") + + new_tile_data = orig_new_tile_data + else: + # No blank tiles remaining, try replacing with open doors + logging.warning(" No blank tiles available, trying open doors.") + if (left is not None) and tile_data.edges.left.is_door: + edges = edges._replace(left=Edge.DOOR) + if (right is not None) and tile_data.edges.right.is_door: + edges = edges._replace(right=Edge.DOOR) + new_tile_data = orig_new_tile_data._replace(edges=edges) - if tile_exists(): - logging.debug(" Still no luck. Using vanilla tile.") + if not tile_exists(): + logging.warning(" Still no luck. Using vanilla tile.") - logging.debug("") + logging.warning("") if tile_exists(): minimap.set_tile_value( x, y, - ALL_DOOR_TILE_IDS[new_tile_data], + all_door_tile_ids[new_tile_data], palette, h_flip, v_flip, ) + + +def get_blank_minimap_tile_id(blank_tile_ids: list[int], is_item: bool) -> int | None: + """Finds a usable tile from the provided list of blank tile IDs. Item tiles require a blank + tile next to them. Non-item tiles can use any blank tile, but solitary tiles are preferred + to save more tiles for item tiles.""" + valid_tile_id: int | None = None + for tile_id in blank_tile_ids: + if is_item: + # Item tiles require a blank tile next to them for the obtained item tile + if tile_id + 1 in blank_tile_ids: + valid_tile_id = tile_id + break + else: + # Prefer solitary blank tiles for non-item tiles + if tile_id + 1 not in blank_tile_ids: + valid_tile_id = tile_id + break + elif valid_tile_id is None: + valid_tile_id = tile_id + if valid_tile_id is not None: + blank_tile_ids.remove(valid_tile_id) + if is_item: + blank_tile_ids.remove(valid_tile_id + 1) + return valid_tile_id diff --git a/src/mars_patcher/minimap_tile_creator.py b/src/mars_patcher/minimap_tile_creator.py new file mode 100644 index 0000000..165f8da --- /dev/null +++ b/src/mars_patcher/minimap_tile_creator.py @@ -0,0 +1,478 @@ +from enum import Enum + +from mars_patcher.constants.minimap_tiles import ColoredDoor, Content, Edge, MapTile, TileEdges + +# Used for the edges of tiles, also used for boss icons that are always visible +COLOR_WHITE_OUTLINE = 1 +# Used for normal connections between tiles, this appears as a wall when the +# tile is unexplored. Also used for boss icons that disappear when explored +COLOR_CONNECTION_BG = 2 +# Used for the background of tiles, either gray (unexplored), pink (normal), +# or green (hidden) +COLOR_BG = 3 +# Used for the outline of blank tiles or tiles that don't fill in the whole square +COLOR_BLANK_OUTLINE = 4 +# Used for the background of blank tiles or tiles that don't fill in the whole square +COLOR_BLANK_BG = 5 +# Used for the white circle/dot representing items, this hides the item when +# the tile is unexplored +COLOR_WHITE_ITEM = 6 +# Used for tiles with a letter in them (navigation, save, etc.) +COLOR_YELLOW_LETTER = 7 + +# Used for colored pixels on the edge of tiles, including hatches and the +# outline of tiles with letters. These appear white when the tile is unexplored +COLOR_BLUE_EDGE = 8 +COLOR_GREEN_EDGE = 9 +COLOR_RED_EDGE = 10 +COLOR_YELLOW_EDGE = 11 + +COLOR_COUNT = 4 + +# Used for colored pixels not on the edge of tiles, including hatches and +# various icons. These are hidden (gray) when the tile is unexplored +COLOR_BLUE_HIDDEN = COLOR_BLUE_EDGE + COLOR_COUNT +COLOR_GREEN_HIDDEN = COLOR_GREEN_EDGE + COLOR_COUNT +COLOR_RED_HIDDEN = COLOR_RED_EDGE + COLOR_COUNT +COLOR_YELLOW_HIDDEN = COLOR_YELLOW_EDGE + COLOR_COUNT + +# The following string lists represent pixels in a minimap tile +# - Pixels with "." are left unchanged +# - Pixels with "-" are set to the background color (COLOR_BG) +# - Pixels with "#" are set to a provided color (see draw_pixel_art below) +# - Spaces are ignored + +PIXELS_ITEM = [ + ". . . . . . . .", + ". . . . . . . .", + ". . . # # . . .", + ". . # . . # . .", + ". . # . . # . .", + ". . . # # . . .", + ". . . . . . . .", + ". . . . . . . .", +] +PIXELS_NAVIGATION = [ + ". . . . . . . .", + ". . . . . . . .", + ". . # . . # . .", + ". . # # . # . .", + ". . # . # # . .", + ". . # . . # . .", + ". . . . . . . .", + ". . . . . . . .", +] +PIXELS_SAVE = [ + ". . . . . . . .", + ". . . . . . . .", + ". . . # # # . .", + ". . # . . . . .", + ". . . # # . . .", + ". . . . . # . .", + ". . # # # . . .", + ". . . . . . . .", +] +PIXELS_RECHARGE = [ + ". . . . . . . .", + ". . . . . . . .", + ". . # # # . . .", + ". . # . . # . .", + ". . # # # . . .", + ". . # . . # . .", + ". . . . . . . .", + ". . . . . . . .", +] +PIXELS_DATA = [ + ". . . . . . . .", + ". . . . . . . .", + ". . # # # . . .", + ". . # . . # . .", + ". . # . . # . .", + ". . # # # . . .", + ". . . . . . . .", + ". . . . . . . .", +] +PIXELS_SECURITY = [ + ". . . . . . . .", + ". . . . . . . .", + ". . # . # . . .", + ". . # # . . . .", + ". . # . # . . .", + ". . # . # . . .", + ". . . . . . . .", + ". . . . . . . .", +] +PIXELS_AUXILIARY = [ + ". . . . . . . .", + ". . . . . . . .", + ". - # . # . . .", + ". - . # . . . .", + ". - # . # . . .", + ". - # . # . . .", + ". . . . . . . .", + ". . . . . . . .", +] +PIXELS_MAP = [ + ". . . . . . . .", + ". . . . . . . .", + ". # . . . # . .", + ". # # . # # . .", + ". # . # . # . .", + ". # . . . # . .", + ". . . . . . . .", + ". . . . . . . .", +] + +PIXELS_BOSS = [ + ". # . . # .", + ". # # # # .", + "# # # # # #", + "# . # # . #", + ". # # # # .", + ". # . . # .", +] + +# The following string lists represent pixels in a minimap tile +# - Pixels with "." are left unchanged +# - Pixels with "-" are set to the background color (COLOR_BG) +# - Pixels with a hex character (0-9 and A-F) are set to that color +# (see draw_colored_pixel_art below) +# - Spaces are ignored + +COLORED_PIXELS_TUNNEL = [ + "4 4 4 4 4 4 4 4", + "4 5 5 5 1 5 5 4", + "1 1 1 5 1 1 5 4", + "5 5 5 5 1 1 1 4", + "5 5 5 5 1 1 5 4", + "1 1 1 5 1 5 5 4", + "4 5 5 5 5 5 5 4", + "4 4 4 4 4 4 4 4", +] + +COLORED_PIXELS_MAJOR = [ + ". . . . . . . .", + ". . . 6 6 . . .", + ". - 6 C C 6 - .", + ". 6 C 6 C C 6 .", + ". 6 C C C C 6 .", + ". - 6 C C 6 - .", + ". . . 6 6 . . .", + ". . . . . . . .", +] +COLORED_PIXELS_CHOZO = [ + ". . . . . . . .", + ". . 6 6 6 . . .", + ". 6 E E E 6 - .", + ". E E 6 E E 6 .", + ". E E E E E 6 .", + ". E 6 E E E 6 .", + ". 6 6 E E E 6 .", + ". . . . . . . .", +] + +HAS_RED_OUTLINE = { + Content.EMPTY_RED_WALLS, + Content.NAVIGATION, + Content.SAVE, + Content.RECHARGE, + Content.HIDDEN_RECHARGE, + Content.DATA, + Content.SECURITY, +} + + +class TileSide(Enum): + TOP = 0 + LEFT = 1 + RIGHT = 2 + BOTTOM = 3 + + +def create_tile(tile: MapTile) -> bytearray: + gfx = bytearray([(COLOR_BG << 4) | COLOR_BG for _ in range(32)]) + + # Handle tunnels separately + if tile.content == Content.TUNNEL: + draw_tunnel(gfx, tile.edges) + if tile.transparent: + make_transparent(gfx) + return gfx + + # Corners + if tile.corners.top_left: + set_pixel(gfx, COLOR_WHITE_OUTLINE, 0, 0) + if tile.corners.top_right: + set_pixel(gfx, COLOR_WHITE_OUTLINE, 7, 0) + if tile.corners.bottom_left: + set_pixel(gfx, COLOR_WHITE_OUTLINE, 0, 7) + if tile.corners.bottom_right: + set_pixel(gfx, COLOR_WHITE_OUTLINE, 7, 7) + + # Edges + for edge, side in zip(tile.edges, TileSide): + if isinstance(edge, Edge): + if edge == Edge.WALL: + draw_wall(gfx, side) + elif edge == Edge.DOOR: + draw_connection(gfx, side) + # TODO: Support Edge.SHORTCUT + elif isinstance(edge, ColoredDoor): + if edge == ColoredDoor.BLUE: + draw_hatch_mf(gfx, COLOR_BLUE_EDGE, side) + elif edge == ColoredDoor.GREEN: + draw_hatch_mf(gfx, COLOR_GREEN_EDGE, side) + elif edge == ColoredDoor.YELLOW: + draw_hatch_mf(gfx, COLOR_YELLOW_EDGE, side) + elif edge == ColoredDoor.RED: + draw_hatch_mf(gfx, COLOR_RED_EDGE, side) + # TODO: ZM hatch types + + # Content + if tile.content in HAS_RED_OUTLINE: + draw_red_outline(gfx, tile.edges) + + # The following don't have doors near them so they aren't needed: + # Gunship, Animals, Boiler Pad + match tile.content: + case Content.NAVIGATION: + draw_pixel_art(gfx, COLOR_YELLOW_LETTER, PIXELS_NAVIGATION) + case Content.SAVE: + draw_pixel_art(gfx, COLOR_YELLOW_LETTER, PIXELS_SAVE) + case Content.RECHARGE: + draw_pixel_art(gfx, COLOR_YELLOW_LETTER, PIXELS_RECHARGE) + case Content.HIDDEN_RECHARGE: + draw_pixel_art(gfx, COLOR_YELLOW_HIDDEN, PIXELS_RECHARGE) + case Content.DATA: + draw_pixel_art(gfx, COLOR_YELLOW_LETTER, PIXELS_DATA) + case Content.ITEM: + draw_pixel_art(gfx, COLOR_WHITE_ITEM, PIXELS_ITEM) + case Content.OBTAINED_ITEM: + draw_obtained_tank(gfx) + case Content.BOSS_RIGHT_DOWNLOADED: + draw_boss_room(gfx, COLOR_CONNECTION_BG, 2, 1) + case Content.BOSS_BOTTOM_LEFT_EXPLORED: + draw_boss_room(gfx, COLOR_WHITE_ITEM, 0, 2) + case Content.BOSS_TOP_LEFT_DOWNLOADED: + draw_boss_room(gfx, COLOR_CONNECTION_BG, 0, 0) + case Content.BOSS_LEFT_EXPLORED: + draw_boss_room(gfx, COLOR_WHITE_ITEM, 0, 1) + case Content.BOSS_TOP_RIGHT_BOTH: + draw_boss_room(gfx, COLOR_WHITE_OUTLINE, 2, 0) + case Content.BOSS_TOP_RIGHT_EXPLORED: + draw_boss_room(gfx, COLOR_WHITE_ITEM, 2, 0) + case Content.GUNSHIP_EDGE: + draw_gunship_edge(gfx) + case Content.SECURITY: + draw_pixel_art(gfx, COLOR_YELLOW_LETTER, PIXELS_SECURITY) + case Content.AUXILLARY_POWER: + draw_pixel_art(gfx, COLOR_YELLOW_LETTER, PIXELS_AUXILIARY) + case _: + if tile.content != Content.EMPTY and tile.content != Content.EMPTY_RED_WALLS: + raise ValueError(f"No implementation to create tile content {tile.content}") + + if tile.transparent: + make_transparent(gfx) + return gfx + + +def make_transparent(gfx: bytearray) -> None: + for y in range(8): + for x in range(8): + color = get_pixel(gfx, x, y) + if color == COLOR_BLANK_OUTLINE or color == COLOR_BLANK_BG: + set_pixel(gfx, 0, x, y) + + +def get_pixel(gfx: bytearray, x: int, y: int) -> int: + index = (y * 8 + x) // 2 + if x % 2 == 0: + return gfx[index] & 0xF + else: + return gfx[index] >> 4 + + +def set_pixel(gfx: bytearray, color: int, x: int, y: int) -> None: + index = (y * 8 + x) // 2 + if x % 2 == 0: + gfx[index] = (gfx[index] & 0xF0) | color + else: + gfx[index] = (gfx[index] & 0xF) | (color << 4) + + +def draw_pixel_art(gfx: bytearray, color: int, art: list[str]) -> None: + for y, row in enumerate(art): + for x, character in enumerate(row.replace(" ", "")): + if character != ".": + actual_color = COLOR_BG if character == "-" else color + set_pixel(gfx, actual_color, x, y) + + +def draw_colored_pixel_art(gfx: bytearray, art: list[str]) -> None: + for y, row in enumerate(art): + for x, character in enumerate(row.replace(" ", "")): + if character != ".": + color = COLOR_BG if character == "-" else int(character, 16) + set_pixel(gfx, color, x, y) + + +def draw_wall(gfx: bytearray, side: TileSide) -> None: + additional_coordinate = 0 if side == TileSide.TOP or side == TileSide.LEFT else 7 + if side == TileSide.TOP or side == TileSide.BOTTOM: + for x in range(8): + set_pixel(gfx, COLOR_WHITE_OUTLINE, x, additional_coordinate) + elif side == TileSide.LEFT or side == TileSide.RIGHT: + for y in range(8): + set_pixel(gfx, COLOR_WHITE_OUTLINE, additional_coordinate, y) + + +def draw_connection(gfx: bytearray, side: TileSide) -> None: + draw_wall(gfx, side) + + if side == TileSide.TOP: + set_pixel(gfx, COLOR_CONNECTION_BG, 3, 0) + set_pixel(gfx, COLOR_CONNECTION_BG, 4, 0) + elif side == TileSide.BOTTOM: + set_pixel(gfx, COLOR_CONNECTION_BG, 3, 7) + set_pixel(gfx, COLOR_CONNECTION_BG, 4, 7) + elif side == TileSide.LEFT: + set_pixel(gfx, COLOR_CONNECTION_BG, 0, 3) + set_pixel(gfx, COLOR_CONNECTION_BG, 0, 4) + elif side == TileSide.RIGHT: + set_pixel(gfx, COLOR_CONNECTION_BG, 7, 3) + set_pixel(gfx, COLOR_CONNECTION_BG, 7, 4) + + +def draw_hatch_mf(gfx: bytearray, color: int, side: TileSide) -> None: + draw_wall(gfx, side) + + inner = color + COLOR_COUNT + if side == TileSide.LEFT: + set_pixel(gfx, color, 0, 3) + set_pixel(gfx, color, 0, 4) + set_pixel(gfx, inner, 1, 2) + set_pixel(gfx, inner, 1, 3) + set_pixel(gfx, inner, 1, 4) + set_pixel(gfx, inner, 1, 5) + elif side == TileSide.RIGHT: + set_pixel(gfx, color, 7, 3) + set_pixel(gfx, color, 7, 4) + set_pixel(gfx, inner, 6, 2) + set_pixel(gfx, inner, 6, 3) + set_pixel(gfx, inner, 6, 4) + set_pixel(gfx, inner, 6, 5) + + +def draw_hatch_zm(gfx: bytearray, color: int, side: TileSide) -> None: + draw_wall(gfx, side) + + inner = color + COLOR_COUNT + if side == TileSide.LEFT: + set_pixel(gfx, color, 0, 3) + set_pixel(gfx, color, 0, 4) + set_pixel(gfx, COLOR_WHITE_ITEM, 1, 2) + set_pixel(gfx, inner, 1, 3) + set_pixel(gfx, inner, 1, 4) + set_pixel(gfx, COLOR_WHITE_ITEM, 1, 5) + elif side == TileSide.RIGHT: + set_pixel(gfx, color, 7, 3) + set_pixel(gfx, color, 7, 4) + set_pixel(gfx, COLOR_WHITE_ITEM, 6, 2) + set_pixel(gfx, inner, 6, 3) + set_pixel(gfx, inner, 6, 4) + set_pixel(gfx, COLOR_WHITE_ITEM, 6, 5) + + +def draw_obtained_tank(gfx: bytearray) -> None: + set_pixel(gfx, COLOR_WHITE_ITEM, 3, 3) + set_pixel(gfx, COLOR_WHITE_ITEM, 3, 4) + set_pixel(gfx, COLOR_WHITE_ITEM, 4, 3) + set_pixel(gfx, COLOR_WHITE_ITEM, 4, 4) + + +def draw_red_outline(gfx: bytearray, edges: TileEdges) -> None: + if (isinstance(edges.left, Edge) and edges.left == Edge.EMPTY) or ( + isinstance(edges.right, Edge) and edges.right == Edge.EMPTY + ): + raise ValueError( + "The current implementation for red outline minimap tiles only supports " + "left and right edges being walls or doors" + ) + + # Check for top and bottom walls + if edges.top == Edge.WALL: + for x in range(1, 7): + set_pixel(gfx, COLOR_RED_EDGE, x, 0) + if edges.bottom == Edge.WALL: + for x in range(1, 7): + set_pixel(gfx, COLOR_RED_EDGE, x, 7) + + # Draw parts of left and right edges that are always there + for y in [0, 1, 6, 7]: + set_pixel(gfx, COLOR_RED_EDGE, 0, y) + set_pixel(gfx, COLOR_RED_EDGE, 7, y) + + # Check for left wall + if isinstance(edges.left, Edge) and edges.left == Edge.WALL: + for y in range(2, 6): + set_pixel(gfx, COLOR_RED_EDGE, 0, y) + else: + # Remove any inner hatch pixels + for y in range(2, 6): + set_pixel(gfx, COLOR_BG, 1, y) + + # Check for right wall + if isinstance(edges.right, Edge) and edges.right == Edge.WALL: + for y in range(2, 6): + set_pixel(gfx, COLOR_RED_EDGE, 7, y) + else: + # Remove any inner hatch pixels + for y in range(2, 6): + set_pixel(gfx, COLOR_BG, 6, y) + + +def draw_boss_room(gfx: bytearray, color: int, x_offset: int, y_offset: int) -> None: + # Offsets are relative to the icon being in the top left corner + for y, row in enumerate(PIXELS_BOSS): + for x, character in enumerate(row.replace(" ", "")): + if character == "#": + set_pixel(gfx, color, x + x_offset, y + y_offset) + + +def draw_gunship_edge(gfx: bytearray) -> None: + set_pixel(gfx, COLOR_YELLOW_LETTER, 7, 5) + + +def draw_tunnel(gfx: bytearray, edges: TileEdges) -> None: + if (isinstance(edges.left, Edge) and edges.left == Edge.DOOR) or isinstance( + edges.left, ColoredDoor + ): + # Door on left, arrow faces right + draw_colored_pixel_art(gfx, COLORED_PIXELS_TUNNEL) + edge = edges.left + x = 0 + elif (isinstance(edges.right, Edge) and edges.right == Edge.DOOR) or isinstance( + edges.right, ColoredDoor + ): + # Door on right, arrow faces left + flipped = [row[::-1] for row in COLORED_PIXELS_TUNNEL] + draw_colored_pixel_art(gfx, flipped) + edge = edges.right + x = 7 + else: + raise ValueError("Tunnel does not have any doors") + + color = -1 + if isinstance(edge, ColoredDoor): + if edge == ColoredDoor.BLUE: + color = COLOR_BLUE_EDGE + elif edge == ColoredDoor.GREEN: + color = COLOR_GREEN_EDGE + elif edge == ColoredDoor.YELLOW: + color = COLOR_YELLOW_EDGE + elif edge == ColoredDoor.RED: + color = COLOR_RED_EDGE + + if color != -1: + set_pixel(gfx, color, x, 3) + set_pixel(gfx, color, x, 4)