diff --git a/docs/tutorials/part_00_setup/part_00_setup.py b/docs/tutorials/part_00_setup/part_00_setup.py new file mode 100644 index 0000000..f90eed9 --- /dev/null +++ b/docs/tutorials/part_00_setup/part_00_setup.py @@ -0,0 +1,30 @@ +"""McRogueFace - Part 0: Setting Up McRogueFace + +Documentation: https://mcrogueface.github.io/tutorial/part_00_setup +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_00_setup/part_00_setup.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +# Create a Scene object - this is the preferred approach +scene = mcrfpy.Scene("hello") + +# Create a caption to display text +title = mcrfpy.Caption( + pos=(512, 300), + text="Hello, Roguelike!" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 32 + +# Add the caption to the scene's UI collection +scene.children.append(title) + +# Activate the scene to display it +scene.activate() + +# Note: There is no run() function! +# The engine is already running - your script is imported by it. \ No newline at end of file diff --git a/docs/tutorials/part_01_grid_movement/part_01_grid_movement.py b/docs/tutorials/part_01_grid_movement/part_01_grid_movement.py new file mode 100644 index 0000000..53c236e --- /dev/null +++ b/docs/tutorials/part_01_grid_movement/part_01_grid_movement.py @@ -0,0 +1,121 @@ +"""McRogueFace - Part 1: The '@' and the Dungeon Grid + +Documentation: https://mcrogueface.github.io/tutorial/part_01_grid_movement +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_01_grid_movement/part_01_grid_movement.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +# Sprite indices for CP437 tileset +SPRITE_AT = 64 # '@' - player character +SPRITE_FLOOR = 46 # '.' - floor tile + +# Grid dimensions (in tiles) +GRID_WIDTH = 20 +GRID_HEIGHT = 15 + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load the texture (sprite sheet) +# Parameters: path, sprite_width, sprite_height +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +# The grid displays tiles and contains entities +grid = mcrfpy.Grid( + pos=(100, 80), # Position on screen (pixels) + size=(640, 480), # Display size (pixels) + grid_size=(GRID_WIDTH, GRID_HEIGHT), # Size in tiles + texture=texture +) + +# Set the zoom level for better visibility +grid.zoom = 2.0 + +# Fill the grid with floor tiles +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + +# Create the player entity at the center of the grid +player = mcrfpy.Entity( + grid_pos=(GRID_WIDTH // 2, GRID_HEIGHT // 2), # Grid coordinates, not pixels! + texture=texture, + sprite_index=SPRITE_AT +) + +# Add the player to the grid +# Option 1: Use the grid parameter in constructor +# player = mcrfpy.Entity(grid_pos=(10, 7), texture=texture, sprite_index=SPRITE_AT, grid=grid) + +# Option 2: Append to grid.entities (what we will use) +grid.entities.append(player) + +# Add the grid to the scene +scene.children.append(grid) + +# Add a title caption +title = mcrfpy.Caption( + pos=(100, 20), + text="Part 1: Grid Movement - Use Arrow Keys or WASD" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 18 +scene.children.append(title) + +# Add a position display +pos_display = mcrfpy.Caption( + pos=(100, 50), + text=f"Player Position: ({player.x}, {player.y})" +) +pos_display.fill_color = mcrfpy.Color(200, 200, 100) +pos_display.font_size = 16 +scene.children.append(pos_display) + +def handle_keys(key: str, action: str) -> None: + """Handle keyboard input to move the player. + + Args: + key: The key that was pressed (e.g., "W", "Up", "Space") + action: Either "start" (key pressed) or "end" (key released) + """ + # Only respond to key press, not release + if action != "start": + return + + # Get current player position + px, py = int(player.x), int(player.y) + + # Calculate new position based on key + if key == "W" or key == "Up": + py -= 1 # Up decreases Y + elif key == "S" or key == "Down": + py += 1 # Down increases Y + elif key == "A" or key == "Left": + px -= 1 # Left decreases X + elif key == "D" or key == "Right": + px += 1 # Right increases X + elif key == "Escape": + mcrfpy.exit() + return + + # Update player position + player.x = px + player.y = py + + # Update the position display + pos_display.text = f"Player Position: ({player.x}, {player.y})" + +# Set the key handler on the scene +# This is the preferred approach - works on ANY scene, not just the active one +scene.on_key = handle_keys + +# Activate the scene +scene.activate() + +print("Part 1 loaded! Use WASD or Arrow keys to move.") \ No newline at end of file diff --git a/docs/tutorials/part_02_tiles_collision/part_02_tiles_collision.py b/docs/tutorials/part_02_tiles_collision/part_02_tiles_collision.py new file mode 100644 index 0000000..66feaa4 --- /dev/null +++ b/docs/tutorials/part_02_tiles_collision/part_02_tiles_collision.py @@ -0,0 +1,206 @@ +"""McRogueFace - Part 2: Walls, Floors, and Collision + +Documentation: https://mcrogueface.github.io/tutorial/part_02_tiles_collision +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_02_tiles_collision/part_02_tiles_collision.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player + +# Grid dimensions +GRID_WIDTH = 30 +GRID_HEIGHT = 20 + +# ============================================================================= +# Map Creation +# ============================================================================= + +def create_map(grid: mcrfpy.Grid) -> None: + """Fill the grid with walls and floors. + + Creates a simple room with walls around the edges and floor in the middle. + """ + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + + # Place walls around the edges + if x == 0 or x == GRID_WIDTH - 1 or y == 0 or y == GRID_HEIGHT - 1: + cell.tilesprite = SPRITE_WALL + cell.walkable = False + else: + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + + # Add some interior walls to make it interesting + # Vertical wall + for y in range(5, 15): + cell = grid.at(10, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + + # Horizontal wall + for x in range(15, 25): + cell = grid.at(x, 10) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + + # Leave gaps for doorways + grid.at(10, 10).tilesprite = SPRITE_FLOOR + grid.at(10, 10).walkable = True + grid.at(20, 10).tilesprite = SPRITE_FLOOR + grid.at(20, 10).walkable = True + +# ============================================================================= +# Collision Detection +# ============================================================================= + +def can_move_to(grid: mcrfpy.Grid, x: int, y: int) -> bool: + """Check if a position is valid for movement. + + Args: + grid: The game grid + x: Target X coordinate (in tiles) + y: Target Y coordinate (in tiles) + + Returns: + True if the position is walkable, False otherwise + """ + # Check grid bounds first + if x < 0 or x >= GRID_WIDTH: + return False + if y < 0 or y >= GRID_HEIGHT: + return False + + # Check if the tile is walkable + cell = grid.at(x, y) + return cell.walkable + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(80, 100), + size=(720, 480), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.5 + +# Build the map +create_map(grid) + +# Create the player in the center of the left room +player = mcrfpy.Entity( + grid_pos=(5, 10), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(80, 20), + text="Part 2: Walls and Collision" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(80, 55), + text="WASD or Arrow Keys to move | Walls block movement" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 16 +scene.children.append(instructions) + +pos_display = mcrfpy.Caption( + pos=(80, 600), + text=f"Position: ({int(player.x)}, {int(player.y)})" +) +pos_display.fill_color = mcrfpy.Color(200, 200, 100) +pos_display.font_size = 16 +scene.children.append(pos_display) + +status_display = mcrfpy.Caption( + pos=(400, 600), + text="Status: Ready" +) +status_display.fill_color = mcrfpy.Color(100, 200, 100) +status_display.font_size = 16 +scene.children.append(status_display) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def handle_keys(key: str, action: str) -> None: + """Handle keyboard input with collision detection.""" + if action != "start": + return + + # Get current position + px, py = int(player.x), int(player.y) + + # Calculate intended new position + new_x, new_y = px, py + + if key == "W" or key == "Up": + new_y -= 1 + elif key == "S" or key == "Down": + new_y += 1 + elif key == "A" or key == "Left": + new_x -= 1 + elif key == "D" or key == "Right": + new_x += 1 + elif key == "Escape": + mcrfpy.exit() + return + else: + return # Ignore other keys + + # Check collision before moving + if can_move_to(grid, new_x, new_y): + player.x = new_x + player.y = new_y + pos_display.text = f"Position: ({new_x}, {new_y})" + status_display.text = "Status: Moved" + status_display.fill_color = mcrfpy.Color(100, 200, 100) + else: + status_display.text = "Status: Blocked!" + status_display.fill_color = mcrfpy.Color(200, 100, 100) + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 2 loaded! Try walking into walls.") \ No newline at end of file diff --git a/docs/tutorials/part_03_dungeon_generation/part_03_dungeon_generation.py b/docs/tutorials/part_03_dungeon_generation/part_03_dungeon_generation.py new file mode 100644 index 0000000..632ad2f --- /dev/null +++ b/docs/tutorials/part_03_dungeon_generation/part_03_dungeon_generation.py @@ -0,0 +1,356 @@ +"""McRogueFace - Part 3: Procedural Dungeon Generation + +Documentation: https://mcrogueface.github.io/tutorial/part_03_dungeon_generation +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_03_dungeon_generation/part_03_dungeon_generation.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy +import random + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 35 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + """Create a new room. + + Args: + x: Left edge X coordinate + y: Top edge Y coordinate + width: Room width in tiles + height: Room height in tiles + """ + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + """Return the center coordinates of the room.""" + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return center_x, center_y + + @property + def inner(self) -> tuple[slice, slice]: + """Return the inner area of the room (excluding walls). + + The inner area is one tile smaller on each side to leave room + for walls between adjacent rooms. + """ + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + """Check if this room overlaps with another room. + + Args: + other: Another RectangularRoom to check against + + Returns: + True if the rooms overlap, False otherwise + """ + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(grid: mcrfpy.Grid) -> None: + """Fill the entire grid with wall tiles.""" + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(grid: mcrfpy.Grid, room: RectangularRoom) -> None: + """Carve out a room by setting its inner tiles to floor. + + Args: + grid: The game grid + room: The room to carve + """ + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + """Carve a horizontal tunnel. + + Args: + grid: The game grid + x1: Starting X coordinate + x2: Ending X coordinate + y: Y coordinate of the tunnel + """ + start_x = min(x1, x2) + end_x = max(x1, x2) + for x in range(start_x, end_x + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + """Carve a vertical tunnel. + + Args: + grid: The game grid + y1: Starting Y coordinate + y2: Ending Y coordinate + x: X coordinate of the tunnel + """ + start_y = min(y1, y2) + end_y = max(y1, y2) + for y in range(start_y, end_y + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + """Carve an L-shaped tunnel between two points. + + Randomly chooses to go horizontal-then-vertical or vertical-then-horizontal. + + Args: + grid: The game grid + start: Starting (x, y) coordinates + end: Ending (x, y) coordinates + """ + x1, y1 = start + x2, y2 = end + + # Randomly choose whether to go horizontal or vertical first + if random.random() < 0.5: + # Horizontal first, then vertical + carve_tunnel_horizontal(grid, x1, x2, y1) + carve_tunnel_vertical(grid, y1, y2, x2) + else: + # Vertical first, then horizontal + carve_tunnel_vertical(grid, y1, y2, x1) + carve_tunnel_horizontal(grid, x1, x2, y2) + +def generate_dungeon(grid: mcrfpy.Grid) -> tuple[int, int]: + """Generate a dungeon with rooms and tunnels. + + Args: + grid: The game grid to generate the dungeon in + + Returns: + The (x, y) coordinates where the player should start + """ + # Start with all walls + fill_with_walls(grid) + + rooms: list[RectangularRoom] = [] + + for _ in range(MAX_ROOMS): + # Random room dimensions + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + + # Random position (leaving 1-tile border) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + # Check for overlap with existing rooms + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue # Skip this room, try another + + # No overlap - carve out the room + carve_room(grid, new_room) + + # Connect to previous room with a tunnel + if rooms: + # Tunnel from this room's center to the previous room's center + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + # Return the center of the first room as the player start position + if rooms: + return rooms[0].center + else: + # Fallback if no rooms were generated + return GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# ============================================================================= +# Collision Detection +# ============================================================================= + +def can_move_to(grid: mcrfpy.Grid, x: int, y: int) -> bool: + """Check if a position is valid for movement.""" + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + return grid.at(x, y).walkable + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(50, 80), + size=(800, 560), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate the dungeon and get player start position +player_start_x, player_start_y = generate_dungeon(grid) + +# Create the player at the starting position +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(50, 15), + text="Part 3: Procedural Dungeon Generation" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(50, 50), + text="WASD/Arrows: Move | R: Regenerate dungeon | Escape: Quit" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 16 +scene.children.append(instructions) + +pos_display = mcrfpy.Caption( + pos=(50, 660), + text=f"Position: ({int(player.x)}, {int(player.y)})" +) +pos_display.fill_color = mcrfpy.Color(200, 200, 100) +pos_display.font_size = 16 +scene.children.append(pos_display) + +room_display = mcrfpy.Caption( + pos=(400, 660), + text="Press R to regenerate the dungeon" +) +room_display.fill_color = mcrfpy.Color(100, 200, 100) +room_display.font_size = 16 +scene.children.append(room_display) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def regenerate_dungeon() -> None: + """Generate a new dungeon and reposition the player.""" + new_x, new_y = generate_dungeon(grid) + player.x = new_x + player.y = new_y + pos_display.text = f"Position: ({new_x}, {new_y})" + room_display.text = "New dungeon generated!" + +def handle_keys(key: str, action: str) -> None: + """Handle keyboard input.""" + if action != "start": + return + + px, py = int(player.x), int(player.y) + new_x, new_y = px, py + + if key == "W" or key == "Up": + new_y -= 1 + elif key == "S" or key == "Down": + new_y += 1 + elif key == "A" or key == "Left": + new_x -= 1 + elif key == "D" or key == "Right": + new_x += 1 + elif key == "R": + regenerate_dungeon() + return + elif key == "Escape": + mcrfpy.exit() + return + else: + return + + if can_move_to(grid, new_x, new_y): + player.x = new_x + player.y = new_y + pos_display.text = f"Position: ({new_x}, {new_y})" + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 3 loaded! Explore the dungeon or press R to regenerate.") \ No newline at end of file diff --git a/docs/tutorials/part_04_fov/part_04_fov.py b/docs/tutorials/part_04_fov/part_04_fov.py new file mode 100644 index 0000000..97d9187 --- /dev/null +++ b/docs/tutorials/part_04_fov/part_04_fov.py @@ -0,0 +1,363 @@ +"""McRogueFace - Part 4: Field of View + +Documentation: https://mcrogueface.github.io/tutorial/part_04_fov +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_04_fov/part_04_fov.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy +import random + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 35 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# FOV settings +FOV_RADIUS = 8 + +# Visibility colors (applied as overlays) +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) # Fully transparent - show tile +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) # Dark blue tint - dimmed +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) # Solid black - hidden + +# ============================================================================= +# Room Class (from Part 3) +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return center_x, center_y + + @property + def inner(self) -> tuple[slice, slice]: + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +# Track which tiles have been discovered (seen at least once) +explored: list[list[bool]] = [] + +def init_explored() -> None: + """Initialize the explored array to all False.""" + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + """Mark a tile as explored.""" + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + """Check if a tile has been explored.""" + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation (from Part 3, with transparent property) +# ============================================================================= + +def fill_with_walls(grid: mcrfpy.Grid) -> None: + """Fill the entire grid with wall tiles.""" + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False # Walls block line of sight + +def carve_room(grid: mcrfpy.Grid, room: RectangularRoom) -> None: + """Carve out a room by setting its inner tiles to floor.""" + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True # Floors allow line of sight + +def carve_tunnel_horizontal(grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + """Carve a horizontal tunnel.""" + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + """Carve a vertical tunnel.""" + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + """Carve an L-shaped tunnel between two points.""" + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(grid, x1, x2, y1) + carve_tunnel_vertical(grid, y1, y2, x2) + else: + carve_tunnel_vertical(grid, y1, y2, x1) + carve_tunnel_horizontal(grid, x1, x2, y2) + +def generate_dungeon(grid: mcrfpy.Grid) -> tuple[int, int]: + """Generate a dungeon with rooms and tunnels.""" + fill_with_walls(grid) + init_explored() # Reset exploration when generating new dungeon + + rooms: list[RectangularRoom] = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + rooms.append(new_room) + + if rooms: + return rooms[0].center + return GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_fov(grid: mcrfpy.Grid, fov_layer, player_x: int, player_y: int) -> None: + """Update the field of view visualization. + + Args: + grid: The game grid + fov_layer: The ColorLayer for FOV visualization + player_x: Player's X position + player_y: Player's Y position + """ + # Compute FOV from player position + grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + # Update each tile's visibility + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if grid.is_in_fov(x, y): + # Currently visible - mark as explored and show clearly + mark_explored(x, y) + fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + # Previously seen but not currently visible - show dimmed + fov_layer.set(x, y, COLOR_DISCOVERED) + else: + # Never seen - hide completely + fov_layer.set(x, y, COLOR_UNKNOWN) + +# ============================================================================= +# Collision Detection +# ============================================================================= + +def can_move_to(grid: mcrfpy.Grid, x: int, y: int) -> bool: + """Check if a position is valid for movement.""" + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + return grid.at(x, y).walkable + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(50, 80), + size=(800, 560), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate the dungeon +player_start_x, player_start_y = generate_dungeon(grid) + +# Add a color layer for FOV visualization (below entities) +fov_layer = grid.add_layer("color", z_index=-1) + +# Initialize the FOV layer to all black (unknown) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Create the player +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +# Calculate initial FOV +update_fov(grid, fov_layer, player_start_x, player_start_y) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(50, 15), + text="Part 4: Field of View" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(50, 50), + text="WASD/Arrows: Move | R: Regenerate | Escape: Quit" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 16 +scene.children.append(instructions) + +pos_display = mcrfpy.Caption( + pos=(50, 660), + text=f"Position: ({int(player.x)}, {int(player.y)})" +) +pos_display.fill_color = mcrfpy.Color(200, 200, 100) +pos_display.font_size = 16 +scene.children.append(pos_display) + +fov_display = mcrfpy.Caption( + pos=(400, 660), + text=f"FOV Radius: {FOV_RADIUS}" +) +fov_display.fill_color = mcrfpy.Color(100, 200, 100) +fov_display.font_size = 16 +scene.children.append(fov_display) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def regenerate_dungeon() -> None: + """Generate a new dungeon and reposition the player.""" + new_x, new_y = generate_dungeon(grid) + player.x = new_x + player.y = new_y + + # Reset FOV layer to unknown + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + # Calculate new FOV + update_fov(grid, fov_layer, new_x, new_y) + pos_display.text = f"Position: ({new_x}, {new_y})" + +def handle_keys(key: str, action: str) -> None: + """Handle keyboard input.""" + if action != "start": + return + + px, py = int(player.x), int(player.y) + new_x, new_y = px, py + + if key == "W" or key == "Up": + new_y -= 1 + elif key == "S" or key == "Down": + new_y += 1 + elif key == "A" or key == "Left": + new_x -= 1 + elif key == "D" or key == "Right": + new_x += 1 + elif key == "R": + regenerate_dungeon() + return + elif key == "Escape": + mcrfpy.exit() + return + else: + return + + if can_move_to(grid, new_x, new_y): + player.x = new_x + player.y = new_y + pos_display.text = f"Position: ({new_x}, {new_y})" + + # Update FOV after movement + update_fov(grid, fov_layer, new_x, new_y) + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 4 loaded! Explore the dungeon - watch the fog of war!") \ No newline at end of file diff --git a/docs/tutorials/part_05_enemies/part_05_enemies.py b/docs/tutorials/part_05_enemies/part_05_enemies.py new file mode 100644 index 0000000..9abfc42 --- /dev/null +++ b/docs/tutorials/part_05_enemies/part_05_enemies.py @@ -0,0 +1,685 @@ +"""McRogueFace - Part 5: Placing Enemies + +Documentation: https://mcrogueface.github.io/tutorial/part_05_enemies +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_05_enemies/part_05_enemies.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy +import random + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player + +# Enemy sprites (lowercase letters in CP437) +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 35 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# Enemy spawn parameters +MAX_ENEMIES_PER_ROOM = 3 + +# FOV settings +FOV_RADIUS = 8 + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# ============================================================================= +# Enemy Data +# ============================================================================= + +# Enemy templates - stats for each enemy type +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "max_hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) # Greenish + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "max_hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) # Darker green + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "max_hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) # Dark green + } +} + +# Global storage for entity data +# Maps entity objects to their data dictionaries +entity_data: dict = {} + +# Global references +player = None +grid = None +fov_layer = None + +# ============================================================================= +# Room Class (from Part 3) +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return center_x, center_y + + @property + def inner(self) -> tuple[slice, slice]: + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking (from Part 4) +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + """Initialize the explored array to all False.""" + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + """Mark a tile as explored.""" + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + """Check if a tile has been explored.""" + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation (from Part 4) +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + """Fill the entire grid with wall tiles.""" + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + """Carve out a room by setting its inner tiles to floor.""" + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + """Carve a horizontal tunnel.""" + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + """Carve a vertical tunnel.""" + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + """Carve an L-shaped tunnel between two points.""" + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +# ============================================================================= +# Enemy Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, texture: mcrfpy.Texture) -> mcrfpy.Entity: + """Spawn an enemy at the given position. + + Args: + target_grid: The game grid + x: X position in tiles + y: Y position in tiles + enemy_type: Type of enemy ("goblin", "orc", or "troll") + texture: The texture to use for the sprite + + Returns: + The created enemy Entity + """ + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=texture, + sprite_index=template["sprite"] + ) + + + # Start hidden until player sees them + enemy.visible = False + + # Add to grid + target_grid.entities.append(enemy) + + # Store enemy data + entity_data[enemy] = { + "type": enemy_type, + "name": enemy_type.capitalize(), + "hp": template["hp"], + "max_hp": template["max_hp"], + "attack": template["attack"], + "defense": template["defense"], + "is_player": False + } + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, texture: mcrfpy.Texture) -> None: + """Spawn random enemies in a room. + + Args: + target_grid: The game grid + room: The room to spawn enemies in + texture: The texture to use for sprites + """ + # Random number of enemies (0 to MAX_ENEMIES_PER_ROOM) + num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM) + + for _ in range(num_enemies): + # Random position within the room's inner area + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + # Check if position is already occupied + if get_blocking_entity_at(target_grid, x, y) is not None: + continue # Skip this spawn attempt + + # Choose enemy type based on weighted random + roll = random.random() + if roll < 0.6: + enemy_type = "goblin" # 60% chance + elif roll < 0.9: + enemy_type = "orc" # 30% chance + else: + enemy_type = "troll" # 10% chance + + spawn_enemy(target_grid, x, y, enemy_type, texture) + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int) -> mcrfpy.Entity | None: + """Get any entity that blocks movement at the given position. + + Args: + target_grid: The game grid + x: X position to check + y: Y position to check + + Returns: + The blocking entity, or None if no entity blocks this position + """ + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def clear_enemies(target_grid: mcrfpy.Grid) -> None: + """Remove all enemies from the grid.""" + global entity_data + + # Get list of enemies to remove (not the player) + enemies_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and not entity_data[entity].get("is_player", False): + enemies_to_remove.append(entity) + + # Remove from grid and entity_data + for enemy in enemies_to_remove: + # Find and remove from grid.entities + for i, e in enumerate(target_grid.entities): + if e == enemy: + target_grid.entities.remove(i) + break + # Remove from entity_data + if enemy in entity_data: + del entity_data[enemy] + +# ============================================================================= +# Entity Visibility +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + """Update visibility of all entities based on FOV. + + Entities outside the player's field of view are hidden. + """ + global player + + for entity in target_grid.entities: + # Player is always visible + if entity == player: + entity.visible = True + continue + + # Other entities are only visible if in FOV + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +# ============================================================================= +# Field of View (from Part 4) +# ============================================================================= + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + """Update the field of view visualization.""" + # Compute FOV from player position + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + # Update each tile's visibility + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + # Update entity visibility + update_entity_visibility(target_grid) + +# ============================================================================= +# Collision Detection +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + """Check if a position is valid for movement. + + A position is valid if: + 1. It is within grid bounds + 2. The tile is walkable + 3. No entity is blocking it + """ + # Check bounds + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + # Check tile walkability + if not target_grid.at(x, y).walkable: + return False + + # Check for blocking entities + if get_blocking_entity_at(target_grid, x, y) is not None: + return False + + return True + +# ============================================================================= +# Dungeon Generation with Enemies +# ============================================================================= + +def generate_dungeon(target_grid: mcrfpy.Grid, texture: mcrfpy.Texture) -> tuple[int, int]: + """Generate a dungeon with rooms, tunnels, and enemies. + + Args: + target_grid: The game grid + texture: The texture for entity sprites + + Returns: + The (x, y) coordinates where the player should start + """ + # Clear any existing enemies + clear_enemies(target_grid) + + # Fill with walls + fill_with_walls(target_grid) + init_explored() + + rooms: list[RectangularRoom] = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(target_grid, new_room) + + if rooms: + carve_l_tunnel(target_grid, new_room.center, rooms[-1].center) + # Spawn enemies in all rooms except the first (player starting room) + spawn_enemies_in_room(target_grid, new_room, texture) + + rooms.append(new_room) + + if rooms: + return rooms[0].center + return GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(50, 80), + size=(800, 560), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate the dungeon (without player first to get starting position) +fill_with_walls(grid) +init_explored() + +rooms: list[RectangularRoom] = [] + +for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + +# Get player starting position +if rooms: + player_start_x, player_start_y = rooms[0].center +else: + player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# Add FOV layer +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Create the player +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +# Store player data +entity_data[player] = { + "type": "player", + "name": "Player", + "hp": 30, + "max_hp": 30, + "attack": 5, + "defense": 2, + "is_player": True +} + +# Now spawn enemies in rooms (except the first one) +for i, room in enumerate(rooms): + if i == 0: + continue # Skip player's starting room + spawn_enemies_in_room(grid, room, texture) + +# Calculate initial FOV +update_fov(grid, fov_layer, player_start_x, player_start_y) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(50, 15), + text="Part 5: Placing Enemies" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(50, 50), + text="WASD/Arrows: Move | R: Regenerate | Escape: Quit" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 16 +scene.children.append(instructions) + +pos_display = mcrfpy.Caption( + pos=(50, 660), + text=f"Position: ({int(player.x)}, {int(player.y)})" +) +pos_display.fill_color = mcrfpy.Color(200, 200, 100) +pos_display.font_size = 16 +scene.children.append(pos_display) + +status_display = mcrfpy.Caption( + pos=(400, 660), + text="Explore the dungeon..." +) +status_display.fill_color = mcrfpy.Color(100, 200, 100) +status_display.font_size = 16 +scene.children.append(status_display) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def regenerate_dungeon() -> None: + """Generate a new dungeon and reposition the player.""" + global player, grid, fov_layer, rooms + + # Clear enemies + clear_enemies(grid) + + # Regenerate dungeon structure + fill_with_walls(grid) + init_explored() + + rooms = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + # Reposition player + if rooms: + new_x, new_y = rooms[0].center + else: + new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + + player.x = new_x + player.y = new_y + + # Spawn new enemies + for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + + # Reset FOV layer + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + # Update FOV + update_fov(grid, fov_layer, new_x, new_y) + pos_display.text = f"Position: ({new_x}, {new_y})" + status_display.text = "New dungeon generated!" + +def handle_keys(key: str, action: str) -> None: + """Handle keyboard input.""" + global player, grid, fov_layer + + if action != "start": + return + + px, py = int(player.x), int(player.y) + new_x, new_y = px, py + + if key == "W" or key == "Up": + new_y -= 1 + elif key == "S" or key == "Down": + new_y += 1 + elif key == "A" or key == "Left": + new_x -= 1 + elif key == "D" or key == "Right": + new_x += 1 + elif key == "R": + regenerate_dungeon() + return + elif key == "Escape": + mcrfpy.exit() + return + else: + return + + # Check for blocking entity (potential combat target) + blocker = get_blocking_entity_at(grid, new_x, new_y) + if blocker is not None and blocker != player: + # For now, just report that we bumped into an enemy + if blocker in entity_data: + enemy_name = entity_data[blocker]["name"] + status_display.text = f"A {enemy_name} blocks your path!" + status_display.fill_color = mcrfpy.Color(200, 150, 100) + return + + # Check if we can move + if can_move_to(grid, new_x, new_y): + player.x = new_x + player.y = new_y + pos_display.text = f"Position: ({new_x}, {new_y})" + status_display.text = "Exploring..." + status_display.fill_color = mcrfpy.Color(100, 200, 100) + + # Update FOV after movement + update_fov(grid, fov_layer, new_x, new_y) + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 5 loaded! Enemies lurk in the dungeon...") \ No newline at end of file diff --git a/docs/tutorials/part_06_combat/part_06_combat.py b/docs/tutorials/part_06_combat/part_06_combat.py new file mode 100644 index 0000000..59d6ab2 --- /dev/null +++ b/docs/tutorials/part_06_combat/part_06_combat.py @@ -0,0 +1,940 @@ +"""McRogueFace - Part 6: Combat System + +Documentation: https://mcrogueface.github.io/tutorial/part_06_combat +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_06_combat/part_06_combat.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy +import random +from dataclasses import dataclass +from typing import Optional + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 35 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# Enemy spawn parameters +MAX_ENEMIES_PER_ROOM = 3 + +# FOV settings +FOV_RADIUS = 8 + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message log settings +MAX_MESSAGES = 5 + +# ============================================================================= +# Fighter Component +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + + @property + def is_alive(self) -> bool: + """Check if this fighter is still alive.""" + return self.hp > 0 + + def take_damage(self, amount: int) -> int: + """Apply damage and return actual damage taken.""" + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + """Heal and return actual amount healed.""" + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + +# ============================================================================= +# Enemy Templates +# ============================================================================= + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Global State +# ============================================================================= + +# Entity data storage +entity_data: dict[mcrfpy.Entity, Fighter] = {} + +# Global references +player: Optional[mcrfpy.Entity] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None + +# Game state +game_over: bool = False + +# Message log +messages: list[tuple[str, mcrfpy.Color]] = [] + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return center_x, center_y + + @property + def inner(self) -> tuple[slice, slice]: + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + """Initialize the explored array to all False.""" + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + """Mark a tile as explored.""" + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + """Check if a tile has been explored.""" + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Message Log +# ============================================================================= + +def add_message(text: str, color: mcrfpy.Color = None) -> None: + """Add a message to the log. + + Args: + text: The message text + color: Optional color (defaults to white) + """ + if color is None: + color = mcrfpy.Color(255, 255, 255) + + messages.append((text, color)) + + # Keep only the most recent messages + while len(messages) > MAX_MESSAGES: + messages.pop(0) + + # Update the message display + update_message_display() + +def update_message_display() -> None: + """Update the message log UI.""" + if message_log_caption is None: + return + + # Combine messages into a single string + lines = [] + for text, color in messages: + lines.append(text) + + message_log_caption.text = "\n".join(lines) + +def clear_messages() -> None: + """Clear all messages.""" + global messages + messages = [] + update_message_display() + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + """Fill the entire grid with wall tiles.""" + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + """Carve out a room by setting its inner tiles to floor.""" + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + """Carve a horizontal tunnel.""" + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + """Carve a vertical tunnel.""" + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + """Carve an L-shaped tunnel between two points.""" + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + """Spawn an enemy at the given position.""" + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + # Create Fighter component for this enemy + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + """Spawn random enemies in a room.""" + num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM) + + for _ in range(num_enemies): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if get_entity_at(target_grid, x, y) is not None: + continue + + roll = random.random() + if roll < 0.6: + enemy_type = "goblin" + elif roll < 0.9: + enemy_type = "orc" + else: + enemy_type = "troll" + + spawn_enemy(target_grid, x, y, enemy_type, tex) + +def get_entity_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + """Get any entity at the given position.""" + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + # Check if this entity is alive (or is a non-Fighter entity) + if entity in entity_data: + if entity_data[entity].is_alive: + return entity + else: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + """Get any living entity that blocks movement at the given position.""" + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + """Remove an entity from the grid and data storage.""" + # Find and remove from grid + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + + # Remove from entity data + if entity in entity_data: + del entity_data[entity] + +def clear_enemies(target_grid: mcrfpy.Grid) -> None: + """Remove all enemies from the grid.""" + enemies_to_remove = [] + + for entity in target_grid.entities: + if entity in entity_data and not entity_data[entity].is_player: + enemies_to_remove.append(entity) + + for enemy in enemies_to_remove: + remove_entity(target_grid, enemy) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + """Calculate damage dealt from attacker to defender. + + Args: + attacker: The attacking Fighter + defender: The defending Fighter + + Returns: + The amount of damage to deal (minimum 0) + """ + damage = max(0, attacker.attack - defender.defense) + return damage + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + """Execute an attack from one entity to another. + + Args: + attacker_entity: The entity performing the attack + defender_entity: The entity being attacked + """ + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + # Calculate and apply damage + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + # Generate combat message + if damage > 0: + if attacker.is_player: + add_message( + f"You hit the {defender.name} for {damage} damage!", + mcrfpy.Color(200, 200, 200) + ) + else: + add_message( + f"The {attacker.name} hits you for {damage} damage!", + mcrfpy.Color(255, 150, 150) + ) + else: + if attacker.is_player: + add_message( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + add_message( + f"The {attacker.name} hits you but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + # Check for death + if not defender.is_alive: + handle_death(defender_entity, defender) + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + """Handle the death of an entity. + + Args: + entity: The entity that died + fighter: The Fighter component of the dead entity + """ + global game_over, grid + + if fighter.is_player: + # Player death + add_message("You have died!", mcrfpy.Color(255, 50, 50)) + add_message("Press R to restart or Escape to quit.", mcrfpy.Color(200, 200, 200)) + game_over = True + + # Change player sprite to corpse + entity.sprite_index = SPRITE_CORPSE + else: + # Enemy death + add_message(f"The {fighter.name} dies!", mcrfpy.Color(100, 255, 100)) + + # Replace with corpse + entity.sprite_index = SPRITE_CORPSE + + # Mark as dead (hp is already 0) + # Remove blocking but keep visual corpse + # Actually remove the entity and its data + remove_entity(grid, entity) + + # Update HP display + update_hp_display() + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + """Update visibility of all entities based on FOV.""" + global player + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + """Update the field of view visualization.""" + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + """Check if a position is valid for movement.""" + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + """Attempt to move the player or attack if blocked by enemy. + + Args: + dx: Change in X position (-1, 0, or 1) + dy: Change in Y position (-1, 0, or 1) + """ + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + target_x = px + dx + target_y = py + dy + + # Check bounds + if target_x < 0 or target_x >= GRID_WIDTH or target_y < 0 or target_y >= GRID_HEIGHT: + return + + # Check for blocking entity + blocker = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if blocker is not None: + # Attack the blocking entity + perform_attack(player, blocker) + # After player attacks, enemies take their turn + enemy_turn() + elif grid.at(target_x, target_y).walkable: + # Move to the empty tile + player.x = target_x + player.y = target_y + pos_display.text = f"Position: ({target_x}, {target_y})" + + # Update FOV after movement + update_fov(grid, fov_layer, target_x, target_y) + + # Enemies take their turn after player moves + enemy_turn() + + # Update HP display + update_hp_display() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + """Execute enemy actions.""" + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + # Collect enemies that can act + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + # Only act if in player's FOV (aware of player) + if not grid.is_in_fov(ex, ey): + continue + + # Check if adjacent to player + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + # Adjacent - attack! + perform_attack(enemy, player) + else: + # Not adjacent - try to move toward player + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + """Move an enemy one step toward the player. + + Uses simple greedy movement - not true pathfinding. + """ + global grid + + # Calculate direction to player + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + # Try to move in the desired direction + # First try the combined direction + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + # Try horizontal only + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + # Try vertical only + enemy.y = ey + dy + # If all fail, enemy stays in place + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_hp_display() -> None: + """Update the HP display in the UI.""" + global player + + if hp_display is None or player is None: + return + + if player in entity_data: + fighter = entity_data[player] + hp_display.text = f"HP: {fighter.hp}/{fighter.max_hp}" + + # Color based on health percentage + hp_percent = fighter.hp / fighter.max_hp + if hp_percent > 0.6: + hp_display.fill_color = mcrfpy.Color(100, 255, 100) + elif hp_percent > 0.3: + hp_display.fill_color = mcrfpy.Color(255, 255, 100) + else: + hp_display.fill_color = mcrfpy.Color(255, 100, 100) + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(50, 80), + size=(800, 480), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate initial dungeon structure +fill_with_walls(grid) +init_explored() + +rooms: list[RectangularRoom] = [] + +for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + +# Get player starting position +if rooms: + player_start_x, player_start_y = rooms[0].center +else: + player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# Add FOV layer +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Create the player +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +# Create player Fighter component +entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True +) + +# Spawn enemies in all rooms except the first +for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + +# Calculate initial FOV +update_fov(grid, fov_layer, player_start_x, player_start_y) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(50, 15), + text="Part 6: Combat System" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(50, 50), + text="WASD/Arrows: Move/Attack | R: Restart | Escape: Quit" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 16 +scene.children.append(instructions) + +# Position display +pos_display = mcrfpy.Caption( + pos=(50, 580), + text=f"Position: ({int(player.x)}, {int(player.y)})" +) +pos_display.fill_color = mcrfpy.Color(200, 200, 100) +pos_display.font_size = 16 +scene.children.append(pos_display) + +# HP display +hp_display = mcrfpy.Caption( + pos=(300, 580), + text="HP: 30/30" +) +hp_display.fill_color = mcrfpy.Color(100, 255, 100) +hp_display.font_size = 16 +scene.children.append(hp_display) + +# Message log (positioned below the grid) +message_log_caption = mcrfpy.Caption( + pos=(50, 610), + text="" +) +message_log_caption.fill_color = mcrfpy.Color(200, 200, 200) +message_log_caption.font_size = 14 +scene.children.append(message_log_caption) + +# Initial message +add_message("Welcome to the dungeon! Find and defeat the enemies.", mcrfpy.Color(100, 100, 255)) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def restart_game() -> None: + """Restart the game with a new dungeon.""" + global player, grid, fov_layer, game_over, entity_data, rooms + + game_over = False + + # Clear all entities and data + entity_data.clear() + + # Remove all entities from grid + while len(grid.entities) > 0: + grid.entities.remove(0) + + # Regenerate dungeon + fill_with_walls(grid) + init_explored() + clear_messages() + + rooms = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + # Get new player starting position + if rooms: + new_x, new_y = rooms[0].center + else: + new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + + # Recreate player + player = mcrfpy.Entity( + grid_pos=(new_x, new_y), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True + ) + + # Spawn enemies + for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + + # Reset FOV layer + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + # Update displays + update_fov(grid, fov_layer, new_x, new_y) + pos_display.text = f"Position: ({new_x}, {new_y})" + update_hp_display() + + add_message("A new adventure begins!", mcrfpy.Color(100, 100, 255)) + +def handle_keys(key: str, action: str) -> None: + """Handle keyboard input.""" + global game_over + + if action != "start": + return + + # Handle restart + if key == "R": + restart_game() + return + + if key == "Escape": + mcrfpy.exit() + return + + # Ignore other input if game is over + if game_over: + return + + # Movement and attack + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 6 loaded! Combat is now active. Good luck!") \ No newline at end of file diff --git a/docs/tutorials/part_07_ui/part_07_ui.py b/docs/tutorials/part_07_ui/part_07_ui.py new file mode 100644 index 0000000..459adee --- /dev/null +++ b/docs/tutorials/part_07_ui/part_07_ui.py @@ -0,0 +1,1035 @@ +"""McRogueFace - Part 7: User Interface + +Documentation: https://mcrogueface.github.io/tutorial/part_07_ui +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_07_ui/part_07_ui.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy +import random +from dataclasses import dataclass +from typing import Optional + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# Enemy spawn parameters +MAX_ENEMIES_PER_ROOM = 3 + +# FOV settings +FOV_RADIUS = 8 + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Fighter Component +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + +# ============================================================================= +# Enemy Templates +# ============================================================================= + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + """A message log that displays recent game messages with colors.""" + + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + """Create a new message log. + + Args: + x: X position of the log + y: Y position of the log + width: Width of the log area + height: Height of the log area + max_messages: Maximum number of messages to display + """ + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + # Create the background frame + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + # Create caption for each message line + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption( + pos=(x + 10, y + 5 + i * line_height), + text="" + ) + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + """Add the message log UI elements to a scene.""" + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + """Add a message to the log. + + Args: + text: The message text + color: Optional color (defaults to white) + """ + if color is None: + color = mcrfpy.Color(200, 200, 200) + + self.messages.append((text, color)) + + # Keep only the most recent messages + while len(self.messages) > self.max_messages: + self.messages.pop(0) + + self._refresh() + + def _refresh(self) -> None: + """Update the caption displays with current messages.""" + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + """Clear all messages.""" + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + """A visual health bar using nested frames.""" + + def __init__(self, x: int, y: int, width: int, height: int): + """Create a new health bar. + + Args: + x: X position + y: Y position + width: Total width of the health bar + height: Height of the health bar + """ + self.x = x + self.y = y + self.width = width + self.height = height + self.max_hp = 30 + self.current_hp = 30 + + # Background frame (dark red - shows when damaged) + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + # Foreground frame (the actual health - shrinks as HP decreases) + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(width - 4, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + # HP text label + self.label = mcrfpy.Caption( + pos=(x + 5, y + 2), + text=f"HP: {self.current_hp}/{self.max_hp}" + ) + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + """Add the health bar UI elements to a scene.""" + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + """Update the health bar display. + + Args: + current_hp: Current HP value + max_hp: Maximum HP value + """ + self.current_hp = current_hp + self.max_hp = max_hp + + # Calculate fill percentage + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + + # Update the foreground width + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + # Update the label + self.label.text = f"HP: {current_hp}/{max_hp}" + + # Update color based on health percentage + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) # Green + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) # Yellow + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) # Red + +# ============================================================================= +# Stats Panel +# ============================================================================= + +class StatsPanel: + """A panel displaying player stats and dungeon info.""" + + def __init__(self, x: int, y: int, width: int, height: int): + """Create a new stats panel. + + Args: + x: X position + y: Y position + width: Panel width + height: Panel height + """ + self.x = x + self.y = y + self.width = width + self.height = height + + # Background frame + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + # Dungeon level caption + self.level_caption = mcrfpy.Caption( + pos=(x + 10, y + 10), + text="Dungeon Level: 1" + ) + self.level_caption.font_size = 16 + self.level_caption.fill_color = mcrfpy.Color(200, 200, 255) + + # Attack stat caption + self.attack_caption = mcrfpy.Caption( + pos=(x + 10, y + 35), + text="Attack: 5" + ) + self.attack_caption.font_size = 14 + self.attack_caption.fill_color = mcrfpy.Color(255, 200, 150) + + # Defense stat caption + self.defense_caption = mcrfpy.Caption( + pos=(x + 120, y + 35), + text="Defense: 2" + ) + self.defense_caption.font_size = 14 + self.defense_caption.fill_color = mcrfpy.Color(150, 200, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + """Add the stats panel UI elements to a scene.""" + scene.children.append(self.frame) + scene.children.append(self.level_caption) + scene.children.append(self.attack_caption) + scene.children.append(self.defense_caption) + + def update(self, dungeon_level: int, attack: int, defense: int) -> None: + """Update the stats panel display. + + Args: + dungeon_level: Current dungeon level + attack: Player attack stat + defense: Player defense stat + """ + self.level_caption.text = f"Dungeon Level: {dungeon_level}" + self.attack_caption.text = f"Attack: {attack}" + self.defense_caption.text = f"Defense: {defense}" + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +player: Optional[mcrfpy.Entity] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +stats_panel: Optional[StatsPanel] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return center_x, center_y + + @property + def inner(self) -> tuple[slice, slice]: + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM) + + for _ in range(num_enemies): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if get_entity_at(target_grid, x, y) is not None: + continue + + roll = random.random() + if roll < 0.6: + enemy_type = "goblin" + elif roll < 0.9: + enemy_type = "orc" + else: + enemy_type = "troll" + + spawn_enemy(target_grid, x, y, enemy_type, tex) + +def get_entity_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data: + if entity_data[entity].is_alive: + return entity + else: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def clear_enemies(target_grid: mcrfpy.Grid) -> None: + enemies_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and not entity_data[entity].is_player: + enemies_to_remove.append(entity) + for enemy in enemies_to_remove: + remove_entity(target_grid, enemy) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} for {damage} damage!", + COLOR_PLAYER_ATTACK + ) + else: + message_log.add( + f"The {attacker.name} hits you for {damage} damage!", + COLOR_ENEMY_ATTACK + ) + else: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + message_log.add( + f"The {attacker.name} hits but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + if not defender.is_alive: + handle_death(defender_entity, defender) + + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over, grid + + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart or Escape to quit.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + else: + message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH) + remove_entity(grid, entity) + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + global player + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + target_x = px + dx + target_y = py + dy + + if target_x < 0 or target_x >= GRID_WIDTH or target_y < 0 or target_y >= GRID_HEIGHT: + return + + blocker = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if blocker is not None: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(target_x, target_y).walkable: + player.x = target_x + player.y = target_y + update_fov(grid, fov_layer, target_x, target_y) + enemy_turn() + + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + if not grid.is_in_fov(ex, ey): + continue + + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + perform_attack(enemy, player) + else: + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + global grid + + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + """Update all UI components.""" + global player, health_bar, stats_panel, dungeon_level + + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + stats_panel.update(dungeon_level, fighter.attack, fighter.defense) + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid (positioned to leave room for UI) +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(800, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate initial dungeon structure +fill_with_walls(grid) +init_explored() + +rooms: list[RectangularRoom] = [] + +for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + +# Get player starting position +if rooms: + player_start_x, player_start_y = rooms[0].center +else: + player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# Add FOV layer +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Create the player +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True +) + +# Spawn enemies +for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + +# Calculate initial FOV +update_fov(grid, fov_layer, player_start_x, player_start_y) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# Create UI Elements +# ============================================================================= + +# Title bar +title = mcrfpy.Caption( + pos=(20, 10), + text="Part 7: User Interface" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +# Instructions +instructions = mcrfpy.Caption( + pos=(300, 15), + text="WASD/Arrows: Move/Attack | R: Restart | Escape: Quit" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +# Health Bar (top right area) +health_bar = HealthBar( + x=700, + y=10, + width=300, + height=30 +) +health_bar.add_to_scene(scene) + +# Stats Panel (below health bar) +stats_panel = StatsPanel( + x=830, + y=GAME_AREA_Y, + width=180, + height=80 +) +stats_panel.add_to_scene(scene) + +# Message Log (bottom of screen) +message_log = MessageLog( + x=20, + y=768 - UI_BOTTOM_HEIGHT + 10, + width=800, + height=UI_BOTTOM_HEIGHT - 20, + max_messages=6 +) +message_log.add_to_scene(scene) + +# Initial messages +message_log.add("Welcome to the dungeon!", COLOR_INFO) +message_log.add("Find and defeat all enemies to progress.", COLOR_INFO) + +# Initialize UI +update_ui() + +# ============================================================================= +# Input Handling +# ============================================================================= + +def restart_game() -> None: + """Restart the game with a new dungeon.""" + global player, grid, fov_layer, game_over, entity_data, rooms, dungeon_level + + game_over = False + dungeon_level = 1 + + entity_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + fill_with_walls(grid) + init_explored() + message_log.clear() + + rooms = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + if rooms: + new_x, new_y = rooms[0].center + else: + new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + + player = mcrfpy.Entity( + grid_pos=(new_x, new_y), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True + ) + + for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, new_x, new_y) + + message_log.add("A new adventure begins!", COLOR_INFO) + + update_ui() + +def handle_keys(key: str, action: str) -> None: + global game_over + + if action != "start": + return + + if key == "R": + restart_game() + return + + if key == "Escape": + mcrfpy.exit() + return + + if game_over: + return + + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 7 loaded! Notice the improved UI with health bar and message log.") \ No newline at end of file diff --git a/docs/tutorials/part_08_items/part_08_items.py b/docs/tutorials/part_08_items/part_08_items.py new file mode 100644 index 0000000..e8f271e --- /dev/null +++ b/docs/tutorials/part_08_items/part_08_items.py @@ -0,0 +1,1275 @@ +"""McRogueFace - Part 8: Items and Inventory + +Documentation: https://mcrogueface.github.io/tutorial/part_08_items +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_08_items/part_08_items.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy +import random +from dataclasses import dataclass, field +from typing import Optional + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains +SPRITE_POTION = 173 # Potion sprite (or use '!' = 33) + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# Enemy spawn parameters +MAX_ENEMIES_PER_ROOM = 3 + +# Item spawn parameters +MAX_ITEMS_PER_ROOM = 2 + +# FOV settings +FOV_RADIUS = 8 + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_PICKUP = mcrfpy.Color(100, 200, 255) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) +COLOR_INVALID = mcrfpy.Color(255, 100, 100) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Fighter Component +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + +# ============================================================================= +# Item Component +# ============================================================================= + +@dataclass +class Item: + """Data for an item that can be picked up and used.""" + name: str + item_type: str + heal_amount: int = 0 + + def describe(self) -> str: + """Return a description of what this item does.""" + if self.item_type == "health_potion": + return f"Restores {self.heal_amount} HP" + return "Unknown item" + +# ============================================================================= +# Inventory System +# ============================================================================= + +@dataclass +class Inventory: + """Container for items the player is carrying.""" + capacity: int = 10 + items: list = field(default_factory=list) + + def add(self, item: Item) -> bool: + """Add an item to the inventory. + + Args: + item: The item to add + + Returns: + True if item was added, False if inventory is full + """ + if len(self.items) >= self.capacity: + return False + self.items.append(item) + return True + + def remove(self, index: int) -> Optional[Item]: + """Remove and return an item by index. + + Args: + index: The index of the item to remove + + Returns: + The removed item, or None if index is invalid + """ + if 0 <= index < len(self.items): + return self.items.pop(index) + return None + + def get(self, index: int) -> Optional[Item]: + """Get an item by index without removing it.""" + if 0 <= index < len(self.items): + return self.items[index] + return None + + def is_full(self) -> bool: + """Check if the inventory is full.""" + return len(self.items) >= self.capacity + + def count(self) -> int: + """Return the number of items in the inventory.""" + return len(self.items) + +# ============================================================================= +# Item Templates +# ============================================================================= + +ITEM_TEMPLATES = { + "health_potion": { + "name": "Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 10 + } +} + +# ============================================================================= +# Enemy Templates +# ============================================================================= + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + """A message log that displays recent game messages with colors.""" + + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption( + pos=(x + 10, y + 5 + i * line_height), + text="" + ) + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + if color is None: + color = mcrfpy.Color(200, 200, 200) + + self.messages.append((text, color)) + + while len(self.messages) > self.max_messages: + self.messages.pop(0) + + self._refresh() + + def _refresh(self) -> None: + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + """A visual health bar using nested frames.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_hp = 30 + self.current_hp = 30 + + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(width - 4, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption( + pos=(x + 5, y + 2), + text=f"HP: {self.current_hp}/{self.max_hp}" + ) + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + self.current_hp = current_hp + self.max_hp = max_hp + + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + self.label.text = f"HP: {current_hp}/{max_hp}" + + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) + +# ============================================================================= +# Inventory Panel +# ============================================================================= + +class InventoryPanel: + """A panel displaying the player's inventory.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption( + pos=(x + 10, y + 5), + text="Inventory (G:pickup, U:use)" + ) + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + # Create slots for inventory items + for i in range(5): # Show up to 5 items + caption = mcrfpy.Caption( + pos=(x + 10, y + 25 + i * 18), + text="" + ) + caption.font_size = 13 + caption.fill_color = mcrfpy.Color(180, 180, 180) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + for caption in self.captions: + scene.children.append(caption) + + def update(self, inventory: Inventory) -> None: + """Update the inventory display.""" + for i, caption in enumerate(self.captions): + if i < len(inventory.items): + item = inventory.items[i] + caption.text = f"{i+1}. {item.name}" + caption.fill_color = mcrfpy.Color(180, 180, 180) + else: + caption.text = f"{i+1}. ---" + caption.fill_color = mcrfpy.Color(80, 80, 80) + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +item_data: dict[mcrfpy.Entity, Item] = {} + +player: Optional[mcrfpy.Entity] = None +player_inventory: Optional[Inventory] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +inventory_panel: Optional[InventoryPanel] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return center_x, center_y + + @property + def inner(self) -> tuple[slice, slice]: + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM) + + for _ in range(num_enemies): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + if roll < 0.6: + enemy_type = "goblin" + elif roll < 0.9: + enemy_type = "orc" + else: + enemy_type = "troll" + + spawn_enemy(target_grid, x, y, enemy_type, tex) + +# ============================================================================= +# Item Management +# ============================================================================= + +def spawn_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + """Spawn an item at the given position. + + Args: + target_grid: The game grid + x: X position in tiles + y: Y position in tiles + item_type: Type of item to spawn + tex: The texture to use + + Returns: + The created item entity + """ + template = ITEM_TEMPLATES[item_type] + + item_entity = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + item_entity.visible = False + + target_grid.entities.append(item_entity) + + # Create Item data + item_data[item_entity] = Item( + name=template["name"], + item_type=template["item_type"], + heal_amount=template.get("heal_amount", 0) + ) + + return item_entity + +def spawn_items_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + """Spawn random items in a room. + + Args: + target_grid: The game grid + room: The room to spawn items in + tex: The texture to use + """ + num_items = random.randint(0, MAX_ITEMS_PER_ROOM) + + for _ in range(num_items): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + # Check if position is blocked by entity or item + if is_position_occupied(target_grid, x, y): + continue + + # For now, only spawn health potions + spawn_item(target_grid, x, y, "health_potion", tex) + +def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + """Get an item entity at the given position. + + Args: + target_grid: The game grid + x: X position to check + y: Y position to check + + Returns: + The item entity, or None if no item at position + """ + for entity in target_grid.entities: + if entity in item_data: + if int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def pickup_item() -> bool: + """Try to pick up an item at the player's position. + + Returns: + True if an item was picked up, False otherwise + """ + global player, player_inventory, grid + + px, py = int(player.x), int(player.y) + item_entity = get_item_at(grid, px, py) + + if item_entity is None: + message_log.add("There is nothing to pick up here.", COLOR_INVALID) + return False + + if player_inventory.is_full(): + message_log.add("Your inventory is full!", COLOR_WARNING) + return False + + # Get the item data + item = item_data.get(item_entity) + if item is None: + return False + + # Add to inventory + player_inventory.add(item) + + # Remove from ground + remove_item_entity(grid, item_entity) + + message_log.add(f"You pick up the {item.name}.", COLOR_PICKUP) + + update_ui() + return True + +def use_item(index: int) -> bool: + """Use an item from the inventory. + + Args: + index: The inventory index of the item to use + + Returns: + True if an item was used, False otherwise + """ + global player, player_inventory + + item = player_inventory.get(index) + if item is None: + message_log.add("Invalid item selection.", COLOR_INVALID) + return False + + # Handle different item types + if item.item_type == "health_potion": + fighter = entity_data.get(player) + if fighter is None: + return False + + if fighter.hp >= fighter.max_hp: + message_log.add("You are already at full health!", COLOR_WARNING) + return False + + # Apply healing + actual_heal = fighter.heal(item.heal_amount) + + # Remove the item from inventory + player_inventory.remove(index) + + message_log.add(f"You drink the {item.name} and recover {actual_heal} HP!", COLOR_HEAL) + + update_ui() + return True + + message_log.add(f"You cannot use the {item.name}.", COLOR_INVALID) + return False + +def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + """Remove an item entity from the grid and item_data.""" + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + + if entity in item_data: + del item_data[entity] + +# ============================================================================= +# Position Checking +# ============================================================================= + +def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + """Check if a position has any entity (enemy, item, or player). + + Args: + target_grid: The game grid + x: X position to check + y: Y position to check + + Returns: + True if position is occupied, False otherwise + """ + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return True + return False + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + """Get any living entity that blocks movement at the given position.""" + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + # Only fighters block movement + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def clear_all_entities(target_grid: mcrfpy.Grid) -> None: + """Remove all entities (enemies and items) except the player.""" + global entity_data, item_data + + entities_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and not entity_data[entity].is_player: + entities_to_remove.append(entity) + elif entity in item_data: + entities_to_remove.append(entity) + + for entity in entities_to_remove: + if entity in entity_data: + del entity_data[entity] + if entity in item_data: + del item_data[entity] + + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} for {damage} damage!", + COLOR_PLAYER_ATTACK + ) + else: + message_log.add( + f"The {attacker.name} hits you for {damage} damage!", + COLOR_ENEMY_ATTACK + ) + else: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + message_log.add( + f"The {attacker.name} hits but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + if not defender.is_alive: + handle_death(defender_entity, defender) + + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over, grid + + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart or Escape to quit.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + else: + message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH) + remove_entity(grid, entity) + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + global player + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + target_x = px + dx + target_y = py + dy + + if target_x < 0 or target_x >= GRID_WIDTH or target_y < 0 or target_y >= GRID_HEIGHT: + return + + blocker = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if blocker is not None: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(target_x, target_y).walkable: + player.x = target_x + player.y = target_y + update_fov(grid, fov_layer, target_x, target_y) + enemy_turn() + + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + if not grid.is_in_fov(ex, ey): + continue + + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + perform_attack(enemy, player) + else: + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + global grid + + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + """Update all UI components.""" + global player, health_bar, inventory_panel, player_inventory + + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + + if player_inventory: + inventory_panel.update(player_inventory) + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(700, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate initial dungeon +fill_with_walls(grid) +init_explored() + +rooms: list[RectangularRoom] = [] + +for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + +# Get player starting position +if rooms: + player_start_x, player_start_y = rooms[0].center +else: + player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# Add FOV layer +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Create the player +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True +) + +# Create player inventory +player_inventory = Inventory(capacity=10) + +# Spawn enemies and items +for i, room in enumerate(rooms): + if i == 0: + continue # Skip player's starting room + spawn_enemies_in_room(grid, room, texture) + spawn_items_in_room(grid, room, texture) + +# Calculate initial FOV +update_fov(grid, fov_layer, player_start_x, player_start_y) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# Create UI Elements +# ============================================================================= + +# Title bar +title = mcrfpy.Caption( + pos=(20, 10), + text="Part 8: Items and Inventory" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +# Instructions +instructions = mcrfpy.Caption( + pos=(300, 15), + text="WASD: Move | G: Pickup | 1-5: Use item | R: Restart" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +# Health Bar +health_bar = HealthBar( + x=730, + y=10, + width=280, + height=30 +) +health_bar.add_to_scene(scene) + +# Inventory Panel +inventory_panel = InventoryPanel( + x=730, + y=GAME_AREA_Y, + width=280, + height=150 +) +inventory_panel.add_to_scene(scene) + +# Message Log +message_log = MessageLog( + x=20, + y=768 - UI_BOTTOM_HEIGHT + 10, + width=990, + height=UI_BOTTOM_HEIGHT - 20, + max_messages=6 +) +message_log.add_to_scene(scene) + +# Initial messages +message_log.add("Welcome to the dungeon!", COLOR_INFO) +message_log.add("Find potions to heal. Press G to pick up items.", COLOR_INFO) + +# Initialize UI +update_ui() + +# ============================================================================= +# Input Handling +# ============================================================================= + +def restart_game() -> None: + """Restart the game with a new dungeon.""" + global player, grid, fov_layer, game_over, entity_data, item_data, rooms + global player_inventory + + game_over = False + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + fill_with_walls(grid) + init_explored() + message_log.clear() + + rooms = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + if rooms: + new_x, new_y = rooms[0].center + else: + new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + + player = mcrfpy.Entity( + grid_pos=(new_x, new_y), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True + ) + + # Reset inventory + player_inventory = Inventory(capacity=10) + + for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + spawn_items_in_room(grid, room, texture) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, new_x, new_y) + + message_log.add("A new adventure begins!", COLOR_INFO) + + update_ui() + +def handle_keys(key: str, action: str) -> None: + global game_over + + if action != "start": + return + + if key == "R": + restart_game() + return + + if key == "Escape": + mcrfpy.exit() + return + + if game_over: + return + + # Movement + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + # Pickup + elif key == "G" or key == ",": + pickup_item() + # Use items by number key + elif key in ["1", "2", "3", "4", "5"]: + index = int(key) - 1 + if use_item(index): + enemy_turn() # Using an item takes a turn + update_ui() + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 8 loaded! Pick up health potions with G, use with 1-5.") \ No newline at end of file diff --git a/docs/tutorials/part_09_ranged/part_09_ranged.py b/docs/tutorials/part_09_ranged/part_09_ranged.py new file mode 100644 index 0000000..f855a75 --- /dev/null +++ b/docs/tutorials/part_09_ranged/part_09_ranged.py @@ -0,0 +1,1396 @@ +"""McRogueFace - Part 9: Ranged Combat and Targeting + +Documentation: https://mcrogueface.github.io/tutorial/part_09_ranged +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_09_ranged/part_09_ranged.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy +import random +from dataclasses import dataclass, field +from typing import Optional +from enum import Enum + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains +SPRITE_POTION = 173 # Potion sprite +SPRITE_CURSOR = 88 # 'X' - targeting cursor + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# Enemy spawn parameters +MAX_ENEMIES_PER_ROOM = 3 + +# Item spawn parameters +MAX_ITEMS_PER_ROOM = 2 + +# FOV and targeting settings +FOV_RADIUS = 8 +RANGED_ATTACK_RANGE = 6 +RANGED_ATTACK_DAMAGE = 4 + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_PICKUP = mcrfpy.Color(100, 200, 255) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) +COLOR_INVALID = mcrfpy.Color(255, 100, 100) +COLOR_RANGED = mcrfpy.Color(255, 255, 100) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Game Modes +# ============================================================================= + +class GameMode(Enum): + """The current game input mode.""" + NORMAL = "normal" + TARGETING = "targeting" + +# ============================================================================= +# Fighter Component +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + +# ============================================================================= +# Item Component +# ============================================================================= + +@dataclass +class Item: + """Data for an item that can be picked up and used.""" + name: str + item_type: str + heal_amount: int = 0 + + def describe(self) -> str: + if self.item_type == "health_potion": + return f"Restores {self.heal_amount} HP" + return "Unknown item" + +# ============================================================================= +# Inventory System +# ============================================================================= + +@dataclass +class Inventory: + """Container for items the player is carrying.""" + capacity: int = 10 + items: list = field(default_factory=list) + + def add(self, item: Item) -> bool: + if len(self.items) >= self.capacity: + return False + self.items.append(item) + return True + + def remove(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items.pop(index) + return None + + def get(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items[index] + return None + + def is_full(self) -> bool: + return len(self.items) >= self.capacity + + def count(self) -> int: + return len(self.items) + +# ============================================================================= +# Templates +# ============================================================================= + +ITEM_TEMPLATES = { + "health_potion": { + "name": "Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 10 + } +} + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + """A message log that displays recent game messages with colors.""" + + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption( + pos=(x + 10, y + 5 + i * line_height), + text="" + ) + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + if color is None: + color = mcrfpy.Color(200, 200, 200) + + self.messages.append((text, color)) + + while len(self.messages) > self.max_messages: + self.messages.pop(0) + + self._refresh() + + def _refresh(self) -> None: + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + """A visual health bar using nested frames.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_hp = 30 + self.current_hp = 30 + + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(width - 4, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption( + pos=(x + 5, y + 2), + text=f"HP: {self.current_hp}/{self.max_hp}" + ) + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + self.current_hp = current_hp + self.max_hp = max_hp + + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + self.label.text = f"HP: {current_hp}/{max_hp}" + + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) + +# ============================================================================= +# Inventory Panel +# ============================================================================= + +class InventoryPanel: + """A panel displaying the player's inventory.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption( + pos=(x + 10, y + 5), + text="Inventory (G:pickup, 1-5:use)" + ) + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + for i in range(5): + caption = mcrfpy.Caption( + pos=(x + 10, y + 25 + i * 18), + text="" + ) + caption.font_size = 13 + caption.fill_color = mcrfpy.Color(180, 180, 180) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + for caption in self.captions: + scene.children.append(caption) + + def update(self, inventory: Inventory) -> None: + for i, caption in enumerate(self.captions): + if i < len(inventory.items): + item = inventory.items[i] + caption.text = f"{i+1}. {item.name}" + caption.fill_color = mcrfpy.Color(180, 180, 180) + else: + caption.text = f"{i+1}. ---" + caption.fill_color = mcrfpy.Color(80, 80, 80) + +# ============================================================================= +# Mode Display +# ============================================================================= + +class ModeDisplay: + """Displays the current game mode.""" + + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption( + pos=(x, y), + text="[NORMAL MODE]" + ) + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, mode: GameMode) -> None: + if mode == GameMode.NORMAL: + self.caption.text = "[NORMAL MODE] - F: Ranged attack" + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + elif mode == GameMode.TARGETING: + self.caption.text = "[TARGETING] - Arrows: Move, Enter: Fire, Esc: Cancel" + self.caption.fill_color = mcrfpy.Color(255, 255, 100) + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +item_data: dict[mcrfpy.Entity, Item] = {} + +player: Optional[mcrfpy.Entity] = None +player_inventory: Optional[Inventory] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 + +# Game mode state +game_mode: GameMode = GameMode.NORMAL +target_cursor: Optional[mcrfpy.Entity] = None +target_x: int = 0 +target_y: int = 0 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +inventory_panel: Optional[InventoryPanel] = None +mode_display: Optional[ModeDisplay] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return center_x, center_y + + @property + def inner(self) -> tuple[slice, slice]: + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM) + + for _ in range(num_enemies): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + if roll < 0.6: + enemy_type = "goblin" + elif roll < 0.9: + enemy_type = "orc" + else: + enemy_type = "troll" + + spawn_enemy(target_grid, x, y, enemy_type, tex) + +def spawn_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ITEM_TEMPLATES[item_type] + + item_entity = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + item_entity.visible = False + + target_grid.entities.append(item_entity) + + item_data[item_entity] = Item( + name=template["name"], + item_type=template["item_type"], + heal_amount=template.get("heal_amount", 0) + ) + + return item_entity + +def spawn_items_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + num_items = random.randint(0, MAX_ITEMS_PER_ROOM) + + for _ in range(num_items): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + spawn_item(target_grid, x, y, "health_potion", tex) + +def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return True + return False + +def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity in item_data: + if int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in item_data: + del item_data[entity] + +def clear_all_entities(target_grid: mcrfpy.Grid) -> None: + global entity_data, item_data + + entities_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and not entity_data[entity].is_player: + entities_to_remove.append(entity) + elif entity in item_data: + entities_to_remove.append(entity) + + for entity in entities_to_remove: + if entity in entity_data: + del entity_data[entity] + if entity in item_data: + del item_data[entity] + + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + +# ============================================================================= +# Targeting System +# ============================================================================= + +def enter_targeting_mode() -> None: + """Enter targeting mode for ranged attack.""" + global game_mode, target_cursor, target_x, target_y, player, grid, texture + + # Start at player position + target_x = int(player.x) + target_y = int(player.y) + + # Create the targeting cursor + target_cursor = mcrfpy.Entity( + grid_pos=(target_x, target_y), + texture=texture, + sprite_index=SPRITE_CURSOR + ) + grid.entities.append(target_cursor) + + game_mode = GameMode.TARGETING + + message_log.add("Targeting mode: Use arrows to aim, Enter to fire, Esc to cancel.", COLOR_INFO) + mode_display.update(game_mode) + +def exit_targeting_mode() -> None: + """Exit targeting mode without firing.""" + global game_mode, target_cursor, grid + + if target_cursor is not None: + # Remove cursor from grid + for i, e in enumerate(grid.entities): + if e == target_cursor: + grid.entities.remove(i) + break + target_cursor = None + + game_mode = GameMode.NORMAL + mode_display.update(game_mode) + +def move_cursor(dx: int, dy: int) -> None: + """Move the targeting cursor.""" + global target_x, target_y, target_cursor, grid, player + + new_x = target_x + dx + new_y = target_y + dy + + # Check bounds + if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT: + return + + # Check if position is in FOV (can only target visible tiles) + if not grid.is_in_fov(new_x, new_y): + message_log.add("You cannot see that location.", COLOR_INVALID) + return + + # Check range + player_x, player_y = int(player.x), int(player.y) + distance = abs(new_x - player_x) + abs(new_y - player_y) + if distance > RANGED_ATTACK_RANGE: + message_log.add(f"Target is out of range (max {RANGED_ATTACK_RANGE}).", COLOR_WARNING) + return + + # Move cursor + target_x = new_x + target_y = new_y + target_cursor.x = target_x + target_cursor.y = target_y + + # Show info about target location + enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + if enemy and enemy in entity_data: + fighter = entity_data[enemy] + message_log.add(f"Target: {fighter.name} (HP: {fighter.hp}/{fighter.max_hp})", COLOR_RANGED) + +def confirm_target() -> None: + """Confirm the target and execute ranged attack.""" + global game_mode, target_x, target_y, player, grid + + # Check if targeting self + if target_x == int(player.x) and target_y == int(player.y): + message_log.add("You cannot target yourself!", COLOR_INVALID) + return + + # Check for enemy at target + target_enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if target_enemy is None or target_enemy not in entity_data: + message_log.add("No valid target at that location.", COLOR_INVALID) + return + + # Perform ranged attack + perform_ranged_attack(target_enemy) + + # Exit targeting mode + exit_targeting_mode() + + # Enemies take their turn + enemy_turn() + + update_ui() + +def perform_ranged_attack(target_entity: mcrfpy.Entity) -> None: + """Execute a ranged attack on the target. + + Args: + target_entity: The entity to attack + """ + global player, game_over + + defender = entity_data.get(target_entity) + attacker = entity_data.get(player) + + if defender is None or attacker is None: + return + + # Ranged attacks deal fixed damage (ignores defense partially) + damage = max(1, RANGED_ATTACK_DAMAGE - defender.defense // 2) + + defender.take_damage(damage) + + message_log.add( + f"Your ranged attack hits the {defender.name} for {damage} damage!", + COLOR_RANGED + ) + + # Check for death + if not defender.is_alive: + handle_death(target_entity, defender) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} for {damage} damage!", + COLOR_PLAYER_ATTACK + ) + else: + message_log.add( + f"The {attacker.name} hits you for {damage} damage!", + COLOR_ENEMY_ATTACK + ) + else: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + message_log.add( + f"The {attacker.name} hits but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + if not defender.is_alive: + handle_death(defender_entity, defender) + + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over, grid + + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart or Escape to quit.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + else: + message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH) + remove_entity(grid, entity) + +# ============================================================================= +# Item Actions +# ============================================================================= + +def pickup_item() -> bool: + global player, player_inventory, grid + + px, py = int(player.x), int(player.y) + item_entity = get_item_at(grid, px, py) + + if item_entity is None: + message_log.add("There is nothing to pick up here.", COLOR_INVALID) + return False + + if player_inventory.is_full(): + message_log.add("Your inventory is full!", COLOR_WARNING) + return False + + item = item_data.get(item_entity) + if item is None: + return False + + player_inventory.add(item) + remove_item_entity(grid, item_entity) + + message_log.add(f"You pick up the {item.name}.", COLOR_PICKUP) + + update_ui() + return True + +def use_item(index: int) -> bool: + global player, player_inventory + + item = player_inventory.get(index) + if item is None: + message_log.add("Invalid item selection.", COLOR_INVALID) + return False + + if item.item_type == "health_potion": + fighter = entity_data.get(player) + if fighter is None: + return False + + if fighter.hp >= fighter.max_hp: + message_log.add("You are already at full health!", COLOR_WARNING) + return False + + actual_heal = fighter.heal(item.heal_amount) + player_inventory.remove(index) + + message_log.add(f"You drink the {item.name} and recover {actual_heal} HP!", COLOR_HEAL) + + update_ui() + return True + + message_log.add(f"You cannot use the {item.name}.", COLOR_INVALID) + return False + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + global player, target_cursor + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + # Targeting cursor is always visible during targeting mode + if entity == target_cursor: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + target_x = px + dx + target_y = py + dy + + if target_x < 0 or target_x >= GRID_WIDTH or target_y < 0 or target_y >= GRID_HEIGHT: + return + + blocker = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if blocker is not None: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(target_x, target_y).walkable: + player.x = target_x + player.y = target_y + update_fov(grid, fov_layer, target_x, target_y) + enemy_turn() + + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + if not grid.is_in_fov(ex, ey): + continue + + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + perform_attack(enemy, player) + else: + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + global grid + + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + global player, health_bar, inventory_panel, player_inventory + + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + + if player_inventory: + inventory_panel.update(player_inventory) + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(700, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate initial dungeon +fill_with_walls(grid) +init_explored() + +rooms: list[RectangularRoom] = [] + +for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + +# Get player starting position +if rooms: + player_start_x, player_start_y = rooms[0].center +else: + player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# Add FOV layer +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Create the player +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True +) + +# Create player inventory +player_inventory = Inventory(capacity=10) + +# Spawn enemies and items +for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + spawn_items_in_room(grid, room, texture) + +# Calculate initial FOV +update_fov(grid, fov_layer, player_start_x, player_start_y) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# Create UI Elements +# ============================================================================= + +# Title bar +title = mcrfpy.Caption( + pos=(20, 10), + text="Part 9: Ranged Combat" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +# Instructions +instructions = mcrfpy.Caption( + pos=(280, 15), + text="WASD: Move | F: Ranged attack | G: Pickup | 1-5: Use | R: Restart" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +# Health Bar +health_bar = HealthBar( + x=730, + y=10, + width=280, + height=30 +) +health_bar.add_to_scene(scene) + +# Mode Display +mode_display = ModeDisplay(x=20, y=40) +mode_display.add_to_scene(scene) + +# Inventory Panel +inventory_panel = InventoryPanel( + x=730, + y=GAME_AREA_Y, + width=280, + height=150 +) +inventory_panel.add_to_scene(scene) + +# Message Log +message_log = MessageLog( + x=20, + y=768 - UI_BOTTOM_HEIGHT + 10, + width=990, + height=UI_BOTTOM_HEIGHT - 20, + max_messages=6 +) +message_log.add_to_scene(scene) + +# Initial messages +message_log.add("Welcome to the dungeon!", COLOR_INFO) +message_log.add("Press F to enter targeting mode for ranged attacks.", COLOR_INFO) + +# Initialize UI +update_ui() + +# ============================================================================= +# Input Handling +# ============================================================================= + +def restart_game() -> None: + global player, grid, fov_layer, game_over, entity_data, item_data, rooms + global player_inventory, game_mode, target_cursor + + # Exit targeting mode if active + if game_mode == GameMode.TARGETING: + exit_targeting_mode() + + game_over = False + game_mode = GameMode.NORMAL + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + fill_with_walls(grid) + init_explored() + message_log.clear() + + rooms = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + if rooms: + new_x, new_y = rooms[0].center + else: + new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + + player = mcrfpy.Entity( + grid_pos=(new_x, new_y), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True + ) + + player_inventory = Inventory(capacity=10) + + for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + spawn_items_in_room(grid, room, texture) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, new_x, new_y) + + message_log.add("A new adventure begins!", COLOR_INFO) + + mode_display.update(game_mode) + update_ui() + +def handle_keys(key: str, action: str) -> None: + global game_over, game_mode + + if action != "start": + return + + # Always allow restart and quit + if key == "R": + restart_game() + return + + if key == "Escape": + if game_mode == GameMode.TARGETING: + exit_targeting_mode() + message_log.add("Targeting cancelled.", COLOR_INFO) + return + else: + mcrfpy.exit() + return + + if game_over: + return + + # Handle input based on game mode + if game_mode == GameMode.TARGETING: + handle_targeting_input(key) + else: + handle_normal_input(key) + +def handle_normal_input(key: str) -> None: + """Handle input in normal game mode.""" + # Movement + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + # Ranged attack (enter targeting mode) + elif key == "F": + enter_targeting_mode() + # Pickup + elif key == "G" or key == ",": + pickup_item() + # Use items + elif key in ["1", "2", "3", "4", "5"]: + index = int(key) - 1 + if use_item(index): + enemy_turn() + update_ui() + +def handle_targeting_input(key: str) -> None: + """Handle input in targeting mode.""" + if key == "Up" or key == "W": + move_cursor(0, -1) + elif key == "Down" or key == "S": + move_cursor(0, 1) + elif key == "Left" or key == "A": + move_cursor(-1, 0) + elif key == "Right" or key == "D": + move_cursor(1, 0) + elif key == "Return" or key == "Space": + confirm_target() + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 9 loaded! Press F to enter targeting mode for ranged attacks.") \ No newline at end of file diff --git a/docs/tutorials/part_10_save_load/part_10_save_load.py b/docs/tutorials/part_10_save_load/part_10_save_load.py new file mode 100644 index 0000000..a0c6380 --- /dev/null +++ b/docs/tutorials/part_10_save_load/part_10_save_load.py @@ -0,0 +1,1565 @@ +"""McRogueFace - Part 10: Saving and Loading + +Documentation: https://mcrogueface.github.io/tutorial/part_10_save_load +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_10_save_load/part_10_save_load.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy +import random +import json +import os +from dataclasses import dataclass, field +from typing import Optional +from enum import Enum + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains +SPRITE_POTION = 173 # Potion sprite +SPRITE_CURSOR = 88 # 'X' - targeting cursor + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# Enemy spawn parameters +MAX_ENEMIES_PER_ROOM = 3 + +# Item spawn parameters +MAX_ITEMS_PER_ROOM = 2 + +# FOV and targeting settings +FOV_RADIUS = 8 +RANGED_ATTACK_RANGE = 6 +RANGED_ATTACK_DAMAGE = 4 + +# Save file location +SAVE_FILE = "savegame.json" + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_PICKUP = mcrfpy.Color(100, 200, 255) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) +COLOR_INVALID = mcrfpy.Color(255, 100, 100) +COLOR_RANGED = mcrfpy.Color(255, 255, 100) +COLOR_SAVE = mcrfpy.Color(100, 255, 200) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Game Modes +# ============================================================================= + +class GameMode(Enum): + NORMAL = "normal" + TARGETING = "targeting" + +# ============================================================================= +# Fighter Component +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + + def to_dict(self) -> dict: + """Serialize fighter data to dictionary.""" + return { + "hp": self.hp, + "max_hp": self.max_hp, + "attack": self.attack, + "defense": self.defense, + "name": self.name, + "is_player": self.is_player + } + + @classmethod + def from_dict(cls, data: dict) -> "Fighter": + """Deserialize fighter data from dictionary.""" + return cls( + hp=data["hp"], + max_hp=data["max_hp"], + attack=data["attack"], + defense=data["defense"], + name=data["name"], + is_player=data.get("is_player", False) + ) + +# ============================================================================= +# Item Component +# ============================================================================= + +@dataclass +class Item: + """Data for an item that can be picked up and used.""" + name: str + item_type: str + heal_amount: int = 0 + + def to_dict(self) -> dict: + """Serialize item data to dictionary.""" + return { + "name": self.name, + "item_type": self.item_type, + "heal_amount": self.heal_amount + } + + @classmethod + def from_dict(cls, data: dict) -> "Item": + """Deserialize item data from dictionary.""" + return cls( + name=data["name"], + item_type=data["item_type"], + heal_amount=data.get("heal_amount", 0) + ) + +# ============================================================================= +# Inventory System +# ============================================================================= + +@dataclass +class Inventory: + """Container for items the player is carrying.""" + capacity: int = 10 + items: list = field(default_factory=list) + + def add(self, item: Item) -> bool: + if len(self.items) >= self.capacity: + return False + self.items.append(item) + return True + + def remove(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items.pop(index) + return None + + def get(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items[index] + return None + + def is_full(self) -> bool: + return len(self.items) >= self.capacity + + def count(self) -> int: + return len(self.items) + + def to_dict(self) -> dict: + """Serialize inventory to dictionary.""" + return { + "capacity": self.capacity, + "items": [item.to_dict() for item in self.items] + } + + @classmethod + def from_dict(cls, data: dict) -> "Inventory": + """Deserialize inventory from dictionary.""" + inv = cls(capacity=data.get("capacity", 10)) + inv.items = [Item.from_dict(item_data) for item_data in data.get("items", [])] + return inv + +# ============================================================================= +# Templates +# ============================================================================= + +ITEM_TEMPLATES = { + "health_potion": { + "name": "Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 10 + } +} + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + """A message log that displays recent game messages with colors.""" + + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption( + pos=(x + 10, y + 5 + i * line_height), + text="" + ) + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + if color is None: + color = mcrfpy.Color(200, 200, 200) + + self.messages.append((text, color)) + + while len(self.messages) > self.max_messages: + self.messages.pop(0) + + self._refresh() + + def _refresh(self) -> None: + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + """A visual health bar using nested frames.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_hp = 30 + self.current_hp = 30 + + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(width - 4, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption( + pos=(x + 5, y + 2), + text=f"HP: {self.current_hp}/{self.max_hp}" + ) + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + self.current_hp = current_hp + self.max_hp = max_hp + + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + self.label.text = f"HP: {current_hp}/{max_hp}" + + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) + +# ============================================================================= +# Inventory Panel +# ============================================================================= + +class InventoryPanel: + """A panel displaying the player's inventory.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption( + pos=(x + 10, y + 5), + text="Inventory (G:pickup, 1-5:use)" + ) + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + for i in range(5): + caption = mcrfpy.Caption( + pos=(x + 10, y + 25 + i * 18), + text="" + ) + caption.font_size = 13 + caption.fill_color = mcrfpy.Color(180, 180, 180) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + for caption in self.captions: + scene.children.append(caption) + + def update(self, inventory: Inventory) -> None: + for i, caption in enumerate(self.captions): + if i < len(inventory.items): + item = inventory.items[i] + caption.text = f"{i+1}. {item.name}" + caption.fill_color = mcrfpy.Color(180, 180, 180) + else: + caption.text = f"{i+1}. ---" + caption.fill_color = mcrfpy.Color(80, 80, 80) + +# ============================================================================= +# Mode Display +# ============================================================================= + +class ModeDisplay: + """Displays the current game mode.""" + + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption( + pos=(x, y), + text="[NORMAL MODE]" + ) + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, mode: GameMode) -> None: + if mode == GameMode.NORMAL: + self.caption.text = "[NORMAL] F:Ranged | S:Save" + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + elif mode == GameMode.TARGETING: + self.caption.text = "[TARGETING] Arrows:Move, Enter:Fire, Esc:Cancel" + self.caption.fill_color = mcrfpy.Color(255, 255, 100) + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +item_data: dict[mcrfpy.Entity, Item] = {} + +player: Optional[mcrfpy.Entity] = None +player_inventory: Optional[Inventory] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 + +# Game mode state +game_mode: GameMode = GameMode.NORMAL +target_cursor: Optional[mcrfpy.Entity] = None +target_x: int = 0 +target_y: int = 0 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +inventory_panel: Optional[InventoryPanel] = None +mode_display: Optional[ModeDisplay] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return center_x, center_y + + @property + def inner(self) -> tuple[slice, slice]: + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Save/Load System +# ============================================================================= + +def save_game() -> bool: + """Save the current game state to a JSON file. + + Returns: + True if save succeeded, False otherwise + """ + global player, player_inventory, grid, explored, dungeon_level + + try: + # Collect tile data + tiles = [] + for y in range(GRID_HEIGHT): + row = [] + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + row.append({ + "tilesprite": cell.tilesprite, + "walkable": cell.walkable, + "transparent": cell.transparent + }) + tiles.append(row) + + # Collect enemy data + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data: + fighter = entity_data[entity] + enemies.append({ + "x": int(entity.x), + "y": int(entity.y), + "type": fighter.name.lower(), + "fighter": fighter.to_dict() + }) + + # Collect ground item data + items_on_ground = [] + for entity in grid.entities: + if entity in item_data: + item = item_data[entity] + items_on_ground.append({ + "x": int(entity.x), + "y": int(entity.y), + "item": item.to_dict() + }) + + # Build save data structure + save_data = { + "version": 1, # For future compatibility + "dungeon_level": dungeon_level, + "player": { + "x": int(player.x), + "y": int(player.y), + "fighter": entity_data[player].to_dict(), + "inventory": player_inventory.to_dict() + }, + "tiles": tiles, + "explored": [[explored[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)], + "enemies": enemies, + "items": items_on_ground + } + + # Write to file + with open(SAVE_FILE, "w") as f: + json.dump(save_data, f, indent=2) + + message_log.add("Game saved successfully!", COLOR_SAVE) + return True + + except Exception as e: + message_log.add(f"Failed to save: {str(e)}", COLOR_INVALID) + print(f"Save error: {e}") + return False + +def load_game() -> bool: + """Load a saved game from JSON file. + + Returns: + True if load succeeded, False otherwise + """ + global player, player_inventory, grid, explored, dungeon_level + global entity_data, item_data, fov_layer, game_over + + if not os.path.exists(SAVE_FILE): + return False + + try: + with open(SAVE_FILE, "r") as f: + save_data = json.load(f) + + # Clear current game state + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + # Restore dungeon level + dungeon_level = save_data.get("dungeon_level", 1) + + # Restore tiles + tiles = save_data["tiles"] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + tile_data = tiles[y][x] + cell.tilesprite = tile_data["tilesprite"] + cell.walkable = tile_data["walkable"] + cell.transparent = tile_data["transparent"] + + # Restore explored state + global explored + explored_data = save_data["explored"] + explored = [[explored_data[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)] + + # Restore player + player_data = save_data["player"] + player = mcrfpy.Entity( + grid_pos=(player_data["x"], player_data["y"]), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter.from_dict(player_data["fighter"]) + player_inventory = Inventory.from_dict(player_data["inventory"]) + + # Restore enemies + for enemy_data in save_data.get("enemies", []): + enemy_type = enemy_data["type"] + template = ENEMY_TEMPLATES.get(enemy_type, ENEMY_TEMPLATES["goblin"]) + + enemy = mcrfpy.Entity( + grid_pos=(enemy_data["x"], enemy_data["y"]), + texture=texture, + sprite_index=template["sprite"] + ) + enemy.visible = False + + grid.entities.append(enemy) + entity_data[enemy] = Fighter.from_dict(enemy_data["fighter"]) + + # Restore ground items + for item_entry in save_data.get("items", []): + template = ITEM_TEMPLATES.get( + item_entry["item"]["item_type"], + ITEM_TEMPLATES["health_potion"] + ) + + item_entity = mcrfpy.Entity( + grid_pos=(item_entry["x"], item_entry["y"]), + texture=texture, + sprite_index=template["sprite"] + ) + item_entity.visible = False + + grid.entities.append(item_entity) + item_data[item_entity] = Item.from_dict(item_entry["item"]) + + # Reset FOV layer + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + # Compute initial FOV + update_fov(grid, fov_layer, int(player.x), int(player.y)) + + game_over = False + + message_log.add("Game loaded successfully!", COLOR_SAVE) + update_ui() + return True + + except Exception as e: + message_log.add(f"Failed to load: {str(e)}", COLOR_INVALID) + print(f"Load error: {e}") + return False + +def delete_save() -> bool: + """Delete the save file. + + Returns: + True if deletion succeeded or file did not exist + """ + try: + if os.path.exists(SAVE_FILE): + os.remove(SAVE_FILE) + return True + except Exception as e: + print(f"Delete save error: {e}") + return False + +def has_save_file() -> bool: + """Check if a save file exists.""" + return os.path.exists(SAVE_FILE) + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM) + + for _ in range(num_enemies): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + if roll < 0.6: + enemy_type = "goblin" + elif roll < 0.9: + enemy_type = "orc" + else: + enemy_type = "troll" + + spawn_enemy(target_grid, x, y, enemy_type, tex) + +def spawn_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ITEM_TEMPLATES[item_type] + + item_entity = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + item_entity.visible = False + + target_grid.entities.append(item_entity) + + item_data[item_entity] = Item( + name=template["name"], + item_type=template["item_type"], + heal_amount=template.get("heal_amount", 0) + ) + + return item_entity + +def spawn_items_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + num_items = random.randint(0, MAX_ITEMS_PER_ROOM) + + for _ in range(num_items): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + spawn_item(target_grid, x, y, "health_potion", tex) + +def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return True + return False + +def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity in item_data: + if int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in item_data: + del item_data[entity] + +# ============================================================================= +# Targeting System +# ============================================================================= + +def enter_targeting_mode() -> None: + global game_mode, target_cursor, target_x, target_y, player, grid, texture + + target_x = int(player.x) + target_y = int(player.y) + + target_cursor = mcrfpy.Entity( + grid_pos=(target_x, target_y), + texture=texture, + sprite_index=SPRITE_CURSOR + ) + grid.entities.append(target_cursor) + + game_mode = GameMode.TARGETING + + message_log.add("Targeting mode: Arrows to aim, Enter to fire, Esc to cancel.", COLOR_INFO) + mode_display.update(game_mode) + +def exit_targeting_mode() -> None: + global game_mode, target_cursor, grid + + if target_cursor is not None: + for i, e in enumerate(grid.entities): + if e == target_cursor: + grid.entities.remove(i) + break + target_cursor = None + + game_mode = GameMode.NORMAL + mode_display.update(game_mode) + +def move_cursor(dx: int, dy: int) -> None: + global target_x, target_y, target_cursor, grid, player + + new_x = target_x + dx + new_y = target_y + dy + + if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT: + return + + if not grid.is_in_fov(new_x, new_y): + message_log.add("You cannot see that location.", COLOR_INVALID) + return + + player_x, player_y = int(player.x), int(player.y) + distance = abs(new_x - player_x) + abs(new_y - player_y) + if distance > RANGED_ATTACK_RANGE: + message_log.add(f"Target is out of range (max {RANGED_ATTACK_RANGE}).", COLOR_WARNING) + return + + target_x = new_x + target_y = new_y + target_cursor.x = target_x + target_cursor.y = target_y + + enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + if enemy and enemy in entity_data: + fighter = entity_data[enemy] + message_log.add(f"Target: {fighter.name} (HP: {fighter.hp}/{fighter.max_hp})", COLOR_RANGED) + +def confirm_target() -> None: + global game_mode, target_x, target_y, player, grid + + if target_x == int(player.x) and target_y == int(player.y): + message_log.add("You cannot target yourself!", COLOR_INVALID) + return + + target_enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if target_enemy is None or target_enemy not in entity_data: + message_log.add("No valid target at that location.", COLOR_INVALID) + return + + perform_ranged_attack(target_enemy) + exit_targeting_mode() + enemy_turn() + update_ui() + +def perform_ranged_attack(target_entity: mcrfpy.Entity) -> None: + global player, game_over + + defender = entity_data.get(target_entity) + attacker = entity_data.get(player) + + if defender is None or attacker is None: + return + + damage = max(1, RANGED_ATTACK_DAMAGE - defender.defense // 2) + + defender.take_damage(damage) + + message_log.add( + f"Your ranged attack hits the {defender.name} for {damage} damage!", + COLOR_RANGED + ) + + if not defender.is_alive: + handle_death(target_entity, defender) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} for {damage} damage!", + COLOR_PLAYER_ATTACK + ) + else: + message_log.add( + f"The {attacker.name} hits you for {damage} damage!", + COLOR_ENEMY_ATTACK + ) + else: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + message_log.add( + f"The {attacker.name} hits but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + if not defender.is_alive: + handle_death(defender_entity, defender) + + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over, grid + + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart or Escape to quit.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + # Delete save on death (permadeath!) + delete_save() + else: + message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH) + remove_entity(grid, entity) + +# ============================================================================= +# Item Actions +# ============================================================================= + +def pickup_item() -> bool: + global player, player_inventory, grid + + px, py = int(player.x), int(player.y) + item_entity = get_item_at(grid, px, py) + + if item_entity is None: + message_log.add("There is nothing to pick up here.", COLOR_INVALID) + return False + + if player_inventory.is_full(): + message_log.add("Your inventory is full!", COLOR_WARNING) + return False + + item = item_data.get(item_entity) + if item is None: + return False + + player_inventory.add(item) + remove_item_entity(grid, item_entity) + + message_log.add(f"You pick up the {item.name}.", COLOR_PICKUP) + + update_ui() + return True + +def use_item(index: int) -> bool: + global player, player_inventory + + item = player_inventory.get(index) + if item is None: + message_log.add("Invalid item selection.", COLOR_INVALID) + return False + + if item.item_type == "health_potion": + fighter = entity_data.get(player) + if fighter is None: + return False + + if fighter.hp >= fighter.max_hp: + message_log.add("You are already at full health!", COLOR_WARNING) + return False + + actual_heal = fighter.heal(item.heal_amount) + player_inventory.remove(index) + + message_log.add(f"You drink the {item.name} and recover {actual_heal} HP!", COLOR_HEAL) + + update_ui() + return True + + message_log.add(f"You cannot use the {item.name}.", COLOR_INVALID) + return False + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + global player, target_cursor + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + if entity == target_cursor: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + new_target_x = px + dx + new_target_y = py + dy + + if new_target_x < 0 or new_target_x >= GRID_WIDTH or new_target_y < 0 or new_target_y >= GRID_HEIGHT: + return + + blocker = get_blocking_entity_at(grid, new_target_x, new_target_y, exclude=player) + + if blocker is not None: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(new_target_x, new_target_y).walkable: + player.x = new_target_x + player.y = new_target_y + update_fov(grid, fov_layer, new_target_x, new_target_y) + enemy_turn() + + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + if not grid.is_in_fov(ex, ey): + continue + + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + perform_attack(enemy, player) + else: + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + global grid + + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + global player, health_bar, inventory_panel, player_inventory + + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + + if player_inventory: + inventory_panel.update(player_inventory) + +# ============================================================================= +# New Game Generation +# ============================================================================= + +def generate_new_game() -> None: + """Generate a fresh dungeon with new player.""" + global player, player_inventory, grid, fov_layer, game_over + global entity_data, item_data, dungeon_level, game_mode + + # Reset state + game_over = False + game_mode = GameMode.NORMAL + dungeon_level = 1 + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + fill_with_walls(grid) + init_explored() + message_log.clear() + + rooms: list[RectangularRoom] = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + if rooms: + new_x, new_y = rooms[0].center + else: + new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + + player = mcrfpy.Entity( + grid_pos=(new_x, new_y), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True + ) + + player_inventory = Inventory(capacity=10) + + for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + spawn_items_in_room(grid, room, texture) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, new_x, new_y) + + mode_display.update(game_mode) + update_ui() + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(700, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Add FOV layer +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# Create UI Elements +# ============================================================================= + +# Title bar +title = mcrfpy.Caption( + pos=(20, 10), + text="Part 10: Save/Load" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +# Instructions +instructions = mcrfpy.Caption( + pos=(250, 15), + text="WASD:Move | F:Ranged | G:Pickup | Ctrl+S:Save | R:Restart" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +# Health Bar +health_bar = HealthBar( + x=730, + y=10, + width=280, + height=30 +) +health_bar.add_to_scene(scene) + +# Mode Display +mode_display = ModeDisplay(x=20, y=40) +mode_display.add_to_scene(scene) + +# Inventory Panel +inventory_panel = InventoryPanel( + x=730, + y=GAME_AREA_Y, + width=280, + height=150 +) +inventory_panel.add_to_scene(scene) + +# Message Log +message_log = MessageLog( + x=20, + y=768 - UI_BOTTOM_HEIGHT + 10, + width=990, + height=UI_BOTTOM_HEIGHT - 20, + max_messages=6 +) +message_log.add_to_scene(scene) + +# ============================================================================= +# Initialize Game (Load or New) +# ============================================================================= + +# Initialize explored array +init_explored() + +# Try to load existing save, otherwise generate new game +if has_save_file(): + message_log.add("Found saved game. Loading...", COLOR_INFO) + if not load_game(): + message_log.add("Failed to load. Starting new game.", COLOR_WARNING) + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) +else: + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) + message_log.add("Press Ctrl+S to save your progress.", COLOR_INFO) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def handle_keys(key: str, action: str) -> None: + global game_over, game_mode + + if action != "start": + return + + # Always allow restart + if key == "R": + delete_save() + generate_new_game() + message_log.add("A new adventure begins!", COLOR_INFO) + return + + if key == "Escape": + if game_mode == GameMode.TARGETING: + exit_targeting_mode() + message_log.add("Targeting cancelled.", COLOR_INFO) + return + else: + # Save on quit + if not game_over: + save_game() + mcrfpy.exit() + return + + # Save game (Ctrl+S or just S when not moving) + if key == "S" and game_mode == GameMode.NORMAL: + # Check if this is meant to be a save (could add modifier check) + # For simplicity, we will use a dedicated save key + pass + + # Dedicated save with period key + if key == "Period" and game_mode == GameMode.NORMAL and not game_over: + save_game() + return + + if game_over: + return + + # Handle input based on game mode + if game_mode == GameMode.TARGETING: + handle_targeting_input(key) + else: + handle_normal_input(key) + +def handle_normal_input(key: str) -> None: + # Movement + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + # Ranged attack + elif key == "F": + enter_targeting_mode() + # Pickup + elif key == "G" or key == ",": + pickup_item() + # Use items + elif key in ["1", "2", "3", "4", "5"]: + index = int(key) - 1 + if use_item(index): + enemy_turn() + update_ui() + +def handle_targeting_input(key: str) -> None: + if key == "Up" or key == "W": + move_cursor(0, -1) + elif key == "Down" or key == "S": + move_cursor(0, 1) + elif key == "Left" or key == "A": + move_cursor(-1, 0) + elif key == "Right" or key == "D": + move_cursor(1, 0) + elif key == "Return" or key == "Space": + confirm_target() + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 10 loaded! Press Period (.) to save, Escape saves and quits.") \ No newline at end of file diff --git a/docs/tutorials/part_11_levels/part_11_levels.py b/docs/tutorials/part_11_levels/part_11_levels.py new file mode 100644 index 0000000..ee31c04 --- /dev/null +++ b/docs/tutorials/part_11_levels/part_11_levels.py @@ -0,0 +1,1735 @@ +"""McRogueFace - Part 11: Multiple Dungeon Levels + +Documentation: https://mcrogueface.github.io/tutorial/part_11_levels +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_11_levels/part_11_levels.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy +import random +import json +import os +from dataclasses import dataclass, field +from typing import Optional +from enum import Enum + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains +SPRITE_POTION = 173 # Potion sprite +SPRITE_CURSOR = 88 # 'X' - targeting cursor +SPRITE_STAIRS_DOWN = 62 # '>' - stairs down + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# FOV and targeting settings +FOV_RADIUS = 8 +RANGED_ATTACK_RANGE = 6 +RANGED_ATTACK_DAMAGE = 4 + +# Save file location +SAVE_FILE = "savegame.json" + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_PICKUP = mcrfpy.Color(100, 200, 255) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) +COLOR_INVALID = mcrfpy.Color(255, 100, 100) +COLOR_RANGED = mcrfpy.Color(255, 255, 100) +COLOR_SAVE = mcrfpy.Color(100, 255, 200) +COLOR_DESCEND = mcrfpy.Color(200, 200, 255) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Game Modes +# ============================================================================= + +class GameMode(Enum): + NORMAL = "normal" + TARGETING = "targeting" + +# ============================================================================= +# Fighter Component +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + + def to_dict(self) -> dict: + return { + "hp": self.hp, + "max_hp": self.max_hp, + "attack": self.attack, + "defense": self.defense, + "name": self.name, + "is_player": self.is_player + } + + @classmethod + def from_dict(cls, data: dict) -> "Fighter": + return cls( + hp=data["hp"], + max_hp=data["max_hp"], + attack=data["attack"], + defense=data["defense"], + name=data["name"], + is_player=data.get("is_player", False) + ) + +# ============================================================================= +# Item Component +# ============================================================================= + +@dataclass +class Item: + """Data for an item that can be picked up and used.""" + name: str + item_type: str + heal_amount: int = 0 + + def to_dict(self) -> dict: + return { + "name": self.name, + "item_type": self.item_type, + "heal_amount": self.heal_amount + } + + @classmethod + def from_dict(cls, data: dict) -> "Item": + return cls( + name=data["name"], + item_type=data["item_type"], + heal_amount=data.get("heal_amount", 0) + ) + +# ============================================================================= +# Inventory System +# ============================================================================= + +@dataclass +class Inventory: + """Container for items the player is carrying.""" + capacity: int = 10 + items: list = field(default_factory=list) + + def add(self, item: Item) -> bool: + if len(self.items) >= self.capacity: + return False + self.items.append(item) + return True + + def remove(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items.pop(index) + return None + + def get(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items[index] + return None + + def is_full(self) -> bool: + return len(self.items) >= self.capacity + + def count(self) -> int: + return len(self.items) + + def to_dict(self) -> dict: + return { + "capacity": self.capacity, + "items": [item.to_dict() for item in self.items] + } + + @classmethod + def from_dict(cls, data: dict) -> "Inventory": + inv = cls(capacity=data.get("capacity", 10)) + inv.items = [Item.from_dict(item_data) for item_data in data.get("items", [])] + return inv + +# ============================================================================= +# Templates +# ============================================================================= + +ITEM_TEMPLATES = { + "health_potion": { + "name": "Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 10 + }, + "greater_health_potion": { + "name": "Greater Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 20 + } +} + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Difficulty Scaling +# ============================================================================= + +def get_max_enemies_per_room(level: int) -> int: + """Get maximum enemies per room based on dungeon level.""" + return min(2 + level, 6) + +def get_max_items_per_room(level: int) -> int: + """Get maximum items per room based on dungeon level.""" + return min(1 + level // 2, 3) + +def get_enemy_weights(level: int) -> list[tuple[str, float]]: + """Get enemy spawn weights based on dungeon level. + + Returns list of (enemy_type, cumulative_weight) tuples. + """ + if level <= 2: + # Levels 1-2: Mostly goblins + return [("goblin", 0.8), ("orc", 0.95), ("troll", 1.0)] + elif level <= 4: + # Levels 3-4: More orcs + return [("goblin", 0.5), ("orc", 0.85), ("troll", 1.0)] + else: + # Level 5+: Dangerous mix + return [("goblin", 0.3), ("orc", 0.6), ("troll", 1.0)] + +def get_item_weights(level: int) -> list[tuple[str, float]]: + """Get item spawn weights based on dungeon level.""" + if level <= 2: + return [("health_potion", 1.0)] + else: + return [("health_potion", 0.7), ("greater_health_potion", 1.0)] + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + """A message log that displays recent game messages with colors.""" + + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption( + pos=(x + 10, y + 5 + i * line_height), + text="" + ) + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + if color is None: + color = mcrfpy.Color(200, 200, 200) + + self.messages.append((text, color)) + + while len(self.messages) > self.max_messages: + self.messages.pop(0) + + self._refresh() + + def _refresh(self) -> None: + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + """A visual health bar using nested frames.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_hp = 30 + self.current_hp = 30 + + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(width - 4, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption( + pos=(x + 5, y + 2), + text=f"HP: {self.current_hp}/{self.max_hp}" + ) + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + self.current_hp = current_hp + self.max_hp = max_hp + + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + self.label.text = f"HP: {current_hp}/{max_hp}" + + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) + +# ============================================================================= +# Inventory Panel +# ============================================================================= + +class InventoryPanel: + """A panel displaying the player's inventory.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption( + pos=(x + 10, y + 5), + text="Inventory (G:pickup, 1-5:use)" + ) + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + for i in range(5): + caption = mcrfpy.Caption( + pos=(x + 10, y + 25 + i * 18), + text="" + ) + caption.font_size = 13 + caption.fill_color = mcrfpy.Color(180, 180, 180) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + for caption in self.captions: + scene.children.append(caption) + + def update(self, inventory: Inventory) -> None: + for i, caption in enumerate(self.captions): + if i < len(inventory.items): + item = inventory.items[i] + caption.text = f"{i+1}. {item.name}" + caption.fill_color = mcrfpy.Color(180, 180, 180) + else: + caption.text = f"{i+1}. ---" + caption.fill_color = mcrfpy.Color(80, 80, 80) + +# ============================================================================= +# Level Display +# ============================================================================= + +class LevelDisplay: + """Displays current dungeon level.""" + + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption( + pos=(x, y), + text="Level: 1" + ) + self.caption.font_size = 18 + self.caption.fill_color = mcrfpy.Color(200, 200, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, level: int) -> None: + self.caption.text = f"Dungeon Level: {level}" + +# ============================================================================= +# Mode Display +# ============================================================================= + +class ModeDisplay: + """Displays the current game mode.""" + + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption( + pos=(x, y), + text="[NORMAL MODE]" + ) + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, mode: GameMode) -> None: + if mode == GameMode.NORMAL: + self.caption.text = "[NORMAL] F:Ranged | >:Descend" + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + elif mode == GameMode.TARGETING: + self.caption.text = "[TARGETING] Arrows:Move, Enter:Fire, Esc:Cancel" + self.caption.fill_color = mcrfpy.Color(255, 255, 100) + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +item_data: dict[mcrfpy.Entity, Item] = {} + +player: Optional[mcrfpy.Entity] = None +player_inventory: Optional[Inventory] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 + +# Stairs position +stairs_position: tuple[int, int] = (0, 0) + +# Game mode state +game_mode: GameMode = GameMode.NORMAL +target_cursor: Optional[mcrfpy.Entity] = None +target_x: int = 0 +target_y: int = 0 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +inventory_panel: Optional[InventoryPanel] = None +mode_display: Optional[ModeDisplay] = None +level_display: Optional[LevelDisplay] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return center_x, center_y + + @property + def inner(self) -> tuple[slice, slice]: + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +def place_stairs(target_grid: mcrfpy.Grid, x: int, y: int) -> None: + """Place stairs down at the given position.""" + global stairs_position + + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_STAIRS_DOWN + cell.walkable = True + cell.transparent = True + + stairs_position = (x, y) + +def generate_dungeon(target_grid: mcrfpy.Grid, level: int) -> tuple[int, int]: + """Generate a new dungeon level. + + Args: + target_grid: The grid to generate into + level: Current dungeon level (affects difficulty) + + Returns: + Player starting position (x, y) + """ + global stairs_position + + fill_with_walls(target_grid) + + rooms: list[RectangularRoom] = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(target_grid, new_room) + + if rooms: + carve_l_tunnel(target_grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + # Place stairs in the last room + if rooms: + stairs_x, stairs_y = rooms[-1].center + place_stairs(target_grid, stairs_x, stairs_y) + + # Return starting position (first room center) + if rooms: + return rooms[0].center + else: + return GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture, level: int) -> None: + """Spawn enemies in a room with level-scaled difficulty.""" + max_enemies = get_max_enemies_per_room(level) + num_enemies = random.randint(0, max_enemies) + enemy_weights = get_enemy_weights(level) + + for _ in range(num_enemies): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + # Select enemy type based on weights + roll = random.random() + enemy_type = "goblin" + for etype, threshold in enemy_weights: + if roll < threshold: + enemy_type = etype + break + + spawn_enemy(target_grid, x, y, enemy_type, tex) + +def spawn_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ITEM_TEMPLATES[item_type] + + item_entity = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + item_entity.visible = False + + target_grid.entities.append(item_entity) + + item_data[item_entity] = Item( + name=template["name"], + item_type=template["item_type"], + heal_amount=template.get("heal_amount", 0) + ) + + return item_entity + +def spawn_items_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture, level: int) -> None: + """Spawn items in a room with level-scaled variety.""" + max_items = get_max_items_per_room(level) + num_items = random.randint(0, max_items) + item_weights = get_item_weights(level) + + for _ in range(num_items): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + # Select item type based on weights + roll = random.random() + item_type = "health_potion" + for itype, threshold in item_weights: + if roll < threshold: + item_type = itype + break + + spawn_item(target_grid, x, y, item_type, tex) + +def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return True + return False + +def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity in item_data: + if int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in item_data: + del item_data[entity] + +def clear_entities_except_player(target_grid: mcrfpy.Grid) -> None: + """Remove all entities except the player.""" + global entity_data, item_data + + entities_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and entity_data[entity].is_player: + continue + entities_to_remove.append(entity) + + for entity in entities_to_remove: + if entity in entity_data: + del entity_data[entity] + if entity in item_data: + del item_data[entity] + + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + +# ============================================================================= +# Level Transition +# ============================================================================= + +def descend_stairs() -> bool: + """Attempt to descend stairs at player's position. + + Returns: + True if descended, False if no stairs here + """ + global player, dungeon_level, grid, fov_layer, stairs_position + + px, py = int(player.x), int(player.y) + + # Check if player is on stairs + if (px, py) != stairs_position: + message_log.add("There are no stairs here.", COLOR_INVALID) + return False + + # Descend to next level + dungeon_level += 1 + + # Clear current level's entities (except player) + clear_entities_except_player(grid) + + # Generate new dungeon + init_explored() + player_start = generate_dungeon(grid, dungeon_level) + + # Move player to starting position + player.x = player_start[0] + player.y = player_start[1] + + # Spawn enemies and items based on level + # We need to get the rooms - regenerate them + # For simplicity, spawn in all floor tiles + spawn_entities_for_level(grid, texture, dungeon_level) + + # Reset FOV + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, player_start[0], player_start[1]) + + message_log.add(f"You descend to level {dungeon_level}...", COLOR_DESCEND) + level_display.update(dungeon_level) + update_ui() + + return True + +def spawn_entities_for_level(target_grid: mcrfpy.Grid, tex: mcrfpy.Texture, level: int) -> None: + """Spawn enemies and items throughout the dungeon.""" + # Find all floor tiles and group them into rough areas + floor_tiles = [] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + if cell.walkable and cell.tilesprite == SPRITE_FLOOR: + floor_tiles.append((x, y)) + + # Spawn enemies + max_enemies = get_max_enemies_per_room(level) * 3 # Approximate total + enemy_weights = get_enemy_weights(level) + + for _ in range(max_enemies): + if not floor_tiles: + break + + x, y = random.choice(floor_tiles) + + # Don't spawn on player or stairs + if (x, y) == (int(player.x), int(player.y)): + continue + if (x, y) == stairs_position: + continue + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + enemy_type = "goblin" + for etype, threshold in enemy_weights: + if roll < threshold: + enemy_type = etype + break + + spawn_enemy(target_grid, x, y, enemy_type, tex) + + # Spawn items + max_items = get_max_items_per_room(level) * 2 + item_weights = get_item_weights(level) + + for _ in range(max_items): + if not floor_tiles: + break + + x, y = random.choice(floor_tiles) + + if (x, y) == (int(player.x), int(player.y)): + continue + if (x, y) == stairs_position: + continue + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + item_type = "health_potion" + for itype, threshold in item_weights: + if roll < threshold: + item_type = itype + break + + spawn_item(target_grid, x, y, item_type, tex) + +# ============================================================================= +# Save/Load System +# ============================================================================= + +def save_game() -> bool: + """Save the current game state to a JSON file.""" + global player, player_inventory, grid, explored, dungeon_level, stairs_position + + try: + tiles = [] + for y in range(GRID_HEIGHT): + row = [] + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + row.append({ + "tilesprite": cell.tilesprite, + "walkable": cell.walkable, + "transparent": cell.transparent + }) + tiles.append(row) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data: + fighter = entity_data[entity] + enemies.append({ + "x": int(entity.x), + "y": int(entity.y), + "type": fighter.name.lower(), + "fighter": fighter.to_dict() + }) + + items_on_ground = [] + for entity in grid.entities: + if entity in item_data: + item = item_data[entity] + items_on_ground.append({ + "x": int(entity.x), + "y": int(entity.y), + "item": item.to_dict() + }) + + save_data = { + "version": 2, + "dungeon_level": dungeon_level, + "stairs_position": list(stairs_position), + "player": { + "x": int(player.x), + "y": int(player.y), + "fighter": entity_data[player].to_dict(), + "inventory": player_inventory.to_dict() + }, + "tiles": tiles, + "explored": [[explored[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)], + "enemies": enemies, + "items": items_on_ground + } + + with open(SAVE_FILE, "w") as f: + json.dump(save_data, f, indent=2) + + message_log.add("Game saved successfully!", COLOR_SAVE) + return True + + except Exception as e: + message_log.add(f"Failed to save: {str(e)}", COLOR_INVALID) + return False + +def load_game() -> bool: + """Load a saved game from JSON file.""" + global player, player_inventory, grid, explored, dungeon_level + global entity_data, item_data, fov_layer, game_over, stairs_position + + if not os.path.exists(SAVE_FILE): + return False + + try: + with open(SAVE_FILE, "r") as f: + save_data = json.load(f) + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + dungeon_level = save_data.get("dungeon_level", 1) + stairs_position = tuple(save_data.get("stairs_position", [0, 0])) + + tiles = save_data["tiles"] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + tile_data = tiles[y][x] + cell.tilesprite = tile_data["tilesprite"] + cell.walkable = tile_data["walkable"] + cell.transparent = tile_data["transparent"] + + global explored + explored_data = save_data["explored"] + explored = [[explored_data[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)] + + player_data = save_data["player"] + player = mcrfpy.Entity( + grid_pos=(player_data["x"], player_data["y"]), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter.from_dict(player_data["fighter"]) + player_inventory = Inventory.from_dict(player_data["inventory"]) + + for enemy_data in save_data.get("enemies", []): + enemy_type = enemy_data["type"] + template = ENEMY_TEMPLATES.get(enemy_type, ENEMY_TEMPLATES["goblin"]) + + enemy = mcrfpy.Entity( + grid_pos=(enemy_data["x"], enemy_data["y"]), + texture=texture, + sprite_index=template["sprite"] + ) + enemy.visible = False + + grid.entities.append(enemy) + entity_data[enemy] = Fighter.from_dict(enemy_data["fighter"]) + + for item_entry in save_data.get("items", []): + item_type = item_entry["item"]["item_type"] + template = ITEM_TEMPLATES.get(item_type, ITEM_TEMPLATES["health_potion"]) + + item_entity = mcrfpy.Entity( + grid_pos=(item_entry["x"], item_entry["y"]), + texture=texture, + sprite_index=template["sprite"] + ) + item_entity.visible = False + + grid.entities.append(item_entity) + item_data[item_entity] = Item.from_dict(item_entry["item"]) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, int(player.x), int(player.y)) + + game_over = False + + message_log.add("Game loaded successfully!", COLOR_SAVE) + level_display.update(dungeon_level) + update_ui() + return True + + except Exception as e: + message_log.add(f"Failed to load: {str(e)}", COLOR_INVALID) + return False + +def delete_save() -> bool: + try: + if os.path.exists(SAVE_FILE): + os.remove(SAVE_FILE) + return True + except Exception: + return False + +def has_save_file() -> bool: + return os.path.exists(SAVE_FILE) + +# ============================================================================= +# Targeting System +# ============================================================================= + +def enter_targeting_mode() -> None: + global game_mode, target_cursor, target_x, target_y, player, grid, texture + + target_x = int(player.x) + target_y = int(player.y) + + target_cursor = mcrfpy.Entity( + grid_pos=(target_x, target_y), + texture=texture, + sprite_index=SPRITE_CURSOR + ) + grid.entities.append(target_cursor) + + game_mode = GameMode.TARGETING + + message_log.add("Targeting mode: Arrows to aim, Enter to fire, Esc to cancel.", COLOR_INFO) + mode_display.update(game_mode) + +def exit_targeting_mode() -> None: + global game_mode, target_cursor, grid + + if target_cursor is not None: + for i, e in enumerate(grid.entities): + if e == target_cursor: + grid.entities.remove(i) + break + target_cursor = None + + game_mode = GameMode.NORMAL + mode_display.update(game_mode) + +def move_cursor(dx: int, dy: int) -> None: + global target_x, target_y, target_cursor, grid, player + + new_x = target_x + dx + new_y = target_y + dy + + if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT: + return + + if not grid.is_in_fov(new_x, new_y): + message_log.add("You cannot see that location.", COLOR_INVALID) + return + + player_x, player_y = int(player.x), int(player.y) + distance = abs(new_x - player_x) + abs(new_y - player_y) + if distance > RANGED_ATTACK_RANGE: + message_log.add(f"Target is out of range (max {RANGED_ATTACK_RANGE}).", COLOR_WARNING) + return + + target_x = new_x + target_y = new_y + target_cursor.x = target_x + target_cursor.y = target_y + + enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + if enemy and enemy in entity_data: + fighter = entity_data[enemy] + message_log.add(f"Target: {fighter.name} (HP: {fighter.hp}/{fighter.max_hp})", COLOR_RANGED) + +def confirm_target() -> None: + global game_mode, target_x, target_y, player, grid + + if target_x == int(player.x) and target_y == int(player.y): + message_log.add("You cannot target yourself!", COLOR_INVALID) + return + + target_enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if target_enemy is None or target_enemy not in entity_data: + message_log.add("No valid target at that location.", COLOR_INVALID) + return + + perform_ranged_attack(target_enemy) + exit_targeting_mode() + enemy_turn() + update_ui() + +def perform_ranged_attack(target_entity: mcrfpy.Entity) -> None: + global player, game_over + + defender = entity_data.get(target_entity) + attacker = entity_data.get(player) + + if defender is None or attacker is None: + return + + damage = max(1, RANGED_ATTACK_DAMAGE - defender.defense // 2) + + defender.take_damage(damage) + + message_log.add( + f"Your ranged attack hits the {defender.name} for {damage} damage!", + COLOR_RANGED + ) + + if not defender.is_alive: + handle_death(target_entity, defender) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} for {damage} damage!", + COLOR_PLAYER_ATTACK + ) + else: + message_log.add( + f"The {attacker.name} hits you for {damage} damage!", + COLOR_ENEMY_ATTACK + ) + else: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + message_log.add( + f"The {attacker.name} hits but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + if not defender.is_alive: + handle_death(defender_entity, defender) + + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over, grid + + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart or Escape to quit.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + delete_save() + else: + message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH) + remove_entity(grid, entity) + +# ============================================================================= +# Item Actions +# ============================================================================= + +def pickup_item() -> bool: + global player, player_inventory, grid + + px, py = int(player.x), int(player.y) + item_entity = get_item_at(grid, px, py) + + if item_entity is None: + message_log.add("There is nothing to pick up here.", COLOR_INVALID) + return False + + if player_inventory.is_full(): + message_log.add("Your inventory is full!", COLOR_WARNING) + return False + + item = item_data.get(item_entity) + if item is None: + return False + + player_inventory.add(item) + remove_item_entity(grid, item_entity) + + message_log.add(f"You pick up the {item.name}.", COLOR_PICKUP) + + update_ui() + return True + +def use_item(index: int) -> bool: + global player, player_inventory + + item = player_inventory.get(index) + if item is None: + message_log.add("Invalid item selection.", COLOR_INVALID) + return False + + if item.item_type == "health_potion": + fighter = entity_data.get(player) + if fighter is None: + return False + + if fighter.hp >= fighter.max_hp: + message_log.add("You are already at full health!", COLOR_WARNING) + return False + + actual_heal = fighter.heal(item.heal_amount) + player_inventory.remove(index) + + message_log.add(f"You drink the {item.name} and recover {actual_heal} HP!", COLOR_HEAL) + + update_ui() + return True + + message_log.add(f"You cannot use the {item.name}.", COLOR_INVALID) + return False + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + global player, target_cursor + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + if entity == target_cursor: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + new_target_x = px + dx + new_target_y = py + dy + + if new_target_x < 0 or new_target_x >= GRID_WIDTH or new_target_y < 0 or new_target_y >= GRID_HEIGHT: + return + + blocker = get_blocking_entity_at(grid, new_target_x, new_target_y, exclude=player) + + if blocker is not None: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(new_target_x, new_target_y).walkable: + player.x = new_target_x + player.y = new_target_y + update_fov(grid, fov_layer, new_target_x, new_target_y) + enemy_turn() + + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + if not grid.is_in_fov(ex, ey): + continue + + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + perform_attack(enemy, player) + else: + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + global grid + + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + global player, health_bar, inventory_panel, player_inventory + + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + + if player_inventory: + inventory_panel.update(player_inventory) + +# ============================================================================= +# New Game Generation +# ============================================================================= + +def generate_new_game() -> None: + """Generate a fresh dungeon with new player.""" + global player, player_inventory, grid, fov_layer, game_over + global entity_data, item_data, dungeon_level, game_mode + + game_over = False + game_mode = GameMode.NORMAL + dungeon_level = 1 + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + init_explored() + message_log.clear() + + player_start = generate_dungeon(grid, dungeon_level) + + player = mcrfpy.Entity( + grid_pos=(player_start[0], player_start[1]), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True + ) + + player_inventory = Inventory(capacity=10) + + spawn_entities_for_level(grid, texture, dungeon_level) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, player_start[0], player_start[1]) + + mode_display.update(game_mode) + level_display.update(dungeon_level) + update_ui() + +# ============================================================================= +# Game Setup +# ============================================================================= + +scene = mcrfpy.Scene("game") +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(700, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +scene.children.append(grid) + +# ============================================================================= +# Create UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(20, 10), + text="Part 11: Multiple Levels" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(260, 15), + text="WASD:Move | >:Descend | F:Ranged | G:Pickup | R:Restart" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +health_bar = HealthBar(x=730, y=10, width=280, height=30) +health_bar.add_to_scene(scene) + +level_display = LevelDisplay(x=730, y=45) +level_display.add_to_scene(scene) + +mode_display = ModeDisplay(x=20, y=40) +mode_display.add_to_scene(scene) + +inventory_panel = InventoryPanel(x=730, y=GAME_AREA_Y, width=280, height=150) +inventory_panel.add_to_scene(scene) + +message_log = MessageLog( + x=20, + y=768 - UI_BOTTOM_HEIGHT + 10, + width=990, + height=UI_BOTTOM_HEIGHT - 20, + max_messages=6 +) +message_log.add_to_scene(scene) + +# ============================================================================= +# Initialize Game +# ============================================================================= + +init_explored() + +if has_save_file(): + message_log.add("Found saved game. Loading...", COLOR_INFO) + if not load_game(): + message_log.add("Failed to load. Starting new game.", COLOR_WARNING) + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) +else: + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) + message_log.add("Find the stairs (>) to descend deeper.", COLOR_INFO) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def handle_keys(key: str, action: str) -> None: + global game_over, game_mode + + if action != "start": + return + + if key == "R": + delete_save() + generate_new_game() + message_log.add("A new adventure begins!", COLOR_INFO) + return + + if key == "Escape": + if game_mode == GameMode.TARGETING: + exit_targeting_mode() + message_log.add("Targeting cancelled.", COLOR_INFO) + return + else: + if not game_over: + save_game() + mcrfpy.exit() + return + + if key == "Period" and game_mode == GameMode.NORMAL and not game_over: + save_game() + return + + if game_over: + return + + if game_mode == GameMode.TARGETING: + handle_targeting_input(key) + else: + handle_normal_input(key) + +def handle_normal_input(key: str) -> None: + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + elif key == "F": + enter_targeting_mode() + elif key == "G" or key == ",": + pickup_item() + elif key == "Period" and mcrfpy.keypressed("LShift"): + # Shift+. (>) to descend stairs + descend_stairs() + elif key in ["1", "2", "3", "4", "5"]: + index = int(key) - 1 + if use_item(index): + enemy_turn() + update_ui() + +def handle_targeting_input(key: str) -> None: + if key == "Up" or key == "W": + move_cursor(0, -1) + elif key == "Down" or key == "S": + move_cursor(0, 1) + elif key == "Left" or key == "A": + move_cursor(-1, 0) + elif key == "Right" or key == "D": + move_cursor(1, 0) + elif key == "Return" or key == "Space": + confirm_target() + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 11 loaded! Find stairs (>) to descend to deeper levels.") \ No newline at end of file diff --git a/docs/tutorials/part_12_experience/part_12_experience.py b/docs/tutorials/part_12_experience/part_12_experience.py new file mode 100644 index 0000000..ec59009 --- /dev/null +++ b/docs/tutorials/part_12_experience/part_12_experience.py @@ -0,0 +1,1850 @@ +"""McRogueFace - Part 12: Experience and Leveling + +Documentation: https://mcrogueface.github.io/tutorial/part_12_experience +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_12_experience/part_12_experience.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy +import random +import json +import os +from dataclasses import dataclass, field +from typing import Optional +from enum import Enum + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains +SPRITE_POTION = 173 # Potion sprite +SPRITE_CURSOR = 88 # 'X' - targeting cursor +SPRITE_STAIRS_DOWN = 62 # '>' - stairs down + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# FOV and targeting settings +FOV_RADIUS = 8 +RANGED_ATTACK_RANGE = 6 +RANGED_ATTACK_DAMAGE = 4 + +# Save file location +SAVE_FILE = "savegame.json" + +# XP values for enemies +ENEMY_XP_VALUES = { + "goblin": 35, + "orc": 50, + "troll": 100 +} + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_PICKUP = mcrfpy.Color(100, 200, 255) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) +COLOR_INVALID = mcrfpy.Color(255, 100, 100) +COLOR_RANGED = mcrfpy.Color(255, 255, 100) +COLOR_SAVE = mcrfpy.Color(100, 255, 200) +COLOR_DESCEND = mcrfpy.Color(200, 200, 255) +COLOR_LEVEL_UP = mcrfpy.Color(255, 255, 100) +COLOR_XP = mcrfpy.Color(200, 200, 100) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Game Modes +# ============================================================================= + +class GameMode(Enum): + NORMAL = "normal" + TARGETING = "targeting" + +# ============================================================================= +# Fighter Component with XP +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity with experience system.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + xp: int = 0 + level: int = 1 + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + @property + def xp_to_next_level(self) -> int: + """Calculate XP needed to reach the next level.""" + return self.level * 100 + + @property + def xp_progress(self) -> float: + """Get XP progress as a percentage (0.0 to 1.0).""" + return self.xp / self.xp_to_next_level if self.xp_to_next_level > 0 else 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + + def gain_xp(self, amount: int) -> bool: + """Add XP and check for level up. + + Args: + amount: XP to add + + Returns: + True if leveled up, False otherwise + """ + self.xp += amount + + if self.xp >= self.xp_to_next_level: + self.level_up() + return True + + return False + + def level_up(self) -> None: + """Level up the character, increasing stats.""" + # Subtract XP cost (excess carries over) + self.xp -= self.xp_to_next_level + + self.level += 1 + + # Stat increases + hp_increase = 5 + attack_increase = 1 + defense_increase = 1 if self.level % 3 == 0 else 0 # Every 3rd level + + self.max_hp += hp_increase + self.hp = self.max_hp # Full heal on level up! + self.attack += attack_increase + self.defense += defense_increase + + def to_dict(self) -> dict: + return { + "hp": self.hp, + "max_hp": self.max_hp, + "attack": self.attack, + "defense": self.defense, + "name": self.name, + "is_player": self.is_player, + "xp": self.xp, + "level": self.level + } + + @classmethod + def from_dict(cls, data: dict) -> "Fighter": + return cls( + hp=data["hp"], + max_hp=data["max_hp"], + attack=data["attack"], + defense=data["defense"], + name=data["name"], + is_player=data.get("is_player", False), + xp=data.get("xp", 0), + level=data.get("level", 1) + ) + +# ============================================================================= +# Item Component +# ============================================================================= + +@dataclass +class Item: + """Data for an item that can be picked up and used.""" + name: str + item_type: str + heal_amount: int = 0 + + def to_dict(self) -> dict: + return { + "name": self.name, + "item_type": self.item_type, + "heal_amount": self.heal_amount + } + + @classmethod + def from_dict(cls, data: dict) -> "Item": + return cls( + name=data["name"], + item_type=data["item_type"], + heal_amount=data.get("heal_amount", 0) + ) + +# ============================================================================= +# Inventory System +# ============================================================================= + +@dataclass +class Inventory: + """Container for items the player is carrying.""" + capacity: int = 10 + items: list = field(default_factory=list) + + def add(self, item: Item) -> bool: + if len(self.items) >= self.capacity: + return False + self.items.append(item) + return True + + def remove(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items.pop(index) + return None + + def get(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items[index] + return None + + def is_full(self) -> bool: + return len(self.items) >= self.capacity + + def count(self) -> int: + return len(self.items) + + def to_dict(self) -> dict: + return { + "capacity": self.capacity, + "items": [item.to_dict() for item in self.items] + } + + @classmethod + def from_dict(cls, data: dict) -> "Inventory": + inv = cls(capacity=data.get("capacity", 10)) + inv.items = [Item.from_dict(item_data) for item_data in data.get("items", [])] + return inv + +# ============================================================================= +# Templates +# ============================================================================= + +ITEM_TEMPLATES = { + "health_potion": { + "name": "Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 10 + }, + "greater_health_potion": { + "name": "Greater Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 20 + } +} + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Difficulty Scaling +# ============================================================================= + +def get_max_enemies_per_room(level: int) -> int: + return min(2 + level, 6) + +def get_max_items_per_room(level: int) -> int: + return min(1 + level // 2, 3) + +def get_enemy_weights(level: int) -> list[tuple[str, float]]: + if level <= 2: + return [("goblin", 0.8), ("orc", 0.95), ("troll", 1.0)] + elif level <= 4: + return [("goblin", 0.5), ("orc", 0.85), ("troll", 1.0)] + else: + return [("goblin", 0.3), ("orc", 0.6), ("troll", 1.0)] + +def get_item_weights(level: int) -> list[tuple[str, float]]: + if level <= 2: + return [("health_potion", 1.0)] + else: + return [("health_potion", 0.7), ("greater_health_potion", 1.0)] + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + """A message log that displays recent game messages with colors.""" + + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption( + pos=(x + 10, y + 5 + i * line_height), + text="" + ) + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + if color is None: + color = mcrfpy.Color(200, 200, 200) + + self.messages.append((text, color)) + + while len(self.messages) > self.max_messages: + self.messages.pop(0) + + self._refresh() + + def _refresh(self) -> None: + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + """A visual health bar using nested frames.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_hp = 30 + self.current_hp = 30 + + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(width - 4, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption( + pos=(x + 5, y + 2), + text=f"HP: {self.current_hp}/{self.max_hp}" + ) + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + self.current_hp = current_hp + self.max_hp = max_hp + + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + self.label.text = f"HP: {current_hp}/{max_hp}" + + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) + +# ============================================================================= +# XP Bar System +# ============================================================================= + +class XPBar: + """A visual XP bar showing progress to next level.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(40, 40, 80) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(100, 100, 150) + + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(0, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(200, 200, 50) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption( + pos=(x + 5, y + 1), + text="Level 1 | XP: 0/100" + ) + self.label.font_size = 14 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, level: int, xp: int, xp_to_next: int) -> None: + """Update the XP bar display. + + Args: + level: Current player level + xp: Current XP + xp_to_next: XP needed to reach next level + """ + percent = xp / xp_to_next if xp_to_next > 0 else 0 + percent = min(1.0, max(0.0, percent)) + + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + self.label.text = f"Level {level} | XP: {xp}/{xp_to_next}" + +# ============================================================================= +# Inventory Panel +# ============================================================================= + +class InventoryPanel: + """A panel displaying the player's inventory.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption( + pos=(x + 10, y + 5), + text="Inventory (G:pickup, 1-5:use)" + ) + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + for i in range(5): + caption = mcrfpy.Caption( + pos=(x + 10, y + 25 + i * 18), + text="" + ) + caption.font_size = 13 + caption.fill_color = mcrfpy.Color(180, 180, 180) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + for caption in self.captions: + scene.children.append(caption) + + def update(self, inventory: Inventory) -> None: + for i, caption in enumerate(self.captions): + if i < len(inventory.items): + item = inventory.items[i] + caption.text = f"{i+1}. {item.name}" + caption.fill_color = mcrfpy.Color(180, 180, 180) + else: + caption.text = f"{i+1}. ---" + caption.fill_color = mcrfpy.Color(80, 80, 80) + +# ============================================================================= +# Stats Panel +# ============================================================================= + +class StatsPanel: + """A panel displaying player stats.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption( + pos=(x + 10, y + 5), + text="Stats" + ) + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + self.attack_label = mcrfpy.Caption( + pos=(x + 10, y + 25), + text="Attack: 5" + ) + self.attack_label.font_size = 13 + self.attack_label.fill_color = mcrfpy.Color(255, 150, 150) + + self.defense_label = mcrfpy.Caption( + pos=(x + 10, y + 43), + text="Defense: 2" + ) + self.defense_label.font_size = 13 + self.defense_label.fill_color = mcrfpy.Color(150, 150, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + scene.children.append(self.attack_label) + scene.children.append(self.defense_label) + + def update(self, attack: int, defense: int) -> None: + self.attack_label.text = f"Attack: {attack}" + self.defense_label.text = f"Defense: {defense}" + +# ============================================================================= +# Level Display +# ============================================================================= + +class LevelDisplay: + """Displays current dungeon level.""" + + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption( + pos=(x, y), + text="Dungeon Level: 1" + ) + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(200, 200, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, level: int) -> None: + self.caption.text = f"Dungeon: {level}" + +# ============================================================================= +# Mode Display +# ============================================================================= + +class ModeDisplay: + """Displays the current game mode.""" + + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption( + pos=(x, y), + text="[NORMAL MODE]" + ) + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, mode: GameMode) -> None: + if mode == GameMode.NORMAL: + self.caption.text = "[NORMAL] F:Ranged | >:Descend" + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + elif mode == GameMode.TARGETING: + self.caption.text = "[TARGETING] Arrows:Move, Enter:Fire, Esc:Cancel" + self.caption.fill_color = mcrfpy.Color(255, 255, 100) + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +item_data: dict[mcrfpy.Entity, Item] = {} + +player: Optional[mcrfpy.Entity] = None +player_inventory: Optional[Inventory] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 +stairs_position: tuple[int, int] = (0, 0) + +game_mode: GameMode = GameMode.NORMAL +target_cursor: Optional[mcrfpy.Entity] = None +target_x: int = 0 +target_y: int = 0 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +xp_bar: Optional[XPBar] = None +inventory_panel: Optional[InventoryPanel] = None +stats_panel: Optional[StatsPanel] = None +mode_display: Optional[ModeDisplay] = None +level_display: Optional[LevelDisplay] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return center_x, center_y + + @property + def inner(self) -> tuple[slice, slice]: + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +def place_stairs(target_grid: mcrfpy.Grid, x: int, y: int) -> None: + global stairs_position + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_STAIRS_DOWN + cell.walkable = True + cell.transparent = True + stairs_position = (x, y) + +def generate_dungeon(target_grid: mcrfpy.Grid, level: int) -> tuple[int, int]: + global stairs_position + fill_with_walls(target_grid) + + rooms: list[RectangularRoom] = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(target_grid, new_room) + + if rooms: + carve_l_tunnel(target_grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + if rooms: + stairs_x, stairs_y = rooms[-1].center + place_stairs(target_grid, stairs_x, stairs_y) + + if rooms: + return rooms[0].center + else: + return GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ITEM_TEMPLATES[item_type] + + item_entity = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + item_entity.visible = False + + target_grid.entities.append(item_entity) + + item_data[item_entity] = Item( + name=template["name"], + item_type=template["item_type"], + heal_amount=template.get("heal_amount", 0) + ) + + return item_entity + +def spawn_entities_for_level(target_grid: mcrfpy.Grid, tex: mcrfpy.Texture, level: int) -> None: + floor_tiles = [] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + if cell.walkable and cell.tilesprite == SPRITE_FLOOR: + floor_tiles.append((x, y)) + + max_enemies = get_max_enemies_per_room(level) * 3 + enemy_weights = get_enemy_weights(level) + + for _ in range(max_enemies): + if not floor_tiles: + break + + x, y = random.choice(floor_tiles) + + if (x, y) == (int(player.x), int(player.y)): + continue + if (x, y) == stairs_position: + continue + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + enemy_type = "goblin" + for etype, threshold in enemy_weights: + if roll < threshold: + enemy_type = etype + break + + spawn_enemy(target_grid, x, y, enemy_type, tex) + + max_items = get_max_items_per_room(level) * 2 + item_weights = get_item_weights(level) + + for _ in range(max_items): + if not floor_tiles: + break + + x, y = random.choice(floor_tiles) + + if (x, y) == (int(player.x), int(player.y)): + continue + if (x, y) == stairs_position: + continue + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + item_type = "health_potion" + for itype, threshold in item_weights: + if roll < threshold: + item_type = itype + break + + spawn_item(target_grid, x, y, item_type, tex) + +def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return True + return False + +def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity in item_data: + if int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in item_data: + del item_data[entity] + +def clear_entities_except_player(target_grid: mcrfpy.Grid) -> None: + global entity_data, item_data + + entities_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and entity_data[entity].is_player: + continue + entities_to_remove.append(entity) + + for entity in entities_to_remove: + if entity in entity_data: + del entity_data[entity] + if entity in item_data: + del item_data[entity] + + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + +# ============================================================================= +# XP and Level Up +# ============================================================================= + +def award_xp(enemy_name: str) -> None: + """Award XP for killing an enemy. + + Args: + enemy_name: Name of the defeated enemy + """ + global player + + fighter = entity_data.get(player) + if fighter is None: + return + + enemy_type = enemy_name.lower() + xp_amount = ENEMY_XP_VALUES.get(enemy_type, 35) + + leveled_up = fighter.gain_xp(xp_amount) + + if leveled_up: + message_log.add( + f"You gained {xp_amount} XP and reached level {fighter.level}!", + COLOR_LEVEL_UP + ) + message_log.add( + f"HP +5, Attack +1{', Defense +1' if fighter.level % 3 == 0 else ''}!", + COLOR_LEVEL_UP + ) + else: + message_log.add(f"You gain {xp_amount} XP.", COLOR_XP) + + update_ui() + +# ============================================================================= +# Level Transition +# ============================================================================= + +def descend_stairs() -> bool: + global player, dungeon_level, grid, fov_layer, stairs_position + + px, py = int(player.x), int(player.y) + + if (px, py) != stairs_position: + message_log.add("There are no stairs here.", COLOR_INVALID) + return False + + dungeon_level += 1 + + clear_entities_except_player(grid) + + init_explored() + player_start = generate_dungeon(grid, dungeon_level) + + player.x = player_start[0] + player.y = player_start[1] + + spawn_entities_for_level(grid, texture, dungeon_level) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, player_start[0], player_start[1]) + + message_log.add(f"You descend to level {dungeon_level}...", COLOR_DESCEND) + level_display.update(dungeon_level) + update_ui() + + return True + +# ============================================================================= +# Save/Load System +# ============================================================================= + +def save_game() -> bool: + global player, player_inventory, grid, explored, dungeon_level, stairs_position + + try: + tiles = [] + for y in range(GRID_HEIGHT): + row = [] + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + row.append({ + "tilesprite": cell.tilesprite, + "walkable": cell.walkable, + "transparent": cell.transparent + }) + tiles.append(row) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data: + fighter = entity_data[entity] + enemies.append({ + "x": int(entity.x), + "y": int(entity.y), + "type": fighter.name.lower(), + "fighter": fighter.to_dict() + }) + + items_on_ground = [] + for entity in grid.entities: + if entity in item_data: + item = item_data[entity] + items_on_ground.append({ + "x": int(entity.x), + "y": int(entity.y), + "item": item.to_dict() + }) + + save_data = { + "version": 3, + "dungeon_level": dungeon_level, + "stairs_position": list(stairs_position), + "player": { + "x": int(player.x), + "y": int(player.y), + "fighter": entity_data[player].to_dict(), + "inventory": player_inventory.to_dict() + }, + "tiles": tiles, + "explored": [[explored[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)], + "enemies": enemies, + "items": items_on_ground + } + + with open(SAVE_FILE, "w") as f: + json.dump(save_data, f, indent=2) + + message_log.add("Game saved successfully!", COLOR_SAVE) + return True + + except Exception as e: + message_log.add(f"Failed to save: {str(e)}", COLOR_INVALID) + return False + +def load_game() -> bool: + global player, player_inventory, grid, explored, dungeon_level + global entity_data, item_data, fov_layer, game_over, stairs_position + + if not os.path.exists(SAVE_FILE): + return False + + try: + with open(SAVE_FILE, "r") as f: + save_data = json.load(f) + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + dungeon_level = save_data.get("dungeon_level", 1) + stairs_position = tuple(save_data.get("stairs_position", [0, 0])) + + tiles = save_data["tiles"] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + tile_data = tiles[y][x] + cell.tilesprite = tile_data["tilesprite"] + cell.walkable = tile_data["walkable"] + cell.transparent = tile_data["transparent"] + + global explored + explored_data = save_data["explored"] + explored = [[explored_data[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)] + + player_data = save_data["player"] + player = mcrfpy.Entity( + grid_pos=(player_data["x"], player_data["y"]), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter.from_dict(player_data["fighter"]) + player_inventory = Inventory.from_dict(player_data["inventory"]) + + for enemy_data in save_data.get("enemies", []): + enemy_type = enemy_data["type"] + template = ENEMY_TEMPLATES.get(enemy_type, ENEMY_TEMPLATES["goblin"]) + + enemy = mcrfpy.Entity( + grid_pos=(enemy_data["x"], enemy_data["y"]), + texture=texture, + sprite_index=template["sprite"] + ) + enemy.visible = False + + grid.entities.append(enemy) + entity_data[enemy] = Fighter.from_dict(enemy_data["fighter"]) + + for item_entry in save_data.get("items", []): + item_type = item_entry["item"]["item_type"] + template = ITEM_TEMPLATES.get(item_type, ITEM_TEMPLATES["health_potion"]) + + item_entity = mcrfpy.Entity( + grid_pos=(item_entry["x"], item_entry["y"]), + texture=texture, + sprite_index=template["sprite"] + ) + item_entity.visible = False + + grid.entities.append(item_entity) + item_data[item_entity] = Item.from_dict(item_entry["item"]) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, int(player.x), int(player.y)) + + game_over = False + + message_log.add("Game loaded successfully!", COLOR_SAVE) + level_display.update(dungeon_level) + update_ui() + return True + + except Exception as e: + message_log.add(f"Failed to load: {str(e)}", COLOR_INVALID) + return False + +def delete_save() -> bool: + try: + if os.path.exists(SAVE_FILE): + os.remove(SAVE_FILE) + return True + except Exception: + return False + +def has_save_file() -> bool: + return os.path.exists(SAVE_FILE) + +# ============================================================================= +# Targeting System +# ============================================================================= + +def enter_targeting_mode() -> None: + global game_mode, target_cursor, target_x, target_y, player, grid, texture + + target_x = int(player.x) + target_y = int(player.y) + + target_cursor = mcrfpy.Entity( + grid_pos=(target_x, target_y), + texture=texture, + sprite_index=SPRITE_CURSOR + ) + grid.entities.append(target_cursor) + + game_mode = GameMode.TARGETING + + message_log.add("Targeting mode: Arrows to aim, Enter to fire, Esc to cancel.", COLOR_INFO) + mode_display.update(game_mode) + +def exit_targeting_mode() -> None: + global game_mode, target_cursor, grid + + if target_cursor is not None: + for i, e in enumerate(grid.entities): + if e == target_cursor: + grid.entities.remove(i) + break + target_cursor = None + + game_mode = GameMode.NORMAL + mode_display.update(game_mode) + +def move_cursor(dx: int, dy: int) -> None: + global target_x, target_y, target_cursor, grid, player + + new_x = target_x + dx + new_y = target_y + dy + + if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT: + return + + if not grid.is_in_fov(new_x, new_y): + message_log.add("You cannot see that location.", COLOR_INVALID) + return + + player_x, player_y = int(player.x), int(player.y) + distance = abs(new_x - player_x) + abs(new_y - player_y) + if distance > RANGED_ATTACK_RANGE: + message_log.add(f"Target is out of range (max {RANGED_ATTACK_RANGE}).", COLOR_WARNING) + return + + target_x = new_x + target_y = new_y + target_cursor.x = target_x + target_cursor.y = target_y + + enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + if enemy and enemy in entity_data: + fighter = entity_data[enemy] + message_log.add(f"Target: {fighter.name} (HP: {fighter.hp}/{fighter.max_hp})", COLOR_RANGED) + +def confirm_target() -> None: + global game_mode, target_x, target_y, player, grid + + if target_x == int(player.x) and target_y == int(player.y): + message_log.add("You cannot target yourself!", COLOR_INVALID) + return + + target_enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if target_enemy is None or target_enemy not in entity_data: + message_log.add("No valid target at that location.", COLOR_INVALID) + return + + perform_ranged_attack(target_enemy) + exit_targeting_mode() + enemy_turn() + update_ui() + +def perform_ranged_attack(target_entity: mcrfpy.Entity) -> None: + global player, game_over + + defender = entity_data.get(target_entity) + attacker = entity_data.get(player) + + if defender is None or attacker is None: + return + + damage = max(1, RANGED_ATTACK_DAMAGE - defender.defense // 2) + + defender.take_damage(damage) + + message_log.add( + f"Your ranged attack hits the {defender.name} for {damage} damage!", + COLOR_RANGED + ) + + if not defender.is_alive: + handle_death(target_entity, defender) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} for {damage} damage!", + COLOR_PLAYER_ATTACK + ) + else: + message_log.add( + f"The {attacker.name} hits you for {damage} damage!", + COLOR_ENEMY_ATTACK + ) + else: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + message_log.add( + f"The {attacker.name} hits but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + if not defender.is_alive: + handle_death(defender_entity, defender) + + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over, grid + + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart or Escape to quit.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + delete_save() + else: + message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH) + # Award XP before removing the entity + award_xp(fighter.name) + remove_entity(grid, entity) + +# ============================================================================= +# Item Actions +# ============================================================================= + +def pickup_item() -> bool: + global player, player_inventory, grid + + px, py = int(player.x), int(player.y) + item_entity = get_item_at(grid, px, py) + + if item_entity is None: + message_log.add("There is nothing to pick up here.", COLOR_INVALID) + return False + + if player_inventory.is_full(): + message_log.add("Your inventory is full!", COLOR_WARNING) + return False + + item = item_data.get(item_entity) + if item is None: + return False + + player_inventory.add(item) + remove_item_entity(grid, item_entity) + + message_log.add(f"You pick up the {item.name}.", COLOR_PICKUP) + + update_ui() + return True + +def use_item(index: int) -> bool: + global player, player_inventory + + item = player_inventory.get(index) + if item is None: + message_log.add("Invalid item selection.", COLOR_INVALID) + return False + + if item.item_type == "health_potion": + fighter = entity_data.get(player) + if fighter is None: + return False + + if fighter.hp >= fighter.max_hp: + message_log.add("You are already at full health!", COLOR_WARNING) + return False + + actual_heal = fighter.heal(item.heal_amount) + player_inventory.remove(index) + + message_log.add(f"You drink the {item.name} and recover {actual_heal} HP!", COLOR_HEAL) + + update_ui() + return True + + message_log.add(f"You cannot use the {item.name}.", COLOR_INVALID) + return False + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + global player, target_cursor + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + if entity == target_cursor: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + new_target_x = px + dx + new_target_y = py + dy + + if new_target_x < 0 or new_target_x >= GRID_WIDTH or new_target_y < 0 or new_target_y >= GRID_HEIGHT: + return + + blocker = get_blocking_entity_at(grid, new_target_x, new_target_y, exclude=player) + + if blocker is not None: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(new_target_x, new_target_y).walkable: + player.x = new_target_x + player.y = new_target_y + update_fov(grid, fov_layer, new_target_x, new_target_y) + enemy_turn() + + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + if not grid.is_in_fov(ex, ey): + continue + + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + perform_attack(enemy, player) + else: + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + global grid + + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + global player, health_bar, xp_bar, inventory_panel, stats_panel, player_inventory + + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + xp_bar.update(fighter.level, fighter.xp, fighter.xp_to_next_level) + stats_panel.update(fighter.attack, fighter.defense) + + if player_inventory: + inventory_panel.update(player_inventory) + +# ============================================================================= +# New Game Generation +# ============================================================================= + +def generate_new_game() -> None: + global player, player_inventory, grid, fov_layer, game_over + global entity_data, item_data, dungeon_level, game_mode + + game_over = False + game_mode = GameMode.NORMAL + dungeon_level = 1 + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + init_explored() + message_log.clear() + + player_start = generate_dungeon(grid, dungeon_level) + + player = mcrfpy.Entity( + grid_pos=(player_start[0], player_start[1]), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True, + xp=0, + level=1 + ) + + player_inventory = Inventory(capacity=10) + + spawn_entities_for_level(grid, texture, dungeon_level) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, player_start[0], player_start[1]) + + mode_display.update(game_mode) + level_display.update(dungeon_level) + update_ui() + +# ============================================================================= +# Game Setup +# ============================================================================= + +scene = mcrfpy.Scene("game") +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(700, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +scene.children.append(grid) + +# ============================================================================= +# Create UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(20, 10), + text="Part 12: Experience System" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(300, 15), + text="WASD:Move | >:Descend | F:Ranged | G:Pickup" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +health_bar = HealthBar(x=730, y=10, width=280, height=25) +health_bar.add_to_scene(scene) + +xp_bar = XPBar(x=730, y=40, width=280, height=20) +xp_bar.add_to_scene(scene) + +level_display = LevelDisplay(x=860, y=65) +level_display.add_to_scene(scene) + +mode_display = ModeDisplay(x=20, y=40) +mode_display.add_to_scene(scene) + +stats_panel = StatsPanel(x=730, y=GAME_AREA_Y, width=140, height=70) +stats_panel.add_to_scene(scene) + +inventory_panel = InventoryPanel(x=730, y=GAME_AREA_Y + 75, width=280, height=120) +inventory_panel.add_to_scene(scene) + +message_log = MessageLog( + x=20, + y=768 - UI_BOTTOM_HEIGHT + 10, + width=990, + height=UI_BOTTOM_HEIGHT - 20, + max_messages=6 +) +message_log.add_to_scene(scene) + +# ============================================================================= +# Initialize Game +# ============================================================================= + +init_explored() + +if has_save_file(): + message_log.add("Found saved game. Loading...", COLOR_INFO) + if not load_game(): + message_log.add("Failed to load. Starting new game.", COLOR_WARNING) + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) +else: + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) + message_log.add("Kill enemies to gain XP and level up!", COLOR_INFO) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def handle_keys(key: str, action: str) -> None: + global game_over, game_mode + + if action != "start": + return + + if key == "R": + delete_save() + generate_new_game() + message_log.add("A new adventure begins!", COLOR_INFO) + return + + if key == "Escape": + if game_mode == GameMode.TARGETING: + exit_targeting_mode() + message_log.add("Targeting cancelled.", COLOR_INFO) + return + else: + if not game_over: + save_game() + mcrfpy.exit() + return + + if key == "Period" and game_mode == GameMode.NORMAL and not game_over: + # Check for shift to descend + descend_stairs() + return + + if game_over: + return + + if game_mode == GameMode.TARGETING: + handle_targeting_input(key) + else: + handle_normal_input(key) + +def handle_normal_input(key: str) -> None: + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + elif key == "F": + enter_targeting_mode() + elif key == "G" or key == ",": + pickup_item() + elif key in ["1", "2", "3", "4", "5"]: + index = int(key) - 1 + if use_item(index): + enemy_turn() + update_ui() + +def handle_targeting_input(key: str) -> None: + if key == "Up" or key == "W": + move_cursor(0, -1) + elif key == "Down" or key == "S": + move_cursor(0, 1) + elif key == "Left" or key == "A": + move_cursor(-1, 0) + elif key == "Right" or key == "D": + move_cursor(1, 0) + elif key == "Return" or key == "Space": + confirm_target() + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 12 loaded! Kill enemies to gain XP and level up!") \ No newline at end of file diff --git a/docs/tutorials/part_13_equipment/part_13_equipment.py b/docs/tutorials/part_13_equipment/part_13_equipment.py new file mode 100644 index 0000000..725f20b --- /dev/null +++ b/docs/tutorials/part_13_equipment/part_13_equipment.py @@ -0,0 +1,1798 @@ +"""McRogueFace - Part 13: Equipment System + +Documentation: https://mcrogueface.github.io/tutorial/part_13_equipment +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_13_equipment/part_13_equipment.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy +import random +import json +import os +from dataclasses import dataclass, field +from typing import Optional +from enum import Enum + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains +SPRITE_POTION = 173 # Potion sprite +SPRITE_CURSOR = 88 # 'X' - targeting cursor +SPRITE_STAIRS_DOWN = 62 # '>' - stairs down +SPRITE_SWORD = 47 # '/' - weapon +SPRITE_ARMOR = 91 # '[' - armor + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# FOV and targeting settings +FOV_RADIUS = 8 +RANGED_ATTACK_RANGE = 6 +RANGED_ATTACK_DAMAGE = 4 + +# Save file location +SAVE_FILE = "savegame.json" + +# XP values for enemies +ENEMY_XP_VALUES = { + "goblin": 35, + "orc": 50, + "troll": 100 +} + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_PICKUP = mcrfpy.Color(100, 200, 255) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) +COLOR_INVALID = mcrfpy.Color(255, 100, 100) +COLOR_RANGED = mcrfpy.Color(255, 255, 100) +COLOR_SAVE = mcrfpy.Color(100, 255, 200) +COLOR_DESCEND = mcrfpy.Color(200, 200, 255) +COLOR_LEVEL_UP = mcrfpy.Color(255, 255, 100) +COLOR_XP = mcrfpy.Color(200, 200, 100) +COLOR_EQUIP = mcrfpy.Color(150, 200, 255) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Game Modes +# ============================================================================= + +class GameMode(Enum): + NORMAL = "normal" + TARGETING = "targeting" + +# ============================================================================= +# Equipment Component +# ============================================================================= + +@dataclass +class Equipment: + """An equippable item that provides stat bonuses.""" + name: str + slot: str # "weapon" or "armor" + attack_bonus: int = 0 + defense_bonus: int = 0 + + def to_dict(self) -> dict: + return { + "name": self.name, + "slot": self.slot, + "attack_bonus": self.attack_bonus, + "defense_bonus": self.defense_bonus + } + + @classmethod + def from_dict(cls, data: dict) -> "Equipment": + return cls( + name=data["name"], + slot=data["slot"], + attack_bonus=data.get("attack_bonus", 0), + defense_bonus=data.get("defense_bonus", 0) + ) + +# ============================================================================= +# Fighter Component with Equipment +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity with experience and equipment.""" + hp: int + max_hp: int + base_attack: int + base_defense: int + name: str + is_player: bool = False + xp: int = 0 + level: int = 1 + weapon: Optional[Equipment] = None + armor: Optional[Equipment] = None + + @property + def attack(self) -> int: + """Total attack including equipment bonus.""" + bonus = self.weapon.attack_bonus if self.weapon else 0 + return self.base_attack + bonus + + @property + def defense(self) -> int: + """Total defense including equipment bonus.""" + bonus = self.armor.defense_bonus if self.armor else 0 + return self.base_defense + bonus + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + @property + def xp_to_next_level(self) -> int: + return self.level * 100 + + @property + def xp_progress(self) -> float: + return self.xp / self.xp_to_next_level if self.xp_to_next_level > 0 else 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + + def gain_xp(self, amount: int) -> bool: + self.xp += amount + if self.xp >= self.xp_to_next_level: + self.level_up() + return True + return False + + def level_up(self) -> None: + self.xp -= self.xp_to_next_level + self.level += 1 + hp_increase = 5 + attack_increase = 1 + defense_increase = 1 if self.level % 3 == 0 else 0 + self.max_hp += hp_increase + self.hp = self.max_hp + self.base_attack += attack_increase + self.base_defense += defense_increase + + def equip(self, equipment: Equipment) -> Optional[Equipment]: + """Equip an item, returning any previously equipped item. + + Args: + equipment: The equipment to equip + + Returns: + Previously equipped item in that slot, or None + """ + old_equipment = None + + if equipment.slot == "weapon": + old_equipment = self.weapon + self.weapon = equipment + elif equipment.slot == "armor": + old_equipment = self.armor + self.armor = equipment + + return old_equipment + + def unequip(self, slot: str) -> Optional[Equipment]: + """Unequip an item from a slot. + + Args: + slot: "weapon" or "armor" + + Returns: + The unequipped item, or None if slot was empty + """ + if slot == "weapon": + item = self.weapon + self.weapon = None + return item + elif slot == "armor": + item = self.armor + self.armor = None + return item + return None + + def to_dict(self) -> dict: + return { + "hp": self.hp, + "max_hp": self.max_hp, + "base_attack": self.base_attack, + "base_defense": self.base_defense, + "name": self.name, + "is_player": self.is_player, + "xp": self.xp, + "level": self.level, + "weapon": self.weapon.to_dict() if self.weapon else None, + "armor": self.armor.to_dict() if self.armor else None + } + + @classmethod + def from_dict(cls, data: dict) -> "Fighter": + fighter = cls( + hp=data["hp"], + max_hp=data["max_hp"], + base_attack=data.get("base_attack", data.get("attack", 5)), + base_defense=data.get("base_defense", data.get("defense", 2)), + name=data["name"], + is_player=data.get("is_player", False), + xp=data.get("xp", 0), + level=data.get("level", 1) + ) + if data.get("weapon"): + fighter.weapon = Equipment.from_dict(data["weapon"]) + if data.get("armor"): + fighter.armor = Equipment.from_dict(data["armor"]) + return fighter + +# ============================================================================= +# Item Component +# ============================================================================= + +@dataclass +class Item: + """Data for an item that can be picked up and used.""" + name: str + item_type: str + heal_amount: int = 0 + equipment: Optional[Equipment] = None + + def to_dict(self) -> dict: + result = { + "name": self.name, + "item_type": self.item_type, + "heal_amount": self.heal_amount + } + if self.equipment: + result["equipment"] = self.equipment.to_dict() + return result + + @classmethod + def from_dict(cls, data: dict) -> "Item": + item = cls( + name=data["name"], + item_type=data["item_type"], + heal_amount=data.get("heal_amount", 0) + ) + if data.get("equipment"): + item.equipment = Equipment.from_dict(data["equipment"]) + return item + +# ============================================================================= +# Inventory System +# ============================================================================= + +@dataclass +class Inventory: + """Container for items the player is carrying.""" + capacity: int = 10 + items: list = field(default_factory=list) + + def add(self, item: Item) -> bool: + if len(self.items) >= self.capacity: + return False + self.items.append(item) + return True + + def remove(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items.pop(index) + return None + + def get(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items[index] + return None + + def is_full(self) -> bool: + return len(self.items) >= self.capacity + + def count(self) -> int: + return len(self.items) + + def to_dict(self) -> dict: + return { + "capacity": self.capacity, + "items": [item.to_dict() for item in self.items] + } + + @classmethod + def from_dict(cls, data: dict) -> "Inventory": + inv = cls(capacity=data.get("capacity", 10)) + inv.items = [Item.from_dict(item_data) for item_data in data.get("items", [])] + return inv + +# ============================================================================= +# Templates +# ============================================================================= + +ITEM_TEMPLATES = { + "health_potion": { + "name": "Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 10 + }, + "greater_health_potion": { + "name": "Greater Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 20 + }, + # Weapons + "dagger": { + "name": "Dagger", + "sprite": SPRITE_SWORD, + + "item_type": "equipment", + "slot": "weapon", + "attack_bonus": 2, + "defense_bonus": 0 + }, + "sword": { + "name": "Sword", + "sprite": SPRITE_SWORD, + + "item_type": "equipment", + "slot": "weapon", + "attack_bonus": 4, + "defense_bonus": 0 + }, + "great_axe": { + "name": "Great Axe", + "sprite": SPRITE_SWORD, + + "item_type": "equipment", + "slot": "weapon", + "attack_bonus": 6, + "defense_bonus": -1 + }, + # Armor + "leather_armor": { + "name": "Leather Armor", + "sprite": SPRITE_ARMOR, + + "item_type": "equipment", + "slot": "armor", + "attack_bonus": 0, + "defense_bonus": 2 + }, + "chain_mail": { + "name": "Chain Mail", + "sprite": SPRITE_ARMOR, + + "item_type": "equipment", + "slot": "armor", + "attack_bonus": 0, + "defense_bonus": 4 + }, + "plate_armor": { + "name": "Plate Armor", + "sprite": SPRITE_ARMOR, + + "item_type": "equipment", + "slot": "armor", + "attack_bonus": -1, + "defense_bonus": 6 + } +} + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Difficulty Scaling with Equipment +# ============================================================================= + +def get_max_enemies_per_room(level: int) -> int: + return min(2 + level, 6) + +def get_max_items_per_room(level: int) -> int: + return min(1 + level // 2, 4) + +def get_enemy_weights(level: int) -> list[tuple[str, float]]: + if level <= 2: + return [("goblin", 0.8), ("orc", 0.95), ("troll", 1.0)] + elif level <= 4: + return [("goblin", 0.5), ("orc", 0.85), ("troll", 1.0)] + else: + return [("goblin", 0.3), ("orc", 0.6), ("troll", 1.0)] + +def get_item_weights(level: int) -> list[tuple[str, float]]: + """Get item spawn weights based on dungeon level.""" + if level <= 1: + return [ + ("health_potion", 0.7), + ("dagger", 0.85), + ("leather_armor", 1.0) + ] + elif level <= 3: + return [ + ("health_potion", 0.4), + ("greater_health_potion", 0.55), + ("dagger", 0.65), + ("sword", 0.75), + ("leather_armor", 0.85), + ("chain_mail", 1.0) + ] + else: + return [ + ("health_potion", 0.2), + ("greater_health_potion", 0.4), + ("sword", 0.55), + ("great_axe", 0.7), + ("chain_mail", 0.85), + ("plate_armor", 1.0) + ] + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame(pos=(x, y), size=(width, height)) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption(pos=(x + 10, y + 5 + i * line_height), text="") + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + if color is None: + color = mcrfpy.Color(200, 200, 200) + self.messages.append((text, color)) + while len(self.messages) > self.max_messages: + self.messages.pop(0) + self._refresh() + + def _refresh(self) -> None: + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + def __init__(self, x: int, y: int, width: int, height: int): + self.x, self.y = x, y + self.width, self.height = width, height + + self.bg_frame = mcrfpy.Frame(pos=(x, y), size=(width, height)) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + self.fg_frame = mcrfpy.Frame(pos=(x + 2, y + 2), size=(width - 4, height - 4)) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption(pos=(x + 5, y + 2), text="HP: 30/30") + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + self.fg_frame.resize(int((self.width - 4) * percent), self.height - 4) + self.label.text = f"HP: {current_hp}/{max_hp}" + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) + +# ============================================================================= +# XP Bar System +# ============================================================================= + +class XPBar: + def __init__(self, x: int, y: int, width: int, height: int): + self.x, self.y = x, y + self.width, self.height = width, height + + self.bg_frame = mcrfpy.Frame(pos=(x, y), size=(width, height)) + self.bg_frame.fill_color = mcrfpy.Color(40, 40, 80) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(100, 100, 150) + + self.fg_frame = mcrfpy.Frame(pos=(x + 2, y + 2), size=(0, height - 4)) + self.fg_frame.fill_color = mcrfpy.Color(200, 200, 50) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption(pos=(x + 5, y + 1), text="Level 1 | XP: 0/100") + self.label.font_size = 14 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, level: int, xp: int, xp_to_next: int) -> None: + percent = min(1.0, max(0.0, xp / xp_to_next if xp_to_next > 0 else 0)) + self.fg_frame.resize(int((self.width - 4) * percent), self.height - 4) + self.label.text = f"Level {level} | XP: {xp}/{xp_to_next}" + +# ============================================================================= +# Equipment Panel +# ============================================================================= + +class EquipmentPanel: + """Panel showing equipped items.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x, self.y = x, y + self.width, self.height = width, height + + self.frame = mcrfpy.Frame(pos=(x, y), size=(width, height)) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption(pos=(x + 10, y + 5), text="Equipment") + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + self.weapon_label = mcrfpy.Caption(pos=(x + 10, y + 25), text="Weapon: None") + self.weapon_label.font_size = 13 + self.weapon_label.fill_color = mcrfpy.Color(255, 150, 150) + + self.armor_label = mcrfpy.Caption(pos=(x + 10, y + 43), text="Armor: None") + self.armor_label.font_size = 13 + self.armor_label.fill_color = mcrfpy.Color(150, 150, 255) + + self.stats_label = mcrfpy.Caption(pos=(x + 10, y + 61), text="ATK: 5 | DEF: 2") + self.stats_label.font_size = 12 + self.stats_label.fill_color = mcrfpy.Color(200, 200, 200) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + scene.children.append(self.weapon_label) + scene.children.append(self.armor_label) + scene.children.append(self.stats_label) + + def update(self, fighter: "Fighter") -> None: + if fighter.weapon: + weapon_text = f"Weapon: {fighter.weapon.name} (+{fighter.weapon.attack_bonus} ATK)" + else: + weapon_text = "Weapon: None" + self.weapon_label.text = weapon_text + + if fighter.armor: + armor_text = f"Armor: {fighter.armor.name} (+{fighter.armor.defense_bonus} DEF)" + else: + armor_text = "Armor: None" + self.armor_label.text = armor_text + + self.stats_label.text = f"Total ATK: {fighter.attack} | DEF: {fighter.defense}" + +# ============================================================================= +# Inventory Panel +# ============================================================================= + +class InventoryPanel: + def __init__(self, x: int, y: int, width: int, height: int): + self.x, self.y = x, y + self.width, self.height = width, height + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame(pos=(x, y), size=(width, height)) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption(pos=(x + 10, y + 5), text="Inventory (G:get E:equip)") + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + for i in range(5): + caption = mcrfpy.Caption(pos=(x + 10, y + 25 + i * 18), text="") + caption.font_size = 13 + caption.fill_color = mcrfpy.Color(180, 180, 180) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + for caption in self.captions: + scene.children.append(caption) + + def update(self, inventory: Inventory) -> None: + for i, caption in enumerate(self.captions): + if i < len(inventory.items): + item = inventory.items[i] + # Show item type indicator + if item.item_type == "equipment" and item.equipment: + if item.equipment.slot == "weapon": + caption.text = f"{i+1}. {item.name} [W]" + caption.fill_color = mcrfpy.Color(255, 150, 150) + else: + caption.text = f"{i+1}. {item.name} [A]" + caption.fill_color = mcrfpy.Color(150, 150, 255) + else: + caption.text = f"{i+1}. {item.name}" + caption.fill_color = mcrfpy.Color(180, 180, 180) + else: + caption.text = f"{i+1}. ---" + caption.fill_color = mcrfpy.Color(80, 80, 80) + +# ============================================================================= +# Level Display +# ============================================================================= + +class LevelDisplay: + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption(pos=(x, y), text="Dungeon: 1") + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(200, 200, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, level: int) -> None: + self.caption.text = f"Dungeon: {level}" + +# ============================================================================= +# Mode Display +# ============================================================================= + +class ModeDisplay: + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption(pos=(x, y), text="[NORMAL MODE]") + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, mode: GameMode) -> None: + if mode == GameMode.NORMAL: + self.caption.text = "[NORMAL] F:Ranged | >:Descend | E:Equip" + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + elif mode == GameMode.TARGETING: + self.caption.text = "[TARGETING] Arrows:Move, Enter:Fire, Esc:Cancel" + self.caption.fill_color = mcrfpy.Color(255, 255, 100) + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +item_data: dict[mcrfpy.Entity, Item] = {} + +player: Optional[mcrfpy.Entity] = None +player_inventory: Optional[Inventory] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 +stairs_position: tuple[int, int] = (0, 0) + +game_mode: GameMode = GameMode.NORMAL +target_cursor: Optional[mcrfpy.Entity] = None +target_x: int = 0 +target_y: int = 0 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +xp_bar: Optional[XPBar] = None +inventory_panel: Optional[InventoryPanel] = None +equipment_panel: Optional[EquipmentPanel] = None +mode_display: Optional[ModeDisplay] = None +level_display: Optional[LevelDisplay] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + def __init__(self, x: int, y: int, width: int, height: int): + self.x1, self.y1 = x, y + self.x2, self.y2 = x + width, y + height + + @property + def center(self) -> tuple[int, int]: + return (self.x1 + self.x2) // 2, (self.y1 + self.y2) // 2 + + @property + def inner(self) -> tuple[slice, slice]: + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return self.x1 <= other.x2 and self.x2 >= other.x1 and self.y1 <= other.y2 and self.y2 >= other.y1 + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation (abbreviated for space) +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel(target_grid: mcrfpy.Grid, start: tuple[int, int], end: tuple[int, int]) -> None: + x1, y1 = start + x2, y2 = end + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +def place_stairs(target_grid: mcrfpy.Grid, x: int, y: int) -> None: + global stairs_position + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_STAIRS_DOWN + cell.walkable = True + cell.transparent = True + stairs_position = (x, y) + +def generate_dungeon(target_grid: mcrfpy.Grid, level: int) -> tuple[int, int]: + fill_with_walls(target_grid) + rooms: list[RectangularRoom] = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + if any(new_room.intersects(other) for other in rooms): + continue + + carve_room(target_grid, new_room) + if rooms: + carve_l_tunnel(target_grid, new_room.center, rooms[-1].center) + rooms.append(new_room) + + if rooms: + place_stairs(target_grid, *rooms[-1].center) + return rooms[0].center + return GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + enemy = mcrfpy.Entity(grid_pos=(x, y), texture=tex, sprite_index=template["sprite"]) + enemy.visible = False + target_grid.entities.append(enemy) + entity_data[enemy] = Fighter( + hp=template["hp"], max_hp=template["hp"], + base_attack=template["attack"], base_defense=template["defense"], + name=enemy_type.capitalize(), is_player=False + ) + return enemy + +def spawn_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ITEM_TEMPLATES[item_type] + item_entity = mcrfpy.Entity(grid_pos=(x, y), texture=tex, sprite_index=template["sprite"]) + item_entity.visible = False + target_grid.entities.append(item_entity) + + # Create the item + item = Item( + name=template["name"], + item_type=template["item_type"], + heal_amount=template.get("heal_amount", 0) + ) + + # If it is equipment, create the equipment data + if template["item_type"] == "equipment": + item.equipment = Equipment( + name=template["name"], + slot=template["slot"], + attack_bonus=template.get("attack_bonus", 0), + defense_bonus=template.get("defense_bonus", 0) + ) + + item_data[item_entity] = item + return item_entity + +def spawn_entities_for_level(target_grid: mcrfpy.Grid, tex: mcrfpy.Texture, level: int) -> None: + floor_tiles = [] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + if cell.walkable and cell.tilesprite == SPRITE_FLOOR: + floor_tiles.append((x, y)) + + # Spawn enemies + max_enemies = get_max_enemies_per_room(level) * 3 + enemy_weights = get_enemy_weights(level) + + for _ in range(max_enemies): + if not floor_tiles: + break + x, y = random.choice(floor_tiles) + if (x, y) == (int(player.x), int(player.y)) or (x, y) == stairs_position: + continue + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + enemy_type = "goblin" + for etype, threshold in enemy_weights: + if roll < threshold: + enemy_type = etype + break + spawn_enemy(target_grid, x, y, enemy_type, tex) + + # Spawn items + max_items = get_max_items_per_room(level) * 2 + item_weights = get_item_weights(level) + + for _ in range(max_items): + if not floor_tiles: + break + x, y = random.choice(floor_tiles) + if (x, y) == (int(player.x), int(player.y)) or (x, y) == stairs_position: + continue + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + item_type = "health_potion" + for itype, threshold in item_weights: + if roll < threshold: + item_type = itype + break + spawn_item(target_grid, x, y, item_type, tex) + +def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return True + return False + +def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity in item_data and int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in item_data: + del item_data[entity] + +def clear_entities_except_player(target_grid: mcrfpy.Grid) -> None: + entities_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and entity_data[entity].is_player: + continue + entities_to_remove.append(entity) + + for entity in entities_to_remove: + if entity in entity_data: + del entity_data[entity] + if entity in item_data: + del item_data[entity] + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + +# ============================================================================= +# Equipment Actions +# ============================================================================= + +def equip_item(index: int) -> bool: + """Equip an item from inventory. + + Args: + index: Inventory index of item to equip + + Returns: + True if item was equipped, False otherwise + """ + global player, player_inventory + + item = player_inventory.get(index) + if item is None: + message_log.add("Invalid item selection.", COLOR_INVALID) + return False + + if item.item_type != "equipment" or item.equipment is None: + message_log.add(f"The {item.name} cannot be equipped.", COLOR_INVALID) + return False + + fighter = entity_data.get(player) + if fighter is None: + return False + + # Remove from inventory + player_inventory.remove(index) + + # Equip and get old equipment + old_equipment = fighter.equip(item.equipment) + + message_log.add(f"You equip the {item.name}.", COLOR_EQUIP) + + # Add old equipment back to inventory + if old_equipment: + old_item = Item( + name=old_equipment.name, + item_type="equipment", + equipment=old_equipment + ) + if player_inventory.add(old_item): + message_log.add(f"You unequip the {old_equipment.name}.", COLOR_INFO) + else: + # Inventory full - drop on ground + drop_equipment(old_equipment) + message_log.add(f"Inventory full! {old_equipment.name} dropped.", COLOR_WARNING) + + update_ui() + return True + +def drop_equipment(equipment: Equipment) -> None: + """Drop equipment on the ground at player position.""" + global player, grid, texture + + px, py = int(player.x), int(player.y) + + # Find template for this equipment + template = None + for key, tmpl in ITEM_TEMPLATES.items(): + if tmpl["name"] == equipment.name: + template = tmpl + break + + if template is None: + # Use default appearance + template = { + "sprite": SPRITE_SWORD if equipment.slot == "weapon" else SPRITE_ARMOR, + "color": mcrfpy.Color(200, 200, 200) + } + + item_entity = mcrfpy.Entity( + grid_pos=(px, py), + texture=texture, + sprite_index=template["sprite"] + ) + item_entity.visible = True + + grid.entities.append(item_entity) + + item_data[item_entity] = Item( + name=equipment.name, + item_type="equipment", + equipment=equipment + ) + +# ============================================================================= +# XP and Level Up +# ============================================================================= + +def award_xp(enemy_name: str) -> None: + global player + fighter = entity_data.get(player) + if fighter is None: + return + + xp_amount = ENEMY_XP_VALUES.get(enemy_name.lower(), 35) + leveled_up = fighter.gain_xp(xp_amount) + + if leveled_up: + message_log.add( + f"You gained {xp_amount} XP and reached level {fighter.level}!", + COLOR_LEVEL_UP + ) + message_log.add( + f"HP +5, Attack +1{', Defense +1' if fighter.level % 3 == 0 else ''}!", + COLOR_LEVEL_UP + ) + else: + message_log.add(f"You gain {xp_amount} XP.", COLOR_XP) + update_ui() + +# ============================================================================= +# Level Transition +# ============================================================================= + +def descend_stairs() -> bool: + global player, dungeon_level, grid, fov_layer, stairs_position + + px, py = int(player.x), int(player.y) + if (px, py) != stairs_position: + message_log.add("There are no stairs here.", COLOR_INVALID) + return False + + dungeon_level += 1 + clear_entities_except_player(grid) + init_explored() + player_start = generate_dungeon(grid, dungeon_level) + player.x, player.y = player_start + spawn_entities_for_level(grid, texture, dungeon_level) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + update_fov(grid, fov_layer, player_start[0], player_start[1]) + + message_log.add(f"You descend to level {dungeon_level}...", COLOR_DESCEND) + level_display.update(dungeon_level) + update_ui() + return True + +# ============================================================================= +# Save/Load System +# ============================================================================= + +def save_game() -> bool: + global player, player_inventory, grid, explored, dungeon_level, stairs_position + + try: + tiles = [] + for y in range(GRID_HEIGHT): + row = [] + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + row.append({ + "tilesprite": cell.tilesprite, + "walkable": cell.walkable, + "transparent": cell.transparent + }) + tiles.append(row) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data: + enemies.append({ + "x": int(entity.x), "y": int(entity.y), + "type": entity_data[entity].name.lower(), + "fighter": entity_data[entity].to_dict() + }) + + items_on_ground = [] + for entity in grid.entities: + if entity in item_data: + items_on_ground.append({ + "x": int(entity.x), "y": int(entity.y), + "item": item_data[entity].to_dict() + }) + + save_data = { + "version": 4, + "dungeon_level": dungeon_level, + "stairs_position": list(stairs_position), + "player": { + "x": int(player.x), "y": int(player.y), + "fighter": entity_data[player].to_dict(), + "inventory": player_inventory.to_dict() + }, + "tiles": tiles, + "explored": [[explored[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)], + "enemies": enemies, + "items": items_on_ground + } + + with open(SAVE_FILE, "w") as f: + json.dump(save_data, f, indent=2) + + message_log.add("Game saved!", COLOR_SAVE) + return True + except Exception as e: + message_log.add(f"Save failed: {e}", COLOR_INVALID) + return False + +def load_game() -> bool: + global player, player_inventory, grid, explored, dungeon_level + global entity_data, item_data, fov_layer, game_over, stairs_position + + if not os.path.exists(SAVE_FILE): + return False + + try: + with open(SAVE_FILE, "r") as f: + save_data = json.load(f) + + entity_data.clear() + item_data.clear() + while len(grid.entities) > 0: + grid.entities.remove(0) + + dungeon_level = save_data.get("dungeon_level", 1) + stairs_position = tuple(save_data.get("stairs_position", [0, 0])) + + tiles = save_data["tiles"] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + tile_data = tiles[y][x] + cell.tilesprite = tile_data["tilesprite"] + cell.walkable = tile_data["walkable"] + cell.transparent = tile_data["transparent"] + + global explored + explored = save_data["explored"] + + player_data = save_data["player"] + player = mcrfpy.Entity( + grid_pos=(player_data["x"], player_data["y"]), + texture=texture, sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + entity_data[player] = Fighter.from_dict(player_data["fighter"]) + player_inventory = Inventory.from_dict(player_data["inventory"]) + + for enemy_data in save_data.get("enemies", []): + template = ENEMY_TEMPLATES.get(enemy_data["type"], ENEMY_TEMPLATES["goblin"]) + enemy = mcrfpy.Entity( + grid_pos=(enemy_data["x"], enemy_data["y"]), + texture=texture, sprite_index=template["sprite"] + ) + enemy.visible = False + grid.entities.append(enemy) + entity_data[enemy] = Fighter.from_dict(enemy_data["fighter"]) + + for item_entry in save_data.get("items", []): + item = Item.from_dict(item_entry["item"]) + # Find template for sprite + template = None + for key, tmpl in ITEM_TEMPLATES.items(): + if tmpl["name"] == item.name: + template = tmpl + break + if template is None: + template = ITEM_TEMPLATES["health_potion"] + + item_entity = mcrfpy.Entity( + grid_pos=(item_entry["x"], item_entry["y"]), + texture=texture, sprite_index=template["sprite"] + ) + item_entity.visible = False + grid.entities.append(item_entity) + item_data[item_entity] = item + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + update_fov(grid, fov_layer, int(player.x), int(player.y)) + + game_over = False + message_log.add("Game loaded!", COLOR_SAVE) + level_display.update(dungeon_level) + update_ui() + return True + except Exception as e: + message_log.add(f"Load failed: {e}", COLOR_INVALID) + return False + +def delete_save() -> bool: + try: + if os.path.exists(SAVE_FILE): + os.remove(SAVE_FILE) + return True + except: + return False + +def has_save_file() -> bool: + return os.path.exists(SAVE_FILE) + +# ============================================================================= +# Targeting System +# ============================================================================= + +def enter_targeting_mode() -> None: + global game_mode, target_cursor, target_x, target_y + target_x, target_y = int(player.x), int(player.y) + target_cursor = mcrfpy.Entity(grid_pos=(target_x, target_y), texture=texture, sprite_index=SPRITE_CURSOR) + grid.entities.append(target_cursor) + game_mode = GameMode.TARGETING + message_log.add("Targeting: Arrows to aim, Enter to fire, Esc to cancel.", COLOR_INFO) + mode_display.update(game_mode) + +def exit_targeting_mode() -> None: + global game_mode, target_cursor + if target_cursor: + for i, e in enumerate(grid.entities): + if e == target_cursor: + grid.entities.remove(i) + break + target_cursor = None + game_mode = GameMode.NORMAL + mode_display.update(game_mode) + +def move_cursor(dx: int, dy: int) -> None: + global target_x, target_y + new_x, new_y = target_x + dx, target_y + dy + if not (0 <= new_x < GRID_WIDTH and 0 <= new_y < GRID_HEIGHT): + return + if not grid.is_in_fov(new_x, new_y): + message_log.add("Cannot see that location.", COLOR_INVALID) + return + distance = abs(new_x - int(player.x)) + abs(new_y - int(player.y)) + if distance > RANGED_ATTACK_RANGE: + message_log.add(f"Out of range (max {RANGED_ATTACK_RANGE}).", COLOR_WARNING) + return + target_x, target_y = new_x, new_y + target_cursor.x, target_cursor.y = target_x, target_y + enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + if enemy and enemy in entity_data: + f = entity_data[enemy] + message_log.add(f"Target: {f.name} (HP: {f.hp}/{f.max_hp})", COLOR_RANGED) + +def confirm_target() -> None: + if target_x == int(player.x) and target_y == int(player.y): + message_log.add("Cannot target yourself!", COLOR_INVALID) + return + target_enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + if not target_enemy or target_enemy not in entity_data: + message_log.add("No valid target.", COLOR_INVALID) + return + perform_ranged_attack(target_enemy) + exit_targeting_mode() + enemy_turn() + update_ui() + +def perform_ranged_attack(target_entity: mcrfpy.Entity) -> None: + defender = entity_data.get(target_entity) + attacker = entity_data.get(player) + if not defender or not attacker: + return + damage = max(1, RANGED_ATTACK_DAMAGE - defender.defense // 2) + defender.take_damage(damage) + message_log.add(f"Ranged attack hits {defender.name} for {damage}!", COLOR_RANGED) + if not defender.is_alive: + handle_death(target_entity, defender) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + if not attacker or not defender: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add(f"You hit {defender.name} for {damage}!", COLOR_PLAYER_ATTACK) + else: + message_log.add(f"{attacker.name} hits you for {damage}!", COLOR_ENEMY_ATTACK) + else: + if attacker.is_player: + message_log.add(f"You hit {defender.name} but deal no damage.", mcrfpy.Color(150, 150, 150)) + else: + message_log.add(f"{attacker.name} hits but deals no damage.", mcrfpy.Color(150, 150, 200)) + + if not defender.is_alive: + handle_death(defender_entity, defender) + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + delete_save() + else: + message_log.add(f"{fighter.name} dies!", COLOR_ENEMY_DEATH) + award_xp(fighter.name) + remove_entity(grid, entity) + +# ============================================================================= +# Item Actions +# ============================================================================= + +def pickup_item() -> bool: + px, py = int(player.x), int(player.y) + item_entity = get_item_at(grid, px, py) + if not item_entity: + message_log.add("Nothing to pick up.", COLOR_INVALID) + return False + if player_inventory.is_full(): + message_log.add("Inventory full!", COLOR_WARNING) + return False + item = item_data.get(item_entity) + if not item: + return False + player_inventory.add(item) + remove_item_entity(grid, item_entity) + message_log.add(f"Picked up {item.name}.", COLOR_PICKUP) + update_ui() + return True + +def use_item(index: int) -> bool: + item = player_inventory.get(index) + if not item: + message_log.add("Invalid selection.", COLOR_INVALID) + return False + + # Handle equipment + if item.item_type == "equipment": + return equip_item(index) + + # Handle consumables + if item.item_type == "health_potion": + fighter = entity_data.get(player) + if not fighter: + return False + if fighter.hp >= fighter.max_hp: + message_log.add("Already at full health!", COLOR_WARNING) + return False + actual_heal = fighter.heal(item.heal_amount) + player_inventory.remove(index) + message_log.add(f"Healed {actual_heal} HP!", COLOR_HEAL) + update_ui() + return True + + message_log.add(f"Cannot use {item.name}.", COLOR_INVALID) + return False + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + for entity in target_grid.entities: + if entity == player or entity == target_cursor: + entity.visible = True + else: + entity.visible = target_grid.is_in_fov(int(entity.x), int(entity.y)) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if not (0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT): + return False + if not target_grid.at(x, y).walkable: + return False + return get_blocking_entity_at(target_grid, x, y, exclude=mover) is None + +def try_move_or_attack(dx: int, dy: int) -> None: + global game_over + if game_over: + return + px, py = int(player.x), int(player.y) + new_x, new_y = px + dx, py + dy + if not (0 <= new_x < GRID_WIDTH and 0 <= new_y < GRID_HEIGHT): + return + + blocker = get_blocking_entity_at(grid, new_x, new_y, exclude=player) + if blocker: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(new_x, new_y).walkable: + player.x, player.y = new_x, new_y + update_fov(grid, fov_layer, new_x, new_y) + enemy_turn() + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global game_over + if game_over: + return + px, py = int(player.x), int(player.y) + + for entity in list(grid.entities): + if entity == player or entity not in entity_data: + continue + fighter = entity_data[entity] + if not fighter.is_alive: + continue + ex, ey = int(entity.x), int(entity.y) + if not grid.is_in_fov(ex, ey): + continue + + dx, dy = px - ex, py - ey + if abs(dx) <= 1 and abs(dy) <= 1 and (dx or dy): + perform_attack(entity, player) + else: + move_toward_player(entity, ex, ey, px, py) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + dx = 1 if px > ex else (-1 if px < ex else 0) + dy = 1 if py > ey else (-1 if py < ey else 0) + + if can_move_to(grid, ex + dx, ey + dy, enemy): + enemy.x, enemy.y = ex + dx, ey + dy + elif dx and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + xp_bar.update(fighter.level, fighter.xp, fighter.xp_to_next_level) + equipment_panel.update(fighter) + if player_inventory: + inventory_panel.update(player_inventory) + +# ============================================================================= +# New Game +# ============================================================================= + +def generate_new_game() -> None: + global player, player_inventory, game_over, dungeon_level, game_mode + + game_over = False + game_mode = GameMode.NORMAL + dungeon_level = 1 + + entity_data.clear() + item_data.clear() + while len(grid.entities) > 0: + grid.entities.remove(0) + + init_explored() + message_log.clear() + + player_start = generate_dungeon(grid, dungeon_level) + + player = mcrfpy.Entity(grid_pos=player_start, texture=texture, sprite_index=SPRITE_PLAYER) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, max_hp=30, base_attack=5, base_defense=2, + name="Player", is_player=True, xp=0, level=1 + ) + + player_inventory = Inventory(capacity=10) + spawn_entities_for_level(grid, texture, dungeon_level) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + update_fov(grid, fov_layer, player_start[0], player_start[1]) + + mode_display.update(game_mode) + level_display.update(dungeon_level) + update_ui() + +# ============================================================================= +# Game Setup +# ============================================================================= + +scene = mcrfpy.Scene("game") +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(700, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +scene.children.append(grid) + +# UI Elements +title = mcrfpy.Caption(pos=(20, 10), text="Part 13: Equipment System") +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption(pos=(300, 15), text="WASD:Move | E:Equip | G:Get | >:Descend") +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +health_bar = HealthBar(x=730, y=10, width=280, height=25) +health_bar.add_to_scene(scene) + +xp_bar = XPBar(x=730, y=40, width=280, height=20) +xp_bar.add_to_scene(scene) + +level_display = LevelDisplay(x=860, y=65) +level_display.add_to_scene(scene) + +mode_display = ModeDisplay(x=20, y=40) +mode_display.add_to_scene(scene) + +equipment_panel = EquipmentPanel(x=730, y=GAME_AREA_Y, width=280, height=85) +equipment_panel.add_to_scene(scene) + +inventory_panel = InventoryPanel(x=730, y=GAME_AREA_Y + 90, width=280, height=120) +inventory_panel.add_to_scene(scene) + +message_log = MessageLog(x=20, y=768 - UI_BOTTOM_HEIGHT + 10, width=990, height=UI_BOTTOM_HEIGHT - 20, max_messages=6) +message_log.add_to_scene(scene) + +# Initialize +init_explored() + +if has_save_file(): + message_log.add("Loading saved game...", COLOR_INFO) + if not load_game(): + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) +else: + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) + message_log.add("Find equipment to grow stronger!", COLOR_INFO) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def handle_keys(key: str, action: str) -> None: + global game_over, game_mode + + if action != "start": + return + + if key == "R": + delete_save() + generate_new_game() + message_log.add("New adventure begins!", COLOR_INFO) + return + + if key == "Escape": + if game_mode == GameMode.TARGETING: + exit_targeting_mode() + message_log.add("Targeting cancelled.", COLOR_INFO) + else: + if not game_over: + save_game() + mcrfpy.exit() + return + + if game_over: + return + + if game_mode == GameMode.TARGETING: + handle_targeting_input(key) + else: + handle_normal_input(key) + +def handle_normal_input(key: str) -> None: + if key in ("W", "Up"): + try_move_or_attack(0, -1) + elif key in ("S", "Down"): + try_move_or_attack(0, 1) + elif key in ("A", "Left"): + try_move_or_attack(-1, 0) + elif key in ("D", "Right"): + try_move_or_attack(1, 0) + elif key == "F": + enter_targeting_mode() + elif key in ("G", ","): + pickup_item() + elif key == "Period": + descend_stairs() + elif key == "E": + message_log.add("Press 1-5 to equip an item from inventory.", COLOR_INFO) + elif key in "12345": + index = int(key) - 1 + if use_item(index): + enemy_turn() + update_ui() + +def handle_targeting_input(key: str) -> None: + if key in ("Up", "W"): + move_cursor(0, -1) + elif key in ("Down", "S"): + move_cursor(0, 1) + elif key in ("Left", "A"): + move_cursor(-1, 0) + elif key in ("Right", "D"): + move_cursor(1, 0) + elif key in ("Return", "Space"): + confirm_target() + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 13: Equipment System - Tutorial Complete!") +print("Find weapons and armor to become stronger!") \ No newline at end of file