draft tutorial revisions

This commit is contained in:
John McCardle 2026-01-03 11:01:10 -05:00
commit 48359b5a48
70 changed files with 6216 additions and 28 deletions

289
docs/templates/complete/ai.py vendored Normal file
View file

@ -0,0 +1,289 @@
"""
ai.py - Enemy AI System for McRogueFace Roguelike
Simple AI behaviors for enemies: chase player when visible, wander otherwise.
Uses A* pathfinding via entity.path_to() for movement.
"""
from typing import List, Tuple, Optional, TYPE_CHECKING
import random
from entities import Enemy, Player, Actor
from combat import melee_attack, CombatResult
if TYPE_CHECKING:
from dungeon import Dungeon
class AIBehavior:
"""Base class for AI behaviors."""
def take_turn(self, enemy: Enemy, player: Player, dungeon: 'Dungeon',
enemies: List[Enemy]) -> Optional[CombatResult]:
"""
Execute one turn of AI behavior.
Args:
enemy: The enemy taking a turn
player: The player to potentially chase/attack
dungeon: The dungeon map
enemies: List of all enemies (for collision avoidance)
Returns:
CombatResult if combat occurred, None otherwise
"""
raise NotImplementedError
class BasicChaseAI(AIBehavior):
"""
Simple chase AI: If player is visible, move toward them.
If adjacent, attack. Otherwise, stand still or wander.
"""
def __init__(self, sight_range: int = 8):
"""
Args:
sight_range: How far the enemy can see
"""
self.sight_range = sight_range
def can_see_player(self, enemy: Enemy, player: Player,
dungeon: 'Dungeon') -> bool:
"""Check if enemy can see the player."""
# Simple distance check combined with line of sight
distance = enemy.distance_to(player)
if distance > self.sight_range:
return False
# Check line of sight using Bresenham's line
return self._has_line_of_sight(enemy.x, enemy.y, player.x, player.y, dungeon)
def _has_line_of_sight(self, x1: int, y1: int, x2: int, y2: int,
dungeon: 'Dungeon') -> bool:
"""
Check if there's a clear line of sight between two points.
Uses Bresenham's line algorithm.
"""
dx = abs(x2 - x1)
dy = abs(y2 - y1)
x, y = x1, y1
sx = 1 if x1 < x2 else -1
sy = 1 if y1 < y2 else -1
if dx > dy:
err = dx / 2
while x != x2:
if not dungeon.is_transparent(x, y):
return False
err -= dy
if err < 0:
y += sy
err += dx
x += sx
else:
err = dy / 2
while y != y2:
if not dungeon.is_transparent(x, y):
return False
err -= dx
if err < 0:
x += sx
err += dy
y += sy
return True
def get_path_to_player(self, enemy: Enemy, player: Player) -> List[Tuple[int, int]]:
"""
Get a path from enemy to player using A* pathfinding.
Uses the entity's built-in path_to method.
"""
try:
path = enemy.entity.path_to(player.x, player.y)
# Convert path to list of tuples
return [(int(p[0]), int(p[1])) for p in path] if path else []
except (AttributeError, TypeError):
# Fallback: simple direction-based movement
return []
def is_position_blocked(self, x: int, y: int, dungeon: 'Dungeon',
enemies: List[Enemy], player: Player) -> bool:
"""Check if a position is blocked by terrain or another actor."""
# Check terrain
if not dungeon.is_walkable(x, y):
return True
# Check player position
if player.x == x and player.y == y:
return True
# Check other enemies
for other in enemies:
if other.is_alive and other.x == x and other.y == y:
return True
return False
def move_toward(self, enemy: Enemy, target_x: int, target_y: int,
dungeon: 'Dungeon', enemies: List[Enemy],
player: Player) -> bool:
"""
Move one step toward the target position.
Returns True if movement occurred, False otherwise.
"""
# Try pathfinding first
path = self.get_path_to_player(enemy, player)
if path and len(path) > 1:
# First element is current position, second is next step
next_x, next_y = path[1]
else:
# Fallback: move in the general direction
dx = 0
dy = 0
if target_x < enemy.x:
dx = -1
elif target_x > enemy.x:
dx = 1
if target_y < enemy.y:
dy = -1
elif target_y > enemy.y:
dy = 1
next_x = enemy.x + dx
next_y = enemy.y + dy
# Check if the position is blocked
if not self.is_position_blocked(next_x, next_y, dungeon, enemies, player):
enemy.move_to(next_x, next_y)
return True
# Try moving in just one axis
if next_x != enemy.x:
if not self.is_position_blocked(next_x, enemy.y, dungeon, enemies, player):
enemy.move_to(next_x, enemy.y)
return True
if next_y != enemy.y:
if not self.is_position_blocked(enemy.x, next_y, dungeon, enemies, player):
enemy.move_to(enemy.x, next_y)
return True
return False
def take_turn(self, enemy: Enemy, player: Player, dungeon: 'Dungeon',
enemies: List[Enemy]) -> Optional[CombatResult]:
"""Execute the enemy's turn."""
if not enemy.is_alive:
return None
# Check if adjacent to player (can attack)
if enemy.distance_to(player) == 1:
return melee_attack(enemy, player)
# Check if can see player
if self.can_see_player(enemy, player, dungeon):
# Move toward player
self.move_toward(enemy, player.x, player.y, dungeon, enemies, player)
return None
class WanderingAI(BasicChaseAI):
"""
AI that wanders randomly when it can't see the player.
More active than BasicChaseAI.
"""
def __init__(self, sight_range: int = 8, wander_chance: float = 0.3):
"""
Args:
sight_range: How far the enemy can see
wander_chance: Probability of wandering each turn (0.0 to 1.0)
"""
super().__init__(sight_range)
self.wander_chance = wander_chance
def wander(self, enemy: Enemy, dungeon: 'Dungeon',
enemies: List[Enemy], player: Player) -> bool:
"""
Move in a random direction.
Returns True if movement occurred.
"""
# Random direction
directions = [
(-1, 0), (1, 0), (0, -1), (0, 1), # Cardinal
(-1, -1), (1, -1), (-1, 1), (1, 1) # Diagonal
]
random.shuffle(directions)
for dx, dy in directions:
new_x = enemy.x + dx
new_y = enemy.y + dy
if not self.is_position_blocked(new_x, new_y, dungeon, enemies, player):
enemy.move_to(new_x, new_y)
return True
return False
def take_turn(self, enemy: Enemy, player: Player, dungeon: 'Dungeon',
enemies: List[Enemy]) -> Optional[CombatResult]:
"""Execute the enemy's turn with wandering behavior."""
if not enemy.is_alive:
return None
# Check if adjacent to player (can attack)
if enemy.distance_to(player) == 1:
return melee_attack(enemy, player)
# Check if can see player
if self.can_see_player(enemy, player, dungeon):
# Chase player
self.move_toward(enemy, player.x, player.y, dungeon, enemies, player)
else:
# Wander randomly
if random.random() < self.wander_chance:
self.wander(enemy, dungeon, enemies, player)
return None
# Default AI instance
default_ai = WanderingAI(sight_range=8, wander_chance=0.3)
def process_enemy_turns(enemies: List[Enemy], player: Player,
dungeon: 'Dungeon',
ai: AIBehavior = None) -> List[CombatResult]:
"""
Process turns for all enemies.
Args:
enemies: List of all enemies
player: The player
dungeon: The dungeon map
ai: AI behavior to use (defaults to WanderingAI)
Returns:
List of combat results from this round of enemy actions
"""
if ai is None:
ai = default_ai
results = []
for enemy in enemies:
if enemy.is_alive:
result = ai.take_turn(enemy, player, dungeon, enemies)
if result:
results.append(result)
return results

