From b5eab85e7060e222d5b1d654a82729f7567a3fc7 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Mon, 21 Jul 2025 23:47:21 -0400 Subject: [PATCH 1/4] Convert UIGrid perspective from index to weak_ptr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major refactor of the perspective system to use entity references instead of indices: - Replaced `int perspective` with `std::weak_ptr perspective_entity` - Added `bool perspective_enabled` flag for explicit control - Direct entity assignment: `grid.perspective = player` - Automatic cleanup when entity is destroyed (weak_ptr becomes invalid) - No issues with collection reordering or entity removal - PythonObjectCache integration preserves Python derived classes API changes: - Old: `grid.perspective = 0` (index), `-1` for omniscient - New: `grid.perspective = entity` (object), `None` to clear - New: `grid.perspective_enabled` controls rendering mode Three rendering states: 1. `perspective_enabled = False`: Omniscient view (default) 2. `perspective_enabled = True` with valid entity: Entity's FOV 3. `perspective_enabled = True` with invalid entity: All black Also includes: - Part 3: Procedural dungeon generation with libtcod.line() - Part 4: Field of view with entity perspective switching 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- roguelike_tutorial/part_3.py | 299 +++++++++++++++++++++++++++++ roguelike_tutorial/part_4.py | 355 +++++++++++++++++++++++++++++++++++ src/UIGrid.cpp | 194 +++++++++++++------ src/UIGrid.h | 7 +- 4 files changed, 792 insertions(+), 63 deletions(-) create mode 100644 roguelike_tutorial/part_3.py create mode 100644 roguelike_tutorial/part_4.py diff --git a/roguelike_tutorial/part_3.py b/roguelike_tutorial/part_3.py new file mode 100644 index 0000000..a81a333 --- /dev/null +++ b/roguelike_tutorial/part_3.py @@ -0,0 +1,299 @@ +""" +McRogueFace Tutorial - Part 3: Procedural Dungeon Generation + +This tutorial builds on Part 2 by adding: +- Binary Space Partition (BSP) dungeon generation +- Rooms connected by hallways using libtcod.line() +- Walkable/non-walkable terrain +- Player spawning in a valid location +- Wall tiles that block movement + +Key code references: +- src/scripts/cos_level.py (lines 7-15, 184-217, 218-224) - BSP algorithm +- mcrfpy.libtcod.line() for smooth hallway generation +""" +import mcrfpy +import random + +# Create and activate a new scene +mcrfpy.createScene("tutorial") +mcrfpy.setScene("tutorial") + +# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile) +texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16) + +# Load the hero sprite texture +hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16) + +# Create a grid of tiles +grid_width, grid_height = 40, 30 # Larger grid for dungeon + +# Calculate the size in pixels to fit the entire grid on-screen +zoom = 2.0 +grid_size = grid_width * zoom * 16, grid_height * zoom * 16 + +# Calculate the position to center the grid on the screen +grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2 + +# Create the grid with a TCODMap for pathfinding/FOV +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] + +# Room class for BSP +class Room: + def __init__(self, x, y, w, h): + self.x1 = x + self.y1 = y + self.x2 = x + w + self.y2 = y + h + self.w = w + self.h = h + + def center(self): + """Return the center coordinates of the room""" + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return (center_x, center_y) + + def intersects(self, other): + """Return True if this room overlaps with another""" + return (self.x1 <= other.x2 and self.x2 >= other.x1 and + self.y1 <= other.y2 and self.y2 >= other.y1) + +# Dungeon generation functions +def carve_room(room): + """Carve out a room in the grid - referenced from cos_level.py lines 117-120""" + # Using individual updates for now (batch updates would be more efficient) + for x in range(room.x1, room.x2): + for y in range(room.y1, room.y2): + if 0 <= x < grid_width and 0 <= y < grid_height: + point = grid.at(x, y) + if point: + point.tilesprite = random.choice(FLOOR_TILES) + point.walkable = True + point.transparent = True + +def carve_hallway(x1, y1, x2, y2): + """Carve a hallway between two points using libtcod.line() + Referenced from cos_level.py lines 184-217, improved with libtcod.line() + """ + # Get all points along the line + points = mcrfpy.libtcod.line(x1, y1, x2, y2) + + # Carve out each point + for x, y in points: + if 0 <= x < grid_width and 0 <= y < grid_height: + point = grid.at(x, y) + if point: + point.tilesprite = random.choice(FLOOR_TILES) + point.walkable = True + point.transparent = True + +def generate_dungeon(max_rooms=10, room_min_size=4, room_max_size=10): + """Generate a dungeon using simplified BSP approach + Referenced from cos_level.py lines 218-224 + """ + rooms = [] + + # First, fill everything with walls + for y in range(grid_height): + for x in range(grid_width): + point = grid.at(x, y) + if point: + point.tilesprite = random.choice(WALL_TILES) + point.walkable = False + point.transparent = False + + # Generate rooms + for _ in range(max_rooms): + # Random room size + w = random.randint(room_min_size, room_max_size) + h = random.randint(room_min_size, room_max_size) + + # Random position (with margin from edges) + x = random.randint(1, grid_width - w - 1) + y = random.randint(1, grid_height - h - 1) + + new_room = Room(x, y, w, h) + + # Check if it overlaps with existing rooms + failed = False + for other_room in rooms: + if new_room.intersects(other_room): + failed = True + break + + if not failed: + # Carve out the room + carve_room(new_room) + + # If not the first room, connect to previous room + if rooms: + # Get centers + prev_x, prev_y = rooms[-1].center() + new_x, new_y = new_room.center() + + # Carve hallway using libtcod.line() + carve_hallway(prev_x, prev_y, new_x, new_y) + + rooms.append(new_room) + + return rooms + +# Generate the dungeon +rooms = generate_dungeon(max_rooms=8, room_min_size=4, room_max_size=8) + +# Add the grid to the scene +mcrfpy.sceneUI("tutorial").append(grid) + +# Spawn player in the first room +if rooms: + spawn_x, spawn_y = rooms[0].center() +else: + # Fallback spawn position + spawn_x, spawn_y = 4, 4 + +# Create a player entity at the spawn position +player = mcrfpy.Entity( + (spawn_x, spawn_y), + texture=hero_texture, + sprite_index=0 +) + +# Add the player entity to the grid +grid.entities.append(player) +grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 + +# Movement state tracking (from Part 2) +is_moving = False +move_queue = [] +current_destination = None +current_move = None + +# Store animation references +player_anim_x = None +player_anim_y = None +grid_anim_x = None +grid_anim_y = None + +def movement_complete(anim, target): + """Called when movement animation completes""" + global is_moving, move_queue, current_destination, current_move + global player_anim_x, player_anim_y + + is_moving = False + current_move = None + current_destination = None + player_anim_x = None + player_anim_y = None + + grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 + + if move_queue: + next_move = move_queue.pop(0) + process_move(next_move) + +motion_speed = 0.20 # Slightly faster for dungeon exploration + +def can_move_to(x, y): + """Check if a position is valid for movement""" + # Boundary check + if x < 0 or x >= grid_width or y < 0 or y >= grid_height: + return False + + # Walkability check + point = grid.at(x, y) + if point and point.walkable: + return True + return False + +def process_move(key): + """Process a move based on the key""" + global is_moving, current_move, current_destination, move_queue + global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y + + if is_moving: + move_queue.clear() + move_queue.append(key) + return + + px, py = int(player.x), int(player.y) + new_x, new_y = px, py + + 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 + + # Check if we can move to the new position + if new_x != px or new_y != py: + if can_move_to(new_x, new_y): + is_moving = True + current_move = key + current_destination = (new_x, new_y) + + if new_x != px: + player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete) + player_anim_x.start(player) + elif new_y != py: + player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad", callback=movement_complete) + player_anim_y.start(player) + + grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear") + grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear") + grid_anim_x.start(grid) + grid_anim_y.start(grid) + else: + # Play a "bump" sound or visual feedback here + print(f"Can't move to ({new_x}, {new_y}) - blocked!") + +def handle_keys(key, state): + """Handle keyboard input to move the player""" + if state == "start": + if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]: + process_move(key) + +# Register the keyboard handler +mcrfpy.keypressScene(handle_keys) + +# Add UI elements +title = mcrfpy.Caption((320, 10), + text="McRogueFace Tutorial - Part 3: Dungeon Generation", +) +title.fill_color = mcrfpy.Color(255, 255, 255, 255) +mcrfpy.sceneUI("tutorial").append(title) + +instructions = mcrfpy.Caption((150, 750), + text=f"Procedural dungeon with {len(rooms)} rooms connected by hallways!", +) +instructions.font_size = 18 +instructions.fill_color = mcrfpy.Color(200, 200, 200, 255) +mcrfpy.sceneUI("tutorial").append(instructions) + +# Debug info +debug_caption = mcrfpy.Caption((10, 40), + text=f"Grid: {grid_width}x{grid_height} | Player spawned at ({spawn_x}, {spawn_y})", +) +debug_caption.font_size = 16 +debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255) +mcrfpy.sceneUI("tutorial").append(debug_caption) + +print("Tutorial Part 3 loaded!") +print(f"Generated dungeon with {len(rooms)} rooms") +print(f"Player spawned at ({spawn_x}, {spawn_y})") +print("Walls now block movement!") +print("Use WASD or Arrow keys to explore the dungeon!") \ No newline at end of file diff --git a/roguelike_tutorial/part_4.py b/roguelike_tutorial/part_4.py new file mode 100644 index 0000000..0533fd0 --- /dev/null +++ b/roguelike_tutorial/part_4.py @@ -0,0 +1,355 @@ +""" +McRogueFace Tutorial - Part 4: Field of View + +This tutorial builds on Part 3 by adding: +- Field of view calculation using grid.compute_fov() +- Entity perspective rendering with grid.perspective +- Three visibility states: unexplored (black), explored (dark), visible (lit) +- Memory of previously seen areas +- Enemy entity to demonstrate perspective switching + +Key code references: +- tests/unit/test_tcod_fov_entities.py (lines 89-118) - FOV with multiple entities +- ROADMAP.md (lines 216-229) - FOV system implementation details +""" +import mcrfpy +import random + +# Create and activate a new scene +mcrfpy.createScene("tutorial") +mcrfpy.setScene("tutorial") + +# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile) +texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16) + +# Load the hero sprite texture +hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16) + +# Create a grid of tiles +grid_width, grid_height = 40, 30 + +# Calculate the size in pixels +zoom = 2.0 +grid_size = grid_width * zoom * 16, grid_height * zoom * 16 + +# Calculate the position to center the grid on the screen +grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2 + +# Create the grid with a TCODMap for pathfinding/FOV +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] + +# Room class for BSP +class Room: + def __init__(self, x, y, w, h): + self.x1 = x + self.y1 = y + self.x2 = x + w + self.y2 = y + h + self.w = w + self.h = h + + def center(self): + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return (center_x, center_y) + + def intersects(self, other): + return (self.x1 <= other.x2 and self.x2 >= other.x1 and + self.y1 <= other.y2 and self.y2 >= other.y1) + +# Dungeon generation functions (from Part 3) +def carve_room(room): + for x in range(room.x1, room.x2): + for y in range(room.y1, room.y2): + if 0 <= x < grid_width and 0 <= y < grid_height: + point = grid.at(x, y) + if point: + point.tilesprite = random.choice(FLOOR_TILES) + point.walkable = True + point.transparent = True + +def carve_hallway(x1, y1, x2, y2): + points = mcrfpy.libtcod.line(x1, y1, x2, y2) + + for x, y in points: + if 0 <= x < grid_width and 0 <= y < grid_height: + point = grid.at(x, y) + if point: + point.tilesprite = random.choice(FLOOR_TILES) + point.walkable = True + point.transparent = True + +def generate_dungeon(max_rooms=10, room_min_size=4, room_max_size=10): + rooms = [] + + # Fill with walls + for y in range(grid_height): + for x in range(grid_width): + point = grid.at(x, y) + if point: + point.tilesprite = random.choice(WALL_TILES) + point.walkable = False + point.transparent = False + + # Generate rooms + for _ in range(max_rooms): + w = random.randint(room_min_size, room_max_size) + h = random.randint(room_min_size, room_max_size) + x = random.randint(1, grid_width - w - 1) + y = random.randint(1, grid_height - h - 1) + + new_room = Room(x, y, w, h) + + failed = False + for other_room in rooms: + if new_room.intersects(other_room): + failed = True + break + + if not failed: + carve_room(new_room) + + if rooms: + prev_x, prev_y = rooms[-1].center() + new_x, new_y = new_room.center() + carve_hallway(prev_x, prev_y, new_x, new_y) + + rooms.append(new_room) + + return rooms + +# Generate the dungeon +rooms = generate_dungeon(max_rooms=8, room_min_size=4, room_max_size=8) + +# Add the grid to the scene +mcrfpy.sceneUI("tutorial").append(grid) + +# Spawn player in the first room +if rooms: + spawn_x, spawn_y = rooms[0].center() +else: + spawn_x, spawn_y = 4, 4 + +# Create a player entity +player = mcrfpy.Entity( + (spawn_x, spawn_y), + texture=hero_texture, + sprite_index=0 +) + +# Add the player entity to the grid +grid.entities.append(player) + +# Create an enemy entity in another room (to demonstrate perspective switching) +enemy = None +if len(rooms) > 1: + enemy_x, enemy_y = rooms[1].center() + enemy = mcrfpy.Entity( + (enemy_x, enemy_y), + texture=texture, + sprite_index=117 # Enemy sprite + ) + grid.entities.append(enemy) + +# Set the grid perspective to the player by default +# Note: The new perspective system uses entity references directly +grid.perspective = player + +# Initial FOV computation +def update_fov(): + """Update field of view from current perspective + Referenced from test_tcod_fov_entities.py lines 89-118 + """ + if grid.perspective == player: + grid.compute_fov(int(player.x), int(player.y), radius=8, algorithm=0) + elif enemy and grid.perspective == enemy: + grid.compute_fov(int(enemy.x), int(enemy.y), radius=6, algorithm=0) + +# Perform initial FOV calculation +update_fov() + +# Center grid on current perspective +def center_on_perspective(): + if grid.perspective == player: + grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 + elif enemy and grid.perspective == enemy: + grid.center = (enemy.x + 0.5) * 16, (enemy.y + 0.5) * 16 + +center_on_perspective() + +# Movement state tracking (from Part 3) +is_moving = False +move_queue = [] +current_destination = None +current_move = None + +# Store animation references +player_anim_x = None +player_anim_y = None +grid_anim_x = None +grid_anim_y = None + +def movement_complete(anim, target): + """Called when movement animation completes""" + global is_moving, move_queue, current_destination, current_move + global player_anim_x, player_anim_y + + is_moving = False + current_move = None + current_destination = None + player_anim_x = None + player_anim_y = None + + # Update FOV after movement + update_fov() + center_on_perspective() + + if move_queue: + next_move = move_queue.pop(0) + process_move(next_move) + +motion_speed = 0.20 + +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 + + point = grid.at(x, y) + if point and point.walkable: + return True + return False + +def process_move(key): + """Process a move based on the key""" + global is_moving, current_move, current_destination, move_queue + global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y + + # Only allow player movement when in player perspective + if grid.perspective != player: + return + + if is_moving: + move_queue.clear() + move_queue.append(key) + return + + px, py = int(player.x), int(player.y) + new_x, new_y = px, py + + 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 + + if new_x != px or new_y != py: + if can_move_to(new_x, new_y): + is_moving = True + current_move = key + current_destination = (new_x, new_y) + + if new_x != px: + player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete) + player_anim_x.start(player) + elif new_y != py: + player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad", callback=movement_complete) + player_anim_y.start(player) + + grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear") + grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear") + grid_anim_x.start(grid) + grid_anim_y.start(grid) + +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_move(key) + + # Perspective switching + elif key == "Tab": + # Switch perspective between player and enemy + if enemy: + if grid.perspective == player: + grid.perspective = enemy + print("Switched to enemy perspective") + else: + grid.perspective = player + print("Switched to player perspective") + + # Update FOV and camera for new perspective + update_fov() + center_on_perspective() + +# Register the keyboard handler +mcrfpy.keypressScene(handle_keys) + +# Add UI elements +title = mcrfpy.Caption((320, 10), + text="McRogueFace Tutorial - Part 4: Field of View", +) +title.fill_color = mcrfpy.Color(255, 255, 255, 255) +mcrfpy.sceneUI("tutorial").append(title) + +instructions = mcrfpy.Caption((150, 720), + text="Use WASD/Arrows to move. Press Tab to switch perspective!", +) +instructions.font_size = 18 +instructions.fill_color = mcrfpy.Color(200, 200, 200, 255) +mcrfpy.sceneUI("tutorial").append(instructions) + +# FOV info +fov_caption = mcrfpy.Caption((150, 745), + text="FOV: Player (radius 8) | Enemy visible in other room", +) +fov_caption.font_size = 16 +fov_caption.fill_color = mcrfpy.Color(100, 200, 255, 255) +mcrfpy.sceneUI("tutorial").append(fov_caption) + +# Debug info +debug_caption = mcrfpy.Caption((10, 40), + text=f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: Player", +) +debug_caption.font_size = 16 +debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255) +mcrfpy.sceneUI("tutorial").append(debug_caption) + +# Update function for perspective display +def update_perspective_display(): + current_perspective = "Player" if grid.perspective == player else "Enemy" + debug_caption.text = f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: {current_perspective}" + + if grid.perspective == player: + fov_caption.text = "FOV: Player (radius 8) | Tab to switch perspective" + else: + fov_caption.text = "FOV: Enemy (radius 6) | Tab to switch perspective" + +# Timer to update display +def update_display(runtime): + update_perspective_display() + +mcrfpy.setTimer("display_update", update_display, 100) + +print("Tutorial Part 4 loaded!") +print("Field of View system active!") +print("- Unexplored areas are black") +print("- Previously seen areas are dark") +print("- Currently visible areas are lit") +print("Press Tab to switch between player and enemy perspective!") +print("Use WASD or Arrow keys to move!") \ No newline at end of file diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index a282b6d..80faee2 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -2,13 +2,14 @@ #include "GameEngine.h" #include "McRFPy_API.h" #include "PythonObjectCache.h" +#include "UIEntity.h" #include // UIDrawable methods now in UIBase.h UIGrid::UIGrid() : grid_x(0), grid_y(0), zoom(1.0f), center_x(0.0f), center_y(0.0f), ptex(nullptr), fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr), - perspective(-1) // Default to omniscient view + perspective_enabled(false) // Default to omniscient view { // Initialize entities list entities = std::make_shared>>(); @@ -36,7 +37,7 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr _ptex, sf::Vector2f _x zoom(1.0f), ptex(_ptex), points(gx * gy), fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr), - perspective(-1) // Default to omniscient view + perspective_enabled(false) // Default to omniscient view { // Use texture dimensions if available, otherwise use defaults int cell_width = _ptex ? _ptex->sprite_width : DEFAULT_CELL_WIDTH; @@ -189,54 +190,78 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) // top layer - opacity for discovered / visible status based on perspective - // Only render visibility overlay if perspective is set (not omniscient) - if (perspective >= 0 && perspective < static_cast(entities->size())) { - // Get the entity whose perspective we're using - auto it = entities->begin(); - std::advance(it, perspective); - auto& entity = *it; + // Only render visibility overlay if perspective is enabled + if (perspective_enabled) { + auto entity = perspective_entity.lock(); // Create rectangle for overlays sf::RectangleShape overlay; overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom)); - for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0); - x < x_limit; - x+=1) - { - for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0); - y < y_limit; - y+=1) + if (entity) { + // Valid entity - use its gridstate for visibility + for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0); + x < x_limit; + x+=1) { - // Skip out-of-bounds cells - if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) continue; - - auto pixel_pos = sf::Vector2f( - (x*cell_width - left_spritepixels) * zoom, - (y*cell_height - top_spritepixels) * zoom ); + for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0); + y < y_limit; + y+=1) + { + // Skip out-of-bounds cells + if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) continue; + + auto pixel_pos = sf::Vector2f( + (x*cell_width - left_spritepixels) * zoom, + (y*cell_height - top_spritepixels) * zoom ); - // Get visibility state from entity's perspective - int idx = y * grid_x + x; - if (idx >= 0 && idx < static_cast(entity->gridstate.size())) { - const auto& state = entity->gridstate[idx]; + // Get visibility state from entity's perspective + int idx = y * grid_x + x; + if (idx >= 0 && idx < static_cast(entity->gridstate.size())) { + const auto& state = entity->gridstate[idx]; + + overlay.setPosition(pixel_pos); + + // Three overlay colors as specified: + if (!state.discovered) { + // Never seen - black + overlay.setFillColor(sf::Color(0, 0, 0, 255)); + renderTexture.draw(overlay); + } else if (!state.visible) { + // Discovered but not currently visible - dark gray + overlay.setFillColor(sf::Color(32, 32, 40, 192)); + renderTexture.draw(overlay); + } + // If visible and discovered, no overlay (fully visible) + } + } + } + } else { + // Invalid/destroyed entity with perspective_enabled = true + // Show all cells as undiscovered (black) + for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0); + x < x_limit; + x+=1) + { + for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0); + y < y_limit; + y+=1) + { + // Skip out-of-bounds cells + if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) continue; + + auto pixel_pos = sf::Vector2f( + (x*cell_width - left_spritepixels) * zoom, + (y*cell_height - top_spritepixels) * zoom ); overlay.setPosition(pixel_pos); - - // Three overlay colors as specified: - if (!state.discovered) { - // Never seen - black - overlay.setFillColor(sf::Color(0, 0, 0, 255)); - renderTexture.draw(overlay); - } else if (!state.visible) { - // Discovered but not currently visible - dark gray - overlay.setFillColor(sf::Color(32, 32, 40, 192)); - renderTexture.draw(overlay); - } - // If visible and discovered, no overlay (fully visible) + overlay.setFillColor(sf::Color(0, 0, 0, 255)); + renderTexture.draw(overlay); } } } } + // else: omniscient view (no overlays) // grid lines for testing & validation /* @@ -527,7 +552,7 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) { PyObject* click_handler = nullptr; float center_x = 0.0f, center_y = 0.0f; float zoom = 1.0f; - int perspective = -1; // perspective is a difficult __init__ arg; needs an entity in collection to work + // perspective is now handled via properties, not init args int visible = 1; float opacity = 1.0f; int z_index = 0; @@ -539,15 +564,15 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) { static const char* kwlist[] = { "pos", "size", "grid_size", "texture", // Positional args (as per spec) // Keyword-only args - "fill_color", "click", "center_x", "center_y", "zoom", "perspective", + "fill_color", "click", "center_x", "center_y", "zoom", "visible", "opacity", "z_index", "name", "x", "y", "w", "h", "grid_x", "grid_y", nullptr }; // Parse arguments with | for optional positional args - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOfffiifizffffii", const_cast(kwlist), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOfffifizffffii", const_cast(kwlist), &pos_obj, &size_obj, &grid_size_obj, &textureObj, // Positional - &fill_color, &click_handler, ¢er_x, ¢er_y, &zoom, &perspective, + &fill_color, &click_handler, ¢er_x, ¢er_y, &zoom, &visible, &opacity, &z_index, &name, &x, &y, &w, &h, &grid_x, &grid_y)) { return -1; } @@ -653,7 +678,8 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) { self->data->center_x = center_x; self->data->center_y = center_y; self->data->zoom = zoom; - self->data->perspective = perspective; + // perspective is now handled by perspective_entity and perspective_enabled + // self->data->perspective = perspective; self->data->visible = visible; self->data->opacity = opacity; self->data->z_index = z_index; @@ -941,33 +967,77 @@ int UIGrid::set_fill_color(PyUIGridObject* self, PyObject* value, void* closure) PyObject* UIGrid::get_perspective(PyUIGridObject* self, void* closure) { - return PyLong_FromLong(self->data->perspective); + auto locked = self->data->perspective_entity.lock(); + if (locked) { + // Check cache first to preserve derived class + if (locked->serial_number != 0) { + PyObject* cached = PythonObjectCache::getInstance().lookup(locked->serial_number); + if (cached) { + return cached; // Already INCREF'd by lookup + } + } + + // Legacy: If the entity has a stored Python object reference + if (locked->self != nullptr) { + Py_INCREF(locked->self); + return locked->self; + } + + // Otherwise, create a new base Entity object + auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); + auto o = (PyUIEntityObject*)type->tp_alloc(type, 0); + if (o) { + o->data = locked; + o->weakreflist = NULL; + Py_DECREF(type); + return (PyObject*)o; + } + Py_XDECREF(type); + } + Py_RETURN_NONE; } int UIGrid::set_perspective(PyUIGridObject* self, PyObject* value, void* closure) { - long perspective = PyLong_AsLong(value); - if (PyErr_Occurred()) { + if (value == Py_None) { + // Clear perspective but keep perspective_enabled unchanged + self->data->perspective_entity.reset(); + return 0; + } + + // Extract UIEntity from PyObject + // Get the Entity type from the module + auto entity_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); + if (!entity_type) { + PyErr_SetString(PyExc_RuntimeError, "Could not get Entity type from mcrfpy module"); return -1; } - // Validate perspective (-1 for omniscient, or valid entity index) - if (perspective < -1) { - PyErr_SetString(PyExc_ValueError, "perspective must be -1 (omniscient) or a valid entity index"); + if (!PyObject_IsInstance(value, entity_type)) { + Py_DECREF(entity_type); + PyErr_SetString(PyExc_TypeError, "perspective must be a UIEntity or None"); return -1; } + Py_DECREF(entity_type); - // Check if entity index is valid (if not omniscient) - if (perspective >= 0 && self->data->entities) { - int entity_count = self->data->entities->size(); - if (perspective >= entity_count) { - PyErr_Format(PyExc_IndexError, "perspective index %ld out of range (grid has %d entities)", - perspective, entity_count); - return -1; - } + PyUIEntityObject* entity_obj = (PyUIEntityObject*)value; + self->data->perspective_entity = entity_obj->data; + self->data->perspective_enabled = true; // Enable perspective when entity assigned + return 0; +} + +PyObject* UIGrid::get_perspective_enabled(PyUIGridObject* self, void* closure) +{ + return PyBool_FromLong(self->data->perspective_enabled); +} + +int UIGrid::set_perspective_enabled(PyUIGridObject* self, PyObject* value, void* closure) +{ + int enabled = PyObject_IsTrue(value); + if (enabled == -1) { + return -1; // Error occurred } - - self->data->perspective = perspective; + self->data->perspective_enabled = enabled; return 0; } @@ -1285,9 +1355,11 @@ PyGetSetDef UIGrid::getsetters[] = { {"texture", (getter)UIGrid::get_texture, NULL, "Texture of the grid", NULL}, //TODO 7DRL-day2-item5 {"fill_color", (getter)UIGrid::get_fill_color, (setter)UIGrid::set_fill_color, "Background fill color of the grid", NULL}, {"perspective", (getter)UIGrid::get_perspective, (setter)UIGrid::set_perspective, - "Entity perspective index for FOV rendering (-1 for omniscient view, 0+ for entity index). " - "When set to an entity index, only cells visible to that entity are rendered normally; " - "explored but not visible cells are darkened, and unexplored cells are black.", NULL}, + "Entity whose perspective to use for FOV rendering (None for omniscient view). " + "Setting an entity automatically enables perspective mode.", NULL}, + {"perspective_enabled", (getter)UIGrid::get_perspective_enabled, (setter)UIGrid::set_perspective_enabled, + "Whether to use perspective-based FOV rendering. When True with no valid entity, " + "all cells appear undiscovered.", NULL}, {"z_index", (getter)UIDrawable::get_int, (setter)UIDrawable::set_int, "Z-order for rendering (lower values rendered first)", (void*)PyObjectsEnum::UIGRID}, {"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UIGRID}, UIDRAWABLE_GETSETTERS, diff --git a/src/UIGrid.h b/src/UIGrid.h index af1c078..79f6cc1 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -77,8 +77,9 @@ public: // Background rendering sf::Color fill_color; - // Perspective system - which entity's view to render (-1 = omniscient/default) - int perspective; + // Perspective system - entity whose view to render + std::weak_ptr perspective_entity; // Weak reference to perspective entity + bool perspective_enabled; // Whether to use perspective rendering // Property system for animations bool setProperty(const std::string& name, float value) override; @@ -103,6 +104,8 @@ public: static int set_fill_color(PyUIGridObject* self, PyObject* value, void* closure); static PyObject* get_perspective(PyUIGridObject* self, void* closure); static int set_perspective(PyUIGridObject* self, PyObject* value, void* closure); + static PyObject* get_perspective_enabled(PyUIGridObject* self, void* closure); + static int set_perspective_enabled(PyUIGridObject* self, PyObject* value, void* closure); static PyObject* py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds); static PyObject* py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds); static PyObject* py_is_in_fov(PyUIGridObject* self, PyObject* args); From 7aef41234393dd979d26a96d2122ebed6d7c9812 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Tue, 22 Jul 2025 23:00:34 -0400 Subject: [PATCH 2/4] feat: Thread-safe FOV system with improved API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major improvements to the Field of View (FOV) system: 1. Added thread safety with mutex protection - Added mutable std::mutex fov_mutex to UIGrid class - Protected computeFOV() and isInFOV() with lock_guard - Minimal overhead for current single-threaded operation - Ready for future multi-threading requirements 2. Enhanced compute_fov() API to return visible cells - Changed return type from void to List[Tuple[int, int, bool, bool]] - Returns (x, y, visible, discovered) for all visible cells - Maintains backward compatibility by still updating internal FOV state - Allows FOV queries without affecting entity states 3. Fixed Part 4 tutorial visibility rendering - Added required entity.update_visibility() calls after compute_fov() - Fixed black grid issue in perspective rendering - Updated hallway generation to use L-shaped corridors The architecture now properly separates concerns while maintaining performance and preparing for future enhancements. Each entity can have independent FOV calculations without race conditions. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- roguelike_tutorial/part_3.py | 18 +++++++++-- roguelike_tutorial/part_4.py | 17 ++++++++-- src/UIGrid.cpp | 63 ++++++++++++++++++++++++++++++------ src/UIGrid.h | 2 ++ 4 files changed, 86 insertions(+), 14 deletions(-) diff --git a/roguelike_tutorial/part_3.py b/roguelike_tutorial/part_3.py index a81a333..cb48b8b 100644 --- a/roguelike_tutorial/part_3.py +++ b/roguelike_tutorial/part_3.py @@ -88,7 +88,21 @@ def carve_hallway(x1, y1, x2, y2): Referenced from cos_level.py lines 184-217, improved with libtcod.line() """ # Get all points along the line - points = mcrfpy.libtcod.line(x1, y1, x2, y2) + + # Simple solution: works if your characters have diagonal movement + #points = mcrfpy.libtcod.line(x1, y1, x2, y2) + + # We don't, so we're going to carve a path with an elbow in it + points = [] + if random.choice([True, False]): + # x1,y1 -> x2,y1 -> x2,y2 + points.extend(mcrfpy.libtcod.line(x1, y1, x2, y1)) + points.extend(mcrfpy.libtcod.line(x2, y1, x2, y2)) + else: + # x1,y1 -> x1,y2 -> x2,y2 + points.extend(mcrfpy.libtcod.line(x1, y1, x1, y2)) + points.extend(mcrfpy.libtcod.line(x1, y2, x2, y2)) + # Carve out each point for x, y in points: @@ -296,4 +310,4 @@ print("Tutorial Part 3 loaded!") print(f"Generated dungeon with {len(rooms)} rooms") print(f"Player spawned at ({spawn_x}, {spawn_y})") print("Walls now block movement!") -print("Use WASD or Arrow keys to explore the dungeon!") \ No newline at end of file +print("Use WASD or Arrow keys to explore the dungeon!") diff --git a/roguelike_tutorial/part_4.py b/roguelike_tutorial/part_4.py index 0533fd0..1934317 100644 --- a/roguelike_tutorial/part_4.py +++ b/roguelike_tutorial/part_4.py @@ -80,8 +80,17 @@ def carve_room(room): point.transparent = True def carve_hallway(x1, y1, x2, y2): - points = mcrfpy.libtcod.line(x1, y1, x2, y2) - + #points = mcrfpy.libtcod.line(x1, y1, x2, y2) + points = [] + if random.choice([True, False]): + # x1,y1 -> x2,y1 -> x2,y2 + points.extend(mcrfpy.libtcod.line(x1, y1, x2, y1)) + points.extend(mcrfpy.libtcod.line(x2, y1, x2, y2)) + else: + # x1,y1 -> x1,y2 -> x2,y2 + points.extend(mcrfpy.libtcod.line(x1, y1, x1, y2)) + points.extend(mcrfpy.libtcod.line(x1, y2, x2, y2)) + for x, y in points: if 0 <= x < grid_width and 0 <= y < grid_height: point = grid.at(x, y) @@ -173,8 +182,10 @@ def update_fov(): """ if grid.perspective == player: grid.compute_fov(int(player.x), int(player.y), radius=8, algorithm=0) + player.update_visibility() elif enemy and grid.perspective == enemy: grid.compute_fov(int(enemy.x), int(enemy.y), radius=6, algorithm=0) + enemy.update_visibility() # Perform initial FOV calculation update_fov() @@ -352,4 +363,4 @@ print("- Unexplored areas are black") print("- Previously seen areas are dark") print("- Currently visible areas are lit") print("Press Tab to switch between player and enemy perspective!") -print("Use WASD or Arrow keys to move!") \ No newline at end of file +print("Use WASD or Arrow keys to move!") diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 80faee2..62e8d22 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -341,6 +341,7 @@ void UIGrid::computeFOV(int x, int y, int radius, bool light_walls, TCOD_fov_alg { if (!tcod_map || x < 0 || x >= grid_x || y < 0 || y >= grid_y) return; + std::lock_guard lock(fov_mutex); tcod_map->computeFov(x, y, radius, light_walls, algo); } @@ -348,6 +349,7 @@ bool UIGrid::isInFOV(int x, int y) const { if (!tcod_map || x < 0 || x >= grid_x || y < 0 || y >= grid_y) return false; + std::lock_guard lock(fov_mutex); return tcod_map->isInFov(x, y); } @@ -1054,8 +1056,43 @@ PyObject* UIGrid::py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* return NULL; } + // Compute FOV self->data->computeFOV(x, y, radius, light_walls, (TCOD_fov_algorithm_t)algorithm); - Py_RETURN_NONE; + + // Build list of visible cells as tuples (x, y, visible, discovered) + PyObject* result_list = PyList_New(0); + if (!result_list) return NULL; + + // Iterate through grid and collect visible cells + for (int gy = 0; gy < self->data->grid_y; gy++) { + for (int gx = 0; gx < self->data->grid_x; gx++) { + if (self->data->isInFOV(gx, gy)) { + // Create tuple (x, y, visible, discovered) + PyObject* cell_tuple = PyTuple_New(4); + if (!cell_tuple) { + Py_DECREF(result_list); + return NULL; + } + + PyTuple_SET_ITEM(cell_tuple, 0, PyLong_FromLong(gx)); + PyTuple_SET_ITEM(cell_tuple, 1, PyLong_FromLong(gy)); + PyTuple_SET_ITEM(cell_tuple, 2, Py_True); // visible + PyTuple_SET_ITEM(cell_tuple, 3, Py_True); // discovered + Py_INCREF(Py_True); // Need to increment ref count for True + Py_INCREF(Py_True); + + // Append to list + if (PyList_Append(result_list, cell_tuple) < 0) { + Py_DECREF(cell_tuple); + Py_DECREF(result_list); + return NULL; + } + Py_DECREF(cell_tuple); // List now owns the reference + } + } + } + + return result_list; } PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args) @@ -1173,16 +1210,20 @@ PyObject* UIGrid::py_compute_astar_path(PyUIGridObject* self, PyObject* args, Py PyMethodDef UIGrid::methods[] = { {"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS}, {"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS, - "compute_fov(x: int, y: int, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> None\n\n" - "Compute field of view from a position.\n\n" + "compute_fov(x: int, y: int, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> List[Tuple[int, int, bool, bool]]\n\n" + "Compute field of view from a position and return visible cells.\n\n" "Args:\n" " x: X coordinate of the viewer\n" " y: Y coordinate of the viewer\n" " radius: Maximum view distance (0 = unlimited)\n" " light_walls: Whether walls are lit when visible\n" " algorithm: FOV algorithm to use (FOV_BASIC, FOV_DIAMOND, FOV_SHADOW, FOV_PERMISSIVE_0-8)\n\n" - "Updates the internal FOV state. Use is_in_fov() to check visibility after calling this.\n" - "When perspective is set, this also updates visibility overlays automatically."}, + "Returns:\n" + " List of tuples (x, y, visible, discovered) for all visible cells:\n" + " - x, y: Grid coordinates\n" + " - visible: True (all returned cells are visible)\n" + " - discovered: True (FOV implies discovery)\n\n" + "Also updates the internal FOV state for use with is_in_fov()."}, {"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS, "is_in_fov(x: int, y: int) -> bool\n\n" "Check if a cell is in the field of view.\n\n" @@ -1255,16 +1296,20 @@ PyMethodDef UIGrid_all_methods[] = { UIDRAWABLE_METHODS, {"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS}, {"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS, - "compute_fov(x: int, y: int, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> None\n\n" - "Compute field of view from a position.\n\n" + "compute_fov(x: int, y: int, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> List[Tuple[int, int, bool, bool]]\n\n" + "Compute field of view from a position and return visible cells.\n\n" "Args:\n" " x: X coordinate of the viewer\n" " y: Y coordinate of the viewer\n" " radius: Maximum view distance (0 = unlimited)\n" " light_walls: Whether walls are lit when visible\n" " algorithm: FOV algorithm to use (FOV_BASIC, FOV_DIAMOND, FOV_SHADOW, FOV_PERMISSIVE_0-8)\n\n" - "Updates the internal FOV state. Use is_in_fov() to check visibility after calling this.\n" - "When perspective is set, this also updates visibility overlays automatically."}, + "Returns:\n" + " List of tuples (x, y, visible, discovered) for all visible cells:\n" + " - x, y: Grid coordinates\n" + " - visible: True (all returned cells are visible)\n" + " - discovered: True (FOV implies discovery)\n\n" + "Also updates the internal FOV state for use with is_in_fov()."}, {"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS, "is_in_fov(x: int, y: int) -> bool\n\n" "Check if a cell is in the field of view.\n\n" diff --git a/src/UIGrid.h b/src/UIGrid.h index 79f6cc1..e8f9311 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -6,6 +6,7 @@ #include "Resources.h" #include #include +#include #include "PyCallable.h" #include "PyTexture.h" @@ -29,6 +30,7 @@ private: TCODMap* tcod_map; // TCOD map for FOV and pathfinding TCODDijkstra* tcod_dijkstra; // Dijkstra pathfinding TCODPath* tcod_path; // A* pathfinding + mutable std::mutex fov_mutex; // Mutex for thread-safe FOV operations public: UIGrid(); From 994e8d186e2596db47bc57752e11d4a4d3070f4b Mon Sep 17 00:00:00 2001 From: John McCardle Date: Wed, 23 Jul 2025 00:21:58 -0400 Subject: [PATCH 3/4] feat: Add Part 5 tutorial - Entity Interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements comprehensive entity interaction system: - Entity class hierarchy inheriting from mcrfpy.Entity - Non-blocking movement animations with destination tracking - Bump interactions (combat when hitting enemies, pushing boulders) - Step-on interactions (buttons that open doors) - Basic enemy AI with line-of-sight pursuit - Concurrent animation system (enemies move while player moves) Also fixes C++ animation system to support Python subclasses: - Changed PyAnimation::start() to use PyObject_IsInstance instead of strcmp - Now properly supports inherited entity classes - Animation system works with any subclass of Frame, Caption, Sprite, Grid, or Entity This completes the core gameplay mechanics needed for roguelike development. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- roguelike_tutorial/part_5.py | 625 +++++++++++++++++++++++++++++++++++ src/PyAnimation.cpp | 38 ++- 2 files changed, 654 insertions(+), 9 deletions(-) create mode 100644 roguelike_tutorial/part_5.py 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; } From 0938a53c4a250bbbe9d9f9e0c4963405b8817da7 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Tue, 29 Jul 2025 21:15:50 -0400 Subject: [PATCH 4/4] Tutorial part 4 and 5 --- roguelike_tutorial/part_4.py | 4 +- roguelike_tutorial/part_5.py | 816 ++++++++++++----------------------- 2 files changed, 279 insertions(+), 541 deletions(-) diff --git a/roguelike_tutorial/part_4.py b/roguelike_tutorial/part_4.py index 1934317..423cca8 100644 --- a/roguelike_tutorial/part_4.py +++ b/roguelike_tutorial/part_4.py @@ -166,8 +166,8 @@ if len(rooms) > 1: enemy_x, enemy_y = rooms[1].center() enemy = mcrfpy.Entity( (enemy_x, enemy_y), - texture=texture, - sprite_index=117 # Enemy sprite + texture=hero_texture, + sprite_index=0 # Enemy sprite ) grid.entities.append(enemy) diff --git a/roguelike_tutorial/part_5.py b/roguelike_tutorial/part_5.py index 374061e..a8d544d 100644 --- a/roguelike_tutorial/part_5.py +++ b/roguelike_tutorial/part_5.py @@ -1,447 +1,289 @@ """ -McRogueFace Tutorial - Part 5: Entity Interactions +McRogueFace Tutorial - Part 5: Interacting with other entities This tutorial builds on Part 4 by adding: -- Entity class hierarchy (PlayerEntity, EnemyEntity, BoulderEntity, ButtonEntity) +- Subclassing mcrfpy.Entity - 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 +# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile) texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16) +# Load the hero sprite texture +hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16) + # Create a grid of tiles grid_width, grid_height = 40, 30 + +# Calculate the size in pixels zoom = 2.0 grid_size = grid_width * zoom * 16, grid_height * zoom * 16 + +# Calculate the position to center the grid on the screen grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2 -# Create the grid +# Create the grid with a TCODMap for pathfinding/FOV 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) -# ============================================================================ - +# Room class for BSP class Room: - def __init__(self, x, y, width, height): + def __init__(self, x, y, w, h): self.x1 = x self.y1 = y - self.x2 = x + width - self.y2 = y + height - + self.x2 = x + w + self.y2 = y + h + self.w = w + self.h = h + def center(self): - return ((self.x1 + self.x2) // 2, (self.y1 + self.y2) // 2) - + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return (center_x, center_y) + 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) +# Dungeon generation functions (from Part 3) +def carve_room(room): + for x in range(room.x1, room.x2): + for y in range(room.y1, room.y2): + if 0 <= x < grid_width and 0 <= y < grid_height: + point = grid.at(x, y) + if point: + point.tilesprite = random.choice(FLOOR_TILES) + point.walkable = True + point.transparent = True -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 carve_hallway(x1, y1, x2, y2): + #points = mcrfpy.libtcod.line(x1, y1, x2, y2) + points = [] + if random.choice([True, False]): + # x1,y1 -> x2,y1 -> x2,y2 + points.extend(mcrfpy.libtcod.line(x1, y1, x2, y1)) + points.extend(mcrfpy.libtcod.line(x2, y1, x2, y2)) + else: + # x1,y1 -> x1,y2 -> x2,y2 + points.extend(mcrfpy.libtcod.line(x1, y1, x1, y2)) + points.extend(mcrfpy.libtcod.line(x1, y2, x2, y2)) -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) - + for x, y in points: + if 0 <= x < grid_width and 0 <= y < grid_height: + point = grid.at(x, y) + if point: + point.tilesprite = random.choice(FLOOR_TILES) + point.walkable = True + point.transparent = True + +def generate_dungeon(max_rooms=10, room_min_size=4, room_max_size=10): 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) + # Fill with walls + for y in range(grid_height): + for x in range(grid_width): + point = grid.at(x, y) + if point: + point.tilesprite = random.choice(WALL_TILES) + point.walkable = False + point.transparent = False + + # Generate rooms + for _ in range(max_rooms): + w = random.randint(room_min_size, room_max_size) + h = random.randint(room_min_size, room_max_size) + x = random.randint(1, grid_width - w - 1) + y = random.randint(1, 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 + failed = False + for other_room in rooms: + if new_room.intersects(other_room): + failed = True + break + + if not failed: + carve_room(new_room) - 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 + if rooms: + prev_x, prev_y = rooms[-1].center() + new_x, new_y = new_room.center() + carve_hallway(prev_x, prev_y, new_x, new_y) + + rooms.append(new_room) return rooms -# ============================================================================ -# Entity Management -# ============================================================================ +# Generate the dungeon +rooms = generate_dungeon(max_rooms=8, room_min_size=4, room_max_size=8) -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 +# Add the grid to the scene +mcrfpy.sceneUI("tutorial").append(grid) -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 +# Spawn player in the first room +if rooms: + spawn_x, spawn_y = rooms[0].center() +else: + spawn_x, spawn_y = 4, 4 + +class GameEntity(mcrfpy.Entity): + """An entity whose default behavior is to prevent others from moving into its tile.""" + + def __init__(self, x, y, walkable=False, **kwargs): + super().__init__(x=x, y=y, **kwargs) + self.walkable = walkable + self.dest_x = x + self.dest_y = y + self.is_moving = False + + 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): + return self.walkable # allow other's motion to proceed if entity is walkable + + def __repr__(self): + return f"<{self.__class__.__name__} x={self.x}, y={self.y}, sprite_index={self.sprite_index}>" + +class BumpableEntity(GameEntity): + def __init__(self, x, y, **kwargs): + super().__init__(x, y, **kwargs) + + def on_bump(self, other): + print(f"Watch it, {other}! You bumped into {self}!") + return False + +# Create a player entity +player = GameEntity( + spawn_x, spawn_y, + texture=hero_texture, + sprite_index=0 +) + +# Add the player entity to the grid +grid.entities.append(player) +for r in rooms: + enemy_x, enemy_y = r.center() + enemy = BumpableEntity( + enemy_x, enemy_y, + grid=grid, + texture=hero_texture, + sprite_index=0 # Enemy sprite + ) + +# Set the grid perspective to the player by default +# Note: The new perspective system uses entity references directly +grid.perspective = player + +# Initial FOV computation +def update_fov(): + """Update field of view from current perspective + Referenced from test_tcod_fov_entities.py lines 89-118 + """ + if grid.perspective == player: + grid.compute_fov(int(player.x), int(player.y), radius=8, algorithm=0) + player.update_visibility() + elif enemy and grid.perspective == enemy: + grid.compute_fov(int(enemy.x), int(enemy.y), radius=6, algorithm=0) + enemy.update_visibility() + +# Perform initial FOV calculation +update_fov() + +# Center grid on current perspective +def center_on_perspective(): + if grid.perspective == player: + grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 + elif enemy and grid.perspective == enemy: + grid.center = (enemy.x + 0.5) * 16, (enemy.y + 0.5) * 16 + +center_on_perspective() + +# Movement state tracking (from Part 3) +#is_moving = False # make it an entity property +move_queue = [] +current_destination = None +current_move = None + +# Store animation references +player_anim_x = None +player_anim_y = None +grid_anim_x = None +grid_anim_y = None + +def movement_complete(anim, target): + """Called when movement animation completes""" + global move_queue, current_destination, current_move + global player_anim_x, player_anim_y + + player.is_moving = False + current_move = None + current_destination = None + player_anim_x = None + player_anim_y = None + + # Update FOV after movement + update_fov() + center_on_perspective() + + if move_queue: + next_move = move_queue.pop(0) + process_move(next_move) + +motion_speed = 0.20 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() + point = grid.at(x, y) + if point and point.walkable: + for e in grid.entities: + if not e.walkable and (x, y) == e.get_position(): # blocking the way + e.on_bump(player) + return False + return True # all checks passed, no collision + return False + +def process_move(key): + """Process a move based on the key""" + global current_move, current_destination, move_queue + global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y + + # Only allow player movement when in player perspective + if grid.perspective != player: + return + + if player.is_moving: + move_queue.clear() + move_queue.append(key) + return + + px, py = int(player.x), int(player.y) new_x, new_y = px, py - # Calculate movement direction if key == "W" or key == "Up": new_y -= 1 elif key == "S" or key == "Down": @@ -450,176 +292,72 @@ def process_player_move(key): 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 new_x != px or new_y != py: + if can_move_to(new_x, new_y): + player.is_moving = True + current_move = key + current_destination = (new_x, new_y) - if enemy.is_moving: - continue # Skip if still animating + if new_x != px: + player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete) + player_anim_x.start(player) + elif new_y != py: + player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad", callback=movement_complete) + player_anim_y.start(player) - # 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 -# ============================================================================ + grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear") + grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear") + grid_anim_x.start(grid) + grid_anim_y.start(grid) 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 + process_move(key) + +# Register the keyboard 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 +# Add UI elements title = mcrfpy.Caption((320, 10), - text="Part 5: Entity Interactions - WASD to move, bump enemies, push boulders!", + text="McRogueFace Tutorial - Part 5: Entity Collision", ) 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 +instructions = mcrfpy.Caption((150, 720), + text="Use WASD/Arrows to move. Try to bump into the other entity!", +) +instructions.font_size = 18 +instructions.fill_color = mcrfpy.Color(200, 200, 200, 255) +mcrfpy.sceneUI("tutorial").append(instructions) + +# Debug info +debug_caption = mcrfpy.Caption((10, 40), + text=f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: Player", +) +debug_caption.font_size = 16 +debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255) +mcrfpy.sceneUI("tutorial").append(debug_caption) + +# Update function for perspective display +def update_perspective_display(): + current_perspective = "Player" if grid.perspective == player else "Enemy" + debug_caption.text = f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: {current_perspective}" + +# Timer to update display +def update_display(runtime): + update_perspective_display() + +mcrfpy.setTimer("display_update", update_display, 100) + +print("Tutorial Part 4 loaded!") +print("Field of View system active!") +print("- Unexplored areas are black") +print("- Previously seen areas are dark") +print("- Currently visible areas are lit") +print("Press Tab to switch between player and enemy perspective!") +print("Use WASD or Arrow keys to move!")