feat: Migrate Grid to user-driven layer rendering (closes #150)

- Add `layers` dict parameter to Grid constructor for explicit layer definitions
  - `layers={"ground": "color", "terrain": "tile"}` creates named layers
  - `layers={}` creates empty grid (entities + pathfinding only)
  - Default creates single TileLayer named "tilesprite" for backward compat

- Implement dynamic GridPoint property access via layer names
  - `grid.at(x,y).layer_name = value` routes to corresponding layer
  - Protected names (walkable, transparent, etc.) still use GridPoint

- Remove base layer rendering from UIGrid::render()
  - Layers are now the sole source of grid rendering
  - Old chunk_manager remains for GridPoint data access
  - FOV overlay unchanged

- Update test to use explicit `layers={}` parameter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-11-28 23:04:09 -05:00
parent 9469c04b01
commit a258613faa
6 changed files with 200 additions and 87 deletions

View File

@ -5,6 +5,7 @@
#include <SFML/Graphics.hpp> #include <SFML/Graphics.hpp>
#include <memory> #include <memory>
#include <vector> #include <vector>
#include <string>
// Forward declarations // Forward declarations
class UIGrid; class UIGrid;
@ -23,6 +24,7 @@ enum class GridLayerType {
class GridLayer { class GridLayer {
public: public:
GridLayerType type; GridLayerType type;
std::string name; // #150 - Layer name for GridPoint property access
int z_index; // Negative = below entities, >= 0 = above entities int z_index; // Negative = below entities, >= 0 = above entities
int grid_x, grid_y; // Dimensions int grid_x, grid_y; // Dimensions
UIGrid* parent_grid; // Parent grid reference UIGrid* parent_grid; // Parent grid reference

View File

@ -5,7 +5,8 @@
#include "UIEntity.h" #include "UIEntity.h"
#include "Profiler.h" #include "Profiler.h"
#include <algorithm> #include <algorithm>
#include <cmath> // #142 - for std::floor #include <cmath> // #142 - for std::floor
#include <cstring> // #150 - for strcmp
// UIDrawable methods now in UIBase.h // UIDrawable methods now in UIBase.h
UIGrid::UIGrid() UIGrid::UIGrid()
@ -159,84 +160,14 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
int left_spritepixels = center_x - (box.getSize().x / 2.0 / zoom); int left_spritepixels = center_x - (box.getSize().x / 2.0 / zoom);
int top_spritepixels = center_y - (box.getSize().y / 2.0 / zoom); int top_spritepixels = center_y - (box.getSize().y / 2.0 / zoom);
//sprite.setScale(sf::Vector2f(zoom, zoom));
sf::RectangleShape r; // for colors and overlays
r.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));
r.setOutlineThickness(0);
int x_limit = left_edge + width_sq + 2; int x_limit = left_edge + width_sq + 2;
if (x_limit > grid_x) x_limit = grid_x; if (x_limit > grid_x) x_limit = grid_x;
int y_limit = top_edge + height_sq + 2; int y_limit = top_edge + height_sq + 2;
if (y_limit > grid_y) y_limit = grid_y; if (y_limit > grid_y) y_limit = grid_y;
// base layer - bottom color, tile sprite ("ground") // #150 - Layers are now the sole source of grid rendering (base layer removed)
int cellsRendered = 0; // Render layers with z_index < 0 (below entities)
// #123 - Use chunk-based rendering for large grids
if (use_chunks && chunk_manager) {
// Get visible chunks based on cell coordinate bounds
float right_edge = left_edge + width_sq + 2;
float bottom_edge = top_edge + height_sq + 2;
auto visible_chunks = chunk_manager->getVisibleChunks(left_edge, top_edge, right_edge, bottom_edge);
for (auto* chunk : visible_chunks) {
// Re-render dirty chunks to their cached textures
if (chunk->dirty) {
chunk->renderToTexture(cell_width, cell_height, ptex);
}
// Calculate pixel position for this chunk's sprite
float chunk_pixel_x = (chunk->world_x * cell_width - left_spritepixels) * zoom;
float chunk_pixel_y = (chunk->world_y * cell_height - top_spritepixels) * zoom;
// Set up and draw the chunk sprite
chunk->cached_sprite.setPosition(chunk_pixel_x, chunk_pixel_y);
chunk->cached_sprite.setScale(zoom, zoom);
renderTexture.draw(chunk->cached_sprite);
cellsRendered += chunk->width * chunk->height;
}
} else {
// Original cell-by-cell rendering for small grids
for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0);
x < x_limit; //x < view_width;
x+=1)
{
//for (float y = (top_edge >= 0 ? top_edge : 0);
for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0);
y < y_limit; //y < view_height;
y+=1)
{
auto pixel_pos = sf::Vector2f(
(x*cell_width - left_spritepixels) * zoom,
(y*cell_height - top_spritepixels) * zoom );
auto gridpoint = at(std::floor(x), std::floor(y));
//sprite.setPosition(pixel_pos);
r.setPosition(pixel_pos);
r.setFillColor(gridpoint.color);
renderTexture.draw(r);
// tilesprite - only draw if texture is available
// if discovered but not visible, set opacity to 90%
// if not discovered... just don't draw it?
if (ptex && gridpoint.tilesprite != -1) {
sprite = ptex->sprite(gridpoint.tilesprite, pixel_pos, sf::Vector2f(zoom, zoom)); //setSprite(gridpoint.tilesprite);;
renderTexture.draw(sprite);
}
cellsRendered++;
}
}
}
// Record how many cells were rendered
Resources::game->metrics.gridCellsRendered += cellsRendered;
// #147 - Render dynamic layers with z_index < 0 (below entities)
sortLayers(); sortLayers();
for (auto& layer : layers) { for (auto& layer : layers) {
if (layer->z_index >= 0) break; // Stop at layers that go above entities if (layer->z_index >= 0) break; // Stop at layers that go above entities
@ -450,20 +381,42 @@ PyObjectsEnum UIGrid::derived_type()
} }
// #147 - Layer management methods // #147 - Layer management methods
std::shared_ptr<ColorLayer> UIGrid::addColorLayer(int z_index) { std::shared_ptr<ColorLayer> UIGrid::addColorLayer(int z_index, const std::string& name) {
auto layer = std::make_shared<ColorLayer>(z_index, grid_x, grid_y, this); auto layer = std::make_shared<ColorLayer>(z_index, grid_x, grid_y, this);
layer->name = name;
layers.push_back(layer); layers.push_back(layer);
layers_need_sort = true; layers_need_sort = true;
return layer; return layer;
} }
std::shared_ptr<TileLayer> UIGrid::addTileLayer(int z_index, std::shared_ptr<PyTexture> texture) { 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_x, grid_y, this, texture); auto layer = std::make_shared<TileLayer>(z_index, grid_x, grid_y, this, texture);
layer->name = name;
layers.push_back(layer); layers.push_back(layer);
layers_need_sort = true; layers_need_sort = true;
return layer; 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 properties
static const std::vector<std::string> protected_names = {
"walkable", "transparent", "color", "color_overlay"
};
for (const auto& pn : protected_names) {
if (name == pn) return true;
}
return false;
}
void UIGrid::removeLayer(std::shared_ptr<GridLayer> layer) { void UIGrid::removeLayer(std::shared_ptr<GridLayer> layer) {
auto it = std::find(layers.begin(), layers.end(), layer); auto it = std::find(layers.begin(), layers.end(), layer);
if (it != layers.end()) { if (it != layers.end()) {
@ -779,6 +732,7 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
PyObject* textureObj = nullptr; PyObject* textureObj = nullptr;
PyObject* fill_color = nullptr; PyObject* fill_color = nullptr;
PyObject* click_handler = nullptr; PyObject* click_handler = nullptr;
PyObject* layers_obj = nullptr; // #150 - layers dict
float center_x = 0.0f, center_y = 0.0f; float center_x = 0.0f, center_y = 0.0f;
float zoom = 1.0f; float zoom = 1.0f;
// perspective is now handled via properties, not init args // perspective is now handled via properties, not init args
@ -788,21 +742,23 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
const char* name = nullptr; const char* name = nullptr;
float x = 0.0f, y = 0.0f, w = 0.0f, h = 0.0f; float x = 0.0f, y = 0.0f, w = 0.0f, h = 0.0f;
int grid_x = 2, grid_y = 2; // Default to 2x2 grid int grid_x = 2, grid_y = 2; // Default to 2x2 grid
// Keywords list matches the new spec: positional args first, then all keyword args // Keywords list matches the new spec: positional args first, then all keyword args
static const char* kwlist[] = { static const char* kwlist[] = {
"pos", "size", "grid_size", "texture", // Positional args (as per spec) "pos", "size", "grid_size", "texture", // Positional args (as per spec)
// Keyword-only args // Keyword-only args
"fill_color", "click", "center_x", "center_y", "zoom", "fill_color", "click", "center_x", "center_y", "zoom",
"visible", "opacity", "z_index", "name", "x", "y", "w", "h", "grid_x", "grid_y", "visible", "opacity", "z_index", "name", "x", "y", "w", "h", "grid_x", "grid_y",
"layers", // #150 - layers dict parameter
nullptr nullptr
}; };
// Parse arguments with | for optional positional args // Parse arguments with | for optional positional args
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOfffifizffffii", const_cast<char**>(kwlist), if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOfffifizffffiiO", const_cast<char**>(kwlist),
&pos_obj, &size_obj, &grid_size_obj, &textureObj, // Positional &pos_obj, &size_obj, &grid_size_obj, &textureObj, // Positional
&fill_color, &click_handler, &center_x, &center_y, &zoom, &fill_color, &click_handler, &center_x, &center_y, &zoom,
&visible, &opacity, &z_index, &name, &x, &y, &w, &h, &grid_x, &grid_y)) { &visible, &opacity, &z_index, &name, &x, &y, &w, &h, &grid_x, &grid_y,
&layers_obj)) {
return -1; return -1;
} }
@ -935,7 +891,55 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
} }
self->data->click_register(click_handler); self->data->click_register(click_handler);
} }
// #150 - Handle layers dict
// Default: {"tilesprite": "tile"} when layers not provided
// Empty dict: no rendering layers (entity storage + pathfinding only)
if (layers_obj == nullptr) {
// Default layer: single TileLayer named "tilesprite"
self->data->addTileLayer(0, texture_ptr, "tilesprite");
} else if (layers_obj != Py_None) {
if (!PyDict_Check(layers_obj)) {
PyErr_SetString(PyExc_TypeError, "layers must be a dict mapping names to types ('color' or 'tile')");
return -1;
}
PyObject* key;
PyObject* value;
Py_ssize_t pos = 0;
int layer_z = 0; // Auto-increment z_index for each layer
while (PyDict_Next(layers_obj, &pos, &key, &value)) {
if (!PyUnicode_Check(key)) {
PyErr_SetString(PyExc_TypeError, "Layer names must be strings");
return -1;
}
if (!PyUnicode_Check(value)) {
PyErr_SetString(PyExc_TypeError, "Layer types must be strings ('color' or 'tile')");
return -1;
}
const char* layer_name = PyUnicode_AsUTF8(key);
const char* layer_type = PyUnicode_AsUTF8(value);
// Check for protected names
if (UIGrid::isProtectedLayerName(layer_name)) {
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer_name);
return -1;
}
if (strcmp(layer_type, "color") == 0) {
self->data->addColorLayer(layer_z++, layer_name);
} else if (strcmp(layer_type, "tile") == 0) {
self->data->addTileLayer(layer_z++, texture_ptr, layer_name);
} else {
PyErr_Format(PyExc_ValueError, "Unknown layer type '%s' (expected 'color' or 'tile')", layer_type);
return -1;
}
}
}
// else: layers_obj is Py_None - explicit empty, no layers created
// Initialize weak reference list // Initialize weak reference list
self->weakreflist = NULL; self->weakreflist = NULL;