187
docs/templates/complete/combat.py vendored Normal file
View file

@ -0,0 +1,187 @@
"""
combat.py - Combat System for McRogueFace Roguelike
Handles attack resolution, damage calculation, and combat outcomes.
"""
from dataclasses import dataclass
from typing import Tuple, Optional
import random
from entities import Actor, Player, Enemy
from constants import (
MSG_PLAYER_ATTACK, MSG_PLAYER_KILL, MSG_PLAYER_MISS,
MSG_ENEMY_ATTACK, MSG_ENEMY_MISS
)
@dataclass
class CombatResult:
"""
Result of a combat action.
Attributes:
attacker: The attacking actor
defender: The defending actor
damage: Damage dealt (after defense)
killed: Whether the defender was killed
message: Human-readable result message
message_color: Color tuple for the message
"""
attacker: Actor
defender: Actor
damage: int
killed: bool
message: str
message_color: Tuple[int, int, int, int]
def calculate_damage(attack: int, defense: int, variance: float = 0.2) -> int:
"""
Calculate damage with some randomness.
Args:
attack: Attacker's attack power
defense: Defender's defense value
variance: Random variance as percentage (0.2 = +/-20%)
Returns:
Final damage amount (minimum 0)
"""
# Base damage is attack vs defense
base_damage = attack - defense
# Add some variance
if base_damage > 0:
variance_amount = int(base_damage * variance)
damage = base_damage + random.randint(-variance_amount, variance_amount)
else:
# Small chance to do 1 damage even with high defense
damage = 1 if random.random() < 0.1 else 0
return max(0, damage)
def attack(attacker: Actor, defender: Actor) -> CombatResult:
"""
Perform an attack from one actor to another.
Args:
attacker: The actor making the attack
defender: The actor being attacked
Returns:
CombatResult with outcome details
"""
# Calculate damage
damage = calculate_damage(
attacker.fighter.attack,
defender.fighter.defense
)
# Apply damage
actual_damage = defender.fighter.take_damage(damage + defender.fighter.defense)
# Note: take_damage applies defense internally, so we add it back
# Actually, we calculated damage already reduced by defense, so just apply it:
defender.fighter.hp = max(0, defender.fighter.hp - damage + actual_damage)
# Simplified: just use take_damage properly
# Reset and do it right:
# Apply raw damage (defense already calculated)
defender.fighter.hp = max(0, defender.fighter.hp - damage)
killed = not defender.is_alive
# Generate message based on attacker/defender types
if isinstance(attacker, Player):
if killed:
message = MSG_PLAYER_KILL % defender.name
color = (255, 255, 100, 255) # Yellow for kills
elif damage > 0:
message = MSG_PLAYER_ATTACK % (defender.name, damage)
color = (255, 255, 255, 255) # White for hits
else:
message = MSG_PLAYER_MISS % defender.name
color = (150, 150, 150, 255) # Gray for misses
else:
if damage > 0:
message = MSG_ENEMY_ATTACK % (attacker.name, damage)
color = (255, 100, 100, 255) # Red for enemy hits
else:
message = MSG_ENEMY_MISS % attacker.name
color = (150, 150, 150, 255) # Gray for misses
return CombatResult(
attacker=attacker,
defender=defender,
damage=damage,
killed=killed,
message=message,
message_color=color
)
def melee_attack(attacker: Actor, defender: Actor) -> CombatResult:
"""
Perform a melee attack (bump attack).
This is the standard roguelike bump-to-attack.
Args:
attacker: The actor making the attack
defender: The actor being attacked
Returns:
CombatResult with outcome details
"""
return attack(attacker, defender)
def try_attack(attacker: Actor, target_x: int, target_y: int,
enemies: list, player: Optional[Player] = None) -> Optional[CombatResult]:
"""
Attempt to attack whatever is at the target position.
Args:
attacker: The actor making the attack
target_x: X coordinate to attack
target_y: Y coordinate to attack
enemies: List of Enemy actors
player: The player (if attacker is an enemy)
Returns:
CombatResult if something was attacked, None otherwise
"""
# Check if player is attacking
if isinstance(attacker, Player):
# Look for enemy at position
for enemy in enemies:
if enemy.is_alive and enemy.x == target_x and enemy.y == target_y:
return melee_attack(attacker, enemy)
else:
# Enemy attacking - check if player is at position
if player and player.x == target_x and player.y == target_y:
return melee_attack(attacker, player)
return None
def process_kill(attacker: Actor, defender: Actor) -> int:
"""
Process the aftermath of killing an enemy.
Args:
attacker: The actor that made the kill
defender: The actor that was killed
Returns:
XP gained (if attacker is player and defender is enemy)
"""
xp_gained = 0
if isinstance(attacker, Player) and isinstance(defender, Enemy):
xp_gained = defender.xp_reward
attacker.gain_xp(xp_gained)
# Remove the dead actor from the grid
defender.remove()
return xp_gained

210
docs/templates/complete/constants.py vendored Normal file
View file

