Phase 4.3: Grid auto-creates GridView with rendering property sync

Grid.__init__() now auto-creates a GridView that shares the Grid's
data via aliasing shared_ptr. This enables the Grid/GridView split:

- PyUIGridObject gains a `view` member (shared_ptr<UIGridView>)
- Grid.view property exposes the auto-created GridView (read-only)
- Rendering property setters (center_x/y, zoom, camera_rotation, x, y,
  w, h) sync changes to the view automatically
- Grid still works as UIDrawable in scenes (no substitution) — backward
  compatible with all existing code and subclasses
- GridView.grid returns the original Grid with identity preservation
- Explicit GridViews (created by user) are independent of Grid's own
  rendering properties

Addresses #252. All 260 tests pass, no breaking changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-03-19 11:24:47 -04:00
commit a35352df4e
4 changed files with 197 additions and 1 deletions

View file

@ -27,9 +27,11 @@ typedef struct {
} PyUICaptionObject;
class UIGrid;
class UIGridView;
typedef struct {
PyObject_HEAD
std::shared_ptr<UIGrid> data;
std::shared_ptr<UIGridView> view; // #252: auto-created rendering view (shim)
PyObject* weakreflist; // Weak reference support
} PyUIGridObject;

View file

@ -1,4 +1,5 @@
#include "UIGrid.h"
#include "UIGridView.h" // #252: GridView shim
#include "UIGridPathfinding.h" // New pathfinding API
#include "GameEngine.h"
#include "McRFPy_API.h"
@ -996,6 +997,33 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
// #184: Check if this is a Python subclass (for callback method support)
self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != (PyObject*)&mcrfpydef::PyUIGridType;
// #252 shim: auto-create a GridView for rendering
// The GridView shares GridData (via aliasing shared_ptr) and copies rendering state
{
auto view = std::make_shared<UIGridView>();
// Share grid data (aliasing shared_ptr: shares UIGrid ownership, points to GridData base)
view->grid_data = std::shared_ptr<GridData>(
self->data, static_cast<GridData*>(self->data.get()));
// Copy rendering state from UIGrid to GridView
view->ptex = texture_ptr;
view->box.setPosition(self->data->box.getPosition());
view->box.setSize(self->data->box.getSize());
view->position = self->data->position;
view->center_x = self->data->center_x;
view->center_y = self->data->center_y;
view->zoom = self->data->zoom;
view->fill_color = self->data->fill_color;
view->camera_rotation = self->data->camera_rotation;
view->perspective_entity = self->data->perspective_entity;
view->perspective_enabled = self->data->perspective_enabled;
view->visible = self->data->visible;
view->opacity = self->data->opacity;
view->z_index = self->data->z_index;
view->name = self->data->name;
view->ensureRenderTextureSize();
self->view = view;
}
return 0; // Success
}
@ -1158,6 +1186,23 @@ int UIGrid::set_float_member(PyUIGridObject* self, PyObject* value, void* closur
self->data->zoom = val;
else if (member_ptr == 7) // camera_rotation
self->data->camera_rotation = val;
// #252 shim: sync rendering state to GridView
if (self->view) {
if (member_ptr == 0) // x
self->view->box.setPosition(val, self->view->box.getPosition().y);
else if (member_ptr == 1) // y
self->view->box.setPosition(self->view->box.getPosition().x, val);
else if (member_ptr == 2) // w
self->view->box.setSize(sf::Vector2f(val, self->view->box.getSize().y));
else if (member_ptr == 3) // h
self->view->box.setSize(sf::Vector2f(self->view->box.getSize().x, val));
else if (member_ptr == 4) self->view->center_x = val;
else if (member_ptr == 5) self->view->center_y = val;
else if (member_ptr == 6) self->view->zoom = val;
else if (member_ptr == 7) self->view->camera_rotation = val;
self->view->position = self->view->box.getPosition();
}
return 0;
}
// TODO (7DRL Day 2, item 5.) return Texture object
@ -2565,6 +2610,10 @@ PyGetSetDef UIGrid::getsetters[] = {
{"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),
// #252 - GridView shim
{"view", (getter)UIGrid::get_view, NULL,
"Auto-created GridView for rendering (read-only). "
"When Grid is appended to a scene, this view is what actually renders.", NULL},
{NULL} /* Sentinel */
};
@ -2594,6 +2643,18 @@ PyObject* UIGrid::get_children(PyUIGridObject* self, void* closure)
return (PyObject*)o;
}
// #252 - get_view returns the auto-created GridView
PyObject* UIGrid::get_view(PyUIGridObject* self, void* closure)
{
if (!self->view) Py_RETURN_NONE;
auto type = &mcrfpydef::PyUIGridViewType;
auto obj = (PyUIGridViewObject*)type->tp_alloc(type, 0);
if (!obj) return PyErr_NoMemory();
obj->data = self->view;
obj->weakreflist = NULL;
return (PyObject*)obj;
}
PyObject* UIGrid::repr(PyUIGridObject* self)
{
std::ostringstream ss;

View file

@ -27,6 +27,7 @@
#include "SpatialHash.h"
#include "UIEntityCollection.h" // EntityCollection types (extracted from UIGrid)
#include "GridData.h" // #252 - Data layer base class
#include "UIGridView.h" // #252 - GridView shim
// Forward declaration for pathfinding
class DijkstraMap;
@ -152,6 +153,7 @@ public:
static int set_on_cell_click(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* get_hovered_cell(PyUIGridObject* self, void* closure);
static PyObject* get_view(PyUIGridObject* self, void* closure); // #252 shim
static PyObject* py_add_layer(PyUIGridObject* self, PyObject* args);
static PyObject* py_remove_layer(PyUIGridObject* self, PyObject* args);
static PyObject* get_layers(PyUIGridObject* self, void* closure);
@ -189,6 +191,7 @@ namespace mcrfpydef {
obj->data->on_cell_exit_callable.reset();
obj->data->on_cell_click_callable.reset();
}
obj->view.reset(); // #252: release GridView shim
obj->data.reset();
Py_TYPE(self)->tp_free(self);
},
@ -300,7 +303,11 @@ namespace mcrfpydef {
.tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject*
{
PyUIGridObject* self = (PyUIGridObject*)type->tp_alloc(type, 0);
if (self) self->data = std::make_shared<UIGrid>();
if (self) {
self->data = std::make_shared<UIGrid>();
// Placement-new the shared_ptr<UIGridView> (tp_alloc zero-fills, not construct)
new (&self->view) std::shared_ptr<UIGridView>();
}
return (PyObject*)self;
}
};

View file

@ -0,0 +1,126 @@
"""Regression test for Grid backward compatibility with GridView shim (#252)."""
import mcrfpy
import sys
def test_grid_creates_view():
"""Grid auto-creates a GridView accessible via .view property."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
assert grid.view is not None, "Grid should auto-create a view"
assert isinstance(grid.view, mcrfpy.GridView)
print("PASS: Grid auto-creates view")
def test_view_shares_grid_data():
"""GridView created by shim shares the same grid data."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
view = grid.view
# View's grid should be the same Grid
assert view.grid is grid, "view.grid should be the same Grid"
assert view.grid.grid_w == 10
print("PASS: view shares grid data")
def test_rendering_property_sync():
"""Setting rendering properties on Grid syncs to the view."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
view = grid.view
grid.zoom = 3.0
assert abs(view.zoom - 3.0) < 0.01, f"View zoom should sync: {view.zoom}"
grid.center_x = 200.0
assert abs(view.center.x - 200.0) < 0.01, f"View center_x should sync: {view.center.x}"
grid.center_y = 150.0
assert abs(view.center.y - 150.0) < 0.01, f"View center_y should sync: {view.center.y}"
grid.camera_rotation = 45.0
# camera_rotation syncs through set_float_member
print("PASS: rendering properties sync to view")
def test_grid_still_works_in_scene():
"""Grid can still be appended to scenes and works as before."""
scene = mcrfpy.Scene("test_compat")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
scene.children.append(grid)
# Grid is in the scene as itself (not substituted)
assert len(scene.children) == 1
retrieved = scene.children[0]
assert type(retrieved).__name__ == "Grid", f"Expected Grid, got {type(retrieved).__name__}"
print("PASS: Grid works in scene as before")
def test_grid_subclass_preserved():
"""Grid subclasses maintain identity when appended to scene."""
scene = mcrfpy.Scene("test_subclass_compat")
mcrfpy.current_scene = scene
class MyGrid(mcrfpy.Grid):
pass
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = MyGrid(grid_size=(5, 5), texture=tex, pos=(0, 0), size=(80, 80))
scene.children.append(grid)
retrieved = scene.children[0]
assert isinstance(retrieved, MyGrid), f"Expected MyGrid, got {type(retrieved).__name__}"
print("PASS: Grid subclass identity preserved")
def test_entity_operations_unaffected():
"""Entity creation and manipulation still works normally."""
scene = mcrfpy.Scene("test_entity_compat")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
scene.children.append(grid)
for y in range(20):
for x in range(20):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
e = mcrfpy.Entity((5, 5), grid=grid)
assert len(grid.entities) == 1
assert e.cell_x == 5
assert e.cell_y == 5
assert len(grid.at(5, 5).entities) == 1
print("PASS: entity operations unaffected")
def test_gridview_independent_rendering():
"""A GridView can have different rendering settings from the Grid."""
scene = mcrfpy.Scene("test_independent")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
# Create an explicit GridView with different settings
view2 = mcrfpy.GridView(grid=grid, pos=(350, 0), size=(160, 160), zoom=0.5)
scene.children.append(grid)
scene.children.append(view2)
# Grid and view2 should have different zoom
assert abs(grid.zoom - 1.0) < 0.01
assert abs(view2.zoom - 0.5) < 0.01
# Changing grid zoom doesn't affect explicit GridView
grid.zoom = 2.0
assert abs(view2.zoom - 0.5) < 0.01, "Explicit GridView should keep its own zoom"
print("PASS: GridView independent rendering")
if __name__ == "__main__":
test_grid_creates_view()
test_view_shares_grid_data()
test_rendering_property_sync()
test_grid_still_works_in_scene()
test_grid_subclass_preserved()
test_entity_operations_unaffected()
test_gridview_independent_rendering()
print("All backward compatibility tests passed")
sys.exit(0)