View File

@ -95,11 +95,15 @@ public:
std::vector<std::shared_ptr<GridLayer>> layers; std::vector<std::shared_ptr<GridLayer>> layers;
bool layers_need_sort = true; // Dirty flag for z_index sorting bool layers_need_sort = true; // Dirty flag for z_index sorting
// Layer management // Layer management (#150 - extended with names)
std::shared_ptr<ColorLayer> addColorLayer(int z_index); std::shared_ptr<ColorLayer> addColorLayer(int z_index, const std::string& name = "");
std::shared_ptr<TileLayer> addTileLayer(int z_index, std::shared_ptr<PyTexture> texture = nullptr); std::shared_ptr<TileLayer> addTileLayer(int z_index, std::shared_ptr<PyTexture> texture = nullptr, const std::string& name = "");
void removeLayer(std::shared_ptr<GridLayer> layer); void removeLayer(std::shared_ptr<GridLayer> layer);
void sortLayers(); void sortLayers();
std::shared_ptr<GridLayer> getLayerByName(const std::string& name);
// #150 - Protected layer names (reserved for GridPoint properties)
static bool isProtectedLayerName(const std::string& name);
// Background rendering // Background rendering
sf::Color fill_color; sf::Color fill_color;

View File

@ -1,5 +1,7 @@
#include "UIGridPoint.h" #include "UIGridPoint.h"
#include "UIGrid.h" #include "UIGrid.h"
#include "GridLayers.h" // #150 - for GridLayerType, ColorLayer, TileLayer
#include <cstring> // #150 - for strcmp
UIGridPoint::UIGridPoint() UIGridPoint::UIGridPoint()
: color(1.0f, 1.0f, 1.0f), color_overlay(0.0f, 0.0f, 0.0f), walkable(false), transparent(false), : color(1.0f, 1.0f, 1.0f), color_overlay(0.0f, 0.0f, 0.0f), walkable(false), transparent(false),
@ -193,9 +195,103 @@ PyObject* UIGridPointState::repr(PyUIGridPointStateObject* self) {
if (!self->data) ss << "<GridPointState (invalid internal object)>"; if (!self->data) ss << "<GridPointState (invalid internal object)>";
else { else {
auto gps = self->data; auto gps = self->data;
ss << "<GridPointState (visible=" << (gps->visible ? "True" : "False") << ", discovered=" << (gps->discovered ? "True" : "False") << ss << "<GridPointState (visible=" << (gps->visible ? "True" : "False") << ", discovered=" << (gps->discovered ? "True" : "False") <<
")>"; ")>";
} }
std::string repr_str = ss.str(); std::string repr_str = ss.str();
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace"); return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
} }
// #150 - Dynamic attribute access for named layers
PyObject* UIGridPoint::getattro(PyUIGridPointObject* self, PyObject* name) {
// First try standard attribute lookup (built-in properties)
PyObject* result = PyObject_GenericGetAttr((PyObject*)self, name);
if (result != nullptr || !PyErr_ExceptionMatches(PyExc_AttributeError)) {
return result;
}
// Clear the AttributeError and check for layer name
PyErr_Clear();
if (!self->grid) {
PyErr_SetString(PyExc_RuntimeError, "GridPoint has no parent grid");
return nullptr;
}
const char* attr_name = PyUnicode_AsUTF8(name);
if (!attr_name) return nullptr;
// Look up layer by name
auto layer = self->grid->getLayerByName(attr_name);
if (!layer) {
PyErr_Format(PyExc_AttributeError, "'GridPoint' object has no attribute '%s'", attr_name);
return nullptr;
}
int x = self->data->grid_x;
int y = self->data->grid_y;
// Get value based on layer type
if (layer->type == GridLayerType::Color) {
auto color_layer = std::static_pointer_cast<ColorLayer>(layer);
return sfColor_to_PyObject(color_layer->at(x, y));
} else if (layer->type == GridLayerType::Tile) {
auto tile_layer = std::static_pointer_cast<TileLayer>(layer);
return PyLong_FromLong(tile_layer->at(x, y));
}
PyErr_SetString(PyExc_RuntimeError, "Unknown layer type");
return nullptr;
}
int UIGridPoint::setattro(PyUIGridPointObject* self, PyObject* name, PyObject* value) {
// First try standard attribute setting (built-in properties)
// We need to check if this is a known attribute first
const char* attr_name = PyUnicode_AsUTF8(name);
if (!attr_name) return -1;
// Check if it's a built-in property (defined in getsetters)
for (PyGetSetDef* gsd = UIGridPoint::getsetters; gsd->name != nullptr; gsd++) {
if (strcmp(gsd->name, attr_name) == 0) {
// It's a built-in property, use standard setter
return PyObject_GenericSetAttr((PyObject*)self, name, value);
}
}
// Not a built-in property - try layer lookup
if (!self->grid) {
PyErr_SetString(PyExc_RuntimeError, "GridPoint has no parent grid");
return -1;
}
auto layer = self->grid->getLayerByName(attr_name);
if (!layer) {
PyErr_Format(PyExc_AttributeError, "'GridPoint' object has no attribute '%s'", attr_name);
return -1;
}
int x = self->data->grid_x;
int y = self->data->grid_y;
// Set value based on layer type
if (layer->type == GridLayerType::Color) {
auto color_layer = std::static_pointer_cast<ColorLayer>(layer);
sf::Color color = PyObject_to_sfColor(value);
if (PyErr_Occurred()) return -1;
color_layer->at(x, y) = color;
color_layer->markDirty();
return 0;
} else if (layer->type == GridLayerType::Tile) {
auto tile_layer = std::static_pointer_cast<TileLayer>(layer);
if (!PyLong_Check(value)) {
PyErr_SetString(PyExc_TypeError, "Tile layer values must be integers");
return -1;
}
tile_layer->at(x, y) = PyLong_AsLong(value);
tile_layer->markDirty();
return 0;
}
PyErr_SetString(PyExc_RuntimeError, "Unknown layer type");
return -1;
}

View File

@ -52,6 +52,10 @@ public:
static PyObject* get_bool_member(PyUIGridPointObject* self, void* closure); static PyObject* get_bool_member(PyUIGridPointObject* self, void* closure);
static int set_color(PyUIGridPointObject* self, PyObject* value, void* closure); static int set_color(PyUIGridPointObject* self, PyObject* value, void* closure);
static PyObject* repr(PyUIGridPointObject* self); static PyObject* repr(PyUIGridPointObject* self);
// #150 - Dynamic property access for named layers
static PyObject* getattro(PyUIGridPointObject* self, PyObject* name);
static int setattro(PyUIGridPointObject* self, PyObject* name, PyObject* value);
}; };
// UIGridPointState - entity-specific info for each cell // UIGridPointState - entity-specific info for each cell
@ -73,6 +77,9 @@ namespace mcrfpydef {
.tp_basicsize = sizeof(PyUIGridPointObject), .tp_basicsize = sizeof(PyUIGridPointObject),
.tp_itemsize = 0, .tp_itemsize = 0,
.tp_repr = (reprfunc)UIGridPoint::repr, .tp_repr = (reprfunc)UIGridPoint::repr,
// #150 - Dynamic attribute access for named layers
.tp_getattro = (getattrofunc)UIGridPoint::getattro,
.tp_setattro = (setattrofunc)UIGridPoint::setattro,
.tp_flags = Py_TPFLAGS_DEFAULT, .tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = "UIGridPoint object", .tp_doc = "UIGridPoint object",
.tp_getset = UIGridPoint::getsetters, .tp_getset = UIGridPoint::getsetters,

View File

@ -21,13 +21,13 @@ def run_test(runtime):
ui = mcrfpy.sceneUI("test") ui = mcrfpy.sceneUI("test")
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16) texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
# Create grid # Create grid with explicit empty layers (#150 migration)
grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15), texture=texture) grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15), texture=texture, layers={})
ui.append(grid) ui.append(grid)
print("\n--- Test 1: Initial state (no layers) ---") print("\n--- Test 1: Initial state (no layers) ---")
if len(grid.layers) == 0: if len(grid.layers) == 0:
print(" PASS: Grid starts with no layers") print(" PASS: Grid starts with no layers (layers={})")
else: else:
print(f" FAIL: Expected 0 layers, got {len(grid.layers)}") print(f" FAIL: Expected 0 layers, got {len(grid.layers)}")
sys.exit(1) sys.exit(1)