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);