draft tutorial revisions
This commit is contained in:
parent
838da4571d
commit
48359b5a48
70 changed files with 6216 additions and 28 deletions
289
docs/templates/complete/ai.py
vendored
Normal file
289
docs/templates/complete/ai.py
vendored
Normal 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
187
docs/templates/complete/combat.py
vendored
Normal 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
210
docs/templates/complete/constants.py
vendored
Normal 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
298
docs/templates/complete/dungeon.py
vendored
Normal 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
319
docs/templates/complete/entities.py
vendored
Normal 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
313
docs/templates/complete/game.py
vendored
Normal 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
232
docs/templates/complete/turns.py
vendored
Normal 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
330
docs/templates/complete/ui.py
vendored
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue