Part 11: Multiple Dungeon Levels

In Part 10, we implemented save and load functionality. Now we will add multiple dungeon levels with stairs to descend deeper into the dungeon. Each level will be progressively more dangerous, with tougher enemies and better rewards.

What You Will Learn

  • Adding stairs down to descend deeper
  • Generating new dungeon levels on descent
  • Tracking current dungeon depth
  • Scaling difficulty with depth
  • Displaying dungeon level in the UI
  • Updating save/load for multiple levels

Multiple Levels Design

Descending through dungeon levels is a core roguelike mechanic:

Depth Effect
1-2 Tutorial levels, mostly goblins
3-4 More orcs, occasional trolls
5+ Dangerous mix of all enemies

Each level is generated fresh when the player descends.

The Complete Code

Create a file called part_11_levels.py:

"""McRogueFace Tutorial - Part 11: Multiple Dungeon Levels

Add stairs and multiple dungeon levels with scaling difficulty.
"""
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
    },
    "orc": {
        "sprite": SPRITE_ORC,
        "hp": 10,
        "attack": 4,
        "defense": 1
    },
    "troll": {
        "sprite": SPRITE_TROLL,
        "hp": 16,
        "attack": 6,
        "defense": 2
    }
}

# =============================================================================
# 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.grid_x) == x and int(entity.grid_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.grid_x) == x and int(entity.grid_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.grid_x) == x and int(entity.grid_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 e in target_grid.entities:
        if e == entity:
            target_grid.entities.remove(e)
            break
    if entity in entity_data:
        del entity_data[entity]

def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None:
    for e in target_grid.entities:
        if e == entity:
            target_grid.entities.remove(e)
            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 e in target_grid.entities:
            if e == entity:
                target_grid.entities.remove(e)
                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.grid_x), int(player.grid_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.grid_pos = player_start

    # 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.grid_x), int(player.grid_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.grid_x), int(player.grid_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.grid_x),
                    "y": int(entity.grid_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.grid_x),
                    "y": int(entity.grid_y),
                    "item": item.to_dict()
                })

        save_data = {
            "version": 2,
            "dungeon_level": dungeon_level,
            "stairs_position": list(stairs_position),
            "player": {
                "x": int(player.grid_x),
                "y": int(player.grid_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.pop()

        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.grid_x), int(player.grid_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.grid_x)
    target_y = int(player.grid_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 e in grid.entities:
            if e == target_cursor:
                grid.entities.remove(e)
                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.grid_x), int(player.grid_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.grid_x) and target_y == int(player.grid_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.grid_x), int(player.grid_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.grid_x), int(entity.grid_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), radius=FOV_RADIUS, algorithm=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.grid_x), int(player.grid_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.grid_x = new_target_x
        player.grid_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.grid_x), int(player.grid_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.pop()

    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,
    zoom=1.0
)

fov_layer = mcrfpy.ColorLayer(z_index=-1)
grid.add_layer(fov_layer)
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: mcrfpy.Key, action: mcrfpy.InputState) -> None:
    global game_over, game_mode

    if action != mcrfpy.InputState.PRESSED:
        return

    if key == mcrfpy.Key.R:
        delete_save()
        generate_new_game()
        message_log.add("A new adventure begins!", COLOR_INFO)
        return

    if key == mcrfpy.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 == mcrfpy.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)

ITEM_KEYS = {
    mcrfpy.Key.Num1: 0,
    mcrfpy.Key.Num2: 1,
    mcrfpy.Key.Num3: 2,
    mcrfpy.Key.Num4: 3,
    mcrfpy.Key.Num5: 4,
}

def handle_normal_input(key: mcrfpy.Key) -> None:
    if key == mcrfpy.Key.W or key == mcrfpy.Key.Up:
        try_move_or_attack(0, -1)
    elif key == mcrfpy.Key.S or key == mcrfpy.Key.Down:
        try_move_or_attack(0, 1)
    elif key == mcrfpy.Key.A or key == mcrfpy.Key.Left:
        try_move_or_attack(-1, 0)
    elif key == mcrfpy.Key.D or key == mcrfpy.Key.Right:
        try_move_or_attack(1, 0)
    elif key == mcrfpy.Key.F:
        enter_targeting_mode()
    elif key == mcrfpy.Key.G or key == mcrfpy.Key.Comma:
        pickup_item()
    elif key == mcrfpy.Key.Period and mcrfpy.keypressed("LShift"):
        # Shift+. (>) to descend stairs
        descend_stairs()
    elif key in ITEM_KEYS:
        index = ITEM_KEYS[key]
        if use_item(index):
            enemy_turn()
            update_ui()

def handle_targeting_input(key: mcrfpy.Key) -> None:
    if key == mcrfpy.Key.Up or key == mcrfpy.Key.W:
        move_cursor(0, -1)
    elif key == mcrfpy.Key.Down or key == mcrfpy.Key.S:
        move_cursor(0, 1)
    elif key == mcrfpy.Key.Left or key == mcrfpy.Key.A:
        move_cursor(-1, 0)
    elif key == mcrfpy.Key.Right or key == mcrfpy.Key.D:
        move_cursor(1, 0)
    elif key == mcrfpy.Key.Enter or key == mcrfpy.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.")

Understanding the Code

Stairs Placement

SPRITE_STAIRS_DOWN = 62  # '>' - stairs down

stairs_position: tuple[int, int] = (0, 0)

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)

Stairs are placed as a tile (not an entity):

  • Use the ‘>’ character (sprite 62)
  • Mark as walkable (player can stand on them)
  • Track position globally for descent check

Dungeon Generation with Stairs

