3 Entity Management
John McCardle edited this page 2025-12-02 02:06:13 +00:00

Entity Management

Last modified: 2025-12-01

Entity Management

Entities are game objects that implement behavior and live on Grids. While Grids handle rendering and mediate interactions, Entities encapsulate game logic like movement, combat, and AI.

Quick Reference

Parent System: Grid-System

Key Types:

  • mcrfpy.Entity - Game entities on grids
  • mcrfpy.Grid - Spatial container for entities
  • mcrfpy.EntityCollection - Collection of entities on a grid

Key Files:

  • src/UIEntity.h / src/UIEntity.cpp
  • src/UIEntityCollection.h / .cpp

Related Issues:

  • #115 - SpatialHash for fast queries (Open)
  • #117 - Memory Pool for entities (Open)

What Are Entities?

Entities are game objects that:

  • Live on a Grid (0 or 1 grid at a time)
  • Have a sprite for visual rendering
  • Have grid position (integer cell coordinates)
  • Implement behavior (movement, AI, combat, inventory)
  • Track visibility (which cells they can see / have seen)

Key distinction: Entities implement behavior. Grids mediate interaction between entities and render them to screen.


Entity-Grid Relationship

The Entity-Grid relationship mirrors the UIDrawable parent-child pattern:

Relationship Property Automatic Behavior
Entity → Grid entity.grid Set when added to grid.entities
Grid → Entities grid.entities Collection of all entities on grid
import mcrfpy

# Create grid and entity
grid = mcrfpy.Grid(grid_size=(50, 50), pos=(0, 0), size=(400, 400))
player = mcrfpy.Entity(pos=(10, 10), sprite_index=0)

# Before adding: entity has no grid
print(player.grid)  # None

# Add to grid
grid.entities.append(player)

# After adding: bidirectional link established
print(player.grid == grid)  # True
print(player in grid.entities)  # True

# Removing breaks the link
grid.entities.remove(player)
print(player.grid)  # None

Important: An entity can only be on 0 or 1 grids at a time. Adding to a new grid automatically removes from the old one.


Entity Properties

Position

# Grid coordinates (integer cells)
entity.x = 15
entity.y = 20
entity.pos = (15, 20)  # Tuple form

# Draw position (float, for animation interpolation)
print(entity.draw_pos)  # Actual render position

Sprite

entity.sprite_index = 5  # Index in texture sprite sheet

Visibility

entity.visible = True
entity.opacity = 0.8  # 0.0 to 1.0

Grid Reference

current_grid = entity.grid  # Read-only, set by collection operations

Field of View & Visibility

Entities track what they can see via gridstate - a per-cell record of visible and discovered states.

FOV Configuration

# Grid-level FOV settings
grid.fov = mcrfpy.FOV.SHADOW  # Algorithm (BASIC, DIAMOND, SHADOW, etc.)
grid.fov_radius = 10           # Default view radius

# Module-level default
mcrfpy.default_fov = mcrfpy.FOV.PERMISSIVE_2

Updating Visibility

# Compute FOV from entity's position and update gridstate
entity.update_visibility()

# This also updates any ColorLayers bound via apply_perspective()

Querying Visible Entities

# Get list of other entities this entity can see
visible_enemies = entity.visible_entities()

# With custom FOV settings
nearby = entity.visible_entities(radius=5)
visible = entity.visible_entities(fov=mcrfpy.FOV.BASIC, radius=8)

Fog of War with ColorLayers

# Create a ColorLayer for fog of war
fov_layer = grid.add_layer('color', z_index=-1)
fov_layer.fill((0, 0, 0, 255))  # Start black (unknown)

# Bind to entity - layer auto-updates when entity.update_visibility() is called
fov_layer.apply_perspective(
    entity=player,
    visible=(0, 0, 0, 0),           # Transparent when visible
    discovered=(40, 40, 60, 180),    # Dark overlay when discovered
    unknown=(0, 0, 0, 255)           # Black when never seen
)

