364 lines
9.3 KiB
Python
364 lines
9.3 KiB
Python
|
|
"""
|
||
|
|
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
|