def generate_dungeon(target_grid: mcrfpy.Grid, level: int) -> tuple[int, int]:
    """Generate a new dungeon level."""
    fill_with_walls(target_grid)

    rooms: list[RectangularRoom] = []

    # ... room generation ...

    # 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

The player starts in the first room, stairs are in the last room. This ensures the player must explore to find the exit.

Descending Stairs

def descend_stairs() -> bool:
    """Attempt to descend stairs at player's position."""
    global player, dungeon_level, grid, fov_layer, stairs_position

    px, py = int(player.grid_x), int(player.grid_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.grid_pos = player_start

    # Spawn enemies and items
    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)
    return True

The descent process:

  1. Verify player is on stairs
  2. Increment dungeon level
  3. Clear all entities except player
  4. Generate new dungeon layout
  5. Move player to new starting position
  6. Spawn level-appropriate enemies and items
  7. Reset exploration and FOV

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_enemy_weights(level: int) -> list[tuple[str, float]]:
    """Get enemy spawn weights based on dungeon level."""
    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)]

Scaling mechanics:

  • More enemies per room as level increases
  • Enemy type distribution shifts toward stronger types
  • Better items appear on deeper levels

Using Spawn Weights

def spawn_enemies_in_room(target_grid, room, tex, level):
    enemy_weights = get_enemy_weights(level)

    # 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

The weight system uses cumulative thresholds:

  • Roll 0.0-0.8: goblin (80%)
  • Roll 0.8-0.95: orc (15%)
  • Roll 0.95-1.0: troll (5%)

Level Display UI

class LevelDisplay:
    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 update(self, level: int) -> None:
        self.caption.text = f"Dungeon Level: {level}"

Simple UI component to show current depth.

Preserving Player State on Descent

def clear_entities_except_player(target_grid: mcrfpy.Grid) -> None:
    """Remove all entities except the player."""
    entities_to_remove = []
    for entity in target_grid.entities:
        if entity in entity_data and entity_data[entity].is_player:
            continue  # Keep the player!
        entities_to_remove.append(entity)
    # ... remove others

The player entity (and their inventory) is preserved when descending. Only the dungeon layout, enemies, and items are regenerated.

Difficulty Progression

Level Max Enemies Goblin % Orc % Troll % Items
1 3 80% 15% 5% Basic potions
2 4 80% 15% 5% Basic potions
3 5 50% 35% 15% + Greater potions
4 6 50% 35% 15% + Greater potions
5+ 6 30% 30% 40% More greater potions

Try This

  1. Stairs up: Add stairs to return to previous levels:
    SPRITE_STAIRS_UP = 60  # '<'
    
    previous_levels = {}  # Store generated levels
    
    def ascend_stairs():
        if dungeon_level == 1:
            message_log.add("You are at the surface.", COLOR_INFO)
            return
        # Save current level, load previous
    
  2. Boss levels: Every 5th level has a boss:
    def is_boss_level(level):
        return level % 5 == 0
    
    if is_boss_level(dungeon_level):
        spawn_boss(grid, texture)
    
  3. Level themes: Different tile appearances per level range

  4. Persistent levels: Save and restore previously visited levels

  5. Victory condition: Reach level 10 and find a special item

Challenge: Level Memory

Remember previous levels and let the player return:

level_cache = {}  # {level_num: serialized_level_data}

def cache_current_level():
    """Save current level state before leaving."""
    level_cache[dungeon_level] = {
        "tiles": serialize_tiles(),
        "enemies": serialize_enemies(),
        "items": serialize_items(),
        "explored": explored.copy()
    }

def restore_level(level_num):
    """Restore a previously visited level."""
    if level_num not in level_cache:
        return False
    data = level_cache[level_num]
    # Restore tiles, enemies, items, explored state

Common Mistakes

  1. Forgetting to clear entities: Always clear enemies/items before generating a new level

  2. Not preserving player: Make sure to keep the player entity when clearing

  3. Stairs on walls: Only place stairs on floor tiles

  4. Missing FOV reset: Reset the FOV layer when changing levels

  5. Save data mismatch: Update save format to include stairs_position and dungeon_level

  6. Infinite descent: Consider a maximum depth or victory condition

Level Generation Flow

descend_stairs()
    |
    +-- Check player on stairs
    |
    +-- Increment dungeon_level
    |
    +-- clear_entities_except_player()
    |
    +-- init_explored()
    |
    +-- generate_dungeon(level)
    |       |
    |       +-- fill_with_walls()
    |       +-- Generate rooms
    |       +-- Carve tunnels
    |       +-- place_stairs() in last room
    |       +-- Return first room center
    |
    +-- Move player to start
    |
    +-- spawn_entities_for_level()
    |       |
    |       +-- get_enemy_weights(level)
    |       +-- get_item_weights(level)
    |       +-- Spawn scaled enemies/items
    |
    +-- Reset FOV layer
    |
    +-- update_fov()

What is Next

In Part 12, we will add experience points and leveling. You will learn:

  • Gaining XP from killing enemies
  • Level up thresholds and stat increases
  • XP bar in the UI
  • Enemy XP values by type

Continue to Part 12: Experience and Leveling


Complete Code Reference

Key additions from Part 10:

  • SPRITE_STAIRS_DOWN: Visual for stairs
  • stairs_position: Global tracking of stairs location
  • place_stairs(): Places stairs tile in dungeon
  • descend_stairs(): Handles level transition
  • get_enemy_weights()/get_item_weights(): Difficulty scaling
  • LevelDisplay: Shows current dungeon depth
  • clear_entities_except_player(): Preserves player on descent
  • spawn_entities_for_level(): Level-scaled spawning