refactor(grid): GridData is a map, not a widget -- delete UIGrid's drawable half

closes #361
closes #370
closes #371

UIGrid inherited both UIDrawable and GridData, so every map carried a second
camera and RenderTexture that nothing on screen corresponded to. Appending a
_GridData to a scene drew the whole map again through that ghost, frozen at
construction values. GridData is now pure data -- no position, no size, no
render() -- and UIGridView is the only widget.

UIGrid is deleted rather than demoted: shedding UIDrawable left an empty
subclass whose only job was to be what an aliasing shared_ptr pointed into.
GridData absorbs the texture (it defines the cell size, which every tile<->pixel
conversion depends on) and its own PythonObjectCache serial; the binding layer
becomes struct PyGridData. PyObjectsEnum::UIGRID and its 14 case arms go with it.

  * mcrfpy.GridData is public and standalone-constructible. A map with no view
    at all -- an offscreen level, still steppable and pathable -- was previously
    undefined behavior: GridData::markDirty did static_cast<UIGrid*>(this).
  * UIGridView::init constructs the GridData directly instead of building a
    throwaway Python _GridData, stealing its base subobject, and copying ~15
    fields of rendering state out of the discarded wrapper. One init() serves
    both modes, so a second view is no longer a crippled kwarg subset
    (Grid(grid=other, z_index=5) used to raise TypeError).
  * entity.grid returns the GridData. Accepted break: an entity may be on a map
    with no view or with several, so "the grid an entity is in" is not a camera.
    Migrate player.grid.center_camera(p) -> view.center_camera(p).
  * tp_name is mcrfpy.GridView; mcrfpy.Grid is an alias to the same type object.

Two latent bugs surfaced and are fixed here:

  #370  Grid.center_camera() and Grid.size = ... were SILENT NO-OPS -- neither
        was defined on the view, so both delegated to the ghost camera. On
        master, center_camera((5,5)) leaves center_x at 50.0.
  #371  The shader uniform binder strcmp'd tp_name == "mcrfpy.Grid" (the VIEW's
        name) then cast to PyUIGridObject. UB that only worked because both
        wrappers had identical layout and both classes put UIDrawable at offset 0.

Suite 327/327.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-07-13 07:14:43 -04:00
commit c696b4ef41
51 changed files with 1520 additions and 2633 deletions

File diff suppressed because one or more lines are too long

View file

