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
diff --git a/src/PySceneObject.h b/src/PySceneObject.h
index bc12ea3..22ef8ab 100644
--- a/src/PySceneObject.h
+++ b/src/PySceneObject.h
@@ -53,7 +53,41 @@ namespace mcrfpydef {
.tp_dealloc = (destructor)PySceneClass::__dealloc,
.tp_repr = (reprfunc)PySceneClass::__repr__,
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, // Allow subclassing
- .tp_doc = PyDoc_STR("Base class for object-oriented scenes"),
+ .tp_doc = PyDoc_STR(
+ "Scene(name: str)\n\n"
+ "Object-oriented scene management with lifecycle callbacks.\n\n"
+ "This is the recommended approach for scene management, replacing module-level\n"
+ "functions like createScene(), setScene(), and sceneUI(). Key advantage: you can\n"
+ "set on_key handlers on ANY scene, not just the currently active one.\n\n"
+ "Args:\n"
+ " name: Unique identifier for this scene. Used for scene transitions.\n\n"
+ "Properties:\n"
+ " name (str, read-only): Scene's unique identifier.\n"
+ " active (bool, read-only): Whether this scene is currently displayed.\n"
+ " children (UICollection, read-only): UI elements in this scene. Modify to add/remove elements.\n"
+ " on_key (callable): Keyboard handler. Set on ANY scene, regardless of which is active!\n"
+ " pos (Vector): Position offset for all UI elements.\n"
+ " visible (bool): Whether the scene renders.\n"
+ " opacity (float): Scene transparency (0.0-1.0).\n\n"
+ "Lifecycle Callbacks (override in subclass):\n"
+ " on_enter(): Called when scene becomes active via activate().\n"
+ " on_exit(): Called when scene is deactivated (another scene activates).\n"
+ " on_keypress(key: str, action: str): Called for keyboard events. Alternative to on_key property.\n"
+ " update(dt: float): Called every frame with delta time in seconds.\n"
+ " on_resize(width: int, height: int): Called when window is resized.\n\n"
+ "Example:\n"
+ " # Basic usage (replacing module functions):\n"
+ " scene = mcrfpy.Scene('main_menu')\n"
+ " scene.children.append(mcrfpy.Caption(text='Welcome', pos=(100, 100)))\n"
+ " scene.on_key = lambda key, action: print(f'Key: {key}')\n"
+ " scene.activate() # Switch to this scene\n\n"
+ " # Subclassing for lifecycle:\n"
+ " class GameScene(mcrfpy.Scene):\n"
+ " def on_enter(self):\n"
+ " print('Game started!')\n"
+ " def update(self, dt):\n"
+ " self.player.move(dt)\n"
+ ),
.tp_methods = nullptr, // Set in McRFPy_API.cpp
.tp_getset = nullptr, // Set in McRFPy_API.cpp
.tp_init = (initproc)PySceneClass::__init__,
diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp
index e56ed5d..7b59b12 100644
--- a/src/UIGrid.cpp
+++ b/src/UIGrid.cpp
@@ -6,8 +6,9 @@
#include "Profiler.h"
#include "PyFOV.h"
#include
-#include // #142 - for std::floor
+#include // #142 - for std::floor, std::isnan
#include // #150 - for strcmp
+#include // #169 - for std::numeric_limits
// UIDrawable methods now in UIBase.h
UIGrid::UIGrid()
@@ -735,7 +736,9 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
PyObject* fill_color = nullptr;
PyObject* click_handler = nullptr;
PyObject* layers_obj = nullptr; // #150 - layers dict
- float center_x = 0.0f, center_y = 0.0f;
+ // #169 - Use NaN as sentinel to detect if user provided center values
+ float center_x = std::numeric_limits::quiet_NaN();
+ float center_y = std::numeric_limits::quiet_NaN();
float zoom = 1.0f;
// perspective is now handled via properties, not init args
int visible = 1;
@@ -862,9 +865,19 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
sf::Vector2f(x, y), sf::Vector2f(w, h));
// Set additional properties
+ self->data->zoom = zoom; // Set zoom first, needed for default center calculation
+
+ // #169 - Calculate default center if not provided by user
+ // Default: tile (0,0) at top-left of widget
+ if (std::isnan(center_x)) {
+ // Center = half widget size (in pixels), so tile 0,0 appears at top-left
+ center_x = w / (2.0f * zoom);
+ }
+ if (std::isnan(center_y)) {
+ center_y = h / (2.0f * zoom);
+ }
self->data->center_x = center_x;
self->data->center_y = center_y;
- self->data->zoom = zoom;
// perspective is now handled by perspective_entity and perspective_enabled
// self->data->perspective = perspective;
self->data->visible = visible;
@@ -1730,6 +1743,72 @@ PyObject* UIGrid::py_entities_in_radius(PyUIGridObject* self, PyObject* args, Py
return result;
}
+// #169 - center_camera implementations
+void UIGrid::center_camera() {
+ // Center on grid's middle tile
+ int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
+ int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
+ center_x = (grid_x / 2.0f) * cell_width;
+ center_y = (grid_y / 2.0f) * cell_height;
+ markDirty(); // #144 - View change affects content
+}
+
+void UIGrid::center_camera(float tile_x, float tile_y) {
+ // Position specified tile at top-left of widget
+ int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
+ int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
+ // To put tile (tx, ty) at top-left: center = tile_pos + half_viewport
+ float half_viewport_x = box.getSize().x / zoom / 2.0f;
+ float half_viewport_y = box.getSize().y / zoom / 2.0f;
+ center_x = tile_x * cell_width + half_viewport_x;
+ center_y = tile_y * cell_height + half_viewport_y;
+ markDirty(); // #144 - View change affects content
+}
+
+PyObject* UIGrid::py_center_camera(PyUIGridObject* self, PyObject* args) {
+ PyObject* pos_arg = nullptr;
+
+ // Parse optional positional argument (tuple of tile coordinates)
+ if (!PyArg_ParseTuple(args, "|O", &pos_arg)) {
+ return nullptr;
+ }
+
+ if (pos_arg == nullptr || pos_arg == Py_None) {
+ // No args: center on grid's middle tile
+ self->data->center_camera();
+ } else if (PyTuple_Check(pos_arg) && PyTuple_Size(pos_arg) == 2) {
+ // Tuple provided: center on (tile_x, tile_y)
+ PyObject* x_obj = PyTuple_GetItem(pos_arg, 0);
+ PyObject* y_obj = PyTuple_GetItem(pos_arg, 1);
+
+ float tile_x, tile_y;
+ if (PyFloat_Check(x_obj)) {
+ tile_x = PyFloat_AsDouble(x_obj);
+ } else if (PyLong_Check(x_obj)) {
+ tile_x = (float)PyLong_AsLong(x_obj);
+ } else {
+ PyErr_SetString(PyExc_TypeError, "tile coordinates must be numeric");
+ return nullptr;
+ }
+
+ if (PyFloat_Check(y_obj)) {
+ tile_y = PyFloat_AsDouble(y_obj);
+ } else if (PyLong_Check(y_obj)) {
+ tile_y = (float)PyLong_AsLong(y_obj);
+ } else {
+ PyErr_SetString(PyExc_TypeError, "tile coordinates must be numeric");
+ return nullptr;
+ }
+
+ self->data->center_camera(tile_x, tile_y);
+ } else {
+ PyErr_SetString(PyExc_TypeError, "center_camera() takes an optional tuple (tile_x, tile_y)");
+ return nullptr;
+ }
+
+ Py_RETURN_NONE;
+}
+
PyMethodDef UIGrid::methods[] = {
{"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS},
{"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
@@ -1818,6 +1897,15 @@ PyMethodDef UIGrid::methods[] = {
" radius: Search radius\n\n"
"Returns:\n"
" List of Entity objects within the radius."},
+ {"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS,
+ "center_camera(pos: tuple = None) -> None\n\n"
+ "Center the camera on a tile coordinate.\n\n"
+ "Args:\n"
+ " pos: Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.\n\n"
+ "Example:\n"
+ " grid.center_camera() # Center on middle of grid\n"
+ " grid.center_camera((5, 10)) # Center on tile (5, 10)\n"
+ " grid.center_camera((0, 0)) # Center on tile (0, 0)"},
{NULL, NULL, 0, NULL}
};
@@ -1929,6 +2017,15 @@ PyMethodDef UIGrid_all_methods[] = {
" radius: Search radius\n\n"
"Returns:\n"
" List of Entity objects within the radius."},
+ {"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS,
+ "center_camera(pos: tuple = None) -> None\n\n"
+ "Center the camera on a tile coordinate.\n\n"
+ "Args:\n"
+ " pos: Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.\n\n"
+ "Example:\n"
+ " grid.center_camera() # Center on middle of grid\n"
+ " grid.center_camera((5, 10)) # Center on tile (5, 10)\n"
+ " grid.center_camera((0, 0)) # Center on tile (0, 0)"},
{NULL} // Sentinel
};
diff --git a/src/UIGrid.h b/src/UIGrid.h
index 2af03b7..3751466 100644
--- a/src/UIGrid.h
+++ b/src/UIGrid.h
@@ -170,6 +170,12 @@ public:
static PyObject* py_get_dijkstra_path(PyUIGridObject* self, PyObject* args);
static PyObject* py_compute_astar_path(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds); // #115
+ static PyObject* py_center_camera(PyUIGridObject* self, PyObject* args); // #169
+
+ // #169 - Camera positioning
+ void center_camera(); // Center on grid's middle tile
+ void center_camera(float tile_x, float tile_y); // Center on specific tile
+
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
static PyObject* get_entities(PyUIGridObject* self, void* closure);
diff --git a/tests/benchmarks/benchmark_moving_entities.py b/tests/benchmarks/benchmark_moving_entities.py
index 6c0fb76..23d79d6 100644
--- a/tests/benchmarks/benchmark_moving_entities.py
+++ b/tests/benchmarks/benchmark_moving_entities.py
@@ -34,12 +34,15 @@ grid = mcrfpy.Grid(
size=(1024, 768)
)
+# Add color layer for floor pattern
+color_layer = grid.add_layer("color", z_index=-1)
+
# Simple floor pattern
for x in range(100):
for y in range(100):
- cell = grid.at((x, y))
+ cell = grid.at(x, y)
cell.tilesprite = 0
- cell.color = (40, 40, 40, 255)
+ color_layer.set(x, y, mcrfpy.Color(40, 40, 40, 255))
# Create 50 entities with random positions and velocities
entities = []
@@ -47,15 +50,15 @@ ENTITY_COUNT = 50
for i in range(ENTITY_COUNT):
entity = mcrfpy.Entity(
- grid_pos=(random.randint(0, 99), random.randint(0, 99)),
- sprite_index=random.randint(10, 20) # Use varied sprites
+ (random.randint(0, 99), random.randint(0, 99)),
+ sprite_index=random.randint(10, 20), # Use varied sprites
+ grid=grid
)
- # Give each entity a random velocity
+ # Give each entity a random velocity (stored as Python attributes)
entity.velocity_x = random.uniform(-0.5, 0.5)
entity.velocity_y = random.uniform(-0.5, 0.5)
- grid.entities.append(entity)
entities.append(entity)
ui.append(grid)
diff --git a/tests/benchmarks/benchmark_suite.py b/tests/benchmarks/benchmark_suite.py
index 18806d4..72c24c7 100644
--- a/tests/benchmarks/benchmark_suite.py
+++ b/tests/benchmarks/benchmark_suite.py
@@ -282,23 +282,23 @@ def setup_grid_stress():
grid.center = (400, 400) # Center view
ui.append(grid)
- # Fill with alternating colors
+ # Add color layer and fill with alternating colors
+ color_layer = grid.add_layer("color", z_index=-1)
for y in range(50):
for x in range(50):
- cell = grid.at(x, y)
if (x + y) % 2 == 0:
- cell.color = mcrfpy.Color(60, 60, 80)
+ color_layer.set(x, y, mcrfpy.Color(60, 60, 80))
else:
- cell.color = mcrfpy.Color(40, 40, 60)
+ color_layer.set(x, y, mcrfpy.Color(40, 40, 60))
# Add 50 entities
try:
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
for i in range(50):
- # Entity takes positional args: (position, texture, sprite_index, grid)
- pos = mcrfpy.Vector(random.randint(5, 45), random.randint(5, 45))
- entity = mcrfpy.Entity(pos, texture, random.randint(0, 100), grid)
+ # Entity takes tuple position and keyword args
+ pos = (random.randint(5, 45), random.randint(5, 45))
+ entity = mcrfpy.Entity(pos, texture=texture, sprite_index=random.randint(0, 100), grid=grid)
grid.entities.append(entity)
except Exception as e:
print(f" Note: Could not create entities: {e}")
diff --git a/tests/benchmarks/layer_performance_test.py b/tests/benchmarks/layer_performance_test.py
index a81b441..6649a6b 100644
--- a/tests/benchmarks/layer_performance_test.py
+++ b/tests/benchmarks/layer_performance_test.py
@@ -6,10 +6,14 @@ Uses C++ benchmark logger (start_benchmark/end_benchmark) for accurate timing.
Results written to JSON files for analysis.
Compares rendering performance between:
-1. Traditional grid.at(x,y).color API (no caching)
-2. New layer system with dirty flag caching
+1. ColorLayer with per-cell modifications (no caching benefit)
+2. ColorLayer with dirty flag caching (static after fill)
3. Various layer configurations
+NOTE: The old grid.at(x,y).color API no longer exists. All color operations
+now go through the ColorLayer system. This benchmark compares different
+layer usage patterns to measure caching effectiveness.
+
Usage:
./mcrogueface --exec tests/benchmarks/layer_performance_test.py
# Results in benchmark_*.json files
@@ -94,7 +98,7 @@ def run_next_test():
# ============================================================================
def setup_base_layer_static():
- """Traditional grid.at(x,y).color API - no modifications during render."""
+ """ColorLayer with per-cell set() calls - static after initial fill."""
mcrfpy.createScene("test_base_static")
ui = mcrfpy.sceneUI("test_base_static")
@@ -102,17 +106,17 @@ def setup_base_layer_static():
pos=(10, 10), size=(600, 600))
ui.append(grid)
- # Fill base layer using traditional API
+ # Fill using ColorLayer with per-cell set() calls (baseline)
+ layer = grid.add_layer("color", z_index=-1)
for y in range(GRID_SIZE):
for x in range(GRID_SIZE):
- cell = grid.at(x, y)
- cell.color = mcrfpy.Color((x * 2) % 256, (y * 2) % 256, 128, 255)
+ layer.set(x, y, mcrfpy.Color((x * 2) % 256, (y * 2) % 256, 128, 255))
mcrfpy.setScene("test_base_static")
def setup_base_layer_modified():
- """Traditional API with single cell modified each frame."""
+ """ColorLayer with single cell modified each frame - tests dirty flag."""
mcrfpy.createScene("test_base_mod")
ui = mcrfpy.sceneUI("test_base_mod")
@@ -120,19 +124,16 @@ def setup_base_layer_modified():
pos=(10, 10), size=(600, 600))
ui.append(grid)
- # Fill base layer
- for y in range(GRID_SIZE):
- for x in range(GRID_SIZE):
- cell = grid.at(x, y)
- cell.color = mcrfpy.Color(100, 100, 100, 255)
+ # Fill using ColorLayer
+ layer = grid.add_layer("color", z_index=-1)
+ layer.fill(mcrfpy.Color(100, 100, 100, 255))
- # Timer to modify one cell per frame
+ # Timer to modify one cell per frame (triggers dirty flag each frame)
mod_counter = [0]
def modify_cell(runtime):
x = mod_counter[0] % GRID_SIZE
y = (mod_counter[0] // GRID_SIZE) % GRID_SIZE
- cell = grid.at(x, y)
- cell.color = mcrfpy.Color(255, 0, 0, 255)
+ layer.set(x, y, mcrfpy.Color(255, 0, 0, 255))
mod_counter[0] += 1
mcrfpy.setScene("test_base_mod")
diff --git a/tests/integration/astar_vs_dijkstra.py b/tests/integration/astar_vs_dijkstra.py
index 5b93c99..be75ea2 100644
--- a/tests/integration/astar_vs_dijkstra.py
+++ b/tests/integration/astar_vs_dijkstra.py
@@ -19,26 +19,30 @@ END_COLOR = mcrfpy.Color(255, 255, 100) # Yellow for end
# Global state
grid = None
+color_layer = None
mode = "ASTAR"
start_pos = (5, 10)
end_pos = (27, 10) # Changed from 25 to 27 to avoid the wall
def create_map():
"""Create a map with obstacles to show pathfinding differences"""
- global grid
-
+ global grid, color_layer
+
mcrfpy.createScene("pathfinding_comparison")
-
+
# Create grid
grid = mcrfpy.Grid(grid_x=30, grid_y=20)
grid.fill_color = mcrfpy.Color(0, 0, 0)
-
+
+ # Add color layer for cell coloring
+ color_layer = grid.add_layer("color", z_index=-1)
+
# Initialize all as floor
for y in range(20):
for x in range(30):
grid.at(x, y).walkable = True
- grid.at(x, y).color = FLOOR_COLOR
-
+ color_layer.set(x, y, FLOOR_COLOR)
+
# Create obstacles that make A* and Dijkstra differ
obstacles = [
# Vertical wall with gaps
@@ -50,15 +54,15 @@ def create_map():
[(x, 10) for x in range(20, 25)],
[(25, y) for y in range(5, 15)],
]
-
+
for obstacle_group in obstacles:
for x, y in obstacle_group:
grid.at(x, y).walkable = False
- grid.at(x, y).color = WALL_COLOR
-
+ color_layer.set(x, y, WALL_COLOR)
+
# Mark start and end
- grid.at(start_pos[0], start_pos[1]).color = START_COLOR
- grid.at(end_pos[0], end_pos[1]).color = END_COLOR
+ color_layer.set(start_pos[0], start_pos[1], START_COLOR)
+ color_layer.set(end_pos[0], end_pos[1], END_COLOR)
def clear_paths():
"""Clear path highlighting"""
@@ -66,34 +70,34 @@ def clear_paths():
for x in range(30):
cell = grid.at(x, y)
if cell.walkable:
- cell.color = FLOOR_COLOR
-
+ color_layer.set(x, y, FLOOR_COLOR)
+
# Restore start and end colors
- grid.at(start_pos[0], start_pos[1]).color = START_COLOR
- grid.at(end_pos[0], end_pos[1]).color = END_COLOR
+ color_layer.set(start_pos[0], start_pos[1], START_COLOR)
+ color_layer.set(end_pos[0], end_pos[1], END_COLOR)
def show_astar():
"""Show A* path"""
clear_paths()
-
+
# Compute A* path
path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1])
-
+
# Color the path
for i, (x, y) in enumerate(path):
if (x, y) != start_pos and (x, y) != end_pos:
- grid.at(x, y).color = ASTAR_COLOR
-
+ color_layer.set(x, y, ASTAR_COLOR)
+
status_text.text = f"A* Path: {len(path)} steps (optimized for single target)"
status_text.fill_color = ASTAR_COLOR
def show_dijkstra():
"""Show Dijkstra exploration"""
clear_paths()
-
+
# Compute Dijkstra from start
grid.compute_dijkstra(start_pos[0], start_pos[1])
-
+
# Color cells by distance (showing exploration)
max_dist = 40.0
for y in range(20):
@@ -103,50 +107,50 @@ def show_dijkstra():
if dist is not None and dist < max_dist:
# Color based on distance
intensity = int(255 * (1 - dist / max_dist))
- grid.at(x, y).color = mcrfpy.Color(0, intensity // 2, intensity)
-
+ color_layer.set(x, y, mcrfpy.Color(0, intensity // 2, intensity))
+
# Get the actual path
path = grid.get_dijkstra_path(end_pos[0], end_pos[1])
-
+
# Highlight the actual path more brightly
for x, y in path:
if (x, y) != start_pos and (x, y) != end_pos:
- grid.at(x, y).color = DIJKSTRA_COLOR
-
+ color_layer.set(x, y, DIJKSTRA_COLOR)
+
# Restore start and end
- grid.at(start_pos[0], start_pos[1]).color = START_COLOR
- grid.at(end_pos[0], end_pos[1]).color = END_COLOR
-
+ color_layer.set(start_pos[0], start_pos[1], START_COLOR)
+ color_layer.set(end_pos[0], end_pos[1], END_COLOR)
+
status_text.text = f"Dijkstra: {len(path)} steps (explores all directions)"
status_text.fill_color = DIJKSTRA_COLOR
def show_both():
"""Show both paths overlaid"""
clear_paths()
-
+
# Get both paths
astar_path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1])
grid.compute_dijkstra(start_pos[0], start_pos[1])
dijkstra_path = grid.get_dijkstra_path(end_pos[0], end_pos[1])
-
+
print(astar_path, dijkstra_path)
# Color Dijkstra path first (blue)
for x, y in dijkstra_path:
if (x, y) != start_pos and (x, y) != end_pos:
- grid.at(x, y).color = DIJKSTRA_COLOR
-
+ color_layer.set(x, y, DIJKSTRA_COLOR)
+
# Then A* path (green) - will overwrite shared cells
for x, y in astar_path:
if (x, y) != start_pos and (x, y) != end_pos:
- grid.at(x, y).color = ASTAR_COLOR
-
+ color_layer.set(x, y, ASTAR_COLOR)
+
# Mark differences
different_cells = []
for cell in dijkstra_path:
if cell not in astar_path:
different_cells.append(cell)
-
+
status_text.text = f"Both paths: A*={len(astar_path)} steps, Dijkstra={len(dijkstra_path)} steps"
if different_cells:
info_text.text = f"Paths differ at {len(different_cells)} cells"
@@ -202,26 +206,26 @@ grid.size = (600, 400) # 30*20, 20*20
grid.position = (100, 100)
# Add title
-title = mcrfpy.Caption("A* vs Dijkstra Pathfinding", 250, 20)
+title = mcrfpy.Caption(pos=(250, 20), text="A* vs Dijkstra Pathfinding")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Add status
-status_text = mcrfpy.Caption("Press A for A*, D for Dijkstra, B for Both", 100, 60)
+status_text = mcrfpy.Caption(pos=(100, 60), text="Press A for A*, D for Dijkstra, B for Both")
status_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status_text)
# Add info
-info_text = mcrfpy.Caption("", 100, 520)
+info_text = mcrfpy.Caption(pos=(100, 520), text="")
info_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(info_text)
# Add legend
-legend1 = mcrfpy.Caption("Red=Start, Yellow=End, Green=A*, Blue=Dijkstra", 100, 540)
+legend1 = mcrfpy.Caption(pos=(100, 540), text="Red=Start, Yellow=End, Green=A*, Blue=Dijkstra")
legend1.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend1)
-legend2 = mcrfpy.Caption("Dark=Walls, Light=Floor", 100, 560)
+legend2 = mcrfpy.Caption(pos=(100, 560), text="Dark=Walls, Light=Floor")
legend2.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend2)
diff --git a/tests/integration/debug_visibility.py b/tests/integration/debug_visibility.py
index da0bd60..89a4ab7 100644
--- a/tests/integration/debug_visibility.py
+++ b/tests/integration/debug_visibility.py
@@ -20,9 +20,8 @@ for y in range(5):
# Create entity
print("Creating entity...")
-entity = mcrfpy.Entity(2, 2)
+entity = mcrfpy.Entity((2, 2), grid=grid)
entity.sprite_index = 64
-grid.entities.append(entity)
print(f"Entity at ({entity.x}, {entity.y})")
# Check gridstate
diff --git a/tests/integration/dijkstra_all_paths.py b/tests/integration/dijkstra_all_paths.py
index e205f08..79ce919 100644
--- a/tests/integration/dijkstra_all_paths.py
+++ b/tests/integration/dijkstra_all_paths.py
@@ -20,6 +20,7 @@ NO_PATH_COLOR = mcrfpy.Color(255, 0, 0) # Pure red for unreachable
# Global state
grid = None
+color_layer = None
entities = []
current_combo_index = 0
all_combinations = [] # All possible pairs
@@ -27,14 +28,17 @@ current_path = []
def create_map():
"""Create the map with entities"""
- global grid, entities, all_combinations
-
+ global grid, color_layer, entities, all_combinations
+
mcrfpy.createScene("dijkstra_all")
-
+
# Create grid
grid = mcrfpy.Grid(grid_x=14, grid_y=10)
grid.fill_color = mcrfpy.Color(0, 0, 0)
-
+
+ # Add color layer for cell coloring
+ color_layer = grid.add_layer("color", z_index=-1)
+
# Map layout - Entity 1 is intentionally trapped!
map_layout = [
"..............", # Row 0
@@ -48,29 +52,28 @@ def create_map():
"..W.WWW.......", # Row 8
"..............", # Row 9
]
-
+
# Create the map
entity_positions = []
for y, row in enumerate(map_layout):
for x, char in enumerate(row):
cell = grid.at(x, y)
-
+
if char == 'W':
cell.walkable = False
- cell.color = WALL_COLOR
+ color_layer.set(x, y, WALL_COLOR)
else:
cell.walkable = True
- cell.color = FLOOR_COLOR
-
+ color_layer.set(x, y, FLOOR_COLOR)
+
if char == 'E':
entity_positions.append((x, y))
-
+
# Create entities
entities = []
for i, (x, y) in enumerate(entity_positions):
- entity = mcrfpy.Entity(x, y)
+ entity = mcrfpy.Entity((x, y), grid=grid)
entity.sprite_index = 49 + i # '1', '2', '3'
- grid.entities.append(entity)
entities.append(entity)
print("Map Analysis:")
@@ -90,47 +93,47 @@ def create_map():
def clear_path_colors():
"""Reset all floor tiles to original color"""
global current_path
-
+
for y in range(grid.grid_y):
for x in range(grid.grid_x):
cell = grid.at(x, y)
if cell.walkable:
- cell.color = FLOOR_COLOR
-
+ color_layer.set(x, y, FLOOR_COLOR)
+
current_path = []
def show_combination(index):
"""Show a specific path combination (valid or invalid)"""
global current_combo_index, current_path
-
+
current_combo_index = index % len(all_combinations)
from_idx, to_idx = all_combinations[current_combo_index]
-
+
# Clear previous path
clear_path_colors()
-
+
# Get entities
e_from = entities[from_idx]
e_to = entities[to_idx]
-
+
# Calculate path
path = e_from.path_to(int(e_to.x), int(e_to.y))
current_path = path if path else []
-
+
# Always color start and end positions
- grid.at(int(e_from.x), int(e_from.y)).color = START_COLOR
- grid.at(int(e_to.x), int(e_to.y)).color = NO_PATH_COLOR if not path else END_COLOR
-
+ color_layer.set(int(e_from.x), int(e_from.y), START_COLOR)
+ color_layer.set(int(e_to.x), int(e_to.y), NO_PATH_COLOR if not path else END_COLOR)
+
# Color the path if it exists
if path:
# Color intermediate steps
for i, (x, y) in enumerate(path):
if i > 0 and i < len(path) - 1:
- grid.at(x, y).color = PATH_COLOR
-
+ color_layer.set(x, y, PATH_COLOR)
+
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} → Entity {to_idx+1} = {len(path)} steps"
status_text.fill_color = mcrfpy.Color(100, 255, 100) # Green for valid
-
+
# Show path steps
path_display = []
for i, (x, y) in enumerate(path[:5]):
@@ -142,7 +145,7 @@ def show_combination(index):
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} → Entity {to_idx+1} = NO PATH!"
status_text.fill_color = mcrfpy.Color(255, 100, 100) # Red for invalid
path_text.text = "Path: [] (No valid path exists)"
-
+
# Update info
info_text.text = f"From: Entity {from_idx+1} at ({int(e_from.x)}, {int(e_from.y)}) | To: Entity {to_idx+1} at ({int(e_to.x)}, {int(e_to.y)})"
@@ -183,37 +186,37 @@ grid.size = (560, 400)
grid.position = (120, 100)
# Add title
-title = mcrfpy.Caption("Dijkstra - All Paths (Valid & Invalid)", 200, 20)
+title = mcrfpy.Caption(pos=(200, 20), text="Dijkstra - All Paths (Valid & Invalid)")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Add status (will change color based on validity)
-status_text = mcrfpy.Caption("Ready", 120, 60)
+status_text = mcrfpy.Caption(pos=(120, 60), text="Ready")
status_text.fill_color = mcrfpy.Color(255, 255, 100)
ui.append(status_text)
# Add info
-info_text = mcrfpy.Caption("", 120, 80)
+info_text = mcrfpy.Caption(pos=(120, 80), text="")
info_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(info_text)
# Add path display
-path_text = mcrfpy.Caption("Path: None", 120, 520)
+path_text = mcrfpy.Caption(pos=(120, 520), text="Path: None")
path_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(path_text)
# Add controls
-controls = mcrfpy.Caption("SPACE/N=Next, P=Previous, 1-6=Jump to path, Q=Quit", 120, 540)
+controls = mcrfpy.Caption(pos=(120, 540), text="SPACE/N=Next, P=Previous, 1-6=Jump to path, Q=Quit")
controls.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(controls)
# Add legend
-legend = mcrfpy.Caption("Red Start→Blue End (valid) | Red Start→Red End (invalid)", 120, 560)
+legend = mcrfpy.Caption(pos=(120, 560), text="Red Start→Blue End (valid) | Red Start→Red End (invalid)")
legend.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend)
# Expected results info
-expected = mcrfpy.Caption("Entity 1 is trapped: paths 1→2, 1→3, 2→1, 3→1 will fail", 120, 580)
+expected = mcrfpy.Caption(pos=(120, 580), text="Entity 1 is trapped: paths 1→2, 1→3, 2→1, 3→1 will fail")
expected.fill_color = mcrfpy.Color(255, 150, 150)
ui.append(expected)
diff --git a/tests/integration/dijkstra_cycle_paths.py b/tests/integration/dijkstra_cycle_paths.py
index 201219c..2f71862 100644
--- a/tests/integration/dijkstra_cycle_paths.py
+++ b/tests/integration/dijkstra_cycle_paths.py
@@ -18,6 +18,7 @@ END_COLOR = mcrfpy.Color(100, 100, 255) # Light blue
# Global state
grid = None
+color_layer = None
entities = []
current_path_index = 0
path_combinations = []
@@ -25,14 +26,17 @@ current_path = []
def create_map():
"""Create the map with entities"""
- global grid, entities
-
+ global grid, color_layer, entities
+
mcrfpy.createScene("dijkstra_cycle")
-
+
# Create grid
grid = mcrfpy.Grid(grid_x=14, grid_y=10)
grid.fill_color = mcrfpy.Color(0, 0, 0)
-
+
+ # Add color layer for cell coloring
+ color_layer = grid.add_layer("color", z_index=-1)
+
# Map layout
map_layout = [
"..............", # Row 0
@@ -46,29 +50,28 @@ def create_map():
"..W.WWW.......", # Row 8
"..............", # Row 9
]
-
+
# Create the map
entity_positions = []
for y, row in enumerate(map_layout):
for x, char in enumerate(row):
cell = grid.at(x, y)
-
+
if char == 'W':
cell.walkable = False
- cell.color = WALL_COLOR
+ color_layer.set(x, y, WALL_COLOR)
else:
cell.walkable = True
- cell.color = FLOOR_COLOR
-
+ color_layer.set(x, y, FLOOR_COLOR)
+
if char == 'E':
entity_positions.append((x, y))
-
+
# Create entities
entities = []
for i, (x, y) in enumerate(entity_positions):
- entity = mcrfpy.Entity(x, y)
+ entity = mcrfpy.Entity((x, y), grid=grid)
entity.sprite_index = 49 + i # '1', '2', '3'
- grid.entities.append(entity)
entities.append(entity)
print("Entities created:")
@@ -113,48 +116,48 @@ def create_map():
def clear_path_colors():
"""Reset all floor tiles to original color"""
global current_path
-
+
for y in range(grid.grid_y):
for x in range(grid.grid_x):
cell = grid.at(x, y)
if cell.walkable:
- cell.color = FLOOR_COLOR
-
+ color_layer.set(x, y, FLOOR_COLOR)
+
current_path = []
def show_path(index):
"""Show a specific path combination"""
global current_path_index, current_path
-
+
if not path_combinations:
status_text.text = "No valid paths available (Entity 1 is trapped!)"
return
-
+
current_path_index = index % len(path_combinations)
from_idx, to_idx, path = path_combinations[current_path_index]
-
+
# Clear previous path
clear_path_colors()
-
+
# Get entities
e_from = entities[from_idx]
e_to = entities[to_idx]
-
+
# Color the path
current_path = path
if path:
# Color start and end
- grid.at(int(e_from.x), int(e_from.y)).color = START_COLOR
- grid.at(int(e_to.x), int(e_to.y)).color = END_COLOR
-
+ color_layer.set(int(e_from.x), int(e_from.y), START_COLOR)
+ color_layer.set(int(e_to.x), int(e_to.y), END_COLOR)
+
# Color intermediate steps
for i, (x, y) in enumerate(path):
if i > 0 and i < len(path) - 1:
- grid.at(x, y).color = PATH_COLOR
-
+ color_layer.set(x, y, PATH_COLOR)
+
# Update status
status_text.text = f"Path {current_path_index + 1}/{len(path_combinations)}: Entity {from_idx+1} → Entity {to_idx+1} ({len(path)} steps)"
-
+
# Update path display
path_display = []
for i, (x, y) in enumerate(path[:5]): # Show first 5 steps
@@ -194,27 +197,27 @@ grid.size = (560, 400)
grid.position = (120, 100)
# Add title
-title = mcrfpy.Caption("Dijkstra Pathfinding - Cycle Paths", 200, 20)
+title = mcrfpy.Caption(pos=(200, 20), text="Dijkstra Pathfinding - Cycle Paths")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Add status
-status_text = mcrfpy.Caption("Press SPACE to cycle paths", 120, 60)
+status_text = mcrfpy.Caption(pos=(120, 60), text="Press SPACE to cycle paths")
status_text.fill_color = mcrfpy.Color(255, 255, 100)
ui.append(status_text)
# Add path display
-path_text = mcrfpy.Caption("Path: None", 120, 520)
+path_text = mcrfpy.Caption(pos=(120, 520), text="Path: None")
path_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(path_text)
# Add controls
-controls = mcrfpy.Caption("SPACE/N=Next, P=Previous, R=Refresh, Q=Quit", 120, 540)
+controls = mcrfpy.Caption(pos=(120, 540), text="SPACE/N=Next, P=Previous, R=Refresh, Q=Quit")
controls.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(controls)
# Add legend
-legend = mcrfpy.Caption("Red=Start, Blue=End, Green=Path, Dark=Wall", 120, 560)
+legend = mcrfpy.Caption(pos=(120, 560), text="Red=Start, Blue=End, Green=Path, Dark=Wall")
legend.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend)
diff --git a/tests/integration/dijkstra_debug.py b/tests/integration/dijkstra_debug.py
index fd182b8..6538fae 100644
--- a/tests/integration/dijkstra_debug.py
+++ b/tests/integration/dijkstra_debug.py
@@ -18,49 +18,52 @@ ENTITY_COLORS = [
# Global state
grid = None
+color_layer = None
entities = []
first_point = None
second_point = None
def create_simple_map():
"""Create a simple test map"""
- global grid, entities
-
+ global grid, color_layer, entities
+
mcrfpy.createScene("dijkstra_debug")
-
+
# Small grid for easy debugging
grid = mcrfpy.Grid(grid_x=10, grid_y=10)
grid.fill_color = mcrfpy.Color(0, 0, 0)
-
+
+ # Add color layer for cell coloring
+ color_layer = grid.add_layer("color", z_index=-1)
+
print("Initializing 10x10 grid...")
-
+
# Initialize all as floor
for y in range(10):
for x in range(10):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
- grid.at(x, y).color = FLOOR_COLOR
-
+ color_layer.set(x, y, FLOOR_COLOR)
+
# Add a simple wall
print("Adding walls at:")
walls = [(5, 2), (5, 3), (5, 4), (5, 5), (5, 6)]
for x, y in walls:
print(f" Wall at ({x}, {y})")
grid.at(x, y).walkable = False
- grid.at(x, y).color = WALL_COLOR
-
+ color_layer.set(x, y, WALL_COLOR)
+
# Create 3 entities
entity_positions = [(2, 5), (8, 5), (5, 8)]
entities = []
-
+
print("\nCreating entities at:")
for i, (x, y) in enumerate(entity_positions):
print(f" Entity {i+1} at ({x}, {y})")
- entity = mcrfpy.Entity(x, y)
+ entity = mcrfpy.Entity((x, y), grid=grid)
entity.sprite_index = 49 + i # '1', '2', '3'
- grid.entities.append(entity)
entities.append(entity)
-
+
return grid
def test_path_highlighting():
@@ -88,12 +91,14 @@ def test_path_highlighting():
print(f" Step {i}: ({x}, {y})")
# Get current color for debugging
cell = grid.at(x, y)
- old_color = (cell.color.r, cell.color.g, cell.color.b)
-
+ old_c = color_layer.at(x, y)
+ old_color = (old_c.r, old_c.g, old_c.b)
+
# Set new color
- cell.color = PATH_COLOR
- new_color = (cell.color.r, cell.color.g, cell.color.b)
-
+ color_layer.set(x, y, PATH_COLOR)
+ new_c = color_layer.at(x, y)
+ new_color = (new_c.r, new_c.g, new_c.b)
+
print(f" Color changed from {old_color} to {new_color}")
print(f" Walkable: {cell.walkable}")
@@ -111,8 +116,8 @@ def test_path_highlighting():
# Verify colors were set
print("\nVerifying cell colors after highlighting:")
for x, y in path[:3]: # Check first 3 cells
- cell = grid.at(x, y)
- color = (cell.color.r, cell.color.g, cell.color.b)
+ c = color_layer.at(x, y)
+ color = (c.r, c.g, c.b)
expected = (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b)
match = color == expected
print(f" Cell ({x}, {y}): color={color}, expected={expected}, match={match}")
@@ -143,12 +148,12 @@ grid.position = (50, 50)
grid.size = (400, 400) # 10*40
# Add title
-title = mcrfpy.Caption("Dijkstra Debug - Press SPACE to retest, Q to quit", 50, 10)
+title = mcrfpy.Caption(pos=(50, 10), text="Dijkstra Debug - Press SPACE to retest, Q to quit")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Add debug info
-info = mcrfpy.Caption("Check console for debug output", 50, 470)
+info = mcrfpy.Caption(pos=(50, 470), text="Check console for debug output")
info.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(info)
diff --git a/tests/integration/dijkstra_interactive.py b/tests/integration/dijkstra_interactive.py
index fdf2176..c9deeae 100644
--- a/tests/integration/dijkstra_interactive.py
+++ b/tests/integration/dijkstra_interactive.py
@@ -29,20 +29,24 @@ ENTITY_COLORS = [
# Global state
grid = None
+color_layer = None
entities = []
first_point = None
second_point = None
def create_map():
"""Create the interactive map with the layout specified by the user"""
- global grid, entities
-
+ global grid, color_layer, entities
+
mcrfpy.createScene("dijkstra_interactive")
-
+
# Create grid - 14x10 as specified
grid = mcrfpy.Grid(grid_x=14, grid_y=10)
grid.fill_color = mcrfpy.Color(0, 0, 0)
-
+
+ # Add color layer for cell coloring
+ color_layer = grid.add_layer("color", z_index=-1)
+
# Define the map layout from user's specification
# . = floor, W = wall, E = entity position
map_layout = [
@@ -57,36 +61,35 @@ def create_map():
"..W.WWW.......", # Row 8
"..............", # Row 9
]
-
+
# Create the map
entity_positions = []
for y, row in enumerate(map_layout):
for x, char in enumerate(row):
cell = grid.at(x, y)
-
+
if char == 'W':
# Wall
cell.walkable = False
cell.transparent = False
- cell.color = WALL_COLOR
+ color_layer.set(x, y, WALL_COLOR)
else:
# Floor
cell.walkable = True
cell.transparent = True
- cell.color = FLOOR_COLOR
-
+ color_layer.set(x, y, FLOOR_COLOR)
+
if char == 'E':
# Entity position
entity_positions.append((x, y))
-
+
# Create entities at marked positions
entities = []
for i, (x, y) in enumerate(entity_positions):
- entity = mcrfpy.Entity(x, y)
+ entity = mcrfpy.Entity((x, y), grid=grid)
entity.sprite_index = 49 + i # '1', '2', '3'
- grid.entities.append(entity)
entities.append(entity)
-
+
return grid
def clear_path_highlight():
@@ -96,37 +99,37 @@ def clear_path_highlight():
for x in range(grid.grid_x):
cell = grid.at(x, y)
if cell.walkable:
- cell.color = FLOOR_COLOR
+ color_layer.set(x, y, FLOOR_COLOR)
def highlight_path():
"""Highlight the path between selected entities"""
if first_point is None or second_point is None:
return
-
+
# Clear previous highlighting
clear_path_highlight()
-
+
# Get entities
entity1 = entities[first_point]
entity2 = entities[second_point]
-
+
# Compute Dijkstra from first entity
grid.compute_dijkstra(int(entity1.x), int(entity1.y))
-
+
# Get path to second entity
path = grid.get_dijkstra_path(int(entity2.x), int(entity2.y))
-
+
if path:
# Highlight the path
for x, y in path:
cell = grid.at(x, y)
if cell.walkable:
- cell.color = PATH_COLOR
-
+ color_layer.set(x, y, PATH_COLOR)
+
# Also highlight start and end with entity colors
- grid.at(int(entity1.x), int(entity1.y)).color = ENTITY_COLORS[first_point]
- grid.at(int(entity2.x), int(entity2.y)).color = ENTITY_COLORS[second_point]
-
+ color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point])
+ color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point])
+
# Update info
distance = grid.get_dijkstra_distance(int(entity2.x), int(entity2.y))
info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps, {distance:.1f} units"
@@ -199,34 +202,33 @@ grid.size = (560, 400) # 14*40, 10*40
grid.position = (120, 60)
# Add title
-title = mcrfpy.Caption("Dijkstra Pathfinding Interactive", 250, 10)
+title = mcrfpy.Caption(pos=(250, 10), text="Dijkstra Pathfinding Interactive")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Add status text
-status_text = mcrfpy.Caption("Press 1/2/3 for first entity, A/B/C for second", 120, 480)
+status_text = mcrfpy.Caption(pos=(120, 480), text="Press 1/2/3 for first entity, A/B/C for second")
status_text.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(status_text)
# Add info text
-info_text = mcrfpy.Caption("Space to clear, Q to quit", 120, 500)
+info_text = mcrfpy.Caption(pos=(120, 500), text="Space to clear, Q to quit")
info_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(info_text)
# Add legend
-legend1 = mcrfpy.Caption("Entities: 1=Red 2=Green 3=Blue", 120, 540)
+legend1 = mcrfpy.Caption(pos=(120, 540), text="Entities: 1=Red 2=Green 3=Blue")
legend1.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend1)
-legend2 = mcrfpy.Caption("Colors: Dark=Wall Light=Floor Cyan=Path", 120, 560)
+legend2 = mcrfpy.Caption(pos=(120, 560), text="Colors: Dark=Wall Light=Floor Cyan=Path")
legend2.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend2)
# Mark entity positions with colored indicators
for i, entity in enumerate(entities):
- marker = mcrfpy.Caption(str(i+1),
- 120 + int(entity.x) * 40 + 15,
- 60 + int(entity.y) * 40 + 10)
+ marker = mcrfpy.Caption(pos=(120 + int(entity.x) * 40 + 15, 60 + int(entity.y) * 40 + 10),
+ text=str(i+1))
marker.fill_color = ENTITY_COLORS[i]
marker.outline = 1
marker.outline_color = mcrfpy.Color(0, 0, 0)
diff --git a/tests/integration/dijkstra_interactive_enhanced.py b/tests/integration/dijkstra_interactive_enhanced.py
index 34da805..35c8655 100644
--- a/tests/integration/dijkstra_interactive_enhanced.py
+++ b/tests/integration/dijkstra_interactive_enhanced.py
@@ -32,6 +32,7 @@ ENTITY_COLORS = [
# Global state
grid = None
+color_layer = None
entities = []
first_point = None
second_point = None
@@ -43,14 +44,17 @@ original_positions = [] # Store original entity positions
def create_map():
"""Create the interactive map with the layout specified by the user"""
- global grid, entities, original_positions
-
+ global grid, color_layer, entities, original_positions
+
mcrfpy.createScene("dijkstra_enhanced")
-
+
# Create grid - 14x10 as specified
grid = mcrfpy.Grid(grid_x=14, grid_y=10)
grid.fill_color = mcrfpy.Color(0, 0, 0)
-
+
+ # Add color layer for cell coloring
+ color_layer = grid.add_layer("color", z_index=-1)
+
# Define the map layout from user's specification
# . = floor, W = wall, E = entity position
map_layout = [
@@ -65,87 +69,86 @@ def create_map():
"..W.WWW.......", # Row 8
"..............", # Row 9
]
-
+
# Create the map
entity_positions = []
for y, row in enumerate(map_layout):
for x, char in enumerate(row):
cell = grid.at(x, y)
-
+
if char == 'W':
# Wall
cell.walkable = False
cell.transparent = False
- cell.color = WALL_COLOR
+ color_layer.set(x, y, WALL_COLOR)
else:
# Floor
cell.walkable = True
cell.transparent = True
- cell.color = FLOOR_COLOR
-
+ color_layer.set(x, y, FLOOR_COLOR)
+
if char == 'E':
# Entity position
entity_positions.append((x, y))
-
+
# Create entities at marked positions
entities = []
original_positions = []
for i, (x, y) in enumerate(entity_positions):
- entity = mcrfpy.Entity(x, y)
+ entity = mcrfpy.Entity((x, y), grid=grid)
entity.sprite_index = 49 + i # '1', '2', '3'
- grid.entities.append(entity)
entities.append(entity)
original_positions.append((x, y))
-
+
return grid
def clear_path_highlight():
"""Clear any existing path highlighting"""
global current_path
-
+
# Reset all floor tiles to original color
for y in range(grid.grid_y):
for x in range(grid.grid_x):
cell = grid.at(x, y)
if cell.walkable:
- cell.color = FLOOR_COLOR
-
+ color_layer.set(x, y, FLOOR_COLOR)
+
current_path = []
def highlight_path():
"""Highlight the path between selected entities using entity.path_to()"""
global current_path
-
+
if first_point is None or second_point is None:
return
-
+
# Clear previous highlighting
clear_path_highlight()
-
+
# Get entities
entity1 = entities[first_point]
entity2 = entities[second_point]
-
+
# Use the new path_to method!
path = entity1.path_to(int(entity2.x), int(entity2.y))
-
+
if path:
current_path = path
-
+
# Highlight the path
for i, (x, y) in enumerate(path):
cell = grid.at(x, y)
if cell.walkable:
# Use gradient for path visualization
if i < len(path) - 1:
- cell.color = PATH_COLOR
+ color_layer.set(x, y, PATH_COLOR)
else:
- cell.color = VISITED_COLOR
-
+ color_layer.set(x, y, VISITED_COLOR)
+
# Highlight start and end with entity colors
- grid.at(int(entity1.x), int(entity1.y)).color = ENTITY_COLORS[first_point]
- grid.at(int(entity2.x), int(entity2.y)).color = ENTITY_COLORS[second_point]
-
+ color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point])
+ color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point])
+
# Update info
info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps"
else:
@@ -291,39 +294,38 @@ grid.size = (560, 400) # 14*40, 10*40
grid.position = (120, 60)
# Add title
-title = mcrfpy.Caption("Enhanced Dijkstra Pathfinding", 250, 10)
+title = mcrfpy.Caption(pos=(250, 10), text="Enhanced Dijkstra Pathfinding")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Add status text
-status_text = mcrfpy.Caption("Press 1/2/3 for first entity, A/B/C for second", 120, 480)
+status_text = mcrfpy.Caption(pos=(120, 480), text="Press 1/2/3 for first entity, A/B/C for second")
status_text.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(status_text)
# Add info text
-info_text = mcrfpy.Caption("Space to clear, Q to quit", 120, 500)
+info_text = mcrfpy.Caption(pos=(120, 500), text="Space to clear, Q to quit")
info_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(info_text)
# Add control text
-control_text = mcrfpy.Caption("Press M to move, P to pause, R to reset", 120, 520)
+control_text = mcrfpy.Caption(pos=(120, 520), text="Press M to move, P to pause, R to reset")
control_text.fill_color = mcrfpy.Color(150, 200, 150)
ui.append(control_text)
# Add legend
-legend1 = mcrfpy.Caption("Entities: 1=Red 2=Green 3=Blue", 120, 560)
+legend1 = mcrfpy.Caption(pos=(120, 560), text="Entities: 1=Red 2=Green 3=Blue")
legend1.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend1)
-legend2 = mcrfpy.Caption("Colors: Dark=Wall Light=Floor Cyan=Path", 120, 580)
+legend2 = mcrfpy.Caption(pos=(120, 580), text="Colors: Dark=Wall Light=Floor Cyan=Path")
legend2.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend2)
# Mark entity positions with colored indicators
for i, entity in enumerate(entities):
- marker = mcrfpy.Caption(str(i+1),
- 120 + int(entity.x) * 40 + 15,
- 60 + int(entity.y) * 40 + 10)
+ marker = mcrfpy.Caption(pos=(120 + int(entity.x) * 40 + 15, 60 + int(entity.y) * 40 + 10),
+ text=str(i+1))
marker.fill_color = ENTITY_COLORS[i]
marker.outline = 1
marker.outline_color = mcrfpy.Color(0, 0, 0)
diff --git a/tests/integration/dijkstra_test.py b/tests/integration/dijkstra_test.py
index 9f99eeb..928a56e 100644
--- a/tests/integration/dijkstra_test.py
+++ b/tests/integration/dijkstra_test.py
@@ -128,12 +128,12 @@ grid.position = (50, 50)
grid.size = (500, 300)
# Add title
-title = mcrfpy.Caption("Dijkstra Pathfinding Test", 200, 10)
+title = mcrfpy.Caption(pos=(200, 10), text="Dijkstra Pathfinding Test")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Add legend
-legend = mcrfpy.Caption("Red=Entity1 Green=Entity2 Blue=Entity3 Cyan=Path 1→3", 50, 360)
+legend = mcrfpy.Caption(pos=(50, 360), text="Red=Entity1 Green=Entity2 Blue=Entity3 Cyan=Path 1→3")
legend.fill_color = mcrfpy.Color(180, 180, 180)
ui.append(legend)
diff --git a/tests/integration/interactive_visibility.py b/tests/integration/interactive_visibility.py
index 3d7aef8..dcb386d 100644
--- a/tests/integration/interactive_visibility.py
+++ b/tests/integration/interactive_visibility.py
@@ -19,33 +19,36 @@ mcrfpy.createScene("visibility_demo")
grid = mcrfpy.Grid(grid_x=30, grid_y=20)
grid.fill_color = mcrfpy.Color(20, 20, 30) # Dark background
+# Add color layer for cell coloring
+color_layer = grid.add_layer("color", z_index=-1)
+
# Initialize grid - all walkable and transparent
for y in range(20):
for x in range(30):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
- cell.color = mcrfpy.Color(100, 100, 120) # Floor color
+ color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Floor color
# Create walls
walls = [
# Central cross
[(15, y) for y in range(8, 12)],
[(x, 10) for x in range(13, 18)],
-
+
# Rooms
# Top-left room
[(x, 5) for x in range(2, 8)] + [(8, y) for y in range(2, 6)],
[(2, y) for y in range(2, 6)] + [(x, 2) for x in range(2, 8)],
-
- # Top-right room
+
+ # Top-right room
[(x, 5) for x in range(22, 28)] + [(22, y) for y in range(2, 6)],
[(28, y) for y in range(2, 6)] + [(x, 2) for x in range(22, 28)],
-
+
# Bottom-left room
[(x, 15) for x in range(2, 8)] + [(8, y) for y in range(15, 18)],
[(2, y) for y in range(15, 18)] + [(x, 18) for x in range(2, 8)],
-
+
# Bottom-right room
[(x, 15) for x in range(22, 28)] + [(22, y) for y in range(15, 18)],
[(28, y) for y in range(15, 18)] + [(x, 18) for x in range(22, 28)],
@@ -57,12 +60,12 @@ for wall_group in walls:
cell = grid.at(x, y)
cell.walkable = False
cell.transparent = False
- cell.color = mcrfpy.Color(40, 20, 20) # Wall color
+ color_layer.set(x, y, mcrfpy.Color(40, 20, 20)) # Wall color
# Create entities
-player = mcrfpy.Entity(5, 10, grid=grid)
+player = mcrfpy.Entity((5, 10), grid=grid)
player.sprite_index = 64 # @
-enemy = mcrfpy.Entity(25, 10, grid=grid)
+enemy = mcrfpy.Entity((25, 10), grid=grid)
enemy.sprite_index = 69 # E
# Update initial visibility
@@ -80,24 +83,24 @@ grid.position = (50, 100)
grid.size = (900, 600) # 30*30, 20*30
# Title
-title = mcrfpy.Caption("Interactive Visibility Demo", 350, 20)
+title = mcrfpy.Caption(pos=(350, 20), text="Interactive Visibility Demo")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Info displays
-perspective_label = mcrfpy.Caption("Perspective: Omniscient", 50, 50)
+perspective_label = mcrfpy.Caption(pos=(50, 50), text="Perspective: Omniscient")
perspective_label.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(perspective_label)
-controls = mcrfpy.Caption("WASD: Move player | Arrows: Move enemy | Tab: Cycle perspective | Space: Update visibility | R: Reset", 50, 730)
+controls = mcrfpy.Caption(pos=(50, 730), text="WASD: Move player | Arrows: Move enemy | Tab: Cycle perspective | Space: Update visibility | R: Reset")
controls.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(controls)
-player_info = mcrfpy.Caption("Player: (5, 10)", 700, 50)
+player_info = mcrfpy.Caption(pos=(700, 50), text="Player: (5, 10)")
player_info.fill_color = mcrfpy.Color(100, 255, 100)
ui.append(player_info)
-enemy_info = mcrfpy.Caption("Enemy: (25, 10)", 700, 70)
+enemy_info = mcrfpy.Caption(pos=(700, 70), text="Enemy: (25, 10)")
enemy_info.fill_color = mcrfpy.Color(255, 100, 100)
ui.append(enemy_info)
diff --git a/tests/integration/simple_interactive_visibility.py b/tests/integration/simple_interactive_visibility.py
index fd95d5a..6243ecb 100644
--- a/tests/integration/simple_interactive_visibility.py
+++ b/tests/integration/simple_interactive_visibility.py
@@ -11,6 +11,9 @@ mcrfpy.createScene("vis_test")
print("Creating grid...")
grid = mcrfpy.Grid(grid_x=10, grid_y=10)
+# Add color layer for cell coloring
+color_layer = grid.add_layer("color", z_index=-1)
+
# Initialize grid
print("Initializing grid...")
for y in range(10):
@@ -18,11 +21,11 @@ for y in range(10):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
- cell.color = mcrfpy.Color(100, 100, 120)
+ color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
# Create entity
print("Creating entity...")
-entity = mcrfpy.Entity(5, 5, grid=grid)
+entity = mcrfpy.Entity((5, 5), grid=grid)
entity.sprite_index = 64
print("Updating visibility...")
diff --git a/tests/integration/simple_visibility_test.py b/tests/integration/simple_visibility_test.py
index 5c20758..1e00b73 100644
--- a/tests/integration/simple_visibility_test.py
+++ b/tests/integration/simple_visibility_test.py
@@ -13,8 +13,8 @@ print("Scene created")
grid = mcrfpy.Grid(grid_x=5, grid_y=5)
print("Grid created")
-# Create entity without appending
-entity = mcrfpy.Entity(2, 2, grid=grid)
+# Create entity with grid association
+entity = mcrfpy.Entity((2, 2), grid=grid)
print(f"Entity created at ({entity.x}, {entity.y})")
# Check if gridstate is initialized
diff --git a/tests/regression/issue_123_chunk_system_test.py b/tests/regression/issue_123_chunk_system_test.py
index 0437494..f8be052 100644
--- a/tests/regression/issue_123_chunk_system_test.py
+++ b/tests/regression/issue_123_chunk_system_test.py
@@ -8,6 +8,10 @@ while small grids use the original flat storage. Verifies that:
2. Large grids work correctly with chunks
3. Cell access (read/write) works for both modes
4. Rendering displays correctly for both modes
+
+NOTE: This test uses ColorLayer for color operations since cell.color
+is no longer supported. The chunk system affects internal storage, which
+ColorLayer also uses.
"""
import mcrfpy
@@ -19,22 +23,21 @@ def test_small_grid():
# Small grid should use flat storage
grid = mcrfpy.Grid(grid_size=(50, 50), pos=(10, 10), size=(400, 400))
+ color_layer = grid.add_layer("color", z_index=-1)
# Set some cells
for y in range(50):
for x in range(50):
cell = grid.at(x, y)
- cell.color = mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255)
+ color_layer.set(x, y, mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255))
cell.tilesprite = -1
# Verify cells
- cell = grid.at(25, 25)
expected_r = (25 * 5) % 256
expected_g = (25 * 5) % 256
- color = cell.color
- r, g = color[0], color[1]
- if r != expected_r or g != expected_g:
- print(f"FAIL: Small grid cell color mismatch. Expected ({expected_r}, {expected_g}), got ({r}, {g})")
+ color = color_layer.at(25, 25)
+ if color.r != expected_r or color.g != expected_g:
+ print(f"FAIL: Small grid cell color mismatch. Expected ({expected_r}, {expected_g}), got ({color.r}, {color.g})")
return False
print(" Small grid: PASS")
@@ -46,6 +49,7 @@ def test_large_grid():
# Large grid should use chunk storage (100 > 64)
grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400))
+ color_layer = grid.add_layer("color", z_index=-1)
# Set cells across multiple chunks
# Chunks are 64x64, so a 100x100 grid has 2x2 = 4 chunks
@@ -61,15 +65,14 @@ def test_large_grid():
for x, y in test_points:
cell = grid.at(x, y)
- cell.color = mcrfpy.Color(x, y, 100, 255)
+ color_layer.set(x, y, mcrfpy.Color(x, y, 100, 255))
cell.tilesprite = -1
# Verify cells
for x, y in test_points:
- cell = grid.at(x, y)
- color = cell.color
- if color[0] != x or color[1] != y:
- print(f"FAIL: Large grid cell ({x},{y}) color mismatch. Expected ({x}, {y}), got ({color[0]}, {color[1]})")
+ color = color_layer.at(x, y)
+ if color.r != x or color.g != y:
+ print(f"FAIL: Large grid cell ({x},{y}) color mismatch. Expected ({x}, {y}), got ({color.r}, {color.g})")
return False
print(" Large grid cell access: PASS")
@@ -81,6 +84,7 @@ def test_very_large_grid():
# 500x500 = 250,000 cells, should use ~64 chunks (8x8)
grid = mcrfpy.Grid(grid_size=(500, 500), pos=(10, 10), size=(400, 400))
+ color_layer = grid.add_layer("color", z_index=-1)
# Set some cells at various positions
test_points = [
@@ -94,14 +98,12 @@ def test_very_large_grid():
]
for x, y in test_points:
- cell = grid.at(x, y)
- cell.color = mcrfpy.Color(x % 256, y % 256, 200, 255)
+ color_layer.set(x, y, mcrfpy.Color(x % 256, y % 256, 200, 255))
# Verify
for x, y in test_points:
- cell = grid.at(x, y)
- color = cell.color
- if color[0] != (x % 256) or color[1] != (y % 256):
+ color = color_layer.at(x, y)
+ if color.r != (x % 256) or color.g != (y % 256):
print(f"FAIL: Very large grid cell ({x},{y}) color mismatch")
return False
@@ -114,20 +116,20 @@ def test_boundary_case():
# 64x64 should use flat storage (not exceeding threshold)
grid_64 = mcrfpy.Grid(grid_size=(64, 64), pos=(10, 10), size=(400, 400))
- cell = grid_64.at(63, 63)
- cell.color = mcrfpy.Color(255, 0, 0, 255)
- color = grid_64.at(63, 63).color
- if color[0] != 255:
- print(f"FAIL: 64x64 grid boundary cell not set correctly, got r={color[0]}")
+ color_layer_64 = grid_64.add_layer("color", z_index=-1)
+ color_layer_64.set(63, 63, mcrfpy.Color(255, 0, 0, 255))
+ color = color_layer_64.at(63, 63)
+ if color.r != 255:
+ print(f"FAIL: 64x64 grid boundary cell not set correctly, got r={color.r}")
return False
# 65x65 should use chunk storage (exceeding threshold)
grid_65 = mcrfpy.Grid(grid_size=(65, 65), pos=(10, 10), size=(400, 400))
- cell = grid_65.at(64, 64)
- cell.color = mcrfpy.Color(0, 255, 0, 255)
- color = grid_65.at(64, 64).color
- if color[1] != 255:
- print(f"FAIL: 65x65 grid cell not set correctly, got g={color[1]}")
+ color_layer_65 = grid_65.add_layer("color", z_index=-1)
+ color_layer_65.set(64, 64, mcrfpy.Color(0, 255, 0, 255))
+ color = color_layer_65.at(64, 64)
+ if color.g != 255:
+ print(f"FAIL: 65x65 grid cell not set correctly, got g={color.g}")
return False
print(" Boundary cases: PASS")
@@ -139,19 +141,18 @@ def test_edge_cases():
# Create 100x100 grid
grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400))
+ color_layer = grid.add_layer("color", z_index=-1)
# Test all corners
corners = [(0, 0), (99, 0), (0, 99), (99, 99)]
for i, (x, y) in enumerate(corners):
- cell = grid.at(x, y)
- cell.color = mcrfpy.Color(i * 60, i * 60, i * 60, 255)
+ color_layer.set(x, y, mcrfpy.Color(i * 60, i * 60, i * 60, 255))
for i, (x, y) in enumerate(corners):
- cell = grid.at(x, y)
expected = i * 60
- color = cell.color
- if color[0] != expected:
- print(f"FAIL: Corner ({x},{y}) color mismatch, expected {expected}, got {color[0]}")
+ color = color_layer.at(x, y)
+ if color.r != expected:
+ print(f"FAIL: Corner ({x},{y}) color mismatch, expected {expected}, got {color.r}")
return False
print(" Edge cases: PASS")
diff --git a/tests/regression/issue_76_test.py b/tests/regression/issue_76_test.py
index 96dd723..ecd985d 100644
--- a/tests/regression/issue_76_test.py
+++ b/tests/regression/issue_76_test.py
@@ -10,10 +10,10 @@ import sys
# Create a derived Entity class
class CustomEntity(mcrfpy.Entity):
- def __init__(self, x, y):
- super().__init__(x, y)
+ def __init__(self, pos):
+ super().__init__(pos)
self.custom_attribute = "I am custom!"
-
+
def custom_method(self):
return "Custom method called"
@@ -21,11 +21,11 @@ def run_test(runtime):
"""Test that derived entity classes maintain their type in collections"""
try:
# Create a grid
- grid = mcrfpy.Grid(10, 10)
-
+ grid = mcrfpy.Grid(grid_size=(10, 10))
+
# Create instances of base and derived entities
- base_entity = mcrfpy.Entity(1, 1)
- custom_entity = CustomEntity(2, 2)
+ base_entity = mcrfpy.Entity((1, 1))
+ custom_entity = CustomEntity((2, 2))
# Add them to the grid's entity collection
grid.entities.append(base_entity)
diff --git a/tests/unit/WORKING_automation_test_example.py b/tests/unit/WORKING_automation_test_example.py
index bb22673..91daaef 100644
--- a/tests/unit/WORKING_automation_test_example.py
+++ b/tests/unit/WORKING_automation_test_example.py
@@ -51,17 +51,17 @@ mcrfpy.setScene("timer_test_scene")
ui = mcrfpy.sceneUI("timer_test_scene")
# Add a bright red frame that should be visible
-frame = mcrfpy.Frame(100, 100, 400, 300,
+frame = mcrfpy.Frame(pos=(100, 100), size=(400, 300),
fill_color=mcrfpy.Color(255, 0, 0), # Bright red
outline_color=mcrfpy.Color(255, 255, 255), # White outline
outline=5.0)
ui.append(frame)
# Add text
-caption = mcrfpy.Caption(mcrfpy.Vector(150, 150),
+caption = mcrfpy.Caption(pos=(150, 150),
text="TIMER TEST - SHOULD BE VISIBLE",
fill_color=mcrfpy.Color(255, 255, 255))
-caption.size = 24
+caption.font_size = 24
frame.children.append(caption)
# Add click handler to demonstrate interaction
diff --git a/tests/unit/check_entity_attrs.py b/tests/unit/check_entity_attrs.py
index d0a44b8..564ea62 100644
--- a/tests/unit/check_entity_attrs.py
+++ b/tests/unit/check_entity_attrs.py
@@ -1,4 +1,4 @@
import mcrfpy
-e = mcrfpy.Entity(0, 0)
+e = mcrfpy.Entity((0, 0))
print("Entity attributes:", dir(e))
print("\nEntity repr:", repr(e))
\ No newline at end of file
diff --git a/tests/unit/debug_render_test.py b/tests/unit/debug_render_test.py
index d7c7f6c..1442f09 100644
--- a/tests/unit/debug_render_test.py
+++ b/tests/unit/debug_render_test.py
@@ -22,7 +22,7 @@ print(f"UI collection type: {type(ui)}")
print(f"Initial UI elements: {len(ui)}")
# Add a simple frame
-frame = mcrfpy.Frame(0, 0, 100, 100,
+frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100),
fill_color=mcrfpy.Color(255, 255, 255))
ui.append(frame)
print(f"After adding frame: {len(ui)} elements")
diff --git a/tests/unit/generate_grid_screenshot.py b/tests/unit/generate_grid_screenshot.py
index 706b704..8c4500d 100644
--- a/tests/unit/generate_grid_screenshot.py
+++ b/tests/unit/generate_grid_screenshot.py
@@ -22,14 +22,13 @@ mcrfpy.createScene("grid")
texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
# Title
-title = mcrfpy.Caption(400, 30, "Grid Example - Dungeon View")
+title = mcrfpy.Caption(pos=(400, 30), text="Grid Example - Dungeon View")
title.font = mcrfpy.default_font
title.font_size = 24
-title.font_color = (255, 255, 255)
+title.fill_color = mcrfpy.Color(255, 255, 255)
# Create main grid (20x15 tiles, each 32x32 pixels)
-grid = mcrfpy.Grid(100, 100, 20, 15, texture, 32, 32)
-grid.texture = texture
+grid = mcrfpy.Grid(pos=(100, 100), grid_size=(20, 15), texture=texture, size=(640, 480))
# Define tile types from Crypt of Sokoban
FLOOR = 58 # Stone floor
@@ -63,36 +62,21 @@ grid.set_tile(12, 8, BOULDER)
# Create some entities on the grid
# Player entity
-player = mcrfpy.Entity(5, 7)
-player.texture = texture
-player.sprite_index = 84 # Player sprite
+player = mcrfpy.Entity((5, 7), texture=texture, sprite_index=84, grid=grid) # Player sprite
# Enemy entities
-rat1 = mcrfpy.Entity(12, 5)
-rat1.texture = texture
-rat1.sprite_index = 123 # Rat
+rat1 = mcrfpy.Entity((12, 5), texture=texture, sprite_index=123, grid=grid) # Rat
-rat2 = mcrfpy.Entity(14, 9)
-rat2.texture = texture
-rat2.sprite_index = 123 # Rat
+rat2 = mcrfpy.Entity((14, 9), texture=texture, sprite_index=123, grid=grid) # Rat
-cyclops = mcrfpy.Entity(10, 10)
-cyclops.texture = texture
-cyclops.sprite_index = 109 # Cyclops
-
-# Add entities to grid
-grid.entities.append(player)
-grid.entities.append(rat1)
-grid.entities.append(rat2)
-grid.entities.append(cyclops)
+cyclops = mcrfpy.Entity((10, 10), texture=texture, sprite_index=109, grid=grid) # Cyclops
# Create a smaller grid showing tile palette
-palette_label = mcrfpy.Caption(100, 600, "Tile Types:")
+palette_label = mcrfpy.Caption(pos=(100, 600), text="Tile Types:")
palette_label.font = mcrfpy.default_font
-palette_label.font_color = (255, 255, 255)
+palette_label.fill_color = mcrfpy.Color(255, 255, 255)
-palette = mcrfpy.Grid(250, 580, 7, 1, texture, 32, 32)
-palette.texture = texture
+palette = mcrfpy.Grid(pos=(250, 580), grid_size=(7, 1), texture=texture, size=(224, 32))
palette.set_tile(0, 0, FLOOR)
palette.set_tile(1, 0, WALL)
palette.set_tile(2, 0, DOOR)
@@ -104,17 +88,17 @@ palette.set_tile(6, 0, BOULDER)
# Labels for palette
labels = ["Floor", "Wall", "Door", "Chest", "Button", "Exit", "Boulder"]
for i, label in enumerate(labels):
- l = mcrfpy.Caption(250 + i * 32, 615, label)
+ l = mcrfpy.Caption(pos=(250 + i * 32, 615), text=label)
l.font = mcrfpy.default_font
l.font_size = 10
- l.font_color = (255, 255, 255)
+ l.fill_color = mcrfpy.Color(255, 255, 255)
mcrfpy.sceneUI("grid").append(l)
# Add info caption
-info = mcrfpy.Caption(100, 680, "Grid supports tiles and entities. Entities can move independently of the tile grid.")
+info = mcrfpy.Caption(pos=(100, 680), text="Grid supports tiles and entities. Entities can move independently of the tile grid.")
info.font = mcrfpy.default_font
info.font_size = 14
-info.font_color = (200, 200, 200)
+info.fill_color = mcrfpy.Color(200, 200, 200)
# Add all elements to scene
ui = mcrfpy.sceneUI("grid")
diff --git a/tests/unit/generate_sprite_screenshot.py b/tests/unit/generate_sprite_screenshot.py
index 3a314bb..ff6114c 100644
--- a/tests/unit/generate_sprite_screenshot.py
+++ b/tests/unit/generate_sprite_screenshot.py
@@ -22,20 +22,20 @@ mcrfpy.createScene("sprites")
texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
# Title
-title = mcrfpy.Caption(400, 30, "Sprite Examples")
+title = mcrfpy.Caption(pos=(400, 30), text="Sprite Examples")
title.font = mcrfpy.default_font
title.font_size = 24
-title.font_color = (255, 255, 255)
+title.fill_color = mcrfpy.Color(255, 255, 255)
# Create a frame background
-frame = mcrfpy.Frame(50, 80, 700, 500)
-frame.bgcolor = (64, 64, 128)
+frame = mcrfpy.Frame(pos=(50, 80), size=(700, 500))
+frame.fill_color = mcrfpy.Color(64, 64, 128)
frame.outline = 2
# Player sprite
-player_label = mcrfpy.Caption(100, 120, "Player")
+player_label = mcrfpy.Caption(pos=(100, 120), text="Player")
player_label.font = mcrfpy.default_font
-player_label.font_color = (255, 255, 255)
+player_label.fill_color = mcrfpy.Color(255, 255, 255)
player = mcrfpy.Sprite(120, 150)
player.texture = texture
@@ -43,9 +43,9 @@ player.sprite_index = 84 # Player sprite
player.scale = (3.0, 3.0)
# Enemy sprites
-enemy_label = mcrfpy.Caption(250, 120, "Enemies")
+enemy_label = mcrfpy.Caption(pos=(250, 120), text="Enemies")
enemy_label.font = mcrfpy.default_font
-enemy_label.font_color = (255, 255, 255)
+enemy_label.fill_color = mcrfpy.Color(255, 255, 255)
rat = mcrfpy.Sprite(250, 150)
rat.texture = texture
@@ -63,9 +63,9 @@ cyclops.sprite_index = 109 # Cyclops
cyclops.scale = (3.0, 3.0)
# Items row
-items_label = mcrfpy.Caption(100, 250, "Items")
+items_label = mcrfpy.Caption(pos=(100, 250), text="Items")
items_label.font = mcrfpy.default_font
-items_label.font_color = (255, 255, 255)
+items_label.fill_color = mcrfpy.Color(255, 255, 255)
# Boulder
boulder = mcrfpy.Sprite(100, 280)
@@ -92,9 +92,9 @@ button.sprite_index = 250 # Button
button.scale = (3.0, 3.0)
# UI elements row
-ui_label = mcrfpy.Caption(100, 380, "UI Elements")
+ui_label = mcrfpy.Caption(pos=(100, 380), text="UI Elements")
ui_label.font = mcrfpy.default_font
-ui_label.font_color = (255, 255, 255)
+ui_label.fill_color = mcrfpy.Color(255, 255, 255)
# Hearts
heart_full = mcrfpy.Sprite(100, 410)
@@ -119,9 +119,9 @@ armor.sprite_index = 211 # Armor
armor.scale = (3.0, 3.0)
# Scale demonstration
-scale_label = mcrfpy.Caption(500, 120, "Scale Demo")
+scale_label = mcrfpy.Caption(pos=(500, 120), text="Scale Demo")
scale_label.font = mcrfpy.default_font
-scale_label.font_color = (255, 255, 255)
+scale_label.fill_color = mcrfpy.Color(255, 255, 255)
# Same sprite at different scales
for i, scale in enumerate([1.0, 2.0, 3.0, 4.0]):
diff --git a/tests/unit/screenshot_transparency_fix_test.py b/tests/unit/screenshot_transparency_fix_test.py
index 7da8878..5d5e333 100644
--- a/tests/unit/screenshot_transparency_fix_test.py
+++ b/tests/unit/screenshot_transparency_fix_test.py
@@ -17,42 +17,42 @@ def test_transparency_workaround():
# WORKAROUND: Create a full-window opaque frame as the first element
# This acts as an opaque background since the scene clears with transparent
print("Creating full-window opaque background...")
- background = mcrfpy.Frame(0, 0, 1024, 768,
+ background = mcrfpy.Frame(pos=(0, 0), size=(1024, 768),
fill_color=mcrfpy.Color(50, 50, 50), # Dark gray
outline_color=None,
outline=0.0)
ui.append(background)
print("✓ Added opaque background frame")
-
+
# Now add normal content on top
print("\nAdding test content...")
-
+
# Red frame
- frame1 = mcrfpy.Frame(100, 100, 200, 150,
+ frame1 = mcrfpy.Frame(pos=(100, 100), size=(200, 150),
fill_color=mcrfpy.Color(255, 0, 0),
outline_color=mcrfpy.Color(255, 255, 255),
outline=3.0)
ui.append(frame1)
-
+
# Green frame
- frame2 = mcrfpy.Frame(350, 100, 200, 150,
+ frame2 = mcrfpy.Frame(pos=(350, 100), size=(200, 150),
fill_color=mcrfpy.Color(0, 255, 0),
outline_color=mcrfpy.Color(0, 0, 0),
outline=3.0)
ui.append(frame2)
-
+
# Blue frame
- frame3 = mcrfpy.Frame(100, 300, 200, 150,
+ frame3 = mcrfpy.Frame(pos=(100, 300), size=(200, 150),
fill_color=mcrfpy.Color(0, 0, 255),
outline_color=mcrfpy.Color(255, 255, 0),
outline=3.0)
ui.append(frame3)
-
+
# Add text
- caption = mcrfpy.Caption(mcrfpy.Vector(250, 50),
+ caption = mcrfpy.Caption(pos=(250, 50),
text="OPAQUE BACKGROUND TEST",
fill_color=mcrfpy.Color(255, 255, 255))
- caption.size = 32
+ caption.font_size = 32
ui.append(caption)
# Take screenshot
diff --git a/tests/unit/simple_screenshot_test.py b/tests/unit/simple_screenshot_test.py
index 42815a4..3117a81 100644
--- a/tests/unit/simple_screenshot_test.py
+++ b/tests/unit/simple_screenshot_test.py
@@ -31,9 +31,9 @@ def take_screenshot(runtime):
mcrfpy.createScene("test")
# Add a visible element
-caption = mcrfpy.Caption(100, 100, "Screenshot Test")
+caption = mcrfpy.Caption(pos=(100, 100), text="Screenshot Test")
caption.font = mcrfpy.default_font
-caption.font_color = (255, 255, 255)
+caption.fill_color = mcrfpy.Color(255, 255, 255)
caption.font_size = 24
mcrfpy.sceneUI("test").append(caption)
diff --git a/tests/unit/simple_timer_screenshot_test.py b/tests/unit/simple_timer_screenshot_test.py
index 5a5c9ac..d4aa001 100644
--- a/tests/unit/simple_timer_screenshot_test.py
+++ b/tests/unit/simple_timer_screenshot_test.py
@@ -30,7 +30,7 @@ mcrfpy.setScene("test")
ui = mcrfpy.sceneUI("test")
# Add visible content - a white frame on default background
-frame = mcrfpy.Frame(100, 100, 200, 200,
+frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200),
fill_color=mcrfpy.Color(255, 255, 255))
ui.append(frame)
diff --git a/tests/unit/test_animation_chaining.py b/tests/unit/test_animation_chaining.py
index b8402fd..7b3700a 100644
--- a/tests/unit/test_animation_chaining.py
+++ b/tests/unit/test_animation_chaining.py
@@ -73,6 +73,9 @@ mcrfpy.createScene("chain_test")
grid = mcrfpy.Grid(grid_x=20, grid_y=15)
grid.fill_color = mcrfpy.Color(20, 20, 30)
+# Add a color layer for cell coloring
+color_layer = grid.add_layer("color", z_index=-1)
+
# Simple map
for y in range(15):
for x in range(20):
@@ -80,17 +83,17 @@ for y in range(15):
if x == 0 or x == 19 or y == 0 or y == 14:
cell.walkable = False
cell.transparent = False
- cell.color = mcrfpy.Color(60, 40, 40)
+ color_layer.set(x, y, mcrfpy.Color(60, 40, 40))
else:
cell.walkable = True
cell.transparent = True
- cell.color = mcrfpy.Color(100, 100, 120)
+ color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
# Create entities
-player = mcrfpy.Entity(2, 2, grid=grid)
+player = mcrfpy.Entity((2, 2), grid=grid)
player.sprite_index = 64 # @
-enemy = mcrfpy.Entity(17, 12, grid=grid)
+enemy = mcrfpy.Entity((17, 12), grid=grid)
enemy.sprite_index = 69 # E
# UI setup
@@ -99,15 +102,15 @@ ui.append(grid)
grid.position = (100, 100)
grid.size = (600, 450)
-title = mcrfpy.Caption("Animation Chaining Test", 300, 20)
+title = mcrfpy.Caption(pos=(300, 20), text="Animation Chaining Test")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
-status = mcrfpy.Caption("Press 1: Animate Player | 2: Animate Enemy | 3: Both | Q: Quit", 100, 50)
+status = mcrfpy.Caption(pos=(100, 50), text="Press 1: Animate Player | 2: Animate Enemy | 3: Both | Q: Quit")
status.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status)
-info = mcrfpy.Caption("Status: Ready", 100, 70)
+info = mcrfpy.Caption(pos=(100, 70), text="Status: Ready")
info.fill_color = mcrfpy.Color(100, 255, 100)
ui.append(info)
diff --git a/tests/unit/test_animation_debug.py b/tests/unit/test_animation_debug.py
index 0b7ab7c..16c21a7 100644
--- a/tests/unit/test_animation_debug.py
+++ b/tests/unit/test_animation_debug.py
@@ -63,14 +63,15 @@ mcrfpy.createScene("anim_debug")
# Simple grid
grid = mcrfpy.Grid(grid_x=15, grid_y=10)
+color_layer = grid.add_layer("color", z_index=-1)
for y in range(10):
for x in range(15):
cell = grid.at(x, y)
cell.walkable = True
- cell.color = mcrfpy.Color(100, 100, 120)
+ color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
# Test entity
-entity = mcrfpy.Entity(5, 5, grid=grid)
+entity = mcrfpy.Entity((5, 5), grid=grid)
entity.sprite_index = 64
# UI
@@ -79,19 +80,19 @@ ui.append(grid)
grid.position = (100, 150)
grid.size = (450, 300)
-title = mcrfpy.Caption("Animation Debug Tool", 250, 20)
+title = mcrfpy.Caption(pos=(250, 20), text="Animation Debug Tool")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
-status = mcrfpy.Caption("Press keys to test animations", 100, 50)
+status = mcrfpy.Caption(pos=(100, 50), text="Press keys to test animations")
status.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status)
-pos_display = mcrfpy.Caption("", 100, 70)
+pos_display = mcrfpy.Caption(pos=(100, 70), text="")
pos_display.fill_color = mcrfpy.Color(255, 255, 100)
ui.append(pos_display)
-active_display = mcrfpy.Caption("Active animations: 0", 100, 90)
+active_display = mcrfpy.Caption(pos=(100, 90), text="Active animations: 0")
active_display.fill_color = mcrfpy.Color(100, 255, 255)
ui.append(active_display)
diff --git a/tests/unit/test_animation_immediate.py b/tests/unit/test_animation_immediate.py
index d24f713..e78c63c 100644
--- a/tests/unit/test_animation_immediate.py
+++ b/tests/unit/test_animation_immediate.py
@@ -13,7 +13,7 @@ print("2. Getting UI...")
ui = mcrfpy.sceneUI("test")
print("3. Creating frame...")
-frame = mcrfpy.Frame(100, 100, 200, 200)
+frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200))
ui.append(frame)
print("4. Creating Animation object...")
diff --git a/tests/unit/test_animation_raii.py b/tests/unit/test_animation_raii.py
index 86ce225..53de59b 100644
--- a/tests/unit/test_animation_raii.py
+++ b/tests/unit/test_animation_raii.py
@@ -30,7 +30,7 @@ def test_1_basic_animation():
"""Test that basic animations still work"""
try:
ui = mcrfpy.sceneUI("test")
- frame = mcrfpy.Frame(100, 100, 100, 100)
+ frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100))
ui.append(frame)
anim = mcrfpy.Animation("x", 200.0, 1000, "linear")
@@ -49,7 +49,7 @@ def test_2_remove_animated_object():
"""Test removing object with active animation"""
try:
ui = mcrfpy.sceneUI("test")
- frame = mcrfpy.Frame(100, 100, 100, 100)
+ frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100))
ui.append(frame)
# Start animation
@@ -73,7 +73,7 @@ def test_3_complete_animation():
"""Test completing animation immediately"""
try:
ui = mcrfpy.sceneUI("test")
- frame = mcrfpy.Frame(100, 100, 100, 100)
+ frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100))
ui.append(frame)
# Start animation
@@ -98,7 +98,7 @@ def test_4_multiple_animations_timer():
nonlocal success
try:
ui = mcrfpy.sceneUI("test")
- frame = mcrfpy.Frame(200, 200, 100, 100)
+ frame = mcrfpy.Frame(pos=(200, 200), size=(100, 100))
ui.append(frame)
# Create multiple animations rapidly (this used to crash)
@@ -129,7 +129,7 @@ def test_5_scene_cleanup():
# Add animated objects to first scene
ui = mcrfpy.sceneUI("test")
for i in range(5):
- frame = mcrfpy.Frame(50 * i, 100, 40, 40)
+ frame = mcrfpy.Frame(pos=(50 * i, 100), size=(40, 40))
ui.append(frame)
anim = mcrfpy.Animation("y", 300.0, 2000, "easeOutBounce")
anim.start(frame)
@@ -148,9 +148,9 @@ def test_6_animation_after_clear():
"""Test animations after clearing UI"""
try:
ui = mcrfpy.sceneUI("test")
-
+
# Create and animate
- frame = mcrfpy.Frame(100, 100, 100, 100)
+ frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100))
ui.append(frame)
anim = mcrfpy.Animation("w", 200.0, 1500, "easeInOutCubic")
anim.start(frame)
@@ -207,7 +207,7 @@ mcrfpy.setScene("test")
# Add a background
ui = mcrfpy.sceneUI("test")
-bg = mcrfpy.Frame(0, 0, 1024, 768)
+bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768))
bg.fill_color = mcrfpy.Color(20, 20, 30)
ui.append(bg)
diff --git a/tests/unit/test_animation_removal.py b/tests/unit/test_animation_removal.py
index a626d91..3aac09d 100644
--- a/tests/unit/test_animation_removal.py
+++ b/tests/unit/test_animation_removal.py
@@ -42,14 +42,14 @@ mcrfpy.setScene("test")
ui = mcrfpy.sceneUI("test")
# Add title and subtitle (to preserve during clearing)
-title = mcrfpy.Caption("Test Title", 400, 20)
-subtitle = mcrfpy.Caption("Test Subtitle", 400, 50)
+title = mcrfpy.Caption(pos=(400, 20), text="Test Title")
+subtitle = mcrfpy.Caption(pos=(400, 50), text="Test Subtitle")
ui.extend([title, subtitle])
# Create initial animated objects
print("Creating initial animated objects...")
for i in range(10):
- f = mcrfpy.Frame(50 + i*30, 100, 25, 25)
+ f = mcrfpy.Frame(pos=(50 + i*30, 100), size=(25, 25))
f.fill_color = mcrfpy.Color(255, 100, 100)
ui.append(f)
diff --git a/tests/unit/test_dijkstra_pathfinding.py b/tests/unit/test_dijkstra_pathfinding.py
index 65ee1e6..a28b103 100644
--- a/tests/unit/test_dijkstra_pathfinding.py
+++ b/tests/unit/test_dijkstra_pathfinding.py
@@ -17,10 +17,15 @@ import sys
def create_test_grid():
"""Create a test grid with obstacles"""
mcrfpy.createScene("dijkstra_test")
-
+
# Create grid
grid = mcrfpy.Grid(grid_x=20, grid_y=20)
-
+
+ # Add color layer for cell coloring
+ color_layer = grid.add_layer("color", z_index=-1)
+ # Store color_layer on grid for access elsewhere
+ grid._color_layer = color_layer
+
# Initialize all cells as walkable
for y in range(grid.grid_y):
for x in range(grid.grid_x):
@@ -28,8 +33,8 @@ def create_test_grid():
cell.walkable = True
cell.transparent = True
cell.tilesprite = 46 # . period
- cell.color = mcrfpy.Color(50, 50, 50)
-
+ color_layer.set(x, y, mcrfpy.Color(50, 50, 50))
+
# Create some walls to make pathfinding interesting
# Vertical wall
for y in range(5, 15):
@@ -37,8 +42,8 @@ def create_test_grid():
cell.walkable = False
cell.transparent = False
cell.tilesprite = 219 # Block
- cell.color = mcrfpy.Color(100, 100, 100)
-
+ color_layer.set(10, y, mcrfpy.Color(100, 100, 100))
+
# Horizontal wall
for x in range(5, 15):
if x != 10: # Leave a gap
@@ -46,8 +51,8 @@ def create_test_grid():
cell.walkable = False
cell.transparent = False
cell.tilesprite = 219
- cell.color = mcrfpy.Color(100, 100, 100)
-
+ color_layer.set(x, 10, mcrfpy.Color(100, 100, 100))
+
return grid
def test_basic_dijkstra():
@@ -133,7 +138,7 @@ def test_multi_target_scenario():
# Mark threat position
cell = grid.at(tx, ty)
cell.tilesprite = 84 # T for threat
- cell.color = mcrfpy.Color(255, 0, 0)
+ grid._color_layer.set(tx, ty, mcrfpy.Color(255, 0, 0))
# Compute Dijkstra from this threat
grid.compute_dijkstra(tx, ty)
@@ -176,7 +181,7 @@ def test_multi_target_scenario():
# Mark safe position
cell = grid.at(best_pos[0], best_pos[1])
cell.tilesprite = 83 # S for safe
- cell.color = mcrfpy.Color(0, 255, 0)
+ grid._color_layer.set(best_pos[0], best_pos[1], mcrfpy.Color(0, 255, 0))
def run_test(runtime):
"""Timer callback to run tests after scene loads"""
@@ -211,7 +216,7 @@ ui = mcrfpy.sceneUI("dijkstra_test")
ui.append(grid)
# Add title
-title = mcrfpy.Caption("Dijkstra Pathfinding Test", 10, 10)
+title = mcrfpy.Caption(pos=(10, 10), text="Dijkstra Pathfinding Test")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
diff --git a/tests/unit/test_entity_animation.py b/tests/unit/test_entity_animation.py
index 342f340..2cf539e 100644
--- a/tests/unit/test_entity_animation.py
+++ b/tests/unit/test_entity_animation.py
@@ -17,13 +17,16 @@ mcrfpy.createScene("test_anim")
grid = mcrfpy.Grid(grid_x=15, grid_y=15)
grid.fill_color = mcrfpy.Color(20, 20, 30)
+# Add a color layer for cell coloring
+color_layer = grid.add_layer("color", z_index=-1)
+
# Initialize all cells as walkable floors
for y in range(15):
for x in range(15):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
- cell.color = mcrfpy.Color(100, 100, 120)
+ color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
# Mark the path we'll follow with different color
path_cells = [(5,5), (6,5), (7,5), (8,5), (9,5), (10,5),
@@ -32,11 +35,10 @@ path_cells = [(5,5), (6,5), (7,5), (8,5), (9,5), (10,5),
(5,9), (5,8), (5,7), (5,6)]
for x, y in path_cells:
- cell = grid.at(x, y)
- cell.color = mcrfpy.Color(120, 120, 150)
+ color_layer.set(x, y, mcrfpy.Color(120, 120, 150))
# Create entity at start position
-entity = mcrfpy.Entity(5, 5, grid=grid)
+entity = mcrfpy.Entity((5, 5), grid=grid)
entity.sprite_index = 64 # @
# UI setup
@@ -46,27 +48,27 @@ grid.position = (100, 100)
grid.size = (450, 450) # 15 * 30 pixels per cell
# Title
-title = mcrfpy.Caption("Entity Animation Test - Square Path", 200, 20)
+title = mcrfpy.Caption(pos=(200, 20), text="Entity Animation Test - Square Path")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Status display
-status = mcrfpy.Caption("Press SPACE to start animation | Q to quit", 100, 50)
+status = mcrfpy.Caption(pos=(100, 50), text="Press SPACE to start animation | Q to quit")
status.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status)
# Position display
-pos_display = mcrfpy.Caption(f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})", 100, 70)
+pos_display = mcrfpy.Caption(pos=(100, 70), text=f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})")
pos_display.fill_color = mcrfpy.Color(255, 255, 100)
ui.append(pos_display)
# Animation info
-anim_info = mcrfpy.Caption("Animation: Not started", 400, 70)
+anim_info = mcrfpy.Caption(pos=(400, 70), text="Animation: Not started")
anim_info.fill_color = mcrfpy.Color(100, 255, 255)
ui.append(anim_info)
# Debug info
-debug_info = mcrfpy.Caption("Debug: Waiting...", 100, 570)
+debug_info = mcrfpy.Caption(pos=(100, 570), text="Debug: Waiting...")
debug_info.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(debug_info)
diff --git a/tests/unit/test_entity_fix.py b/tests/unit/test_entity_fix.py
index 90a660d..eef131b 100644
--- a/tests/unit/test_entity_fix.py
+++ b/tests/unit/test_entity_fix.py
@@ -33,16 +33,19 @@ mcrfpy.createScene("fix_demo")
grid = mcrfpy.Grid(grid_x=15, grid_y=10)
grid.fill_color = mcrfpy.Color(20, 20, 30)
+# Add color layer for cell coloring
+color_layer = grid.add_layer("color", z_index=-1)
+
# Make floor
for y in range(10):
for x in range(15):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
- cell.color = mcrfpy.Color(100, 100, 120)
+ color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
# Create entity
-entity = mcrfpy.Entity(2, 2, grid=grid)
+entity = mcrfpy.Entity((2, 2), grid=grid)
entity.sprite_index = 64 # @
# UI
@@ -52,19 +55,19 @@ grid.position = (100, 150)
grid.size = (450, 300)
# Info displays
-title = mcrfpy.Caption("Entity Animation Issue Demo", 250, 20)
+title = mcrfpy.Caption(pos=(250, 20), text="Entity Animation Issue Demo")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
-pos_info = mcrfpy.Caption("", 100, 50)
+pos_info = mcrfpy.Caption(pos=(100, 50), text="")
pos_info.fill_color = mcrfpy.Color(255, 255, 100)
ui.append(pos_info)
-sprite_info = mcrfpy.Caption("", 100, 70)
+sprite_info = mcrfpy.Caption(pos=(100, 70), text="")
sprite_info.fill_color = mcrfpy.Color(255, 100, 100)
ui.append(sprite_info)
-status = mcrfpy.Caption("Press SPACE to animate entity", 100, 100)
+status = mcrfpy.Caption(pos=(100, 100), text="Press SPACE to animate entity")
status.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status)
diff --git a/tests/unit/test_entity_path_to.py b/tests/unit/test_entity_path_to.py
index eab54d4..caeb4c1 100644
--- a/tests/unit/test_entity_path_to.py
+++ b/tests/unit/test_entity_path_to.py
@@ -22,8 +22,7 @@ for x, y in walls:
grid.at(x, y).walkable = False
# Create entity
-entity = mcrfpy.Entity(2, 2)
-grid.entities.append(entity)
+entity = mcrfpy.Entity((2, 2), grid=grid)
print(f"Entity at: ({entity.x}, {entity.y})")
diff --git a/tests/unit/test_entity_path_to_edge_cases.py b/tests/unit/test_entity_path_to_edge_cases.py
index f255aca..ef67d8f 100644
--- a/tests/unit/test_entity_path_to_edge_cases.py
+++ b/tests/unit/test_entity_path_to_edge_cases.py
@@ -9,7 +9,7 @@ print("=" * 50)
# Test 1: Entity without grid
print("Test 1: Entity not in grid")
try:
- entity = mcrfpy.Entity(5, 5)
+ entity = mcrfpy.Entity((5, 5))
path = entity.path_to(8, 8)
print(" ✗ Should have failed for entity not in grid")
except ValueError as e:
@@ -31,8 +31,7 @@ for y in range(5):
for x in range(5):
grid.at(x, 2).walkable = False
-entity = mcrfpy.Entity(1, 1)
-grid.entities.append(entity)
+entity = mcrfpy.Entity((1, 1), grid=grid)
try:
path = entity.path_to(1, 4)
diff --git a/tests/unit/test_grid_background.py b/tests/unit/test_grid_background.py
index c79cf8e..b74daf4 100644
--- a/tests/unit/test_grid_background.py
+++ b/tests/unit/test_grid_background.py
@@ -13,32 +13,28 @@ def test_grid_background():
ui = mcrfpy.sceneUI("test")
# Create a grid with default background
- grid = mcrfpy.Grid(20, 15, grid_size=(20, 15))
- grid.x = 50
- grid.y = 50
- grid.w = 400
- grid.h = 300
+ grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15))
ui.append(grid)
-
- # Add some tiles to see the background better
+
+ # Add color layer for some tiles to see the background better
+ color_layer = grid.add_layer("color", z_index=-1)
for x in range(5, 15):
for y in range(5, 10):
- point = grid.at(x, y)
- point.color = mcrfpy.Color(100, 150, 100)
+ color_layer.set(x, y, mcrfpy.Color(100, 150, 100))
# Add UI to show current background color
- info_frame = mcrfpy.Frame(500, 50, 200, 150,
+ info_frame = mcrfpy.Frame(pos=(500, 50), size=(200, 150),
fill_color=mcrfpy.Color(40, 40, 40),
outline_color=mcrfpy.Color(200, 200, 200),
outline=2)
ui.append(info_frame)
-
- color_caption = mcrfpy.Caption(510, 60, "Background Color:")
+
+ color_caption = mcrfpy.Caption(pos=(510, 60), text="Background Color:")
color_caption.font_size = 14
color_caption.fill_color = mcrfpy.Color(255, 255, 255)
info_frame.children.append(color_caption)
-
- color_display = mcrfpy.Caption(510, 80, "")
+
+ color_display = mcrfpy.Caption(pos=(510, 80), text="")
color_display.font_size = 12
color_display.fill_color = mcrfpy.Color(200, 200, 200)
info_frame.children.append(color_display)
diff --git a/tests/unit/test_headless_detection.py b/tests/unit/test_headless_detection.py
index bfc284e..babe65d 100644
--- a/tests/unit/test_headless_detection.py
+++ b/tests/unit/test_headless_detection.py
@@ -11,8 +11,8 @@ ui = mcrfpy.sceneUI("detect_test")
mcrfpy.setScene("detect_test")
# Create a frame
-frame = mcrfpy.Frame(100, 100, 200, 200)
-frame.fill_color = (255, 100, 100, 255)
+frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200))
+frame.fill_color = mcrfpy.Color(255, 100, 100, 255)
ui.append(frame)
def test_mode(runtime):
diff --git a/tests/unit/test_headless_modes.py b/tests/unit/test_headless_modes.py
index 124e9f9..3e36658 100644
--- a/tests/unit/test_headless_modes.py
+++ b/tests/unit/test_headless_modes.py
@@ -10,13 +10,13 @@ ui = mcrfpy.sceneUI("headless_test")
mcrfpy.setScene("headless_test")
# Create a visible indicator
-frame = mcrfpy.Frame(200, 200, 400, 200)
-frame.fill_color = (100, 200, 100, 255)
+frame = mcrfpy.Frame(pos=(200, 200), size=(400, 200))
+frame.fill_color = mcrfpy.Color(100, 200, 100, 255)
ui.append(frame)
-caption = mcrfpy.Caption((400, 300), "If you see this, windowed mode is working!", mcrfpy.default_font)
-caption.size = 24
-caption.fill_color = (255, 255, 255)
+caption = mcrfpy.Caption(pos=(400, 300), text="If you see this, windowed mode is working!")
+caption.font_size = 24
+caption.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(caption)
print("Script started. Window should appear unless --headless was specified.")
diff --git a/tests/unit/test_metrics.py b/tests/unit/test_metrics.py
index e760b2b..885e2c5 100644
--- a/tests/unit/test_metrics.py
+++ b/tests/unit/test_metrics.py
@@ -115,18 +115,18 @@ mcrfpy.setScene("metrics_test")
ui = mcrfpy.sceneUI("metrics_test")
# Create various UI elements
-frame1 = mcrfpy.Frame(10, 10, 200, 150)
-frame1.fill_color = (100, 100, 100, 128)
+frame1 = mcrfpy.Frame(pos=(10, 10), size=(200, 150))
+frame1.fill_color = mcrfpy.Color(100, 100, 100, 128)
ui.append(frame1)
-caption1 = mcrfpy.Caption("Test Caption", 50, 50)
+caption1 = mcrfpy.Caption(pos=(50, 50), text="Test Caption")
ui.append(caption1)
-sprite1 = mcrfpy.Sprite(100, 100)
+sprite1 = mcrfpy.Sprite(pos=(100, 100))
ui.append(sprite1)
# Invisible element (should not count as visible)
-frame2 = mcrfpy.Frame(300, 10, 100, 100)
+frame2 = mcrfpy.Frame(pos=(300, 10), size=(100, 100))
frame2.visible = False
ui.append(frame2)
diff --git a/tests/unit/test_path_colors.py b/tests/unit/test_path_colors.py
index 779ff9e..1bcd9cd 100644
--- a/tests/unit/test_path_colors.py
+++ b/tests/unit/test_path_colors.py
@@ -11,17 +11,20 @@ print("=" * 50)
mcrfpy.createScene("test")
grid = mcrfpy.Grid(grid_x=5, grid_y=5)
+# Add color layer for cell coloring
+color_layer = grid.add_layer("color", z_index=-1)
+
# Initialize
for y in range(5):
for x in range(5):
grid.at(x, y).walkable = True
- grid.at(x, y).color = mcrfpy.Color(200, 200, 200) # Light gray
+ color_layer.set(x, y, mcrfpy.Color(200, 200, 200)) # Light gray
# Add entities
-e1 = mcrfpy.Entity(0, 0)
-e2 = mcrfpy.Entity(4, 4)
-grid.entities.append(e1)
-grid.entities.append(e2)
+e1 = mcrfpy.Entity((0, 0), grid=grid)
+e2 = mcrfpy.Entity((4, 4), grid=grid)
+e1.sprite_index = 64
+e2.sprite_index = 69
print(f"Entity 1 at ({e1.x}, {e1.y})")
print(f"Entity 2 at ({e2.x}, {e2.y})")
@@ -35,24 +38,25 @@ PATH_COLOR = mcrfpy.Color(100, 255, 100) # Green
print(f"\nSetting path cells to green ({PATH_COLOR.r}, {PATH_COLOR.g}, {PATH_COLOR.b})...")
for x, y in path:
- cell = grid.at(x, y)
# Check before
- before = cell.color[:3] # Get RGB from tuple
-
+ before_c = color_layer.at(x, y)
+ before = (before_c.r, before_c.g, before_c.b)
+
# Set color
- cell.color = PATH_COLOR
-
+ color_layer.set(x, y, PATH_COLOR)
+
# Check after
- after = cell.color[:3] # Get RGB from tuple
-
+ after_c = color_layer.at(x, y)
+ after = (after_c.r, after_c.g, after_c.b)
+
print(f" Cell ({x},{y}): {before} -> {after}")
# Verify all path cells
print("\nVerifying all cells in grid:")
for y in range(5):
for x in range(5):
- cell = grid.at(x, y)
- color = cell.color[:3] # Get RGB from tuple
+ c = color_layer.at(x, y)
+ color = (c.r, c.g, c.b)
is_path = (x, y) in path
print(f" ({x},{y}): color={color}, in_path={is_path}")
diff --git a/tests/unit/test_pathfinding_integration.py b/tests/unit/test_pathfinding_integration.py
index 8f779f6..a27f6a5 100644
--- a/tests/unit/test_pathfinding_integration.py
+++ b/tests/unit/test_pathfinding_integration.py
@@ -21,10 +21,8 @@ for i in range(5):
grid.at(5, i + 2).walkable = False
# Create entities
-e1 = mcrfpy.Entity(2, 5)
-e2 = mcrfpy.Entity(8, 5)
-grid.entities.append(e1)
-grid.entities.append(e2)
+e1 = mcrfpy.Entity((2, 5), grid=grid)
+e2 = mcrfpy.Entity((8, 5), grid=grid)
# Test pathfinding between entities
print(f"Entity 1 at ({e1.x}, {e1.y})")
diff --git a/tests/unit/test_properties_quick.py b/tests/unit/test_properties_quick.py
index 31822c2..e16774a 100644
--- a/tests/unit/test_properties_quick.py
+++ b/tests/unit/test_properties_quick.py
@@ -10,7 +10,7 @@ def test_properties(runtime):
# Test Frame
try:
- frame = mcrfpy.Frame(10, 10, 100, 100)
+ frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100))
print(f"Frame visible: {frame.visible}")
frame.visible = False
print(f"Frame visible after setting to False: {frame.visible}")
diff --git a/tests/unit/test_scene_object_api.py b/tests/unit/test_scene_object_api.py
new file mode 100644
index 0000000..f52c1d4
--- /dev/null
+++ b/tests/unit/test_scene_object_api.py
@@ -0,0 +1,235 @@
+#!/usr/bin/env python3
+"""Test the object-oriented Scene API (alternative to module-level functions).
+
+The Scene object provides an OOP approach to scene management with several advantages:
+1. `scene.on_key` can be set on ANY scene, not just the current one
+2. `scene.children` provides direct access to UI elements
+3. Subclassing enables lifecycle callbacks (on_enter, on_exit, update, etc.)
+
+This is the recommended approach for new code, replacing:
+- mcrfpy.createScene(name) -> scene = mcrfpy.Scene(name)
+- mcrfpy.setScene(name) -> scene.activate()
+- mcrfpy.sceneUI(name) -> scene.children
+- mcrfpy.keypressScene(callback) -> scene.on_key = callback
+"""
+import mcrfpy
+import sys
+
+def test_scene_object_basics():
+ """Test basic Scene object creation and properties."""
+ print("=== Test: Scene Object Basics ===")
+
+ # Create scene using object-oriented approach
+ scene = mcrfpy.Scene("oop_test")
+
+ # Check name property
+ assert scene.name == "oop_test", f"Expected 'oop_test', got '{scene.name}'"
+ print(f" name: {scene.name}")
+
+ # Check active property (should be False, not yet activated)
+ print(f" active: {scene.active}")
+
+ # Check children property returns UICollection
+ children = scene.children
+ print(f" children type: {type(children).__name__}")
+ print(f" children count: {len(children)}")
+
+ # Add UI elements via children
+ frame = mcrfpy.Frame(pos=(50, 50), size=(200, 100), fill_color=mcrfpy.Color(100, 100, 200))
+ scene.children.append(frame)
+ print(f" children count after append: {len(scene.children)}")
+
+ print(" PASS: Basic properties work correctly")
+ return scene
+
+def test_scene_activation():
+ """Test scene activation."""
+ print("\n=== Test: Scene Activation ===")
+
+ scene1 = mcrfpy.Scene("scene_a")
+ scene2 = mcrfpy.Scene("scene_b")
+
+ # Neither active yet
+ print(f" Before activation - scene1.active: {scene1.active}, scene2.active: {scene2.active}")
+
+ # Activate scene1
+ scene1.activate()
+ print(f" After scene1.activate() - scene1.active: {scene1.active}, scene2.active: {scene2.active}")
+ assert scene1.active == True, "scene1 should be active"
+ assert scene2.active == False, "scene2 should not be active"
+
+ # Activate scene2
+ scene2.activate()
+ print(f" After scene2.activate() - scene1.active: {scene1.active}, scene2.active: {scene2.active}")
+ assert scene1.active == False, "scene1 should not be active now"
+ assert scene2.active == True, "scene2 should be active"
+
+ print(" PASS: Scene activation works correctly")
+
+def test_scene_on_key():
+ """Test setting on_key callback on scene objects.
+
+ This is the KEY ADVANTAGE over module-level keypressScene():
+ You can set on_key on ANY scene, not just the current one!
+ """
+ print("\n=== Test: Scene on_key Property ===")
+
+ scene1 = mcrfpy.Scene("keys_scene1")
+ scene2 = mcrfpy.Scene("keys_scene2")
+
+ # Track which callback was called
+ callback_log = []
+
+ def scene1_keyhandler(key, action):
+ callback_log.append(("scene1", key, action))
+
+ def scene2_keyhandler(key, action):
+ callback_log.append(("scene2", key, action))
+
+ # Set callbacks on BOTH scenes BEFORE activating either
+ # This is impossible with keypressScene() which only works on current scene!
+ scene1.on_key = scene1_keyhandler
+ scene2.on_key = scene2_keyhandler
+
+ print(f" scene1.on_key set: {scene1.on_key is not None}")
+ print(f" scene2.on_key set: {scene2.on_key is not None}")
+
+ # Verify callbacks are retrievable
+ assert callable(scene1.on_key), "scene1.on_key should be callable"
+ assert callable(scene2.on_key), "scene2.on_key should be callable"
+
+ # Test clearing callback
+ scene1.on_key = None
+ assert scene1.on_key is None, "scene1.on_key should be None after clearing"
+ print(" scene1.on_key cleared successfully")
+
+ # Re-set it
+ scene1.on_key = scene1_keyhandler
+
+ print(" PASS: on_key property works correctly")
+
+def test_scene_visual_properties():
+ """Test scene-level visual properties (pos, visible, opacity)."""
+ print("\n=== Test: Scene Visual Properties ===")
+
+ scene = mcrfpy.Scene("visual_props_test")
+
+ # Test pos property
+ print(f" Initial pos: {scene.pos}")
+ scene.pos = (100, 50)
+ print(f" After setting pos=(100, 50): {scene.pos}")
+
+ # Test visible property
+ print(f" Initial visible: {scene.visible}")
+ scene.visible = False
+ print(f" After setting visible=False: {scene.visible}")
+ assert scene.visible == False, "visible should be False"
+ scene.visible = True
+
+ # Test opacity property
+ print(f" Initial opacity: {scene.opacity}")
+ scene.opacity = 0.5
+ print(f" After setting opacity=0.5: {scene.opacity}")
+ assert 0.49 < scene.opacity < 0.51, f"opacity should be ~0.5, got {scene.opacity}"
+
+ print(" PASS: Visual properties work correctly")
+
+def test_scene_subclass():
+ """Test subclassing Scene for lifecycle callbacks."""
+ print("\n=== Test: Scene Subclass with Lifecycle ===")
+
+ class GameScene(mcrfpy.Scene):
+ def __init__(self, name):
+ super().__init__(name)
+ self.enter_count = 0
+ self.exit_count = 0
+ self.update_count = 0
+
+ def on_enter(self):
+ self.enter_count += 1
+ print(f" GameScene.on_enter() called (count: {self.enter_count})")
+
+ def on_exit(self):
+ self.exit_count += 1
+ print(f" GameScene.on_exit() called (count: {self.exit_count})")
+
+ def on_keypress(self, key, action):
+ print(f" GameScene.on_keypress({key}, {action})")
+
+ def update(self, dt):
+ self.update_count += 1
+ # Note: update is called every frame, so we don't print
+
+ game_scene = GameScene("game_scene_test")
+ other_scene = mcrfpy.Scene("other_scene_test")
+
+ # Add some UI to game scene
+ game_scene.children.append(
+ mcrfpy.Caption(pos=(100, 100), text="Game Scene", fill_color=mcrfpy.Color(255, 255, 255))
+ )
+
+ print(f" Created GameScene with {len(game_scene.children)} children")
+ print(f" enter_count before activation: {game_scene.enter_count}")
+
+ # Activate - should trigger on_enter
+ game_scene.activate()
+ print(f" enter_count after activation: {game_scene.enter_count}")
+
+ # Switch away - should trigger on_exit
+ other_scene.activate()
+ print(f" exit_count after switching: {game_scene.exit_count}")
+
+ print(" PASS: Subclassing works correctly")
+
+def test_comparison_with_module_functions():
+ """Demonstrate the difference between old and new approaches."""
+ print("\n=== Comparison: Module Functions vs Scene Objects ===")
+
+ print("\n OLD APPROACH (module-level functions):")
+ print(" mcrfpy.createScene('my_scene')")
+ print(" mcrfpy.setScene('my_scene')")
+ print(" ui = mcrfpy.sceneUI('my_scene')")
+ print(" ui.append(mcrfpy.Frame(...))")
+ print(" mcrfpy.keypressScene(handler) # ONLY works on current scene!")
+
+ print("\n NEW APPROACH (Scene objects):")
+ print(" scene = mcrfpy.Scene('my_scene')")
+ print(" scene.activate()")
+ print(" scene.children.append(mcrfpy.Frame(...))")
+ print(" scene.on_key = handler # Works on ANY scene!")
+
+ print("\n KEY BENEFITS:")
+ print(" 1. scene.on_key can be set on non-active scenes")
+ print(" 2. Subclassing enables on_enter/on_exit/update callbacks")
+ print(" 3. Object reference makes code more readable")
+ print(" 4. scene.children is clearer than sceneUI(name)")
+
+ print("\n PASS: Documentation complete")
+
+def main():
+ """Run all Scene object API tests."""
+ print("=" * 60)
+ print("Scene Object API Test Suite")
+ print("=" * 60)
+
+ try:
+ test_scene_object_basics()
+ test_scene_activation()
+ test_scene_on_key()
+ test_scene_visual_properties()
+ test_scene_subclass()
+ test_comparison_with_module_functions()
+
+ print("\n" + "=" * 60)
+ print("ALL TESTS PASSED!")
+ print("=" * 60)
+ sys.exit(0)
+
+ except Exception as e:
+ print(f"\nTEST FAILED: {e}")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/unit/test_scene_transitions.py b/tests/unit/test_scene_transitions.py
index 603db6a..ea541b6 100644
--- a/tests/unit/test_scene_transitions.py
+++ b/tests/unit/test_scene_transitions.py
@@ -11,51 +11,51 @@ def create_test_scenes():
# Scene 1: Red background
mcrfpy.createScene("red_scene")
ui1 = mcrfpy.sceneUI("red_scene")
- bg1 = mcrfpy.Frame(0, 0, 1024, 768, fill_color=mcrfpy.Color(255, 0, 0, 255))
- label1 = mcrfpy.Caption(512, 384, "RED SCENE", font=mcrfpy.Font.font_ui)
- label1.color = mcrfpy.Color(255, 255, 255, 255)
+ bg1 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(255, 0, 0, 255))
+ label1 = mcrfpy.Caption(pos=(512, 384), text="RED SCENE", font=mcrfpy.Font.font_ui)
+ label1.fill_color = mcrfpy.Color(255, 255, 255, 255)
ui1.append(bg1)
ui1.append(label1)
-
+
# Scene 2: Blue background
mcrfpy.createScene("blue_scene")
ui2 = mcrfpy.sceneUI("blue_scene")
- bg2 = mcrfpy.Frame(0, 0, 1024, 768, fill_color=mcrfpy.Color(0, 0, 255, 255))
- label2 = mcrfpy.Caption(512, 384, "BLUE SCENE", font=mcrfpy.Font.font_ui)
- label2.color = mcrfpy.Color(255, 255, 255, 255)
+ bg2 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(0, 0, 255, 255))
+ label2 = mcrfpy.Caption(pos=(512, 384), text="BLUE SCENE", font=mcrfpy.Font.font_ui)
+ label2.fill_color = mcrfpy.Color(255, 255, 255, 255)
ui2.append(bg2)
ui2.append(label2)
-
+
# Scene 3: Green background
mcrfpy.createScene("green_scene")
ui3 = mcrfpy.sceneUI("green_scene")
- bg3 = mcrfpy.Frame(0, 0, 1024, 768, fill_color=mcrfpy.Color(0, 255, 0, 255))
- label3 = mcrfpy.Caption(512, 384, "GREEN SCENE", font=mcrfpy.Font.font_ui)
- label3.color = mcrfpy.Color(0, 0, 0, 255) # Black text on green
+ bg3 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(0, 255, 0, 255))
+ label3 = mcrfpy.Caption(pos=(512, 384), text="GREEN SCENE", font=mcrfpy.Font.font_ui)
+ label3.fill_color = mcrfpy.Color(0, 0, 0, 255) # Black text on green
ui3.append(bg3)
ui3.append(label3)
-
+
# Scene 4: Menu scene with buttons
mcrfpy.createScene("menu_scene")
ui4 = mcrfpy.sceneUI("menu_scene")
- bg4 = mcrfpy.Frame(0, 0, 1024, 768, fill_color=mcrfpy.Color(50, 50, 50, 255))
-
- title = mcrfpy.Caption(512, 100, "SCENE TRANSITION DEMO", font=mcrfpy.Font.font_ui)
- title.color = mcrfpy.Color(255, 255, 255, 255)
+ bg4 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(50, 50, 50, 255))
+
+ title = mcrfpy.Caption(pos=(512, 100), text="SCENE TRANSITION DEMO", font=mcrfpy.Font.font_ui)
+ title.fill_color = mcrfpy.Color(255, 255, 255, 255)
ui4.append(bg4)
ui4.append(title)
-
+
# Add instruction text
- instructions = mcrfpy.Caption(512, 200, "Press keys 1-6 for different transitions", font=mcrfpy.Font.font_ui)
- instructions.color = mcrfpy.Color(200, 200, 200, 255)
+ instructions = mcrfpy.Caption(pos=(512, 200), text="Press keys 1-6 for different transitions", font=mcrfpy.Font.font_ui)
+ instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
ui4.append(instructions)
-
- controls = mcrfpy.Caption(512, 250, "1: Fade | 2: Slide Left | 3: Slide Right | 4: Slide Up | 5: Slide Down | 6: Instant", font=mcrfpy.Font.font_ui)
- controls.color = mcrfpy.Color(150, 150, 150, 255)
+
+ controls = mcrfpy.Caption(pos=(512, 250), text="1: Fade | 2: Slide Left | 3: Slide Right | 4: Slide Up | 5: Slide Down | 6: Instant", font=mcrfpy.Font.font_ui)
+ controls.fill_color = mcrfpy.Color(150, 150, 150, 255)
ui4.append(controls)
-
- scene_info = mcrfpy.Caption(512, 300, "R: Red Scene | B: Blue Scene | G: Green Scene | M: Menu", font=mcrfpy.Font.font_ui)
- scene_info.color = mcrfpy.Color(150, 150, 150, 255)
+
+ scene_info = mcrfpy.Caption(pos=(512, 300), text="R: Red Scene | B: Blue Scene | G: Green Scene | M: Menu", font=mcrfpy.Font.font_ui)
+ scene_info.fill_color = mcrfpy.Color(150, 150, 150, 255)
ui4.append(scene_info)
print("Created test scenes: red_scene, blue_scene, green_scene, menu_scene")
diff --git a/tests/unit/test_scene_transitions_headless.py b/tests/unit/test_scene_transitions_headless.py
index 3dd791a..1e9b571 100644
--- a/tests/unit/test_scene_transitions_headless.py
+++ b/tests/unit/test_scene_transitions_headless.py
@@ -13,13 +13,13 @@ def test_scene_transitions():
# Scene 1
mcrfpy.createScene("scene1")
ui1 = mcrfpy.sceneUI("scene1")
- frame1 = mcrfpy.Frame(0, 0, 100, 100, fill_color=mcrfpy.Color(255, 0, 0))
+ frame1 = mcrfpy.Frame(pos=(0, 0), size=(100, 100), fill_color=mcrfpy.Color(255, 0, 0))
ui1.append(frame1)
-
- # Scene 2
+
+ # Scene 2
mcrfpy.createScene("scene2")
ui2 = mcrfpy.sceneUI("scene2")
- frame2 = mcrfpy.Frame(0, 0, 100, 100, fill_color=mcrfpy.Color(0, 0, 255))
+ frame2 = mcrfpy.Frame(pos=(0, 0), size=(100, 100), fill_color=mcrfpy.Color(0, 0, 255))
ui2.append(frame2)
# Test each transition type
diff --git a/tests/unit/test_simple_drawable.py b/tests/unit/test_simple_drawable.py
index a42fdcb..8a03baf 100644
--- a/tests/unit/test_simple_drawable.py
+++ b/tests/unit/test_simple_drawable.py
@@ -8,7 +8,7 @@ def simple_test(runtime):
try:
# Test basic functionality
- frame = mcrfpy.Frame(10, 10, 100, 100)
+ frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100))
print(f"Frame created: visible={frame.visible}, opacity={frame.opacity}")
bounds = frame.get_bounds()
diff --git a/tests/unit/test_text_input.py b/tests/unit/test_text_input.py
index 69464df..bc39a7f 100644
--- a/tests/unit/test_text_input.py
+++ b/tests/unit/test_text_input.py
@@ -18,13 +18,13 @@ def create_demo():
scene = mcrfpy.sceneUI("text_demo")
# Background
- bg = mcrfpy.Frame(0, 0, 800, 600)
- bg.fill_color = (40, 40, 40, 255)
+ bg = mcrfpy.Frame(pos=(0, 0), size=(800, 600))
+ bg.fill_color = mcrfpy.Color(40, 40, 40, 255)
scene.append(bg)
-
+
# Title
- title = mcrfpy.Caption("Text Input Widget Demo", 20, 20)
- title.fill_color = (255, 255, 255, 255)
+ title = mcrfpy.Caption(pos=(20, 20), text="Text Input Widget Demo")
+ title.fill_color = mcrfpy.Color(255, 255, 255, 255)
scene.append(title)
# Focus manager
@@ -62,8 +62,8 @@ def create_demo():
inputs.append(comment_input)
# Status display
- status = mcrfpy.Caption("Ready for input...", 50, 360)
- status.fill_color = (150, 255, 150, 255)
+ status = mcrfpy.Caption(pos=(50, 360), text="Ready for input...")
+ status.fill_color = mcrfpy.Color(150, 255, 150, 255)
scene.append(status)
# Update handler
diff --git a/tests/unit/test_uicaption_visual.py b/tests/unit/test_uicaption_visual.py
index 7d578f2..a4e3fc5 100644
--- a/tests/unit/test_uicaption_visual.py
+++ b/tests/unit/test_uicaption_visual.py
@@ -69,11 +69,11 @@ def main():
mcrfpy.setScene("test")
# Create multiple captions for testing
- caption1 = mcrfpy.Caption(50, 50, "Caption 1: Normal", fill_color=(255, 255, 255))
- caption2 = mcrfpy.Caption(50, 100, "Caption 2: Will be invisible", fill_color=(255, 200, 200))
- caption3 = mcrfpy.Caption(50, 150, "Caption 3: 50% opacity", fill_color=(200, 255, 200))
- caption4 = mcrfpy.Caption(50, 200, "Caption 4: 25% opacity", fill_color=(200, 200, 255))
- caption5 = mcrfpy.Caption(50, 250, "Caption 5: 0% opacity", fill_color=(255, 255, 200))
+ caption1 = mcrfpy.Caption(pos=(50, 50), text="Caption 1: Normal", fill_color=mcrfpy.Color(255, 255, 255))
+ caption2 = mcrfpy.Caption(pos=(50, 100), text="Caption 2: Will be invisible", fill_color=mcrfpy.Color(255, 200, 200))
+ caption3 = mcrfpy.Caption(pos=(50, 150), text="Caption 3: 50% opacity", fill_color=mcrfpy.Color(200, 255, 200))
+ caption4 = mcrfpy.Caption(pos=(50, 200), text="Caption 4: 25% opacity", fill_color=mcrfpy.Color(200, 200, 255))
+ caption5 = mcrfpy.Caption(pos=(50, 250), text="Caption 5: 0% opacity", fill_color=mcrfpy.Color(255, 255, 200))
# Add captions to scene
ui = mcrfpy.sceneUI("test")
@@ -84,7 +84,7 @@ def main():
ui.append(caption5)
# Also add a frame as background to see transparency better
- frame = mcrfpy.Frame(40, 40, 400, 250, fill_color=(50, 50, 50))
+ frame = mcrfpy.Frame(pos=(40, 40), size=(400, 250), fill_color=mcrfpy.Color(50, 50, 50))
frame.z_index = -1 # Put it behind the captions
ui.append(frame)
diff --git a/tests/unit/test_visibility.py b/tests/unit/test_visibility.py
index 23ea9fc..b866078 100644
--- a/tests/unit/test_visibility.py
+++ b/tests/unit/test_visibility.py
@@ -18,6 +18,9 @@ mcrfpy.createScene("visibility_test")
grid = mcrfpy.Grid(grid_x=20, grid_y=15)
grid.fill_color = mcrfpy.Color(20, 20, 30) # Dark background
+# Add a color layer for cell coloring
+color_layer = grid.add_layer("color", z_index=-1)
+
# Initialize grid - all walkable and transparent
print("\nInitializing 20x15 grid...")
for y in range(15):
@@ -25,7 +28,7 @@ for y in range(15):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
- cell.color = mcrfpy.Color(100, 100, 120) # Floor color
+ color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Floor color
# Create some walls to block vision
print("Adding walls...")
@@ -47,14 +50,14 @@ for wall_group in walls:
cell = grid.at(x, y)
cell.walkable = False
cell.transparent = False
- cell.color = mcrfpy.Color(40, 20, 20) # Wall color
+ color_layer.set(x, y, mcrfpy.Color(40, 20, 20)) # Wall color
# Create entities
print("\nCreating entities...")
entities = [
- mcrfpy.Entity(2, 7), # Left side
- mcrfpy.Entity(18, 7), # Right side
- mcrfpy.Entity(10, 1), # Top center (above wall)
+ mcrfpy.Entity((2, 7)), # Left side
+ mcrfpy.Entity((18, 7)), # Right side
+ mcrfpy.Entity((10, 1)), # Top center (above wall)
]
for i, entity in enumerate(entities):
@@ -138,17 +141,17 @@ grid.position = (50, 50)
grid.size = (600, 450) # 20*30, 15*30
# Add title
-title = mcrfpy.Caption("Knowledge Stubs 1 - Visibility Test", 200, 10)
+title = mcrfpy.Caption(pos=(200, 10), text="Knowledge Stubs 1 - Visibility Test")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Add info
-info = mcrfpy.Caption("Perspective: -1 (omniscient)", 50, 520)
+info = mcrfpy.Caption(pos=(50, 520), text="Perspective: -1 (omniscient)")
info.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(info)
# Add legend
-legend = mcrfpy.Caption("Black=Never seen, Dark gray=Discovered, Normal=Visible", 50, 540)
+legend = mcrfpy.Caption(pos=(50, 540), text="Black=Never seen, Dark gray=Discovered, Normal=Visible")
legend.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend)
diff --git a/tests/unit/test_visual_path.py b/tests/unit/test_visual_path.py
index 31b385f..11a8c71 100644
--- a/tests/unit/test_visual_path.py
+++ b/tests/unit/test_visual_path.py
@@ -4,28 +4,10 @@
import mcrfpy
import sys
-# Colors as tuples (r, g, b, a)
-WALL_COLOR = (60, 30, 30, 255)
-FLOOR_COLOR = (200, 200, 220, 255)
-PATH_COLOR = (100, 255, 100, 255)
-
-def check_render(dt):
- """Timer callback to verify rendering"""
- print(f"\nTimer fired after {dt}ms")
-
- # Take screenshot
- from mcrfpy import automation
- automation.screenshot("visual_path_test.png")
- print("Screenshot saved as visual_path_test.png")
-
- # Sample some path cells to verify colors
- print("\nSampling path cell colors from grid:")
- for x, y in [(1, 1), (2, 2), (3, 3)]:
- cell = grid.at(x, y)
- color = cell.color
- print(f" Cell ({x},{y}): color={color[:3]}")
-
- sys.exit(0)
+# Colors
+WALL_COLOR = mcrfpy.Color(60, 30, 30)
+FLOOR_COLOR = mcrfpy.Color(200, 200, 220)
+PATH_COLOR = mcrfpy.Color(100, 255, 100)
# Create scene
mcrfpy.createScene("visual_test")
@@ -34,20 +16,38 @@ mcrfpy.createScene("visual_test")
grid = mcrfpy.Grid(grid_x=5, grid_y=5)
grid.fill_color = mcrfpy.Color(0, 0, 0)
+# Add color layer for cell coloring
+color_layer = grid.add_layer("color", z_index=-1)
+
+def check_render(dt):
+ """Timer callback to verify rendering"""
+ print(f"\nTimer fired after {dt}ms")
+
+ # Take screenshot
+ from mcrfpy import automation
+ automation.screenshot("visual_path_test.png")
+ print("Screenshot saved as visual_path_test.png")
+
+ # Sample some path cells to verify colors
+ print("\nSampling path cell colors from grid:")
+ for x, y in [(1, 1), (2, 2), (3, 3)]:
+ color = color_layer.at(x, y)
+ print(f" Cell ({x},{y}): color=({color.r}, {color.g}, {color.b})")
+
+ sys.exit(0)
+
# Initialize all cells as floor
print("Initializing grid...")
for y in range(5):
for x in range(5):
grid.at(x, y).walkable = True
- grid.at(x, y).color = FLOOR_COLOR
+ color_layer.set(x, y, FLOOR_COLOR)
# Create entities
-e1 = mcrfpy.Entity(0, 0)
-e2 = mcrfpy.Entity(4, 4)
+e1 = mcrfpy.Entity((0, 0), grid=grid)
+e2 = mcrfpy.Entity((4, 4), grid=grid)
e1.sprite_index = 64 # @
e2.sprite_index = 69 # E
-grid.entities.append(e1)
-grid.entities.append(e2)
print(f"Entity 1 at ({e1.x}, {e1.y})")
print(f"Entity 2 at ({e2.x}, {e2.y})")
@@ -60,7 +60,7 @@ print(f"\nPath from E1 to E2: {path}")
if path:
print("\nColoring path cells green...")
for x, y in path:
- grid.at(x, y).color = PATH_COLOR
+ color_layer.set(x, y, PATH_COLOR)
print(f" Set ({x},{y}) to green")
# Set up UI
@@ -70,7 +70,7 @@ grid.position = (50, 50)
grid.size = (250, 250)
# Add title
-title = mcrfpy.Caption("Path Visualization Test", 50, 10)
+title = mcrfpy.Caption(pos=(50, 10), text="Path Visualization Test")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
diff --git a/tests/unit/ui_Frame_test_detailed.py b/tests/unit/ui_Frame_test_detailed.py
index 3058d70..938a5a4 100644
--- a/tests/unit/ui_Frame_test_detailed.py
+++ b/tests/unit/ui_Frame_test_detailed.py
@@ -16,11 +16,11 @@ def test_issue_38_children():
print("\nTest 1: Passing children argument to Frame constructor")
try:
# Create some child elements
- child1 = mcrfpy.Caption(mcrfpy.Vector(10, 10), text="Child 1")
- child2 = mcrfpy.Sprite(mcrfpy.Vector(10, 30))
-
+ child1 = mcrfpy.Caption(pos=(10, 10), text="Child 1")
+ child2 = mcrfpy.Sprite(pos=(10, 30))
+
# Try to create frame with children argument
- frame = mcrfpy.Frame(10, 10, 200, 150, children=[child1, child2])
+ frame = mcrfpy.Frame(pos=(10, 10), size=(200, 150), children=[child1, child2])
print("✗ UNEXPECTED: Frame accepted children argument (should fail per issue #38)")
except TypeError as e:
print(f"✓ EXPECTED: Frame constructor rejected children argument: {e}")
@@ -30,12 +30,12 @@ def test_issue_38_children():
# Test 2: Verify children can be added after creation
print("\nTest 2: Adding children after Frame creation")
try:
- frame = mcrfpy.Frame(10, 10, 200, 150)
+ frame = mcrfpy.Frame(pos=(10, 10), size=(200, 150))
ui.append(frame)
-
+
# Add children via the children collection
- child1 = mcrfpy.Caption(mcrfpy.Vector(10, 10), text="Added Child 1")
- child2 = mcrfpy.Caption(mcrfpy.Vector(10, 30), text="Added Child 2")
+ child1 = mcrfpy.Caption(pos=(10, 10), text="Added Child 1")
+ child2 = mcrfpy.Caption(pos=(10, 30), text="Added Child 2")
frame.children.append(child1)
frame.children.append(child2)
@@ -65,33 +65,33 @@ def test_issue_42_click_callback():
return True
try:
- frame1 = mcrfpy.Frame(10, 10, 200, 150)
+ frame1 = mcrfpy.Frame(pos=(10, 10), size=(200, 150))
ui.append(frame1)
frame1.on_click = correct_callback
print("✓ Click callback with correct signature assigned successfully")
except Exception as e:
print(f"✗ Failed to assign correct callback: {type(e).__name__}: {e}")
-
+
# Test 2: Callback with wrong signature (no args)
print("\nTest 2: Click callback with no arguments")
def wrong_callback_no_args():
print(" Wrong callback called")
-
+
try:
- frame2 = mcrfpy.Frame(220, 10, 200, 150)
+ frame2 = mcrfpy.Frame(pos=(220, 10), size=(200, 150))
ui.append(frame2)
frame2.on_click = wrong_callback_no_args
print("✓ Click callback with no args assigned (will fail at runtime per issue #42)")
except Exception as e:
print(f"✗ Failed to assign callback: {type(e).__name__}: {e}")
-
+
# Test 3: Callback with wrong signature (too few args)
print("\nTest 3: Click callback with too few arguments")
def wrong_callback_few_args(x, y):
print(f" Wrong callback called: x={x}, y={y}")
-
+
try:
- frame3 = mcrfpy.Frame(10, 170, 200, 150)
+ frame3 = mcrfpy.Frame(pos=(10, 170), size=(200, 150))
ui.append(frame3)
frame3.on_click = wrong_callback_few_args
print("✓ Click callback with 2 args assigned (will fail at runtime per issue #42)")
diff --git a/tests/unit/ui_Grid_none_texture_test.py b/tests/unit/ui_Grid_none_texture_test.py
index 38150ef..a283cee 100644
--- a/tests/unit/ui_Grid_none_texture_test.py
+++ b/tests/unit/ui_Grid_none_texture_test.py
@@ -7,24 +7,24 @@ import sys
def test_grid_none_texture(runtime):
"""Test Grid functionality without texture"""
print("\n=== Testing Grid with None texture ===")
-
+
# Test 1: Create Grid with None texture
try:
- grid = mcrfpy.Grid(10, 10, None, mcrfpy.Vector(50, 50), mcrfpy.Vector(400, 400))
+ grid = mcrfpy.Grid(grid_size=(10, 10), pos=(50, 50), size=(400, 400))
print("✓ Grid created successfully with None texture")
except Exception as e:
print(f"✗ Failed to create Grid with None texture: {e}")
sys.exit(1)
-
+
# Add to UI
ui = mcrfpy.sceneUI("grid_none_test")
ui.append(grid)
-
+
# Test 2: Verify grid properties
try:
grid_size = grid.grid_size
print(f"✓ Grid size: {grid_size}")
-
+
# Check texture property
texture = grid.texture
if texture is None:
@@ -33,39 +33,41 @@ def test_grid_none_texture(runtime):
print(f"✗ Grid texture should be None, got: {texture}")
except Exception as e:
print(f"✗ Property access failed: {e}")
-
- # Test 3: Access grid points and set colors
+
+ # Test 3: Access grid points using ColorLayer (new API)
+ # Note: GridPoint no longer has .color - must use ColorLayer system
try:
+ # Add a color layer to the grid
+ color_layer = grid.add_layer("color", z_index=-1)
# Create a checkerboard pattern with colors
for x in range(10):
for y in range(10):
- point = grid.at(x, y)
if (x + y) % 2 == 0:
- point.color = mcrfpy.Color(255, 0, 0, 255) # Red
+ color_layer.set(x, y, mcrfpy.Color(255, 0, 0, 255)) # Red
else:
- point.color = mcrfpy.Color(0, 0, 255, 255) # Blue
- print("✓ Successfully set grid point colors")
+ color_layer.set(x, y, mcrfpy.Color(0, 0, 255, 255)) # Blue
+ print("✓ Successfully set grid colors via ColorLayer")
except Exception as e:
print(f"✗ Failed to set grid colors: {e}")
-
+
# Test 4: Add entities to the grid
try:
# Create an entity with its own texture
entity_texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
- entity = mcrfpy.Entity(mcrfpy.Vector(5, 5), entity_texture, 1, grid)
+ entity = mcrfpy.Entity((5, 5), texture=entity_texture, sprite_index=1, grid=grid)
grid.entities.append(entity)
print(f"✓ Added entity to grid, total entities: {len(grid.entities)}")
except Exception as e:
print(f"✗ Failed to add entity: {e}")
-
+
# Test 5: Test grid interaction properties
try:
# Test zoom
grid.zoom = 2.0
print(f"✓ Set zoom to: {grid.zoom}")
-
- # Test center
- grid.center = mcrfpy.Vector(5, 5)
+
+ # Test center (uses pixel coordinates)
+ grid.center = (200, 200)
print(f"✓ Set center to: {grid.center}")
except Exception as e:
print(f"✗ Grid properties failed: {e}")
@@ -86,7 +88,7 @@ mcrfpy.setScene("grid_none_test")
# Add a background frame so we can see the grid
ui = mcrfpy.sceneUI("grid_none_test")
-background = mcrfpy.Frame(0, 0, 800, 600,
+background = mcrfpy.Frame(pos=(0, 0), size=(800, 600),
fill_color=mcrfpy.Color(200, 200, 200),
outline_color=mcrfpy.Color(0, 0, 0),
outline=2.0)
diff --git a/tests/unit/ui_UICollection_issue69_test.py b/tests/unit/ui_UICollection_issue69_test.py
index 3299bcd..44af8d2 100644
--- a/tests/unit/ui_UICollection_issue69_test.py
+++ b/tests/unit/ui_UICollection_issue69_test.py
@@ -11,8 +11,8 @@ def test_UICollection():
ui = mcrfpy.sceneUI("collection_test")
# Add various UI elements
- frame = mcrfpy.Frame(10, 10, 100, 100)
- caption = mcrfpy.Caption(mcrfpy.Vector(120, 10), text="Test")
+ frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100))
+ caption = mcrfpy.Caption(pos=(120, 10), text="Test")
# Skip sprite for now since it requires a texture
ui.append(frame)
@@ -74,9 +74,9 @@ def test_UICollection():
# Test type preservation (Issue #76)
try:
# Add a frame with children to test nested collections
- parent_frame = mcrfpy.Frame(250, 10, 200, 200,
+ parent_frame = mcrfpy.Frame(pos=(250, 10), size=(200, 200),
fill_color=mcrfpy.Color(200, 200, 200))
- child_caption = mcrfpy.Caption(mcrfpy.Vector(10, 10), text="Child")
+ child_caption = mcrfpy.Caption(pos=(10, 10), text="Child")
parent_frame.children.append(child_caption)
ui.append(parent_frame)
diff --git a/tests/unit/validate_screenshot_test.py b/tests/unit/validate_screenshot_test.py
index e949eda..7e1a068 100644
--- a/tests/unit/validate_screenshot_test.py
+++ b/tests/unit/validate_screenshot_test.py
@@ -18,50 +18,50 @@ def test_screenshot_validation():
print("Creating UI elements...")
# Bright red frame with white outline
- frame1 = mcrfpy.Frame(50, 50, 300, 200,
+ frame1 = mcrfpy.Frame(pos=(50, 50), size=(300, 200),
fill_color=mcrfpy.Color(255, 0, 0), # Bright red
outline_color=mcrfpy.Color(255, 255, 255), # White
outline=5.0)
ui.append(frame1)
print("Added red frame at (50, 50)")
-
+
# Bright green frame
- frame2 = mcrfpy.Frame(400, 50, 300, 200,
+ frame2 = mcrfpy.Frame(pos=(400, 50), size=(300, 200),
fill_color=mcrfpy.Color(0, 255, 0), # Bright green
outline_color=mcrfpy.Color(0, 0, 0), # Black
outline=3.0)
ui.append(frame2)
print("Added green frame at (400, 50)")
-
+
# Blue frame
- frame3 = mcrfpy.Frame(50, 300, 300, 200,
+ frame3 = mcrfpy.Frame(pos=(50, 300), size=(300, 200),
fill_color=mcrfpy.Color(0, 0, 255), # Bright blue
outline_color=mcrfpy.Color(255, 255, 0), # Yellow
outline=4.0)
ui.append(frame3)
print("Added blue frame at (50, 300)")
-
+
# Add text captions
- caption1 = mcrfpy.Caption(mcrfpy.Vector(60, 60),
+ caption1 = mcrfpy.Caption(pos=(60, 60),
text="RED FRAME TEST",
fill_color=mcrfpy.Color(255, 255, 255))
- caption1.size = 24
+ caption1.font_size = 24
frame1.children.append(caption1)
-
- caption2 = mcrfpy.Caption(mcrfpy.Vector(410, 60),
+
+ caption2 = mcrfpy.Caption(pos=(410, 60),
text="GREEN FRAME TEST",
fill_color=mcrfpy.Color(0, 0, 0))
- caption2.size = 24
+ caption2.font_size = 24
ui.append(caption2)
-
- caption3 = mcrfpy.Caption(mcrfpy.Vector(60, 310),
+
+ caption3 = mcrfpy.Caption(pos=(60, 310),
text="BLUE FRAME TEST",
fill_color=mcrfpy.Color(255, 255, 0))
- caption3.size = 24
+ caption3.font_size = 24
ui.append(caption3)
-
+
# White background frame to ensure non-transparent background
- background = mcrfpy.Frame(0, 0, 1024, 768,
+ background = mcrfpy.Frame(pos=(0, 0), size=(1024, 768),
fill_color=mcrfpy.Color(200, 200, 200)) # Light gray
# Insert at beginning so it's behind everything
ui.remove(len(ui) - 1) # Remove to re-add at start
diff --git a/tests/unit/working_timer_test.py b/tests/unit/working_timer_test.py
index 4435014..a9d96c5 100644
--- a/tests/unit/working_timer_test.py
+++ b/tests/unit/working_timer_test.py
@@ -11,16 +11,16 @@ mcrfpy.setScene("timer_works")
ui = mcrfpy.sceneUI("timer_works")
# Add visible content
-frame = mcrfpy.Frame(100, 100, 300, 200,
+frame = mcrfpy.Frame(pos=(100, 100), size=(300, 200),
fill_color=mcrfpy.Color(255, 0, 0),
outline_color=mcrfpy.Color(255, 255, 255),
outline=3.0)
ui.append(frame)
-caption = mcrfpy.Caption(mcrfpy.Vector(150, 150),
+caption = mcrfpy.Caption(pos=(150, 150),
text="TIMER TEST SUCCESS",
fill_color=mcrfpy.Color(255, 255, 255))
-caption.size = 24
+caption.font_size = 24
ui.append(caption)
# Timer callback with correct signature
diff --git a/tools/generate_dynamic_docs.py b/tools/generate_dynamic_docs.py
index c6e4d4d..e70cdc4 100644
--- a/tools/generate_dynamic_docs.py
+++ b/tools/generate_dynamic_docs.py
@@ -10,6 +10,7 @@ import inspect
import datetime
import html
import re
+import types
from pathlib import Path
def transform_doc_links(docstring, format='html', base_url=''):
@@ -214,11 +215,21 @@ def get_all_classes():
"parsed": parse_docstring(method_doc)
}
elif isinstance(attr, property):
+ # Pure Python property
prop_doc = (attr.fget.__doc__ if attr.fget else "") or ""
class_info["properties"][attr_name] = {
"doc": prop_doc,
"readonly": attr.fset is None
}
+ elif isinstance(attr, (types.GetSetDescriptorType, types.MemberDescriptorType)):
+ # C++ extension property (PyGetSetDef or PyMemberDef)
+ prop_doc = attr.__doc__ or ""
+ # Check if docstring indicates read-only (convention: "read-only" in description)
+ readonly = "read-only" in prop_doc.lower()
+ class_info["properties"][attr_name] = {
+ "doc": prop_doc,
+ "readonly": readonly
+ }
except:
pass