@ -0,0 +1,210 @@
"""
constants.py - Game Constants for McRogueFace Complete Roguelike Template
All configuration values in one place for easy tweaking.
"""
# =============================================================================
# WINDOW AND DISPLAY
# =============================================================================
SCREEN_WIDTH = 1024
SCREEN_HEIGHT = 768
# Grid display area (where the dungeon is rendered)
GRID_X = 0
GRID_Y = 0
GRID_WIDTH = 800
GRID_HEIGHT = 600
# Tile dimensions (must match your texture)
TILE_WIDTH = 16
TILE_HEIGHT = 16
# =============================================================================
# DUNGEON GENERATION
# =============================================================================
# Size of the dungeon in tiles
DUNGEON_WIDTH = 80
DUNGEON_HEIGHT = 45
# Room size constraints
ROOM_MIN_SIZE = 6
ROOM_MAX_SIZE = 12
MAX_ROOMS = 15
# Enemy spawning per room
MAX_ENEMIES_PER_ROOM = 3
MIN_ENEMIES_PER_ROOM = 0
# =============================================================================
# SPRITE INDICES (for kenney_tinydungeon.png - 16x16 tiles)
# Adjust these if using a different tileset
# =============================================================================
# Terrain
SPRITE_FLOOR = 48 # Dungeon floor
SPRITE_WALL = 33 # Wall tile
SPRITE_STAIRS_DOWN = 50 # Stairs going down
SPRITE_DOOR = 49 # Door tile
# Player sprites
SPRITE_PLAYER = 84 # Player character (knight)
# Enemy sprites
SPRITE_GOBLIN = 111 # Goblin enemy
SPRITE_ORC = 112 # Orc enemy
SPRITE_TROLL = 116 # Troll enemy
# Items (for future expansion)
SPRITE_POTION = 89 # Health potion
SPRITE_CHEST = 91 # Treasure chest
# =============================================================================
# COLORS (R, G, B, A)
# =============================================================================
# Map colors
COLOR_DARK_WALL = (50, 50, 100, 255)
COLOR_DARK_FLOOR = (30, 30, 50, 255)
COLOR_LIGHT_WALL = (100, 100, 150, 255)
COLOR_LIGHT_FLOOR = (80, 80, 100, 255)
# FOV overlay colors
COLOR_FOG = (0, 0, 0, 200) # Unexplored areas
COLOR_REMEMBERED = (0, 0, 0, 128) # Seen but not visible
COLOR_VISIBLE = (0, 0, 0, 0) # Currently visible (transparent)
# UI Colors
COLOR_UI_BG = (20, 20, 30, 230)
COLOR_UI_BORDER = (80, 80, 120, 255)
COLOR_TEXT = (255, 255, 255, 255)
COLOR_TEXT_HIGHLIGHT = (255, 255, 100, 255)
# Health bar colors
COLOR_HP_BAR_BG = (80, 0, 0, 255)
COLOR_HP_BAR_FILL = (0, 180, 0, 255)
COLOR_HP_BAR_WARNING = (180, 180, 0, 255)
COLOR_HP_BAR_CRITICAL = (180, 0, 0, 255)
# Message log colors
COLOR_MSG_DEFAULT = (255, 255, 255, 255)
COLOR_MSG_DAMAGE = (255, 100, 100, 255)
COLOR_MSG_HEAL = (100, 255, 100, 255)
COLOR_MSG_INFO = (100, 100, 255, 255)
COLOR_MSG_IMPORTANT = (255, 255, 100, 255)
# =============================================================================
# PLAYER STATS
# =============================================================================
PLAYER_START_HP = 30
PLAYER_START_ATTACK = 5
PLAYER_START_DEFENSE = 2
# =============================================================================
# ENEMY STATS
# Each enemy type: (hp, attack, defense, xp_reward, name)
# =============================================================================
ENEMY_STATS = {
'goblin': {
'hp': 10,
'attack': 3,
'defense': 0,
'xp': 35,
'sprite': SPRITE_GOBLIN,
'name': 'Goblin'
},
'orc': {
'hp': 16,
'attack': 4,
'defense': 1,
'xp': 50,
'sprite': SPRITE_ORC,
'name': 'Orc'
},
'troll': {
'hp': 24,
'attack': 6,
'defense': 2,
'xp': 100,
'sprite': SPRITE_TROLL,
'name': 'Troll'
}
}
# Enemy spawn weights per dungeon level
# Format: {level: [(enemy_type, weight), ...]}
# Higher weight = more likely to spawn
ENEMY_SPAWN_WEIGHTS = {
1: [('goblin', 100)],
2: [('goblin', 80), ('orc', 20)],
3: [('goblin', 60), ('orc', 40)],
4: [('goblin', 40), ('orc', 50), ('troll', 10)],
5: [('goblin', 20), ('orc', 50), ('troll', 30)],
}
# Default weights for levels beyond those defined
DEFAULT_SPAWN_WEIGHTS = [('goblin', 10), ('orc', 50), ('troll', 40)]
# =============================================================================
# FOV (Field of View) SETTINGS
# =============================================================================
FOV_RADIUS = 8 # How far the player can see
FOV_LIGHT_WALLS = True # Whether walls at FOV edge are visible
# =============================================================================
# INPUT KEYS
# Key names as returned by McRogueFace keypressScene
# =============================================================================
KEY_UP = ['Up', 'W', 'Numpad8']
KEY_DOWN = ['Down', 'S', 'Numpad2']
KEY_LEFT = ['Left', 'A', 'Numpad4']
KEY_RIGHT = ['Right', 'D', 'Numpad6']
# Diagonal movement (numpad)
KEY_UP_LEFT = ['Numpad7']
KEY_UP_RIGHT = ['Numpad9']
KEY_DOWN_LEFT = ['Numpad1']
KEY_DOWN_RIGHT = ['Numpad3']
# Actions
KEY_WAIT = ['Period', 'Numpad5'] # Skip turn
KEY_DESCEND = ['Greater', 'Space'] # Go down stairs (> key or space)
# =============================================================================
# GAME MESSAGES
# =============================================================================
MSG_WELCOME = "Welcome to the dungeon! Find the stairs to descend deeper."
MSG_DESCEND = "You descend the stairs to level %d..."
MSG_PLAYER_ATTACK = "You attack the %s for %d damage!"
MSG_PLAYER_KILL = "You have slain the %s!"
MSG_PLAYER_MISS = "You attack the %s but do no damage."
MSG_ENEMY_ATTACK = "The %s attacks you for %d damage!"
MSG_ENEMY_MISS = "The %s attacks you but does no damage."
MSG_BLOCKED = "You can't move there!"
MSG_STAIRS = "You see stairs leading down here. Press > or Space to descend."
MSG_DEATH = "You have died! Press R to restart."
MSG_NO_STAIRS = "There are no stairs here."
# =============================================================================
# UI LAYOUT
# =============================================================================
# Health bar
HP_BAR_X = 10
HP_BAR_Y = 620
HP_BAR_WIDTH = 200
HP_BAR_HEIGHT = 24
# Message log
MSG_LOG_X = 10
MSG_LOG_Y = 660
MSG_LOG_WIDTH = 780
MSG_LOG_HEIGHT = 100
MSG_LOG_MAX_LINES = 5
# Dungeon level display
LEVEL_DISPLAY_X = 700
LEVEL_DISPLAY_Y = 620
# =============================================================================
# ASSET PATHS
# =============================================================================
TEXTURE_PATH = "assets/kenney_tinydungeon.png"
FONT_PATH = "assets/JetbrainsMono.ttf"

298
docs/templates/complete/dungeon.py vendored Normal file
View file

