Part 13: Equipment System

In Part 12, we added experience points and character leveling. Now we will implement the equipment system - weapons and armor that provide stat bonuses. This is the final part of our roguelike tutorial, bringing together everything you have learned.

What You Will Learn

  • Equipment slots (weapon, armor)
  • Items with attack and defense bonuses
  • Equipping items from inventory
  • Equipment affecting combat calculations
  • Dropping and swapping equipment
  • Equipment panel in UI
  • Completing your roguelike

Equipment Design

Equipment adds another layer of character customization:

Slot Stat Bonus Examples
Weapon Attack Dagger, Sword, Axe
Armor Defense Leather, Chain, Plate

Equipment bonuses stack with your base stats and level-up bonuses.

The Complete Code

Create a file called part_13_equipment.py:

"""McRogueFace Tutorial - Part 13: Equipment System

Add weapons and armor with stat bonuses.
"""
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
    },
    "orc": {
        "sprite": SPRITE_ORC,
        "hp": 10,
        "attack": 4,
        "defense": 1
    },
    "troll": {
        "sprite": SPRITE_TROLL,
        "hp": 16,
        "attack": 6,
        "defense": 2
    }
}

# =============================================================================
# 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) == (player.grid_x, player.grid_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) == (player.grid_x, player.grid_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 entity.grid_x == x and 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 and entity.grid_x == x and 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 entity.grid_x == x and 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:
    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

