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
|
||||
176
docs/templates/minimal/game.py
vendored
Normal file
176
docs/templates/minimal/game.py
vendored
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""
|
||||
McRogueFace Minimal Template
|
||||
============================
|
||||
|
||||
A starting point for simple roguelike prototypes.
|
||||
|
||||
This template demonstrates:
|
||||
- Scene object pattern (preferred OOP approach)
|
||||
- Grid-based movement with boundary checking
|
||||
- Keyboard input handling
|
||||
- Entity positioning on a grid
|
||||
|
||||
Usage:
|
||||
Place this file in your McRogueFace scripts directory and run McRogueFace.
|
||||
Use arrow keys to move the @ symbol. Press Escape to exit.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
|
||||
# =============================================================================
|
||||
# CONSTANTS
|
||||
# =============================================================================
|
||||
|
||||
# Grid dimensions (in tiles)
|
||||
GRID_WIDTH: int = 20
|
||||
GRID_HEIGHT: int = 15
|
||||
|
||||
# Tile size in pixels (must match your sprite sheet)
|
||||
TILE_SIZE: int = 16
|
||||
|
||||
# CP437 sprite indices (standard roguelike character mapping)
|
||||
# In CP437, character codes map to sprite indices: '@' = 64, '.' = 46, etc.
|
||||
SPRITE_PLAYER: int = 64 # '@' symbol
|
||||
SPRITE_FLOOR: int = 46 # '.' symbol
|
||||
|
||||
# Colors (RGBA tuples)
|
||||
COLOR_BACKGROUND: tuple[int, int, int] = (20, 20, 30)
|
||||
|
||||
# =============================================================================
|
||||
# GAME STATE
|
||||
# =============================================================================
|
||||
|
||||
# Player position in grid coordinates
|
||||
player_x: int = GRID_WIDTH // 2
|
||||
player_y: int = GRID_HEIGHT // 2
|
||||
|
||||
# Reference to player entity (set during setup)
|
||||
player_entity: mcrfpy.Entity = None
|
||||
|
||||
# =============================================================================
|
||||
# MOVEMENT LOGIC
|
||||
# =============================================================================
|
||||
|
||||
def try_move(dx: int, dy: int) -> bool:
|
||||
"""
|
||||
Attempt to move the player by (dx, dy) tiles.
|
||||
|
||||
Args:
|
||||
dx: Horizontal movement (-1 = left, +1 = right, 0 = none)
|
||||
dy: Vertical movement (-1 = up, +1 = down, 0 = none)
|
||||
|
||||
Returns:
|
||||
True if movement succeeded, False if blocked by boundary
|
||||
"""
|
||||
global player_x, player_y
|
||||
|
||||
new_x = player_x + dx
|
||||
new_y = player_y + dy
|
||||
|
||||
# Boundary checking: ensure player stays within grid
|
||||
if 0 <= new_x < GRID_WIDTH and 0 <= new_y < GRID_HEIGHT:
|
||||
player_x = new_x
|
||||
player_y = new_y
|
||||
|
||||
# Update the entity's position on the grid
|
||||
player_entity.x = player_x
|
||||
player_entity.y = player_y
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# =============================================================================
|
||||
# INPUT HANDLING
|
||||
# =============================================================================
|
||||
|
||||
def handle_keypress(key: str, action: str) -> None:
|
||||
"""
|
||||
Handle keyboard input for the game scene.
|
||||
|
||||
Args:
|
||||
key: The key that was pressed (e.g., "Up", "Down", "Escape", "a", "W")
|
||||
action: Either "start" (key pressed) or "end" (key released)
|
||||
|
||||
Note:
|
||||
We only process on "start" to avoid double-triggering on key release.
|
||||
"""
|
||||
if action != "start":
|
||||
return
|
||||
|
||||
# Movement keys (both arrow keys and WASD)
|
||||
if key == "Up" or key == "W" or key == "w":
|
||||
try_move(0, -1)
|
||||
elif key == "Down" or key == "S" or key == "s":
|
||||
try_move(0, 1)
|
||||
elif key == "Left" or key == "A" or key == "a":
|
||||
try_move(-1, 0)
|
||||
elif key == "Right" or key == "D" or key == "d":
|
||||
try_move(1, 0)
|
||||
|
||||
# Exit on Escape
|
||||
elif key == "Escape":
|
||||
mcrfpy.exit()
|
||||
|
||||
# =============================================================================
|
||||
# SCENE SETUP
|
||||
# =============================================================================
|
||||
|
||||
def setup_game() -> mcrfpy.Scene:
|
||||
"""
|
||||
Create and configure the game scene.
|
||||
|
||||
Returns:
|
||||
The configured Scene object, ready to be activated.
|
||||
"""
|
||||
global player_entity
|
||||
|
||||
# Create the scene using the OOP pattern (preferred over createScene)
|
||||
scene = mcrfpy.Scene("game")
|
||||
|
||||
# Load the sprite sheet texture
|
||||
# Adjust the path and tile size to match your assets
|
||||
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", TILE_SIZE, TILE_SIZE)
|
||||
|
||||
# Create the game grid
|
||||
# Grid(pos, size, grid_size) where:
|
||||
# pos = pixel position on screen
|
||||
# size = pixel dimensions of the grid display
|
||||
# grid_size = number of tiles (columns, rows)
|
||||
grid = mcrfpy.Grid(
|
||||
pos=(32, 32),
|
||||
grid_size=(GRID_WIDTH, GRID_HEIGHT),
|
||||
texture=texture
|
||||
)
|
||||
grid.fill_color = mcrfpy.Color(*COLOR_BACKGROUND)
|
||||
|
||||
# Fill the grid with floor tiles
|
||||
for x in range(GRID_WIDTH):
|
||||
for y in range(GRID_HEIGHT):
|
||||
point = grid.at(x, y)
|
||||
point.tilesprite = SPRITE_FLOOR
|
||||
point.walkable = True
|
||||
point.transparent = True
|
||||
|
||||
# Create the player entity
|
||||
player_entity = mcrfpy.Entity(
|
||||
pos=(player_x, player_y),
|
||||
texture=texture,
|
||||
sprite_index=SPRITE_PLAYER
|
||||
)
|
||||
grid.entities.append(player_entity)
|
||||
|
||||
# Add the grid to the scene's UI
|
||||
scene.children.append(grid)
|
||||
|
||||
# Set up keyboard input handler for this scene
|
||||
scene.on_key = handle_keypress
|
||||
|
||||
return scene
|
||||
|
||||
# =============================================================================
|
||||
# MAIN ENTRY POINT
|
||||
# =============================================================================
|
||||
|
||||
# Create and activate the game scene
|
||||
game_scene = setup_game()
|
||||
game_scene.activate()
|
||||
138
docs/templates/roguelike/constants.py
vendored
Normal file
138
docs/templates/roguelike/constants.py
vendored
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""
|
||||
constants.py - Roguelike Template Constants
|
||||
|
||||
This module defines all the constants used throughout the roguelike template,
|
||||
including sprite indices for CP437 tileset, colors for FOV system, and
|
||||
game configuration values.
|
||||
|
||||
CP437 is the classic IBM PC character set commonly used in traditional roguelikes.
|
||||
The sprite indices correspond to ASCII character codes in a CP437 tileset.
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
|
||||
# =============================================================================
|
||||
# SPRITE INDICES (CP437 Character Codes)
|
||||
# =============================================================================
|
||||
# These indices correspond to characters in a CP437-style tileset.
|
||||
# The default McRogueFace tileset uses 16x16 sprites arranged in a grid.
|
||||
|
||||
# Terrain sprites
|
||||
SPRITE_FLOOR = 46 # '.' - Standard floor tile
|
||||
SPRITE_WALL = 35 # '#' - Wall/obstacle tile
|
||||
SPRITE_DOOR_CLOSED = 43 # '+' - Closed door
|
||||
SPRITE_DOOR_OPEN = 47 # '/' - Open door
|
||||
SPRITE_STAIRS_DOWN = 62 # '>' - Stairs going down
|
||||
SPRITE_STAIRS_UP = 60 # '<' - Stairs going up
|
||||
|
||||
# Player sprite
|
||||
SPRITE_PLAYER = 64 # '@' - The classic roguelike player symbol
|
||||
|
||||
# Enemy sprites
|
||||
SPRITE_ORC = 111 # 'o' - Orc enemy
|
||||
SPRITE_TROLL = 84 # 'T' - Troll enemy
|
||||
SPRITE_GOBLIN = 103 # 'g' - Goblin enemy
|
||||
SPRITE_RAT = 114 # 'r' - Giant rat
|
||||
SPRITE_SNAKE = 115 # 's' - Snake
|
||||
SPRITE_ZOMBIE = 90 # 'Z' - Zombie
|
||||
|
||||
# Item sprites
|
||||
SPRITE_POTION = 33 # '!' - Potion
|
||||
SPRITE_SCROLL = 63 # '?' - Scroll
|
||||
SPRITE_GOLD = 36 # '$' - Gold/treasure
|
||||
SPRITE_WEAPON = 41 # ')' - Weapon
|
||||
SPRITE_ARMOR = 91 # '[' - Armor
|
||||
SPRITE_RING = 61 # '=' - Ring
|
||||
|
||||
# =============================================================================
|
||||
# FOV/VISIBILITY COLORS
|
||||
# =============================================================================
|
||||
# These colors are applied as overlays to grid tiles to create the fog of war
|
||||
# effect. The alpha channel determines how much of the original tile shows through.
|
||||
|
||||
# Fully visible - no overlay (alpha = 0 means completely transparent overlay)
|
||||
COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0)
|
||||
|
||||
# Previously explored but not currently visible - dim blue-gray overlay
|
||||
# This creates the "memory" effect where you can see the map layout
|
||||
# but not current enemy positions
|
||||
COLOR_EXPLORED = mcrfpy.Color(50, 50, 80, 180)
|
||||
|
||||
# Never seen - completely black (alpha = 255 means fully opaque)
|
||||
COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255)
|
||||
|
||||
# =============================================================================
|
||||
# TILE COLORS
|
||||
# =============================================================================
|
||||
# Base colors for different tile types (applied to the tile's color property)
|
||||
|
||||
COLOR_FLOOR = mcrfpy.Color(50, 50, 50) # Dark gray floor
|
||||
COLOR_WALL = mcrfpy.Color(100, 100, 100) # Lighter gray walls
|
||||
COLOR_FLOOR_LIT = mcrfpy.Color(100, 90, 70) # Warm lit floor
|
||||
COLOR_WALL_LIT = mcrfpy.Color(130, 110, 80) # Warm lit walls
|
||||
|
||||
# =============================================================================
|
||||
# ENTITY COLORS
|
||||
# =============================================================================
|
||||
# Colors applied to entity sprites
|
||||
|
||||
COLOR_PLAYER = mcrfpy.Color(255, 255, 255) # White player
|
||||
COLOR_ORC = mcrfpy.Color(63, 127, 63) # Green orc
|
||||
COLOR_TROLL = mcrfpy.Color(0, 127, 0) # Darker green troll
|
||||
COLOR_GOBLIN = mcrfpy.Color(127, 127, 0) # Yellow-green goblin
|
||||
|
||||
# =============================================================================
|
||||
# GAME CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# Map dimensions (in tiles)
|
||||
MAP_WIDTH = 80
|
||||
MAP_HEIGHT = 45
|
||||
|
||||
# Room generation parameters
|
||||
ROOM_MIN_SIZE = 6 # Minimum room dimension
|
||||
ROOM_MAX_SIZE = 12 # Maximum room dimension
|
||||
MAX_ROOMS = 30 # Maximum number of rooms to generate
|
||||
|
||||
# FOV settings
|
||||
FOV_RADIUS = 8 # How far the player can see
|
||||
|
||||
# Display settings
|
||||
GRID_PIXEL_WIDTH = 1024 # Grid display width in pixels
|
||||
GRID_PIXEL_HEIGHT = 768 # Grid display height in pixels
|
||||
|
||||
# Sprite size (should match your tileset)
|
||||
SPRITE_WIDTH = 16
|
||||
SPRITE_HEIGHT = 16
|
||||
|
||||
# =============================================================================
|
||||
# ENEMY DEFINITIONS
|
||||
# =============================================================================
|
||||
# Dictionary of enemy types with their properties for easy spawning
|
||||
|
||||
ENEMY_TYPES = {
|
||||
"orc": {
|
||||
"sprite": SPRITE_ORC,
|
||||
"color": COLOR_ORC,
|
||||
"name": "Orc",
|
||||
"hp": 10,
|
||||
"power": 3,
|
||||
"defense": 0,
|
||||
},
|
||||
"troll": {
|
||||
"sprite": SPRITE_TROLL,
|
||||
"color": COLOR_TROLL,
|
||||
"name": "Troll",
|
||||
"hp": 16,
|
||||
"power": 4,
|
||||
"defense": 1,
|
||||
},
|
||||
"goblin": {
|
||||
"sprite": SPRITE_GOBLIN,
|
||||
"color": COLOR_GOBLIN,
|
||||
"name": "Goblin",
|
||||
"hp": 6,
|
||||
"power": 2,
|
||||
"defense": 0,
|
||||
},
|
||||
}
|
||||
340
docs/templates/roguelike/dungeon.py
vendored
Normal file
340
docs/templates/roguelike/dungeon.py
vendored
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
"""
|
||||
dungeon.py - Procedural Dungeon Generation
|
||||
|
||||
This module provides classic roguelike dungeon generation using the
|
||||
"rooms and corridors" algorithm:
|
||||
|
||||
1. Generate random non-overlapping rectangular rooms
|
||||
2. Connect rooms with L-shaped corridors
|
||||
3. Mark tiles as walkable/transparent based on terrain type
|
||||
|
||||
The algorithm is simple but effective, producing dungeons similar to
|
||||
the original Rogue game.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import random
|
||||
from typing import Iterator, Tuple, List, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import mcrfpy
|
||||
|
||||
from constants import (
|
||||
MAP_WIDTH, MAP_HEIGHT,
|
||||
ROOM_MIN_SIZE, ROOM_MAX_SIZE, MAX_ROOMS,
|
||||
SPRITE_FLOOR, SPRITE_WALL,
|
||||
COLOR_FLOOR, COLOR_WALL,
|
||||
)
|
||||
|
||||
|
||||
class RectangularRoom:
|
||||
"""
|
||||
A rectangular room in the dungeon.
|
||||
|
||||
This class represents a single room and provides utilities for
|
||||
working with room geometry. Rooms are defined by their top-left
|
||||
corner (x1, y1) and bottom-right corner (x2, y2).
|
||||
|
||||
Attributes:
|
||||
x1, y1: Top-left corner coordinates
|
||||
x2, y2: Bottom-right corner coordinates
|
||||
"""
|
||||
|
||||
def __init__(self, x: int, y: int, width: int, height: int) -> None:
|
||||
"""
|
||||
Create a new rectangular room.
|
||||
|
||||
Args:
|
||||
x: X coordinate of the top-left corner
|
||||
y: Y coordinate of the top-left corner
|
||||
width: Width of the room in tiles
|
||||
height: Height of the room in tiles
|
||||
"""
|
||||
self.x1 = x
|
||||
self.y1 = y
|
||||
self.x2 = x + width
|
||||
self.y2 = y + height
|
||||
|
||||
@property
|
||||
def center(self) -> Tuple[int, int]:
|
||||
"""
|
||||
Return the center coordinates of the room.
|
||||
|
||||
This is useful for connecting rooms with corridors and
|
||||
for placing the player in the starting room.
|
||||
|
||||
Returns:
|
||||
Tuple of (center_x, center_y)
|
||||
"""
|
||||
center_x = (self.x1 + self.x2) // 2
|
||||
center_y = (self.y1 + self.y2) // 2
|
||||
return center_x, center_y
|
||||
|
||||
@property
|
||||
def inner(self) -> Tuple[slice, slice]:
|
||||
"""
|
||||
Return the inner area of the room as a pair of slices.
|
||||
|
||||
The inner area excludes the walls (1 tile border), giving
|
||||
the floor area where entities can be placed.
|
||||
|
||||
Returns:
|
||||
Tuple of (x_slice, y_slice) for array indexing
|
||||
"""
|
||||
# Add 1 to exclude the walls on all sides
|
||||
return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2)
|
||||
|
||||
def intersects(self, other: RectangularRoom) -> bool:
|
||||
"""
|
||||
Check if this room overlaps with another room.
|
||||
|
||||
Used during generation to ensure rooms don't overlap.
|
||||
|
||||
Args:
|
||||
other: Another RectangularRoom to check against
|
||||
|
||||
Returns:
|
||||
True if the rooms overlap, False otherwise
|
||||
"""
|
||||
return (
|
||||
self.x1 <= other.x2
|
||||
and self.x2 >= other.x1
|
||||
and self.y1 <= other.y2
|
||||
and self.y2 >= other.y1
|
||||
)
|
||||
|
||||
def inner_tiles(self) -> Iterator[Tuple[int, int]]:
|
||||
"""
|
||||
Iterate over all floor tile coordinates in the room.
|
||||
|
||||
Yields coordinates for the interior of the room (excluding walls).
|
||||
|
||||
Yields:
|
||||
Tuples of (x, y) coordinates
|
||||
"""
|
||||
for x in range(self.x1 + 1, self.x2):
|
||||
for y in range(self.y1 + 1, self.y2):
|
||||
yield x, y
|
||||
|
||||
|
||||
def tunnel_between(
|
||||
start: Tuple[int, int],
|
||||
end: Tuple[int, int]
|
||||
) -> Iterator[Tuple[int, int]]:
|
||||
"""
|
||||
Generate an L-shaped tunnel between two points.
|
||||
|
||||
The tunnel goes horizontally first, then vertically (or vice versa,
|
||||
chosen randomly). This creates the classic roguelike corridor style.
|
||||
|
||||
Args:
|
||||
start: Starting (x, y) coordinates
|
||||
end: Ending (x, y) coordinates
|
||||
|
||||
Yields:
|
||||
Tuples of (x, y) coordinates for each tile in the tunnel
|
||||
"""
|
||||
x1, y1 = start
|
||||
x2, y2 = end
|
||||
|
||||
# Randomly choose whether to go horizontal-first or vertical-first
|
||||
if random.random() < 0.5:
|
||||
# Horizontal first, then vertical
|
||||
corner_x, corner_y = x2, y1
|
||||
else:
|
||||
# Vertical first, then horizontal
|
||||
corner_x, corner_y = x1, y2
|
||||
|
||||
# Generate the horizontal segment
|
||||
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
|
||||
yield x, y1
|
||||
|
||||
# Generate the vertical segment
|
||||
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
|
||||
yield corner_x, y
|
||||
|
||||
# Generate to the endpoint (if needed)
|
||||
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
|
||||
yield x, corner_y
|
||||
|
||||
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
|
||||
yield x2, y
|
||||
|
||||
|
||||
def generate_dungeon(
|
||||
max_rooms: int = MAX_ROOMS,
|
||||
room_min_size: int = ROOM_MIN_SIZE,
|
||||
room_max_size: int = ROOM_MAX_SIZE,
|
||||
map_width: int = MAP_WIDTH,
|
||||
map_height: int = MAP_HEIGHT,
|
||||
) -> List[RectangularRoom]:
|
||||
"""
|
||||
Generate a dungeon using the rooms-and-corridors algorithm.
|
||||
|
||||
This function creates a list of non-overlapping rooms. The actual
|
||||
tile data should be applied to a Grid using populate_grid().
|
||||
|
||||
Algorithm:
|
||||
1. Try to place MAX_ROOMS rooms randomly
|
||||
2. Reject rooms that overlap existing rooms
|
||||
3. Connect each new room to the previous room with a corridor
|
||||
|
||||
Args:
|
||||
max_rooms: Maximum number of rooms to generate
|
||||
room_min_size: Minimum room dimension
|
||||
room_max_size: Maximum room dimension
|
||||
map_width: Width of the dungeon in tiles
|
||||
map_height: Height of the dungeon in tiles
|
||||
|
||||
Returns:
|
||||
List of RectangularRoom objects representing the dungeon layout
|
||||
"""
|
||||
rooms: List[RectangularRoom] = []
|
||||
|
||||
for _ in range(max_rooms):
|
||||
# Random room dimensions
|
||||
room_width = random.randint(room_min_size, room_max_size)
|
||||
room_height = random.randint(room_min_size, room_max_size)
|
||||
|
||||
# Random position (ensuring room fits within map bounds)
|
||||
x = random.randint(0, map_width - room_width - 1)
|
||||
y = random.randint(0, map_height - room_height - 1)
|
||||
|
||||
new_room = RectangularRoom(x, y, room_width, room_height)
|
||||
|
||||
# Check if this room overlaps with any existing room
|
||||
if any(new_room.intersects(other) for other in rooms):
|
||||
continue # Skip this room, try again
|
||||
|
||||
# Room is valid, add it
|
||||
rooms.append(new_room)
|
||||
|
||||
return rooms
|
||||
|
||||
|
||||
def populate_grid(grid: mcrfpy.Grid, rooms: List[RectangularRoom]) -> None:
|
||||
"""
|
||||
Apply dungeon layout to a McRogueFace Grid.
|
||||
|
||||
This function:
|
||||
1. Fills the entire grid with walls
|
||||
2. Carves out floor tiles for each room
|
||||
3. Carves corridors connecting adjacent rooms
|
||||
4. Sets walkable/transparent flags appropriately
|
||||
|
||||
Args:
|
||||
grid: The McRogueFace Grid to populate
|
||||
rooms: List of RectangularRoom objects from generate_dungeon()
|
||||
"""
|
||||
grid_width, grid_height = grid.grid_size
|
||||
|
||||
# Step 1: Fill entire map with walls
|
||||
for x in range(grid_width):
|
||||
for y in range(grid_height):
|
||||
point = grid.at(x, y)
|
||||
point.tilesprite = SPRITE_WALL
|
||||
point.walkable = False
|
||||
point.transparent = False
|
||||
point.color = COLOR_WALL
|
||||
|
||||
# Step 2: Carve out rooms
|
||||
for room in rooms:
|
||||
for x, y in room.inner_tiles():
|
||||
# Bounds check (room might extend past grid)
|
||||
if 0 <= x < grid_width and 0 <= y < grid_height:
|
||||
point = grid.at(x, y)
|
||||
point.tilesprite = SPRITE_FLOOR
|
||||
point.walkable = True
|
||||
point.transparent = True
|
||||
point.color = COLOR_FLOOR
|
||||
|
||||
# Step 3: Carve corridors between adjacent rooms
|
||||
for i in range(1, len(rooms)):
|
||||
# Connect each room to the previous room
|
||||
start = rooms[i - 1].center
|
||||
end = rooms[i].center
|
||||
|
||||
for x, y in tunnel_between(start, end):
|
||||
if 0 <= x < grid_width and 0 <= y < grid_height:
|
||||
point = grid.at(x, y)
|
||||
point.tilesprite = SPRITE_FLOOR
|
||||
point.walkable = True
|
||||
point.transparent = True
|
||||
point.color = COLOR_FLOOR
|
||||
|
||||
|
||||
def get_random_floor_position(
|
||||
grid: mcrfpy.Grid,
|
||||
rooms: List[RectangularRoom],
|
||||
exclude_first_room: bool = False
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
Get a random walkable floor position for entity placement.
|
||||
|
||||
This is useful for placing enemies, items, or other entities
|
||||
in valid floor locations.
|
||||
|
||||
Args:
|
||||
grid: The populated Grid to search
|
||||
rooms: List of rooms (used for faster random selection)
|
||||
exclude_first_room: If True, won't return positions from the
|
||||
first room (where the player usually starts)
|
||||
|
||||
Returns:
|
||||
Tuple of (x, y) coordinates of a walkable floor tile
|
||||
"""
|
||||
available_rooms = rooms[1:] if exclude_first_room and len(rooms) > 1 else rooms
|
||||
|
||||
if not available_rooms:
|
||||
# Fallback: find any walkable tile
|
||||
grid_width, grid_height = grid.grid_size
|
||||
walkable_tiles = []
|
||||
for x in range(grid_width):
|
||||
for y in range(grid_height):
|
||||
if grid.at(x, y).walkable:
|
||||
walkable_tiles.append((x, y))
|
||||
return random.choice(walkable_tiles) if walkable_tiles else (1, 1)
|
||||
|
||||
# Pick a random room and a random position within it
|
||||
room = random.choice(available_rooms)
|
||||
floor_tiles = list(room.inner_tiles())
|
||||
return random.choice(floor_tiles)
|
||||
|
||||
|
||||
def get_spawn_positions(
|
||||
rooms: List[RectangularRoom],
|
||||
count: int,
|
||||
exclude_first_room: bool = True
|
||||
) -> List[Tuple[int, int]]:
|
||||
"""
|
||||
Get multiple spawn positions for enemies.
|
||||
|
||||
Distributes enemies across different rooms for better gameplay.
|
||||
|
||||
Args:
|
||||
rooms: List of rooms from dungeon generation
|
||||
count: Number of positions to generate
|
||||
exclude_first_room: If True, won't spawn in the player's starting room
|
||||
|
||||
Returns:
|
||||
List of (x, y) coordinate tuples
|
||||
"""
|
||||
available_rooms = rooms[1:] if exclude_first_room and len(rooms) > 1 else rooms
|
||||
|
||||
if not available_rooms:
|
||||
return []
|
||||
|
||||
positions = []
|
||||
for i in range(count):
|
||||
# Cycle through rooms to distribute enemies
|
||||
room = available_rooms[i % len(available_rooms)]
|
||||
floor_tiles = list(room.inner_tiles())
|
||||
|
||||
# Try to avoid placing on the same tile
|
||||
available_tiles = [t for t in floor_tiles if t not in positions]
|
||||
if available_tiles:
|
||||
positions.append(random.choice(available_tiles))
|
||||
elif floor_tiles:
|
||||
positions.append(random.choice(floor_tiles))
|
||||
|
||||
return positions
|
||||
364
docs/templates/roguelike/entities.py
vendored
Normal file
364
docs/templates/roguelike/entities.py
vendored
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
"""
|
||||
entities.py - Entity Management for Roguelike Template
|
||||
|
||||
This module provides entity creation and management utilities for the
|
||||
roguelike template. Entities in McRogueFace are game objects that exist
|
||||
on a Grid, such as the player, enemies, items, and NPCs.
|
||||
|
||||
The module includes:
|
||||
- Entity factory functions for creating common entity types
|
||||
- Helper functions for entity management
|
||||
- Simple data containers for entity stats (for future expansion)
|
||||
|
||||
Note: McRogueFace entities are simple position + sprite objects. For
|
||||
complex game logic like AI, combat, and inventory, you'll want to wrap
|
||||
them in Python classes that reference the underlying Entity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Tuple, Optional, List, Dict, Any, TYPE_CHECKING
|
||||
from dataclasses import dataclass
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import mcrfpy
|
||||
|
||||
from constants import (
|
||||
SPRITE_PLAYER, SPRITE_ORC, SPRITE_TROLL, SPRITE_GOBLIN,
|
||||
COLOR_PLAYER, COLOR_ORC, COLOR_TROLL, COLOR_GOBLIN,
|
||||
ENEMY_TYPES,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntityStats:
|
||||
"""
|
||||
Optional stats container for game entities.
|
||||
|
||||
This dataclass can be used to track stats for entities that need them.
|
||||
Attach it to your entity wrapper class for combat, leveling, etc.
|
||||
|
||||
Attributes:
|
||||
hp: Current hit points
|
||||
max_hp: Maximum hit points
|
||||
power: Attack power
|
||||
defense: Damage reduction
|
||||
name: Display name for the entity
|
||||
"""
|
||||
hp: int = 10
|
||||
max_hp: int = 10
|
||||
power: int = 3
|
||||
defense: int = 0
|
||||
name: str = "Unknown"
|
||||
|
||||
@property
|
||||
def is_alive(self) -> bool:
|
||||
"""Check if the entity is still alive."""
|
||||
return self.hp > 0
|
||||
|
||||
def take_damage(self, amount: int) -> int:
|
||||
"""
|
||||
Apply damage, accounting for defense.
|
||||
|
||||
Args:
|
||||
amount: Raw damage amount
|
||||
|
||||
Returns:
|
||||
Actual damage dealt after defense
|
||||
"""
|
||||
actual_damage = max(0, amount - self.defense)
|
||||
self.hp = max(0, self.hp - actual_damage)
|
||||
return actual_damage
|
||||
|
||||
def heal(self, amount: int) -> int:
|
||||
"""
|
||||
Heal the entity.
|
||||
|
||||
Args:
|
||||
amount: Amount to heal
|
||||
|
||||
Returns:
|
||||
Actual amount healed (may be less if near max HP)
|
||||
"""
|
||||
old_hp = self.hp
|
||||
self.hp = min(self.max_hp, self.hp + amount)
|
||||
return self.hp - old_hp
|
||||
|
||||
|
||||
def create_player(
|
||||
grid: mcrfpy.Grid,
|
||||
texture: mcrfpy.Texture,
|
||||
x: int,
|
||||
y: int
|
||||
) -> mcrfpy.Entity:
|
||||
"""
|
||||
Create and place the player entity on the grid.
|
||||
|
||||
The player uses the classic '@' symbol (sprite index 64 in CP437).
|
||||
|
||||
Args:
|
||||
grid: The Grid to place the player on
|
||||
texture: The texture/tileset to use
|
||||
x: Starting X position
|
||||
y: Starting Y position
|
||||
|
||||
Returns:
|
||||
The created player Entity
|
||||
"""
|
||||
import mcrfpy
|
||||
|
||||
player = mcrfpy.Entity(
|
||||
pos=(x, y),
|
||||
texture=texture,
|
||||
sprite_index=SPRITE_PLAYER
|
||||
)
|
||||
grid.entities.append(player)
|
||||
|
||||
return player
|
||||
|
||||
|
||||
def create_enemy(
|
||||
grid: mcrfpy.Grid,
|
||||
texture: mcrfpy.Texture,
|
||||
x: int,
|
||||
y: int,
|
||||
enemy_type: str = "orc"
|
||||
) -> Tuple[mcrfpy.Entity, EntityStats]:
|
||||
"""
|
||||
Create an enemy entity with associated stats.
|
||||
|
||||
Enemy types are defined in constants.py. Currently available:
|
||||
- "orc": Standard enemy, balanced stats
|
||||
- "troll": Tough enemy, high HP and power
|
||||
- "goblin": Weak enemy, low stats
|
||||
|
||||
Args:
|
||||
grid: The Grid to place the enemy on
|
||||
texture: The texture/tileset to use
|
||||
x: X position
|
||||
y: Y position
|
||||
enemy_type: Key from ENEMY_TYPES dict
|
||||
|
||||
Returns:
|
||||
Tuple of (Entity, EntityStats) for the created enemy
|
||||
"""
|
||||
import mcrfpy
|
||||
|
||||
# Get enemy definition, default to orc if not found
|
||||
enemy_def = ENEMY_TYPES.get(enemy_type, ENEMY_TYPES["orc"])
|
||||
|
||||
entity = mcrfpy.Entity(
|
||||
pos=(x, y),
|
||||
texture=texture,
|
||||
sprite_index=enemy_def["sprite"]
|
||||
)
|
||||
grid.entities.append(entity)
|
||||
|
||||
stats = EntityStats(
|
||||
hp=enemy_def["hp"],
|
||||
max_hp=enemy_def["hp"],
|
||||
power=enemy_def["power"],
|
||||
defense=enemy_def["defense"],
|
||||
name=enemy_def["name"]
|
||||
)
|
||||
|
||||
return entity, stats
|
||||
|
||||
|
||||
def create_enemies_in_rooms(
|
||||
grid: mcrfpy.Grid,
|
||||
texture: mcrfpy.Texture,
|
||||
rooms: list,
|
||||
enemies_per_room: int = 2,
|
||||
skip_first_room: bool = True
|
||||
) -> List[Tuple[mcrfpy.Entity, EntityStats]]:
|
||||
"""
|
||||
Populate dungeon rooms with enemies.
|
||||
|
||||
This helper function places random enemies throughout the dungeon,
|
||||
typically skipping the first room (where the player starts).
|
||||
|
||||
Args:
|
||||
grid: The Grid to populate
|
||||
texture: The texture/tileset to use
|
||||
rooms: List of RectangularRoom objects from dungeon generation
|
||||
enemies_per_room: Maximum enemies to spawn per room
|
||||
skip_first_room: If True, don't spawn enemies in the first room
|
||||
|
||||
Returns:
|
||||
List of (Entity, EntityStats) tuples for all created enemies
|
||||
"""
|
||||
import random
|
||||
|
||||
enemies = []
|
||||
enemy_type_keys = list(ENEMY_TYPES.keys())
|
||||
|
||||
# Iterate through rooms, optionally skipping the first
|
||||
rooms_to_populate = rooms[1:] if skip_first_room else rooms
|
||||
|
||||
for room in rooms_to_populate:
|
||||
# Random number of enemies (0 to enemies_per_room)
|
||||
num_enemies = random.randint(0, enemies_per_room)
|
||||
|
||||
# Get available floor tiles in this room
|
||||
floor_tiles = list(room.inner_tiles())
|
||||
|
||||
for _ in range(num_enemies):
|
||||
if not floor_tiles:
|
||||
break
|
||||
|
||||
# Pick a random position and remove it from available
|
||||
pos = random.choice(floor_tiles)
|
||||
floor_tiles.remove(pos)
|
||||
|
||||
# Pick a random enemy type (weighted toward weaker enemies)
|
||||
if random.random() < 0.8:
|
||||
enemy_type = "orc" # 80% orcs
|
||||
else:
|
||||
enemy_type = "troll" # 20% trolls
|
||||
|
||||
x, y = pos
|
||||
entity, stats = create_enemy(grid, texture, x, y, enemy_type)
|
||||
enemies.append((entity, stats))
|
||||
|
||||
return enemies
|
||||
|
||||
|
||||
def get_blocking_entity_at(
|
||||
entities: List[mcrfpy.Entity],
|
||||
x: int,
|
||||
y: int
|
||||
) -> Optional[mcrfpy.Entity]:
|
||||
"""
|
||||
Check if there's a blocking entity at the given position.
|
||||
|
||||
Useful for collision detection - checks if an entity exists at
|
||||
the target position before moving there.
|
||||
|
||||
Args:
|
||||
entities: List of entities to check
|
||||
x: X coordinate to check
|
||||
y: Y coordinate to check
|
||||
|
||||
Returns:
|
||||
The entity at that position, or None if empty
|
||||
"""
|
||||
for entity in entities:
|
||||
if entity.pos[0] == x and entity.pos[1] == y:
|
||||
return entity
|
||||
return None
|
||||
|
||||
|
||||
def move_entity(
|
||||
entity: mcrfpy.Entity,
|
||||
grid: mcrfpy.Grid,
|
||||
dx: int,
|
||||
dy: int,
|
||||
entities: List[mcrfpy.Entity] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Attempt to move an entity by a delta.
|
||||
|
||||
Checks for:
|
||||
- Grid bounds
|
||||
- Walkable terrain
|
||||
- Other blocking entities (if entities list provided)
|
||||
|
||||
Args:
|
||||
entity: The entity to move
|
||||
grid: The grid for terrain collision
|
||||
dx: Delta X (-1, 0, or 1 typically)
|
||||
dy: Delta Y (-1, 0, or 1 typically)
|
||||
entities: Optional list of entities to check for collision
|
||||
|
||||
Returns:
|
||||
True if movement succeeded, False otherwise
|
||||
"""
|
||||
dest_x = entity.pos[0] + dx
|
||||
dest_y = entity.pos[1] + dy
|
||||
|
||||
# Check grid bounds
|
||||
grid_width, grid_height = grid.grid_size
|
||||
if not (0 <= dest_x < grid_width and 0 <= dest_y < grid_height):
|
||||
return False
|
||||
|
||||
# Check if tile is walkable
|
||||
if not grid.at(dest_x, dest_y).walkable:
|
||||
return False
|
||||
|
||||
# Check for blocking entities
|
||||
if entities and get_blocking_entity_at(entities, dest_x, dest_y):
|
||||
return False
|
||||
|
||||
# Move is valid
|
||||
entity.pos = (dest_x, dest_y)
|
||||
return True
|
||||
|
||||
|
||||
def distance_between(
|
||||
entity1: mcrfpy.Entity,
|
||||
entity2: mcrfpy.Entity
|
||||
) -> float:
|
||||
"""
|
||||
Calculate the Chebyshev distance between two entities.
|
||||
|
||||
Chebyshev distance (also called chessboard distance) counts
|
||||
diagonal moves as 1, which is standard for roguelikes.
|
||||
|
||||
Args:
|
||||
entity1: First entity
|
||||
entity2: Second entity
|
||||
|
||||
Returns:
|
||||
Distance in tiles (diagonal = 1)
|
||||
"""
|
||||
dx = abs(entity1.pos[0] - entity2.pos[0])
|
||||
dy = abs(entity1.pos[1] - entity2.pos[1])
|
||||
return max(dx, dy)
|
||||
|
||||
|
||||
def entities_in_radius(
|
||||
center: mcrfpy.Entity,
|
||||
entities: List[mcrfpy.Entity],
|
||||
radius: float
|
||||
) -> List[mcrfpy.Entity]:
|
||||
"""
|
||||
Find all entities within a given radius of a center entity.
|
||||
|
||||
Uses Chebyshev distance for roguelike-style radius.
|
||||
|
||||
Args:
|
||||
center: The entity to search around
|
||||
entities: List of entities to check
|
||||
radius: Maximum distance in tiles
|
||||
|
||||
Returns:
|
||||
List of entities within the radius (excluding center)
|
||||
"""
|
||||
nearby = []
|
||||
for entity in entities:
|
||||
if entity is not center:
|
||||
if distance_between(center, entity) <= radius:
|
||||
nearby.append(entity)
|
||||
return nearby
|
||||
|
||||
|
||||
def remove_entity(
|
||||
entity: mcrfpy.Entity,
|
||||
grid: mcrfpy.Grid
|
||||
) -> bool:
|
||||
"""
|
||||
Remove an entity from a grid.
|
||||
|
||||
Args:
|
||||
entity: The entity to remove
|
||||
grid: The grid containing the entity
|
||||
|
||||
Returns:
|
||||
True if removal succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
idx = entity.index()
|
||||
grid.entities.remove(idx)
|
||||
return True
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
290
docs/templates/roguelike/game.py
vendored
Normal file
290
docs/templates/roguelike/game.py
vendored
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
"""
|
||||
game.py - Roguelike Template Main Entry Point
|
||||
|
||||
A minimal but complete roguelike starter using McRogueFace.
|
||||
|
||||
This template demonstrates:
|
||||
- Scene and grid setup
|
||||
- Procedural dungeon generation
|
||||
- Player entity with keyboard movement
|
||||
- Enemy entities (static, no AI)
|
||||
- Field of view using TCOD via Entity.update_visibility()
|
||||
- FOV visualization with grid color overlays
|
||||
|
||||
Run with: ./mcrogueface
|
||||
|
||||
Controls:
|
||||
- Arrow keys / WASD: Move player
|
||||
- Escape: Quit game
|
||||
|
||||
The template is designed to be extended. Good next steps:
|
||||
- Add enemy AI (chase player, pathfinding)
|
||||
- Implement combat system
|
||||
- Add items and inventory
|
||||
- Add multiple dungeon levels
|
||||
"""
|
||||
|
||||
import mcrfpy
|
||||
from typing import List, Tuple
|
||||
|
||||
# Import our template modules
|
||||
from constants import (
|
||||
MAP_WIDTH, MAP_HEIGHT,
|
||||
SPRITE_WIDTH, SPRITE_HEIGHT,
|
||||
FOV_RADIUS,
|
||||
COLOR_VISIBLE, COLOR_EXPLORED, COLOR_UNKNOWN,
|
||||
SPRITE_PLAYER,
|
||||
)
|
||||
from dungeon import generate_dungeon, populate_grid, RectangularRoom
|
||||
from entities import (
|
||||
create_player,
|
||||
create_enemies_in_rooms,
|
||||
move_entity,
|
||||
EntityStats,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GAME STATE
|
||||
# =============================================================================
|
||||
# Global game state - in a larger game, you'd use a proper state management
|
||||
# system, but for a template this keeps things simple and visible.
|
||||
|
||||
class GameState:
|
||||
"""Container for all game state."""
|
||||
|
||||
def __init__(self):
|
||||
# Core game objects (set during initialization)
|
||||
self.grid: mcrfpy.Grid = None
|
||||
self.player: mcrfpy.Entity = None
|
||||
self.rooms: List[RectangularRoom] = []
|
||||
self.enemies: List[Tuple[mcrfpy.Entity, EntityStats]] = []
|
||||
|
||||
# Texture reference
|
||||
self.texture: mcrfpy.Texture = None
|
||||
|
||||
|
||||
# Global game state instance
|
||||
game = GameState()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FOV (FIELD OF VIEW) SYSTEM
|
||||
# =============================================================================
|
||||
|
||||
def update_fov() -> None:
|
||||
"""
|
||||
Update the field of view based on player position.
|
||||
|
||||
This function:
|
||||
1. Calls update_visibility() on the player entity to compute FOV using TCOD
|
||||
2. Applies color overlays to tiles based on visibility state
|
||||
|
||||
The FOV creates the classic roguelike effect where:
|
||||
- Visible tiles are fully bright (no overlay)
|
||||
- Previously seen tiles are dimmed (remembered layout)
|
||||
- Never-seen tiles are completely dark
|
||||
|
||||
TCOD handles the actual FOV computation based on the grid's
|
||||
walkable and transparent flags set during dungeon generation.
|
||||
"""
|
||||
if not game.player or not game.grid:
|
||||
return
|
||||
|
||||
# Tell McRogueFace/TCOD to recompute visibility from player position
|
||||
game.player.update_visibility()
|
||||
|
||||
grid_width, grid_height = game.grid.grid_size
|
||||
|
||||
# Apply visibility colors to each tile
|
||||
for x in range(grid_width):
|
||||
for y in range(grid_height):
|
||||
point = game.grid.at(x, y)
|
||||
|
||||
# Get the player's visibility state for this tile
|
||||
state = game.player.at(x, y)
|
||||
|
||||
if state.visible:
|
||||
# Currently visible - no overlay (full brightness)
|
||||
point.color_overlay = COLOR_VISIBLE
|
||||
elif state.discovered:
|
||||
# Previously seen - dimmed overlay (memory)
|
||||
point.color_overlay = COLOR_EXPLORED
|
||||
else:
|
||||
# Never seen - completely dark
|
||||
point.color_overlay = COLOR_UNKNOWN
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INPUT HANDLING
|
||||
# =============================================================================
|
||||
|
||||
def handle_keys(key: str, state: str) -> None:
|
||||
"""
|
||||
Handle keyboard input for player movement and game controls.
|
||||
|
||||
This is the main input handler registered with McRogueFace.
|
||||
It processes key events and updates game state accordingly.
|
||||
|
||||
Args:
|
||||
key: The key that was pressed (e.g., "W", "Up", "Escape")
|
||||
state: Either "start" (key pressed) or "end" (key released)
|
||||
"""
|
||||
# Only process key press events, not releases
|
||||
if state != "start":
|
||||
return
|
||||
|
||||
# Movement deltas: (dx, dy)
|
||||
movement = {
|
||||
# Arrow keys
|
||||
"Up": (0, -1),
|
||||
"Down": (0, 1),
|
||||
"Left": (-1, 0),
|
||||
"Right": (1, 0),
|
||||
# WASD keys
|
||||
"W": (0, -1),
|
||||
"S": (0, 1),
|
||||
"A": (-1, 0),
|
||||
"D": (1, 0),
|
||||
# Numpad (for diagonal movement if desired)
|
||||
"Numpad8": (0, -1),
|
||||
"Numpad2": (0, 1),
|
||||
"Numpad4": (-1, 0),
|
||||
"Numpad6": (1, 0),
|
||||
"Numpad7": (-1, -1),
|
||||
"Numpad9": (1, -1),
|
||||
"Numpad1": (-1, 1),
|
||||
"Numpad3": (1, 1),
|
||||
}
|
||||
|
||||
if key in movement:
|
||||
dx, dy = movement[key]
|
||||
|
||||
# Get list of all entity objects for collision checking
|
||||
all_entities = [e for e, _ in game.enemies]
|
||||
|
||||
# Attempt to move the player
|
||||
if move_entity(game.player, game.grid, dx, dy, all_entities):
|
||||
# Movement succeeded - update FOV
|
||||
update_fov()
|
||||
|
||||
# Center camera on player
|
||||
px, py = game.player.pos
|
||||
game.grid.center = (px, py)
|
||||
|
||||
elif key == "Escape":
|
||||
# Quit the game
|
||||
mcrfpy.exit()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GAME INITIALIZATION
|
||||
# =============================================================================
|
||||
|
||||
def initialize_game() -> None:
|
||||
"""
|
||||
Set up the game world.
|
||||
|
||||
This function:
|
||||
1. Creates the scene and loads resources
|
||||
2. Generates the dungeon layout
|
||||
3. Creates and places all entities
|
||||
4. Initializes the FOV system
|
||||
5. Sets up input handling
|
||||
"""
|
||||
# Create the game scene
|
||||
mcrfpy.createScene("game")
|
||||
ui = mcrfpy.sceneUI("game")
|
||||
|
||||
# Load the tileset texture
|
||||
# The default McRogueFace texture works great for roguelikes
|
||||
game.texture = mcrfpy.Texture(
|
||||
"assets/kenney_tinydungeon.png",
|
||||
SPRITE_WIDTH,
|
||||
SPRITE_HEIGHT
|
||||
)
|
||||
|
||||
# Create the grid (tile-based game world)
|
||||
# Using keyword arguments for clarity - this is the preferred style
|
||||
game.grid = mcrfpy.Grid(
|
||||
pos=(0, 0), # Screen position in pixels
|
||||
size=(1024, 768), # Display size in pixels
|
||||
grid_size=(MAP_WIDTH, MAP_HEIGHT), # Map size in tiles
|
||||
texture=game.texture
|
||||
)
|
||||
ui.append(game.grid)
|
||||
|
||||
# Generate dungeon layout
|
||||
game.rooms = generate_dungeon()
|
||||
|
||||
# Apply dungeon to grid (sets tiles, walkable flags, etc.)
|
||||
populate_grid(game.grid, game.rooms)
|
||||
|
||||
# Place player in the center of the first room
|
||||
if game.rooms:
|
||||
start_x, start_y = game.rooms[0].center
|
||||
else:
|
||||
# Fallback if no rooms generated
|
||||
start_x, start_y = MAP_WIDTH // 2, MAP_HEIGHT // 2
|
||||
|
||||
game.player = create_player(
|
||||
grid=game.grid,
|
||||
texture=game.texture,
|
||||
x=start_x,
|
||||
y=start_y
|
||||
)
|
||||
|
||||
# Center camera on player
|
||||
game.grid.center = (start_x, start_y)
|
||||
|
||||
# Spawn enemies in other rooms
|
||||
game.enemies = create_enemies_in_rooms(
|
||||
grid=game.grid,
|
||||
texture=game.texture,
|
||||
rooms=game.rooms,
|
||||
enemies_per_room=2,
|
||||
skip_first_room=True
|
||||
)
|
||||
|
||||
# Initial FOV calculation
|
||||
update_fov()
|
||||
|
||||
# Register input handler
|
||||
mcrfpy.keypressScene(handle_keys)
|
||||
|
||||
# Switch to game scene
|
||||
mcrfpy.setScene("game")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN ENTRY POINT
|
||||
# =============================================================================
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Main entry point for the roguelike template.
|
||||
|
||||
This function is called when the script starts. It initializes
|
||||
the game and McRogueFace handles the game loop automatically.
|
||||
"""
|
||||
initialize_game()
|
||||
|
||||
# Display welcome message
|
||||
print("=" * 50)
|
||||
print(" ROGUELIKE TEMPLATE")
|
||||
print("=" * 50)
|
||||
print("Controls:")
|
||||
print(" Arrow keys / WASD - Move")
|
||||
print(" Escape - Quit")
|
||||
print()
|
||||
print(f"Dungeon generated with {len(game.rooms)} rooms")
|
||||
print(f"Enemies spawned: {len(game.enemies)}")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
# Run the game
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
else:
|
||||
# McRogueFace runs game.py directly, not as __main__
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue