Convert UIGrid perspective from index to weak_ptr<UIEntity>

Major refactor of the perspective system to use entity references instead of indices:

- Replaced `int perspective` with `std::weak_ptr<UIEntity> 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 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-07-21 23:47:21 -04:00
parent bd6407db29
commit b5eab85e70
4 changed files with 792 additions and 63 deletions

View File

@ -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!")

View File

@ -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!")

View File

@ -2,13 +2,14 @@
#include "GameEngine.h"
#include "McRFPy_API.h"
#include "PythonObjectCache.h"
#include "UIEntity.h"
#include <algorithm>
// 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<std::list<std::shared_ptr<UIEntity>>>();
@ -36,7 +37,7 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr<PyTexture> _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<int>(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<int>(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<int>(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<char**>(kwlist),
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOfffifizffffii", const_cast<char**>(kwlist),
&pos_obj, &size_obj, &grid_size_obj, &textureObj, // Positional
&fill_color, &click_handler, &center_x, &center_y, &zoom, &perspective,
&fill_color, &click_handler, &center_x, &center_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,

View File

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