@ -10,7 +10,7 @@
#include "UIFrame.h"
#include "UICaption.h"
#include "UISprite.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UIGridView.h"
#include "UILine.h"
#include "UICircle.h"
@ -574,17 +574,6 @@ static PyObject* convertDrawableToPython(std::shared_ptr<UIDrawable> drawable) {
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIGRID:
{
type = &mcrfpydef::PyUIGridType;
auto pyObj = (PyUIGridObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UIGrid>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIGRIDVIEW:
{
type = &mcrfpydef::PyUIGridViewType;

View file

@ -1,6 +1,6 @@
#include "EntityBehavior.h"
#include "UIEntity.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UIGridPathfinding.h"
#include "PathProvider.h"
#include <random>
@ -25,21 +25,21 @@ static thread_local std::mt19937 rng{std::random_device{}()};
// Per-behavior execution functions
// =============================================================================
static BehaviorOutput executeIdle(UIEntity& entity, UIGrid& grid) {
static BehaviorOutput executeIdle(UIEntity& entity, GridData& grid) {
return {BehaviorResult::NO_ACTION, {}};
}
static BehaviorOutput executeCustom(UIEntity& entity, UIGrid& grid) {
static BehaviorOutput executeCustom(UIEntity& entity, GridData& grid) {
// CUSTOM does nothing built-in — step callback handles everything
return {BehaviorResult::NO_ACTION, {}};
}
static bool isCellWalkable(UIGrid& grid, int x, int y) {
static bool isCellWalkable(GridData& grid, int x, int y) {
if (x < 0 || x >= grid.grid_w || y < 0 || y >= grid.grid_h) return false;
return grid.isWalkable(x, y); // #332
}
static BehaviorOutput executeNoise(UIEntity& entity, UIGrid& grid, bool include_diagonals) {
static BehaviorOutput executeNoise(UIEntity& entity, GridData& grid, bool include_diagonals) {
int cx = entity.cell_position.x;
int cy = entity.cell_position.y;
@ -70,7 +70,7 @@ static BehaviorOutput executeNoise(UIEntity& entity, UIGrid& grid, bool include_
return {BehaviorResult::MOVED, target};
}
static BehaviorOutput executePath(UIEntity& entity, UIGrid& grid) {
static BehaviorOutput executePath(UIEntity& entity, GridData& grid) {
auto& behavior = entity.behavior;
if (behavior.path_step_index >= static_cast<int>(behavior.current_path.size())) {
@ -87,7 +87,7 @@ static BehaviorOutput executePath(UIEntity& entity, UIGrid& grid) {
return {BehaviorResult::MOVED, target};
}
static BehaviorOutput executeWaypoint(UIEntity& entity, UIGrid& grid) {
static BehaviorOutput executeWaypoint(UIEntity& entity, GridData& grid) {
auto& behavior = entity.behavior;
if (behavior.waypoints.empty()) {
@ -136,7 +136,7 @@ static BehaviorOutput executeWaypoint(UIEntity& entity, UIGrid& grid) {
return {BehaviorResult::MOVED, target};
}
static BehaviorOutput executePatrol(UIEntity& entity, UIGrid& grid) {
static BehaviorOutput executePatrol(UIEntity& entity, GridData& grid) {
auto& behavior = entity.behavior;
if (behavior.waypoints.empty()) {
@ -184,7 +184,7 @@ static BehaviorOutput executePatrol(UIEntity& entity, UIGrid& grid) {
return {BehaviorResult::MOVED, target};
}
static BehaviorOutput executeLoop(UIEntity& entity, UIGrid& grid) {
static BehaviorOutput executeLoop(UIEntity& entity, GridData& grid) {
auto& behavior = entity.behavior;
if (behavior.waypoints.empty()) {
@ -227,7 +227,7 @@ static BehaviorOutput executeLoop(UIEntity& entity, UIGrid& grid) {
return {BehaviorResult::MOVED, target};
}
static BehaviorOutput executeSleep(UIEntity& entity, UIGrid& grid) {
static BehaviorOutput executeSleep(UIEntity& entity, GridData& grid) {
auto& behavior = entity.behavior;
if (behavior.sleep_turns_remaining > 0) {
@ -243,7 +243,7 @@ static BehaviorOutput executeSleep(UIEntity& entity, UIGrid& grid) {
// PathProvider. FLEE differs only in which map is stored in the provider -
// DijkstraProvider over an inverted DijkstraMap descends away from the threat,
// which matches the old max-distance-neighbor behavior.
static BehaviorOutput executeProviderStep(UIEntity& entity, UIGrid& grid) {
static BehaviorOutput executeProviderStep(UIEntity& entity, GridData& grid) {
auto& behavior = entity.behavior;
if (!behavior.path_provider) {
return {BehaviorResult::NO_ACTION, {}};
@ -266,7 +266,7 @@ static BehaviorOutput executeProviderStep(UIEntity& entity, UIGrid& grid) {
// =============================================================================
// Main dispatch
// =============================================================================
BehaviorOutput executeBehavior(UIEntity& entity, UIGrid& grid) {
BehaviorOutput executeBehavior(UIEntity& entity, GridData& grid) {
switch (entity.behavior.type) {
case BehaviorType::IDLE: return executeIdle(entity, grid);
case BehaviorType::CUSTOM: return executeCustom(entity, grid);

View file

@ -6,7 +6,7 @@
// Forward declarations
class UIEntity;
class UIGrid;
class GridData;
class DijkstraMap;
class PathProvider;
@ -71,4 +71,4 @@ struct EntityBehavior {
// =============================================================================
// Behavior execution - does NOT modify entity position, just returns intent
// =============================================================================
BehaviorOutput executeBehavior(UIEntity& entity, UIGrid& grid);
BehaviorOutput executeBehavior(UIEntity& entity, GridData& grid);

View file

@ -2,16 +2,15 @@
#include "GridData.h"
#include "UIEntity.h"
#include "PyTexture.h"
#include "UIGrid.h" // #313 - markDirty forwards to the UIGrid subobject
#include "UIGridView.h" // #313/#359 - and notifies registered views
#include "UIGridView.h" // #313/#359 - markDirty notifies registered views
#include "PythonObjectCache.h" // #361 - GridData owns its own cache serial
#include <algorithm>
// #313 - Render invalidation from the data layer (see GridData.h).
// GridData is never independently heap-allocated (always a UIGrid base
// subobject), so the downcast is valid; remove once #252 allows pure GridData.
// #313/#361 - Render invalidation from the data layer (see GridData.h).
// Notifies every registered view; there is no longer a drawable half of `this`
// to notify.
void GridData::markDirty() {
content_generation++; // #351 - content changed; invalidate view early-out
static_cast<UIGrid*>(this)->UIDrawable::markDirty();
for (auto& weak_view : views) {
if (auto view = weak_view.lock()) view->markDirty();
}
@ -19,7 +18,6 @@ void GridData::markDirty() {
void GridData::markCompositeDirty() {
content_generation++; // #351 - content changed; invalidate view early-out
static_cast<UIGrid*>(this)->UIDrawable::markCompositeDirty();
for (auto& weak_view : views) {
if (auto view = weak_view.lock()) view->markCompositeDirty();
}
@ -58,8 +56,26 @@ GridData::GridData()
// #364: children live on UIGridView, not here -- see GridData.h.
}
// #361 - The real constructor. Cell size comes from the texture and is mirrored
// into the plane dimensions; a GridData with no texture falls back to 16x16 so
// tile<->pixel math stays defined for a map that is never drawn.
GridData::GridData(int gx, int gy, std::shared_ptr<PyTexture> texture)
: ptex(texture)
{
entities = std::make_shared<std::vector<std::shared_ptr<UIEntity>>>(); // #329
cell_width_px = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
cell_height_px = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
initStorage(gx, gy, this);
}
GridData::~GridData()
{
// #361: GridData carries its own PythonObjectCache serial now that it is
// not a UIDrawable (which used to do this in ~UIDrawable).
if (serial_number != 0) {
PythonObjectCache::getInstance().remove(serial_number);
}
// #270: Null out parent_grid in all layers so surviving shared_ptrs
// (held by Python wrappers) don't dangle after grid destruction
for (auto& layer : layers) {

View file

@ -28,9 +28,14 @@ class UIDrawable;
class UIGridView;
class PyTexture;
// GridData is a MAP, not a widget (#361). It has no position, no size, no
// camera, and no render(). Only UIGridView -- which holds a camera and points
// at a GridData -- is a UIDrawable. `mcrfpy.Grid` is a GridView that creates
// its own GridData; N views may share one GridData (split-screen, minimap).
class GridData {
public:
GridData();
GridData(int gx, int gy, std::shared_ptr<PyTexture> texture);
virtual ~GridData();
// =========================================================================
@ -38,9 +43,25 @@ public:
// =========================================================================
int grid_w = 0, grid_h = 0;
// #313 - Cell pixel dimensions, mirrored from the owning UIGrid's texture
// at construction (UIGrid::ptex is write-once, ctor only). Lets the data
// layer do tile<->pixel math without reaching into rendering state.
// #361 - The tile atlas. This is DATA, not rendering state: it is what
// defines the cell size in pixels, which every tile<->pixel conversion in
// the engine (entity positions, layer geometry, a view's camera math)
// depends on. Views copy it to draw with; they do not own it. Write-once
// at construction, so cell_width_px/cell_height_px never go stale.
std::shared_ptr<PyTexture> ptex;
std::shared_ptr<PyTexture> getTexture() const { return ptex; }
static constexpr int DEFAULT_CELL_WIDTH = 16;
static constexpr int DEFAULT_CELL_HEIGHT = 16;
// #361 - PythonObjectCache identity. GridData used to inherit this from
// UIDrawable; now that it is not a drawable it carries its own, so that
// `entity.grid`, `view.grid_data` and the GridPoint/layer back-references
// all resolve to ONE Python wrapper instead of a fresh one per access.
uint64_t serial_number = 0;
// #313 - Cell pixel dimensions, mirrored from ptex at construction. Lets
// the data layer do tile<->pixel math without reaching into a view.
int cell_width_px = 16;
int cell_height_px = 16;
int cell_width() const { return cell_width_px; }
@ -170,19 +191,21 @@ public:
// #313/#359 - Render invalidation from the data layer. Entities hold
// shared_ptr<GridData> but still need to invalidate rendering when their
// visual state changes. These set the dirty flags on the UIGrid subobject
// (GridData is never independently heap-allocated -- always a UIGrid base)
// AND notify every registered view (see `views` above), covering both
// render paths (a bare _GridData rendered directly, and every GridView
// sharing this data) and every view's own ancestor chain (e.g. a
// Frame(use_render_texture=True) wrapping ANY of the N views needs its
// own cache invalidated, not just the creator's). Note the #351 render
// early-out itself does NOT depend on this notification -- it polls
// content_generation directly in UIGridView::render() -- this push is only
// for propagating markContentDirty/markCompositeDirty up a view's parent
// chain, which is a bottom-up push, not something pollable from a
// top-down render traversal. Within UIGrid itself the UIDrawable versions
// win via using-declarations (see UIGrid.h).
// visual state changes, so the data notifies every registered view (see
// `views` above) -- and each view then pushes up its OWN ancestor chain,
// since a Frame(cache_subtree=True) wrapping ANY of the N views needs its
// cache invalidated, not just the creator's.
//
// #361: these used to ALSO downcast `this` to UIGrid and set the dirty
// flags on its UIDrawable half, feeding a second, stale render path (a
// bare _GridData appended to a scene drew the map through a frozen camera).
// That path is gone: GridData is not a drawable, so there is nothing to
// notify but the views.
//
// Note the #351 render early-out does NOT depend on this push -- it polls
// content_generation directly in UIGridView::render(). This is only for
// bottom-up propagation through a view's parent chain, which a top-down
// render traversal cannot poll for.
void markDirty();
void markCompositeDirty();

View file

@ -1,6 +1,6 @@
#include "GridLayers.h"
#include "McRFPy_Doc.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UIGridView.h"
#include "UIEntity.h"
#include "PyColor.h"
@ -941,7 +941,7 @@ int PyGridLayerAPI::ColorLayer_init(PyColorLayerObject* self, PyObject* args, Py
// Validate grid kwarg type up-front (before allocating data).
if (grid_obj && grid_obj != Py_None &&
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridType) &&
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyGridDataType) &&
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
PyErr_SetString(PyExc_TypeError, "grid must be a mcrfpy.Grid instance");
return -1;
@ -1773,8 +1773,8 @@ PyObject* PyGridLayerAPI::ColorLayer_get_grid(PyColorLayerObject* self, void* cl
}
// No cached wrapper — allocate a new one
auto* grid_type = &mcrfpydef::PyUIGridType;
PyUIGridObject* py_grid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
auto* grid_type = &mcrfpydef::PyGridDataType;
PyGridDataObject* py_grid = (PyGridDataObject*)grid_type->tp_alloc(grid_type, 0);
if (!py_grid) return NULL;
py_grid->data = self->grid;
@ -1810,21 +1810,21 @@ int PyGridLayerAPI::ColorLayer_set_grid(PyColorLayerObject* self, PyObject* valu
// Validate it's a Grid (GridView) or internal _GridData
if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType) &&
!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) {
!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyGridDataType)) {
PyErr_SetString(PyExc_TypeError, "grid must be a Grid object or None");
return -1;
}
// Extract UIGrid shared_ptr from Grid (UIGridView) or _GridData (UIGrid)
std::shared_ptr<UIGrid> target_grid;
std::shared_ptr<GridData> target_grid;
if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
auto* pyview = (PyUIGridViewObject*)value;
if (pyview->data->grid_data) {
// GridView's grid_data is aliased from a UIGrid, cast back
target_grid = std::static_pointer_cast<UIGrid>(pyview->data->grid_data);
target_grid = std::static_pointer_cast<GridData>(pyview->data->grid_data);
}
} else if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) {
target_grid = ((PyUIGridObject*)value)->data;
} else if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyGridDataType)) {
target_grid = ((PyGridDataObject*)value)->data;
}
if (!target_grid) {
PyErr_SetString(PyExc_RuntimeError, "Grid has no data");
@ -1843,7 +1843,7 @@ int PyGridLayerAPI::ColorLayer_set_grid(PyColorLayerObject* self, PyObject* valu
}
// Check for protected names
if (!self->data->name.empty() && UIGrid::isProtectedLayerName(self->data->name)) {
if (!self->data->name.empty() && GridData::isProtectedLayerName(self->data->name)) {
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", self->data->name.c_str());
self->grid.reset();
return -1;
@ -2005,7 +2005,7 @@ int PyGridLayerAPI::TileLayer_init(PyTileLayerObject* self, PyObject* args, PyOb
// Validate grid kwarg type up-front (before allocating data).
if (grid_obj && grid_obj != Py_None &&
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridType) &&
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyGridDataType) &&
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
PyErr_SetString(PyExc_TypeError, "grid must be a mcrfpy.Grid instance");
return -1;
@ -2497,8 +2497,8 @@ PyObject* PyGridLayerAPI::TileLayer_get_grid(PyTileLayerObject* self, void* clos
}
// No cached wrapper — allocate a new one
auto* grid_type = &mcrfpydef::PyUIGridType;
PyUIGridObject* py_grid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
auto* grid_type = &mcrfpydef::PyGridDataType;
PyGridDataObject* py_grid = (PyGridDataObject*)grid_type->tp_alloc(grid_type, 0);
if (!py_grid) return NULL;
py_grid->data = self->grid;
@ -2534,20 +2534,20 @@ int PyGridLayerAPI::TileLayer_set_grid(PyTileLayerObject* self, PyObject* value,
// Validate it's a Grid (GridView) or internal _GridData
if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType) &&
!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) {
!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyGridDataType)) {
PyErr_SetString(PyExc_TypeError, "grid must be a Grid object or None");
return -1;
}
// Extract UIGrid shared_ptr from Grid (UIGridView) or _GridData (UIGrid)
std::shared_ptr<UIGrid> target_grid;
std::shared_ptr<GridData> target_grid;
if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
auto* pyview = (PyUIGridViewObject*)value;
if (pyview->data->grid_data) {
target_grid = std::static_pointer_cast<UIGrid>(pyview->data->grid_data);
target_grid = std::static_pointer_cast<GridData>(pyview->data->grid_data);
}
} else if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) {
target_grid = ((PyUIGridObject*)value)->data;
} else if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyGridDataType)) {
target_grid = ((PyGridDataObject*)value)->data;
}
if (!target_grid) {
PyErr_SetString(PyExc_RuntimeError, "Grid has no data");
@ -2566,7 +2566,7 @@ int PyGridLayerAPI::TileLayer_set_grid(PyTileLayerObject* self, PyObject* value,
}
// Check for protected names
if (!self->data->name.empty() && UIGrid::isProtectedLayerName(self->data->name)) {
if (!self->data->name.empty() && GridData::isProtectedLayerName(self->data->name)) {
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", self->data->name.c_str());
self->grid.reset();
return -1;

View file

@ -8,7 +8,7 @@
#include <string>
// Forward declarations
class UIGrid;
class GridData;
class GridData;
class PyTexture;
class UIEntity;
@ -178,19 +178,19 @@ public:
typedef struct {
PyObject_HEAD
std::shared_ptr<GridLayer> data;
std::shared_ptr<UIGrid> grid; // Parent grid reference
std::shared_ptr<GridData> grid; // Parent grid reference
} PyGridLayerObject;
typedef struct {
PyObject_HEAD
std::shared_ptr<ColorLayer> data;
std::shared_ptr<UIGrid> grid;
std::shared_ptr<GridData> grid;
} PyColorLayerObject;
typedef struct {
PyObject_HEAD
std::shared_ptr<TileLayer> data;
std::shared_ptr<UIGrid> grid;
std::shared_ptr<GridData> grid;
} PyTileLayerObject;
// #335 - context manager returned by ColorLayer.edit()/TileLayer.edit().

View file

@ -294,8 +294,7 @@ const char* ImGuiSceneExplorer::getTypeName(UIDrawable* drawable) {
case PyObjectsEnum::UIFRAME: return "Frame";
case PyObjectsEnum::UICAPTION: return "Caption";
case PyObjectsEnum::UISPRITE: return "Sprite";
case PyObjectsEnum::UIGRID: return "Grid";
case PyObjectsEnum::UIGRIDVIEW: return "GridView";
case PyObjectsEnum::UIGRIDVIEW: return "Grid";
case PyObjectsEnum::UILINE: return "Line";
case PyObjectsEnum::UICIRCLE: return "Circle";
case PyObjectsEnum::UIARC: return "Arc";

View file

@ -10,7 +10,7 @@ class GameEngine;
class UIDrawable;
class UIEntity;
class UIFrame;
class UIGrid;
class GridData;
/**
* @brief ImGui-based scene tree explorer for debugging

View file

@ -472,7 +472,7 @@ PyObject* PyInit_mcrfpy()
// #184: Set the metaclass for UI types that support callback methods
// This must be done BEFORE PyType_Ready is called on these types
PyTypeObject* ui_types_with_callbacks[] = {
&PyUIFrameType, &PyUICaptionType, &PyUISpriteType, &PyUIGridType,
&PyUIFrameType, &PyUICaptionType, &PyUISpriteType, &PyGridDataType,
&PyUILineType, &PyUICircleType, &PyUIArcType, &PyViewport3DType,
&mcrfpydef::PyUIGridViewType,
nullptr
@ -492,7 +492,8 @@ PyObject* PyInit_mcrfpy()
/*UI widgets*/
&PyUICaptionType, &PyUISpriteType, &PyUIFrameType, &PyUIEntityType,
&PyUILineType, &PyUICircleType, &PyUIArcType, &PyViewport3DType,
&mcrfpydef::PyUIGridViewType, // #252: GridView IS the primary "Grid" type
&mcrfpydef::PyUIGridViewType, // #252/#361: the widget. "Grid" is an alias.
&PyGridDataType, // #361: the map. Public, and NOT a drawable.
/*3D entities*/
&mcrfpydef::PyEntity3DType, &mcrfpydef::PyEntityCollection3DType,
@ -552,9 +553,6 @@ PyObject* PyInit_mcrfpy()
// Types that are used internally but NOT exported to module namespace (#189)
// These still need PyType_Ready() but are not added to module
PyTypeObject* internal_types[] = {
/*#252: internal grid data type - UIGrid is now internal, GridView is "Grid"*/
&PyUIGridType,
/*game map data - returned by Grid.at() but not directly instantiable*/
&PyUIGridPointType,
@ -652,7 +650,7 @@ PyObject* PyInit_mcrfpy()
PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist);
PyUICaptionType.tp_weaklistoffset = offsetof(PyUICaptionObject, weakreflist);
PyUISpriteType.tp_weaklistoffset = offsetof(PyUISpriteObject, weakreflist);
PyUIGridType.tp_weaklistoffset = offsetof(PyUIGridObject, weakreflist);
PyGridDataType.tp_weaklistoffset = offsetof(PyGridDataObject, weakreflist);
mcrfpydef::PyUIGridViewType.tp_weaklistoffset = offsetof(PyUIGridViewObject, weakreflist);
PyUIEntityType.tp_weaklistoffset = offsetof(PyUIEntityObject, weakreflist);
PyUILineType.tp_weaklistoffset = offsetof(PyUILineObject, weakreflist);
@ -697,10 +695,13 @@ PyObject* PyInit_mcrfpy()
t = internal_types[i];
}
// #252: Add "GridView" as an alias for the Grid type (which is PyUIGridViewType)
// This allows both mcrfpy.Grid(...) and mcrfpy.GridView(...) to work
// #361: "Grid" is an alias for GridView -- the SAME type object, not a subclass,
// so isinstance() works in both directions and there is no MRO to explain.
// It is NOT deprecated: now that GridData is a separate public type,
// Grid(grid_size=...) is simply "a GridView that creates its own GridData" --
// a constructor overload, not legacy behavior.
Py_INCREF(&mcrfpydef::PyUIGridViewType);
PyModule_AddObject(m, "GridView", (PyObject*)&mcrfpydef::PyUIGridViewType);
PyModule_AddObject(m, "Grid", (PyObject*)&mcrfpydef::PyUIGridViewType);
// Add default_font and default_texture to module
McRFPy_API::default_font = std::make_shared<PyFont>("assets/JetbrainsMono.ttf");
@ -1642,16 +1643,6 @@ static void find_in_collection(std::vector<std::shared_ptr<UIDrawable>>* collect
}
break;
}
case PyObjectsEnum::UIGRID: {
auto grid = std::static_pointer_cast<UIGrid>(drawable);
auto type = &mcrfpydef::PyUIGridType;
auto o = (PyUIGridObject*)type->tp_alloc(type, 0);
if (o) {
o->data = grid;
py_obj = (PyObject*)o;
}
break;
}
case PyObjectsEnum::UIGRIDVIEW: {
auto gridview = std::static_pointer_cast<UIGridView>(drawable);
auto type = &mcrfpydef::PyUIGridViewType;

View file

@ -1,9 +1,9 @@
#include "PathProvider.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UIGridPathfinding.h"
#include "UIGridPoint.h"
static bool cellWalkable(UIGrid& grid, int x, int y) {
static bool cellWalkable(GridData& grid, int x, int y) {
if (x < 0 || x >= grid.grid_w || y < 0 || y >= grid.grid_h) return false;
return grid.isWalkable(x, y); // #332
}
@ -14,7 +14,7 @@ static bool cellWalkable(UIGrid& grid, int x, int y) {
DijkstraProvider::DijkstraProvider(std::shared_ptr<DijkstraMap> map)
: map_(std::move(map)) {}
sf::Vector2i DijkstraProvider::nextStep(sf::Vector2i from, UIGrid& /*grid*/, bool* ok) {
sf::Vector2i DijkstraProvider::nextStep(sf::Vector2i from, GridData& /*grid*/, bool* ok) {
if (!map_) {
if (ok) *ok = false;
return {-1, -1};
@ -31,7 +31,7 @@ sf::Vector2i DijkstraProvider::nextStep(sf::Vector2i from, UIGrid& /*grid*/, boo
AStarProvider::AStarProvider(std::vector<sf::Vector2i> path)
: path_(std::move(path)) {}
sf::Vector2i AStarProvider::nextStep(sf::Vector2i /*from*/, UIGrid& /*grid*/, bool* ok) {
sf::Vector2i AStarProvider::nextStep(sf::Vector2i /*from*/, GridData& /*grid*/, bool* ok) {
if (index_ >= path_.size()) {
if (ok) *ok = false;
return {-1, -1};
@ -46,7 +46,7 @@ sf::Vector2i AStarProvider::nextStep(sf::Vector2i /*from*/, UIGrid& /*grid*/, bo
TargetProvider::TargetProvider(sf::Vector2i target)
: target_(target) {}
sf::Vector2i TargetProvider::nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) {
sf::Vector2i TargetProvider::nextStep(sf::Vector2i from, GridData& grid, bool* ok) {
int dx = target_.x - from.x;
int dy = target_.y - from.y;
if (dx == 0 && dy == 0) {

View file

@ -3,7 +3,7 @@
#include <memory>
#include <vector>
class UIGrid;
class GridData;
class DijkstraMap;
// =============================================================================
@ -22,7 +22,7 @@ public:
// DijkstraProvider relies on the TCOD map used at compute time,
// TargetProvider re-queries the live grid, AStarProvider trusts the
// pre-computed path.
virtual sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) = 0;
virtual sf::Vector2i nextStep(sf::Vector2i from, GridData& grid, bool* ok) = 0;
// Hint for providers that hold iteration state (currently only A*).
virtual void reset() {}
@ -33,7 +33,7 @@ public:
class DijkstraProvider : public PathProvider {
public:
explicit DijkstraProvider(std::shared_ptr<DijkstraMap> map);
sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) override;
sf::Vector2i nextStep(sf::Vector2i from, GridData& grid, bool* ok) override;
private:
std::shared_ptr<DijkstraMap> map_;
@ -43,7 +43,7 @@ private:
class AStarProvider : public PathProvider {
public:
explicit AStarProvider(std::vector<sf::Vector2i> path);
sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) override;
sf::Vector2i nextStep(sf::Vector2i from, GridData& grid, bool* ok) override;
void reset() override { index_ = 0; }
private:
@ -57,7 +57,7 @@ private:
class TargetProvider : public PathProvider {
public:
explicit TargetProvider(sf::Vector2i target);
sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) override;
sf::Vector2i nextStep(sf::Vector2i from, GridData& grid, bool* ok) override;
private:
sf::Vector2i target_;

View file

@ -6,7 +6,7 @@
#include "UIFrame.h"
#include "UICaption.h"
#include "UISprite.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UIEntity.h"
#include "UI.h" // For the PyTypeObject definitions
#include <cstring> // For strcmp in parseConflictMode
@ -253,14 +253,8 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args, PyObject*
handled = true;
}
}
else if (PyObject_IsInstance(target_obj, (PyObject*)&mcrfpydef::PyUIGridType)) {
PyUIGridObject* grid = (PyUIGridObject*)target_obj;
if (grid->data) {
self->data->start(grid->data);
AnimationManager::getInstance().addAnimation(self->data, conflict_mode);
handled = true;
}
}
// #361: a GridData is NOT an animation target -- it has no position, size,
// color or camera to tween. Animate the Grid (GridView) that displays it.
else if (PyObject_IsInstance(target_obj, (PyObject*)&mcrfpydef::PyUIEntityType)) {
// Special handling for Entity since it doesn't inherit from UIDrawable
PyUIEntityObject* entity = (PyUIEntityObject*)target_obj;

263
src/PyGridData.cpp Normal file
View file

@ -0,0 +1,263 @@
// PyGridData.cpp - Python bindings for GridData (mcrfpy.GridData).
//
// #361: this file used to define `class UIGrid : public UIDrawable, public
// GridData` -- a map that was also a widget, carrying a second camera and
// RenderTexture that no user ever saw. GridData is now pure data (GridData.cpp)
// and UIGridView is the only widget. What remains here is the binding layer:
// construction, properties, and the shared helpers the Grid() factory path in
// UIGridView needs.
#include "PyGridData.h"
#include "UIGridView.h"
#include "UIGridPathfinding.h"
#include "GameEngine.h"
#include "McRFPy_API.h"
#include "PythonObjectCache.h"
#include "UIEntity.h"
#include "PyFOV.h"
#include "PyVector.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <limits>
// =========================================================================
// Shared construction helpers (used by GridData() and by Grid()/GridView)
// =========================================================================
int PyGridData::parse_grid_shape(PyObject* grid_size_obj, PyObject* texture_obj,
int& grid_w, int& grid_h,
std::shared_ptr<PyTexture>& texture_out)
{
if (grid_size_obj) {
if (!PyTuple_Check(grid_size_obj) || PyTuple_Size(grid_size_obj) != 2) {
PyErr_SetString(PyExc_TypeError, "grid_size must be a tuple (grid_w, grid_h)");
return -1;
}
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)) {
PyErr_SetString(PyExc_TypeError, "grid_size tuple must contain integers");
return -1;
}
grid_w = PyLong_AsLong(gx_val);
grid_h = PyLong_AsLong(gy_val);
}
if (grid_w <= 0 || grid_h <= 0) {
PyErr_SetString(PyExc_ValueError, "Grid dimensions must be positive integers");
return -1;
}
if (grid_w > GRID_MAX || grid_h > GRID_MAX) { // #212
PyErr_Format(PyExc_ValueError,
"Grid dimensions cannot exceed %d (got %dx%d)", GRID_MAX, grid_w, grid_h);
return -1;
}
if (texture_obj && texture_obj != Py_None) {
if (!PyObject_IsInstance(texture_obj, (PyObject*)&mcrfpydef::PyTextureType)) {
PyErr_SetString(PyExc_TypeError, "texture must be a mcrfpy.Texture instance or None");
return -1;
}
texture_out = reinterpret_cast<PyTextureObject*>(texture_obj)->data;
} else {
texture_out = McRFPy_API::default_texture;
}
return 0;
}
// Attach one layer object (already type-checked) to `grid`. Returns -1 with an
// exception set on failure.
static int attach_layer(const std::shared_ptr<GridData>& grid,
const std::shared_ptr<GridLayer>& layer)
{
if (!layer->name.empty() && GridData::isProtectedLayerName(layer->name)) {
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
return -1;
}
if (!layer->name.empty()) {
auto existing = grid->getLayerByName(layer->name);
if (existing) {
existing->parent_grid = nullptr;
grid->removeLayer(existing);
}
}
// Lazy allocation: a layer left at (0,0) adopts the grid's dimensions.
if (layer->grid_x == 0 && layer->grid_y == 0) {
layer->resize(grid->grid_w, grid->grid_h);
} else if (layer->grid_x != grid->grid_w || layer->grid_y != grid->grid_h) {
PyErr_Format(PyExc_ValueError,
"Layer size (%d, %d) does not match Grid size (%d, %d)",
layer->grid_x, layer->grid_y, grid->grid_w, grid->grid_h);
return -1;
}
layer->parent_grid = grid.get();
grid->layers.push_back(layer);
return 0;
}
int PyGridData::apply_layers_arg(const std::shared_ptr<GridData>& grid,
PyObject* layers_obj,
std::shared_ptr<PyTexture> default_texture)
{
// Omitted -> one default TileLayer named "tilesprite" (z_index -1, below entities).
if (layers_obj == nullptr) {
grid->addTileLayer(-1, default_texture, "tilesprite");
return 0;
}
// Explicit None -> no rendering layers (entity storage + pathfinding only).
if (layers_obj == Py_None) return 0;
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* color_layer_type = (PyObject*)&mcrfpydef::PyColorLayerType;
auto* tile_layer_type = (PyObject*)&mcrfpydef::PyTileLayerType;
PyObject* item;
while ((item = PyIter_Next(iterator)) != NULL) {
bool is_color = PyObject_IsInstance(item, color_layer_type);
bool is_tile = PyObject_IsInstance(item, tile_layer_type);
if (!is_color && !is_tile) {
Py_DECREF(item);
Py_DECREF(iterator);
PyErr_SetString(PyExc_TypeError,
"layers must contain only ColorLayer or TileLayer objects");
return -1;
}
// Both wrapper types share the same {data, grid} prefix layout, but read
// them through their own types rather than punning.
std::shared_ptr<GridLayer> layer;
bool already_attached = false;
if (is_color) {
auto* py_layer = (PyColorLayerObject*)item;
if (!py_layer->data) {
Py_DECREF(item); Py_DECREF(iterator);
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return -1;
}
already_attached = (bool)py_layer->grid;
layer = py_layer->data;
} else {
auto* py_layer = (PyTileLayerObject*)item;
if (!py_layer->data) {
Py_DECREF(item); Py_DECREF(iterator);
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return -1;
}
already_attached = (bool)py_layer->grid;
layer = py_layer->data;
}
if (already_attached) {
Py_DECREF(item); Py_DECREF(iterator);
PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid");
return -1;
}
if (attach_layer(grid, layer) < 0) {
Py_DECREF(item); Py_DECREF(iterator);
return -1;
}
if (is_color) {
((PyColorLayerObject*)item)->grid = grid;
} else {
auto* py_layer = (PyTileLayerObject*)item;
py_layer->grid = grid;
// #254 - a TileLayer with no texture of its own inherits the grid's.
auto tile_layer_ptr = std::static_pointer_cast<TileLayer>(layer);
if (!tile_layer_ptr->texture) {
tile_layer_ptr->texture = grid->getTexture();
}
}
Py_DECREF(item);
}
Py_DECREF(iterator);
if (PyErr_Occurred()) return -1;
grid->layers_need_sort = true;
return 0;
}
// #348/#361 - One Python wrapper per GridData. `entity.grid`, `view.grid_data`
// and the layer/GridPoint back-references must all hand back the SAME object,
// so identity (`a.grid is b.grid`) holds and repeated attribute access does not
// churn allocations.
PyObject* PyGridData::pyobject_for(const std::shared_ptr<GridData>& grid)
{
if (!grid) Py_RETURN_NONE;
if (grid->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(grid->serial_number);
if (cached) return cached; // new strong ref
}
auto* type = &mcrfpydef::PyGridDataType;
auto* obj = (PyGridDataObject*)type->tp_alloc(type, 0);
if (!obj) return nullptr;
obj->data = grid;
obj->weakreflist = NULL;
if (grid->serial_number == 0) {
grid->serial_number = PythonObjectCache::getInstance().assignSerial();
}
PyObject* weakref = PyWeakref_NewRef((PyObject*)obj, NULL);
if (weakref) {
PythonObjectCache::getInstance().registerObject(grid->serial_number, weakref);
Py_DECREF(weakref);
}
return (PyObject*)obj;
}
// =========================================================================
// GridData(grid_size=..., texture=..., layers=...)
// =========================================================================
int PyGridData::init(PyGridDataObject* self, PyObject* args, PyObject* kwds)
{
PyObject* grid_size_obj = nullptr;
PyObject* texture_obj = nullptr;
PyObject* layers_obj = nullptr;
int grid_w = 2, grid_h = 2;
static const char* kwlist[] = {
"grid_size", "texture", "layers", "grid_w", "grid_h", nullptr
};
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOii", const_cast<char**>(kwlist),
&grid_size_obj, &texture_obj, &layers_obj,
&grid_w, &grid_h)) {
return -1;
}
std::shared_ptr<PyTexture> texture;
if (parse_grid_shape(grid_size_obj, texture_obj, grid_w, grid_h, texture) < 0) {
return -1;
}
self->data = std::make_shared<GridData>(grid_w, grid_h, texture);
if (apply_layers_arg(self->data, layers_obj, texture) < 0) {
return -1;
}
self->weakreflist = NULL;
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);
}
}
return 0;
}

169
src/PyGridData.h Normal file
View file

@ -0,0 +1,169 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include "structmember.h"
#include "IndexTexture.h"
#include "Resources.h"
#include <list>
#include <libtcod.h>
#include <mutex>
#include <optional>
#include <map>
#include <memory>
#include "PyCallable.h"
#include "PyTexture.h"
#include "PyDrawable.h"
#include "PyColor.h"
#include "PyVector.h"
#include "PyFont.h"
#include "UIGridPoint.h"
#include "UIEntity.h"
#include "UIDrawable.h"
#include "UIBase.h"
#include "GridLayers.h"
#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;
// PyGridData - the Python binding layer for GridData (mcrfpy.GridData).
//
// #361: there is no longer a C++ `UIGrid` class. It used to inherit BOTH
// UIDrawable and GridData, which meant every map dragged a second, redundant
// camera and RenderTexture around, and appending one to a scene drew the map a
// second time through that frozen camera. GridData is a MAP -- no position, no
// size, no render() -- and UIGridView is the only widget. This struct holds
// nothing but the static binding functions; it is never instantiated.
struct PyGridData
{
// Shared by GridData::init and UIGridView::init (the Grid() factory path):
// attaches an iterable of ColorLayer/TileLayer objects to a fresh GridData,
// lazily sizing any layer left at (0,0). Returns -1 with a Python exception
// set on failure. layers_obj == nullptr means "default tilesprite layer";
// Py_None means "no layers at all" (entity storage + pathfinding only).
static int apply_layers_arg(const std::shared_ptr<GridData>& grid,
PyObject* layers_obj,
std::shared_ptr<PyTexture> default_texture);
// Parses grid_size/grid_w/grid_h/texture into a validated (w, h, texture)
// triple. Returns -1 with an exception set on failure.
static int parse_grid_shape(PyObject* grid_size_obj, PyObject* texture_obj,
int& grid_w, int& grid_h,
std::shared_ptr<PyTexture>& texture_out);
// Builds (or reuses) the one Python wrapper for a GridData (#348 identity).
static PyObject* pyobject_for(const std::shared_ptr<GridData>& grid);
static int init(PyGridDataObject* self, PyObject* args, PyObject* kwds);
static PyObject* get_grid_size(PyGridDataObject* self, void* closure);
static PyObject* get_grid_w(PyGridDataObject* self, void* closure);
static PyObject* get_grid_h(PyGridDataObject* self, void* closure);
static PyObject* get_texture(PyGridDataObject* self, void* closure);
static PyObject* get_fov(PyGridDataObject* self, void* closure);
static int set_fov(PyGridDataObject* self, PyObject* value, void* closure);
static PyObject* get_fov_radius(PyGridDataObject* self, void* closure);
static int set_fov_radius(PyGridDataObject* self, PyObject* value, void* closure);
static PyObject* py_at(PyGridDataObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_compute_fov(PyGridDataObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_is_in_fov(PyGridDataObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_entities_in_radius(PyGridDataObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_apply_threshold(PyGridDataObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_apply_ranges(PyGridDataObject* self, PyObject* args);
static PyObject* py_step(PyGridDataObject* self, PyObject* args, PyObject* kwds);
static PyGetSetDef getsetters[];
static PyMappingMethods mpmethods;
static PyObject* subscript(PyGridDataObject* self, PyObject* key);
static PyObject* get_entities(PyGridDataObject* self, void* closure);
// #364: get_children lives on UIGridView -- overlay children belong to the view.
static PyObject* repr(PyGridDataObject* self);
static PyObject* py_add_layer(PyGridDataObject* self, PyObject* args);
static PyObject* py_remove_layer(PyGridDataObject* self, PyObject* args);
static PyObject* get_layers(PyGridDataObject* self, void* closure);
static PyObject* py_layer(PyGridDataObject* self, PyObject* args);
};
// UIEntityCollection types are now in UIEntityCollection.h
// Forward declaration of methods array
extern PyMethodDef PyGridData_all_methods[];
namespace mcrfpydef {
inline PyTypeObject PyGridDataType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
// #361: public, and NOT a Drawable subclass -- tp_base is left null
// (i.e. `object`). Appending one to scene.children raises TypeError.
.tp_name = "mcrfpy.GridData",
.tp_basicsize = sizeof(PyGridDataObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)[](PyObject* self)
{
PyGridDataObject* obj = (PyGridDataObject*)self;
PyObject_GC_UnTrack(self);
if (obj->weakreflist != NULL) {
PyObject_ClearWeakRefs(self);
}
// #361: no click/hover callbacks to unregister -- a map is not a
// widget and receives no input. Cell callbacks live on UIGridView
// (#355), overlay children on UIGridView (#364).
obj->data.reset();
Py_TYPE(self)->tp_free(self);
},
.tp_repr = (reprfunc)PyGridData::repr,
.tp_as_mapping = &PyGridData::mpmethods,
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC,
.tp_doc = PyDoc_STR("GridData(grid_size=None, texture=None, layers=None)\n\n"
"A tile map: cells, entities, layers, FOV and pathfinding state.\n\n"
"GridData is DATA, not a widget -- it has no position, no size, no\n"
"camera, and is never drawn. Appending one to a scene raises TypeError.\n"
"To display it, point one or more Grid (GridView) cameras at it:\n\n"
" data = mcrfpy.GridData(grid_size=(80, 40))\n"
" main = mcrfpy.Grid(grid=data, pos=(0, 0), size=(800, 400))\n"
" mini = mcrfpy.Grid(grid=data, pos=(820, 0), size=(160, 80))\n\n"
"Both views show the same map and the same entities, each through its\n"
"own camera. A map with no view at all is perfectly valid (an offscreen\n"
"level, still steppable and queryable).\n\n"
"mcrfpy.Grid(grid_size=...) is sugar for a GridView that creates its own\n"
"GridData; reach it with `grid.grid_data`.\n\n"
"Args:\n"
" grid_size (tuple): Grid dimensions as (grid_w, grid_h). Default: (2, 2)\n"
" texture (Texture): Tile atlas; defines the cell size in pixels.\n"
" layers (list): ColorLayer/TileLayer objects. Omit for a default\n"
" 'tilesprite' TileLayer; pass None for no layers at all.\n\n"
"Attributes:\n"
" grid_size (Vector, read-only): Dimensions in tiles\n"
" grid_w, grid_h (int, read-only): Dimensions in tiles\n"
" texture (Texture, read-only): Tile atlas\n"
" entities (EntityCollection): Entities on this map\n"
" layers (tuple, read-only): Layers sorted by z_index\n"
" fov (FOV): Default field-of-view algorithm\n"
" fov_radius (int): Default field-of-view radius"),
.tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int {
// #361: a GridData holds no Python callbacks -- nothing to visit.
(void)self; (void)visit; (void)arg;
return 0;
},
.tp_clear = [](PyObject* self) -> int {
(void)self;
return 0;
},
.tp_methods = PyGridData_all_methods,
.tp_getset = PyGridData::getsetters,
.tp_init = (initproc)PyGridData::init,
.tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject*
{
PyGridDataObject* self = (PyGridDataObject*)type->tp_alloc(type, 0);
if (self) {
self->data = std::make_shared<GridData>();
self->weakreflist = nullptr;
}
return (PyObject*)self;
}
};
}

View file

@ -1,8 +1,9 @@
// UIGridPyMethods.cpp — Python method implementations for UIGrid
// Extracted from UIGrid.cpp (#149) for maintainability.
// PyGridDataMethods.cpp - Python method implementations for GridData.
// #361: center_camera() moved to UIGridView (a map has no camera), and the
// UIDrawable method set is gone (a map is not a widget).
// Contains: FOV, layer management, spatial queries, camera, heightmap ops,
// step/behavior system, py_at/subscript, and method table arrays.
#include "UIGrid.h"
#include "PyGridData.h"
#include "UIGridView.h"
#include "UIGridPathfinding.h"
#include "McRFPy_API.h"
@ -21,7 +22,7 @@
// Cell access: py_at, subscript, mpmethods
// =========================================================================
PyObject* UIGrid::py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds)
PyObject* PyGridData::py_at(PyGridDataObject* self, PyObject* args, PyObject* kwds)
{
int x, y;
@ -46,7 +47,7 @@ PyObject* UIGrid::py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds)
return (PyObject*)obj;
}
PyObject* UIGrid::subscript(PyUIGridObject* self, PyObject* key)
PyObject* PyGridData::subscript(PyGridDataObject* self, PyObject* key)
{
if (!PyTuple_Check(key) || PyTuple_Size(key) != 2) {
PyErr_SetString(PyExc_TypeError, "Grid indices must be a tuple of (x, y)");
@ -84,7 +85,7 @@ PyObject* UIGrid::subscript(PyUIGridObject* self, PyObject* key)
}
// Setitem on _GridData / Grid: GridPoints are views, not assignable.
static int UIGrid_subscript_assign(PyUIGridObject* self, PyObject* key, PyObject* value)
static int UIGrid_subscript_assign(PyGridDataObject* self, PyObject* key, PyObject* value)
{
(void)self; (void)key; (void)value;
PyErr_SetString(PyExc_TypeError,
@ -92,9 +93,9 @@ static int UIGrid_subscript_assign(PyUIGridObject* self, PyObject* key, PyObject
return -1;
}
PyMappingMethods UIGrid::mpmethods = {
PyMappingMethods PyGridData::mpmethods = {
.mp_length = NULL,
.mp_subscript = (binaryfunc)UIGrid::subscript,
.mp_subscript = (binaryfunc)PyGridData::subscript,
.mp_ass_subscript = (objobjargproc)UIGrid_subscript_assign,
};
@ -102,7 +103,7 @@ PyMappingMethods UIGrid::mpmethods = {
// FOV methods
// =========================================================================
PyObject* UIGrid::py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds)
PyObject* PyGridData::py_compute_fov(PyGridDataObject* self, PyObject* args, PyObject* kwds)
{
static const char* kwlist[] = {"pos", "radius", "light_walls", "algorithm", NULL};
PyObject* pos_obj = NULL;
@ -135,7 +136,7 @@ PyObject* UIGrid::py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject*
Py_RETURN_NONE;
}
PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds)
PyObject* PyGridData::py_is_in_fov(PyGridDataObject* self, PyObject* args, PyObject* kwds)
{
int x, y;
if (!PyPosition_ParseInt(args, kwds, &x, &y)) {
@ -150,7 +151,7 @@ PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* k
// Layer management
// =========================================================================
PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args) {
PyObject* PyGridData::py_add_layer(PyGridDataObject* self, PyObject* args) {
PyObject* layer_obj;
if (!PyArg_ParseTuple(args, "O", &layer_obj)) {
return NULL;
@ -177,7 +178,7 @@ PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args) {
layer = py_layer->data;
py_layer_ref = layer_obj;
if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) {
if (!layer->name.empty() && GridData::isProtectedLayerName(layer->name)) {
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
return NULL;
}
@ -219,7 +220,7 @@ PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args) {
layer = py_layer->data;
py_layer_ref = layer_obj;
if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) {
if (!layer->name.empty() && GridData::isProtectedLayerName(layer->name)) {
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
return NULL;
}
@ -260,7 +261,7 @@ PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args) {
return py_layer_ref;
}
PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) {
PyObject* PyGridData::py_remove_layer(PyGridDataObject* self, PyObject* args) {
PyObject* layer_obj;
if (!PyArg_ParseTuple(args, "O", &layer_obj)) {
return NULL;
@ -305,7 +306,7 @@ PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) {
return NULL;
}
PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) {
PyObject* PyGridData::py_layer(PyGridDataObject* self, PyObject* args) {
const char* name_str;
if (!PyArg_ParseTuple(args, "s", &name_str)) {
return NULL;
@ -339,7 +340,7 @@ PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) {
// Spatial queries
// =========================================================================
PyObject* UIGrid::py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds)
PyObject* PyGridData::py_entities_in_radius(PyGridDataObject* self, PyObject* args, PyObject* kwds)
{
static const char* kwlist[] = {"pos", "radius", NULL};
PyObject* pos_obj;
@ -390,74 +391,11 @@ PyObject* UIGrid::py_entities_in_radius(PyUIGridObject* self, PyObject* args, Py
return result;
}
// =========================================================================
// Camera positioning
// =========================================================================
void UIGrid::center_camera() {
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();
}
void UIGrid::center_camera(float tile_x, float tile_y) {
int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
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();
}
PyObject* UIGrid::py_center_camera(PyUIGridObject* self, PyObject* args) {
PyObject* pos_arg = nullptr;
if (!PyArg_ParseTuple(args, "|O", &pos_arg)) {
return nullptr;
}
if (pos_arg == nullptr || pos_arg == Py_None) {
self->data->center_camera();
} else if (PyTuple_Check(pos_arg) && PyTuple_Size(pos_arg) == 2) {
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;
}
// =========================================================================
// HeightMap application methods
// =========================================================================
PyObject* UIGrid::py_apply_threshold(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
PyObject* PyGridData::py_apply_threshold(PyGridDataObject* self, PyObject* args, PyObject* kwds) {
static const char* keywords[] = {"source", "range", "walkable", "transparent", nullptr};
PyObject* source_obj = nullptr;
PyObject* range_obj = nullptr;
@ -533,7 +471,7 @@ PyObject* UIGrid::py_apply_threshold(PyUIGridObject* self, PyObject* args, PyObj
return (PyObject*)self;
}
PyObject* UIGrid::py_apply_ranges(PyUIGridObject* self, PyObject* args) {
PyObject* PyGridData::py_apply_ranges(PyGridDataObject* self, PyObject* args) {
PyObject* source_obj = nullptr;
PyObject* ranges_obj = nullptr;
@ -696,7 +634,7 @@ static void fireStepCallback(std::shared_ptr<UIEntity>& entity, int trigger_int,
Py_DECREF(trigger_obj);
}
PyObject* UIGrid::py_step(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
PyObject* PyGridData::py_step(PyGridDataObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"n", "turn_order", nullptr};
int n = 1;
PyObject* turn_order_filter = nullptr;
@ -827,10 +765,7 @@ PyObject* UIGrid::py_step(PyUIGridObject* self, PyObject* args, PyObject* kwds)
}
// #351 - invalidate the view's render early-out once if anything moved.
// Must call the GridData override explicitly: UIGrid's `using
// UIDrawable::markCompositeDirty` would otherwise shadow it and skip the
// content_generation bump + registered-views notification.
if (content_changed) grid->GridData::markCompositeDirty();
if (content_changed) grid->markCompositeDirty();
Py_RETURN_NONE;
}
@ -839,9 +774,11 @@ PyObject* UIGrid::py_step(PyUIGridObject* self, PyObject* args, PyObject* kwds)
// Method tables
// =========================================================================
PyMethodDef UIGrid::methods[] = {
{"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, at,
typedef PyGridDataObject PyObjectType;
PyMethodDef PyGridData_all_methods[] = {
{"at", (PyCFunction)PyGridData::py_at, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(GridData, at,
MCRF_SIG("(x: int, y: int)", "GridPoint"),
MCRF_DESC("Return the GridPoint cell at grid coordinates (x, y)."),
MCRF_ARGS_START
@ -851,8 +788,8 @@ PyMethodDef UIGrid::methods[] = {
MCRF_RAISES("IndexError", "If x or y is out of range")
MCRF_NOTE("Also accepts a single positional tuple/list/Vector: at((x, y)) or at(vec), or keyword form: at(pos=(x, y)).")
)},
{"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, compute_fov,
{"compute_fov", (PyCFunction)PyGridData::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(GridData, compute_fov,
MCRF_SIG("(pos, radius: int = 0, light_walls: bool = True, algorithm: FOV | int = FOV.BASIC)", "None"),
MCRF_DESC("Compute field of view from a position. Updates the internal FOV state; use is_in_fov() to query visibility."),
MCRF_ARGS_START
@ -862,8 +799,8 @@ PyMethodDef UIGrid::methods[] = {
MCRF_ARG("algorithm", "FOV algorithm to use (FOV.BASIC, FOV.DIAMOND, FOV.SHADOW, FOV.PERMISSIVE_0-8)")
MCRF_RETURNS("None")
)},
{"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, is_in_fov,
{"is_in_fov", (PyCFunction)PyGridData::py_is_in_fov, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(GridData, is_in_fov,
MCRF_SIG("(x: int, y: int)", "bool"),
MCRF_DESC("Check if a cell is in the field of view. Must call compute_fov() first to calculate visibility."),
MCRF_ARGS_START
@ -873,7 +810,7 @@ PyMethodDef UIGrid::methods[] = {
MCRF_NOTE("Also accepts a single positional tuple/list/Vector: is_in_fov((x, y)) or is_in_fov(vec), or keyword form: is_in_fov(pos=(x, y)).")
)},
{"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, find_path,
MCRF_METHOD(GridData, find_path,
MCRF_SIG("(start, end, diagonal_cost: float = 1.41, collide: str = None, heuristic = None, weight: float = 1.0)", "AStarPath | None"),
MCRF_DESC("Compute A* path between two points. The returned AStarPath can be iterated or walked step-by-step."),
MCRF_ARGS_START
@ -886,7 +823,7 @@ PyMethodDef UIGrid::methods[] = {
MCRF_RETURNS("AStarPath object if path exists, None otherwise")
)},
{"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, get_dijkstra_map,
MCRF_METHOD(GridData, get_dijkstra_map,
MCRF_SIG("(root=None, diagonal_cost: float = 1.41, collide: str = None, roots=None)", "DijkstraMap"),
MCRF_DESC("Get or create a cached Dijkstra distance map for a root position. Call clear_dijkstra_maps() after changing grid walkability to invalidate."),
MCRF_ARGS_START
@ -897,12 +834,12 @@ PyMethodDef UIGrid::methods[] = {
MCRF_RETURNS("DijkstraMap object for querying distances and paths")
)},
{"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS,
MCRF_METHOD(Grid, clear_dijkstra_maps,
MCRF_METHOD(GridData, clear_dijkstra_maps,
MCRF_SIG("()", "None"),
MCRF_DESC("Clear all cached Dijkstra maps. Call this after modifying grid cell walkability to ensure pathfinding uses updated walkability data.")
)},
{"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS,
MCRF_METHOD(Grid, add_layer,
{"add_layer", (PyCFunction)PyGridData::py_add_layer, METH_VARARGS,
MCRF_METHOD(GridData, add_layer,
MCRF_SIG("(layer: ColorLayer | TileLayer)", "ColorLayer | TileLayer"),
MCRF_DESC("Add a layer to the grid. Layers with size (0, 0) are automatically resized to match the grid."),
MCRF_ARGS_START
@ -911,8 +848,8 @@ PyMethodDef UIGrid::methods[] = {
MCRF_RAISES("ValueError", "If layer is already attached to another grid, or if layer size doesn't match grid (and isn't (0,0))")
MCRF_RAISES("TypeError", "If argument is not a ColorLayer or TileLayer")
)},
{"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS,
MCRF_METHOD(Grid, remove_layer,
{"remove_layer", (PyCFunction)PyGridData::py_remove_layer, METH_VARARGS,
MCRF_METHOD(GridData, remove_layer,
MCRF_SIG("(name_or_layer: str | ColorLayer | TileLayer)", "None"),
MCRF_DESC("Remove a layer from the grid by name or by object reference."),
MCRF_ARGS_START
@ -920,16 +857,16 @@ PyMethodDef UIGrid::methods[] = {
MCRF_RAISES("KeyError", "If name is provided but no layer with that name exists")
MCRF_RAISES("TypeError", "If argument is not a string, ColorLayer, or TileLayer")
)},
{"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS,
MCRF_METHOD(Grid, layer,
{"layer", (PyCFunction)PyGridData::py_layer, METH_VARARGS,
MCRF_METHOD(GridData, layer,
MCRF_SIG("(name: str)", "ColorLayer | TileLayer | None"),
MCRF_DESC("Get a layer by its name."),
MCRF_ARGS_START
MCRF_ARG("name", "The name of the layer to find")
MCRF_RETURNS("The layer with the specified name, or None if not found")
)},
{"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, entities_in_radius,
{"entities_in_radius", (PyCFunction)PyGridData::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(GridData, entities_in_radius,
MCRF_SIG("(pos: tuple | Vector, radius: float)", "list"),
MCRF_DESC("Query entities within radius using spatial hash (O(k) where k = nearby entities)."),
MCRF_ARGS_START
@ -937,168 +874,29 @@ PyMethodDef UIGrid::methods[] = {
MCRF_ARG("radius", "Search radius")
MCRF_RETURNS("List of Entity objects within the radius")
)},
{"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS,
MCRF_METHOD(Grid, center_camera,
MCRF_SIG("(pos: tuple = None)", "None"),
MCRF_DESC("Center the camera on a tile coordinate. If pos is None, centers on the grid's middle tile."),
MCRF_ARGS_START
MCRF_ARG("pos", "Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.")
)},
{"apply_threshold", (PyCFunction)UIGrid::py_apply_threshold, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, apply_threshold,
MCRF_SIG("(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None)", "Grid"),
{"apply_threshold", (PyCFunction)PyGridData::py_apply_threshold, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(GridData, apply_threshold,
MCRF_SIG("(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None)", "GridData"),
MCRF_DESC("Apply walkable/transparent properties to cells where heightmap values fall within range."),
MCRF_ARGS_START
MCRF_ARG("source", "HeightMap with values to check. Must match grid size.")
MCRF_ARG("range", "Tuple of (min, max) - cells with values in this range are affected")
MCRF_ARG("walkable", "If not None, set walkable to this value for cells in range")
MCRF_ARG("transparent", "If not None, set transparent to this value for cells in range")
MCRF_RETURNS("Grid: self, for method chaining")
MCRF_RETURNS("GridData: self, for method chaining")
MCRF_RAISES("ValueError", "If HeightMap size doesn't match grid size")
)},
{"apply_ranges", (PyCFunction)UIGrid::py_apply_ranges, METH_VARARGS,
MCRF_METHOD(Grid, apply_ranges,
MCRF_SIG("(source: HeightMap, ranges: list)", "Grid"),
{"apply_ranges", (PyCFunction)PyGridData::py_apply_ranges, METH_VARARGS,
MCRF_METHOD(GridData, apply_ranges,
MCRF_SIG("(source: HeightMap, ranges: list)", "GridData"),
MCRF_DESC("Apply multiple walkability thresholds to the grid in a single pass."),
MCRF_ARGS_START
MCRF_ARG("source", "HeightMap with values to check. Must match grid size.")
MCRF_ARG("ranges", "List of (range_tuple, properties_dict) tuples where range_tuple=(min,max) and properties_dict has 'walkable'/'transparent' keys")
MCRF_RETURNS("Grid: self, for method chaining")
MCRF_RETURNS("GridData: self, for method chaining")
)},
{NULL, NULL, 0, NULL}
};
typedef PyUIGridObject PyObjectType;
PyMethodDef UIGrid_all_methods[] = {
UIDRAWABLE_METHODS,
{"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, at,
MCRF_SIG("(x: int, y: int)", "GridPoint"),
MCRF_DESC("Return the GridPoint cell at grid coordinates (x, y)."),
MCRF_ARGS_START
MCRF_ARG("x", "Column index (0-based)")
MCRF_ARG("y", "Row index (0-based)")
MCRF_RETURNS("GridPoint: the cell at the given position")
MCRF_RAISES("IndexError", "If x or y is out of range")
MCRF_NOTE("Also accepts a single positional tuple/list/Vector: at((x, y)) or at(vec), or keyword form: at(pos=(x, y)).")
)},
{"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, compute_fov,
MCRF_SIG("(pos, radius: int = 0, light_walls: bool = True, algorithm: FOV | int = FOV.BASIC)", "None"),
MCRF_DESC("Compute field of view from a position. Updates the internal FOV state; use is_in_fov() to query visibility."),
MCRF_ARGS_START
MCRF_ARG("pos", "Position as (x, y) tuple, list, or Vector")
MCRF_ARG("radius", "Maximum view distance (0 = unlimited)")
MCRF_ARG("light_walls", "Whether walls are lit when visible")
MCRF_ARG("algorithm", "FOV algorithm to use (FOV.BASIC, FOV.DIAMOND, FOV.SHADOW, FOV.PERMISSIVE_0-8)")
MCRF_RETURNS("None")
)},
{"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, is_in_fov,
MCRF_SIG("(x: int, y: int)", "bool"),
MCRF_DESC("Check if a cell is in the field of view. Must call compute_fov() first to calculate visibility."),
MCRF_ARGS_START
MCRF_ARG("x", "Column index (0-based)")
MCRF_ARG("y", "Row index (0-based)")
MCRF_RETURNS("True if the cell is visible, False otherwise")
MCRF_NOTE("Also accepts a single positional tuple/list/Vector: is_in_fov((x, y)) or is_in_fov(vec), or keyword form: is_in_fov(pos=(x, y)).")
)},
{"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, find_path,
MCRF_SIG("(start, end, diagonal_cost: float = 1.41, collide: str = None, heuristic = None, weight: float = 1.0)", "AStarPath | None"),
MCRF_DESC("Compute A* path between two points. The returned AStarPath can be iterated or walked step-by-step."),
MCRF_ARGS_START
MCRF_ARG("start", "Starting position as Vector, Entity, or (x, y) tuple")
MCRF_ARG("end", "Target position as Vector, Entity, or (x, y) tuple")
MCRF_ARG("diagonal_cost", "Cost of diagonal movement (default: 1.41)")
MCRF_ARG("collide", "Label string. Entities with this label block pathfinding.")
MCRF_ARG("heuristic", "Heuristic enum member, string name, or int (EUCLIDEAN=0, MANHATTAN=1, CHEBYSHEV=2). None uses default (Euclidean).")
MCRF_ARG("weight", "Heuristic weight multiplier. Values > 1.0 trade optimality for speed (weighted A*).")
MCRF_RETURNS("AStarPath object if path exists, None otherwise")
)},
{"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, get_dijkstra_map,
MCRF_SIG("(root=None, diagonal_cost: float = 1.41, collide: str = None, roots=None)", "DijkstraMap"),
MCRF_DESC("Get or create a cached Dijkstra distance map for a root position. Call clear_dijkstra_maps() after changing grid walkability to invalidate."),
MCRF_ARGS_START
MCRF_ARG("root", "Root position as Vector, Entity, or (x, y) tuple. Use 'root' for single-source maps (cached by position).")
MCRF_ARG("diagonal_cost", "Cost of diagonal movement (default: 1.41)")
MCRF_ARG("collide", "Label string. Entities with this label block pathfinding.")
MCRF_ARG("roots", "Sequence of root positions or a DiscreteMap mask for multi-source Dijkstra. Pass 'roots' instead of 'root' for multi-source maps (not cached).")
MCRF_RETURNS("DijkstraMap object for querying distances and paths")
)},
{"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS,
MCRF_METHOD(Grid, clear_dijkstra_maps,
MCRF_SIG("()", "None"),
MCRF_DESC("Clear all cached Dijkstra maps. Call this after modifying grid cell walkability to ensure pathfinding uses updated walkability data.")
)},
{"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS,
MCRF_METHOD(Grid, add_layer,
MCRF_SIG("(layer: ColorLayer | TileLayer)", "ColorLayer | TileLayer"),
MCRF_DESC("Add a layer to the grid. Layers with size (0, 0) are automatically resized to match the grid."),
MCRF_ARGS_START
MCRF_ARG("layer", "A ColorLayer or TileLayer object. Named layers replace any existing layer with the same name.")
MCRF_RETURNS("The added layer object")
MCRF_RAISES("ValueError", "If layer is already attached to another grid, or if layer size doesn't match grid (and isn't (0,0))")
MCRF_RAISES("TypeError", "If argument is not a ColorLayer or TileLayer")
)},
{"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS,
MCRF_METHOD(Grid, remove_layer,
MCRF_SIG("(name_or_layer: str | ColorLayer | TileLayer)", "None"),
MCRF_DESC("Remove a layer from the grid by name or by object reference."),
MCRF_ARGS_START
MCRF_ARG("name_or_layer", "Either a layer name (str) or the layer object itself")
MCRF_RAISES("KeyError", "If name is provided but no layer with that name exists")
MCRF_RAISES("TypeError", "If argument is not a string, ColorLayer, or TileLayer")
)},
{"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS,
MCRF_METHOD(Grid, layer,
MCRF_SIG("(name: str)", "ColorLayer | TileLayer | None"),
MCRF_DESC("Get a layer by its name."),
MCRF_ARGS_START
MCRF_ARG("name", "The name of the layer to find")
MCRF_RETURNS("The layer with the specified name, or None if not found")
)},
{"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, entities_in_radius,
MCRF_SIG("(pos: tuple | Vector, radius: float)", "list"),
MCRF_DESC("Query entities within radius using spatial hash (O(k) where k = nearby entities)."),
MCRF_ARGS_START
MCRF_ARG("pos", "Center position as (x, y) tuple, Vector, or other 2-element sequence")
MCRF_ARG("radius", "Search radius")
MCRF_RETURNS("List of Entity objects within the radius")
)},
{"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS,
MCRF_METHOD(Grid, center_camera,
MCRF_SIG("(pos: tuple = None)", "None"),
MCRF_DESC("Center the camera on a tile coordinate. If pos is None, centers on the grid's middle tile."),
MCRF_ARGS_START
MCRF_ARG("pos", "Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.")
)},
{"apply_threshold", (PyCFunction)UIGrid::py_apply_threshold, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, apply_threshold,
MCRF_SIG("(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None)", "Grid"),
MCRF_DESC("Apply walkable/transparent properties to cells where heightmap values fall within range."),
MCRF_ARGS_START
MCRF_ARG("source", "HeightMap with values to check. Must match grid size.")
MCRF_ARG("range", "Tuple of (min, max) - cells with values in this range are affected")
MCRF_ARG("walkable", "If not None, set walkable to this value for cells in range")
MCRF_ARG("transparent", "If not None, set transparent to this value for cells in range")
MCRF_RETURNS("Grid: self, for method chaining")
MCRF_RAISES("ValueError", "If HeightMap size doesn't match grid size")
)},
{"apply_ranges", (PyCFunction)UIGrid::py_apply_ranges, METH_VARARGS,
MCRF_METHOD(Grid, apply_ranges,
MCRF_SIG("(source: HeightMap, ranges: list)", "Grid"),
MCRF_DESC("Apply multiple walkability thresholds to the grid in a single pass."),
MCRF_ARGS_START
MCRF_ARG("source", "HeightMap with values to check. Must match grid size.")
MCRF_ARG("ranges", "List of (range_tuple, properties_dict) tuples where range_tuple=(min,max) and properties_dict has 'walkable'/'transparent' keys")
MCRF_RETURNS("Grid: self, for method chaining")
)},
{"step", (PyCFunction)UIGrid::py_step, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(Grid, step,
{"step", (PyCFunction)PyGridData::py_step, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(GridData, step,
MCRF_SIG("(n: int = 1, turn_order: int = None)", "None"),
MCRF_DESC("Execute n rounds of turn-based entity behavior. Each round: entities grouped by turn_order (ascending), behaviors executed, triggers fired (TARGET, DONE, BLOCKED), movement animated."),
MCRF_ARGS_START

View file

@ -0,0 +1,214 @@
// PyGridDataProperties.cpp - Python getter/setter implementations for GridData.
// #361: the widget properties (pos/size/center/zoom/camera_rotation/fill_color/
// x/y/w/h/on_click/z_index/name/visible/opacity/align/rotation/shader) are gone --
// a map has no position and is never drawn. They live on UIGridView, which is the
// only widget. What is left is what a MAP actually has.
#include "PyGridData.h"
#include "UIGridView.h"
#include "McRFPy_API.h"
#include "PythonObjectCache.h"
#include "PyColor.h"
#include "PyVector.h"
#include "PyFOV.h"
#include "UIBase.h"
#include "UICollection.h"
#include "McRFPy_Doc.h"
// =========================================================================
// Grid dimension properties
// =========================================================================
PyObject* PyGridData::get_grid_size(PyGridDataObject* self, void* closure) {
return PyVector(sf::Vector2f(static_cast<float>(self->data->grid_w),
static_cast<float>(self->data->grid_h))).pyObject();
}
PyObject* PyGridData::get_grid_w(PyGridDataObject* self, void* closure) {
return PyLong_FromLong(self->data->grid_w);
}
PyObject* PyGridData::get_grid_h(PyGridDataObject* self, void* closure) {
return PyLong_FromLong(self->data->grid_h);
}
// =========================================================================
// Texture property
// =========================================================================
PyObject* PyGridData::get_texture(PyGridDataObject* self, void* closure) {
auto texture = self->data->getTexture();
if (!texture) {
Py_RETURN_NONE;
}
auto type = &mcrfpydef::PyTextureType;
auto obj = (PyTextureObject*)type->tp_alloc(type, 0);
obj->data = texture;
return (PyObject*)obj;
}
PyObject* PyGridData::get_fov(PyGridDataObject* self, void* closure)
{
if (PyFOV::fov_enum_class) {
PyObject* value = PyLong_FromLong(self->data->fov_algorithm);
if (!value) return NULL;
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;
}
return PyLong_FromLong(self->data->fov_algorithm);
}
int PyGridData::set_fov(PyGridDataObject* self, PyObject* value, void* closure)
{
TCOD_fov_algorithm_t algo;
if (!PyFOV::from_arg(value, &algo, nullptr)) {
return -1;
}
self->data->fov_algorithm = algo;
self->data->markDirty();
return 0;
}
PyObject* PyGridData::get_fov_radius(PyGridDataObject* self, void* closure)
{
return PyLong_FromLong(self->data->fov_radius);
}
int PyGridData::set_fov_radius(PyGridDataObject* 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;
self->data->markDirty();
return 0;
}
// =========================================================================
// Collection getters
// =========================================================================
PyObject* PyGridData::get_entities(PyGridDataObject* self, void* closure)
{
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;
}
// #364: get_children moved to UIGridView. GridData holds no drawables, so the
// internal _GridData no longer exposes a children collection at all.
PyObject* PyGridData::get_layers(PyGridDataObject* self, void* closure) {
self->data->sortLayers();
PyObject* tuple = PyTuple_New(self->data->layers.size());
if (!tuple) return NULL;
auto* color_layer_type = &mcrfpydef::PyColorLayerType;
auto* tile_layer_type = &mcrfpydef::PyTileLayerType;
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(tuple);
return NULL;
}
PyTuple_SET_ITEM(tuple, i, py_layer);
}
return tuple;
}
// =========================================================================
// repr
// =========================================================================
PyObject* PyGridData::repr(PyGridDataObject* self)
{
std::ostringstream ss;
if (!self->data) ss << "<GridData (invalid internal object)>";
else {
auto grid = self->data;
ss << "<GridData (" << grid->grid_w << "x" << grid->grid_h << ", "
<< grid->entities->size() << " entities, "
<< grid->layers.size() << " layers)>";
}
std::string repr_str = ss.str();
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
}
// #355 - cell callback properties moved to UIGridView (src/UIGridView.cpp)
// =========================================================================
// getsetters[] table
//
// #361: a map has no pos/size/center/zoom/camera_rotation/fill_color/x/y/w/h/
// on_click/z_index/name/visible/opacity/align/rotation/shader. Every one of those
// was a widget property that happened to hang off the data because UIGrid was
// both. They are on UIGridView now. What a GridData exposes is its shape, its
// contents, and its FOV defaults.
// =========================================================================
PyGetSetDef PyGridData::getsetters[] = {
{"grid_size", (getter)PyGridData::get_grid_size, NULL,
MCRF_PROPERTY(grid_size, "Grid dimensions as (grid_w, grid_h) (Vector, read-only)."), NULL},
{"grid_w", (getter)PyGridData::get_grid_w, NULL,
MCRF_PROPERTY(grid_w, "Grid width in cells (int, read-only)."), NULL},
{"grid_h", (getter)PyGridData::get_grid_h, NULL,
MCRF_PROPERTY(grid_h, "Grid height in cells (int, read-only)."), NULL},
{"entities", (getter)PyGridData::get_entities, NULL,
MCRF_PROPERTY(entities, "EntityCollection of entities on this map (EntityCollection, read-only). Shared by every view of this data."), NULL},
{"layers", (getter)PyGridData::get_layers, NULL,
MCRF_PROPERTY(layers, "Tuple of grid layers sorted by z_index (tuple, read-only). Contains ColorLayer and TileLayer objects."), NULL},
{"texture", (getter)PyGridData::get_texture, NULL,
MCRF_PROPERTY(texture, "Tile atlas for this map (Texture | None, read-only). Defines the cell size in pixels."), NULL},
{"fov", (getter)PyGridData::get_fov, (setter)PyGridData::set_fov,
MCRF_PROPERTY(fov,
"FOV algorithm for this map (FOV enum). "
"Used by entity.updateVisibility() and layer methods when fov=None."
), NULL},
{"fov_radius", (getter)PyGridData::get_fov_radius, (setter)PyGridData::set_fov_radius,
MCRF_PROPERTY(fov_radius, "Default FOV radius for this map (int). Used when radius is not specified."), NULL},
{NULL}
};

View file

@ -3,7 +3,7 @@
#include "Resources.h"
#include "PyCallable.h"
#include "UIFrame.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UIGridView.h"
#include "McRFPy_Automation.h" // #111 - For simulated mouse position
#include "PythonObjectCache.h" // #184 - For subclass callback support

View file

@ -3,7 +3,7 @@
#include "UIFrame.h"
#include "UICaption.h"
#include "UISprite.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UILine.h"
#include "UICircle.h"
#include "UIArc.h"
@ -162,8 +162,15 @@ int PyPropertyBindingType::init(PyPropertyBindingObject* self, PyObject* args, P
target_ptr = ((PyUICaptionObject*)target_obj)->data;
} else if (strcmp(type_name, "mcrfpy.Sprite") == 0) {
target_ptr = ((PyUISpriteObject*)target_obj)->data;
} else if (strcmp(type_name, "mcrfpy.Grid") == 0) {
target_ptr = ((PyUIGridObject*)target_obj)->data;
} else if (strcmp(type_name, "mcrfpy.Grid") == 0 ||
strcmp(type_name, "mcrfpy.GridView") == 0) {
// #361: mcrfpy.Grid IS the GridView. This arm used to cast the object to
// PyUIGridObject (the _GridData wrapper) and read its shared_ptr<UIGrid>
// out of a PyUIGridViewObject -- type confusion that only "worked" because
// both wrappers had identical layout and both C++ classes happened to put
// UIDrawable at offset 0. Now that GridData is not a drawable the compiler
// rejects it, which is how it surfaced.
target_ptr = ((PyUIGridViewObject*)target_obj)->data;
} else if (strcmp(type_name, "mcrfpy.Line") == 0) {
target_ptr = ((PyUILineObject*)target_obj)->data;
} else if (strcmp(type_name, "mcrfpy.Circle") == 0) {

View file

@ -19,5 +19,5 @@
#include "UISprite.h"
#include "UIGridPoint.h"
#include "UIEntity.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UICollection.h"

View file

@ -5,7 +5,7 @@
#include "PyAlignment.h"
#include "UIFrame.h" // parent= kwarg: Frame parent type
#include "UICaption.h" // parent= kwarg: needed for ATTACH macro instantiation
#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type
#include "PyGridData.h" // parent= kwarg: Grid/GridView parent type
#include "PySceneObject.h" // parent= kwarg: Scene parent type
#include <cmath>
#include <sstream>

View file

@ -26,13 +26,13 @@ typedef struct {
PyObject* weakreflist; // Weak reference support
} PyUICaptionObject;
class UIGrid;
class GridData;
class UIGridView;
typedef struct {
PyObject_HEAD
std::shared_ptr<UIGrid> data;
std::shared_ptr<GridData> data;
PyObject* weakreflist; // Weak reference support
} PyUIGridObject;
} PyGridDataObject;
class UISprite;
typedef struct {
@ -166,7 +166,7 @@ static PyObject* UIDrawable_animate(T* self, PyObject* args, PyObject* kwds)
if ((parent_obj) && (parent_obj) != Py_None) { \
if (!PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PyUIFrameType) && \
!PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PySceneType) && \
!PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PyUIGridType) && \
!PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PyGridDataType) && \
!PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PyUIGridViewType)) { \
PyErr_SetString(PyExc_TypeError, "parent must be a Frame, Scene, or Grid"); \
return -1; \

View file

@ -8,7 +8,7 @@
#include "PyShader.h" // #106: Shader support
#include "PyUniformCollection.h" // #106: Uniform collection support
#include "UIFrame.h" // parent= kwarg: Frame parent type
#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type
#include "PyGridData.h" // parent= kwarg: Grid/GridView parent type
#include "PySceneObject.h" // parent= kwarg: Scene parent type
// UIDrawable methods now in UIBase.h
#include "McRFPy_Doc.h"

View file

@ -8,7 +8,7 @@
#include "PyAlignment.h"
#include "UIFrame.h" // parent= kwarg: Frame parent type
#include "UICaption.h" // parent= kwarg: needed for ATTACH macro instantiation
#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type
#include "PyGridData.h" // parent= kwarg: Grid/GridView parent type
#include "PySceneObject.h" // parent= kwarg: Scene parent type
#include <cmath>

View file

@ -4,7 +4,7 @@
#include "UIFrame.h"
#include "UICaption.h"
#include "UISprite.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UIGridView.h"
#include "UILine.h"
#include "UICircle.h"
@ -69,17 +69,6 @@ static PyObject* convertDrawableToPython(std::shared_ptr<UIDrawable> drawable) {
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIGRID:
{
type = &PyUIGridType;
auto pyObj = (PyUIGridObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UIGrid>(drawable);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIGRIDVIEW:
{
type = &PyUIGridViewType;
@ -163,8 +152,9 @@ static std::shared_ptr<UIDrawable> extractDrawable(PyObject* o) {
return ((PyUICaptionObject*)o)->data;
if (PyObject_IsInstance(o, (PyObject*)&PyUISpriteType))
return ((PyUISpriteObject*)o)->data;
if (PyObject_IsInstance(o, (PyObject*)&PyUIGridType))
return ((PyUIGridObject*)o)->data;
// #361: a GridData is NOT a drawable and cannot be appended to a collection.
// It falls through to the caller's TypeError -- appending a map to a scene used
// to draw the whole grid a second time through a frozen, invisible camera.
if (PyObject_IsInstance(o, (PyObject*)&PyUIGridViewType))
return ((PyUIGridViewObject*)o)->data;
if (PyObject_IsInstance(o, (PyObject*)&PyUILineType))

View file

@ -3,7 +3,7 @@
#include "UIFrame.h"
#include "UICaption.h"
#include "UISprite.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UIGridView.h"
#include "UILine.h"
#include "UICircle.h"
@ -28,8 +28,6 @@ static UIDrawable* extractDrawable(PyObject* self, PyObjectsEnum objtype) {
return ((PyUICaptionObject*)self)->data.get();
case PyObjectsEnum::UISPRITE:
return ((PyUISpriteObject*)self)->data.get();
case PyObjectsEnum::UIGRID:
return ((PyUIGridObject*)self)->data.get();
case PyObjectsEnum::UILINE:
return ((PyUILineObject*)self)->data.get();
case PyObjectsEnum::UICIRCLE:
@ -53,8 +51,6 @@ static std::shared_ptr<UIDrawable> extractDrawableShared(PyObject* self, PyObjec
return ((PyUICaptionObject*)self)->data;
case PyObjectsEnum::UISPRITE:
return ((PyUISpriteObject*)self)->data;
case PyObjectsEnum::UIGRID:
return ((PyUIGridObject*)self)->data;
case PyObjectsEnum::UILINE:
return ((PyUILineObject*)self)->data;
case PyObjectsEnum::UICIRCLE:
@ -259,12 +255,6 @@ PyObject* UIDrawable::get_click(PyObject* self, void* closure) {
else
ptr = NULL;
break;
case PyObjectsEnum::UIGRID:
if (((PyUIGridObject*)self)->data->click_callable)
ptr = ((PyUIGridObject*)self)->data->click_callable->borrow();
else
ptr = NULL;
break;
case PyObjectsEnum::UILINE:
if (((PyUILineObject*)self)->data->click_callable)
ptr = ((PyUILineObject*)self)->data->click_callable->borrow();
@ -314,9 +304,6 @@ int UIDrawable::set_click(PyObject* self, PyObject* value, void* closure) {
case PyObjectsEnum::UISPRITE:
target = (((PyUISpriteObject*)self)->data.get());
break;
case PyObjectsEnum::UIGRID:
target = (((PyUIGridObject*)self)->data.get());
break;
case PyObjectsEnum::UILINE:
target = (((PyUILineObject*)self)->data.get());
break;
@ -727,20 +714,21 @@ int UIDrawable::set_rotate_with_camera(PyObject* self, PyObject* value, void* cl
return 0;
}
// #221 - Grid coordinate properties (only valid when parent is UIGrid)
// #221 - Grid coordinate properties (only valid when the parent is a Grid, i.e. a UIGridView)
PyObject* UIDrawable::get_grid_pos(PyObject* self, void* closure) {
PyObjectsEnum objtype = static_cast<PyObjectsEnum>(reinterpret_cast<intptr_t>(closure));
UIDrawable* drawable = extractDrawable(self, objtype);
if (!drawable) return NULL;
// Check if parent is a UIGrid
// Check if parent is a Grid (UIGridView) -- #361: GridData is not a drawable
// and can never be a parent.
auto parent_ptr = drawable->getParent();
if (!parent_ptr) {
PyErr_SetString(PyExc_RuntimeError, "drawable is not a child of a Grid");
return NULL;
}
UIGrid* grid = dynamic_cast<UIGrid*>(parent_ptr.get());
UIGridView* grid = dynamic_cast<UIGridView*>(parent_ptr.get());
if (!grid) {
PyErr_SetString(PyExc_RuntimeError, "drawable is not a direct child of a Grid");
return NULL;
@ -766,14 +754,15 @@ int UIDrawable::set_grid_pos(PyObject* self, PyObject* value, void* closure) {
UIDrawable* drawable = extractDrawable(self, objtype);
if (!drawable) return -1;
// Check if parent is a UIGrid
// Check if parent is a Grid (UIGridView) -- #361: GridData is not a drawable
// and can never be a parent.
auto parent_ptr = drawable->getParent();
if (!parent_ptr) {
PyErr_SetString(PyExc_RuntimeError, "drawable is not a child of a Grid");
return -1;
}
UIGrid* grid = dynamic_cast<UIGrid*>(parent_ptr.get());
UIGridView* grid = dynamic_cast<UIGridView*>(parent_ptr.get());
if (!grid) {
PyErr_SetString(PyExc_RuntimeError, "drawable is not a direct child of a Grid");
return -1;
@ -843,14 +832,15 @@ PyObject* UIDrawable::get_grid_size(PyObject* self, void* closure) {
UIDrawable* drawable = extractDrawable(self, objtype);
if (!drawable) return NULL;
// Check if parent is a UIGrid
// Check if parent is a Grid (UIGridView) -- #361: GridData is not a drawable
// and can never be a parent.
auto parent_ptr = drawable->getParent();
if (!parent_ptr) {
PyErr_SetString(PyExc_RuntimeError, "drawable is not a child of a Grid");
return NULL;
}
UIGrid* grid = dynamic_cast<UIGrid*>(parent_ptr.get());
UIGridView* grid = dynamic_cast<UIGridView*>(parent_ptr.get());
if (!grid) {
PyErr_SetString(PyExc_RuntimeError, "drawable is not a direct child of a Grid");
return NULL;
@ -877,14 +867,15 @@ int UIDrawable::set_grid_size(PyObject* self, PyObject* value, void* closure) {
UIDrawable* drawable = extractDrawable(self, objtype);
if (!drawable) return -1;
// Check if parent is a UIGrid
// Check if parent is a Grid (UIGridView) -- #361: GridData is not a drawable
// and can never be a parent.
auto parent_ptr = drawable->getParent();
if (!parent_ptr) {
PyErr_SetString(PyExc_RuntimeError, "drawable is not a child of a Grid");
return -1;
}
UIGrid* grid = dynamic_cast<UIGrid*>(parent_ptr.get());
UIGridView* grid = dynamic_cast<UIGridView*>(parent_ptr.get());
if (!grid) {
PyErr_SetString(PyExc_RuntimeError, "drawable is not a direct child of a Grid");
return -1;
@ -1336,17 +1327,6 @@ PyObject* UIDrawable::get_parent(PyObject* self, void* closure) {
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIGRID:
{
type = &mcrfpydef::PyUIGridType;
auto pyObj = (PyUIGridObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UIGrid>(parent_ptr);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIGRIDVIEW:
{
type = &mcrfpydef::PyUIGridViewType;
@ -1381,9 +1361,6 @@ int UIDrawable::set_parent(PyObject* self, PyObject* value, void* closure) {
case PyObjectsEnum::UISPRITE:
drawable = ((PyUISpriteObject*)self)->data;
break;
case PyObjectsEnum::UIGRID:
drawable = ((PyUIGridObject*)self)->data;
break;
case PyObjectsEnum::UILINE:
drawable = ((PyUILineObject*)self)->data;
break;
@ -1408,7 +1385,7 @@ int UIDrawable::set_parent(PyObject* self, PyObject* value, void* closure) {
}
// Value must be a Frame, Grid, or Scene (things with children collections).
// #364: the internal _GridData (PyUIGridType) is NOT among them -- it holds
// #364: the internal _GridData (PyGridDataType) is NOT among them -- it holds
// entities and cells, never drawables -- so it falls through to the TypeError.
bool is_frame = PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIFrameType);
bool is_gridview = PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType);
@ -1577,10 +1554,6 @@ PyObject* UIDrawable::get_on_enter(PyObject* self, void* closure) {
if (((PyUISpriteObject*)self)->data->on_enter_callable)
ptr = ((PyUISpriteObject*)self)->data->on_enter_callable->borrow();
break;
case PyObjectsEnum::UIGRID:
if (((PyUIGridObject*)self)->data->on_enter_callable)
ptr = ((PyUIGridObject*)self)->data->on_enter_callable->borrow();
break;
case PyObjectsEnum::UILINE:
if (((PyUILineObject*)self)->data->on_enter_callable)
ptr = ((PyUILineObject*)self)->data->on_enter_callable->borrow();
@ -1622,9 +1595,6 @@ int UIDrawable::set_on_enter(PyObject* self, PyObject* value, void* closure) {
case PyObjectsEnum::UISPRITE:
target = ((PyUISpriteObject*)self)->data.get();
break;
case PyObjectsEnum::UIGRID:
target = ((PyUIGridObject*)self)->data.get();
break;
case PyObjectsEnum::UILINE:
target = ((PyUILineObject*)self)->data.get();
break;
@ -1668,10 +1638,6 @@ PyObject* UIDrawable::get_on_exit(PyObject* self, void* closure) {
if (((PyUISpriteObject*)self)->data->on_exit_callable)
ptr = ((PyUISpriteObject*)self)->data->on_exit_callable->borrow();
break;
case PyObjectsEnum::UIGRID:
if (((PyUIGridObject*)self)->data->on_exit_callable)
ptr = ((PyUIGridObject*)self)->data->on_exit_callable->borrow();
break;
case PyObjectsEnum::UILINE:
if (((PyUILineObject*)self)->data->on_exit_callable)
ptr = ((PyUILineObject*)self)->data->on_exit_callable->borrow();
@ -1713,9 +1679,6 @@ int UIDrawable::set_on_exit(PyObject* self, PyObject* value, void* closure) {
case PyObjectsEnum::UISPRITE:
target = ((PyUISpriteObject*)self)->data.get();
break;
case PyObjectsEnum::UIGRID:
target = ((PyUIGridObject*)self)->data.get();
break;
case PyObjectsEnum::UILINE:
target = ((PyUILineObject*)self)->data.get();
break;
@ -1768,10 +1731,6 @@ PyObject* UIDrawable::get_on_move(PyObject* self, void* closure) {
if (((PyUISpriteObject*)self)->data->on_move_callable)
ptr = ((PyUISpriteObject*)self)->data->on_move_callable->borrow();
break;
case PyObjectsEnum::UIGRID:
if (((PyUIGridObject*)self)->data->on_move_callable)
ptr = ((PyUIGridObject*)self)->data->on_move_callable->borrow();
break;
case PyObjectsEnum::UILINE:
if (((PyUILineObject*)self)->data->on_move_callable)
ptr = ((PyUILineObject*)self)->data->on_move_callable->borrow();
@ -1813,9 +1772,6 @@ int UIDrawable::set_on_move(PyObject* self, PyObject* value, void* closure) {
case PyObjectsEnum::UISPRITE:
target = ((PyUISpriteObject*)self)->data.get();
break;
case PyObjectsEnum::UIGRID:
target = ((PyUIGridObject*)self)->data.get();
break;
case PyObjectsEnum::UILINE:
target = ((PyUILineObject*)self)->data.get();
break;
@ -2180,7 +2136,6 @@ PyObject* UIDrawable::py_realign(PyObject* self, PyObject* args) {
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUICaptionType)) objtype = PyObjectsEnum::UICAPTION;
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUISpriteType)) objtype = PyObjectsEnum::UISPRITE;
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUIGridViewType)) objtype = PyObjectsEnum::UIGRIDVIEW;
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUIGridType)) objtype = PyObjectsEnum::UIGRID;
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUILineType)) objtype = PyObjectsEnum::UILINE;
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUICircleType)) objtype = PyObjectsEnum::UICIRCLE;
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUIArcType)) objtype = PyObjectsEnum::UIARC;

View file

@ -22,14 +22,15 @@ class UniformCollection;
// PyShaderObject is a typedef, forward declare as a struct with explicit typedef
typedef struct PyShaderObjectStruct PyShaderObject;
class UIFrame; class UICaption; class UISprite; class UIEntity; class UIGrid; class GridData;
class UIFrame; class UICaption; class UISprite; class UIEntity; class GridData;
enum PyObjectsEnum : int
{
UIFRAME = 1,
UICAPTION,
UISPRITE,
UIGRID,
// #361: no UIGRID. GridData is not a UIDrawable, so it has no derived_type()
// and can never appear in a scene graph. Every grid node IS a UIGRIDVIEW.
UILINE,
UICIRCLE,
UIARC,
@ -130,7 +131,7 @@ public:
static PyObject* get_rotate_with_camera(PyObject* self, void* closure);
static int set_rotate_with_camera(PyObject* self, PyObject* value, void* closure);
// #221 - Grid coordinate properties (only valid when parent is UIGrid)
// #221 - Grid coordinate properties (only valid when the parent is a Grid/UIGridView)
static PyObject* get_grid_pos(PyObject* self, void* closure);
static int set_grid_pos(PyObject* self, PyObject* value, void* closure);
static PyObject* get_grid_size(PyObject* self, void* closure);
@ -380,7 +381,7 @@ static PyObject* PyUIDrawable_get_click(PyObject* self, void* closure) {
ptr = ((PyUISpriteObject*)self)->data->click_callable->borrow();
break;
case PyObjectsEnum::UIGRID:
ptr = ((PyUIGridObject*)self)->data->click_callable->borrow();
ptr = ((PyGridDataObject*)self)->data->click_callable->borrow();
break;
default:
PyErr_SetString(PyExc_TypeError, "no idea how you did that; invalid UIDrawable derived instance for _get_click");
@ -409,7 +410,7 @@ static int PyUIDrawable_set_click(PyObject* self, PyObject* value, void* closure
target = (((PyUISpriteObject*)self)->data.get());
break;
case PyObjectsEnum::UIGRID:
target = (((PyUIGridObject*)self)->data.get());
target = (((PyGridDataObject*)self)->data.get());
break;
default:
PyErr_SetString(PyExc_TypeError, "no idea how you did that; invalid UIDrawable derived instance for _set_click");

View file

@ -1,5 +1,5 @@
#include "UIEntity.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UIGridView.h" // #252: Entity.grid accepts GridView
#include "UIGridPathfinding.h"
#include "PathProvider.h"
@ -23,19 +23,10 @@
#include "McRFPy_Doc.h"
#include <cassert>
// #313: UIEntity::grid holds the GridData base, but some Python wrappers
// (PyUIGridObject, PyUIGridPointObject) and pathfinding helpers still take the
// full UIGrid. GridData is never independently heap-allocated -- it is always
// a UIGrid base subobject (see GridData.h) -- so this aliasing downcast is
// valid and shares the original control block (never mints a new one, which
// would double-free and break the #251 use_count dealloc gate).
// TODO(#252): remove once those wrappers accept pure GridData.
static std::shared_ptr<UIGrid> grid_as_uigrid(const std::shared_ptr<GridData>& grid)
{
if (!grid) return nullptr;
assert(dynamic_cast<UIGrid*>(grid.get()) != nullptr);
return std::shared_ptr<UIGrid>(grid, static_cast<UIGrid*>(grid.get()));
}
// #361: grid_as_uigrid() is gone. It existed to recover the "full UIGrid" that
// every GridData was secretly a base subobject of, so that Python wrappers and
// pathfinding helpers could get at the drawable half. There is no drawable half
// any more -- GridData is the whole object, and every wrapper takes it directly.
UIEntity::UIEntity()
: grid(nullptr), position(0.0f, 0.0f), sprite_offset(0.0f, 0.0f)
@ -195,7 +186,7 @@ PyObject* UIEntity::at(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
auto type = &mcrfpydef::PyUIGridPointType;
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
if (!obj) return NULL;
obj->grid = grid_as_uigrid(entity->grid); // #313: wrapper still holds UIGrid
obj->grid = entity->grid;
obj->x = x;
obj->y = y;
return (PyObject*)obj;
@ -290,7 +281,7 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
}
// Handle grid argument - accept both internal _GridData and GridView (unified Grid)
if (grid_obj && !PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridType) &&
if (grid_obj && !PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyGridDataType) &&
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
PyErr_SetString(PyExc_TypeError, "grid must be a mcrfpy.Grid instance");
return -1;
@ -379,7 +370,7 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
grid_ptr = pyview->data->grid_data;
} else {
// Internal _GridData type
PyUIGridObject* pygrid = (PyUIGridObject*)grid_obj;
PyGridDataObject* pygrid = (PyGridDataObject*)grid_obj;
grid_ptr = pygrid->data;
}
if (grid_ptr) {
@ -819,69 +810,20 @@ PyObject* UIEntity::get_grid(PyUIEntityObject* self, void* closure)
Py_RETURN_NONE;
}
auto& grid = self->data->grid;
// #252/#359: If the grid has a registered GridView, return that instead.
// This preserves the unified Grid API where entity.grid returns the same
// object the user created via mcrfpy.Grid(...). primaryView() is
// deterministic even with multiple views sharing this GridData: it's
// views.front(), the view that actually created the data (a fresh
// GridData is only ever produced by the Grid() factory path), or the
// first still-alive secondary view if that original view has since died.
auto owning_view = grid->primaryView();
if (owning_view) {
// Check cache for the GridView
if (owning_view->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(owning_view->serial_number);
if (cached) return cached;
}
auto view_type = &mcrfpydef::PyUIGridViewType;
auto pyView = (PyUIGridViewObject*)view_type->tp_alloc(view_type, 0);
if (pyView) {
pyView->data = owning_view;
pyView->weakreflist = NULL;
if (owning_view->serial_number == 0) {
owning_view->serial_number = PythonObjectCache::getInstance().assignSerial();
}
PyObject* weakref = PyWeakref_NewRef((PyObject*)pyView, NULL);
if (weakref) {
PythonObjectCache::getInstance().registerObject(owning_view->serial_number, weakref);
Py_DECREF(weakref);
}
}
return (PyObject*)pyView;
}
// Fallback: return internal _GridData wrapper (no owning view)
// #313: serial_number lives on the UIDrawable side; recover the full
// UIGrid via the aliasing helper (same control block, no new ownership).
auto uigrid = grid_as_uigrid(grid);
if (uigrid->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(uigrid->serial_number);
if (cached) {
return cached;
}
}
auto grid_type = &mcrfpydef::PyUIGridType;
auto pyGrid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
if (pyGrid) {
pyGrid->data = uigrid;
pyGrid->weakreflist = NULL;
if (uigrid->serial_number == 0) {
uigrid->serial_number = PythonObjectCache::getInstance().assignSerial();
}
PyObject* weakref = PyWeakref_NewRef((PyObject*)pyGrid, NULL);
if (weakref) {
PythonObjectCache::getInstance().registerObject(uigrid->serial_number, weakref);
Py_DECREF(weakref);
}
}
return (PyObject*)pyGrid;
// #361 - Returns the GridData (the MAP), not a view.
//
// This is a deliberate, user-visible break. It used to return primaryView() --
// the Grid whose constructor happened to create this data -- falling back to a
// bare _GridData wrapper when no view existed. But "the grid an entity is in"
// is not a camera: an entity may live on a map with NO view (an offscreen
// level, still steppable and queryable) or with SEVERAL, and picking
// views.front() was an arbitrary answer dressed up as a deterministic one.
//
// Migration: `player.grid.center_camera(player.pos)` becomes
// `view.center_camera(player.pos)` on the view you are actually rendering.
// Everything that is genuinely about the map -- at(), entities, layers,
// compute_fov(), find_path(), step() -- is unchanged.
return PyGridData::pyobject_for(self->data->grid);
}
int UIEntity::set_grid(PyUIEntityObject* self, PyObject* value, void* closure)
@ -924,8 +866,8 @@ int UIEntity::set_grid(PyUIEntityObject* self, PyObject* value, void* closure)
PyErr_SetString(PyExc_ValueError, "Grid has no data");
return -1;
}
} else if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) {
new_grid = ((PyUIGridObject*)value)->data;
} else if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyGridDataType)) {
new_grid = ((PyGridDataObject*)value)->data;
} else {
PyErr_SetString(PyExc_TypeError, "grid must be a Grid or None");
return -1;
@ -1263,12 +1205,9 @@ PyObject* UIEntity::find_path(PyUIEntityObject* self, PyObject* args, PyObject*
auto grid = self->data->grid;
// Extract target position
// #313: ExtractPosition still takes UIGrid*; the downcast is valid because
// GridData is always a UIGrid base subobject (see grid_as_uigrid).
int target_x, target_y;
if (!UIGridPathfinding::ExtractPosition(target_obj, &target_x, &target_y,
static_cast<UIGrid*>(grid.get()), "target")) {
grid.get(), "target")) {
return NULL;
}
@ -1282,13 +1221,9 @@ PyObject* UIEntity::find_path(PyUIEntityObject* self, PyObject* args, PyObject*
return NULL;
}
// Build args to delegate to Grid.find_path
// Create a temporary PyUIGridObject wrapper for the grid (internal _GridData type)
// #313: wrapper holds shared_ptr<UIGrid>; alias-cast from the data ptr.
auto* grid_type = &mcrfpydef::PyUIGridType;
auto pyGrid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
// Delegate to GridData.find_path on the map's one Python wrapper (#361).
PyObject* pyGrid = PyGridData::pyobject_for(grid);
if (!pyGrid) return NULL;
new (&pyGrid->data) std::shared_ptr<UIGrid>(grid_as_uigrid(grid));
// Build keyword args for Grid.find_path
PyObject* start_tuple = Py_BuildValue("(ii)", start_x, start_y);
@ -1307,7 +1242,7 @@ PyObject* UIEntity::find_path(PyUIEntityObject* self, PyObject* args, PyObject*
Py_DECREF(py_collide);
}
PyObject* result = UIGridPathfinding::Grid_find_path(pyGrid, fwd_args, fwd_kwds);
PyObject* result = UIGridPathfinding::Grid_find_path((PyGridDataObject*)pyGrid, fwd_args, fwd_kwds);
Py_DECREF(fwd_args);
Py_DECREF(fwd_kwds);

View file

@ -22,7 +22,7 @@
#include "DiscreteMap.h"
#include <memory>
class UIGrid;
class GridData;
class GridData;
// UIEntity

View file

@ -5,7 +5,7 @@
#include "UIEntityCollection.h"
#include "UIEntity.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "McRFPy_API.h"
#include "McRFPy_Doc.h"
#include "PythonObjectCache.h"

View file

@ -18,13 +18,13 @@
// Forward declarations
class UIEntity;
class UIGrid;
class GridData;
// Python object for EntityCollection
typedef struct {
PyObject_HEAD
std::shared_ptr<std::vector<std::shared_ptr<UIEntity>>> data;
std::shared_ptr<UIGrid> grid;
std::shared_ptr<GridData> grid;
} PyUIEntityCollectionObject;
// Python object for EntityCollection iterator

View file

@ -4,7 +4,7 @@
#include "PyVector.h"
#include "UICaption.h"
#include "UISprite.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "PySceneObject.h" // parent= kwarg: Scene parent type
#include "McRFPy_API.h"
#include "PythonObjectCache.h"
@ -789,7 +789,6 @@ int UIFrame::init(PyUIFrameObject* self, PyObject* args, PyObject* kwds)
if (!PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUIFrameType) &&
!PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUICaptionType) &&
!PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUISpriteType) &&
!PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUIGridType) &&
!PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
Py_DECREF(child);
PyErr_SetString(PyExc_TypeError, "children must contain only Frame, Caption, Sprite, or Grid objects");
@ -806,9 +805,8 @@ int UIFrame::init(PyUIFrameObject* self, PyObject* args, PyObject* kwds)
drawable = ((PyUISpriteObject*)child)->data;
} else if (PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
drawable = ((PyUIGridViewObject*)child)->data;
} else if (PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUIGridType)) {
drawable = ((PyUIGridObject*)child)->data;
}
// #361: a GridData is not a drawable -- it is rejected above.
if (drawable) {
drawable->setParent(self->data); // Set parent before adding (enables alignment)

File diff suppressed because it is too large Load diff

View file

@ -1,275 +0,0 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include "structmember.h"
#include "IndexTexture.h"
#include "Resources.h"
#include <list>
#include <libtcod.h>
#include <mutex>
#include <optional>
#include <map>
#include <memory>
#include "PyCallable.h"
#include "PyTexture.h"
#include "PyDrawable.h"
#include "PyColor.h"
#include "PyVector.h"
#include "PyFont.h"
#include "UIGridPoint.h"
#include "UIEntity.h"
#include "UIDrawable.h"
#include "UIBase.h"
#include "GridLayers.h"
#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;
// UIGrid inherits both UIDrawable (rendering) and GridData (state).
// This allows GridData to be shared with GridView for multi-view support (#252).
class UIGrid: public UIDrawable, public GridData
{
private:
std::shared_ptr<PyTexture> ptex;
// Default cell dimensions when no texture is provided
static constexpr int DEFAULT_CELL_WIDTH = 16;
static constexpr int DEFAULT_CELL_HEIGHT = 16;
public:
UIGrid();
UIGrid(int, int, std::shared_ptr<PyTexture>, sf::Vector2f, sf::Vector2f);
~UIGrid();
// #313: GridData also declares markDirty/markCompositeDirty (data-layer
// invalidation for entities holding shared_ptr<GridData>). Within UIGrid,
// keep resolving unqualified calls to the UIDrawable flag-setters --
// identical to pre-#313 behavior.
using UIDrawable::markDirty;
using UIDrawable::markCompositeDirty;
void update();
void render(sf::Vector2f, sf::RenderTarget&) override final;
PyObjectsEnum derived_type() override final;
virtual UIDrawable* click_at(sf::Vector2f point) override final;
GridData* asGridData() override { return static_cast<GridData*>(this); } // #355
// Phase 1 virtual method implementations
sf::FloatRect get_bounds() const override;
void move(float dx, float dy) override;
void resize(float w, float h) override;
void onPositionChanged() override;
// =========================================================================
// Rendering-only members (NOT in GridData)
// =========================================================================
sf::RectangleShape box;
float center_x, center_y, zoom;
float camera_rotation = 0.0f;
std::shared_ptr<PyTexture> getTexture();
sf::Sprite sprite, output;
sf::RenderTexture renderTexture;
sf::Vector2u renderTextureSize{0, 0};
void ensureRenderTextureSize();
std::unique_ptr<sf::RenderTexture> rotationTexture; // #338 - lazy: only rotating grids allocate it
unsigned int rotationTextureSize = 0;
// Background rendering
sf::Color fill_color;
// #355: the perspective system (entity + enabled flag) lives on UIGridView,
// which renders the FOV overlay. Keeping a copy here was a dead duplicate.
// #355 - cell callback firing, screenToCell and cell hover moved to UIGridView.
// getEffectiveCellSize stays: UIDrawable uses it for grid_pos <-> pixel math.
sf::Vector2f getEffectiveCellSize() const;
// Property system for animations
bool setProperty(const std::string& name, float value) override;
bool setProperty(const std::string& name, const sf::Vector2f& value) override;
bool getProperty(const std::string& name, float& value) const override;
bool getProperty(const std::string& name, sf::Vector2f& value) const override;
bool hasProperty(const std::string& name) const override;
// #169 - Camera positioning
void center_camera();
void center_camera(float tile_x, float tile_y);
// =========================================================================
// Python API (static methods)
// =========================================================================
static int init(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* get_grid_size(PyUIGridObject* self, void* closure);
static PyObject* get_grid_w(PyUIGridObject* self, void* closure);
static PyObject* get_grid_h(PyUIGridObject* self, void* closure);
static PyObject* get_size(PyUIGridObject* self, void* closure);
static int set_size(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* get_center(PyUIGridObject* self, void* closure);
static int set_center(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* get_float_member(PyUIGridObject* self, void* closure);
static int set_float_member(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* get_texture(PyUIGridObject* self, void* closure);
static PyObject* get_fill_color(PyUIGridObject* self, void* closure);
static int set_fill_color(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* get_fov(PyUIGridObject* self, void* closure);
static int set_fov(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* get_fov_radius(PyUIGridObject* self, void* closure);
static int set_fov_radius(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_center_camera(PyUIGridObject* self, PyObject* args);
static PyObject* get_camera_rotation(PyUIGridObject* self, void* closure);
static int set_camera_rotation(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* py_apply_threshold(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_apply_ranges(PyUIGridObject* self, PyObject* args);
static PyObject* py_step(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
static PyMappingMethods mpmethods;
static PyObject* subscript(PyUIGridObject* self, PyObject* key);
static PyObject* get_entities(PyUIGridObject* self, void* closure);
// #364: get_children lives on UIGridView -- overlay children belong to the view.
static PyObject* repr(PyUIGridObject* self);
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);
static PyObject* py_layer(PyUIGridObject* self, PyObject* args);
};
// UIEntityCollection types are now in UIEntityCollection.h
// Forward declaration of methods array
extern PyMethodDef UIGrid_all_methods[];
namespace mcrfpydef {
inline PyTypeObject PyUIGridType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy._GridData", // #252: internal type, "Grid" is now GridView
.tp_basicsize = sizeof(PyUIGridObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)[](PyObject* self)
{
PyUIGridObject* obj = (PyUIGridObject*)self;
// Untrack from GC before destroying
PyObject_GC_UnTrack(self);
// Clear weak references
if (obj->weakreflist != NULL) {
PyObject_ClearWeakRefs(self);
}
// Only unregister callbacks if we're the last owner (#251)
if (obj->data && obj->data.use_count() <= 1) {
obj->data->click_unregister();
obj->data->on_enter_unregister();
obj->data->on_exit_unregister();
obj->data->on_move_unregister();
// #355: cell callbacks now live on UIGridView, not GridData.
}
obj->data.reset();
Py_TYPE(self)->tp_free(self);
},
.tp_repr = (reprfunc)UIGrid::repr,
.tp_as_mapping = &UIGrid::mpmethods,
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC,
.tp_doc = PyDoc_STR("Grid(pos=None, size=None, grid_size=None, texture=None, **kwargs)\n\n"
"A grid-based UI element for tile-based rendering and entity management.\n\n"
"Args:\n"
" pos (tuple, optional): Position as (x, y) tuple. Default: (0, 0)\n"
" size (tuple, optional): Size as (width, height) tuple. Default: auto-calculated from grid_size\n"
" grid_size (tuple, optional): Grid dimensions as (grid_w, grid_h) tuple. Default: (2, 2)\n"
" texture (Texture, optional): Texture containing tile sprites. Default: default texture\n\n"
"Keyword Args:\n"
" fill_color (Color): Background fill color. Default: None\n"
" click (callable): Click event handler. Default: None\n"
" center_x (float): X coordinate of center point. Default: 0\n"
" center_y (float): Y coordinate of center point. Default: 0\n"
" zoom (float): Zoom level for rendering. Default: 1.0\n"
" visible (bool): Visibility state. Default: True\n"
" opacity (float): Opacity (0.0-1.0). Default: 1.0\n"
" z_index (int): Rendering order. Default: 0\n"
" name (str): Element name for finding. Default: None\n"
" x (float): X position override. Default: 0\n"
" y (float): Y position override. Default: 0\n"
" w (float): Width override. Default: auto-calculated\n"
" h (float): Height override. Default: auto-calculated\n"
" grid_w (int): Grid width override. Default: 2\n"
" grid_h (int): Grid height override. Default: 2\n"
" align (Alignment): Alignment relative to parent. Default: None\n"
" margin (float): Margin from parent edge when aligned. Default: 0\n"
" horiz_margin (float): Horizontal margin override. Default: 0 (use margin)\n"
" vert_margin (float): Vertical margin override. Default: 0 (use margin)\n\n"
"Attributes:\n"
" x, y (float): Position in pixels\n"
" w, h (float): Size in pixels\n"
" pos (Vector): Position as a Vector object\n"
" size (Vector): Size as (width, height) Vector\n"
" center (Vector): Center point as (x, y) Vector\n"
" center_x, center_y (float): Center point coordinates\n"
" zoom (float): Zoom level for rendering\n"
" grid_size (Vector): Grid dimensions (width, height) in tiles\n"
" grid_w, grid_h (int): Grid dimensions\n"
" texture (Texture): Tile texture atlas\n"
" fill_color (Color): Background color\n"
" entities (EntityCollection): Collection of entities in the grid\n"
" click (callable): Click event handler\n"
" visible (bool): Visibility state\n"
" opacity (float): Opacity value\n"
" z_index (int): Rendering order\n"
" name (str): Element name\n"
" align (Alignment): Alignment relative to parent (or None)\n"
" margin (float): General margin for alignment\n"
" horiz_margin (float): Horizontal margin override\n"
" vert_margin (float): Vertical margin override"),
.tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int {
PyUIGridObject* obj = (PyUIGridObject*)self;
if (obj->data) {
if (obj->data->click_callable) {
PyObject* callback = obj->data->click_callable->borrow();
if (callback && callback != Py_None) Py_VISIT(callback);
}
if (obj->data->on_enter_callable) {
PyObject* callback = obj->data->on_enter_callable->borrow();
if (callback && callback != Py_None) Py_VISIT(callback);
}
if (obj->data->on_exit_callable) {
PyObject* callback = obj->data->on_exit_callable->borrow();
if (callback && callback != Py_None) Py_VISIT(callback);
}
if (obj->data->on_move_callable) {
PyObject* callback = obj->data->on_move_callable->borrow();
if (callback && callback != Py_None) Py_VISIT(callback);
}
}
return 0;
},
.tp_clear = [](PyObject* self) -> int {
PyUIGridObject* obj = (PyUIGridObject*)self;
if (obj->data) {
obj->data->click_unregister();
obj->data->on_enter_unregister();
obj->data->on_exit_unregister();
obj->data->on_move_unregister();
}
return 0;
},
.tp_methods = UIGrid_all_methods,
.tp_getset = UIGrid::getsetters,
.tp_base = &mcrfpydef::PyDrawableType,
.tp_init = (initproc)UIGrid::init,
.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>();
}
return (PyObject*)self;
}
};
}

View file

@ -1,5 +1,5 @@
#include "UIGridPathfinding.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UIEntity.h"
#include "PyVector.h"
#include "McRFPy_API.h"
@ -130,7 +130,7 @@ sf::Vector2i DijkstraMap::descentStep(int x, int y, bool* valid) const {
//=============================================================================
bool UIGridPathfinding::ExtractPosition(PyObject* obj, int* x, int* y,
UIGrid* expected_grid,
GridData* expected_grid,
const char* arg_name) {
// Check if it's an Entity
if (PyObject_IsInstance(obj, (PyObject*)&mcrfpydef::PyUIEntityType)) {
@ -610,7 +610,7 @@ static void restoreCollisionLabel(GridData* grid,
}
}
PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
PyObject* UIGridPathfinding::Grid_find_path(PyGridDataObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"start", "end", "diagonal_cost", "collide",
"heuristic", "weight", NULL};
PyObject* start_obj = NULL;
@ -715,7 +715,7 @@ PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args
// - a DiscreteMap mask (non-zero cells become roots)
// Returns true on success; populates `out_roots` and `out_mask_used`.
// If a DiscreteMap mask is used, caller should prefer the masked C path.
static bool collectRoots(PyObject* root_obj, UIGrid* grid,
static bool collectRoots(PyObject* root_obj, GridData* grid,
std::vector<sf::Vector2i>* out_roots,
PyDiscreteMapObject** out_mask)
{
@ -795,7 +795,7 @@ static bool collectRoots(PyObject* root_obj, UIGrid* grid,
return false;
}
PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyGridDataObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"root", "diagonal_cost", "collide", "roots", NULL};
PyObject* root_obj = NULL;
PyObject* roots_obj = NULL;
@ -896,7 +896,7 @@ PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObjec
return (PyObject*)result;
}
PyObject* UIGridPathfinding::Grid_clear_dijkstra_maps(PyUIGridObject* self, PyObject* args) {
PyObject* UIGridPathfinding::Grid_clear_dijkstra_maps(PyGridDataObject* self, PyObject* args) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Grid is invalid");
return NULL;

View file

@ -1,14 +1,14 @@
#pragma once
#include "Common.h"
#include "Python.h"
#include "UIBase.h" // For PyUIGridObject typedef
#include "UIBase.h" // For PyGridDataObject typedef
#include <libtcod.h>
#include <vector>
#include <memory>
#include <map>
// Forward declarations
class UIGrid;
class GridData;
//=============================================================================
// AStarPath - A computed A* path result, consumed like an iterator
@ -91,7 +91,7 @@ namespace UIGridPathfinding {
// Sets Python error and returns false on failure
// If expected_grid is provided and obj is Entity, validates grid membership
bool ExtractPosition(PyObject* obj, int* x, int* y,
UIGrid* expected_grid = nullptr,
GridData* expected_grid = nullptr,
const char* arg_name = "position");
//=========================================================================
@ -143,13 +143,13 @@ namespace UIGridPathfinding {
//=========================================================================
// Grid.find_path() -> AStarPath | None
PyObject* Grid_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds);
PyObject* Grid_find_path(PyGridDataObject* self, PyObject* args, PyObject* kwds);
// Grid.get_dijkstra_map() -> DijkstraMap
PyObject* Grid_get_dijkstra_map(PyUIGridObject* self, PyObject* args, PyObject* kwds);
PyObject* Grid_get_dijkstra_map(PyGridDataObject* self, PyObject* args, PyObject* kwds);
// Grid.clear_dijkstra_maps() -> None
PyObject* Grid_clear_dijkstra_maps(PyUIGridObject* self, PyObject* args);
PyObject* Grid_clear_dijkstra_maps(PyGridDataObject* self, PyObject* args);
}
//=============================================================================

View file

@ -1,5 +1,5 @@
#include "UIGridPoint.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UIEntity.h" // #114 - for GridPoint.entities
#include "GridLayers.h" // #150 - for GridLayerType, ColorLayer, TileLayer
#include "McRFPy_Doc.h" // #177 - for MCRF_PROPERTY macro

View file

@ -22,7 +22,7 @@ class UIGridPoint;
typedef struct {
PyObject_HEAD
std::shared_ptr<UIGrid> grid;
std::shared_ptr<GridData> grid;
int x, y; // Grid coordinates - compute data pointer on access
} PyUIGridPointObject;

View file

@ -1,423 +0,0 @@
// UIGridPyProperties.cpp — Python getter/setter implementations for UIGrid
// Extracted from UIGrid.cpp (#149) for maintainability.
// Contains: all PyGetSetDef getters/setters and the getsetters[] array.
#include "UIGrid.h"
#include "UIGridView.h"
#include "McRFPy_API.h"
#include "PythonObjectCache.h"
#include "PyColor.h"
#include "PyVector.h"
#include "PyFOV.h"
#include "UIBase.h"
#include "UICollection.h"
#include "McRFPy_Doc.h"
// =========================================================================
// Grid dimension properties
// =========================================================================
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_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;
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));
unsigned int tex_width = static_cast<unsigned int>(w * 1.5f);
unsigned int tex_height = static_cast<unsigned int>(h * 1.5f);
tex_width = std::min(tex_width, 4096u);
tex_height = std::min(tex_height, 4096u);
self->data->renderTexture.create(tex_width, tex_height);
self->data->markDirty();
return 0;
}
// =========================================================================
// Camera/view properties
// =========================================================================
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;
self->data->markDirty();
return 0;
}
PyObject* UIGrid::get_float_member(PyUIGridObject* self, void* closure)
{
auto member_ptr = reinterpret_cast<intptr_t>(closure);
if (member_ptr == 0)
return PyFloat_FromDouble(self->data->box.getPosition().x);
else if (member_ptr == 1)
return PyFloat_FromDouble(self->data->box.getPosition().y);
else if (member_ptr == 2)
return PyFloat_FromDouble(self->data->box.getSize().x);
else if (member_ptr == 3)
return PyFloat_FromDouble(self->data->box.getSize().y);
else if (member_ptr == 4)
return PyFloat_FromDouble(self->data->center_x);
else if (member_ptr == 5)
return PyFloat_FromDouble(self->data->center_y);
else if (member_ptr == 6)
return PyFloat_FromDouble(self->data->zoom);
else if (member_ptr == 7)
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)
self->data->box.setPosition(val, self->data->box.getPosition().y);
else if (member_ptr == 1)
self->data->box.setPosition(self->data->box.getPosition().x, val);
else if (member_ptr == 2)
{
self->data->box.setSize(sf::Vector2f(val, self->data->box.getSize().y));
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)
{
self->data->box.setSize(sf::Vector2f(self->data->box.getSize().x, val));
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)
self->data->center_x = val;
else if (member_ptr == 5)
self->data->center_y = val;
else if (member_ptr == 6)
self->data->zoom = val;
else if (member_ptr == 7)
self->data->camera_rotation = val;
if (member_ptr == 0 || member_ptr == 1) {
self->data->markCompositeDirty();
} else {
self->data->markDirty();
}
return 0;
}
// =========================================================================
// Texture property
// =========================================================================
PyObject* UIGrid::get_texture(PyUIGridObject* self, void* closure) {
auto texture = self->data->getTexture();
if (!texture) {
Py_RETURN_NONE;
}
auto type = &mcrfpydef::PyTextureType;
auto obj = (PyTextureObject*)type->tp_alloc(type, 0);
obj->data = texture;
return (PyObject*)obj;
}
// =========================================================================
// Fill color
// =========================================================================
PyObject* UIGrid::get_fill_color(PyUIGridObject* self, void* closure)
{
auto& color = self->data->fill_color;
auto type = &mcrfpydef::PyColorType;
PyObject* args = Py_BuildValue("(iiii)", color.r, color.g, color.b, color.a);
PyObject* obj = PyObject_CallObject((PyObject*)type, args);
Py_DECREF(args);
return obj;
}
int UIGrid::set_fill_color(PyUIGridObject* self, PyObject* value, void* closure)
{
if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyColorType)) {
PyErr_SetString(PyExc_TypeError, "fill_color must be a Color object");
return -1;
}
PyColorObject* color = (PyColorObject*)value;
self->data->fill_color = color->data;
self->data->markDirty();
return 0;
}
PyObject* UIGrid::get_fov(PyUIGridObject* self, void* closure)
{
if (PyFOV::fov_enum_class) {
PyObject* value = PyLong_FromLong(self->data->fov_algorithm);
if (!value) return NULL;
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;
}
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;
self->data->markDirty();
return 0;
}
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;
self->data->markDirty();
return 0;
}
// =========================================================================
// Collection getters
// =========================================================================
PyObject* UIGrid::get_entities(PyUIGridObject* self, void* closure)
{
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;
}
// #364: get_children moved to UIGridView. GridData holds no drawables, so the
// internal _GridData no longer exposes a children collection at all.
PyObject* UIGrid::get_layers(PyUIGridObject* self, void* closure) {
self->data->sortLayers();
PyObject* tuple = PyTuple_New(self->data->layers.size());
if (!tuple) return NULL;
auto* color_layer_type = &mcrfpydef::PyColorLayerType;
auto* tile_layer_type = &mcrfpydef::PyTileLayerType;
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(tuple);
return NULL;
}
PyTuple_SET_ITEM(tuple, i, py_layer);
}
return tuple;
}
// =========================================================================
// repr
// =========================================================================
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");
}
// #355 - cell callback properties moved to UIGridView (src/UIGridView.cpp)
// =========================================================================
// getsetters[] table
// =========================================================================
typedef PyUIGridObject PyObjectType;
PyGetSetDef UIGrid::getsetters[] = {
{"grid_size", (getter)UIGrid::get_grid_size, NULL,
MCRF_PROPERTY(grid_size, "Grid dimensions as (grid_w, grid_h) (Vector, read-only)."), NULL},
{"grid_w", (getter)UIGrid::get_grid_w, NULL,
MCRF_PROPERTY(grid_w, "Grid width in cells (int, read-only)."), NULL},
{"grid_h", (getter)UIGrid::get_grid_h, NULL,
MCRF_PROPERTY(grid_h, "Grid height in cells (int, read-only)."), NULL},
{"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos,
MCRF_PROPERTY(pos, "Position of the grid as Vector (Vector)."), (void*)PyObjectsEnum::UIGRID},
{"grid_pos", (getter)UIDrawable::get_grid_pos, (setter)UIDrawable::set_grid_pos,
MCRF_PROPERTY(grid_pos, "Position in parent grid's tile coordinates (Vector). Only valid when parent is a Grid."), (void*)PyObjectsEnum::UIGRID},
{"size", (getter)UIGrid::get_size, (setter)UIGrid::set_size,
MCRF_PROPERTY(size, "Size of the grid widget as Vector (Vector, width x height in pixels)."), NULL},
{"center", (getter)UIGrid::get_center, (setter)UIGrid::set_center,
MCRF_PROPERTY(center, "Camera center point in pixel coordinates (Vector). Controls which part of the grid world is visible (pan)."), NULL},
{"entities", (getter)UIGrid::get_entities, NULL,
MCRF_PROPERTY(entities, "EntityCollection of entities on this grid (EntityCollection, read-only)."), NULL},
// #364: `children` is a property of the VIEW (mcrfpy.Grid / GridView), not of the
// data. See UIGridView::getsetters.
{"layers", (getter)UIGrid::get_layers, NULL,
MCRF_PROPERTY(layers, "Tuple of grid layers sorted by z_index (tuple, read-only). Contains ColorLayer and TileLayer objects."), NULL},
{"x", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member,
MCRF_PROPERTY(x, "Top-left corner X-coordinate in pixels (float)."), (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 0)},
{"y", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member,
MCRF_PROPERTY(y, "Top-left corner Y-coordinate in pixels (float)."), (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 1)},
{"w", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member,
MCRF_PROPERTY(w, "Visible widget width in pixels (float)."), (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 2)},
{"h", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member,
MCRF_PROPERTY(h, "Visible widget height in pixels (float)."), (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 3)},
{"center_x", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member,
MCRF_PROPERTY(center_x, "Camera center X-coordinate in pixels (float)."), (void*)4},
{"center_y", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member,
MCRF_PROPERTY(center_y, "Camera center Y-coordinate in pixels (float)."), (void*)5},
{"zoom", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member,
MCRF_PROPERTY(zoom, "Zoom factor for rendering the grid (float). Values > 1 zoom in; values < 1 zoom out."), (void*)6},
{"camera_rotation", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member,
MCRF_PROPERTY(camera_rotation, "Rotation of grid contents around camera center in degrees (float). 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: MouseButton, action: InputState)."
), (void*)PyObjectsEnum::UIGRID},
{"texture", (getter)UIGrid::get_texture, NULL,
MCRF_PROPERTY(texture, "Texture used for tile rendering (Texture | None, read-only)."), NULL},
{"fill_color", (getter)UIGrid::get_fill_color, (setter)UIGrid::set_fill_color,
MCRF_PROPERTY(fill_color,
"Background fill color of the grid (Color). "
"Returns a copy; modifying components requires reassignment. "
"For animation, use 'fill_color.r', 'fill_color.g', etc."
), NULL},
{"fov", (getter)UIGrid::get_fov, (setter)UIGrid::set_fov,
MCRF_PROPERTY(fov,
"FOV algorithm for this grid (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,
MCRF_PROPERTY(fov_radius, "Default FOV radius for this grid (int). Used when radius is 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,
MCRF_PROPERTY(name, "Name for finding elements (str)."), (void*)PyObjectsEnum::UIGRID},
UIDRAWABLE_GETSETTERS,
UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIGRID),
UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIGRID),
UIDRAWABLE_ROTATION_GETSETTERS(PyObjectsEnum::UIGRID),
UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UIGRID),
{NULL}
};

View file

@ -1,6 +1,6 @@
// UIGridView.cpp - Rendering view for GridData (#252)
#include "UIGridView.h"
#include "UIGrid.h"
#include "PyGridData.h"
#include "UIGridPoint.h"
#include "UIEntity.h"
#include "GameEngine.h"
@ -91,7 +91,7 @@ void UIGridView::resize(float w, float h)
box.setSize(sf::Vector2f(w, h));
// #364: aligned children re-anchor against the widget's new bounds. This ran in
// UIGrid::resize before children moved to the view; it belongs here now.
// PyGridData::resize before children moved to the view; it belongs here now.
if (children) {
for (auto& child : *children) {
if (child->getAlignment() != AlignmentType::NONE) {
@ -186,7 +186,7 @@ void UIGridView::center_camera(float tile_x, float tile_y)
}
// =========================================================================
// Render -- adapted from UIGrid::render()
// Render -- adapted from PyGridData::render()
// =========================================================================
void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
{
@ -572,192 +572,180 @@ bool UIGridView::hasProperty(const std::string& name) const
int UIGridView::init(PyUIGridViewObject* self, PyObject* args, PyObject* kwds)
{
// Extract parent= up front so it doesn't confuse downstream parsing in
// either init mode. parent_obj is borrowed from the original kwds (which
// the caller owns and outlives this function), so no INCREF is needed --
// but we must not delete from the caller's dict, so make a working copy.
// #361: ONE parse for both construction modes.
//
// Grid(grid_size=(80,40), pos=..., size=...) -- creates its own GridData
// Grid(grid=data, pos=..., size=...) -- a camera onto existing data
//
// This used to be two functions, and the factory mode built a throwaway
// Python _GridData object just to steal its GridData base subobject via an
// aliasing shared_ptr, then copied a dozen fields of rendering state out of
// the discarded wrapper. The view now constructs the GridData directly. As a
// side effect the view-mode kwarg set is no longer a crippled subset: z_index,
// visible, opacity, on_click and align work on a second view too, which they
// silently did not before (they raised TypeError).
PyObject* pos_obj = nullptr;
PyObject* size_obj = nullptr;
PyObject* grid_size_obj = nullptr;
PyObject* texture_obj = nullptr;
PyObject* grid_obj = nullptr;
PyObject* fill_color_obj = nullptr;
PyObject* click_handler = nullptr;
PyObject* layers_obj = nullptr;
PyObject* parent_obj = nullptr;
PyObject* dispatch_kwds = kwds;
if (kwds) {
parent_obj = PyDict_GetItemString(kwds, "parent"); // borrowed ref
if (parent_obj) {
dispatch_kwds = PyDict_Copy(kwds);
if (!dispatch_kwds) return -1;
PyDict_DelItemString(dispatch_kwds, "parent");
}
PyObject* align_obj = nullptr;
// #169 - NaN sentinel: distinguishes "user passed a center" from "default it".
float center_x = std::numeric_limits<float>::quiet_NaN();
float center_y = std::numeric_limits<float>::quiet_NaN();
float zoom = 1.0f;
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;
float margin = 0.0f, horiz_margin = -1.0f, vert_margin = -1.0f;
static const char* kwlist[] = {
"pos", "size", "grid_size", "texture", // positional
"grid", "fill_color", "on_click", "center_x", "center_y", "zoom",
"visible", "opacity", "z_index", "name", "x", "y", "w", "h",
"grid_w", "grid_h", "layers", "parent",
"align", "margin", "horiz_margin", "vert_margin",
nullptr
};
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOOfffifizffffiiOOOfff",
const_cast<char**>(kwlist),
&pos_obj, &size_obj, &grid_size_obj, &texture_obj,
&grid_obj, &fill_color_obj, &click_handler,
&center_x, &center_y, &zoom,
&visible, &opacity, &z_index, &name,
&x, &y, &w, &h, &grid_w, &grid_h,
&layers_obj, &parent_obj,
&align_obj, &margin, &horiz_margin, &vert_margin)) {
return -1;
}
// Determine mode by checking for 'grid' kwarg
PyObject* grid_kwarg = nullptr;
if (dispatch_kwds) {
grid_kwarg = PyDict_GetItemString(dispatch_kwds, "grid"); // borrowed ref
}
const bool attaching = (grid_obj && grid_obj != Py_None);
bool explicit_view = (grid_kwarg && grid_kwarg != Py_None);
int rc = explicit_view
? init_explicit_view(self, args, dispatch_kwds)
: init_with_data(self, args, dispatch_kwds);
if (dispatch_kwds != kwds) Py_DECREF(dispatch_kwds);
if (rc != 0) return rc;
if (parent_obj) {
UIDRAWABLE_ATTACH_TO_PARENT(parent_obj, self);
}
return 0;
}
int UIGridView::init_explicit_view(PyUIGridViewObject* self, PyObject* args, PyObject* kwds)
{
{
// Mode 1: View of existing grid data
static const char* kwlist[] = {"grid", "pos", "size", "zoom", "fill_color", "name", nullptr};
PyObject* grid_obj = nullptr;
PyObject* pos_obj = nullptr;
PyObject* size_obj = nullptr;
float zoom_val = 1.0f;
PyObject* fill_obj = nullptr;
const char* name_str = nullptr;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOfOz", const_cast<char**>(kwlist),
&grid_obj, &pos_obj, &size_obj, &zoom_val, &fill_obj, &name_str)) {
// --------------------------------------------------------------------
// The map: attach to an existing one, or build a new one.
// --------------------------------------------------------------------
if (attaching) {
// A camera onto someone else's map cannot also specify that map's shape.
// Silently ignoring these would quietly hand back a view of a DIFFERENT
// size than asked for.
if (grid_size_obj || texture_obj || layers_obj) {
PyErr_SetString(PyExc_TypeError,
"grid= names an existing GridData; grid_size/texture/layers describe a "
"new one. Pass one or the other, not both.");
return -1;
}
self->data->zoom = zoom_val;
if (name_str) self->data->UIDrawable::name = std::string(name_str);
if (PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyGridDataType)) {
self->data->grid_data = ((PyGridDataObject*)grid_obj)->data;
} else if (PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
// Another Grid/GridView: share the map it is looking at.
self->data->grid_data = ((PyUIGridViewObject*)grid_obj)->data->grid_data;
} else {
PyErr_SetString(PyExc_TypeError, "grid must be a GridData or a Grid");
return -1;
}
if (!self->data->grid_data) {
PyErr_SetString(PyExc_ValueError, "grid has no data to view");
return -1;
}
self->data->ptex = self->data->grid_data->getTexture();
} else {
std::shared_ptr<PyTexture> texture;
if (PyGridData::parse_grid_shape(grid_size_obj, texture_obj,
grid_w, grid_h, texture) < 0) {
return -1;
}
self->data->grid_data = std::make_shared<GridData>(grid_w, grid_h, texture);
if (PyGridData::apply_layers_arg(self->data->grid_data, layers_obj, texture) < 0) {
return -1;
}
self->data->ptex = texture;
}
// Accept both internal _GridData and GridView (unified Grid) as grid source
if (grid_obj && grid_obj != Py_None) {
if (PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridType)) {
// Internal _GridData object
PyUIGridObject* pygrid = (PyUIGridObject*)grid_obj;
self->data->grid_data = std::shared_ptr<GridData>(
pygrid->data, static_cast<GridData*>(pygrid->data.get()));
self->data->ptex = pygrid->data->getTexture();
} else if (PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
// Another GridView (unified Grid) - share its grid_data
PyUIGridViewObject* pyview = (PyUIGridViewObject*)grid_obj;
if (pyview->data->grid_data) {
self->data->grid_data = pyview->data->grid_data;
self->data->ptex = pyview->data->ptex;
}
} else {
PyErr_SetString(PyExc_TypeError, "grid must be a Grid object");
auto& grid_data = self->data->grid_data;
// --------------------------------------------------------------------
// The camera: this view's own rendering state.
// --------------------------------------------------------------------
if (pos_obj && pos_obj != Py_None) {
PyVectorObject* vec = PyVector::from_arg(pos_obj);
if (vec) {
x = vec->data.x;
y = vec->data.y;
Py_DECREF(vec);
} else {
PyErr_Clear();
sf::Vector2f p = PyObject_to_sfVector2f(pos_obj);
if (PyErr_Occurred()) {
PyErr_SetString(PyExc_TypeError, "pos must be a tuple (x, y) or Vector");
return -1;
}
}
if (pos_obj && pos_obj != Py_None) {
sf::Vector2f pos = PyObject_to_sfVector2f(pos_obj);
if (PyErr_Occurred()) return -1;
self->data->position = pos;
self->data->box.setPosition(pos);
}
if (size_obj && size_obj != Py_None) {
sf::Vector2f size = PyObject_to_sfVector2f(size_obj);
if (PyErr_Occurred()) return -1;
self->data->box.setSize(size);
}
if (fill_obj && fill_obj != Py_None) {
self->data->fill_color = PyColor::fromPy(fill_obj);
if (PyErr_Occurred()) return -1;
}
if (self->data->grid_data) {
self->data->center_camera();
self->data->ensureRenderTextureSize();
// #359 - Register this view so it receives markDirty/markCompositeDirty
// notifications (its own ancestor chain -- e.g. a Frame(use_render_texture=
// True) wrapping this view -- needs invalidating too, not just the
// creating view's). Does NOT disturb identity: primaryView() (used by
// UIEntity::get_grid) is views.front(), which is always the view that
// created this GridData via init_with_data, since init_explicit_view can
// only attach to grid_data that already exists.
self->data->grid_data->registerView(self->data);
}
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);
}
}
self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != (PyObject*)&mcrfpydef::PyUIGridViewType;
return 0;
}
}
int UIGridView::init_with_data(PyUIGridViewObject* self, PyObject* args, PyObject* kwds)
{
// Create a temporary UIGrid using the user's kwargs.
// This reuses UIGrid::init's complex parsing for grid_size, texture, layers, etc.
// Remove 'grid' key from kwds if present (e.g. grid=None)
PyObject* filtered_kwds = kwds ? PyDict_Copy(kwds) : PyDict_New();
if (!filtered_kwds) return -1;
if (PyDict_DelItemString(filtered_kwds, "grid") < 0) {
PyErr_Clear(); // OK if "grid" wasn't in dict
}
// Create UIGrid via Python type system, forwarding positional args
PyObject* grid_type = (PyObject*)&mcrfpydef::PyUIGridType;
PyObject* grid_py = PyObject_Call(grid_type, args, filtered_kwds);
Py_DECREF(filtered_kwds);
if (!grid_py) return -1;
PyUIGridObject* pygrid = (PyUIGridObject*)grid_py;
// Take UIGrid data via aliasing shared_ptr
self->data->grid_data = std::shared_ptr<GridData>(
pygrid->data, static_cast<GridData*>(pygrid->data.get()));
self->data->ptex = pygrid->data->getTexture();
// Copy rendering state from UIGrid to GridView
self->data->position = pygrid->data->position;
self->data->box.setPosition(pygrid->data->box.getPosition());
self->data->box.setSize(pygrid->data->box.getSize());
self->data->zoom = pygrid->data->zoom;
self->data->center_x = pygrid->data->center_x;
self->data->center_y = pygrid->data->center_y;
self->data->fill_color = sf::Color(pygrid->data->box.getFillColor());
self->data->visible = pygrid->data->visible;
self->data->opacity = pygrid->data->opacity;
self->data->z_index = pygrid->data->z_index;
self->data->name = pygrid->data->name;
self->data->camera_rotation = pygrid->data->camera_rotation;
// Copy alignment
self->data->align_type = pygrid->data->align_type;
self->data->align_margin = pygrid->data->align_margin;
self->data->align_horiz_margin = pygrid->data->align_horiz_margin;
self->data->align_vert_margin = pygrid->data->align_vert_margin;
// Copy click callback if set
if (pygrid->data->click_callable) {
PyObject* cb = pygrid->data->click_callable->borrow();
if (cb && cb != Py_None) {
self->data->click_register(cb);
x = p.x;
y = p.y;
}
}
// #359 - Register this view on the GridData (dirty-notification + identity)
self->data->grid_data->registerView(self->data);
if (size_obj && size_obj != Py_None) {
sf::Vector2f s = PyObject_to_sfVector2f(size_obj);
if (PyErr_Occurred()) {
PyErr_SetString(PyExc_TypeError, "size must be a tuple (w, h)");
return -1;
}
w = s.x;
h = s.y;
} else if (w == 0.0f && h == 0.0f) {
// Default: show the whole map at 1:1.
w = grid_data->grid_w * static_cast<float>(grid_data->cell_width());
h = grid_data->grid_h * static_cast<float>(grid_data->cell_height());
}
self->data->zoom = zoom;
self->data->position = sf::Vector2f(x, y);
self->data->box.setPosition(self->data->position);
self->data->box.setSize(sf::Vector2f(w, h));
self->data->visible = visible;
self->data->opacity = opacity;
self->data->z_index = z_index;
if (name) self->data->UIDrawable::name = std::string(name);
// #169 - default camera puts tile (0,0) at the widget's top-left.
if (std::isnan(center_x)) 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;
if (fill_color_obj && fill_color_obj != Py_None) {
self->data->fill_color = PyColor::fromPy(fill_color_obj);
if (PyErr_Occurred()) return -1;
}
if (click_handler && click_handler != Py_None) {
if (!PyCallable_Check(click_handler)) {
PyErr_SetString(PyExc_TypeError, "on_click must be callable");
return -1;
}
self->data->click_register(click_handler);
}
UIDRAWABLE_PROCESS_ALIGNMENT(self->data, align_obj, margin, horiz_margin, vert_margin);
self->data->ensureRenderTextureSize();
Py_DECREF(grid_py);
// #359 - Register with the map so data-layer mutations invalidate THIS view's
// cache and push dirt up ITS ancestor chain. Registration order matters: the
// creating view is views.front().
grid_data->registerView(self->data);
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);
@ -766,7 +754,12 @@ int UIGridView::init_with_data(PyUIGridViewObject* self, PyObject* args, PyObjec
Py_DECREF(weakref);
}
}
self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != (PyObject*)&mcrfpydef::PyUIGridViewType;
self->data->is_python_subclass =
(PyObject*)Py_TYPE(self) != (PyObject*)&mcrfpydef::PyUIGridViewType;
if (parent_obj && parent_obj != Py_None) {
UIDRAWABLE_ATTACH_TO_PARENT(parent_obj, self);
}
return 0;
}
@ -774,7 +767,7 @@ int UIGridView::init_with_data(PyUIGridViewObject* self, PyObject* args, PyObjec
PyObject* UIGridView::repr(PyUIGridViewObject* self)
{
std::ostringstream ss;
ss << "<Grid";
ss << "<GridView";
if (self->data->grid_data) {
ss << " (" << self->data->grid_data->grid_w << "x" << self->data->grid_data->grid_h << ")";
} else {
@ -872,47 +865,19 @@ PyObject* UIGridView::get_grid(PyUIGridViewObject* self, void* closure)
return self->data->cached_grid_wrapper;
}
// grid_data is an aliasing shared_ptr into a UIGrid (GridData is a base of UIGrid).
// Reconstruct shared_ptr<UIGrid> to return the proper Python wrapper.
auto grid_ptr = static_cast<UIGrid*>(self->data->grid_data.get());
auto grid_as_uigrid = std::shared_ptr<UIGrid>(
self->data->grid_data, grid_ptr);
// #361: build (or find) the map's one Python wrapper, then adopt it as this
// view's persistent cache so subsequent accesses hit the fast path above.
// The aliasing shared_ptr this used to reconstruct -- to recover the "full
// UIGrid" that every GridData was secretly a base subobject of -- is gone
// along with UIGrid itself.
PyObject* pyGrid = PyGridData::pyobject_for(self->data->grid_data);
if (!pyGrid || pyGrid == Py_None) return pyGrid;
// A wrapper may already exist (user holds grid.grid_data, another view cached
// it): honor the serial-number cache, then adopt it as this view's persistent
// wrapper so subsequent accesses hit the fast path above.
if (grid_ptr->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(grid_ptr->serial_number);
if (cached) {
// lookup() returns a new strong ref; keep it as the view's cache and
// hand a second ref to the caller.
self->data->cached_grid_wrapper = cached;
Py_INCREF(cached);
return cached;
}
}
auto grid_type = &mcrfpydef::PyUIGridType;
auto pyGrid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
if (!pyGrid) return PyErr_NoMemory();
pyGrid->data = grid_as_uigrid;
pyGrid->weakreflist = NULL;
if (grid_ptr->serial_number == 0) {
grid_ptr->serial_number = PythonObjectCache::getInstance().assignSerial();
}
PyObject* weakref = PyWeakref_NewRef((PyObject*)pyGrid, NULL);
if (weakref) {
PythonObjectCache::getInstance().registerObject(grid_ptr->serial_number, weakref);
Py_DECREF(weakref);
}
// Hold one strong ref for the view's lifetime (#348 cache); return a second
// ref to the caller.
self->data->cached_grid_wrapper = (PyObject*)pyGrid;
Py_INCREF((PyObject*)pyGrid);
return (PyObject*)pyGrid;
// pyobject_for returns a strong ref; keep it as the view's cache and hand a
// second ref to the caller.
self->data->cached_grid_wrapper = pyGrid;
Py_INCREF(pyGrid);
return pyGrid;
}
int UIGridView::set_grid(PyUIGridViewObject* self, PyObject* value, void* closure)
@ -930,16 +895,16 @@ int UIGridView::set_grid(PyUIGridViewObject* self, PyObject* value, void* closur
self->data->grid_data = nullptr;
return 0;
}
// Accept internal _GridData (UIGrid) objects
if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) {
PyUIGridObject* pygrid = (PyUIGridObject*)value;
self->data->grid_data = std::shared_ptr<GridData>(
pygrid->data, static_cast<GridData*>(pygrid->data.get()));
// A GridData: point this camera at that map. (#361: no aliasing cast -- the
// wrapper holds the GridData itself, not a UIGrid it was a base subobject of.)
if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyGridDataType)) {
PyGridDataObject* pygrid = (PyGridDataObject*)value;
self->data->grid_data = pygrid->data;
self->data->ptex = pygrid->data->getTexture();
self->data->grid_data->registerView(self->data);
return 0;
}
// Accept GridView (unified Grid) objects - share their grid_data
// A Grid/GridView: share the map it is looking at.
if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
PyUIGridViewObject* pyview = (PyUIGridViewObject*)value;
if (pyview->data->grid_data) {
@ -954,7 +919,7 @@ int UIGridView::set_grid(PyUIGridViewObject* self, PyObject* value, void* closur
self->data->grid_data = nullptr;
return 0;
}
PyErr_SetString(PyExc_TypeError, "grid must be a Grid object or None");
PyErr_SetString(PyExc_TypeError, "grid_data must be a GridData, a Grid, or None");
return -1;
}
@ -982,9 +947,77 @@ int UIGridView::set_zoom(PyUIGridViewObject* self, PyObject* value, void* closur
double val = PyFloat_AsDouble(value);
if (val == -1.0 && PyErr_Occurred()) return -1;
self->data->zoom = static_cast<float>(val);
self->data->markDirty();
return 0;
}
// #361 - `size` and `center_camera()` were NEVER on the view. They resolved, via
// getattro delegation, to the internal UIGrid's copies -- the ghost camera nobody
// renders -- so `grid.size = (w, h)` and `grid.center_camera(...)` were SILENT
// NO-OPS on the widget the user was actually looking at. Deleting the ghost is
// what surfaced them.
PyObject* UIGridView::get_size(PyUIGridViewObject* self, void* closure)
{
return PyVector(self->data->box.getSize()).pyObject();
}
int UIGridView::set_size(PyUIGridViewObject* self, PyObject* value, void* closure)
{
float w, h;
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->resize(w, h);
return 0;
}
PyObject* UIGridView::py_center_camera(PyUIGridViewObject* self, PyObject* args)
{
PyObject* pos_arg = nullptr;
if (!PyArg_ParseTuple(args, "|O", &pos_arg)) return nullptr;
if (pos_arg == nullptr || pos_arg == Py_None) {
self->data->center_camera();
Py_RETURN_NONE;
}
float tile_x, tile_y;
PyVectorObject* vec = PyVector::from_arg(pos_arg);
if (vec) {
tile_x = vec->data.x;
tile_y = vec->data.y;
Py_DECREF(vec);
} else {
PyErr_Clear();
if (!PyTuple_Check(pos_arg) || PyTuple_Size(pos_arg) != 2) {
PyErr_SetString(PyExc_TypeError,
"center_camera() takes an optional (tile_x, tile_y) tuple or Vector");
return nullptr;
}
PyObject* x_obj = PyTuple_GetItem(pos_arg, 0);
PyObject* y_obj = PyTuple_GetItem(pos_arg, 1);
if (!(PyFloat_Check(x_obj) || PyLong_Check(x_obj)) ||
!(PyFloat_Check(y_obj) || PyLong_Check(y_obj))) {
PyErr_SetString(PyExc_TypeError, "tile coordinates must be numeric");
return nullptr;
}
tile_x = PyFloat_Check(x_obj) ? PyFloat_AsDouble(x_obj) : (float)PyLong_AsLong(x_obj);
tile_y = PyFloat_Check(y_obj) ? PyFloat_AsDouble(y_obj) : (float)PyLong_AsLong(y_obj);
}
self->data->center_camera(tile_x, tile_y);
Py_RETURN_NONE;
}
PyObject* UIGridView::get_fill_color(PyUIGridViewObject* self, void* closure)
{
auto type = &mcrfpydef::PyColorType;
@ -1004,7 +1037,7 @@ int UIGridView::set_fill_color(PyUIGridViewObject* self, PyObject* value, void*
// Perspective (#355 fix): OWNED BY THE VIEW. The FOV overlay is drawn by
// UIGridView::render, so the state it gates on must live here -- previously
// `grid.perspective` fell through getattro to the internal _GridData and wrote
// UIGrid::perspective_enabled, which render() never read: the overlay was dead.
// PyGridData::perspective_enabled, which render() never read: the overlay was dead.
// Per-view ownership is also the correct semantics for shared GridData (#359):
// two views of one map can follow two different entities' FOV.
// =========================================================================
@ -1065,7 +1098,7 @@ int UIGridView::set_perspective_enabled(PyUIGridViewObject* self, PyObject* valu
PyObject* UIGridView::get_texture(PyUIGridViewObject* self, void* closure)
{
// #318: return a Texture wrapper sharing the underlying shared_ptr<PyTexture>,
// mirroring UIGrid::get_texture. None only when the view has no texture.
// mirroring PyGridData::get_texture. None only when the view has no texture.
auto& texture = self->data->ptex;
if (!texture) Py_RETURN_NONE;
@ -1495,15 +1528,10 @@ PyObject* UIGridView::subscript(PyUIGridViewObject* self, PyObject* key)
return NULL;
}
// Reconstruct shared_ptr<UIGrid> from GridData via aliasing constructor
// (mirrors UIGridView::get_grid).
auto grid_ptr = static_cast<UIGrid*>(grid_data.get());
auto grid_as_uigrid = std::shared_ptr<UIGrid>(grid_data, grid_ptr);
auto type = &mcrfpydef::PyUIGridPointType;
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
if (!obj) return NULL;
obj->grid = grid_as_uigrid;
obj->grid = grid_data; // #361: the GridPoint holds the map directly
obj->x = x;
obj->y = y;
return (PyObject*)obj;
@ -1529,6 +1557,17 @@ typedef PyUIGridViewObject PyObjectType;
// Methods and getsetters arrays
PyMethodDef UIGridView_all_methods[] = {
UIDRAWABLE_METHODS,
{"center_camera", (PyCFunction)UIGridView::py_center_camera, METH_VARARGS,
MCRF_METHOD(GridView, center_camera,
MCRF_SIG("(pos: tuple = None)", "None"),
MCRF_DESC("Point this view's camera at a tile, or at the middle of the map when "
"called with no argument.")
MCRF_ARGS_START
MCRF_ARG("pos", "(tile_x, tile_y) to center on. Offset by 0.5 to aim at a tile's "
"middle rather than its top-left corner.")
MCRF_NOTE("The camera belongs to the VIEW, not the map: two views of one GridData "
"pan independently.")
)},
{NULL}
};
@ -1561,6 +1600,9 @@ PyGetSetDef UIGridView::getsetters[] = {
), NULL},
{"center", (getter)UIGridView::get_center, (setter)UIGridView::set_center,
MCRF_PROPERTY(center, "Camera center point in pixel coordinates (Vector)."), NULL},
{"size", (getter)UIGridView::get_size, (setter)UIGridView::set_size,
MCRF_PROPERTY(size, "Size of the grid widget in pixels as (w, h) (Vector). This is the "
"viewport, not the map: the map's dimensions are grid_size."), NULL},
{"zoom", (getter)UIGridView::get_zoom, (setter)UIGridView::set_zoom,
MCRF_PROPERTY(zoom, "Zoom level for rendering (float). Values greater than 1.0 magnify; less than 1.0 shrink."), NULL},
{"fill_color", (getter)UIGridView::get_fill_color, (setter)UIGridView::set_fill_color,

View file

@ -160,8 +160,8 @@ public:
// Python API
// =========================================================================
static int init(PyUIGridViewObject* self, PyObject* args, PyObject* kwds);
static int init_explicit_view(PyUIGridViewObject* self, PyObject* args, PyObject* kwds);
static int init_with_data(PyUIGridViewObject* self, PyObject* args, PyObject* kwds);
// #361: one init() for both modes -- Grid(grid_size=...) creates its own
// GridData, Grid(grid=data) attaches to an existing one.
static PyObject* repr(PyUIGridViewObject* self);
// #252 - Attribute delegation to underlying Grid
@ -177,6 +177,11 @@ public:
static int set_center(PyUIGridViewObject* self, PyObject* value, void* closure);
static PyObject* get_zoom(PyUIGridViewObject* self, void* closure);
static int set_zoom(PyUIGridViewObject* self, PyObject* value, void* closure);
// #361 - `size` and `center_camera()` used to delegate to the internal UIGrid's
// ghost camera, making them silent no-ops on the widget being rendered.
static PyObject* get_size(PyUIGridViewObject* self, void* closure);
static int set_size(PyUIGridViewObject* self, PyObject* value, void* closure);
static PyObject* py_center_camera(PyUIGridViewObject* self, PyObject* args);
static PyObject* get_fill_color(PyUIGridViewObject* self, void* closure);
static int set_fill_color(PyUIGridViewObject* self, PyObject* value, void* closure);
static PyObject* get_texture(PyUIGridViewObject* self, void* closure);
@ -220,7 +225,9 @@ namespace mcrfpydef {
// Attribute access delegates to underlying Grid for data properties/methods.
inline PyTypeObject PyUIGridViewType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.Grid", // #252: primary name is Grid
// #361: the canonical name is GridView -- it IS the camera/widget. "Grid"
// is bound to this same type object as an alias (see McRFPy_API.cpp).
.tp_name = "mcrfpy.GridView",
.tp_basicsize = sizeof(PyUIGridViewObject),
.tp_itemsize = 0,
.tp_dealloc = (destructor)[](PyObject* self)
@ -232,7 +239,7 @@ namespace mcrfpydef {
}
// #359: Unregister from the GridData's view list before releasing
// grid_data -- but only when this wrapper is the LAST owner of the
// view (#251 pattern, mirrors PyUIGridType). An ungated unregister
// view (#251 pattern, mirrors PyGridDataType). An ungated unregister
// would sever the back-reference whenever ANY Python wrapper was
// GC'd while the C++ view lived on (e.g. held by scene.children),
// breaking entity.grid -> Grid identity and the #313 data-layer

View file

@ -8,7 +8,7 @@
#include "PyAlignment.h"
#include "UIFrame.h" // parent= kwarg: Frame parent type
#include "UICaption.h" // parent= kwarg: needed for ATTACH macro instantiation
#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type
#include "PyGridData.h" // parent= kwarg: Grid/GridView parent type
#include "PySceneObject.h" // parent= kwarg: Scene parent type
#include <cmath>

View file

@ -4,7 +4,7 @@
#include "PythonObjectCache.h"
#include "UIFrame.h" // #144: For snapshot= parameter
#include "UICaption.h" // parent= kwarg: needed for ATTACH macro instantiation
#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type
#include "PyGridData.h" // parent= kwarg: Grid/GridView parent type
#include "PySceneObject.h" // parent= kwarg: Scene parent type
#include "PyAlignment.h"
#include "PyShader.h" // #106: Shader support

View file

@ -104,10 +104,19 @@ UITestScene::UITestScene(GameEngine* g) : Scene(g)
std::cout << "pointer to ui_elements now shows size=" << ui->size() << std::endl;
*/
// UIGrid test: (in grid cells) ( in screen pixels )
// constructor args: w h texture x y w h
auto e5 = std::make_shared<UIGrid>(4, 4, ptex, sf::Vector2f(550, 150), sf::Vector2f(200, 200));
e5->zoom=2.0;
// Grid test. #361: the map (GridData) and the camera onto it (UIGridView) are
// separate objects now -- only the view goes in the scene.
auto e5 = std::make_shared<GridData>(4, 4, ptex);
auto e5_view = std::make_shared<UIGridView>();
e5_view->grid_data = e5;
e5_view->ptex = ptex;
e5_view->position = sf::Vector2f(550, 150);
e5_view->box.setPosition(e5_view->position);
e5_view->box.setSize(sf::Vector2f(200, 200));
e5_view->zoom = 2.0;
e5_view->center_camera();
e5_view->ensureRenderTextureSize();
e5->registerView(e5_view);
// #150 - GridPoint no longer has color/tilesprite properties
// Use layers for visual rendering; GridPoint only has walkable/transparent
@ -116,7 +125,7 @@ UITestScene::UITestScene(GameEngine* g) : Scene(g)
e5->setWalkable(0, 0, true); // #332
e5->setTransparent(0, 0, true);
ui_elements->push_back(e5);
ui_elements->push_back(e5_view);
//UIEntity test:
// asdf

View file

@ -7,8 +7,9 @@
# (c) entity pixel position (x/y/pos) is derived from the GRID's cell size,
# NOT the entity's own texture -- setting a different-sized texture on the
# entity must not move it
# (d) entity.grid returns the same Grid (GridView) object across step/FOV cycles
# (PythonObjectCache identity)
# (d) entity.grid returns the same GridData object across step/FOV cycles
# (PythonObjectCache identity). #361 changed WHAT it returns -- the map, not
# a view -- but the identity guarantee this test exists for is unchanged.
# (e) find_path() and at() still work, and constructing their temporary internal
# grid wrappers does not destroy the grid's cell callbacks (#251 guard)
# (f) a GridPoint obtained from entity.at() outlives the entity and the grid
@ -79,10 +80,15 @@ def main():
"(c) pixel pos unchanged after setting a different-sized entity texture")
# --- (d) entity.grid identity across a step/FOV cycle ---
check(entity.grid is grid, "(d) entity.grid is the user-created Grid object")
# #361: entity.grid is the GridData (the map), not the Grid (a camera onto it).
# An entity can be on a map with no view at all, or with several; "the grid an
# entity is in" is not a camera. It is still one stable object, which is what
# the PythonObjectCache identity guarantee here is about.
check(entity.grid is grid.grid_data, "(d) entity.grid is the map this Grid views")
check(isinstance(entity.grid, mcrfpy.GridData), "(d) entity.grid is a GridData")
grid.compute_fov((2, 2), radius=5)
grid.step(1)
check(entity.grid is grid, "(d) entity.grid identity survives step + compute_fov")
check(entity.grid is grid.grid_data, "(d) entity.grid identity survives step + compute_fov")
# --- (e) find_path / at() work; cell callbacks survive temp grid wrappers ---
grid.on_cell_click = lambda pos, btn, action: None
@ -131,18 +137,22 @@ def main():
except RuntimeError:
check(True, "(g) uninitialized Entity texture set raises RuntimeError")
# --- (h) owning_view survives Python wrapper GC while the C++ view lives ---
# The canonical idiom: append the Grid to a scene, drop the Python
# reference. The data-layer dirty notifications and entity.grid -> Grid
# identity both depend on owning_view staying intact.
# --- (h) entity.grid survives GC of the view's Python wrapper ---
# The canonical idiom: append the Grid to a scene, drop the Python reference.
# The C++ view lives on in the scene graph; the entity's map must stay valid
# and identical. (#361: entity.grid is the GridData, so this no longer depends
# on a view existing at all -- but the wrapper-GC hazard is the same one.)
scene = mcrfpy.Scene("issue313_ov")
g2 = mcrfpy.Grid(grid_size=(4, 4), texture=tex16, pos=(0, 0), size=(64, 64))
e2 = mcrfpy.Entity(grid_pos=(1, 1), texture=tex16, grid=g2)
data2 = g2.grid_data
scene.children.append(g2)
del g2
gc.collect()
check(type(e2.grid).__name__ == "Grid",
"(h) entity.grid still returns Grid after wrapper GC (owning_view intact)")
check(e2.grid is data2,
"(h) entity.grid is the same GridData after the view wrapper is GC'd")
check(e2.grid.at(1, 1) is not None,
"(h) that GridData is still usable after the view wrapper is GC'd")
# --- (f) GridPoint outlives entity + grid references ---
entity.die()

View file

@ -30,12 +30,13 @@ cameras over one dataset):
dead property is gone, not silently still None.
Design note carried over from the fix: GridData::views is a *list*, not a
single pointer, specifically because N views can share one GridData. Index 0
is guaranteed (by construction: a fresh GridData is only ever produced by
Grid()'s factory path, `init_with_data`; `init_explicit_view` can only ATTACH
to an already-existing GridData) to be the view that created the data, so
UIEntity.grid identity (test 2) stays deterministic even with secondary
views registered -- not an arbitrary pick from an unordered set.
single pointer, specifically because N views can share one GridData.
#361 update: entity.grid no longer returns a view at all -- it returns the
GridData. The "which view is primary?" question that views.front() answered for
identity purposes is gone, because the answer was always arbitrary dressed up as
deterministic. The registry itself remains, and is still load-bearing for
dirty-notification fan-out (test 3).
"""
import sys
import os
@ -95,18 +96,20 @@ def test_1_two_views_render_shared_mutations():
def test_2_entity_grid_identity():
"""entity.grid returns the SAME object as the Grid() that actually
created the data (GridData::views.front()), not the secondary view, not
None, and not a fresh throwaway wrapper -- even once a secondary view is
registered on the same GridData."""
"""entity.grid is the shared GridData -- one stable object, the same one both
views are looking at, and NOT either view (#361)."""
print("(2) entity.grid identity with a secondary view registered...")
grid_a = mcrfpy.Grid(grid_size=(5, 5))
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(80, 80)) # noqa: F841 (keep alive)
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(80, 80))
e = mcrfpy.Entity((1, 1), grid=grid_a)
assert e.grid is grid_a, f"entity.grid identity broken: {e.grid!r} is not grid_a ({grid_a!r})"
assert e.grid is not grid_b, "entity.grid returned the secondary view instead of the creator"
data = grid_a.grid_data
assert grid_b.grid_data is data, "the two views do not share one GridData"
assert e.grid is data, f"entity.grid identity broken: {e.grid!r} is not {data!r}"
assert e.grid is e.grid, "entity.grid is not stable across reads"
assert e.grid is not grid_a and e.grid is not grid_b, \
"entity.grid returned a view; it must return the map (#361)"
print(" OK")

View file

@ -0,0 +1,228 @@
"""Regression test for #361 - GridData is a map, not a widget.
Before this change `UIGrid` inherited BOTH UIDrawable and GridData, so every map
secretly carried a second camera and RenderTexture that nothing on screen
corresponded to. Three consequences, all tested here:
1. `scene.children.append(some_grid_data)` DREW THE MAP A SECOND TIME, through
that ghost camera, frozen at construction values. It must now raise TypeError.
2. Anything that wrote to the ghost camera was a silent no-op on the widget the
user was actually looking at. `Grid.center_camera(...)` and `Grid.size = ...`
both delegated to it and did nothing (case 4) -- that is not a hypothetical:
it was the behavior on master until this fix.
3. A GridData could never be independently heap-allocated (`GridData::markDirty`
did `static_cast<UIGrid*>(this)`), so a map with no view was inexpressible.
Now `mcrfpy.GridData(...)` is public and standalone: an offscreen level that
is still steppable, queryable and pathable (case 2).
Also covers the accepted API break: `entity.grid` returns the GridData (the map),
not a view. An entity may be on a map with no view at all, or with several -- "the
grid an entity is in" simply is not a camera.
"""
import mcrfpy
from mcrfpy import automation
import sys, os, tempfile
TMPDIR = tempfile.mkdtemp(prefix="mcrf361_")
_counter = 0
_results = []
def check(label, ok):
_results.append((label, bool(ok)))
print(" [%s] %s" % ("PASS" if ok else "FAIL", label))
def shot():
"""Render a frame to PNG and return its bytes. Identical pixels -> identical
bytes; headless screenshots are synchronous (#153)."""
global _counter
path = os.path.join(TMPDIR, "s%d.png" % _counter)
_counter += 1
automation.screenshot(path)
with open(path, "rb") as f:
return f.read()
def case_griddata_is_not_drawable():
"""A map has no position, no size, no camera, and cannot enter a scene graph."""
data = mcrfpy.GridData(grid_size=(10, 10))
scene = mcrfpy.Scene("t361_reject")
try:
scene.children.append(data)
check("1a: appending a GridData to a scene raises TypeError", False)
except TypeError:
check("1a: appending a GridData to a scene raises TypeError", True)
frame = mcrfpy.Frame(pos=(0, 0), size=(10, 10))
try:
frame.children.append(data)
check("1b: appending a GridData to a Frame raises TypeError", False)
except TypeError:
check("1b: appending a GridData to a Frame raises TypeError", True)
# The widget properties are gone, not merely unused: keeping them would mean
# keeping the ghost camera they wrote to.
for prop in ("x", "y", "w", "h", "pos", "size", "center", "center_x", "zoom",
"camera_rotation", "fill_color", "visible", "opacity", "z_index",
"on_click", "parent", "children"):
if hasattr(data, prop):
check("1c: GridData exposes no widget property (found %r)" % prop, False)
return
check("1c: GridData exposes no widget properties", True)
check("1d: GridData is not a Drawable", not isinstance(data, mcrfpy.Drawable))
def case_map_with_no_view():
"""A GridData with no view at all: mutable, steppable, queryable, pathable.
This is what the old `static_cast<UIGrid*>(this)` in GridData::markDirty made
impossible -- a standalone GridData would have been undefined behavior on the
first cell write.
"""
data = mcrfpy.GridData(grid_size=(12, 12))
check("2a: standalone GridData constructs", data.grid_w == 12 and data.grid_h == 12)
for x in range(12):
for y in range(12):
data.at(x, y).walkable = True
data.at(x, y).transparent = True
check("2b: cells are writable with no view attached", data.at(5, 5).walkable)
e = mcrfpy.Entity((1, 1), grid=data)
check("2c: an entity can live on a viewless map", len(data.entities) == 1)
check("2d: entity.grid is that map", e.grid is data)
data.step(1)
check("2e: a viewless map can be stepped", True)
data.compute_fov((5, 5), radius=4)
check("2f: a viewless map computes FOV", data.is_in_fov(5, 6))
path = data.find_path((0, 0), (11, 11))
check("2g: a viewless map is pathable", path is not None and len(list(path)) > 0)
def case_two_views_one_map():
"""N cameras, one map: independent cameras, shared entities and cells."""
data = mcrfpy.GridData(grid_size=(20, 20))
main = mcrfpy.Grid(grid=data, pos=(0, 0), size=(200, 200))
mini = mcrfpy.Grid(grid=data, pos=(220, 0), size=(100, 100))
check("3a: both views share one GridData",
main.grid_data is data and mini.grid_data is data)
main.center_camera((5, 5))
mini.center_camera((15, 15))
check("3b: the two cameras pan independently",
main.center_x != mini.center_x and main.center_y != mini.center_y)
main.zoom = 2.0
check("3c: the two cameras zoom independently", mini.zoom == 1.0)
mcrfpy.Entity((3, 3), grid=main)
check("3d: an entity added through one view is on the shared map",
len(main.entities) == 1 and len(mini.entities) == 1 and len(data.entities) == 1)
data.at(4, 4).walkable = True
check("3e: a cell written on the map is seen through both views",
main.at(4, 4).walkable and mini.at(4, 4).walkable)
def case_grid_is_gridview():
"""mcrfpy.Grid IS mcrfpy.GridView -- one type object, two names, no subclassing."""
check("4a: Grid is GridView (the same type object)", mcrfpy.Grid is mcrfpy.GridView)
g = mcrfpy.Grid(grid_size=(8, 8), pos=(0, 0), size=(80, 80))
check("4b: isinstance(Grid(...), GridView)", isinstance(g, mcrfpy.GridView))
check("4c: Grid() creates its own GridData",
isinstance(g.grid_data, mcrfpy.GridData) and g.grid_data.grid_w == 8)
# grid= names an existing map; grid_size= describes a new one. Asking for both
# is a contradiction, and silently ignoring one would hand back a view of a
# different map than the caller asked for.
try:
mcrfpy.Grid(grid=g.grid_data, grid_size=(4, 4))
check("4d: Grid(grid=..., grid_size=...) raises TypeError", False)
except TypeError:
check("4d: Grid(grid=..., grid_size=...) raises TypeError", True)
def case_camera_writes_are_not_no_ops():
"""#361's smoking gun: center_camera() and size= used to write to the ghost.
On master both of these silently did nothing to the rendered widget -- they
resolved, through attribute delegation, to the internal UIGrid's copy of the
camera, which no view reads. The assertions below fail on master.
"""
g = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(100, 100))
before = g.center_x
g.center_camera((5.0, 5.0))
check("5a: center_camera() moves THIS view's camera", g.center_x != before)
g.size = (300, 300)
check("5b: size= resizes THIS view's widget",
g.w == 300.0 and g.h == 300.0 and g.size.x == 300.0)
def case_no_ghost_render():
"""The ghost path is gone: only the view draws, and only where the view is.
Constructing a second view of the same map must change the frame; destroying
the reference to a map must not (the view holds it alive).
"""
scene = mcrfpy.Scene("t361_render")
scene.activate()
data = mcrfpy.GridData(grid_size=(6, 6))
for x in range(6):
for y in range(6):
data.at(x, y).walkable = True
view = mcrfpy.Grid(grid=data, pos=(10, 10), size=(96, 96),
fill_color=mcrfpy.Color(200, 30, 30))
scene.children.append(view)
a = shot()
# A second camera on the same map paints a second region of the screen.
view2 = mcrfpy.Grid(grid=data, pos=(120, 10), size=(96, 96),
fill_color=mcrfpy.Color(30, 200, 30))
scene.children.append(view2)
b = shot()
check("6a: adding a second view of the same map changes the frame", a != b)
# Mutating the shared map repaints BOTH views.
mcrfpy.Entity((2, 2), grid=data)
c = shot()
check("6b: mutating the shared map repaints the views", b != c)
# Idle frames are stable -- the #351 early-out still holds.
d = shot()
check("6c: idle frames are byte-identical", c == d)
def run_tests():
case_griddata_is_not_drawable()
case_map_with_no_view()
case_two_views_one_map()
case_grid_is_gridview()
case_camera_writes_are_not_no_ops()
case_no_ghost_render()
return all(ok for _, ok in _results)
if __name__ == "__main__":
try:
ok = run_tests()
print("PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
except Exception:
import traceback
traceback.print_exc()
print("FAIL")
sys.exit(1)

View file

@ -55,8 +55,10 @@ def case_child_parent_is_the_view():
grid.children.append(bubble)
check("1a: child.parent is the grid view", bubble.parent.name == "the_view")
check("1b: child.parent is a Grid, not a _GridData",
type(bubble.parent).__name__ == "Grid")
# #361: mcrfpy.Grid IS mcrfpy.GridView (one type object, two names), and the
# canonical tp_name is GridView.
check("1b: child.parent is a Grid, not a GridData",
isinstance(bubble.parent, mcrfpy.Grid))
def case_children_are_per_view_entities_are_shared():
@ -87,11 +89,11 @@ def case_griddata_is_not_a_parent():
orphan = mcrfpy.Frame(pos=(0, 0), size=(10, 10))
try:
orphan.parent = grid.grid_data
check("3a: assigning a _GridData as parent raises TypeError", False)
check("3a: assigning a GridData as parent raises TypeError", False)
except TypeError:
check("3a: assigning a _GridData as parent raises TypeError", True)
check("3a: assigning a GridData as parent raises TypeError", True)
check("3b: _GridData exposes no children collection",
check("3b: GridData exposes no children collection",
not hasattr(grid.grid_data, "children"))

View file

@ -244,7 +244,7 @@ submodule automation
DONE = 0
TARGET = 2
=== FROZEN TYPES (30) ===
=== FROZEN TYPES (31) ===
[AStarPath]
prop destination: Vector (ro)
prop origin: Vector (ro)
@ -567,6 +567,7 @@ submodule automation
prop rotate_with_camera: bool (rw)
prop rotation: Any (rw)
prop shader: Any (rw)
prop size: Vector (rw)
prop texture: Texture | None (ro)
prop uniforms: Any (ro)
prop vert_margin: float (rw)
@ -577,9 +578,32 @@ submodule automation
prop z_index: int (rw)
prop zoom: float (rw)
meth animate :: animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation
meth center_camera :: center_camera(pos: tuple = None) -> None
meth move :: move(dx, dy) or (delta) -> None
meth realign :: realign() -> None
meth resize :: resize(width, height) or (size) -> None
[GridData]
prop entities: EntityCollection (ro)
prop fov: Any (rw)
prop fov_radius: int (rw)
prop grid_h: int (ro)
prop grid_size: Vector (ro)
prop grid_w: int (ro)
prop layers: tuple (ro)
prop texture: Texture | None (ro)
meth add_layer :: add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer
meth apply_ranges :: apply_ranges(source: HeightMap, ranges: list) -> GridData
meth apply_threshold :: apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> GridData
meth at :: at(x: int, y: int) -> GridPoint
meth clear_dijkstra_maps :: clear_dijkstra_maps() -> None
meth compute_fov :: compute_fov(pos, radius: int = 0, light_walls: bool = True, algorithm: FOV | int = FOV.BASIC) -> None
meth entities_in_radius :: entities_in_radius(pos: tuple | Vector, radius: float) -> list
meth find_path :: find_path(start, end, diagonal_cost: float = 1.41, collide: str = None, heuristic = None, weight: float = 1.0) -> AStarPath | None
meth get_dijkstra_map :: get_dijkstra_map(root=None, diagonal_cost: float = 1.41, collide: str = None, roots=None) -> DijkstraMap
meth is_in_fov :: is_in_fov(x: int, y: int) -> bool
meth layer :: layer(name: str) -> ColorLayer | TileLayer | None
meth remove_layer :: remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None
meth step :: step(n: int = 1, turn_order: int = None) -> None
[GridView]
prop align: Any (rw)
prop bounds: tuple (ro)
@ -614,6 +638,7 @@ submodule automation
prop rotate_with_camera: bool (rw)
prop rotation: Any (rw)
prop shader: Any (rw)
prop size: Vector (rw)
prop texture: Texture | None (ro)
prop uniforms: Any (ro)
prop vert_margin: float (rw)
@ -624,6 +649,7 @@ submodule automation
prop z_index: int (rw)
prop zoom: float (rw)
meth animate :: animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation
meth center_camera :: center_camera(pos: tuple = None) -> None
meth move :: move(dx, dy) or (delta) -> None
meth realign :: realign() -> None
meth resize :: resize(width, height) or (size) -> None
@ -1120,56 +1146,19 @@ submodule automation
meth terrain_enum :: terrain_enum() -> IntEnum
=== INTERNAL TYPES (reached via live instances) ===
[_GridData]
prop align: Any (rw)
prop bounds: tuple (ro)
prop camera_rotation: float (rw)
prop center: Vector (rw)
prop center_x: float (rw)
prop center_y: float (rw)
[GridData]
prop entities: EntityCollection (ro)
prop fill_color: Color (rw)
prop fov: Any (rw)
prop fov_radius: int (rw)
prop global_bounds: tuple (ro)
prop global_position: Any (ro)
prop grid_h: int (ro)
prop grid_pos: Vector (rw)
prop grid_size: Vector (ro)
prop grid_w: int (ro)
prop h: float (rw)
prop horiz_margin: float (rw)
prop hovered: Any (ro)
prop layers: tuple (ro)
prop margin: float (rw)
prop name: str (rw)
prop on_click: Any (rw)
prop on_enter: Any (rw)
prop on_exit: Any (rw)
prop on_move: Any (rw)
prop opacity: Any (rw)
prop origin: Any (rw)
prop parent: Any (rw)
prop pos: Vector (rw)
prop rotate_with_camera: bool (rw)
prop rotation: Any (rw)
prop shader: Any (rw)
prop size: Vector (rw)
prop texture: Texture | None (ro)
prop uniforms: Any (ro)
prop vert_margin: float (rw)
prop visible: bool (rw)
prop w: float (rw)
prop x: float (rw)
prop y: float (rw)
prop z_index: Any (rw)
prop zoom: float (rw)
meth add_layer :: add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer
meth animate :: animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation
meth apply_ranges :: apply_ranges(source: HeightMap, ranges: list) -> Grid
meth apply_threshold :: apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> Grid
meth apply_ranges :: apply_ranges(source: HeightMap, ranges: list) -> GridData
meth apply_threshold :: apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> GridData
meth at :: at(x: int, y: int) -> GridPoint
meth center_camera :: center_camera(pos: tuple = None) -> None
meth clear_dijkstra_maps :: clear_dijkstra_maps() -> None
meth compute_fov :: compute_fov(pos, radius: int = 0, light_walls: bool = True, algorithm: FOV | int = FOV.BASIC) -> None
meth entities_in_radius :: entities_in_radius(pos: tuple | Vector, radius: float) -> list
@ -1177,10 +1166,7 @@ submodule automation
meth get_dijkstra_map :: get_dijkstra_map(root=None, diagonal_cost: float = 1.41, collide: str = None, roots=None) -> DijkstraMap
meth is_in_fov :: is_in_fov(x: int, y: int) -> bool
meth layer :: layer(name: str) -> ColorLayer | TileLayer | None
meth move :: move(dx, dy) or (delta) -> None
meth realign :: realign() -> None
meth remove_layer :: remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None
meth resize :: resize(width, height) or (size) -> None
meth step :: step(n: int = 1, turn_order: int = None) -> None
[GridPoint]
prop entities: list (ro)
@ -1234,8 +1220,8 @@ func size :: size() -> Vector
func tripleClick :: tripleClick(pos: tuple | list | Vector | None = None) -> None
func typewrite :: typewrite(message: str, interval: float = 0.0) -> None
=== DELEGATION INTEGRITY (Grid instance -> _GridData) ===
delegated-resolved: 61/61
=== DELEGATION INTEGRITY (Grid instance -> GridData) ===
delegated-resolved: 21/21
=== WRITABILITY PROBES (#313-touched properties) ===
Entity.grid: writable

View file

@ -13,7 +13,7 @@ Intentional re-baseline (after a deliberate, reviewed API change):
Design notes (see docs/plan-313-314.md):
* mcrfpy.Grid IS mcrfpy.GridView and delegates its real API to an internal
_GridData via tp_getattro -- that surface is INVISIBLE to dir(). We therefore
GridData via tp_getattro -- that surface is INVISIBLE to dir(). We therefore
walk grid.grid_data explicitly AND probe delegation integrity on a live instance.
* Singleton/constant VALUES are never captured (build/platform/object dependent);
only their type name is recorded.
@ -60,7 +60,7 @@ def _internal_type_probes():
g = mcrfpy.Grid(grid_size=(3, 3))
gd = getattr(g, "grid_data", None)
if gd is not None:
probes.append(("_GridData", type(gd)))
probes.append(("GridData", type(gd)))
try:
probes.append(("GridPoint", type(g.at(0, 0))))
except Exception:
@ -200,8 +200,8 @@ def enum_lines(cls):
# Delegation + writability probes (catch breaks invisible to type introspection)
# --------------------------------------------------------------------------- #
def delegation_probe_lines():
"""Assert the GridView instance still resolves its delegated _GridData surface
via tp_getattro. A break here leaves the _GridData type unchanged (invisible to
"""Assert the GridView instance still resolves its delegated GridData surface
via tp_getattro. A break here leaves the GridData type unchanged (invisible to
the type snapshot) but breaks the user-facing Grid contract."""
lines = []
try:
@ -337,7 +337,7 @@ def build_snapshot():
out.append("func %s :: %s" % (n, first_doc_line(v.__doc__) or "<no-doc>"))
out.append("")
out.append("=== DELEGATION INTEGRITY (Grid instance -> _GridData) ===")
out.append("=== DELEGATION INTEGRITY (Grid instance -> GridData) ===")
out.extend(delegation_probe_lines())
out.append("")