McRogueFace/src/UIGrid.cpp
John McCardle 726a9cf09d Mobile-"ish" emscripten support
Full screen "wasm-game" for viewport compatibility between desktop and
web interfaces. Viewport modes ("fit", "center", and "stretch") should
now work the same way under WASM/SDL and SFML. This should also enable
android or web-for-mobile aspect ratios to be supported more easily.
2026-02-09 08:40:34 -05:00

3238 lines
123 KiB
C++

#include "UIGrid.h"
#include "UIGridPathfinding.h" // New pathfinding API
#include "GameEngine.h"
#include "McRFPy_API.h"
#include "PythonObjectCache.h"
#include "PyAlignment.h"
#include "PyTypeCache.h" // Thread-safe cached Python types
#include "UIEntity.h"
#include "Profiler.h"
#include "PyFOV.h"
#include "PyPositionHelper.h" // For standardized position argument parsing
#include "PyVector.h" // #179, #181 - For Vector return types
#include "PyHeightMap.h" // #199 - HeightMap application methods
#include "PyShader.h" // #106: Shader support
#include "PyUniformCollection.h" // #106: Uniform collection support
#include "PyMouseButton.h" // For MouseButton enum
#include "PyInputState.h" // For InputState enum
#include <algorithm>
#include <cmath> // #142 - for std::floor, std::isnan
#include <cstring> // #150 - for strcmp
#include <limits> // #169 - for std::numeric_limits
// UIDrawable methods now in UIBase.h
// UIEntityCollection code moved to UIEntityCollection.cpp
UIGrid::UIGrid()
: grid_w(0), grid_h(0), zoom(1.0f), center_x(0.0f), center_y(0.0f), ptex(nullptr),
fill_color(8, 8, 8, 255), tcod_map(nullptr),
perspective_enabled(false), fov_algorithm(FOV_BASIC), fov_radius(10),
use_chunks(false) // Default to omniscient view
{
// Initialize entities list
entities = std::make_shared<std::list<std::shared_ptr<UIEntity>>>();
// Initialize children collection (for UIDrawables like speech bubbles, effects)
children = std::make_shared<std::vector<std::shared_ptr<UIDrawable>>>();
// Initialize box with safe defaults
box.setSize(sf::Vector2f(0, 0));
position = sf::Vector2f(0, 0); // Set base class position
box.setPosition(position); // Sync box position
box.setFillColor(sf::Color(0, 0, 0, 0));
// #228 - Initialize render texture to game resolution (small default until game init)
renderTexture.create(1, 1);
renderTextureSize = {1, 1};
// Initialize output sprite
output.setTextureRect(sf::IntRect(0, 0, 0, 0));
output.setPosition(0, 0);
output.setTexture(renderTexture.getTexture());
// Points vector starts empty (grid_w * grid_h = 0)
// TCOD map will be created when grid is resized
}
UIGrid::UIGrid(int gx, int gy, std::shared_ptr<PyTexture> _ptex, sf::Vector2f _xy, sf::Vector2f _wh)
: grid_w(gx), grid_h(gy),
zoom(1.0f),
ptex(_ptex),
fill_color(8, 8, 8, 255), tcod_map(nullptr),
perspective_enabled(false), fov_algorithm(FOV_BASIC), fov_radius(10),
use_chunks(gx > CHUNK_THRESHOLD || gy > CHUNK_THRESHOLD) // #123 - Use chunks for large grids
{
// Use texture dimensions if available, otherwise use defaults
int cell_width = _ptex ? _ptex->sprite_width : DEFAULT_CELL_WIDTH;
int cell_height = _ptex ? _ptex->sprite_height : DEFAULT_CELL_HEIGHT;
center_x = (gx/2) * cell_width;
center_y = (gy/2) * cell_height;
entities = std::make_shared<std::list<std::shared_ptr<UIEntity>>>();
// Initialize children collection (for UIDrawables like speech bubbles, effects)
children = std::make_shared<std::vector<std::shared_ptr<UIDrawable>>>();
box.setSize(_wh);
position = _xy; // Set base class position
box.setPosition(position); // Sync box position
box.setFillColor(sf::Color(0,0,0,0));
// #228 - create renderTexture sized to game resolution (dynamically resized as needed)
ensureRenderTextureSize();
// Only initialize sprite if texture is available
if (ptex) {
sprite = ptex->sprite(0);
}
output.setTextureRect(
sf::IntRect(0, 0,
box.getSize().x, box.getSize().y));
output.setPosition(box.getPosition());
// textures are upside-down inside renderTexture
output.setTexture(renderTexture.getTexture());
// Create TCOD map for FOV and as source for pathfinding
tcod_map = new TCODMap(gx, gy);
// Note: DijkstraMap objects are created on-demand via get_dijkstra_map()
// A* paths are computed on-demand via find_path()
// #123 - Initialize storage based on grid size
if (use_chunks) {
// Large grid: use chunk-based storage
chunk_manager = std::make_unique<ChunkManager>(gx, gy, this);
// Initialize all cells with parent reference
for (int cy = 0; cy < chunk_manager->chunks_y; ++cy) {
for (int cx = 0; cx < chunk_manager->chunks_x; ++cx) {
GridChunk* chunk = chunk_manager->getChunk(cx, cy);
if (!chunk) continue;
for (int ly = 0; ly < chunk->height; ++ly) {
for (int lx = 0; lx < chunk->width; ++lx) {
auto& cell = chunk->at(lx, ly);
cell.grid_x = chunk->world_x + lx;
cell.grid_y = chunk->world_y + ly;
cell.parent_grid = this;
}
}
}
}
} else {
// Small grid: use flat storage (original behavior)
points.resize(gx * gy);
for (int y = 0; y < gy; y++) {
for (int x = 0; x < gx; x++) {
int idx = y * gx + x;
points[idx].grid_x = x;
points[idx].grid_y = y;
points[idx].parent_grid = this;
}
}
}
// Initial sync of TCOD map
syncTCODMap();
}
void UIGrid::update() {}
void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
{
// Profile total grid rendering time
ScopedTimer gridTimer(Resources::game->metrics.gridRenderTime);
// Check visibility
if (!visible) return;
// #228 - Ensure renderTexture matches current game resolution
ensureRenderTextureSize();
// TODO: Apply opacity to output sprite
// Get cell dimensions - use texture if available, otherwise defaults
int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
// Determine if we need camera rotation handling
bool has_camera_rotation = (camera_rotation != 0.0f);
float grid_w_px = box.getSize().x;
float grid_h_px = box.getSize().y;
// Calculate AABB for rotated view (if camera rotation is active)
float rad = camera_rotation * (M_PI / 180.0f);
float cos_r = std::cos(rad);
float sin_r = std::sin(rad);
float abs_cos = std::abs(cos_r);
float abs_sin = std::abs(sin_r);
// AABB dimensions of the rotated viewport
float aabb_w = grid_w_px * abs_cos + grid_h_px * abs_sin;
float aabb_h = grid_w_px * abs_sin + grid_h_px * abs_cos;
// Choose which texture to render to
sf::RenderTexture* activeTexture = &renderTexture;
if (has_camera_rotation) {
// Ensure rotation texture is large enough
unsigned int needed_size = static_cast<unsigned int>(std::max(aabb_w, aabb_h) + 1);
if (rotationTextureSize < needed_size) {
rotationTexture.create(needed_size, needed_size);
rotationTextureSize = needed_size;
}
activeTexture = &rotationTexture;
activeTexture->clear(fill_color);
} else {
output.setPosition(box.getPosition() + offset);
output.setTextureRect(sf::IntRect(0, 0, grid_w_px, grid_h_px));
renderTexture.clear(fill_color);
}
// Calculate visible tile range
// For camera rotation, use AABB dimensions; otherwise use grid dimensions
float render_w = has_camera_rotation ? aabb_w : grid_w_px;
float render_h = has_camera_rotation ? aabb_h : grid_h_px;
float center_x_sq = center_x / cell_width;
float center_y_sq = center_y / cell_height;
float width_sq = render_w / (cell_width * zoom);
float height_sq = render_h / (cell_height * zoom);
float left_edge = center_x_sq - (width_sq / 2.0);
float top_edge = center_y_sq - (height_sq / 2.0);
int left_spritepixels = center_x - (render_w / 2.0 / zoom);
int top_spritepixels = center_y - (render_h / 2.0 / zoom);
int x_limit = left_edge + width_sq + 2;
if (x_limit > grid_w) x_limit = grid_w;
int y_limit = top_edge + height_sq + 2;
if (y_limit > grid_h) y_limit = grid_h;
// #150 - Layers are now the sole source of grid rendering (base layer removed)
// Render layers with z_index < 0 (below entities)
sortLayers();
for (auto& layer : layers) {
if (layer->z_index >= 0) break; // Stop at layers that go above entities
layer->render(*activeTexture, left_spritepixels, top_spritepixels,
left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height);
}
// middle layer - entities
// disabling entity rendering until I can render their UISprite inside the rendertexture (not directly to window)
{
ScopedTimer entityTimer(Resources::game->metrics.entityRenderTime);
int entitiesRendered = 0;
int totalEntities = entities->size();
for (auto e : *entities) {
// Skip out-of-bounds entities for performance
// Check if entity is within visible bounds (with 1 cell margin for partially visible entities)
if (e->position.x < left_edge - 1 || e->position.x >= left_edge + width_sq + 1 ||
e->position.y < top_edge - 1 || e->position.y >= top_edge + height_sq + 1) {
continue; // Skip this entity as it's not visible
}
//auto drawent = e->cGrid->indexsprite.drawable();
auto& drawent = e->sprite;
//drawent.setScale(zoom, zoom);
drawent.setScale(sf::Vector2f(zoom, zoom));
auto pixel_pos = sf::Vector2f(
(e->position.x*cell_width - left_spritepixels) * zoom,
(e->position.y*cell_height - top_spritepixels) * zoom );
drawent.render(pixel_pos, *activeTexture);
entitiesRendered++;
}
// Record entity rendering stats
Resources::game->metrics.entitiesRendered += entitiesRendered;
Resources::game->metrics.totalEntities += totalEntities;
}
// #147 - Render dynamic layers with z_index >= 0 (above entities)
for (auto& layer : layers) {
if (layer->z_index < 0) continue; // Skip layers below entities
layer->render(*activeTexture, left_spritepixels, top_spritepixels,
left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height);
}
// Children layer - UIDrawables in grid-world pixel coordinates
// Positioned between entities and FOV overlay for proper z-ordering
if (children && !children->empty()) {
// Sort by z_index if needed
if (children_need_sort) {
std::sort(children->begin(), children->end(),
[](const auto& a, const auto& b) { return a->z_index < b->z_index; });
children_need_sort = false;
}
for (auto& child : *children) {
if (!child->visible) continue;
// Cull children outside visible region (convert pixel pos to cell coords)
float child_grid_x = child->position.x / cell_width;
float child_grid_y = child->position.y / cell_height;
if (child_grid_x < left_edge - 2 || child_grid_x >= left_edge + width_sq + 2 ||
child_grid_y < top_edge - 2 || child_grid_y >= top_edge + height_sq + 2) {
continue; // Not visible, skip rendering
}
// Transform grid-world pixel position to RenderTexture pixel position
auto pixel_pos = sf::Vector2f(
(child->position.x - left_spritepixels) * zoom,
(child->position.y - top_spritepixels) * zoom
);
child->render(pixel_pos, *activeTexture);
}
}
// top layer - opacity for discovered / visible status based on perspective
// Only render visibility overlay if perspective is enabled
if (perspective_enabled) {
ScopedTimer fovTimer(Resources::game->metrics.fovOverlayTime);
auto entity = perspective_entity.lock();
// Create rectangle for overlays
sf::RectangleShape overlay;
overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));
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)
{
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_w || y < 0 || y >= grid_h) 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_w + 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));
activeTexture->draw(overlay);
} else if (!state.visible) {
// Discovered but not currently visible - dark gray
overlay.setFillColor(sf::Color(32, 32, 40, 192));
activeTexture->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_w || y < 0 || y >= grid_h) continue;
auto pixel_pos = sf::Vector2f(
(x*cell_width - left_spritepixels) * zoom,
(y*cell_height - top_spritepixels) * zoom );
overlay.setPosition(pixel_pos);
overlay.setFillColor(sf::Color(0, 0, 0, 255));
activeTexture->draw(overlay);
}
}
}
}
// else: omniscient view (no overlays)
// grid lines for testing & validation
/*
sf::Vertex line[] =
{
sf::Vertex(sf::Vector2f(0, 0), sf::Color::Red),
sf::Vertex(box.getSize(), sf::Color::Red),
};
renderTexture.draw(line, 2, sf::Lines);
sf::Vertex lineb[] =
{
sf::Vertex(sf::Vector2f(0, box.getSize().y), sf::Color::Blue),
sf::Vertex(sf::Vector2f(box.getSize().x, 0), sf::Color::Blue),
};
renderTexture.draw(lineb, 2, sf::Lines);
*/
// Finalize the active texture
activeTexture->display();
// If camera rotation was used, rotate and blit to the grid's renderTexture
if (has_camera_rotation) {
// Clear the final renderTexture with fill color
renderTexture.clear(fill_color);
// Create sprite from the larger rotated texture
sf::Sprite rotatedSprite(rotationTexture.getTexture());
// Set origin to center of the rendered content
float tex_center_x = aabb_w / 2.0f;
float tex_center_y = aabb_h / 2.0f;
rotatedSprite.setOrigin(tex_center_x, tex_center_y);
// Apply rotation
rotatedSprite.setRotation(camera_rotation);
// Position so the rotated center lands at the viewport center
rotatedSprite.setPosition(grid_w_px / 2.0f, grid_h_px / 2.0f);
// Set texture rect to only use the AABB portion (texture may be larger)
rotatedSprite.setTextureRect(sf::IntRect(0, 0, static_cast<int>(aabb_w), static_cast<int>(aabb_h)));
// Draw to the grid's renderTexture (which clips to grid bounds)
renderTexture.draw(rotatedSprite);
renderTexture.display();
// Set up output sprite
output.setPosition(box.getPosition() + offset);
output.setTextureRect(sf::IntRect(0, 0, grid_w_px, grid_h_px));
}
// Apply viewport rotation (UIDrawable::rotation) to the entire grid widget
if (rotation != 0.0f) {
output.setOrigin(origin);
output.setRotation(rotation);
// Adjust position to account for origin offset
output.setPosition(box.getPosition() + offset + origin);
} else {
output.setOrigin(0, 0);
output.setRotation(0);
// Position already set above
}
// #106: Apply shader if set
if (shader && shader->shader) {
sf::Vector2f resolution(box.getSize().x, box.getSize().y);
PyShader::applyEngineUniforms(*shader->shader, resolution);
// Apply user uniforms
if (uniforms) {
uniforms->applyTo(*shader->shader);
}
target.draw(output, shader->shader.get());
} else {
target.draw(output);
}
}
UIGridPoint& UIGrid::at(int x, int y)
{
// #123 - Route through chunk manager for large grids
if (use_chunks && chunk_manager) {
return chunk_manager->at(x, y);
}
return points[y * grid_w + x];
}
UIGrid::~UIGrid()
{
// Clear Dijkstra maps first (they reference tcod_map)
dijkstra_maps.clear();
if (tcod_map) {
delete tcod_map;
tcod_map = nullptr;
}
}
void UIGrid::ensureRenderTextureSize()
{
// Get game resolution (or use sensible defaults during early init)
sf::Vector2u resolution{1920, 1080};
if (Resources::game) {
resolution = Resources::game->getGameResolution();
}
// Clamp to reasonable maximum (SFML texture size limits)
unsigned int required_w = std::min(resolution.x, 4096u);
unsigned int required_h = std::min(resolution.y, 4096u);
// Only recreate if size changed
if (renderTextureSize.x != required_w || renderTextureSize.y != required_h) {
renderTexture.create(required_w, required_h);
renderTextureSize = {required_w, required_h};
output.setTexture(renderTexture.getTexture());
}
}
PyObjectsEnum UIGrid::derived_type()
{
return PyObjectsEnum::UIGRID;
}
// #147 - Layer management methods
std::shared_ptr<ColorLayer> UIGrid::addColorLayer(int z_index, const std::string& name) {
auto layer = std::make_shared<ColorLayer>(z_index, grid_w, grid_h, this);
layer->name = name;
layers.push_back(layer);
layers_need_sort = true;
return layer;
}
std::shared_ptr<TileLayer> UIGrid::addTileLayer(int z_index, std::shared_ptr<PyTexture> texture, const std::string& name) {
auto layer = std::make_shared<TileLayer>(z_index, grid_w, grid_h, this, texture);
layer->name = name;
layers.push_back(layer);
layers_need_sort = true;
return layer;
}
std::shared_ptr<GridLayer> UIGrid::getLayerByName(const std::string& name) {
for (auto& layer : layers) {
if (layer->name == name) {
return layer;
}
}
return nullptr;
}
bool UIGrid::isProtectedLayerName(const std::string& name) {
// #150 - These names are reserved for GridPoint pathfinding properties
static const std::vector<std::string> protected_names = {
"walkable", "transparent"
};
for (const auto& pn : protected_names) {
if (name == pn) return true;
}
return false;
}
void UIGrid::removeLayer(std::shared_ptr<GridLayer> layer) {
auto it = std::find(layers.begin(), layers.end(), layer);
if (it != layers.end()) {
layers.erase(it);
}
}
void UIGrid::sortLayers() {
if (layers_need_sort) {
std::sort(layers.begin(), layers.end(),
[](const auto& a, const auto& b) { return a->z_index < b->z_index; });
layers_need_sort = false;
}
}
// TCOD integration methods
void UIGrid::syncTCODMap()
{
if (!tcod_map) return;
for (int y = 0; y < grid_h; y++) {
for (int x = 0; x < grid_w; x++) {
const UIGridPoint& point = at(x, y);
tcod_map->setProperties(x, y, point.transparent, point.walkable);
}
}
}
void UIGrid::syncTCODMapCell(int x, int y)
{
if (!tcod_map || x < 0 || x >= grid_w || y < 0 || y >= grid_h) return;
const UIGridPoint& point = at(x, y);
tcod_map->setProperties(x, y, point.transparent, point.walkable);
}
void UIGrid::computeFOV(int x, int y, int radius, bool light_walls, TCOD_fov_algorithm_t algo)
{
if (!tcod_map || x < 0 || x >= grid_w || y < 0 || y >= grid_h) return;
std::lock_guard<std::mutex> lock(fov_mutex);
tcod_map->computeFov(x, y, radius, light_walls, algo);
}
bool UIGrid::isInFOV(int x, int y) const
{
if (!tcod_map || x < 0 || x >= grid_w || y < 0 || y >= grid_h) return false;
std::lock_guard<std::mutex> lock(fov_mutex);
return tcod_map->isInFov(x, y);
}
// Pathfinding methods moved to UIGridPathfinding.cpp
// - Grid.find_path() returns AStarPath objects
// - Grid.get_dijkstra_map() returns DijkstraMap objects (cached)
// Phase 1 implementations
sf::FloatRect UIGrid::get_bounds() const
{
auto size = box.getSize();
return sf::FloatRect(position.x, position.y, size.x, size.y);
}
void UIGrid::move(float dx, float dy)
{
position.x += dx;
position.y += dy;
box.setPosition(position); // Keep box in sync
output.setPosition(position); // Keep output sprite in sync too
}
void UIGrid::resize(float w, float h)
{
box.setSize(sf::Vector2f(w, h));
// Recreate render texture with new size
if (w > 0 && h > 0) {
renderTexture.create(static_cast<unsigned int>(w), static_cast<unsigned int>(h));
output.setTexture(renderTexture.getTexture());
}
// Notify aligned children to recalculate their positions
if (children) {
for (auto& child : *children) {
if (child->getAlignment() != AlignmentType::NONE) {
child->applyAlignment();
}
}
}
}
void UIGrid::onPositionChanged()
{
// Sync box and output sprite positions with base class position
box.setPosition(position);
output.setPosition(position);
}
std::shared_ptr<PyTexture> UIGrid::getTexture()
{
return ptex;
}
UIDrawable* UIGrid::click_at(sf::Vector2f point)
{
// Check grid bounds first
if (!box.getGlobalBounds().contains(point)) {
return nullptr;
}
// Transform to local coordinates
sf::Vector2f localPoint = point - box.getPosition();
// Get cell dimensions
int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
// Calculate visible area parameters (from render function)
float center_x_sq = center_x / cell_width;
float center_y_sq = center_y / cell_height;
float width_sq = box.getSize().x / (cell_width * zoom);
float height_sq = box.getSize().y / (cell_height * zoom);
int left_spritepixels = center_x - (box.getSize().x / 2.0 / zoom);
int top_spritepixels = center_y - (box.getSize().y / 2.0 / zoom);
// Convert click position to grid-world pixel coordinates
float grid_world_x = localPoint.x / zoom + left_spritepixels;
float grid_world_y = localPoint.y / zoom + top_spritepixels;
// Convert to grid cell coordinates
float grid_x = grid_world_x / cell_width;
float grid_y = grid_world_y / cell_height;
// Check children first (they render on top, so they get priority)
// Children are positioned in grid-world pixel coordinates
if (children && !children->empty()) {
// Check in reverse z-order (highest z_index first, rendered last = on top)
for (auto it = children->rbegin(); it != children->rend(); ++it) {
auto& child = *it;
if (!child->visible) continue;
// Transform click to child's local coordinate space
// Children's position is in grid-world pixels
sf::Vector2f childLocalPoint = sf::Vector2f(grid_world_x, grid_world_y);
if (auto target = child->click_at(childLocalPoint)) {
return target;
}
}
}
// Check entities in reverse order (assuming they should be checked top to bottom)
// Note: entities list is not sorted by z-index currently, but we iterate in reverse
// to match the render order assumption
if (entities) {
for (auto it = entities->rbegin(); it != entities->rend(); ++it) {
auto& entity = *it;
if (!entity || !entity->sprite.visible) continue;
// Check if click is within entity's grid cell
// Entities occupy a 1x1 grid cell centered on their position
float dx = grid_x - entity->position.x;
float dy = grid_y - entity->position.y;
if (dx >= -0.5f && dx < 0.5f && dy >= -0.5f && dy < 0.5f) {
// Click is within the entity's cell
// Check if entity sprite has a click handler
// For now, we return the entity's sprite as the click target
// Note: UIEntity doesn't derive from UIDrawable, so we check its sprite
if (entity->sprite.click_callable) {
return &entity->sprite;
}
}
}
}
// No entity handled it, check if grid itself has handler
// #184: Also check for Python subclass (might have on_click or on_cell_click method)
// Store clicked cell for later callback firing (with button/action from PyScene)
int cell_x = static_cast<int>(std::floor(grid_x));
int cell_y = static_cast<int>(std::floor(grid_y));
if (cell_x >= 0 && cell_x < this->grid_w && cell_y >= 0 && cell_y < this->grid_h) {
last_clicked_cell = sf::Vector2i(cell_x, cell_y);
} else {
last_clicked_cell = std::nullopt;
}
// Return this if we have any handler (property callback, subclass method, or cell callback)
if (click_callable || is_python_subclass || on_cell_click_callable) {
return this;
}
return nullptr;
}
int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
// Define all parameters with defaults
PyObject* pos_obj = nullptr;
PyObject* size_obj = nullptr;
PyObject* grid_size_obj = nullptr;
PyObject* textureObj = nullptr;
PyObject* fill_color = nullptr;
PyObject* click_handler = nullptr;
PyObject* layers_obj = nullptr; // #150 - layers dict
// #169 - Use NaN as sentinel to detect if user provided center values
float center_x = std::numeric_limits<float>::quiet_NaN();
float center_y = std::numeric_limits<float>::quiet_NaN();
float zoom = 1.0f;
// perspective is now handled via properties, not init args
int visible = 1;
float opacity = 1.0f;
int z_index = 0;
const char* name = nullptr;
float x = 0.0f, y = 0.0f, w = 0.0f, h = 0.0f;
int grid_w = 2, grid_h = 2; // Default to 2x2 grid
PyObject* align_obj = nullptr; // Alignment enum or None
float margin = 0.0f;
float horiz_margin = -1.0f;
float vert_margin = -1.0f;
// Keywords list matches the new spec: positional args first, then all keyword args
static const char* kwlist[] = {
"pos", "size", "grid_size", "texture", // Positional args (as per spec)
// Keyword-only args
"fill_color", "on_click", "center_x", "center_y", "zoom",
"visible", "opacity", "z_index", "name", "x", "y", "w", "h", "grid_w", "grid_h",
"layers", // #150 - layers dict parameter
"align", "margin", "horiz_margin", "vert_margin",
nullptr
};
// Parse arguments with | for optional positional args
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOfffifizffffiiOOfff", const_cast<char**>(kwlist),
&pos_obj, &size_obj, &grid_size_obj, &textureObj, // Positional
&fill_color, &click_handler, &center_x, &center_y, &zoom,
&visible, &opacity, &z_index, &name, &x, &y, &w, &h, &grid_w, &grid_h,
&layers_obj,
&align_obj, &margin, &horiz_margin, &vert_margin)) {
return -1;
}
// Handle position argument (can be tuple, Vector, or use x/y keywords)
if (pos_obj) {
PyVectorObject* vec = PyVector::from_arg(pos_obj);
if (vec) {
x = vec->data.x;
y = vec->data.y;
Py_DECREF(vec);
} else {
PyErr_Clear();
if (PyTuple_Check(pos_obj) && PyTuple_Size(pos_obj) == 2) {
PyObject* x_val = PyTuple_GetItem(pos_obj, 0);
PyObject* y_val = PyTuple_GetItem(pos_obj, 1);
if ((PyFloat_Check(x_val) || PyLong_Check(x_val)) &&
(PyFloat_Check(y_val) || PyLong_Check(y_val))) {
x = PyFloat_Check(x_val) ? PyFloat_AsDouble(x_val) : PyLong_AsLong(x_val);
y = PyFloat_Check(y_val) ? PyFloat_AsDouble(y_val) : PyLong_AsLong(y_val);
} else {
PyErr_SetString(PyExc_TypeError, "pos tuple must contain numbers");
return -1;
}
} else {
PyErr_SetString(PyExc_TypeError, "pos must be a tuple (x, y) or Vector");
return -1;
}
}
}
// Handle size argument (can be tuple or use w/h keywords)
if (size_obj) {
if (PyTuple_Check(size_obj) && PyTuple_Size(size_obj) == 2) {
PyObject* w_val = PyTuple_GetItem(size_obj, 0);
PyObject* h_val = PyTuple_GetItem(size_obj, 1);
if ((PyFloat_Check(w_val) || PyLong_Check(w_val)) &&
(PyFloat_Check(h_val) || PyLong_Check(h_val))) {
w = PyFloat_Check(w_val) ? PyFloat_AsDouble(w_val) : PyLong_AsLong(w_val);
h = PyFloat_Check(h_val) ? PyFloat_AsDouble(h_val) : PyLong_AsLong(h_val);
} else {
PyErr_SetString(PyExc_TypeError, "size tuple must contain numbers");
return -1;
}
} else {
PyErr_SetString(PyExc_TypeError, "size must be a tuple (w, h)");
return -1;
}
}
// Handle grid_size argument (can be tuple or use grid_w/grid_h keywords)
if (grid_size_obj) {
if (PyTuple_Check(grid_size_obj) && PyTuple_Size(grid_size_obj) == 2) {
PyObject* gx_val = PyTuple_GetItem(grid_size_obj, 0);
PyObject* gy_val = PyTuple_GetItem(grid_size_obj, 1);
if (PyLong_Check(gx_val) && PyLong_Check(gy_val)) {
grid_w = PyLong_AsLong(gx_val);
grid_h = PyLong_AsLong(gy_val);
} else {
PyErr_SetString(PyExc_TypeError, "grid_size tuple must contain integers");
return -1;
}
} else {
PyErr_SetString(PyExc_TypeError, "grid_size must be a tuple (grid_w, grid_h)");
return -1;
}
}
// Validate grid dimensions
if (grid_w <= 0 || grid_h <= 0) {
PyErr_SetString(PyExc_ValueError, "Grid dimensions must be positive integers");
return -1;
}
// #212 - Validate against GRID_MAX
if (grid_w > GRID_MAX || grid_h > GRID_MAX) {
PyErr_Format(PyExc_ValueError,
"Grid dimensions cannot exceed %d (got %dx%d)",
GRID_MAX, grid_w, grid_h);
return -1;
}
// Handle texture argument
std::shared_ptr<PyTexture> texture_ptr = nullptr;
if (textureObj && textureObj != Py_None) {
if (!PyObject_IsInstance(textureObj, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Texture"))) {
PyErr_SetString(PyExc_TypeError, "texture must be a mcrfpy.Texture instance or None");
return -1;
}
PyTextureObject* pyTexture = reinterpret_cast<PyTextureObject*>(textureObj);
texture_ptr = pyTexture->data;
} else {
// Use default texture when None is provided or texture not specified
texture_ptr = McRFPy_API::default_texture;
}
// If size wasn't specified, calculate based on grid dimensions and texture
if (!size_obj && texture_ptr) {
w = grid_w * texture_ptr->sprite_width;
h = grid_h * texture_ptr->sprite_height;
} else if (!size_obj) {
w = grid_w * 16.0f; // Default tile size
h = grid_h * 16.0f;
}
// Create the grid
self->data = std::make_shared<UIGrid>(grid_w, grid_h, texture_ptr,
sf::Vector2f(x, y), sf::Vector2f(w, h));
// Set additional properties
self->data->zoom = zoom; // Set zoom first, needed for default center calculation
// #169 - Calculate default center if not provided by user
// Default: tile (0,0) at top-left of widget
if (std::isnan(center_x)) {
// Center = half widget size (in pixels), so tile 0,0 appears at top-left
center_x = w / (2.0f * zoom);
}
if (std::isnan(center_y)) {
center_y = h / (2.0f * zoom);
}
self->data->center_x = center_x;
self->data->center_y = center_y;
// 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;
if (name) {
self->data->name = std::string(name);
}
// Process alignment arguments
UIDRAWABLE_PROCESS_ALIGNMENT(self->data, align_obj, margin, horiz_margin, vert_margin);
// Handle fill_color
if (fill_color && fill_color != Py_None) {
PyColorObject* color_obj = PyColor::from_arg(fill_color);
if (!color_obj) {
PyErr_SetString(PyExc_TypeError, "fill_color must be a Color or color tuple");
return -1;
}
self->data->box.setFillColor(color_obj->data);
Py_DECREF(color_obj);
}
// Handle click handler
if (click_handler && click_handler != Py_None) {
if (!PyCallable_Check(click_handler)) {
PyErr_SetString(PyExc_TypeError, "click must be callable");
return -1;
}
self->data->click_register(click_handler);
}
// #150 - Handle layers parameter
// Default: single TileLayer named "tilesprite" when layers not provided
// Empty list/None: no rendering layers (entity storage + pathfinding only)
// List of layer objects: add each layer with lazy allocation
if (layers_obj == nullptr) {
// Default layer: single TileLayer named "tilesprite" (z_index -1 = below entities)
self->data->addTileLayer(-1, texture_ptr, "tilesprite");
} else if (layers_obj != Py_None) {
// Accept any iterable of layer objects
PyObject* iterator = PyObject_GetIter(layers_obj);
if (!iterator) {
PyErr_SetString(PyExc_TypeError, "layers must be an iterable of ColorLayer or TileLayer objects");
return -1;
}
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
if (!mcrfpy_module) {
Py_DECREF(iterator);
return -1;
}
auto* color_layer_type = PyObject_GetAttrString(mcrfpy_module, "ColorLayer");
auto* tile_layer_type = PyObject_GetAttrString(mcrfpy_module, "TileLayer");
Py_DECREF(mcrfpy_module);
if (!color_layer_type || !tile_layer_type) {
if (color_layer_type) Py_DECREF(color_layer_type);
if (tile_layer_type) Py_DECREF(tile_layer_type);
Py_DECREF(iterator);
return -1;
}
PyObject* item;
while ((item = PyIter_Next(iterator)) != NULL) {
std::shared_ptr<GridLayer> layer;
if (PyObject_IsInstance(item, color_layer_type)) {
PyColorLayerObject* py_layer = (PyColorLayerObject*)item;
if (!py_layer->data) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return -1;
}
// Check if already attached to another grid
if (py_layer->grid) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid");
return -1;
}
layer = py_layer->data;
// Check for protected names
if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
return -1;
}
// Handle name collision
if (!layer->name.empty()) {
auto existing = self->data->getLayerByName(layer->name);
if (existing) {
existing->parent_grid = nullptr;
self->data->removeLayer(existing);
}
}
// Lazy allocation: resize if layer is (0,0)
if (layer->grid_x == 0 && layer->grid_y == 0) {
layer->resize(self->data->grid_w, self->data->grid_h);
} else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError,
"Layer size (%d, %d) does not match Grid size (%d, %d)",
layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h);
return -1;
}
// Link to grid
layer->parent_grid = self->data.get();
self->data->layers.push_back(layer);
py_layer->grid = self->data;
} else if (PyObject_IsInstance(item, tile_layer_type)) {
PyTileLayerObject* py_layer = (PyTileLayerObject*)item;
if (!py_layer->data) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return -1;
}
// Check if already attached to another grid
if (py_layer->grid) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid");
return -1;
}
layer = py_layer->data;
// Check for protected names
if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
return -1;
}
// Handle name collision
if (!layer->name.empty()) {
auto existing = self->data->getLayerByName(layer->name);
if (existing) {
existing->parent_grid = nullptr;
self->data->removeLayer(existing);
}
}
// Lazy allocation: resize if layer is (0,0)
if (layer->grid_x == 0 && layer->grid_y == 0) {
layer->resize(self->data->grid_w, self->data->grid_h);
} else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError,
"Layer size (%d, %d) does not match Grid size (%d, %d)",
layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h);
return -1;
}
// Link to grid
layer->parent_grid = self->data.get();
self->data->layers.push_back(layer);
py_layer->grid = self->data;
} else {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_TypeError, "layers must contain only ColorLayer or TileLayer objects");
return -1;
}
Py_DECREF(item);
}
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
if (PyErr_Occurred()) {
return -1;
}
self->data->layers_need_sort = true;
}
// else: layers_obj is Py_None - explicit empty, no layers created
// Initialize weak reference list
self->weakreflist = NULL;
// Register in Python object cache
if (self->data->serial_number == 0) {
self->data->serial_number = PythonObjectCache::getInstance().assignSerial();
PyObject* weakref = PyWeakref_NewRef((PyObject*)self, NULL);
if (weakref) {
PythonObjectCache::getInstance().registerObject(self->data->serial_number, weakref);
Py_DECREF(weakref); // Cache owns the reference now
}
}
// #184: Check if this is a Python subclass (for callback method support)
PyObject* grid_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid");
if (grid_type) {
self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != grid_type;
Py_DECREF(grid_type);
}
return 0; // Success
}
// #179 - Return grid_size as Vector
PyObject* UIGrid::get_grid_size(PyUIGridObject* self, void* closure) {
return PyVector(sf::Vector2f(static_cast<float>(self->data->grid_w),
static_cast<float>(self->data->grid_h))).pyObject();
}
PyObject* UIGrid::get_grid_w(PyUIGridObject* self, void* closure) {
return PyLong_FromLong(self->data->grid_w);
}
PyObject* UIGrid::get_grid_h(PyUIGridObject* self, void* closure) {
return PyLong_FromLong(self->data->grid_h);
}
PyObject* UIGrid::get_position(PyUIGridObject* self, void* closure) {
// #179 - Return position as Vector (consistent with get_size, get_grid_size)
return PyVector(self->data->position).pyObject();
}
int UIGrid::set_position(PyUIGridObject* self, PyObject* value, void* closure) {
float x, y;
if (!PyArg_ParseTuple(value, "ff", &x, &y)) {
PyErr_SetString(PyExc_ValueError, "Position must be a tuple of two floats");
return -1;
}
self->data->position = sf::Vector2f(x, y); // Update base class position
self->data->box.setPosition(self->data->position); // Sync box position
self->data->output.setPosition(self->data->position); // Sync output sprite position
return 0;
}
// #181 - Return size as Vector
PyObject* UIGrid::get_size(PyUIGridObject* self, void* closure) {
auto& box = self->data->box;
return PyVector(box.getSize()).pyObject();
}
int UIGrid::set_size(PyUIGridObject* self, PyObject* value, void* closure) {
float w, h;
// Accept Vector or tuple
PyVectorObject* vec = PyVector::from_arg(value);
if (vec) {
w = vec->data.x;
h = vec->data.y;
Py_DECREF(vec);
} else {
PyErr_Clear();
if (!PyArg_ParseTuple(value, "ff", &w, &h)) {
PyErr_SetString(PyExc_TypeError, "size must be a Vector or tuple (w, h)");
return -1;
}
}
self->data->box.setSize(sf::Vector2f(w, h));
// Recreate renderTexture with new size to avoid rendering issues
// Add some padding to handle zoom and ensure we don't cut off content
unsigned int tex_width = static_cast<unsigned int>(w * 1.5f);
unsigned int tex_height = static_cast<unsigned int>(h * 1.5f);
// Clamp to reasonable maximum to avoid GPU memory issues
tex_width = std::min(tex_width, 4096u);
tex_height = std::min(tex_height, 4096u);
self->data->renderTexture.create(tex_width, tex_height);
return 0;
}
// #181 - Return center as Vector
PyObject* UIGrid::get_center(PyUIGridObject* self, void* closure) {
return PyVector(sf::Vector2f(self->data->center_x, self->data->center_y)).pyObject();
}
int UIGrid::set_center(PyUIGridObject* self, PyObject* value, void* closure) {
float x, y;
if (!PyArg_ParseTuple(value, "ff", &x, &y)) {
PyErr_SetString(PyExc_ValueError, "Size must be a tuple of two floats");
return -1;
}
self->data->center_x = x;
self->data->center_y = y;
return 0;
}
PyObject* UIGrid::get_float_member(PyUIGridObject* self, void* closure)
{
auto member_ptr = reinterpret_cast<intptr_t>(closure);
if (member_ptr == 0) // x
return PyFloat_FromDouble(self->data->box.getPosition().x);
else if (member_ptr == 1) // y
return PyFloat_FromDouble(self->data->box.getPosition().y);
else if (member_ptr == 2) // w
return PyFloat_FromDouble(self->data->box.getSize().x);
else if (member_ptr == 3) // h
return PyFloat_FromDouble(self->data->box.getSize().y);
else if (member_ptr == 4) // center_x
return PyFloat_FromDouble(self->data->center_x);
else if (member_ptr == 5) // center_y
return PyFloat_FromDouble(self->data->center_y);
else if (member_ptr == 6) // zoom
return PyFloat_FromDouble(self->data->zoom);
else if (member_ptr == 7) // camera_rotation
return PyFloat_FromDouble(self->data->camera_rotation);
else
{
PyErr_SetString(PyExc_AttributeError, "Invalid attribute");
return nullptr;
}
}
int UIGrid::set_float_member(PyUIGridObject* self, PyObject* value, void* closure)
{
float val;
auto member_ptr = reinterpret_cast<intptr_t>(closure);
if (PyFloat_Check(value))
{
val = PyFloat_AsDouble(value);
}
else if (PyLong_Check(value))
{
val = PyLong_AsLong(value);
}
else
{
PyErr_SetString(PyExc_TypeError, "Value must be a number (int or float)");
return -1;
}
if (member_ptr == 0) // x
self->data->box.setPosition(val, self->data->box.getPosition().y);
else if (member_ptr == 1) // y
self->data->box.setPosition(self->data->box.getPosition().x, val);
else if (member_ptr == 2) // w
{
self->data->box.setSize(sf::Vector2f(val, self->data->box.getSize().y));
// Recreate renderTexture when width changes
unsigned int tex_width = static_cast<unsigned int>(val * 1.5f);
unsigned int tex_height = static_cast<unsigned int>(self->data->box.getSize().y * 1.5f);
tex_width = std::min(tex_width, 4096u);
tex_height = std::min(tex_height, 4096u);
self->data->renderTexture.create(tex_width, tex_height);
}
else if (member_ptr == 3) // h
{
self->data->box.setSize(sf::Vector2f(self->data->box.getSize().x, val));
// Recreate renderTexture when height changes
unsigned int tex_width = static_cast<unsigned int>(self->data->box.getSize().x * 1.5f);
unsigned int tex_height = static_cast<unsigned int>(val * 1.5f);
tex_width = std::min(tex_width, 4096u);
tex_height = std::min(tex_height, 4096u);
self->data->renderTexture.create(tex_width, tex_height);
}
else if (member_ptr == 4) // center_x
self->data->center_x = val;
else if (member_ptr == 5) // center_y
self->data->center_y = val;
else if (member_ptr == 6) // zoom
self->data->zoom = val;
else if (member_ptr == 7) // camera_rotation
self->data->camera_rotation = val;
return 0;
}
// TODO (7DRL Day 2, item 5.) return Texture object
/*
PyObject* UIGrid::get_texture(PyUIGridObject* self, void* closure) {
Py_INCREF(self->texture);
return self->texture;
}
*/
PyObject* UIGrid::get_texture(PyUIGridObject* self, void* closure) {
//return self->data->getTexture()->pyObject();
// PyObject_GetAttrString(McRFPy_API::mcrf_module, "GridPointState")
//PyTextureObject* obj = (PyTextureObject*)((&PyTextureType)->tp_alloc(&PyTextureType, 0));
// Return None if no texture
auto texture = self->data->getTexture();
if (!texture) {
Py_RETURN_NONE;
}
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Texture");
auto obj = (PyTextureObject*)type->tp_alloc(type, 0);
obj->data = texture;
return (PyObject*)obj;
}
PyObject* UIGrid::py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds)
{
int x, y;
// Use the flexible position parsing helper - accepts:
// at(x, y), at((x, y)), at([x, y]), at(Vector(x, y)), at(pos=(x, y)), etc.
if (!PyPosition_ParseInt(args, kwds, &x, &y)) {
return NULL; // Error already set by PyPosition_ParseInt
}
// Range validation
if (x < 0 || x >= self->data->grid_w) {
PyErr_Format(PyExc_IndexError, "x index %d is out of range [0, %d)", x, self->data->grid_w);
return NULL;
}
if (y < 0 || y >= self->data->grid_h) {
PyErr_Format(PyExc_IndexError, "y index %d is out of range [0, %d)", y, self->data->grid_h);
return NULL;
}
// Use the type directly since GridPoint is internal-only (not exported to module)
auto type = &mcrfpydef::PyUIGridPointType;
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
//auto target = std::static_pointer_cast<UIEntity>(target);
// #123 - Use at() method to route through chunks for large grids
obj->data = &(self->data->at(x, y));
obj->grid = self->data;
return (PyObject*)obj;
}
// Grid subscript access: grid[x, y] -> GridPoint
// Enables Pythonic cell access syntax
PyObject* UIGrid::subscript(PyUIGridObject* self, PyObject* key)
{
// We expect a tuple of (x, y)
if (!PyTuple_Check(key) || PyTuple_Size(key) != 2) {
PyErr_SetString(PyExc_TypeError, "Grid indices must be a tuple of (x, y)");
return NULL;
}
PyObject* x_obj = PyTuple_GetItem(key, 0);
PyObject* y_obj = PyTuple_GetItem(key, 1);
if (!PyLong_Check(x_obj) || !PyLong_Check(y_obj)) {
PyErr_SetString(PyExc_TypeError, "Grid indices must be integers");
return NULL;
}
int x = PyLong_AsLong(x_obj);
int y = PyLong_AsLong(y_obj);
// Range validation
if (x < 0 || x >= self->data->grid_w) {
PyErr_Format(PyExc_IndexError, "x index %d is out of range [0, %d)", x, self->data->grid_w);
return NULL;
}
if (y < 0 || y >= self->data->grid_h) {
PyErr_Format(PyExc_IndexError, "y index %d is out of range [0, %d)", y, self->data->grid_h);
return NULL;
}
// Create GridPoint object (same as py_at)
auto type = &mcrfpydef::PyUIGridPointType;
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
if (!obj) return NULL;
obj->data = &(self->data->at(x, y));
obj->grid = self->data;
return (PyObject*)obj;
}
// Mapping methods for grid[x, y] subscript access
PyMappingMethods UIGrid::mpmethods = {
.mp_length = NULL, // No len() for grid via mapping (use grid_w * grid_h)
.mp_subscript = (binaryfunc)UIGrid::subscript,
.mp_ass_subscript = NULL // No assignment via subscript (use grid[x,y].property = value)
};
PyObject* UIGrid::get_fill_color(PyUIGridObject* self, void* closure)
{
auto& color = self->data->fill_color;
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Color");
PyObject* args = Py_BuildValue("(iiii)", color.r, color.g, color.b, color.a);
PyObject* obj = PyObject_CallObject((PyObject*)type, args);
Py_DECREF(args);
Py_DECREF(type);
return obj;
}
int UIGrid::set_fill_color(PyUIGridObject* self, PyObject* value, void* closure)
{
if (!PyObject_IsInstance(value, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Color"))) {
PyErr_SetString(PyExc_TypeError, "fill_color must be a Color object");
return -1;
}
PyColorObject* color = (PyColorObject*)value;
self->data->fill_color = color->data;
return 0;
}
PyObject* UIGrid::get_perspective(PyUIGridObject* self, void* closure)
{
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)
{
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;
}
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);
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_enabled = enabled;
return 0;
}
// #114 - FOV algorithm property
PyObject* UIGrid::get_fov(PyUIGridObject* self, void* closure)
{
// Return the FOV enum member for the current algorithm
if (PyFOV::fov_enum_class) {
// Get the enum member by value
PyObject* value = PyLong_FromLong(self->data->fov_algorithm);
if (!value) return NULL;
// Call FOV(value) to get the enum member
PyObject* args = PyTuple_Pack(1, value);
Py_DECREF(value);
if (!args) return NULL;
PyObject* result = PyObject_Call(PyFOV::fov_enum_class, args, NULL);
Py_DECREF(args);
return result;
}
// Fallback to integer
return PyLong_FromLong(self->data->fov_algorithm);
}
int UIGrid::set_fov(PyUIGridObject* self, PyObject* value, void* closure)
{
TCOD_fov_algorithm_t algo;
if (!PyFOV::from_arg(value, &algo, nullptr)) {
return -1;
}
self->data->fov_algorithm = algo;
return 0;
}
// #114 - FOV radius property
PyObject* UIGrid::get_fov_radius(PyUIGridObject* self, void* closure)
{
return PyLong_FromLong(self->data->fov_radius);
}
int UIGrid::set_fov_radius(PyUIGridObject* self, PyObject* value, void* closure)
{
if (!PyLong_Check(value)) {
PyErr_SetString(PyExc_TypeError, "fov_radius must be an integer");
return -1;
}
long radius = PyLong_AsLong(value);
if (radius == -1 && PyErr_Occurred()) {
return -1;
}
if (radius < 0) {
PyErr_SetString(PyExc_ValueError, "fov_radius must be non-negative");
return -1;
}
self->data->fov_radius = (int)radius;
return 0;
}
// Python API implementations for TCOD functionality
PyObject* UIGrid::py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds)
{
static const char* kwlist[] = {"pos", "radius", "light_walls", "algorithm", NULL};
PyObject* pos_obj = NULL;
int radius = 0;
int light_walls = 1;
int algorithm = FOV_BASIC;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|ipi", const_cast<char**>(kwlist),
&pos_obj, &radius, &light_walls, &algorithm)) {
return NULL;
}
int x, y;
if (!PyPosition_FromObjectInt(pos_obj, &x, &y)) {
return NULL;
}
// Compute FOV
self->data->computeFOV(x, y, radius, light_walls, (TCOD_fov_algorithm_t)algorithm);
// Return None - use is_in_fov() to query visibility
// See issue #146: returning a list had O(grid_size) performance
Py_RETURN_NONE;
}
PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds)
{
int x, y;
if (!PyPosition_ParseInt(args, kwds, &x, &y)) {
return NULL;
}
bool in_fov = self->data->isInFOV(x, y);
return PyBool_FromLong(in_fov);
}
// Old pathfinding Python methods removed - see UIGridPathfinding.cpp for new implementation
// Grid.find_path() now returns AStarPath objects
// Grid.get_dijkstra_map() returns DijkstraMap objects (cached by root)
// #147 - Layer system Python API
PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args) {
PyObject* layer_obj;
if (!PyArg_ParseTuple(args, "O", &layer_obj)) {
return NULL;
}
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
if (!mcrfpy_module) return NULL;
auto* color_layer_type = PyObject_GetAttrString(mcrfpy_module, "ColorLayer");
auto* tile_layer_type = PyObject_GetAttrString(mcrfpy_module, "TileLayer");
Py_DECREF(mcrfpy_module);
if (!color_layer_type || !tile_layer_type) {
if (color_layer_type) Py_DECREF(color_layer_type);
if (tile_layer_type) Py_DECREF(tile_layer_type);
return NULL;
}
std::shared_ptr<GridLayer> layer;
PyObject* py_layer_ref = nullptr;
if (PyObject_IsInstance(layer_obj, color_layer_type)) {
PyColorLayerObject* py_layer = (PyColorLayerObject*)layer_obj;
if (!py_layer->data) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return NULL;
}
// Check if already attached to another grid
if (py_layer->grid && py_layer->grid.get() != self->data.get()) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid");
return NULL;
}
layer = py_layer->data;
py_layer_ref = layer_obj;
// Check for protected names
if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
return NULL;
}
// Handle name collision - unlink existing layer with same name
if (!layer->name.empty()) {
auto existing = self->data->getLayerByName(layer->name);
if (existing && existing.get() != layer.get()) {
existing->parent_grid = nullptr;
self->data->removeLayer(existing);
}
}
// Lazy allocation: resize if layer is (0,0)
if (layer->grid_x == 0 && layer->grid_y == 0) {
layer->resize(self->data->grid_w, self->data->grid_h);
} else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError,
"Layer size (%d, %d) does not match Grid size (%d, %d)",
layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h);
return NULL;
}
// Link to grid
layer->parent_grid = self->data.get();
self->data->layers.push_back(layer);
self->data->layers_need_sort = true;
py_layer->grid = self->data;
} else if (PyObject_IsInstance(layer_obj, tile_layer_type)) {
PyTileLayerObject* py_layer = (PyTileLayerObject*)layer_obj;
if (!py_layer->data) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return NULL;
}
// Check if already attached to another grid
if (py_layer->grid && py_layer->grid.get() != self->data.get()) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid");
return NULL;
}
layer = py_layer->data;
py_layer_ref = layer_obj;
// Check for protected names
if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
return NULL;
}
// Handle name collision - unlink existing layer with same name
if (!layer->name.empty()) {
auto existing = self->data->getLayerByName(layer->name);
if (existing && existing.get() != layer.get()) {
existing->parent_grid = nullptr;
self->data->removeLayer(existing);
}
}
// Lazy allocation: resize if layer is (0,0)
if (layer->grid_x == 0 && layer->grid_y == 0) {
layer->resize(self->data->grid_w, self->data->grid_h);
} else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError,
"Layer size (%d, %d) does not match Grid size (%d, %d)",
layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h);
return NULL;
}
// Link to grid
layer->parent_grid = self->data.get();
self->data->layers.push_back(layer);
self->data->layers_need_sort = true;
py_layer->grid = self->data;
} else {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_TypeError, "layer must be a ColorLayer or TileLayer");
return NULL;
}
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
// Return the layer object (incref it since we're returning a reference)
Py_INCREF(py_layer_ref);
return py_layer_ref;
}
PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) {
PyObject* layer_obj;
if (!PyArg_ParseTuple(args, "O", &layer_obj)) {
return NULL;
}
// Check if it's a string (layer name)
if (PyUnicode_Check(layer_obj)) {
const char* name_str = PyUnicode_AsUTF8(layer_obj);
if (!name_str) return NULL;
auto layer = self->data->getLayerByName(std::string(name_str));
if (!layer) {
PyErr_Format(PyExc_KeyError, "Layer '%s' not found", name_str);
return NULL;
}
layer->parent_grid = nullptr;
self->data->removeLayer(layer);
Py_RETURN_NONE;
}
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
if (!mcrfpy_module) return NULL;
// Check if ColorLayer
auto* color_layer_type = PyObject_GetAttrString(mcrfpy_module, "ColorLayer");
if (color_layer_type && PyObject_IsInstance(layer_obj, color_layer_type)) {
Py_DECREF(color_layer_type);
Py_DECREF(mcrfpy_module);
auto* py_layer = (PyColorLayerObject*)layer_obj;
if (py_layer->data) {
py_layer->data->parent_grid = nullptr;
self->data->removeLayer(py_layer->data);
py_layer->grid.reset();
}
Py_RETURN_NONE;
}
if (color_layer_type) Py_DECREF(color_layer_type);
// Check if TileLayer
auto* tile_layer_type = PyObject_GetAttrString(mcrfpy_module, "TileLayer");
if (tile_layer_type && PyObject_IsInstance(layer_obj, tile_layer_type)) {
Py_DECREF(tile_layer_type);
Py_DECREF(mcrfpy_module);
auto* py_layer = (PyTileLayerObject*)layer_obj;
if (py_layer->data) {
py_layer->data->parent_grid = nullptr;
self->data->removeLayer(py_layer->data);
py_layer->grid.reset();
}
Py_RETURN_NONE;
}
if (tile_layer_type) Py_DECREF(tile_layer_type);
Py_DECREF(mcrfpy_module);
PyErr_SetString(PyExc_TypeError, "layer must be a string (layer name), ColorLayer, or TileLayer");
return NULL;
}
PyObject* UIGrid::get_layers(PyUIGridObject* self, void* closure) {
self->data->sortLayers();
PyObject* tuple = PyTuple_New(self->data->layers.size());
if (!tuple) return NULL;
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
if (!mcrfpy_module) {
Py_DECREF(tuple);
return NULL;
}
auto* color_layer_type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "ColorLayer");
auto* tile_layer_type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "TileLayer");
Py_DECREF(mcrfpy_module);
if (!color_layer_type || !tile_layer_type) {
if (color_layer_type) Py_DECREF(color_layer_type);
if (tile_layer_type) Py_DECREF(tile_layer_type);
Py_DECREF(tuple);
return NULL;
}
for (size_t i = 0; i < self->data->layers.size(); ++i) {
auto& layer = self->data->layers[i];
PyObject* py_layer = nullptr;
if (layer->type == GridLayerType::Color) {
PyColorLayerObject* obj = (PyColorLayerObject*)color_layer_type->tp_alloc(color_layer_type, 0);
if (obj) {
obj->data = std::static_pointer_cast<ColorLayer>(layer);
obj->grid = self->data;
py_layer = (PyObject*)obj;
}
} else {
PyTileLayerObject* obj = (PyTileLayerObject*)tile_layer_type->tp_alloc(tile_layer_type, 0);
if (obj) {
obj->data = std::static_pointer_cast<TileLayer>(layer);
obj->grid = self->data;
py_layer = (PyObject*)obj;
}
}
if (!py_layer) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
Py_DECREF(tuple);
return NULL;
}
PyTuple_SET_ITEM(tuple, i, py_layer); // Steals reference
}
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
return tuple;
}
PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) {
const char* name_str;
if (!PyArg_ParseTuple(args, "s", &name_str)) {
return NULL;
}
auto layer = self->data->getLayerByName(std::string(name_str));
if (!layer) {
Py_RETURN_NONE;
}
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
if (!mcrfpy_module) return NULL;
if (layer->type == GridLayerType::Color) {
auto* type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "ColorLayer");
Py_DECREF(mcrfpy_module);
if (!type) return NULL;
PyColorLayerObject* obj = (PyColorLayerObject*)type->tp_alloc(type, 0);
Py_DECREF(type);
if (!obj) return NULL;
obj->data = std::static_pointer_cast<ColorLayer>(layer);
obj->grid = self->data;
return (PyObject*)obj;
} else {
auto* type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "TileLayer");
Py_DECREF(mcrfpy_module);
if (!type) return NULL;
PyTileLayerObject* obj = (PyTileLayerObject*)type->tp_alloc(type, 0);
Py_DECREF(type);
if (!obj) return NULL;
obj->data = std::static_pointer_cast<TileLayer>(layer);
obj->grid = self->data;
return (PyObject*)obj;
}
}
// #115 - Spatial hash query for entities in radius
// #216 - Updated to use position tuple/Vector instead of x, y
PyObject* UIGrid::py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds)
{
static const char* kwlist[] = {"pos", "radius", NULL};
PyObject* pos_obj;
float radius;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "Of", const_cast<char**>(kwlist),
&pos_obj, &radius)) {
return NULL;
}
// Parse position from tuple, Vector, or other 2-element sequence
float x, y;
if (!PyPosition_FromObject(pos_obj, &x, &y)) {
return NULL; // Error already set by helper
}
if (radius < 0) {
PyErr_SetString(PyExc_ValueError, "radius must be non-negative");
return NULL;
}
// Query spatial hash for entities in radius
auto entities = self->data->spatial_hash.queryRadius(x, y, radius);
// Create result list
PyObject* result = PyList_New(entities.size());
if (!result) return PyErr_NoMemory();
// Cache Entity type for efficiency
static PyTypeObject* cached_entity_type = nullptr;
if (!cached_entity_type) {
cached_entity_type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
if (!cached_entity_type) {
Py_DECREF(result);
return NULL;
}
Py_INCREF(cached_entity_type);
}
for (size_t i = 0; i < entities.size(); i++) {
auto& entity = entities[i];
// Return stored Python object if it exists
if (entity->self != nullptr) {
Py_INCREF(entity->self);
PyList_SET_ITEM(result, i, entity->self);
} else {
// Create new Python Entity wrapper
auto pyEntity = (PyUIEntityObject*)cached_entity_type->tp_alloc(cached_entity_type, 0);
if (!pyEntity) {
Py_DECREF(result);
return PyErr_NoMemory();
}
pyEntity->data = entity;
pyEntity->weakreflist = NULL;
PyList_SET_ITEM(result, i, (PyObject*)pyEntity);
}
}
return result;
}
// #169 - center_camera implementations
void UIGrid::center_camera() {
// Center on grid's middle tile
int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
center_x = (grid_w / 2.0f) * cell_width;
center_y = (grid_h / 2.0f) * cell_height;
markDirty(); // #144 - View change affects content
}
void UIGrid::center_camera(float tile_x, float tile_y) {
// Position specified tile at top-left of widget
int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
// To put tile (tx, ty) at top-left: center = tile_pos + half_viewport
float half_viewport_x = box.getSize().x / zoom / 2.0f;
float half_viewport_y = box.getSize().y / zoom / 2.0f;
center_x = tile_x * cell_width + half_viewport_x;
center_y = tile_y * cell_height + half_viewport_y;
markDirty(); // #144 - View change affects content
}
PyObject* UIGrid::py_center_camera(PyUIGridObject* self, PyObject* args) {
PyObject* pos_arg = nullptr;
// Parse optional positional argument (tuple of tile coordinates)
if (!PyArg_ParseTuple(args, "|O", &pos_arg)) {
return nullptr;
}
if (pos_arg == nullptr || pos_arg == Py_None) {
// No args: center on grid's middle tile
self->data->center_camera();
} else if (PyTuple_Check(pos_arg) && PyTuple_Size(pos_arg) == 2) {
// Tuple provided: center on (tile_x, tile_y)
PyObject* x_obj = PyTuple_GetItem(pos_arg, 0);
PyObject* y_obj = PyTuple_GetItem(pos_arg, 1);
float tile_x, tile_y;
if (PyFloat_Check(x_obj)) {
tile_x = PyFloat_AsDouble(x_obj);
} else if (PyLong_Check(x_obj)) {
tile_x = (float)PyLong_AsLong(x_obj);
} else {
PyErr_SetString(PyExc_TypeError, "tile coordinates must be numeric");
return nullptr;
}
if (PyFloat_Check(y_obj)) {
tile_y = PyFloat_AsDouble(y_obj);
} else if (PyLong_Check(y_obj)) {
tile_y = (float)PyLong_AsLong(y_obj);
} else {
PyErr_SetString(PyExc_TypeError, "tile coordinates must be numeric");
return nullptr;
}
self->data->center_camera(tile_x, tile_y);
} else {
PyErr_SetString(PyExc_TypeError, "center_camera() takes an optional tuple (tile_x, tile_y)");
return nullptr;
}
Py_RETURN_NONE;
}
// #199 - HeightMap application methods
PyObject* UIGrid::py_apply_threshold(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
static const char* keywords[] = {"source", "range", "walkable", "transparent", nullptr};
PyObject* source_obj = nullptr;
PyObject* range_obj = nullptr;
PyObject* walkable_obj = Py_None;
PyObject* transparent_obj = Py_None;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|OO", const_cast<char**>(keywords),
&source_obj, &range_obj, &walkable_obj, &transparent_obj)) {
return nullptr;
}
// Validate source is a HeightMap
PyObject* heightmap_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "HeightMap");
if (!heightmap_type) {
PyErr_SetString(PyExc_RuntimeError, "HeightMap type not found in module");
return nullptr;
}
bool is_heightmap = PyObject_IsInstance(source_obj, heightmap_type);
Py_DECREF(heightmap_type);
if (!is_heightmap) {
PyErr_SetString(PyExc_TypeError, "source must be a HeightMap");
return nullptr;
}
PyHeightMapObject* hmap = (PyHeightMapObject*)source_obj;
if (!hmap->heightmap) {
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
return nullptr;
}
// Parse range tuple
if (!PyTuple_Check(range_obj) || PyTuple_Size(range_obj) != 2) {
PyErr_SetString(PyExc_TypeError, "range must be a tuple of (min, max)");
return nullptr;
}
float range_min = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 0));
float range_max = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 1));
if (PyErr_Occurred()) {
return nullptr;
}
// Check size match
if (hmap->heightmap->w != self->data->grid_w || hmap->heightmap->h != self->data->grid_h) {
PyErr_Format(PyExc_ValueError,
"HeightMap size (%d, %d) does not match Grid size (%d, %d)",
hmap->heightmap->w, hmap->heightmap->h, self->data->grid_w, self->data->grid_h);
return nullptr;
}
// Parse optional walkable/transparent booleans
bool set_walkable = (walkable_obj != Py_None);
bool set_transparent = (transparent_obj != Py_None);
bool walkable_value = false;
bool transparent_value = false;
if (set_walkable) {
walkable_value = PyObject_IsTrue(walkable_obj);
}
if (set_transparent) {
transparent_value = PyObject_IsTrue(transparent_obj);
}
// Apply threshold
for (int y = 0; y < self->data->grid_h; y++) {
for (int x = 0; x < self->data->grid_w; x++) {
float value = TCOD_heightmap_get_value(hmap->heightmap, x, y);
if (value >= range_min && value <= range_max) {
UIGridPoint& point = self->data->at(x, y);
if (set_walkable) {
point.walkable = walkable_value;
}
if (set_transparent) {
point.transparent = transparent_value;
}
}
}
}
// Sync TCOD map if it exists
if (self->data->getTCODMap()) {
self->data->syncTCODMap();
}
// Return self for chaining
Py_INCREF(self);
return (PyObject*)self;
}
PyObject* UIGrid::py_apply_ranges(PyUIGridObject* self, PyObject* args) {
PyObject* source_obj = nullptr;
PyObject* ranges_obj = nullptr;
if (!PyArg_ParseTuple(args, "OO", &source_obj, &ranges_obj)) {
return nullptr;
}
// Validate source is a HeightMap
PyObject* heightmap_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "HeightMap");
if (!heightmap_type) {
PyErr_SetString(PyExc_RuntimeError, "HeightMap type not found in module");
return nullptr;
}
bool is_heightmap = PyObject_IsInstance(source_obj, heightmap_type);
Py_DECREF(heightmap_type);
if (!is_heightmap) {
PyErr_SetString(PyExc_TypeError, "source must be a HeightMap");
return nullptr;
}
PyHeightMapObject* hmap = (PyHeightMapObject*)source_obj;
if (!hmap->heightmap) {
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
return nullptr;
}
// Validate ranges is a list
if (!PyList_Check(ranges_obj)) {
PyErr_SetString(PyExc_TypeError, "ranges must be a list");
return nullptr;
}
// Check size match
if (hmap->heightmap->w != self->data->grid_w || hmap->heightmap->h != self->data->grid_h) {
PyErr_Format(PyExc_ValueError,
"HeightMap size (%d, %d) does not match Grid size (%d, %d)",
hmap->heightmap->w, hmap->heightmap->h, self->data->grid_w, self->data->grid_h);
return nullptr;
}
// Parse all ranges first to catch errors early
struct RangeEntry {
float min, max;
bool set_walkable, set_transparent;
bool walkable_value, transparent_value;
};
std::vector<RangeEntry> entries;
Py_ssize_t num_ranges = PyList_Size(ranges_obj);
for (Py_ssize_t i = 0; i < num_ranges; i++) {
PyObject* entry = PyList_GetItem(ranges_obj, i);
if (!PyTuple_Check(entry) || PyTuple_Size(entry) != 2) {
PyErr_Format(PyExc_TypeError,
"ranges[%zd] must be a tuple of (range, properties_dict)", i);
return nullptr;
}
PyObject* range_tuple = PyTuple_GetItem(entry, 0);
PyObject* props_dict = PyTuple_GetItem(entry, 1);
if (!PyTuple_Check(range_tuple) || PyTuple_Size(range_tuple) != 2) {
PyErr_Format(PyExc_TypeError,
"ranges[%zd] range must be a tuple of (min, max)", i);
return nullptr;
}
if (!PyDict_Check(props_dict)) {
PyErr_Format(PyExc_TypeError,
"ranges[%zd] properties must be a dict", i);
return nullptr;
}
RangeEntry re;
re.min = (float)PyFloat_AsDouble(PyTuple_GetItem(range_tuple, 0));
re.max = (float)PyFloat_AsDouble(PyTuple_GetItem(range_tuple, 1));
if (PyErr_Occurred()) {
return nullptr;
}
// Parse walkable from dict
PyObject* walkable_val = PyDict_GetItemString(props_dict, "walkable");
re.set_walkable = (walkable_val != nullptr);
if (re.set_walkable) {
re.walkable_value = PyObject_IsTrue(walkable_val);
}
// Parse transparent from dict
PyObject* transparent_val = PyDict_GetItemString(props_dict, "transparent");
re.set_transparent = (transparent_val != nullptr);
if (re.set_transparent) {
re.transparent_value = PyObject_IsTrue(transparent_val);
}
entries.push_back(re);
}
// Apply all ranges in a single pass
for (int y = 0; y < self->data->grid_h; y++) {
for (int x = 0; x < self->data->grid_w; x++) {
float value = TCOD_heightmap_get_value(hmap->heightmap, x, y);
UIGridPoint& point = self->data->at(x, y);
// Check each range (first match wins)
for (const auto& re : entries) {
if (value >= re.min && value <= re.max) {
if (re.set_walkable) {
point.walkable = re.walkable_value;
}
if (re.set_transparent) {
point.transparent = re.transparent_value;
}
break; // First matching range wins
}
}
}
}
// Sync TCOD map if it exists
if (self->data->getTCODMap()) {
self->data->syncTCODMap();
}
// Return self for chaining
Py_INCREF(self);
return (PyObject*)self;
}
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(pos, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> None\n\n"
"Compute field of view from a position.\n\n"
"Args:\n"
" pos: Position as (x, y) tuple, list, or Vector\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(pos) to query visibility."},
{"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS | METH_KEYWORDS,
"is_in_fov(pos) -> bool\n\n"
"Check if a cell is in the field of view.\n\n"
"Args:\n"
" pos: Position as (x, y) tuple, list, or Vector\n\n"
"Returns:\n"
" True if the cell is visible, False otherwise\n\n"
"Must call compute_fov() first to calculate visibility."},
{"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS,
"find_path(start, end, diagonal_cost: float = 1.41) -> AStarPath | None\n\n"
"Compute A* path between two points.\n\n"
"Args:\n"
" start: Starting position as Vector, Entity, or (x, y) tuple\n"
" end: Target position as Vector, Entity, or (x, y) tuple\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n"
"Returns:\n"
" AStarPath object if path exists, None otherwise.\n\n"
"The returned AStarPath can be iterated or walked step-by-step."},
{"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS,
"get_dijkstra_map(root, diagonal_cost: float = 1.41) -> DijkstraMap\n\n"
"Get or create a Dijkstra distance map for a root position.\n\n"
"Args:\n"
" root: Root position as Vector, Entity, or (x, y) tuple\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n"
"Returns:\n"
" DijkstraMap object for querying distances and paths.\n\n"
"Grid caches DijkstraMaps by root position. Multiple requests for the\n"
"same root return the same cached map. Call clear_dijkstra_maps() after\n"
"changing grid walkability to invalidate the cache."},
{"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS,
"clear_dijkstra_maps() -> None\n\n"
"Clear all cached Dijkstra maps.\n\n"
"Call this after modifying grid cell walkability to ensure pathfinding\n"
"uses updated walkability data."},
{"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS,
"add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer"},
{"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS,
"remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None"},
{"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS,
"layer(name: str) -> ColorLayer | TileLayer | None"},
{"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS,
"entities_in_radius(pos: tuple|Vector, radius: float) -> list[Entity]\n\n"
"Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n"
"Args:\n"
" pos: Center position as (x, y) tuple, Vector, or other 2-element sequence\n"
" radius: Search radius\n\n"
"Returns:\n"
" List of Entity objects within the radius."},
{"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS,
"center_camera(pos: tuple = None) -> None\n\n"
"Center the camera on a tile coordinate.\n\n"
"Args:\n"
" pos: Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.\n\n"
"Example:\n"
" grid.center_camera() # Center on middle of grid\n"
" grid.center_camera((5, 10)) # Center on tile (5, 10)\n"
" grid.center_camera((0, 0)) # Center on tile (0, 0)"},
// #199 - HeightMap application methods
{"apply_threshold", (PyCFunction)UIGrid::py_apply_threshold, METH_VARARGS | METH_KEYWORDS,
"apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> Grid\n\n"
"Apply walkable/transparent properties where heightmap values are in range.\n\n"
"Args:\n"
" source: HeightMap with values to check. Must match grid size.\n"
" range: Tuple of (min, max) - cells with values in this range are affected.\n"
" walkable: If not None, set walkable to this value for cells in range.\n"
" transparent: If not None, set transparent to this value for cells in range.\n\n"
"Returns:\n"
" Grid: self, for method chaining.\n\n"
"Raises:\n"
" ValueError: If HeightMap size doesn't match grid size."},
{"apply_ranges", (PyCFunction)UIGrid::py_apply_ranges, METH_VARARGS,
"apply_ranges(source: HeightMap, ranges: list) -> Grid\n\n"
"Apply multiple thresholds in a single pass.\n\n"
"Args:\n"
" source: HeightMap with values to check. Must match grid size.\n"
" ranges: List of (range_tuple, properties_dict) tuples.\n"
" range_tuple: (min, max) value range\n"
" properties_dict: {'walkable': bool, 'transparent': bool}\n\n"
"Returns:\n"
" Grid: self, for method chaining.\n\n"
"Example:\n"
" grid.apply_ranges(terrain, [\n"
" ((0.0, 0.3), {'walkable': False, 'transparent': True}), # Water\n"
" ((0.3, 0.8), {'walkable': True, 'transparent': True}), # Land\n"
" ((0.8, 1.0), {'walkable': False, 'transparent': False}), # Mountains\n"
" ])"},
{NULL, NULL, 0, NULL}
};
// Define the PyObjectType alias for the macros
typedef PyUIGridObject PyObjectType;
// Combined methods array
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(pos, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> None\n\n"
"Compute field of view from a position.\n\n"
"Args:\n"
" pos: Position as (x, y) tuple, list, or Vector\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(pos) to query visibility."},
{"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS | METH_KEYWORDS,
"is_in_fov(pos) -> bool\n\n"
"Check if a cell is in the field of view.\n\n"
"Args:\n"
" pos: Position as (x, y) tuple, list, or Vector\n\n"
"Returns:\n"
" True if the cell is visible, False otherwise\n\n"
"Must call compute_fov() first to calculate visibility."},
{"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS,
"find_path(start, end, diagonal_cost: float = 1.41) -> AStarPath | None\n\n"
"Compute A* path between two points.\n\n"
"Args:\n"
" start: Starting position as Vector, Entity, or (x, y) tuple\n"
" end: Target position as Vector, Entity, or (x, y) tuple\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n"
"Returns:\n"
" AStarPath object if path exists, None otherwise.\n\n"
"The returned AStarPath can be iterated or walked step-by-step."},
{"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS,
"get_dijkstra_map(root, diagonal_cost: float = 1.41) -> DijkstraMap\n\n"
"Get or create a Dijkstra distance map for a root position.\n\n"
"Args:\n"
" root: Root position as Vector, Entity, or (x, y) tuple\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n"
"Returns:\n"
" DijkstraMap object for querying distances and paths.\n\n"
"Grid caches DijkstraMaps by root position. Multiple requests for the\n"
"same root return the same cached map. Call clear_dijkstra_maps() after\n"
"changing grid walkability to invalidate the cache."},
{"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS,
"clear_dijkstra_maps() -> None\n\n"
"Clear all cached Dijkstra maps.\n\n"
"Call this after modifying grid cell walkability to ensure pathfinding\n"
"uses updated walkability data."},
{"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS,
"add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer\n\n"
"Add a layer to the grid.\n\n"
"Args:\n"
" layer: A ColorLayer or TileLayer object. Layers with size (0, 0) are\n"
" automatically resized to match the grid. Named layers replace\n"
" any existing layer with the same name.\n\n"
"Returns:\n"
" The added layer object.\n\n"
"Raises:\n"
" ValueError: If layer is already attached to another grid, or if\n"
" layer size doesn't match grid (and isn't (0,0)).\n"
" TypeError: If argument is not a ColorLayer or TileLayer."},
{"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS,
"remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None\n\n"
"Remove a layer from the grid.\n\n"
"Args:\n"
" name_or_layer: Either a layer name (str) or the layer object itself.\n\n"
"Raises:\n"
" KeyError: If name is provided but no layer with that name exists.\n"
" TypeError: If argument is not a string, ColorLayer, or TileLayer."},
{"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS,
"layer(name: str) -> ColorLayer | TileLayer | None\n\n"
"Get a layer by its name.\n\n"
"Args:\n"
" name: The name of the layer to find.\n\n"
"Returns:\n"
" The layer with the specified name, or None if not found."},
{"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS,
"entities_in_radius(pos: tuple|Vector, radius: float) -> list[Entity]\n\n"
"Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n"
"Args:\n"
" pos: Center position as (x, y) tuple, Vector, or other 2-element sequence\n"
" radius: Search radius\n\n"
"Returns:\n"
" List of Entity objects within the radius."},
{"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS,
"center_camera(pos: tuple = None) -> None\n\n"
"Center the camera on a tile coordinate.\n\n"
"Args:\n"
" pos: Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.\n\n"
"Example:\n"
" grid.center_camera() # Center on middle of grid\n"
" grid.center_camera((5, 10)) # Center on tile (5, 10)\n"
" grid.center_camera((0, 0)) # Center on tile (0, 0)"},
// #199 - HeightMap application methods
{"apply_threshold", (PyCFunction)UIGrid::py_apply_threshold, METH_VARARGS | METH_KEYWORDS,
"apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> Grid\n\n"
"Apply walkable/transparent properties where heightmap values are in range.\n\n"
"Args:\n"
" source: HeightMap with values to check. Must match grid size.\n"
" range: Tuple of (min, max) - cells with values in this range are affected.\n"
" walkable: If not None, set walkable to this value for cells in range.\n"
" transparent: If not None, set transparent to this value for cells in range.\n\n"
"Returns:\n"
" Grid: self, for method chaining.\n\n"
"Raises:\n"
" ValueError: If HeightMap size doesn't match grid size."},
{"apply_ranges", (PyCFunction)UIGrid::py_apply_ranges, METH_VARARGS,
"apply_ranges(source: HeightMap, ranges: list) -> Grid\n\n"
"Apply multiple thresholds in a single pass.\n\n"
"Args:\n"
" source: HeightMap with values to check. Must match grid size.\n"
" ranges: List of (range_tuple, properties_dict) tuples.\n"
" range_tuple: (min, max) value range\n"
" properties_dict: {'walkable': bool, 'transparent': bool}\n\n"
"Returns:\n"
" Grid: self, for method chaining.\n\n"
"Example:\n"
" grid.apply_ranges(terrain, [\n"
" ((0.0, 0.3), {'walkable': False, 'transparent': True}), # Water\n"
" ((0.3, 0.8), {'walkable': True, 'transparent': True}), # Land\n"
" ((0.8, 1.0), {'walkable': False, 'transparent': False}), # Mountains\n"
" ])"},
{NULL} // Sentinel
};
PyGetSetDef UIGrid::getsetters[] = {
// TODO - refactor into get_vector_member with field identifier values `(void*)n`
{"grid_size", (getter)UIGrid::get_grid_size, NULL, "Grid dimensions (grid_w, grid_h)", NULL},
{"grid_w", (getter)UIGrid::get_grid_w, NULL, "Grid width in cells", NULL},
{"grid_h", (getter)UIGrid::get_grid_h, NULL, "Grid height in cells", NULL},
{"position", (getter)UIGrid::get_position, (setter)UIGrid::set_position, "Position of the grid (x, y)", NULL},
{"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, "Position of the grid as Vector", (void*)PyObjectsEnum::UIGRID},
{"grid_pos", (getter)UIDrawable::get_grid_pos, (setter)UIDrawable::set_grid_pos, "Position in parent grid's tile coordinates (only when parent is Grid)", (void*)PyObjectsEnum::UIGRID},
{"size", (getter)UIGrid::get_size, (setter)UIGrid::set_size, "Size of the grid as Vector (width, height)", NULL},
{"center", (getter)UIGrid::get_center, (setter)UIGrid::set_center, "Grid coordinate at the center of the Grid's view (pan)", NULL},
{"entities", (getter)UIGrid::get_entities, NULL, "EntityCollection of entities on this grid", NULL},
{"children", (getter)UIGrid::get_children, NULL, "UICollection of UIDrawable children (speech bubbles, effects, overlays)", NULL},
{"layers", (getter)UIGrid::get_layers, NULL, "List of grid layers (ColorLayer, TileLayer) sorted by z_index", NULL},
{"x", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "top-left corner X-coordinate", (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 0)},
{"y", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "top-left corner Y-coordinate", (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 1)},
{"w", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "visible widget width", (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 2)},
{"h", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "visible widget height", (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 3)},
{"center_x", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "center of the view X-coordinate", (void*)4},
{"center_y", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "center of the view Y-coordinate", (void*)5},
{"zoom", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "zoom factor for displaying the Grid", (void*)6},
{"camera_rotation", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "Rotation of grid contents around camera center (degrees). The grid widget stays axis-aligned; only the view into the world rotates.", (void*)7},
{"on_click", (getter)UIDrawable::get_click, (setter)UIDrawable::set_click,
MCRF_PROPERTY(on_click,
"Callable executed when object is clicked. "
"Function receives (pos: Vector, button: str, action: str)."
), (void*)PyObjectsEnum::UIGRID},
{"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. Returns a copy; modifying components requires reassignment. "
"For animation, use 'fill_color.r', 'fill_color.g', etc.", NULL},
{"perspective", (getter)UIGrid::get_perspective, (setter)UIGrid::set_perspective,
"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},
{"fov", (getter)UIGrid::get_fov, (setter)UIGrid::set_fov,
"FOV algorithm for this grid (mcrfpy.FOV enum). "
"Used by entity.updateVisibility() and layer methods when fov=None.", NULL},
{"fov_radius", (getter)UIGrid::get_fov_radius, (setter)UIGrid::set_fov_radius,
"Default FOV radius for this grid. Used when radius not specified.", NULL},
{"z_index", (getter)UIDrawable::get_int, (setter)UIDrawable::set_int,
MCRF_PROPERTY(z_index,
"Z-order for rendering (lower values rendered first). "
"Automatically triggers scene resort when changed."
), (void*)PyObjectsEnum::UIGRID},
{"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UIGRID},
UIDRAWABLE_GETSETTERS,
UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIGRID),
UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIGRID),
UIDRAWABLE_ROTATION_GETSETTERS(PyObjectsEnum::UIGRID),
// #142 - Grid cell mouse events
{"on_cell_enter", (getter)UIGrid::get_on_cell_enter, (setter)UIGrid::set_on_cell_enter,
"Callback when mouse enters a grid cell. Called with (cell_pos: Vector).", NULL},
{"on_cell_exit", (getter)UIGrid::get_on_cell_exit, (setter)UIGrid::set_on_cell_exit,
"Callback when mouse exits a grid cell. Called with (cell_pos: Vector).", NULL},
{"on_cell_click", (getter)UIGrid::get_on_cell_click, (setter)UIGrid::set_on_cell_click,
"Callback when a grid cell is clicked. Called with (cell_pos: Vector).", NULL},
{"hovered_cell", (getter)UIGrid::get_hovered_cell, NULL,
"Currently hovered cell as (x, y) tuple, or None if not hovering.", NULL},
UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UIGRID),
{NULL} /* Sentinel */
};
PyObject* UIGrid::get_entities(PyUIGridObject* self, void* closure)
{
// Returns EntityCollection for entity management
// Use the type directly from namespace (type not exported to module)
PyTypeObject* type = &mcrfpydef::PyUIEntityCollectionType;
auto o = (PyUIEntityCollectionObject*)type->tp_alloc(type, 0);
if (o) {
o->data = self->data->entities;
o->grid = self->data;
}
return (PyObject*)o;
}
PyObject* UIGrid::get_children(PyUIGridObject* self, void* closure)
{
// Returns UICollection for UIDrawable children (speech bubbles, effects, overlays)
// Use the type directly from namespace (#189 - type not exported to module)
PyTypeObject* type = &mcrfpydef::PyUICollectionType;
auto o = (PyUICollectionObject*)type->tp_alloc(type, 0);
if (o) {
o->data = self->data->children;
o->owner = self->data; // #122: Set owner for parent tracking
}
return (PyObject*)o;
}
PyObject* UIGrid::repr(PyUIGridObject* self)
{
std::ostringstream ss;
if (!self->data) ss << "<Grid (invalid internal object)>";
else {
auto grid = self->data;
auto box = grid->box;
ss << "<Grid (x=" << box.getPosition().x << ", y=" << box.getPosition().y << ", w=" << box.getSize().x << ", h=" << box.getSize().y << ", " <<
"center=(" << grid->center_x << ", " << grid->center_y << "), zoom=" << grid->zoom <<
")>";
}
std::string repr_str = ss.str();
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
}
/* // TODO standard pointer would need deleted, but I opted for a shared pointer. tp_dealloc currently not even defined in the PyTypeObject
void PyUIGrid_dealloc(PyUIGridObject* self) {
delete self->data; // Clean up the allocated UIGrid object
Py_TYPE(self)->tp_free((PyObject*)self);
}
*/
// #142 - Grid cell mouse event getters/setters
PyObject* UIGrid::get_on_cell_enter(PyUIGridObject* self, void* closure) {
if (self->data->on_cell_enter_callable) {
PyObject* cb = self->data->on_cell_enter_callable->borrow();
Py_INCREF(cb); // Return new reference, not borrowed
return cb;
}
Py_RETURN_NONE;
}
// #230 - Cell hover callbacks now use PyCellHoverCallable (position-only)
int UIGrid::set_on_cell_enter(PyUIGridObject* self, PyObject* value, void* closure) {
if (value == Py_None) {
self->data->on_cell_enter_callable.reset();
} else {
self->data->on_cell_enter_callable = std::make_unique<PyCellHoverCallable>(value);
}
return 0;
}
PyObject* UIGrid::get_on_cell_exit(PyUIGridObject* self, void* closure) {
if (self->data->on_cell_exit_callable) {
PyObject* cb = self->data->on_cell_exit_callable->borrow();
Py_INCREF(cb); // Return new reference, not borrowed
return cb;
}
Py_RETURN_NONE;
}
// #230 - Cell hover callbacks now use PyCellHoverCallable (position-only)
int UIGrid::set_on_cell_exit(PyUIGridObject* self, PyObject* value, void* closure) {
if (value == Py_None) {
self->data->on_cell_exit_callable.reset();
} else {
self->data->on_cell_exit_callable = std::make_unique<PyCellHoverCallable>(value);
}
return 0;
}
PyObject* UIGrid::get_on_cell_click(PyUIGridObject* self, void* closure) {
if (self->data->on_cell_click_callable) {
PyObject* cb = self->data->on_cell_click_callable->borrow();
Py_INCREF(cb); // Return new reference, not borrowed
return cb;
}
Py_RETURN_NONE;
}
int UIGrid::set_on_cell_click(PyUIGridObject* self, PyObject* value, void* closure) {
if (value == Py_None) {
self->data->on_cell_click_callable.reset();
} else {
self->data->on_cell_click_callable = std::make_unique<PyClickCallable>(value);
}
return 0;
}
PyObject* UIGrid::get_hovered_cell(PyUIGridObject* self, void* closure) {
if (self->data->hovered_cell.has_value()) {
return Py_BuildValue("(ii)", self->data->hovered_cell->x, self->data->hovered_cell->y);
}
Py_RETURN_NONE;
}
// #142 - Convert screen coordinates to cell coordinates
std::optional<sf::Vector2i> UIGrid::screenToCell(sf::Vector2f screen_pos) const {
// Get grid's global position
sf::Vector2f global_pos = get_global_position();
sf::Vector2f local_pos = screen_pos - global_pos;
// Check if within grid bounds
sf::FloatRect bounds = box.getGlobalBounds();
if (local_pos.x < 0 || local_pos.y < 0 ||
local_pos.x >= bounds.width || local_pos.y >= bounds.height) {
return std::nullopt;
}
// Get cell size from texture or default
float cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
float cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
// Apply zoom
cell_width *= zoom;
cell_height *= zoom;
// Calculate grid space position (account for center/pan)
float half_width = bounds.width / 2.0f;
float half_height = bounds.height / 2.0f;
float grid_space_x = (local_pos.x - half_width) / zoom + center_x;
float grid_space_y = (local_pos.y - half_height) / zoom + center_y;
// Convert to cell coordinates
int cell_x = static_cast<int>(std::floor(grid_space_x / (ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH)));
int cell_y = static_cast<int>(std::floor(grid_space_y / (ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT)));
// Check if within valid cell range
if (cell_x < 0 || cell_x >= grid_w || cell_y < 0 || cell_y >= grid_h) {
return std::nullopt;
}
return sf::Vector2i(cell_x, cell_y);
}
// #221 - Get effective cell size (texture size * zoom)
sf::Vector2f UIGrid::getEffectiveCellSize() const {
float cell_w = ptex ? static_cast<float>(ptex->sprite_width) : static_cast<float>(DEFAULT_CELL_WIDTH);
float cell_h = ptex ? static_cast<float>(ptex->sprite_height) : static_cast<float>(DEFAULT_CELL_HEIGHT);
return sf::Vector2f(cell_w * zoom, cell_h * zoom);
}
// Helper function to convert button string to MouseButton enum value
static int buttonStringToEnum(const std::string& button) {
if (button == "left") return 0; // MouseButton.LEFT
if (button == "right") return 1; // MouseButton.RIGHT
if (button == "middle") return 2; // MouseButton.MIDDLE
if (button == "wheel_up") return 3; // MouseButton.WHEEL_UP
if (button == "wheel_down") return 4; // MouseButton.WHEEL_DOWN
return 0; // Default to LEFT
}
// Helper function to convert action string to InputState enum value
static int actionStringToEnum(const std::string& action) {
if (action == "start" || action == "pressed") return 0; // InputState.PRESSED
if (action == "end" || action == "released") return 1; // InputState.RELEASED
return 0; // Default to PRESSED
}
// #142 - Refresh cell callback cache for Python subclass method support
void UIGrid::refreshCellCallbackCache(PyObject* pyObj) {
if (!pyObj || !is_python_subclass) {
cell_callback_cache.valid = false;
return;
}
// Get the class's callback generation counter
PyObject* cls = (PyObject*)Py_TYPE(pyObj);
uint32_t current_gen = 0;
PyObject* gen_obj = PyObject_GetAttrString(cls, "_mcrf_callback_gen");
if (gen_obj) {
current_gen = static_cast<uint32_t>(PyLong_AsUnsignedLong(gen_obj));
Py_DECREF(gen_obj);
} else {
PyErr_Clear();
}
// Check if cache is still valid
if (cell_callback_cache.valid && cell_callback_cache.generation == current_gen) {
return; // Cache is fresh
}
// Refresh cache - check for each cell callback method
cell_callback_cache.has_on_cell_click = false;
cell_callback_cache.has_on_cell_enter = false;
cell_callback_cache.has_on_cell_exit = false;
// Check class hierarchy for each method
PyTypeObject* type = Py_TYPE(pyObj);
while (type && type != &mcrfpydef::PyUIGridType && type != &PyBaseObject_Type) {
if (type->tp_dict) {
if (!cell_callback_cache.has_on_cell_click) {
PyObject* method = PyDict_GetItemString(type->tp_dict, "on_cell_click");
if (method && PyCallable_Check(method)) {
cell_callback_cache.has_on_cell_click = true;
}
}
if (!cell_callback_cache.has_on_cell_enter) {
PyObject* method = PyDict_GetItemString(type->tp_dict, "on_cell_enter");
if (method && PyCallable_Check(method)) {
cell_callback_cache.has_on_cell_enter = true;
}
}
if (!cell_callback_cache.has_on_cell_exit) {
PyObject* method = PyDict_GetItemString(type->tp_dict, "on_cell_exit");
if (method && PyCallable_Check(method)) {
cell_callback_cache.has_on_cell_exit = true;
}
}
}
type = type->tp_base;
}
cell_callback_cache.generation = current_gen;
cell_callback_cache.valid = true;
}
// Helper to create typed cell callback arguments: (Vector, MouseButton, InputState)
static PyObject* createCellCallbackArgs(sf::Vector2i cell, const std::string& button, const std::string& action) {
// Create Vector object for cell position
PyObject* vector_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector");
if (!vector_type) {
PyErr_Print();
return nullptr;
}
PyObject* cell_pos = PyObject_CallFunction(vector_type, "ff", (float)cell.x, (float)cell.y);
Py_DECREF(vector_type);
if (!cell_pos) {
PyErr_Print();
return nullptr;
}
// Create MouseButton enum
int button_val = buttonStringToEnum(button);
PyObject* button_enum = PyObject_CallFunction(PyMouseButton::mouse_button_enum_class, "i", button_val);
if (!button_enum) {
Py_DECREF(cell_pos);
PyErr_Print();
return nullptr;
}
// Create InputState enum
int action_val = actionStringToEnum(action);
PyObject* action_enum = PyObject_CallFunction(PyInputState::input_state_enum_class, "i", action_val);
if (!action_enum) {
Py_DECREF(cell_pos);
Py_DECREF(button_enum);
PyErr_Print();
return nullptr;
}
PyObject* args = Py_BuildValue("(OOO)", cell_pos, button_enum, action_enum);
Py_DECREF(cell_pos);
Py_DECREF(button_enum);
Py_DECREF(action_enum);
return args;
}
// #230 - Helper to create cell hover callback arguments: (Vector) only
static PyObject* createCellHoverArgs(sf::Vector2i cell) {
// Create Vector object for cell position
PyObject* vector_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector");
if (!vector_type) {
PyErr_Print();
return nullptr;
}
PyObject* cell_pos = PyObject_CallFunction(vector_type, "ii", cell.x, cell.y);
Py_DECREF(vector_type);
if (!cell_pos) {
PyErr_Print();
return nullptr;
}
PyObject* args = Py_BuildValue("(O)", cell_pos);
Py_DECREF(cell_pos);
return args;
}
// Fire cell click callback with full signature (cell_pos, button, action)
bool UIGrid::fireCellClick(sf::Vector2i cell, const std::string& button, const std::string& action) {
// Try property-assigned callback first
if (on_cell_click_callable && !on_cell_click_callable->isNone()) {
PyObject* args = createCellCallbackArgs(cell, button, action);
if (args) {
PyObject* result = PyObject_CallObject(on_cell_click_callable->borrow(), args);
Py_DECREF(args);
if (!result) {
std::cerr << "Cell click callback raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
return true;
}
}
// Try Python subclass method
if (is_python_subclass) {
PyObject* pyObj = PythonObjectCache::getInstance().lookup(this->serial_number);
if (pyObj) {
refreshCellCallbackCache(pyObj);
if (cell_callback_cache.has_on_cell_click) {
PyObject* method = PyObject_GetAttrString(pyObj, "on_cell_click");
if (method && PyCallable_Check(method)) {
PyObject* args = createCellCallbackArgs(cell, button, action);
if (args) {
PyObject* result = PyObject_CallObject(method, args);
Py_DECREF(args);
Py_DECREF(method);
Py_DECREF(pyObj);
if (!result) {
std::cerr << "Cell click method raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
return true;
}
}
Py_XDECREF(method);
}
Py_DECREF(pyObj);
}
}
return false;
}
// #230 - Fire cell enter callback with position-only signature (cell_pos)
bool UIGrid::fireCellEnter(sf::Vector2i cell) {
// Try property-assigned callback first (now PyCellHoverCallable)
if (on_cell_enter_callable && !on_cell_enter_callable->isNone()) {
on_cell_enter_callable->call(cell);
return true;
}
// Try Python subclass method
if (is_python_subclass) {
PyObject* pyObj = PythonObjectCache::getInstance().lookup(this->serial_number);
if (pyObj) {
refreshCellCallbackCache(pyObj);
if (cell_callback_cache.has_on_cell_enter) {
PyObject* method = PyObject_GetAttrString(pyObj, "on_cell_enter");
if (method && PyCallable_Check(method)) {
// #230: Cell hover takes only (cell_pos)
PyObject* args = createCellHoverArgs(cell);
if (args) {
PyObject* result = PyObject_CallObject(method, args);
Py_DECREF(args);
Py_DECREF(method);
Py_DECREF(pyObj);
if (!result) {
std::cerr << "Cell enter method raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
return true;
}
}
Py_XDECREF(method);
}
Py_DECREF(pyObj);
}
}
return false;
}
// #230 - Fire cell exit callback with position-only signature (cell_pos)
bool UIGrid::fireCellExit(sf::Vector2i cell) {
// Try property-assigned callback first (now PyCellHoverCallable)
if (on_cell_exit_callable && !on_cell_exit_callable->isNone()) {
on_cell_exit_callable->call(cell);
return true;
}
// Try Python subclass method
if (is_python_subclass) {
PyObject* pyObj = PythonObjectCache::getInstance().lookup(this->serial_number);
if (pyObj) {
refreshCellCallbackCache(pyObj);
if (cell_callback_cache.has_on_cell_exit) {
PyObject* method = PyObject_GetAttrString(pyObj, "on_cell_exit");
if (method && PyCallable_Check(method)) {
// #230: Cell hover takes only (cell_pos)
PyObject* args = createCellHoverArgs(cell);
if (args) {
PyObject* result = PyObject_CallObject(method, args);
Py_DECREF(args);
Py_DECREF(method);
Py_DECREF(pyObj);
if (!result) {
std::cerr << "Cell exit method raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
return true;
}
}
Py_XDECREF(method);
}
Py_DECREF(pyObj);
}
}
return false;
}
// #142 - Update cell hover state and fire callbacks
// #230 - Cell hover callbacks now take only (cell_pos), no button/action
void UIGrid::updateCellHover(sf::Vector2f mousepos, const std::string& button, const std::string& action) {
(void)button; // #230 - No longer used for hover callbacks
(void)action; // #230 - No longer used for hover callbacks
auto new_cell = screenToCell(mousepos);
// Check if cell changed
if (new_cell != hovered_cell) {
// Fire exit callback for old cell
if (hovered_cell.has_value()) {
fireCellExit(hovered_cell.value());
}
// Fire enter callback for new cell
if (new_cell.has_value()) {
fireCellEnter(new_cell.value());
}
hovered_cell = new_cell;
}
}
// UIEntityCollection code has been moved to UIEntityCollection.cpp
// Property system implementation for animations
bool UIGrid::setProperty(const std::string& name, float value) {
if (name == "x") {
position.x = value;
box.setPosition(position);
output.setPosition(position);
markCompositeDirty(); // #144 - Position change, texture still valid
return true;
}
else if (name == "y") {
position.y = value;
box.setPosition(position);
output.setPosition(position);
markCompositeDirty(); // #144 - Position change, texture still valid
return true;
}
else if (name == "w" || name == "width") {
box.setSize(sf::Vector2f(value, box.getSize().y));
output.setTextureRect(sf::IntRect(0, 0, box.getSize().x, box.getSize().y));
markDirty(); // #144 - Size change
return true;
}
else if (name == "h" || name == "height") {
box.setSize(sf::Vector2f(box.getSize().x, value));
output.setTextureRect(sf::IntRect(0, 0, box.getSize().x, box.getSize().y));
markDirty(); // #144 - Size change
return true;
}
else if (name == "center_x") {
center_x = value;
markDirty(); // #144 - View change affects content
return true;
}
else if (name == "center_y") {
center_y = value;
markDirty(); // #144 - View change affects content
return true;
}
else if (name == "zoom") {
zoom = value;
markDirty(); // #144 - View change affects content
return true;
}
else if (name == "camera_rotation") {
camera_rotation = value;
markDirty(); // View rotation affects content
return true;
}
else if (name == "rotation") {
rotation = value;
markCompositeDirty(); // Viewport rotation doesn't affect internal content
return true;
}
else if (name == "origin_x") {
origin.x = value;
markCompositeDirty();
return true;
}
else if (name == "origin_y") {
origin.y = value;
markCompositeDirty();
return true;
}
else if (name == "z_index") {
z_index = static_cast<int>(value);
markDirty(); // #144 - Z-order change affects parent
return true;
}
else if (name == "fill_color.r") {
fill_color.r = static_cast<uint8_t>(std::max(0.0f, std::min(255.0f, value)));
markDirty(); // #144 - Content change
return true;
}
else if (name == "fill_color.g") {
fill_color.g = static_cast<uint8_t>(std::max(0.0f, std::min(255.0f, value)));
markDirty(); // #144 - Content change
return true;
}
else if (name == "fill_color.b") {
fill_color.b = static_cast<uint8_t>(std::max(0.0f, std::min(255.0f, value)));
markDirty(); // #144 - Content change
return true;
}
else if (name == "fill_color.a") {
fill_color.a = static_cast<uint8_t>(std::max(0.0f, std::min(255.0f, value)));
markDirty(); // #144 - Content change
return true;
}
// #106: Shader uniform properties
if (setShaderProperty(name, value)) {
return true;
}
return false;
}
bool UIGrid::setProperty(const std::string& name, const sf::Vector2f& value) {
if (name == "position") {
position = value;
box.setPosition(position);
output.setPosition(position);
markCompositeDirty(); // #144 - Position change, texture still valid
return true;
}
else if (name == "size") {
box.setSize(value);
output.setTextureRect(sf::IntRect(0, 0, box.getSize().x, box.getSize().y));
markDirty(); // #144 - Size change
return true;
}
else if (name == "center") {
center_x = value.x;
center_y = value.y;
markDirty(); // #144 - View change affects content
return true;
}
else if (name == "origin") {
origin = value;
markCompositeDirty();
return true;
}
return false;
}
bool UIGrid::getProperty(const std::string& name, float& value) const {
if (name == "x") {
value = position.x;
return true;
}
else if (name == "y") {
value = position.y;
return true;
}
else if (name == "w" || name == "width") {
value = box.getSize().x;
return true;
}
else if (name == "h" || name == "height") {
value = box.getSize().y;
return true;
}
else if (name == "center_x") {
value = center_x;
return true;
}
else if (name == "center_y") {
value = center_y;
return true;
}
else if (name == "zoom") {
value = zoom;
return true;
}
else if (name == "camera_rotation") {
value = camera_rotation;
return true;
}
else if (name == "rotation") {
value = rotation;
return true;
}
else if (name == "origin_x") {
value = origin.x;
return true;
}
else if (name == "origin_y") {
value = origin.y;
return true;
}
else if (name == "z_index") {
value = static_cast<float>(z_index);
return true;
}
else if (name == "fill_color.r") {
value = static_cast<float>(fill_color.r);
return true;
}
else if (name == "fill_color.g") {
value = static_cast<float>(fill_color.g);
return true;
}
else if (name == "fill_color.b") {
value = static_cast<float>(fill_color.b);
return true;
}
else if (name == "fill_color.a") {
value = static_cast<float>(fill_color.a);
return true;
}
// #106: Shader uniform properties
if (getShaderProperty(name, value)) {
return true;
}
return false;
}
bool UIGrid::getProperty(const std::string& name, sf::Vector2f& value) const {
if (name == "position") {
value = position;
return true;
}
else if (name == "size") {
value = box.getSize();
return true;
}
else if (name == "center") {
value = sf::Vector2f(center_x, center_y);
return true;
}
else if (name == "origin") {
value = origin;
return true;
}
return false;
}
bool UIGrid::hasProperty(const std::string& name) const {
// Float properties
if (name == "x" || name == "y" ||
name == "w" || name == "h" || name == "width" || name == "height" ||
name == "center_x" || name == "center_y" || name == "zoom" ||
name == "camera_rotation" || name == "rotation" ||
name == "origin_x" || name == "origin_y" || name == "z_index" ||
name == "fill_color.r" || name == "fill_color.g" ||
name == "fill_color.b" || name == "fill_color.a") {
return true;
}
// Vector2f properties
if (name == "position" || name == "size" || name == "center" || name == "origin") {
return true;
}
// #106: Shader uniform properties
if (hasShaderProperty(name)) {
return true;
}
return false;
}