625 lines
20 KiB
Python
625 lines
20 KiB
Python
"""
|
|
McRogueFace Tutorial - Part 5: Entity Interactions
|
|
|
|
This tutorial builds on Part 4 by adding:
|
|
- Entity class hierarchy (PlayerEntity, EnemyEntity, BoulderEntity, ButtonEntity)
|
|
- Non-blocking movement animations with destination tracking
|
|
- Bump interactions (combat, pushing)
|
|
- Step-on interactions (buttons, doors)
|
|
- Concurrent enemy AI with smooth animations
|
|
|
|
Key concepts:
|
|
- Entities inherit from mcrfpy.Entity for proper C++/Python integration
|
|
- Logic operates on destination positions during animations
|
|
- Player input is processed immediately, not blocked by animations
|
|
"""
|
|
import mcrfpy
|
|
import random
|
|
|
|
# ============================================================================
|
|
# Entity Classes - Inherit from mcrfpy.Entity
|
|
# ============================================================================
|
|
|
|
class GameEntity(mcrfpy.Entity):
|
|
"""Base class for all game entities with interaction logic"""
|
|
def __init__(self, x, y, **kwargs):
|
|
# Extract grid before passing to parent
|
|
grid = kwargs.pop('grid', None)
|
|
super().__init__(x=x, y=y, **kwargs)
|
|
|
|
# Current position is tracked by parent Entity.x/y
|
|
# Add destination tracking for animation system
|
|
self.dest_x = x
|
|
self.dest_y = y
|
|
self.is_moving = False
|
|
|
|
# Game properties
|
|
self.blocks_movement = True
|
|
self.hp = 10
|
|
self.max_hp = 10
|
|
self.entity_type = "generic"
|
|
|
|
# Add to grid if provided
|
|
if grid:
|
|
grid.entities.append(self)
|
|
|
|
def start_move(self, new_x, new_y, duration=0.2, callback=None):
|
|
"""Start animating movement to new position"""
|
|
self.dest_x = new_x
|
|
self.dest_y = new_y
|
|
self.is_moving = True
|
|
|
|
# Create animations for smooth movement
|
|
if callback:
|
|
# Only x animation needs callback since they run in parallel
|
|
anim_x = mcrfpy.Animation("x", float(new_x), duration, "easeInOutQuad", callback=callback)
|
|
else:
|
|
anim_x = mcrfpy.Animation("x", float(new_x), duration, "easeInOutQuad")
|
|
anim_y = mcrfpy.Animation("y", float(new_y), duration, "easeInOutQuad")
|
|
|
|
anim_x.start(self)
|
|
anim_y.start(self)
|
|
|
|
def get_position(self):
|
|
"""Get logical position (destination if moving, otherwise current)"""
|
|
if self.is_moving:
|
|
return (self.dest_x, self.dest_y)
|
|
return (int(self.x), int(self.y))
|
|
|
|
def on_bump(self, other):
|
|
"""Called when another entity tries to move into our space"""
|
|
return False # Block movement by default
|
|
|
|
def on_step(self, other):
|
|
"""Called when another entity steps on us (non-blocking)"""
|
|
pass
|
|
|
|
def take_damage(self, damage):
|
|
"""Apply damage and handle death"""
|
|
self.hp -= damage
|
|
if self.hp <= 0:
|
|
self.hp = 0
|
|
self.die()
|
|
|
|
def die(self):
|
|
"""Remove entity from grid"""
|
|
# The C++ die() method handles removal from grid
|
|
super().die()
|
|
|
|
class PlayerEntity(GameEntity):
|
|
"""The player character"""
|
|
def __init__(self, x, y, **kwargs):
|
|
kwargs['sprite_index'] = 64 # Hero sprite
|
|
super().__init__(x=x, y=y, **kwargs)
|
|
self.damage = 3
|
|
self.entity_type = "player"
|
|
self.blocks_movement = True
|
|
|
|
def on_bump(self, other):
|
|
"""Player bumps into something"""
|
|
if other.entity_type == "enemy":
|
|
# Deal damage
|
|
other.take_damage(self.damage)
|
|
return False # Can't move into enemy space
|
|
elif other.entity_type == "boulder":
|
|
# Try to push
|
|
dx = self.dest_x - int(self.x)
|
|
dy = self.dest_y - int(self.y)
|
|
return other.try_push(dx, dy)
|
|
return False
|
|
|
|
class EnemyEntity(GameEntity):
|
|
"""Basic enemy with AI"""
|
|
def __init__(self, x, y, **kwargs):
|
|
kwargs['sprite_index'] = 65 # Enemy sprite
|
|
super().__init__(x=x, y=y, **kwargs)
|
|
self.damage = 1
|
|
self.entity_type = "enemy"
|
|
self.ai_state = "wander"
|
|
self.hp = 5
|
|
self.max_hp = 5
|
|
|
|
def on_bump(self, other):
|
|
"""Enemy bumps into something"""
|
|
if other.entity_type == "player":
|
|
other.take_damage(self.damage)
|
|
return False
|
|
return False
|
|
|
|
def can_see_player(self, player_pos, grid):
|
|
"""Check if enemy can see the player position"""
|
|
# Simple check: within 6 tiles and has line of sight
|
|
mx, my = self.get_position()
|
|
px, py = player_pos
|
|
|
|
dist = abs(px - mx) + abs(py - my)
|
|
if dist > 6:
|
|
return False
|
|
|
|
# Use libtcod for line of sight
|
|
line = list(mcrfpy.libtcod.line(mx, my, px, py))
|
|
if len(line) > 7: # Too far
|
|
return False
|
|
for x, y in line[1:-1]: # Skip start and end points
|
|
cell = grid.at(x, y)
|
|
if cell and not cell.transparent:
|
|
return False
|
|
return True
|
|
|
|
def ai_turn(self, grid, player):
|
|
"""Decide next move"""
|
|
px, py = player.get_position()
|
|
mx, my = self.get_position()
|
|
|
|
# Simple AI: move toward player if visible
|
|
if self.can_see_player((px, py), grid):
|
|
# Calculate direction toward player
|
|
dx = 0
|
|
dy = 0
|
|
if px > mx:
|
|
dx = 1
|
|
elif px < mx:
|
|
dx = -1
|
|
if py > my:
|
|
dy = 1
|
|
elif py < my:
|
|
dy = -1
|
|
|
|
# Prefer cardinal movement
|
|
if dx != 0 and dy != 0:
|
|
# Pick horizontal or vertical based on greater distance
|
|
if abs(px - mx) > abs(py - my):
|
|
dy = 0
|
|
else:
|
|
dx = 0
|
|
|
|
return (mx + dx, my + dy)
|
|
else:
|
|
# Random movement
|
|
dx, dy = random.choice([(0,1), (0,-1), (1,0), (-1,0)])
|
|
return (mx + dx, my + dy)
|
|
|
|
class BoulderEntity(GameEntity):
|
|
"""Pushable boulder"""
|
|
def __init__(self, x, y, **kwargs):
|
|
kwargs['sprite_index'] = 7 # Boulder sprite
|
|
super().__init__(x=x, y=y, **kwargs)
|
|
self.entity_type = "boulder"
|
|
self.pushable = True
|
|
|
|
def try_push(self, dx, dy):
|
|
"""Attempt to push boulder in direction"""
|
|
new_x = int(self.x) + dx
|
|
new_y = int(self.y) + dy
|
|
|
|
# Check if destination is free
|
|
if can_move_to(new_x, new_y):
|
|
self.start_move(new_x, new_y)
|
|
return True
|
|
return False
|
|
|
|
class ButtonEntity(GameEntity):
|
|
"""Pressure plate that triggers when stepped on"""
|
|
def __init__(self, x, y, target=None, **kwargs):
|
|
kwargs['sprite_index'] = 8 # Button sprite
|
|
super().__init__(x=x, y=y, **kwargs)
|
|
self.blocks_movement = False # Can be walked over
|
|
self.entity_type = "button"
|
|
self.pressed = False
|
|
self.pressed_by = set() # Track who's pressing
|
|
self.target = target # Door or other triggerable
|
|
|
|
def on_step(self, other):
|
|
"""Activate when stepped on"""
|
|
if other not in self.pressed_by:
|
|
self.pressed_by.add(other)
|
|
if not self.pressed:
|
|
self.pressed = True
|
|
self.sprite_index = 9 # Pressed sprite
|
|
if self.target:
|
|
self.target.activate()
|
|
|
|
def on_leave(self, other):
|
|
"""Deactivate when entity leaves"""
|
|
if other in self.pressed_by:
|
|
self.pressed_by.remove(other)
|
|
if len(self.pressed_by) == 0 and self.pressed:
|
|
self.pressed = False
|
|
self.sprite_index = 8 # Unpressed sprite
|
|
if self.target:
|
|
self.target.deactivate()
|
|
|
|
class DoorEntity(GameEntity):
|
|
"""Door that can be opened by buttons"""
|
|
def __init__(self, x, y, **kwargs):
|
|
kwargs['sprite_index'] = 3 # Closed door sprite
|
|
super().__init__(x=x, y=y, **kwargs)
|
|
self.entity_type = "door"
|
|
self.is_open = False
|
|
|
|
def activate(self):
|
|
"""Open the door"""
|
|
self.is_open = True
|
|
self.blocks_movement = False
|
|
self.sprite_index = 11 # Open door sprite
|
|
|
|
def deactivate(self):
|
|
"""Close the door"""
|
|
self.is_open = False
|
|
self.blocks_movement = True
|
|
self.sprite_index = 3 # Closed door sprite
|
|
|
|
# ============================================================================
|
|
# Global Game State
|
|
# ============================================================================
|
|
|
|
# Create and activate a new scene
|
|
mcrfpy.createScene("tutorial")
|
|
mcrfpy.setScene("tutorial")
|
|
|
|
# Load the texture
|
|
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
|
|
|
|
# Create a grid of tiles
|
|
grid_width, grid_height = 40, 30
|
|
zoom = 2.0
|
|
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
|
|
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
|
|
|
|
# Create the grid
|
|
grid = mcrfpy.Grid(
|
|
pos=grid_position,
|
|
grid_size=(grid_width, grid_height),
|
|
texture=texture,
|
|
size=grid_size,
|
|
)
|
|
grid.zoom = zoom
|
|
|
|
# Define tile types
|
|
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
|
|
WALL_TILES = [3, 7, 11]
|
|
|
|
# Game state
|
|
player = None
|
|
enemies = []
|
|
all_entities = []
|
|
is_player_turn = True
|
|
move_duration = 0.2
|
|
|
|
# ============================================================================
|
|
# Dungeon Generation (from Part 3)
|
|
# ============================================================================
|
|
|
|
class Room:
|
|
def __init__(self, x, y, width, height):
|
|
self.x1 = x
|
|
self.y1 = y
|
|
self.x2 = x + width
|
|
self.y2 = y + height
|
|
|
|
def center(self):
|
|
return ((self.x1 + self.x2) // 2, (self.y1 + self.y2) // 2)
|
|
|
|
def intersects(self, other):
|
|
return (self.x1 <= other.x2 and self.x2 >= other.x1 and
|
|
self.y1 <= other.y2 and self.y2 >= other.y1)
|
|
|
|
def create_room(room):
|
|
"""Carve out a room in the grid"""
|
|
for x in range(room.x1 + 1, room.x2):
|
|
for y in range(room.y1 + 1, room.y2):
|
|
cell = grid.at(x, y)
|
|
if cell:
|
|
cell.walkable = True
|
|
cell.transparent = True
|
|
cell.tilesprite = random.choice(FLOOR_TILES)
|
|
|
|
def create_l_shaped_hallway(x1, y1, x2, y2):
|
|
"""Create L-shaped hallway between two points"""
|
|
corner_x = x2
|
|
corner_y = y1
|
|
|
|
if random.random() < 0.5:
|
|
corner_x = x1
|
|
corner_y = y2
|
|
|
|
for x, y in mcrfpy.libtcod.line(x1, y1, corner_x, corner_y):
|
|
cell = grid.at(x, y)
|
|
if cell:
|
|
cell.walkable = True
|
|
cell.transparent = True
|
|
cell.tilesprite = random.choice(FLOOR_TILES)
|
|
|
|
for x, y in mcrfpy.libtcod.line(corner_x, corner_y, x2, y2):
|
|
cell = grid.at(x, y)
|
|
if cell:
|
|
cell.walkable = True
|
|
cell.transparent = True
|
|
cell.tilesprite = random.choice(FLOOR_TILES)
|
|
|
|
def generate_dungeon():
|
|
"""Generate a simple dungeon with rooms and hallways"""
|
|
# Initialize all cells as walls
|
|
for x in range(grid_width):
|
|
for y in range(grid_height):
|
|
cell = grid.at(x, y)
|
|
if cell:
|
|
cell.walkable = False
|
|
cell.transparent = False
|
|
cell.tilesprite = random.choice(WALL_TILES)
|
|
|
|
rooms = []
|
|
num_rooms = 0
|
|
|
|
for _ in range(30):
|
|
w = random.randint(4, 8)
|
|
h = random.randint(4, 8)
|
|
x = random.randint(0, grid_width - w - 1)
|
|
y = random.randint(0, grid_height - h - 1)
|
|
|
|
new_room = Room(x, y, w, h)
|
|
|
|
# Check if room intersects with existing rooms
|
|
if any(new_room.intersects(other_room) for other_room in rooms):
|
|
continue
|
|
|
|
create_room(new_room)
|
|
|
|
if num_rooms > 0:
|
|
# Connect to previous room
|
|
new_x, new_y = new_room.center()
|
|
prev_x, prev_y = rooms[num_rooms - 1].center()
|
|
create_l_shaped_hallway(prev_x, prev_y, new_x, new_y)
|
|
|
|
rooms.append(new_room)
|
|
num_rooms += 1
|
|
|
|
return rooms
|
|
|
|
# ============================================================================
|
|
# Entity Management
|
|
# ============================================================================
|
|
|
|
def get_entities_at(x, y):
|
|
"""Get all entities at a specific position (including moving ones)"""
|
|
entities = []
|
|
for entity in all_entities:
|
|
ex, ey = entity.get_position()
|
|
if ex == x and ey == y:
|
|
entities.append(entity)
|
|
return entities
|
|
|
|
def get_blocking_entity_at(x, y):
|
|
"""Get the first blocking entity at position"""
|
|
for entity in get_entities_at(x, y):
|
|
if entity.blocks_movement:
|
|
return entity
|
|
return None
|
|
|
|
def can_move_to(x, y):
|
|
"""Check if a position is valid for movement"""
|
|
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
|
|
return False
|
|
|
|
cell = grid.at(x, y)
|
|
if not cell or not cell.walkable:
|
|
return False
|
|
|
|
# Check for blocking entities
|
|
if get_blocking_entity_at(x, y):
|
|
return False
|
|
|
|
return True
|
|
|
|
def can_entity_move_to(entity, x, y):
|
|
"""Check if specific entity can move to position"""
|
|
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
|
|
return False
|
|
|
|
cell = grid.at(x, y)
|
|
if not cell or not cell.walkable:
|
|
return False
|
|
|
|
# Check for other blocking entities (not self)
|
|
blocker = get_blocking_entity_at(x, y)
|
|
if blocker and blocker != entity:
|
|
return False
|
|
|
|
return True
|
|
|
|
# ============================================================================
|
|
# Turn Management
|
|
# ============================================================================
|
|
|
|
def process_player_move(key):
|
|
"""Handle player input with immediate response"""
|
|
global is_player_turn
|
|
|
|
if not is_player_turn or player.is_moving:
|
|
return # Not player's turn or still animating
|
|
|
|
px, py = player.get_position()
|
|
new_x, new_y = px, py
|
|
|
|
# Calculate movement direction
|
|
if key == "W" or key == "Up":
|
|
new_y -= 1
|
|
elif key == "S" or key == "Down":
|
|
new_y += 1
|
|
elif key == "A" or key == "Left":
|
|
new_x -= 1
|
|
elif key == "D" or key == "Right":
|
|
new_x += 1
|
|
else:
|
|
return # Not a movement key
|
|
|
|
if new_x == px and new_y == py:
|
|
return # No movement
|
|
|
|
# Check what's at destination
|
|
cell = grid.at(new_x, new_y)
|
|
if not cell or not cell.walkable:
|
|
return # Can't move into walls
|
|
|
|
blocking_entity = get_blocking_entity_at(new_x, new_y)
|
|
|
|
if blocking_entity:
|
|
# Try bump interaction
|
|
if not player.on_bump(blocking_entity):
|
|
# Movement blocked, but turn still happens
|
|
is_player_turn = False
|
|
mcrfpy.setTimer("enemy_turn", process_enemy_turns, 50)
|
|
return
|
|
|
|
# Movement is valid - start player animation
|
|
is_player_turn = False
|
|
player.start_move(new_x, new_y, duration=move_duration, callback=player_move_complete)
|
|
|
|
# Update grid center to follow player
|
|
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, move_duration, "linear")
|
|
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, move_duration, "linear")
|
|
grid_anim_x.start(grid)
|
|
grid_anim_y.start(grid)
|
|
|
|
# Start enemy turns after a short delay (so player sees their move start first)
|
|
mcrfpy.setTimer("enemy_turn", process_enemy_turns, 50)
|
|
|
|
def process_enemy_turns(timer_name):
|
|
"""Process all enemy AI decisions and start their animations"""
|
|
enemies_to_move = []
|
|
|
|
for enemy in enemies:
|
|
if enemy.hp <= 0: # Skip dead enemies
|
|
continue
|
|
|
|
if enemy.is_moving:
|
|
continue # Skip if still animating
|
|
|
|
# AI decides next move based on player's destination
|
|
target_x, target_y = enemy.ai_turn(grid, player)
|
|
|
|
# Check if move is valid
|
|
cell = grid.at(target_x, target_y)
|
|
if not cell or not cell.walkable:
|
|
continue
|
|
|
|
# Check what's at the destination
|
|
blocking_entity = get_blocking_entity_at(target_x, target_y)
|
|
|
|
if blocking_entity and blocking_entity != enemy:
|
|
# Try bump interaction
|
|
enemy.on_bump(blocking_entity)
|
|
# Enemy doesn't move but still took its turn
|
|
else:
|
|
# Valid move - add to list
|
|
enemies_to_move.append((enemy, target_x, target_y))
|
|
|
|
# Start all enemy animations simultaneously
|
|
for enemy, tx, ty in enemies_to_move:
|
|
enemy.start_move(tx, ty, duration=move_duration)
|
|
|
|
def player_move_complete(anim, entity):
|
|
"""Called when player animation finishes"""
|
|
global is_player_turn
|
|
|
|
player.is_moving = False
|
|
|
|
# Check for step-on interactions at new position
|
|
for entity in get_entities_at(int(player.x), int(player.y)):
|
|
if entity != player and not entity.blocks_movement:
|
|
entity.on_step(player)
|
|
|
|
# Update FOV from new position
|
|
update_fov()
|
|
|
|
# Player's turn is ready again
|
|
is_player_turn = True
|
|
|
|
def update_fov():
|
|
"""Update field of view from player position"""
|
|
px, py = int(player.x), int(player.y)
|
|
grid.compute_fov(px, py, radius=8)
|
|
player.update_visibility()
|
|
|
|
# ============================================================================
|
|
# Input Handling
|
|
# ============================================================================
|
|
|
|
def handle_keys(key, state):
|
|
"""Handle keyboard input"""
|
|
if state == "start":
|
|
# Movement keys
|
|
if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]:
|
|
process_player_move(key)
|
|
|
|
# Register the key handler
|
|
mcrfpy.keypressScene(handle_keys)
|
|
|
|
# ============================================================================
|
|
# Initialize Game
|
|
# ============================================================================
|
|
|
|
# Generate dungeon
|
|
rooms = generate_dungeon()
|
|
|
|
# Place player in first room
|
|
if rooms:
|
|
start_x, start_y = rooms[0].center()
|
|
player = PlayerEntity(start_x, start_y, grid=grid)
|
|
all_entities.append(player)
|
|
|
|
# Place enemies in other rooms
|
|
for i in range(1, min(6, len(rooms))):
|
|
room = rooms[i]
|
|
ex, ey = room.center()
|
|
enemy = EnemyEntity(ex, ey, grid=grid)
|
|
enemies.append(enemy)
|
|
all_entities.append(enemy)
|
|
|
|
# Place some boulders
|
|
for i in range(3):
|
|
room = random.choice(rooms[1:])
|
|
bx = random.randint(room.x1 + 1, room.x2 - 1)
|
|
by = random.randint(room.y1 + 1, room.y2 - 1)
|
|
if can_move_to(bx, by):
|
|
boulder = BoulderEntity(bx, by, grid=grid)
|
|
all_entities.append(boulder)
|
|
|
|
# Place a button and door in one of the rooms
|
|
if len(rooms) > 2:
|
|
button_room = rooms[-2]
|
|
door_room = rooms[-1]
|
|
|
|
# Place door at entrance to last room
|
|
dx, dy = door_room.center()
|
|
door = DoorEntity(dx, door_room.y1, grid=grid)
|
|
all_entities.append(door)
|
|
|
|
# Place button in second to last room
|
|
bx, by = button_room.center()
|
|
button = ButtonEntity(bx, by, target=door, grid=grid)
|
|
all_entities.append(button)
|
|
|
|
# Set grid perspective to player
|
|
grid.perspective = player
|
|
grid.center_x = (start_x + 0.5) * 16
|
|
grid.center_y = (start_y + 0.5) * 16
|
|
|
|
# Initial FOV calculation
|
|
update_fov()
|
|
|
|
# Add grid to scene
|
|
mcrfpy.sceneUI("tutorial").append(grid)
|
|
|
|
# Show instructions
|
|
title = mcrfpy.Caption((320, 10),
|
|
text="Part 5: Entity Interactions - WASD to move, bump enemies, push boulders!",
|
|
)
|
|
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
|
|
mcrfpy.sceneUI("tutorial").append(title)
|
|
|
|
print("Part 5: Entity Interactions - Tutorial loaded!")
|
|
print("- Bump into enemies to attack them")
|
|
print("- Push boulders by walking into them")
|
|
print("- Step on buttons to open doors")
|
|
print("- Enemies will pursue you when they can see you") |