@ -0,0 +1,298 @@
"""
dungeon.py - Procedural Dungeon Generation for McRogueFace
Generates a roguelike dungeon with rooms connected by corridors.
Includes stairs placement for multi-level progression.
"""
import random
from dataclasses import dataclass
from typing import List, Tuple, Optional
from constants import (
DUNGEON_WIDTH, DUNGEON_HEIGHT,
ROOM_MIN_SIZE, ROOM_MAX_SIZE, MAX_ROOMS,
SPRITE_FLOOR, SPRITE_WALL, SPRITE_STAIRS_DOWN,
MAX_ENEMIES_PER_ROOM, MIN_ENEMIES_PER_ROOM,
ENEMY_SPAWN_WEIGHTS, DEFAULT_SPAWN_WEIGHTS
)
@dataclass
class Rect:
"""A rectangle representing a room in the dungeon."""
x: int
y: int
width: int
height: int
@property
def x2(self) -> int:
return self.x + self.width
@property
def y2(self) -> int:
return self.y + self.height
@property
def center(self) -> Tuple[int, int]:
"""Return the center coordinates of this room."""
center_x = (self.x + self.x2) // 2
center_y = (self.y + self.y2) // 2
return center_x, center_y
def intersects(self, other: 'Rect') -> bool:
"""Check if this room overlaps with another (with 1 tile buffer)."""
return (self.x <= other.x2 + 1 and self.x2 + 1 >= other.x and
self.y <= other.y2 + 1 and self.y2 + 1 >= other.y)
def inner(self) -> Tuple[int, int, int, int]:
"""Return the inner area of the room (excluding walls)."""
return self.x + 1, self.y + 1, self.width - 2, self.height - 2
class Tile:
"""Represents a single tile in the dungeon."""
def __init__(self, walkable: bool = False, transparent: bool = False,
sprite: int = SPRITE_WALL):
self.walkable = walkable
self.transparent = transparent
self.sprite = sprite
self.explored = False
self.visible = False
class Dungeon:
"""
The dungeon map with rooms, corridors, and tile data.
Attributes:
width: Width of the dungeon in tiles
height: Height of the dungeon in tiles
level: Current dungeon depth
tiles: 2D array of Tile objects
rooms: List of rooms (Rect objects)
player_start: Starting position for the player
stairs_pos: Position of the stairs down
"""
def __init__(self, width: int = DUNGEON_WIDTH, height: int = DUNGEON_HEIGHT,
level: int = 1):
self.width = width
self.height = height
self.level = level
self.tiles: List[List[Tile]] = []
self.rooms: List[Rect] = []
self.player_start: Tuple[int, int] = (0, 0)
self.stairs_pos: Tuple[int, int] = (0, 0)
# Initialize all tiles as walls
self._init_tiles()
def _init_tiles(self) -> None:
"""Fill the dungeon with wall tiles."""
self.tiles = [
[Tile(walkable=False, transparent=False, sprite=SPRITE_WALL)
for _ in range(self.height)]
for _ in range(self.width)
]
def in_bounds(self, x: int, y: int) -> bool:
"""Check if coordinates are within dungeon bounds."""
return 0 <= x < self.width and 0 <= y < self.height
def is_walkable(self, x: int, y: int) -> bool:
"""Check if a tile can be walked on."""
if not self.in_bounds(x, y):
return False
return self.tiles[x][y].walkable
def is_transparent(self, x: int, y: int) -> bool:
"""Check if a tile allows light to pass through."""
if not self.in_bounds(x, y):
return False
return self.tiles[x][y].transparent
def get_tile(self, x: int, y: int) -> Optional[Tile]:
"""Get the tile at the given position."""
if not self.in_bounds(x, y):
return None
return self.tiles[x][y]
def set_tile(self, x: int, y: int, walkable: bool, transparent: bool,
sprite: int) -> None:
"""Set properties of a tile."""
if self.in_bounds(x, y):
tile = self.tiles[x][y]
tile.walkable = walkable
tile.transparent = transparent
tile.sprite = sprite
def carve_room(self, room: Rect) -> None:
"""Carve out a room in the dungeon (make tiles walkable)."""
inner_x, inner_y, inner_w, inner_h = room.inner()
for x in range(inner_x, inner_x + inner_w):
for y in range(inner_y, inner_y + inner_h):
self.set_tile(x, y, walkable=True, transparent=True,
sprite=SPRITE_FLOOR)
def carve_tunnel_h(self, x1: int, x2: int, y: int) -> None:
"""Carve a horizontal tunnel."""
for x in range(min(x1, x2), max(x1, x2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite=SPRITE_FLOOR)
def carve_tunnel_v(self, y1: int, y2: int, x: int) -> None:
"""Carve a vertical tunnel."""
for y in range(min(y1, y2), max(y1, y2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite=SPRITE_FLOOR)
def connect_rooms(self, room1: Rect, room2: Rect) -> None:
"""Connect two rooms with an L-shaped corridor."""
x1, y1 = room1.center
x2, y2 = room2.center
# Randomly choose to go horizontal then vertical, or vice versa
if random.random() < 0.5:
self.carve_tunnel_h(x1, x2, y1)
self.carve_tunnel_v(y1, y2, x2)
else:
self.carve_tunnel_v(y1, y2, x1)
self.carve_tunnel_h(x1, x2, y2)
def place_stairs(self) -> None:
"""Place stairs in the last room."""
if self.rooms:
# Stairs go in the center of the last room
self.stairs_pos = self.rooms[-1].center
x, y = self.stairs_pos
self.set_tile(x, y, walkable=True, transparent=True,
sprite=SPRITE_STAIRS_DOWN)
def generate(self) -> None:
"""Generate the dungeon using BSP-style room placement."""
self._init_tiles()
self.rooms.clear()
for _ in range(MAX_ROOMS):
# Random room dimensions
w = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE)
h = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE)
# Random position (ensure room fits in dungeon)
x = random.randint(1, self.width - w - 1)
y = random.randint(1, self.height - h - 1)
new_room = Rect(x, y, w, h)
# Check for intersections with existing rooms
if any(new_room.intersects(other) for other in self.rooms):
continue
# Room is valid - carve it out
self.carve_room(new_room)
if self.rooms:
# Connect to previous room
self.connect_rooms(self.rooms[-1], new_room)
else:
# First room - player starts here
self.player_start = new_room.center
self.rooms.append(new_room)
# Place stairs in the last room
self.place_stairs()
def get_spawn_positions(self) -> List[Tuple[int, int]]:
"""
Get valid spawn positions for enemies.
Returns positions from all rooms except the first (player start).
"""
positions = []
for room in self.rooms[1:]: # Skip first room (player start)
inner_x, inner_y, inner_w, inner_h = room.inner()
for x in range(inner_x, inner_x + inner_w):
for y in range(inner_y, inner_y + inner_h):
# Don't spawn on stairs
if (x, y) != self.stairs_pos:
positions.append((x, y))
return positions
def get_enemy_spawns(self) -> List[Tuple[str, int, int]]:
"""
Determine which enemies to spawn and where.
Returns list of (enemy_type, x, y) tuples.
"""
spawns = []
# Get spawn weights for this level
weights = ENEMY_SPAWN_WEIGHTS.get(self.level, DEFAULT_SPAWN_WEIGHTS)
# Create weighted list for random selection
enemy_types = []
for enemy_type, weight in weights:
enemy_types.extend([enemy_type] * weight)
# Spawn enemies in each room (except the first)
for room in self.rooms[1:]:
num_enemies = random.randint(MIN_ENEMIES_PER_ROOM, MAX_ENEMIES_PER_ROOM)
# Scale up enemies slightly with dungeon level
num_enemies = min(num_enemies + (self.level - 1) // 2, MAX_ENEMIES_PER_ROOM + 2)
inner_x, inner_y, inner_w, inner_h = room.inner()
used_positions = set()
for _ in range(num_enemies):
# Find an unused position
attempts = 0
while attempts < 20:
x = random.randint(inner_x, inner_x + inner_w - 1)
y = random.randint(inner_y, inner_y + inner_h - 1)
if (x, y) not in used_positions and (x, y) != self.stairs_pos:
enemy_type = random.choice(enemy_types)
spawns.append((enemy_type, x, y))
used_positions.add((x, y))
break
attempts += 1
return spawns
def apply_to_grid(self, grid) -> None:
"""
Apply the dungeon data to a McRogueFace Grid object.
Args:
grid: A mcrfpy.Grid object to update
"""
for x in range(self.width):
for y in range(self.height):
tile = self.tiles[x][y]
point = grid.at(x, y)
point.tilesprite = tile.sprite
point.walkable = tile.walkable
point.transparent = tile.transparent
def generate_dungeon(level: int = 1) -> Dungeon:
"""
Convenience function to generate a new dungeon.
Args:
level: The dungeon depth (affects enemy spawns)
Returns:
A fully generated Dungeon object
"""
dungeon = Dungeon(level=level)
dungeon.generate()
return dungeon

319
docs/templates/complete/entities.py vendored Normal file
View file

@ -0,0 +1,319 @@
"""
entities.py - Player and Enemy Entity Definitions
Defines the game actors with stats, rendering, and basic behaviors.
Uses composition with McRogueFace Entity objects for rendering.
"""
from dataclasses import dataclass, field
from typing import Optional, List, Tuple, TYPE_CHECKING
import mcrfpy
from constants import (
PLAYER_START_HP, PLAYER_START_ATTACK, PLAYER_START_DEFENSE,
SPRITE_PLAYER, ENEMY_STATS, FOV_RADIUS
)
if TYPE_CHECKING:
from dungeon import Dungeon
@dataclass
class Fighter:
"""
Combat statistics component for entities that can fight.
Attributes:
hp: Current hit points
max_hp: Maximum hit points
attack: Attack power
defense: Damage reduction
"""
hp: int
max_hp: int
attack: int
defense: int
@property
def is_alive(self) -> bool:
"""Check if this fighter is still alive."""
return self.hp > 0
@property
def hp_percent(self) -> float:
"""Return HP as a percentage (0.0 to 1.0)."""
if self.max_hp <= 0:
return 0.0
return self.hp / self.max_hp
def heal(self, amount: int) -> int:
"""
Heal by the given amount, up to max_hp.
Returns:
The actual amount healed.
"""
old_hp = self.hp
self.hp = min(self.hp + amount, self.max_hp)
return self.hp - old_hp
def take_damage(self, amount: int) -> int:
"""
Take damage, reduced by defense.
Args:
amount: Raw damage before defense calculation
Returns:
The actual damage taken after defense.
"""
# Defense reduces damage, minimum 0
actual_damage = max(0, amount - self.defense)
self.hp = max(0, self.hp - actual_damage)
return actual_damage
class Actor:
"""
Base class for all game actors (player and enemies).
Wraps a McRogueFace Entity and adds game logic.
"""
def __init__(self, x: int, y: int, sprite: int, name: str,
texture: mcrfpy.Texture, grid: mcrfpy.Grid,
fighter: Fighter):
"""
Create a new actor.
Args:
x: Starting X position
y: Starting Y position
sprite: Sprite index for rendering
name: Display name of this actor
texture: Texture for the entity sprite
grid: Grid to add the entity to
fighter: Combat statistics
"""
self.name = name
self.fighter = fighter
self.grid = grid
self._x = x
self._y = y
# Create the McRogueFace entity
self.entity = mcrfpy.Entity((x, y), texture, sprite)
grid.entities.append(self.entity)
@property
def x(self) -> int:
return self._x
@x.setter
def x(self, value: int) -> None:
self._x = value
self.entity.pos = (value, self._y)
@property
def y(self) -> int:
return self._y
@y.setter
def y(self, value: int) -> None:
self._y = value
self.entity.pos = (self._x, value)
@property
def pos(self) -> Tuple[int, int]:
return (self._x, self._y)
@pos.setter
def pos(self, value: Tuple[int, int]) -> None:
self._x, self._y = value
self.entity.pos = value
@property
def is_alive(self) -> bool:
return self.fighter.is_alive
def move(self, dx: int, dy: int) -> None:
"""Move by the given delta."""
self.x += dx
self.y += dy
def move_to(self, x: int, y: int) -> None:
"""Move to an absolute position."""
self.pos = (x, y)
def distance_to(self, other: 'Actor') -> int:
"""Calculate Manhattan distance to another actor."""
return abs(self.x - other.x) + abs(self.y - other.y)
def remove(self) -> None:
"""Remove this actor's entity from the grid."""
try:
idx = self.entity.index()
self.grid.entities.remove(idx)
except (ValueError, RuntimeError):
pass # Already removed
class Player(Actor):
"""
The player character with additional player-specific functionality.
"""
def __init__(self, x: int, y: int, texture: mcrfpy.Texture,
grid: mcrfpy.Grid):
fighter = Fighter(
hp=PLAYER_START_HP,
max_hp=PLAYER_START_HP,
attack=PLAYER_START_ATTACK,
defense=PLAYER_START_DEFENSE
)
super().__init__(
x=x, y=y,
sprite=SPRITE_PLAYER,
name="Player",
texture=texture,
grid=grid,
fighter=fighter
)
self.xp = 0
self.level = 1
self.dungeon_level = 1
def gain_xp(self, amount: int) -> bool:
"""
Gain experience points.
Args:
amount: XP to gain
Returns:
True if the player leveled up
"""
self.xp += amount
xp_to_level = self.xp_for_next_level
if self.xp >= xp_to_level:
self.level_up()
return True
return False
@property
def xp_for_next_level(self) -> int:
"""XP required for the next level."""
return self.level * 100
def level_up(self) -> None:
"""Level up the player, improving stats."""
self.level += 1
# Improve stats
hp_increase = 5
attack_increase = 1
defense_increase = 1 if self.level % 3 == 0 else 0
self.fighter.max_hp += hp_increase
self.fighter.hp += hp_increase # Heal the increase amount
self.fighter.attack += attack_increase
self.fighter.defense += defense_increase
def update_fov(self, dungeon: 'Dungeon') -> None:
"""
Update field of view based on player position.
Uses entity.update_visibility() for TCOD FOV calculation.
"""
# Update the entity's visibility data
self.entity.update_visibility()
# Apply FOV to dungeon tiles
for x in range(dungeon.width):
for y in range(dungeon.height):
state = self.entity.at(x, y)
tile = dungeon.get_tile(x, y)
if tile:
tile.visible = state.visible
if state.visible:
tile.explored = True
class Enemy(Actor):
"""
An enemy actor with AI behavior.
"""
def __init__(self, x: int, y: int, enemy_type: str,
texture: mcrfpy.Texture, grid: mcrfpy.Grid):
"""
Create a new enemy.
Args:
x: Starting X position
y: Starting Y position
enemy_type: Key into ENEMY_STATS dictionary
texture: Texture for the entity sprite
grid: Grid to add the entity to
"""
stats = ENEMY_STATS.get(enemy_type, ENEMY_STATS['goblin'])
fighter = Fighter(
hp=stats['hp'],
max_hp=stats['hp'],
attack=stats['attack'],
defense=stats['defense']
)
super().__init__(
x=x, y=y,
sprite=stats['sprite'],
name=stats['name'],
texture=texture,
grid=grid,
fighter=fighter
)
self.enemy_type = enemy_type
self.xp_reward = stats['xp']
# AI state
self.target: Optional[Actor] = None
self.path: List[Tuple[int, int]] = []
def create_player(x: int, y: int, texture: mcrfpy.Texture,
grid: mcrfpy.Grid) -> Player:
"""
Factory function to create the player.
Args:
x: Starting X position
y: Starting Y position
texture: Texture for player sprite
grid: Grid to add player to
Returns:
A new Player instance
"""
return Player(x, y, texture, grid)
def create_enemy(x: int, y: int, enemy_type: str,
texture: mcrfpy.Texture, grid: mcrfpy.Grid) -> Enemy:
"""
Factory function to create an enemy.
Args:
x: Starting X position
y: Starting Y position
enemy_type: Type of enemy ('goblin', 'orc', 'troll')
texture: Texture for enemy sprite
grid: Grid to add enemy to
Returns:
A new Enemy instance
"""
return Enemy(x, y, enemy_type, texture, grid)

313
docs/templates/complete/game.py vendored Normal file
View file

@ -0,0 +1,313 @@
"""
game.py - Main Entry Point for McRogueFace Complete Roguelike Template
This is the main game file that ties everything together:
- Scene setup
- Input handling
- Game loop
- Level transitions
To run: Copy this template to your McRogueFace scripts/ directory
and rename to game.py (or import from game.py).
"""
import mcrfpy
from typing import List, Optional
# Import game modules
from constants import (
SCREEN_WIDTH, SCREEN_HEIGHT,
GRID_X, GRID_Y, GRID_WIDTH, GRID_HEIGHT,
DUNGEON_WIDTH, DUNGEON_HEIGHT,
TEXTURE_PATH, FONT_PATH,
KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT,
KEY_UP_LEFT, KEY_UP_RIGHT, KEY_DOWN_LEFT, KEY_DOWN_RIGHT,
KEY_WAIT, KEY_DESCEND,
MSG_WELCOME, MSG_DESCEND, MSG_BLOCKED, MSG_STAIRS, MSG_DEATH, MSG_NO_STAIRS,
FOV_RADIUS, COLOR_FOG, COLOR_REMEMBERED, COLOR_VISIBLE
)
from dungeon import Dungeon, generate_dungeon
from entities import Player, Enemy, create_player, create_enemy
from turns import TurnManager, GameState
from ui import GameUI, DeathScreen
class Game:
"""
Main game class that manages the complete roguelike experience.
"""
def __init__(self):
"""Initialize the game."""
# Load resources
self.texture = mcrfpy.Texture(TEXTURE_PATH, 16, 16)
self.font = mcrfpy.Font(FONT_PATH)
# Create scene
mcrfpy.createScene("game")
self.ui_collection = mcrfpy.sceneUI("game")
# Create grid
self.grid = mcrfpy.Grid(
DUNGEON_WIDTH, DUNGEON_HEIGHT,
self.texture,
GRID_X, GRID_Y,
GRID_WIDTH, GRID_HEIGHT
)
self.ui_collection.append(self.grid)
# Game state
self.dungeon: Optional[Dungeon] = None
self.player: Optional[Player] = None
self.enemies: List[Enemy] = []
self.turn_manager: Optional[TurnManager] = None
self.current_level = 1
# UI
self.game_ui = GameUI(self.font)
self.game_ui.add_to_scene(self.ui_collection)
self.death_screen: Optional[DeathScreen] = None
self.game_over = False
# Set up input handling
mcrfpy.keypressScene(self.handle_keypress)
# Start the game
self.new_game()
# Switch to game scene
mcrfpy.setScene("game")
def new_game(self) -> None:
"""Start a new game from level 1."""
self.current_level = 1
self.game_over = False
# Clear any death screen
if self.death_screen:
self.death_screen.remove_from_scene(self.ui_collection)
self.death_screen = None
# Generate first level
self.generate_level()
# Welcome message
self.game_ui.clear_messages()
self.game_ui.add_message(MSG_WELCOME, (255, 255, 100, 255))
def generate_level(self) -> None:
"""Generate a new dungeon level."""
# Clear existing entities from grid
while len(self.grid.entities) > 0:
self.grid.entities.remove(0)
self.enemies.clear()
# Generate dungeon
self.dungeon = generate_dungeon(self.current_level)
self.dungeon.apply_to_grid(self.grid)
# Create player at start position
start_x, start_y = self.dungeon.player_start
self.player = create_player(start_x, start_y, self.texture, self.grid)
self.player.dungeon_level = self.current_level
# Spawn enemies
enemy_spawns = self.dungeon.get_enemy_spawns()
for enemy_type, x, y in enemy_spawns:
enemy = create_enemy(x, y, enemy_type, self.texture, self.grid)
self.enemies.append(enemy)
# Set up turn manager
self.turn_manager = TurnManager(self.player, self.enemies, self.dungeon)
self.turn_manager.on_message = self.game_ui.add_message
self.turn_manager.on_player_death = self.on_player_death
# Update FOV
self.update_fov()
# Center camera on player
self.center_camera()
# Update UI
self.game_ui.update_level(self.current_level)
self.update_ui()
def descend(self) -> None:
"""Go down to the next dungeon level."""
# Check if player is on stairs
if self.player.pos != self.dungeon.stairs_pos:
self.game_ui.add_message(MSG_NO_STAIRS, (150, 150, 150, 255))
return
self.current_level += 1
self.game_ui.add_message(MSG_DESCEND % self.current_level, (100, 100, 255, 255))
# Keep player stats
old_hp = self.player.fighter.hp
old_max_hp = self.player.fighter.max_hp
old_attack = self.player.fighter.attack
old_defense = self.player.fighter.defense
old_xp = self.player.xp
old_level = self.player.level
# Generate new level
self.generate_level()
# Restore player stats
self.player.fighter.hp = old_hp
self.player.fighter.max_hp = old_max_hp
self.player.fighter.attack = old_attack
self.player.fighter.defense = old_defense
self.player.xp = old_xp
self.player.level = old_level
self.update_ui()
def update_fov(self) -> None:
"""Update field of view and apply to grid tiles."""
if not self.player or not self.dungeon:
return
# Use entity's built-in FOV calculation
self.player.entity.update_visibility()
# Apply visibility to tiles
for x in range(self.dungeon.width):
for y in range(self.dungeon.height):
point = self.grid.at(x, y)
tile = self.dungeon.get_tile(x, y)
if tile:
state = self.player.entity.at(x, y)
if state.visible:
# Currently visible
tile.explored = True
tile.visible = True
point.color_overlay = mcrfpy.Color(*COLOR_VISIBLE)
elif tile.explored:
# Explored but not visible
tile.visible = False
point.color_overlay = mcrfpy.Color(*COLOR_REMEMBERED)
else:
# Never seen
point.color_overlay = mcrfpy.Color(*COLOR_FOG)
def center_camera(self) -> None:
"""Center the camera on the player."""
if self.player:
self.grid.center = (self.player.x, self.player.y)
def update_ui(self) -> None:
"""Update all UI elements."""
if self.player:
self.game_ui.update_hp(
self.player.fighter.hp,
self.player.fighter.max_hp
)
def on_player_death(self) -> None:
"""Handle player death."""
self.game_over = True
self.game_ui.add_message(MSG_DEATH, (255, 0, 0, 255))
# Show death screen
self.death_screen = DeathScreen(self.font)
self.death_screen.add_to_scene(self.ui_collection)
def handle_keypress(self, key: str, state: str) -> None:
"""
Handle keyboard input.
Args:
key: Key name
state: "start" for key down, "end" for key up
"""
# Only handle key down events
if state != "start":
return
# Handle restart when dead
if self.game_over:
if key == "R":
self.new_game()
return
# Handle movement
dx, dy = 0, 0
if key in KEY_UP:
dy = -1
elif key in KEY_DOWN:
dy = 1
elif key in KEY_LEFT:
dx = -1
elif key in KEY_RIGHT:
dx = 1
elif key in KEY_UP_LEFT:
dx, dy = -1, -1
elif key in KEY_UP_RIGHT:
dx, dy = 1, -1
elif key in KEY_DOWN_LEFT:
dx, dy = -1, 1
elif key in KEY_DOWN_RIGHT:
dx, dy = 1, 1
elif key in KEY_WAIT:
# Skip turn
self.turn_manager.handle_wait()
self.after_turn()
return
elif key in KEY_DESCEND:
# Try to descend
self.descend()
return
elif key == "Escape":
# Quit game
mcrfpy.exit()
return
# Process movement/attack
if dx != 0 or dy != 0:
if self.turn_manager.handle_player_action(dx, dy):
self.after_turn()
else:
# Movement was blocked
self.game_ui.add_message(MSG_BLOCKED, (150, 150, 150, 255))
def after_turn(self) -> None:
"""Called after each player turn."""
# Update FOV
self.update_fov()
# Center camera
self.center_camera()
# Update UI
self.update_ui()
# Check if standing on stairs
if self.player.pos == self.dungeon.stairs_pos:
self.game_ui.add_message(MSG_STAIRS, (100, 255, 100, 255))
# Clean up dead enemies
self.enemies = [e for e in self.enemies if e.is_alive]
# =============================================================================
# ENTRY POINT
# =============================================================================
# Global game instance
game: Optional[Game] = None
def start_game():
"""Start the game."""
global game
game = Game()
# Auto-start when this script is loaded
start_game()

232
docs/templates/complete/turns.py vendored Normal file
View file

@ -0,0 +1,232 @@
"""
turns.py - Turn Management System for McRogueFace Roguelike
Handles the turn-based game flow: player turn, then enemy turns.
"""
from enum import Enum, auto
from typing import List, Optional, Callable, TYPE_CHECKING
from entities import Player, Enemy
from combat import try_attack, process_kill, CombatResult
from ai import process_enemy_turns
if TYPE_CHECKING:
from dungeon import Dungeon
class GameState(Enum):
"""Current state of the game."""
PLAYER_TURN = auto() # Waiting for player input
ENEMY_TURN = auto() # Processing enemy actions
PLAYER_DEAD = auto() # Player has died
VICTORY = auto() # Player has won (optional)
LEVEL_TRANSITION = auto() # Moving to next level
class TurnManager:
"""
Manages the turn-based game loop.
The game follows this flow:
1. Player takes action (move or attack)
2. If action was valid, enemies take turns
3. Check for game over conditions
4. Return to step 1
"""
def __init__(self, player: Player, enemies: List[Enemy], dungeon: 'Dungeon'):
"""
Initialize the turn manager.
Args:
player: The player entity
enemies: List of all enemies
dungeon: The dungeon map
"""
self.player = player
self.enemies = enemies
self.dungeon = dungeon
self.state = GameState.PLAYER_TURN
self.turn_count = 0
# Callbacks for game events
self.on_message: Optional[Callable[[str, tuple], None]] = None
self.on_player_death: Optional[Callable[[], None]] = None
self.on_enemy_death: Optional[Callable[[Enemy], None]] = None
self.on_turn_end: Optional[Callable[[int], None]] = None
def reset(self, player: Player, enemies: List[Enemy], dungeon: 'Dungeon') -> None:
"""Reset the turn manager with new game state."""
self.player = player
self.enemies = enemies
self.dungeon = dungeon
self.state = GameState.PLAYER_TURN
self.turn_count = 0
def add_message(self, message: str, color: tuple = (255, 255, 255, 255)) -> None:
"""Add a message to the log via callback."""
if self.on_message:
self.on_message(message, color)
def handle_player_action(self, dx: int, dy: int) -> bool:
"""
Handle a player movement or attack action.
Args:
dx: X direction (-1, 0, or 1)
dy: Y direction (-1, 0, or 1)
Returns:
True if the action consumed a turn, False otherwise
"""
if self.state != GameState.PLAYER_TURN:
return False
target_x = self.player.x + dx
target_y = self.player.y + dy
# Check for attack
result = try_attack(self.player, target_x, target_y, self.enemies)
if result:
# Player attacked something
self.add_message(result.message, result.message_color)
if result.killed:
# Process kill
xp = process_kill(self.player, result.defender)
self.enemies.remove(result.defender)
if xp > 0:
self.add_message(f"You gain {xp} XP!", (255, 255, 100, 255))
if self.on_enemy_death:
self.on_enemy_death(result.defender)
# Action consumed a turn
self._end_player_turn()
return True
# No attack - try to move
if self.dungeon.is_walkable(target_x, target_y):
# Check for enemy blocking
blocked = False
for enemy in self.enemies:
if enemy.is_alive and enemy.x == target_x and enemy.y == target_y:
blocked = True
break
if not blocked:
self.player.move_to(target_x, target_y)
self._end_player_turn()
return True
# Movement blocked
return False
def handle_wait(self) -> bool:
"""
Handle the player choosing to wait (skip turn).
Returns:
True (always consumes a turn)
"""
if self.state != GameState.PLAYER_TURN:
return False
self.add_message("You wait...", (150, 150, 150, 255))
self._end_player_turn()
return True
def _end_player_turn(self) -> None:
"""End the player's turn and process enemy turns."""
self.state = GameState.ENEMY_TURN
self._process_enemy_turns()
def _process_enemy_turns(self) -> None:
"""Process all enemy turns."""
# Get combat results from enemy actions
results = process_enemy_turns(
self.enemies,
self.player,
self.dungeon
)
# Report results
for result in results:
self.add_message(result.message, result.message_color)
# Check if player died
if not self.player.is_alive:
self.state = GameState.PLAYER_DEAD
if self.on_player_death:
self.on_player_death()
else:
# End turn
self.turn_count += 1
self.state = GameState.PLAYER_TURN
if self.on_turn_end:
self.on_turn_end(self.turn_count)
def is_player_turn(self) -> bool:
"""Check if it's the player's turn."""
return self.state == GameState.PLAYER_TURN
def is_game_over(self) -> bool:
"""Check if the game is over (player dead)."""
return self.state == GameState.PLAYER_DEAD
def get_enemy_count(self) -> int:
"""Get the number of living enemies."""
return sum(1 for e in self.enemies if e.is_alive)
class ActionResult:
"""Result of a player action."""
def __init__(self, success: bool, message: str = "",
color: tuple = (255, 255, 255, 255)):
self.success = success
self.message = message
self.color = color
def try_move_or_attack(player: Player, dx: int, dy: int,
dungeon: 'Dungeon', enemies: List[Enemy]) -> ActionResult:
"""
Attempt to move or attack in a direction.
This is a simpler, standalone function for games that don't want
the full TurnManager.
Args:
player: The player
dx: X direction
dy: Y direction
dungeon: The dungeon map
enemies: List of enemies
Returns:
ActionResult indicating success and any message
"""
target_x = player.x + dx
target_y = player.y + dy
# Check for attack
for enemy in enemies:
if enemy.is_alive and enemy.x == target_x and enemy.y == target_y:
result = try_attack(player, target_x, target_y, enemies)
if result:
if result.killed:
process_kill(player, enemy)
enemies.remove(enemy)
return ActionResult(True, result.message, result.message_color)
# Check for movement
if dungeon.is_walkable(target_x, target_y):
player.move_to(target_x, target_y)
return ActionResult(True)
return ActionResult(False, "You can't move there!", (150, 150, 150, 255))

330
docs/templates/complete/ui.py vendored Normal file
View file

@ -0,0 +1,330 @@
"""
ui.py - User Interface Components for McRogueFace Roguelike
Contains the health bar and message log UI elements.
"""
from typing import List, Tuple, Optional
from dataclasses import dataclass
import mcrfpy
from constants import (
HP_BAR_X, HP_BAR_Y, HP_BAR_WIDTH, HP_BAR_HEIGHT,
MSG_LOG_X, MSG_LOG_Y, MSG_LOG_WIDTH, MSG_LOG_HEIGHT, MSG_LOG_MAX_LINES,
LEVEL_DISPLAY_X, LEVEL_DISPLAY_Y,
COLOR_UI_BG, COLOR_UI_BORDER, COLOR_TEXT,
COLOR_HP_BAR_BG, COLOR_HP_BAR_FILL, COLOR_HP_BAR_WARNING, COLOR_HP_BAR_CRITICAL,
COLOR_MSG_DEFAULT
)
@dataclass
class Message:
"""A message in the message log."""
text: str
color: Tuple[int, int, int, int]
class HealthBar:
"""
Visual health bar displaying player HP.
Uses nested Frames: an outer background frame and an inner fill frame
that resizes based on HP percentage.
"""
def __init__(self, x: int = HP_BAR_X, y: int = HP_BAR_Y,
width: int = HP_BAR_WIDTH, height: int = HP_BAR_HEIGHT,
font: mcrfpy.Font = None):
"""
Create a health bar.
Args:
x: X position
y: Y position
width: Total width of the bar
height: Height of the bar
font: Font for the HP text
"""
self.x = x
self.y = y
self.width = width
self.height = height
self.font = font or mcrfpy.default_font
# Background frame
self.bg_frame = mcrfpy.Frame(x, y, width, height)
self.bg_frame.fill_color = mcrfpy.Color(*COLOR_HP_BAR_BG)
self.bg_frame.outline = 2
self.bg_frame.outline_color = mcrfpy.Color(*COLOR_UI_BORDER)
# Fill frame (inside background)
self.fill_frame = mcrfpy.Frame(x + 2, y + 2, width - 4, height - 4)
self.fill_frame.fill_color = mcrfpy.Color(*COLOR_HP_BAR_FILL)
self.fill_frame.outline = 0
# HP text
self.hp_text = mcrfpy.Caption("HP: 0 / 0", self.font, x + 8, y + 4)
self.hp_text.fill_color = mcrfpy.Color(*COLOR_TEXT)
self._max_fill_width = width - 4
def add_to_scene(self, ui: mcrfpy.UICollection) -> None:
"""Add all health bar components to a scene."""
ui.append(self.bg_frame)
ui.append(self.fill_frame)
ui.append(self.hp_text)
def update(self, current_hp: int, max_hp: int) -> None:
"""
Update the health bar display.
Args:
current_hp: Current hit points
max_hp: Maximum hit points
"""
# Calculate fill percentage
if max_hp <= 0:
percent = 0.0
else:
percent = max(0.0, min(1.0, current_hp / max_hp))
# Update fill bar width
self.fill_frame.w = int(self._max_fill_width * percent)
# Update color based on HP percentage
if percent > 0.6:
color = COLOR_HP_BAR_FILL
elif percent > 0.3:
color = COLOR_HP_BAR_WARNING
else:
color = COLOR_HP_BAR_CRITICAL
self.fill_frame.fill_color = mcrfpy.Color(*color)
# Update text
self.hp_text.text = f"HP: {current_hp} / {max_hp}"
class MessageLog:
"""
Scrolling message log displaying game events.
Uses a Frame container with Caption children for each line.
"""
def __init__(self, x: int = MSG_LOG_X, y: int = MSG_LOG_Y,
width: int = MSG_LOG_WIDTH, height: int = MSG_LOG_HEIGHT,
max_lines: int = MSG_LOG_MAX_LINES,
font: mcrfpy.Font = None):
"""
Create a message log.
Args:
x: X position
y: Y position
width: Width of the log
height: Height of the log
max_lines: Maximum number of visible lines
font: Font for the messages
"""
self.x = x
self.y = y
self.width = width
self.height = height
self.max_lines = max_lines
self.font = font or mcrfpy.default_font
# Container frame
self.frame = mcrfpy.Frame(x, y, width, height)
self.frame.fill_color = mcrfpy.Color(*COLOR_UI_BG)
self.frame.outline = 1
self.frame.outline_color = mcrfpy.Color(*COLOR_UI_BORDER)
# Message storage
self.messages: List[Message] = []
self.captions: List[mcrfpy.Caption] = []
# Line height (approximate based on font)
self.line_height = 18
# Create caption objects for each line
self._init_captions()
def _init_captions(self) -> None:
"""Initialize caption objects for message display."""
for i in range(self.max_lines):
caption = mcrfpy.Caption(
"",
self.font,
self.x + 5,
self.y + 5 + i * self.line_height
)
caption.fill_color = mcrfpy.Color(*COLOR_MSG_DEFAULT)
self.captions.append(caption)
def add_to_scene(self, ui: mcrfpy.UICollection) -> None:
"""Add the message log to a scene."""
ui.append(self.frame)
for caption in self.captions:
ui.append(caption)
def add_message(self, text: str,
color: Tuple[int, int, int, int] = COLOR_MSG_DEFAULT) -> None:
"""
Add a message to the log.
Args:
text: Message text
color: Text color as (R, G, B, A)
"""
self.messages.append(Message(text, color))
# Trim old messages
if len(self.messages) > 100:
self.messages = self.messages[-100:]
# Update display
self._update_display()
def _update_display(self) -> None:
"""Update the displayed messages."""
# Get the most recent messages
recent = self.messages[-self.max_lines:]
for i, caption in enumerate(self.captions):
if i < len(recent):
msg = recent[i]
caption.text = msg.text
caption.fill_color = mcrfpy.Color(*msg.color)
else:
caption.text = ""
def clear(self) -> None:
"""Clear all messages."""
self.messages.clear()
self._update_display()
class LevelDisplay:
"""Simple display showing current dungeon level."""
def __init__(self, x: int = LEVEL_DISPLAY_X, y: int = LEVEL_DISPLAY_Y,
font: mcrfpy.Font = None):
"""
Create a level display.
Args:
x: X position
y: Y position
font: Font for the text
"""
self.font = font or mcrfpy.default_font
self.caption = mcrfpy.Caption("Level: 1", self.font, x, y)
self.caption.fill_color = mcrfpy.Color(*COLOR_TEXT)
def add_to_scene(self, ui: mcrfpy.UICollection) -> None:
"""Add to a scene."""
ui.append(self.caption)
def update(self, level: int) -> None:
"""Update the displayed level."""
self.caption.text = f"Dungeon Level: {level}"
class GameUI:
"""
Container for all UI elements.
Provides a single point of access for updating the entire UI.
"""
def __init__(self, font: mcrfpy.Font = None):
"""
Create the game UI.
Args:
font: Font for all UI elements
"""
self.font = font or mcrfpy.default_font
# Create UI components
self.health_bar = HealthBar(font=self.font)
self.message_log = MessageLog(font=self.font)
self.level_display = LevelDisplay(font=self.font)
def add_to_scene(self, ui: mcrfpy.UICollection) -> None:
"""Add all UI elements to a scene."""
self.health_bar.add_to_scene(ui)
self.message_log.add_to_scene(ui)
self.level_display.add_to_scene(ui)
def update_hp(self, current_hp: int, max_hp: int) -> None:
"""Update the health bar."""
self.health_bar.update(current_hp, max_hp)
def add_message(self, text: str,
color: Tuple[int, int, int, int] = COLOR_MSG_DEFAULT) -> None:
"""Add a message to the log."""
self.message_log.add_message(text, color)
def update_level(self, level: int) -> None:
"""Update the dungeon level display."""
self.level_display.update(level)
def clear_messages(self) -> None:
"""Clear the message log."""
self.message_log.clear()
class DeathScreen:
"""Game over screen shown when player dies."""
def __init__(self, font: mcrfpy.Font = None):
"""
Create the death screen.
Args:
font: Font for text
"""
self.font = font or mcrfpy.default_font
self.elements: List = []
# Semi-transparent overlay
self.overlay = mcrfpy.Frame(0, 0, 1024, 768)
self.overlay.fill_color = mcrfpy.Color(0, 0, 0, 180)
self.elements.append(self.overlay)
# Death message
self.death_text = mcrfpy.Caption(
"YOU HAVE DIED",
self.font,
362, 300
)
self.death_text.fill_color = mcrfpy.Color(255, 0, 0, 255)
self.death_text.outline = 2
self.death_text.outline_color = mcrfpy.Color(0, 0, 0, 255)
self.elements.append(self.death_text)
# Restart prompt
self.restart_text = mcrfpy.Caption(
"Press R to restart",
self.font,
400, 400
)
self.restart_text.fill_color = mcrfpy.Color(200, 200, 200, 255)
self.elements.append(self.restart_text)
def add_to_scene(self, ui: mcrfpy.UICollection) -> None:
"""Add death screen elements to a scene."""
for element in self.elements:
ui.append(element)
def remove_from_scene(self, ui: mcrfpy.UICollection) -> None:
"""Remove death screen elements from a scene."""
for element in self.elements:
try:
ui.remove(element)
except (ValueError, RuntimeError):
pass