# =============================================================================
# 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 = player.grid_x, player.grid_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
        }

    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 = player.grid_x, player.grid_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.grid_pos = 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": entity.grid_x, "y": entity.grid_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": entity.grid_x, "y": entity.grid_y,
                    "item": item_data[entity].to_dict()
                })

        save_data = {
            "version": 4,
            "dungeon_level": dungeon_level,
            "stairs_position": list(stairs_position),
            "player": {
                "x": player.grid_x, "y": 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!", 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(grid.entities[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, player.grid_x, player.grid_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 = player.grid_x, 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: 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 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
    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 - player.grid_x) + abs(new_y - player.grid_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.grid_pos = (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 == player.grid_x and target_y == player.grid_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 = player.grid_x, player.grid_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(entity.grid_x, entity.grid_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, True, 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 = player.grid_x, player.grid_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.grid_pos = (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 = player.grid_x, player.grid_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 = entity.grid_x, entity.grid_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.grid_pos = (ex + dx, ey + dy)
    elif dx and can_move_to(grid, ex + dx, ey, enemy):
        enemy.grid_x = ex + dx
    elif dy and can_move_to(grid, ex, ey + dy, enemy):
        enemy.grid_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(grid.entities[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,
    zoom=1.0
)

fov_layer = mcrfpy.ColorLayer(z_index=-1, name="fov")
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)

# 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: 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("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)
        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)

EQUIP_SLOT_KEYS = {
    mcrfpy.Key.NUM_1: 0,
    mcrfpy.Key.NUM_2: 1,
    mcrfpy.Key.NUM_3: 2,
    mcrfpy.Key.NUM_4: 3,
    mcrfpy.Key.NUM_5: 4,
}

def handle_normal_input(key: mcrfpy.Key) -> None:
    if key in (mcrfpy.Key.W, mcrfpy.Key.UP):
        try_move_or_attack(0, -1)
    elif key in (mcrfpy.Key.S, mcrfpy.Key.DOWN):
        try_move_or_attack(0, 1)
    elif key in (mcrfpy.Key.A, mcrfpy.Key.LEFT):
        try_move_or_attack(-1, 0)
    elif key in (mcrfpy.Key.D, mcrfpy.Key.RIGHT):
        try_move_or_attack(1, 0)
    elif key == mcrfpy.Key.F:
        enter_targeting_mode()
    elif key in (mcrfpy.Key.G, mcrfpy.Key.COMMA):
        pickup_item()
    elif key == mcrfpy.Key.PERIOD:
        descend_stairs()
    elif key == mcrfpy.Key.E:
        message_log.add("Press 1-5 to equip an item from inventory.", COLOR_INFO)
    elif key in EQUIP_SLOT_KEYS:
        index = EQUIP_SLOT_KEYS[key]
        if use_item(index):
            enemy_turn()
            update_ui()

def handle_targeting_input(key: mcrfpy.Key) -> None:
    if key in (mcrfpy.Key.UP, mcrfpy.Key.W):
        move_cursor(0, -1)
    elif key in (mcrfpy.Key.DOWN, mcrfpy.Key.S):
        move_cursor(0, 1)
    elif key in (mcrfpy.Key.LEFT, mcrfpy.Key.A):
        move_cursor(-1, 0)
    elif key in (mcrfpy.Key.RIGHT, mcrfpy.Key.D):
        move_cursor(1, 0)
    elif key in (mcrfpy.Key.ENTER, mcrfpy.Key.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!")

Understanding the Code

The Equipment Class

@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

Equipment is a simple data class:

  • slot determines which equipment slot it uses
  • attack_bonus adds to attack (weapons)
  • defense_bonus adds to defense (armor)
  • Some items can have both bonuses or penalties

Fighter Equipment Properties

@dataclass
class Fighter:
    # ... other fields ...
    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

Key changes:

  • base_attack and base_defense store raw stats
  • attack and defense properties add equipment bonuses
  • Equipment slots are Optional (can be empty)

Equipping Items

def equip(self, equipment: Equipment) -> Optional[Equipment]:
    """Equip an item, returning any previously equipped item."""
    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

The equip method:

  1. Saves the old equipment (if any)
  2. Assigns the new equipment to the correct slot
  3. Returns the old equipment for handling

Equipment Templates

ITEM_TEMPLATES = {
    # Weapons
    "dagger": {
        "name": "Dagger",
        "sprite": SPRITE_SWORD,
        "item_type": "equipment",
        "slot": "weapon",
        "attack_bonus": 2,
        "defense_bonus": 0
    },
    "great_axe": {
        "name": "Great Axe",
        "item_type": "equipment",
        "slot": "weapon",
        "attack_bonus": 6,
        "defense_bonus": -1  # Heavy weapons reduce defense!
    },
    # Armor
    "plate_armor": {
        "name": "Plate Armor",
        "item_type": "equipment",
        "slot": "armor",
        "attack_bonus": -1,  # Heavy armor reduces attack!
        "defense_bonus": 6
    }
}

Equipment can have trade-offs:

  • Great Axe: High attack but reduces defense
  • Plate Armor: High defense but reduces attack speed

Spawning Equipment

def spawn_item(target_grid, x, y, item_type, tex):
    # ... create entity ...

    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

Equipment items have an embedded Equipment object that stores the stat bonuses.

Equipment Panel UI

class EquipmentPanel:
    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}"

Shows:

  • Currently equipped weapon and its bonus
  • Currently equipped armor and its bonus
  • Total attack and defense (base + equipment)

Using Equipment from Inventory

def use_item(index: int) -> bool:
    item = player_inventory.get(index)

    # Handle equipment
    if item.item_type == "equipment":
        return equip_item(index)

    # Handle consumables
    if item.item_type == "health_potion":
        # ... healing logic ...

The use_item function routes to equip_item() for equipment.

Swapping Equipment

def equip_item(index: int) -> bool:
    # 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)

When equipping:

  1. Remove new item from inventory
  2. Equip it, getting old equipment back
  3. Add old equipment to inventory (or drop if full)

Equipment Tier Progression

Level Range Weapons Armor
1 Dagger (+2 ATK) Leather (+2 DEF)
2-3 Sword (+4 ATK) Chain Mail (+4 DEF)
4+ Great Axe (+6 ATK, -1 DEF) Plate (+6 DEF, -1 ATK)

Congratulations!

You have completed the McRogueFace roguelike tutorial! You have built a complete game with:

  • Procedural dungeon generation
  • Field of view and exploration
  • Turn-based combat (melee and ranged)
  • Multiple enemy types with AI
  • Items and inventory system
  • Health potions and consumables
  • Save and load functionality
  • Multiple dungeon levels
  • Experience points and leveling
  • Equipment with stat bonuses

What You Can Add Next

Here are ideas for extending your roguelike:

More Content

  • New enemy types with special abilities
  • Magic spells and scrolls
  • Traps and environmental hazards
  • Treasure rooms and locked doors
  • Boss enemies every few levels

Advanced Systems

  • Procedural item generation (random stats)
  • Hunger/food system
  • Character classes with different starting stats
  • Skills and abilities
  • Shops to buy/sell items

Polish

  • Sound effects and music
  • Animations for attacks and movement
  • Mini-map of explored areas
  • Character death recap statistics
  • High score table

Technical Improvements

  • Pathfinding (A* algorithm) for smarter enemies
  • More efficient FOV algorithms
  • Customizable keybindings
  • Multiple save slots

Resources for Further Learning

  • Roguelike Development: The RogueBasin wiki has extensive articles
  • Procedural Generation: Explore BSP trees, cellular automata, wave function collapse
  • Game Design: Study classic roguelikes like NetHack, DCSS, Brogue

Final Words

Building a roguelike is an excellent way to learn game development. The genre is forgiving of simple graphics while demanding solid mechanics and systems design. Every feature you add teaches you something new about programming, game design, or both.

Your roguelike is now a complete, playable game. But more importantly, it is a foundation you can build upon. Every great roguelike started somewhere - now yours has too.

Happy roguelike development!


Complete Tutorial Index