# Now whenever player moves:
player.x = new_x
player.y = new_y
player.update_visibility()  # Automatically updates the fog layer

One-Time FOV Draw

# Draw FOV without binding (useful for previews, spell ranges, etc.)
fov_layer.draw_fov(
    source=(player.x, player.y),
    radius=10,
    fov=mcrfpy.FOV.SHADOW,
    visible=(255, 255, 200, 64),
    discovered=(100, 100, 100, 128),
    unknown=(0, 0, 0, 255)
)

Gridstate Access

# Entity's per-cell visibility memory
for state in entity.gridstate:
    print(f"visible={state.visible}, discovered={state.discovered}")

# Access specific cell state
state = entity.at((x, y))
if state.visible:
    print("Entity can currently see this cell")
elif state.discovered:
    print("Entity has seen this cell before")

GridPointState.point - Accessing Cell Data (#16)

The GridPointState.point property provides access to the underlying GridPoint from an entity's perspective:

state = entity.at((x, y))

# If entity has NOT discovered this cell, point is None
if not state.discovered:
    print(state.point)  # None - entity doesn't know what's here

# If entity HAS discovered the cell, point gives access to GridPoint
if state.discovered:
    point = state.point  # Live reference to GridPoint
    print(f"walkable: {point.walkable}")
    print(f"transparent: {point.transparent}")
    print(f"entities here: {point.entities}")  # List of entities at cell

Key behaviors:

  • Returns None if discovered=False (entity has never seen this cell)
  • Returns live GridPoint reference if discovered=True
  • Changes to the GridPoint are immediately visible through state.point
  • This is intentionally not a cached copy - for historical memory, implement your own system in Python

Use case - Entity perspective queries:

def can_entity_see_walkable_path(entity, x, y):
    """Check if entity knows this cell is walkable."""
    state = entity.at((x, y))
    if state.point is None:
        return None  # Unknown - entity hasn't discovered it
    return state.point.walkable

def get_known_entities_at(entity, x, y):
    """Get entities at cell if entity has discovered it."""
    state = entity.at((x, y))
    if state.point is None:
        return []  # Entity doesn't know this cell
    return state.point.entities

Ground truth access:

If you need the actual cell data regardless of entity perspective, access it through the grid directly:

# Entity perspective (respects discovered state)
state = entity.at((x, y))
point_or_none = state.point

# Ground truth (always returns GridPoint)
point = entity.grid.at(x, y)

EntityCollection

grid.entities is an EntityCollection with list-like operations:

# Add entities
grid.entities.append(entity)
grid.entities.extend([entity1, entity2, entity3])
grid.entities.insert(0, entity)  # Insert at index

# Remove entities
grid.entities.remove(entity)
entity = grid.entities.pop()  # Remove and return last
entity = grid.entities.pop(0)  # Remove and return at index

# Query
count = len(grid.entities)
idx = grid.entities.index(entity)
n = grid.entities.count(entity)
found = grid.entities.find("entity_name")  # Find by name

# Iteration
for entity in grid.entities:
    print(entity.pos)

Entity Lifecycle

Creation

# Basic creation
entity = mcrfpy.Entity(pos=(10, 10), sprite_index=0)

# With name for later lookup
entity = mcrfpy.Entity(pos=(10, 10), sprite_index=0, name="player")

Adding to Grid

grid.entities.append(entity)
# entity.grid is now set to grid
# Entity will be rendered with the grid

Movement

# Direct position change
entity.pos = (new_x, new_y)

# Animated movement
mcrfpy.Animation("x", target_x, 0.3, "easeOutQuad").start(entity)
mcrfpy.Animation("y", target_y, 0.3, "easeOutQuad").start(entity)

# Update visibility after movement
entity.update_visibility()

Removal

# Method 1: Remove from collection
grid.entities.remove(entity)

# Method 2: Entity.die() - removes from parent grid
entity.die()

# After removal: entity.grid is None

Transfer Between Grids

def transfer_entity(entity, to_grid, new_pos):
    """Move entity to a different grid."""
    entity.die()  # Remove from current grid
    entity.pos = new_pos
    to_grid.entities.append(entity)

Common Patterns

Player Entity with FOV

class Player:
    def __init__(self, grid, start_pos):
        self.entity = mcrfpy.Entity(pos=start_pos, sprite_index=0, name="player")
        grid.entities.append(self.entity)
        
        # Set up fog of war
        self.fov_layer = grid.add_layer('color', z_index=-1)
        self.fov_layer.fill((0, 0, 0, 255))
        self.fov_layer.apply_perspective(
            entity=self.entity,
            visible=(0, 0, 0, 0),
            discovered=(30, 30, 50, 180),
            unknown=(0, 0, 0, 255)
        )
        self.entity.update_visibility()

    def move(self, dx, dy):
        new_x = self.entity.x + dx
        new_y = self.entity.y + dy

        point = self.entity.grid.at(new_x, new_y)
        if point and point.walkable:
            self.entity.pos = (new_x, new_y)
            self.entity.update_visibility()  # Update FOV after move
            return True
        return False
    
    def get_visible_enemies(self):
        """Get enemies this player can currently see."""
        return [e for e in self.entity.visible_entities() 
                if e.name and e.name.startswith("enemy")]

Enemy Entity

class Enemy:
    def __init__(self, grid, pos, aggro_range=10):
        self.entity = mcrfpy.Entity(pos=pos, sprite_index=1, name="enemy")
        self.aggro_range = aggro_range
        self.health = 100
        grid.entities.append(self.entity)

    def update(self, player_pos):
        dx = player_pos[0] - self.entity.x
        dy = player_pos[1] - self.entity.y
        dist = (dx*dx + dy*dy) ** 0.5

        if dist < self.aggro_range:
            self.chase(player_pos)
        else:
            self.wander()

    def chase(self, target):
        # Use pathfinding
        path = self.entity.path_to(target)
        if path and len(path) > 1:
            next_cell = path[1]  # path[0] is current position
            self.entity.pos = next_cell

    def wander(self):
        import random
        dx = random.choice([-1, 0, 1])
        dy = random.choice([-1, 0, 1])

        new_pos = (self.entity.x + dx, self.entity.y + dy)
        point = self.entity.grid.at(*new_pos)
        if point and point.walkable:
            self.entity.pos = new_pos

Item Entity

class Item:
    def __init__(self, grid, pos, item_type):
        self.entity = mcrfpy.Entity(pos=pos, sprite_index=10 + item_type)
        self.item_type = item_type
        grid.entities.append(self.entity)

    def pickup(self, collector):
        """Called when another entity picks up this item."""
        collector.inventory.append(self.item_type)
        self.entity.die()  # Remove from grid

For more interaction patterns (click handling, selection, context menus), see Grid-Interaction-Patterns.


Pathfinding

Entities have built-in pathfinding via libtcod:

# A* pathfinding to target
path = entity.path_to((target_x, target_y))
# Returns list of (x, y) tuples, or empty if no path

if path:
    next_step = path[1]  # path[0] is current position
    entity.pos = next_step

Pathfinding respects GridPoint.walkable properties set on the grid.


Performance Considerations

Current: Entity queries are O(n):

# Finding entities at position requires iteration
def entities_at(grid, x, y):
    return [e for e in grid.entities if e.x == x and e.y == y]

New in v1.0: Use GridPoint.entities for cell-based queries:

# O(n) but more convenient - filters grid.entities by position
entities_here = grid.at(x, y).entities

Workarounds:

  • Keep entity counts reasonable (< 200 for best performance)
  • Use timer callbacks for AI updates, not per-frame
  • Cache query results when possible

Future: #115 SpatialHash will provide O(1) position queries.

See Performance-and-Profiling for optimization guidance.



Last updated: 2025-12-01