diff --git a/roguelike_tutorial/part_5.py b/roguelike_tutorial/part_5.py new file mode 100644 index 0000000..374061e --- /dev/null +++ b/roguelike_tutorial/part_5.py @@ -0,0 +1,625 @@ +""" +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") \ No newline at end of file diff --git a/src/PyAnimation.cpp b/src/PyAnimation.cpp index d45c6eb..c81a2ea 100644 --- a/src/PyAnimation.cpp +++ b/src/PyAnimation.cpp @@ -138,47 +138,67 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) { return NULL; } - // Check type by comparing type names - const char* type_name = Py_TYPE(target_obj)->tp_name; + // Get type objects from the module to ensure they're initialized + PyObject* frame_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame"); + PyObject* caption_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption"); + PyObject* sprite_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite"); + PyObject* grid_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid"); + PyObject* entity_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); - if (strcmp(type_name, "mcrfpy.Frame") == 0) { + bool handled = false; + + // Use PyObject_IsInstance to support inheritance + if (frame_type && PyObject_IsInstance(target_obj, frame_type)) { PyUIFrameObject* frame = (PyUIFrameObject*)target_obj; if (frame->data) { self->data->start(frame->data); AnimationManager::getInstance().addAnimation(self->data); + handled = true; } } - else if (strcmp(type_name, "mcrfpy.Caption") == 0) { + else if (caption_type && PyObject_IsInstance(target_obj, caption_type)) { PyUICaptionObject* caption = (PyUICaptionObject*)target_obj; if (caption->data) { self->data->start(caption->data); AnimationManager::getInstance().addAnimation(self->data); + handled = true; } } - else if (strcmp(type_name, "mcrfpy.Sprite") == 0) { + else if (sprite_type && PyObject_IsInstance(target_obj, sprite_type)) { PyUISpriteObject* sprite = (PyUISpriteObject*)target_obj; if (sprite->data) { self->data->start(sprite->data); AnimationManager::getInstance().addAnimation(self->data); + handled = true; } } - else if (strcmp(type_name, "mcrfpy.Grid") == 0) { + else if (grid_type && PyObject_IsInstance(target_obj, grid_type)) { PyUIGridObject* grid = (PyUIGridObject*)target_obj; if (grid->data) { self->data->start(grid->data); AnimationManager::getInstance().addAnimation(self->data); + handled = true; } } - else if (strcmp(type_name, "mcrfpy.Entity") == 0) { + else if (entity_type && PyObject_IsInstance(target_obj, entity_type)) { // Special handling for Entity since it doesn't inherit from UIDrawable PyUIEntityObject* entity = (PyUIEntityObject*)target_obj; if (entity->data) { self->data->startEntity(entity->data); AnimationManager::getInstance().addAnimation(self->data); + handled = true; } } - else { - PyErr_SetString(PyExc_TypeError, "Target must be a Frame, Caption, Sprite, Grid, or Entity"); + + // Clean up references + Py_XDECREF(frame_type); + Py_XDECREF(caption_type); + Py_XDECREF(sprite_type); + Py_XDECREF(grid_type); + Py_XDECREF(entity_type); + + if (!handled) { + PyErr_SetString(PyExc_TypeError, "Target must be a Frame, Caption, Sprite, Grid, or Entity (or a subclass of these)"); return NULL; }