Merge branch 'feat/355-grid-input-revival': revive Grid input, split GridData from GridView

Grid input had been dead since the #252 GridView unification: cell callbacks never
fired, grid children were unclickable, and cell hover was stuck. Chasing that down
surfaced the reason -- UIGrid inherited BOTH UIDrawable and GridData, so every map
secretly carried a second camera and RenderTexture that nothing on screen
corresponded to, and input, children, and dirty propagation all dead-ended in it.

The branch fixes the input regression and then removes the thing that caused it.

Grid input (#355, #357, #358, #360, #362, #363, #365, #366):
  Cell callbacks and hit-testing move to UIGridView -- input is a property of the
  CAMERA, not the data; two views over one map must track hover independently. A
  weak_ptr view registry replaces the single owning_view, so N views can share one
  map. mcrfpy.find()/findAll() descend into grids again, the ImGui scene explorer
  can expand them, hover-exit fires when the cursor leaves the window, and
  UICollection.repr names Grids instead of "UIDrawable".

The type split (#359, #361, #364, #370, #371):
  UIGrid is DELETED, not demoted. GridData is now a public, standalone-constructible
  mcrfpy.GridData: a map, with no position, no size, no camera and no render(). Only
  UIGridView is a UIDrawable. mcrfpy.Grid is an alias bound to the SAME type object
  as GridView -- not a subclass, so isinstance works both ways and there is no MRO to
  explain. Overlay children belong to the view; entities belong to the map.

  Two silent bugs fell out of that, each verified against unmodified master before
  being fixed: Grid.center_camera() and Grid.size = ... were NO-OPS (#370), writing
  to the ghost camera nothing rendered; and the shader uniform binder was doing
  type-confused pointer arithmetic (#371), reading a GridView wrapper as a _GridData
  wrapper -- which "worked" only because both wrappers had identical layout and both
  C++ classes happened to put UIDrawable at offset 0.

Render invalidation (#367, #368):
  markContentDirty()'s walk up the parent chain was gated on a flag that was never
  cleared -- clearDirty() had exactly one call site in the engine. render_dirty was
  a write-once latch, so the guard was permanently false and content invalidation
  propagated NOWHERE: Frame(cache_subtree=True) silently froze its subtree's content.
  Text never updated, colours never changed. Only movement survived, because
  position changes were already on an unconditional path. The walk is now
  unconditional too. Entity movement -- the hot path -- is unaffected (164ns/move
  either way); Crucible shows no regression.

BREAKING: entity.grid returns the GridData (the map), not a view. An entity may live
on a map with no view at all, or with several -- "the grid an entity is in" simply is
not a camera. The setter still accepts a GridView, so assignment is unchanged; only
the read moved. Use view.grid_data for the map, and the view itself for the camera.
type(g).__name__ is now 'GridView'.

Suite 329/329.
This commit is contained in:
John McCardle 2026-07-13 07:42:17 -04:00
commit 36e74e0dc3
68 changed files with 4823 additions and 3604 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,91 @@
# Grid Coordinate Spaces & the Overlay Pattern
Clarifies which coordinate space `UIDrawable` children are positioned in depending on
their parent, and how to build screen-space UI (HUDs, dialogs) that sits on top of a
panning/zooming `Grid`. See issue #360.
## The short version
- **`Frame.children`** are positioned **frame-local**: relative to the Frame's
top-left corner. A `Frame` has no camera and cannot pan its content, so
frame-local is effectively already screen space.
- **`Grid.children`** are positioned in the grid's **pixel-world coordinates** — the
same origin as `Entity` positions — **not** in the grid widget's on-screen pixels.
They pan and zoom with the grid's camera (`grid.center`, `grid.zoom`), exactly like
entities and tiles do.
This is not an inconsistency to "fix": a `Frame` genuinely has no notion of a camera,
so there is no other coordinate space its children could reasonably use. A `Grid`
does have a camera, and its children are deliberately anchored to grid content (the
world), not to the viewport. Entities are positioned by cell; grid-world pixel
coordinates share that same origin, so a `Grid` child can be placed precisely next to
an entity (using `entity.pos`, itself documented as "pixel position relative to
grid") and will continue to track that entity's neighborhood as the camera moves.
The use case for `Grid.children` is **diegetic** UI: speech bubbles, damage numbers,
range indicators, path markers — things that belong to a place or an entity in the
world and should move/zoom with it.
```python
# A speech bubble anchored near an entity, in grid-world pixels.
# entity.pos is already "pixel position relative to grid" -- the same origin
# Grid.children use.
bubble = mcrfpy.Caption(text="Hello!", pos=(entity.pos.x, entity.pos.y - 20))
grid.children.append(bubble)
```
If you instead want UI that floats over the grid at a fixed screen position
regardless of where the camera is pointed — a health bar HUD, an inventory panel, a
modal dialog — **`Grid.children` is the wrong tool**, because those children would
pan and zoom right along with everything else and drift off screen.
## The overlay pattern
Put a `Frame` as a **sibling** of the `Grid` (not a child of it), sized and
positioned to match the `Grid`, and draw your screen-space UI as children of that
Frame instead. Give the overlay Frame a later `z_index` (or simply append it after
the Grid) so it draws on top.
```
Scene (or a container Frame)
├── Grid # pan, zoom, entities, diegetic children
│ ├── entities: positioned by cell
│ └── children: positioned in grid-world pixels (pan/zoom with camera)
└── Frame (overlay) # same pos/size as the Grid, drawn after it
└── children: positioned in frame-local coordinates (screen space)
```
```python
import mcrfpy
scene = mcrfpy.Scene("game")
ui = scene.children
grid = mcrfpy.Grid(grid_size=(40, 30), pos=(0, 0), size=(640, 480))
ui.append(grid)
# Diegetic marker: lives in the grid's world, pans/zooms with the camera.
marker = mcrfpy.Caption(text="!", pos=(160, 96))
grid.children.append(marker)
# Screen-space HUD: same pos/size as the grid, but its own children are
# frame-local, so they stay put no matter how grid.center/grid.zoom change.
overlay = mcrfpy.Frame(pos=(0, 0), size=(640, 480))
overlay.fill_color = mcrfpy.Color(0, 0, 0, 0) # transparent container
ui.append(overlay) # appended after grid -> draws on top
health_bar = mcrfpy.Frame(pos=(10, 10), size=(200, 20))
health_bar.fill_color = mcrfpy.Color(200, 40, 40)
overlay.children.append(health_bar)
```
Panning or zooming `grid` (`grid.center = ...`, `grid.zoom = ...`) moves `marker`
along with the world; `health_bar` never moves, because it belongs to `overlay`, not
to `grid`.
## See also
- `Grid.children` / `Frame.children` docstrings (`mcrfpy.Grid.children.__doc__`,
`mcrfpy.Frame.children.__doc__`) for the API-level statement of this contract.
- `Entity.draw_pos` / `Entity.cell_pos` for the cell-vs-pixel distinction on the
entity side of the same coordinate system.

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

@ -621,6 +621,22 @@ void GameEngine::processEvent(const sf::Event& event)
}
return;
}
// #363 - The cursor left the window. No further MouseMoved will arrive, so
// whatever is hovered must be exited now or it stays hovered forever.
//
// #367 - Losing focus strands hover identically. The cursor may still be physically
// over the window, but an unfocused window is delivered no MouseMoved, so the hover
// walk never runs and the last hovered drawable stays lit while the game sits in the
// background. Once unfocused we genuinely do not know where the pointer is, and
// "hovering nothing" is the only truthful state to hold; the next MouseMoved after
// focus returns restores hover.
else if (event.type == sf::Event::MouseLeft || event.type == sf::Event::LostFocus)
{
if (auto* pyscene = dynamic_cast<PyScene*>(currentScene())) {
pyscene->do_mouse_leave();
}
return;
}
else
return;

View file

@ -2,37 +2,80 @@
#include "GridData.h"
#include "UIEntity.h"
#include "PyTexture.h"
#include "UIGrid.h" // #313 - markDirty forwards to the UIGrid subobject
#include "UIGridView.h" // #313 - and notifies owning_view
#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();
if (auto view = owning_view.lock()) {
view->markDirty();
for (auto& weak_view : views) {
if (auto view = weak_view.lock()) view->markDirty();
}
}
void GridData::markCompositeDirty() {
content_generation++; // #351 - content changed; invalidate view early-out
static_cast<UIGrid*>(this)->UIDrawable::markCompositeDirty();
if (auto view = owning_view.lock()) {
view->markCompositeDirty();
for (auto& weak_view : views) {
if (auto view = weak_view.lock()) view->markCompositeDirty();
}
}
// #359 - View registry (see GridData.h). Kept small (typically 1-2 entries:
// split-screen / minimap use cases), so linear scan is fine.
void GridData::registerView(const std::shared_ptr<UIGridView>& view) {
if (!view) return;
for (auto& weak_view : views) {
if (weak_view.lock() == view) return; // already registered
}
views.push_back(view);
}
void GridData::unregisterView(UIGridView* view) {
views.erase(
std::remove_if(views.begin(), views.end(),
[view](const std::weak_ptr<UIGridView>& weak_view) {
auto locked = weak_view.lock();
return !locked || locked.get() == view; // prune expired too
}),
views.end());
}
std::shared_ptr<UIGridView> GridData::primaryView() const {
for (auto& weak_view : views) {
if (auto view = weak_view.lock()) return view;
}
return nullptr;
}
GridData::GridData()
{
entities = std::make_shared<std::vector<std::shared_ptr<UIEntity>>>(); // #329
children = std::make_shared<std::vector<std::shared_ptr<UIDrawable>>>();
// #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; }
@ -119,37 +140,47 @@ public:
std::shared_ptr<GridLayer> getLayerByName(const std::string& name);
static bool isProtectedLayerName(const std::string& name);
// =========================================================================
// Cell callbacks (#142, #230)
// =========================================================================
std::unique_ptr<PyCellHoverCallable> on_cell_enter_callable;
std::unique_ptr<PyCellHoverCallable> on_cell_exit_callable;
std::unique_ptr<PyClickCallable> on_cell_click_callable;
std::optional<sf::Vector2i> hovered_cell;
std::optional<sf::Vector2i> last_clicked_cell;
// #355 - Cell input (callbacks, hovered/last-clicked cell, subclass-dispatch
// cache) moved to UIGridView: input is a property of the VIEW (camera), not of
// the data. Two views over one GridData must track hover independently, and
// the subclassable Python type (mcrfpy.Grid) IS UIGridView.
struct CellCallbackCache {
uint32_t generation = 0;
bool valid = false;
bool has_on_cell_click = false;
bool has_on_cell_enter = false;
bool has_on_cell_exit = false;
};
CellCallbackCache cell_callback_cache;
// fireCellClick/Enter/Exit and refreshCellCallbackCache are on UIGrid
// because they need access to UIDrawable::serial_number/is_python_subclass
// #364 - UIDrawable children (speech bubbles, markers, range indicators) moved
// to UIGridView. They are OVERLAYS painted over the map through one camera, not
// world contents: nothing collides with them, they occupy no cell, and the turn
// manager cannot see them. Entities are the world contents, and those stay here,
// shared by every view.
//
// Ownership follows from that. A UIDrawable has exactly one `parent`, but a
// GridData may have N views -- so a child owned by the data could only ever name
// one of them as parent, arbitrarily, and would dangle if that view died while
// the others kept rendering it. Owned by the view, a child's parent is a real
// scene-graph drawable, so its dirty push reaches the view and every caching
// ancestor above it, and its grid-world position resolves through exactly one
// camera.
// =========================================================================
// UIDrawable children (speech bubbles, effects, overlays)
// #252/#359 - GridView back-references. Multiple GridViews can share one
// GridData (split-screen, minimap, multiple cameras); a single weak_ptr
// cannot represent that, so this is a vector. A fresh GridData is only
// ever produced by UIGridView::init_with_data (the Grid() factory path --
// UIGridView::init_explicit_view / set_grid can only ATTACH to an
// EXISTING GridData, never create one), so views.front() is always the
// view that created this data: that ordering guarantee is what lets
// primaryView() give UIEntity::get_grid a deterministic (not arbitrary)
// answer for API identity, even once secondary views are registered.
// =========================================================================
std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>> children;
bool children_need_sort = true;
std::vector<std::weak_ptr<UIGridView>> views;
// =========================================================================
// #252 - Owning GridView back-reference (for Entity.grid → GridView lookup)
// =========================================================================
std::weak_ptr<UIGridView> owning_view;
// Append view (idempotent -- a view already present is not duplicated).
void registerView(const std::shared_ptr<UIGridView>& view);
// Remove view by identity (prunes expired entries too). Safe to call even
// if view was never registered.
void unregisterView(UIGridView* view);
// The view that created this GridData (views.front()), or the first
// still-alive view if the creator has since been destroyed while
// secondary views live on. nullptr if no view is alive.
std::shared_ptr<UIGridView> primaryView() const;
// #351 - Monotonic counter bumped whenever grid content drawn into a view's
// RenderTexture changes (entities added/removed/moved, sprite changes, layer
@ -158,14 +189,23 @@ public:
// live on the view and are compared there directly, not counted here.
uint64_t content_generation = 0;
// #313 - Render invalidation from the data layer. Entities hold
// #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 owning_view, covering both render paths (a bare _GridData
// rendered directly, and the normal GridView). Within UIGrid itself the
// UIDrawable versions win via using-declarations (see UIGrid.h).
// Multi-view broadcast (secondary views) is deferred to #252.
// 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

@ -11,8 +11,8 @@
#include "UIFrame.h"
#include "UICaption.h"
#include "UISprite.h"
#include "UIGrid.h"
#include "UIGridView.h"
#include "GridData.h"
#include "UIEntity.h"
#include "ImGuiConsole.h"
#include "PythonObjectCache.h"
@ -118,22 +118,30 @@ void ImGuiSceneExplorer::renderDrawableNode(std::shared_ptr<UIDrawable> drawable
// Check if this node has children
bool hasChildren = false;
UIFrame* frame = nullptr;
UIGrid* grid = nullptr;
// #358: dispatch on the asGridData() virtual (#355), not derived_type() --
// nothing in a scene graph is ever a bare UIGRID since #252 (Grid nodes are
// UIGridView instances), so a UIGRID enum arm here is always dead code.
GridData* gridData = drawable->asGridData();
// #364: overlay children hang off the VIEW; entities off the shared data.
UIGridView* gridView = nullptr;
switch (drawable->derived_type()) {
case PyObjectsEnum::UIFRAME:
frame = static_cast<UIFrame*>(drawable.get());
hasChildren = frame->children && !frame->children->empty();
break;
case PyObjectsEnum::UIGRID:
grid = static_cast<UIGrid*>(drawable.get());
hasChildren = (grid->entities && !grid->entities->empty()) ||
(grid->children && !grid->children->empty());
case PyObjectsEnum::UIGRIDVIEW:
gridView = static_cast<UIGridView*>(drawable.get());
break;
default:
break;
}
if (gridData) {
hasChildren = (gridData->entities && !gridData->entities->empty()) ||
(gridView && gridView->children && !gridView->children->empty());
}
if (!hasChildren) {
flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen;
}
@ -168,14 +176,14 @@ void ImGuiSceneExplorer::renderDrawableNode(std::shared_ptr<UIDrawable> drawable
}
}
if (grid) {
if (gridData) {
// Render entities
if (grid->entities && !grid->entities->empty()) {
if (gridData->entities && !gridData->entities->empty()) {
ImGuiTreeNodeFlags entityGroupFlags = ImGuiTreeNodeFlags_OpenOnArrow;
bool entitiesOpen = ImGui::TreeNodeEx("Entities", entityGroupFlags, "Entities (%zu)",
grid->entities->size());
gridData->entities->size());
if (entitiesOpen) {
for (auto& entity : *grid->entities) {
for (auto& entity : *gridData->entities) {
if (entity) {
renderEntityNode(entity);
}
@ -184,13 +192,14 @@ void ImGuiSceneExplorer::renderDrawableNode(std::shared_ptr<UIDrawable> drawable
}
}
// Render grid's drawable children (overlays)
if (grid->children && !grid->children->empty()) {
// Render the view's drawable children (overlays) -- #364: owned by the
// view, not the shared data, so a second view lists its own.
if (gridView && gridView->children && !gridView->children->empty()) {
ImGuiTreeNodeFlags overlayGroupFlags = ImGuiTreeNodeFlags_OpenOnArrow;
bool overlaysOpen = ImGui::TreeNodeEx("Overlays", overlayGroupFlags, "Overlays (%zu)",
grid->children->size());
gridView->children->size());
if (overlaysOpen) {
for (auto& child : *grid->children) {
for (auto& child : *gridView->children) {
if (child) {
renderDrawableNode(child, depth + 1);
}
@ -285,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

@ -53,6 +53,7 @@
#include <emscripten.h>
#endif
#include <sys/stat.h> // mkdir
#include <unordered_set> // #359 - dedupe shared GridData during find/findAll walk
// ImGui is only available for SFML builds
#if !defined(MCRF_HEADLESS) && !defined(MCRF_SDL2)
#include "ImGuiConsole.h"
@ -471,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
@ -491,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,
@ -551,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,
@ -651,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);
@ -696,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");
@ -1587,11 +1589,21 @@ static bool name_matches_pattern(const std::string& name, const std::string& pat
return pattern_pos == pattern.length() && name_pos == name.length();
}
// Helper to recursively search a collection for named elements
static void find_in_collection(std::vector<std::shared_ptr<UIDrawable>>* collection, const std::string& pattern,
bool find_all, PyObject* results) {
// Also search Grid entities (forward-declared: find_in_collection recurses
// into grid children via GridData, and the two helpers are mutually called).
static void find_in_grid_entities(GridData* grid, const std::string& pattern,
bool find_all, PyObject* results);
// Helper to recursively search a collection for named elements.
// visited_grids: #359 - N views can share ONE GridData (split-screen/minimap).
// asGridData() returns the same GridData* from each of them, so without this set
// the shared grid's entities and overlay children are walked (and appended) once
// per view -- findAll() would return N duplicate wrappers for the same object.
static void find_in_collection(std::vector<std::shared_ptr<UIDrawable>>* collection, const std::string& pattern,
bool find_all, PyObject* results,
std::unordered_set<GridData*>& visited_grids) {
if (!collection) return;
for (auto& drawable : *collection) {
if (!drawable) continue;
@ -1631,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;
@ -1671,39 +1673,74 @@ static void find_in_collection(std::vector<std::shared_ptr<UIDrawable>>* collect
// Recursively search in Frame children
if (drawable->derived_type() == PyObjectsEnum::UIFRAME) {
auto frame = std::static_pointer_cast<UIFrame>(drawable);
find_in_collection(frame->children.get(), pattern, find_all, results);
find_in_collection(frame->children.get(), pattern, find_all, results, visited_grids);
if (!find_all && PyList_Size(results) > 0) {
return; // Found one, stop searching
}
}
// #364 - A grid's overlay children belong to the VIEW, so they are searched
// per view and NOT deduplicated: two views over one GridData hold different
// children, and each must be visited.
if (drawable->derived_type() == PyObjectsEnum::UIGRIDVIEW) {
auto view = std::static_pointer_cast<UIGridView>(drawable);
find_in_collection(view->children.get(), pattern, find_all, results, visited_grids);
if (!find_all && PyList_Size(results) > 0) {
return;
}
}
// #357 - Recursively search a grid's entities. Dispatch via asGridData()
// (#355), not derived_type(): a real mcrfpy.Grid() is a UIGridView
// (PyObjectsEnum::UIGRIDVIEW), so gating on UIGRID here would be
// structurally unreachable again.
// #359 - entities are shared world content, so visit each GridData exactly
// once per search: several views may point at the same data.
if (GridData* gd = drawable->asGridData()) {
if (visited_grids.insert(gd).second) {
find_in_grid_entities(gd, pattern, find_all, results);
if (!find_all && PyList_Size(results) > 0) {
return;
}
}
}
}
}
// Also search Grid entities
static void find_in_grid_entities(UIGrid* grid, const std::string& pattern,
static void find_in_grid_entities(GridData* grid, const std::string& pattern,
bool find_all, PyObject* results) {
if (!grid || !grid->entities) return;
for (auto& entity : *grid->entities) {
if (!entity) continue;
// Entities delegate name to their sprite
if (name_matches_pattern(entity->sprite.name, pattern)) {
auto type = &mcrfpydef::PyUIEntityType;
auto o = (PyUIEntityObject*)type->tp_alloc(type, 0);
if (o) {
o->data = entity;
PyObject* py_obj = (PyObject*)o;
if (find_all) {
PyList_Append(results, py_obj);
Py_DECREF(py_obj);
} else {
PyList_Append(results, py_obj);
Py_DECREF(py_obj);
return;
// #266/#357: the cache is the ONLY correct way to wrap an existing
// entity. A fresh tp_alloc would (a) return a base-class Entity for
// a Python subclass instance, losing identity and its __dict__, and
// (b) leak the entity's strong self-reference when that duplicate
// wrapper is deallocated -- PyUIEntityType's tp_dealloc clears
// data->pyobject unconditionally. Mirrors UIEntityCollection::getitem.
PyObject* py_obj = nullptr;
if (entity->serial_number != 0) {
py_obj = PythonObjectCache::getInstance().lookup(entity->serial_number); // new ref
}
if (!py_obj) {
auto type = &mcrfpydef::PyUIEntityType;
auto o = (PyUIEntityObject*)type->tp_alloc(type, 0);
if (o) {
o->data = entity;
o->weakreflist = NULL;
py_obj = (PyObject*)o;
}
}
if (py_obj) {
PyList_Append(results, py_obj);
Py_DECREF(py_obj);
if (!find_all) return;
}
}
}
}
@ -1739,20 +1776,11 @@ PyObject* McRFPy_API::_find(PyObject* self, PyObject* args) {
ui_elements = current->ui_elements;
}
// Search the scene's UI elements
find_in_collection(ui_elements.get(), name, false, results);
// Also search all grids in the scene for entities
if (PyList_Size(results) == 0 && ui_elements) {
for (auto& drawable : *ui_elements) {
if (drawable && drawable->derived_type() == PyObjectsEnum::UIGRID) {
auto grid = std::static_pointer_cast<UIGrid>(drawable);
find_in_grid_entities(grid.get(), name, false, results);
if (PyList_Size(results) > 0) break;
}
}
}
// Search the scene's UI elements. find_in_collection recurses into a
// grid's entities and overlay children via asGridData() (#355/#357).
std::unordered_set<GridData*> visited_grids; // #359 - shared GridData visited once
find_in_collection(ui_elements.get(), name, false, results, visited_grids);
// Return the first result or None
if (PyList_Size(results) > 0) {
PyObject* result = PyList_GetItem(results, 0);
@ -1796,19 +1824,11 @@ PyObject* McRFPy_API::_findAll(PyObject* self, PyObject* args) {
ui_elements = current->ui_elements;
}
// Search the scene's UI elements
find_in_collection(ui_elements.get(), pattern, true, results);
// Also search all grids in the scene for entities
if (ui_elements) {
for (auto& drawable : *ui_elements) {
if (drawable && drawable->derived_type() == PyObjectsEnum::UIGRID) {
auto grid = std::static_pointer_cast<UIGrid>(drawable);
find_in_grid_entities(grid.get(), pattern, true, results);
}
}
}
// Search the scene's UI elements. find_in_collection recurses into a
// grid's entities and overlay children via asGridData() (#355/#357).
std::unordered_set<GridData*> visited_grids; // #359 - shared GridData visited once
find_in_collection(ui_elements.get(), pattern, true, results, visited_grids);
return results;
}

View file

@ -113,6 +113,17 @@ sf::Keyboard::Key McRFPy_Automation::stringToKey(const std::string& keyName) {
return sf::Keyboard::Unknown;
}
// #367 - Inject a window-level event. These carry no coordinates and no button, which
// is why they get their own entry point rather than riding injectMouseEvent.
void McRFPy_Automation::injectWindowEvent(sf::Event::EventType type) {
auto engine = getGameEngine();
if (!engine) return;
sf::Event event;
event.type = type;
engine->processEvent(event);
}
// Inject mouse event into the game engine
void McRFPy_Automation::injectMouseEvent(sf::Event::EventType type, int x, int y, sf::Mouse::Button button, float scrollDelta) {
auto engine = getGameEngine();
@ -326,6 +337,24 @@ PyObject* McRFPy_Automation::_moveTo(PyObject* self, PyObject* args, PyObject* k
Py_RETURN_NONE;
}
// #363 - Simulate the cursor leaving the window. sf::Event::MouseLeft carries no
// coordinates, so this takes none; the engine reports the last known position to the
// exit callbacks it fires. The simulated position is deliberately NOT moved -- the
// cursor is outside the window, not at some new point inside it.
PyObject* McRFPy_Automation::_mouseLeave(PyObject* self, PyObject* args) {
injectMouseEvent(sf::Event::MouseLeft, simulated_mouse_pos.x, simulated_mouse_pos.y);
Py_RETURN_NONE;
}
// #367 - Simulate the window losing focus (alt-tab). Like a window-leave, this clears
// all hover state: an unfocused window receives no MouseMoved, so any drawable still
// marked hovered would stay lit indefinitely. The simulated cursor position is left
// alone -- focus and pointer location are independent, and the pointer has not moved.
PyObject* McRFPy_Automation::_loseFocus(PyObject* self, PyObject* args) {
injectWindowEvent(sf::Event::LostFocus);
Py_RETURN_NONE;
}
// Move mouse relative - accepts moveRel(offset, duration)
PyObject* McRFPy_Automation::_moveRel(PyObject* self, PyObject* args, PyObject* kwargs) {
static const char* kwlist[] = {"offset", "duration", NULL};
@ -972,6 +1001,25 @@ static PyMethodDef automationMethods[] = {
MCRF_ARG("pos", "Position as (x, y) tuple, [x, y] list, Vector, or None for current position")
MCRF_ARG("button", "Mouse button: 'left', 'right', or 'middle' (default 'left')")
)},
{"mouseLeave", (PyCFunction)McRFPy_Automation::_mouseLeave, METH_NOARGS,
MCRF_METHOD(automation, mouseLeave,
MCRF_SIG("()", "None"),
MCRF_DESC("Simulate the cursor leaving the window, clearing all hover state. "
"Every hovered drawable fires its on_exit, every hovered grid fires "
"on_cell_exit, and Grid.hovered_cell becomes None."),
MCRF_NOTE("Takes no position: the cursor is outside the window, so there is none. "
"Exit callbacks receive the last position passed to moveTo/click.")
)},
{"loseFocus", (PyCFunction)McRFPy_Automation::_loseFocus, METH_NOARGS,
MCRF_METHOD(automation, loseFocus,
MCRF_SIG("()", "None"),
MCRF_DESC("Simulate the window losing focus (alt-tab), clearing all hover state. "
"Every hovered drawable fires its on_exit, every hovered grid fires "
"on_cell_exit, and Grid.hovered_cell becomes None."),
MCRF_NOTE("An unfocused window is delivered no mouse motion, so the engine cannot "
"know where the pointer is; hovering nothing is the only truthful state. "
"Hover is restored by the next mouse movement after focus returns.")
)},
{"typewrite", (PyCFunction)McRFPy_Automation::_typewrite, METH_VARARGS | METH_KEYWORDS,
MCRF_METHOD(automation, typewrite,

View file

@ -35,6 +35,8 @@ public:
static PyObject* _scroll(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _mouseDown(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _mouseUp(PyObject* self, PyObject* args, PyObject* kwargs);
static PyObject* _mouseLeave(PyObject* self, PyObject* args); // #363
static PyObject* _loseFocus(PyObject* self, PyObject* args); // #367
// Keyboard
static PyObject* _typewrite(PyObject* self, PyObject* args, PyObject* kwargs);
@ -44,6 +46,9 @@ public:
// Helper functions
static void injectMouseEvent(sf::Event::EventType type, int x, int y, sf::Mouse::Button button = sf::Mouse::Left, float scrollDelta = 0.0f);
// #367 - Window-level events (LostFocus, GainedFocus) carry no payload at all, so
// they do not belong in injectMouseEvent's coordinate-bearing signature.
static void injectWindowEvent(sf::Event::EventType type);
static void injectKeyEvent(sf::Event::EventType type, sf::Keyboard::Key key);
static void injectTextEvent(sf::Uint32 unicode);
static sf::Keyboard::Key stringToKey(const std::string& keyName);

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 + owning_view 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
@ -257,41 +257,19 @@ void PyScene::do_mouse_input(std::string button, std::string type)
// #184: Try property-assigned callable first (fast path)
if (target->click_callable && !target->click_callable->isNone()) {
target->click_callable->call(mousepos, button, type);
// Also fire grid cell click if applicable
if (target->derived_type() == PyObjectsEnum::UIGRID) {
auto grid = static_cast<UIGrid*>(target);
if (grid->last_clicked_cell.has_value()) {
grid->fireCellClick(grid->last_clicked_cell.value(), button, type);
grid->last_clicked_cell = std::nullopt;
}
}
target->dispatchCellClick(button, type); // #355 (no-op unless grid view)
return; // Stop after first handler
}
// #184: Try Python subclass method
if (tryCallPythonMethod(target, "on_click", mousepos, button.c_str(), type.c_str())) {
// Also fire grid cell click if applicable
if (target->derived_type() == PyObjectsEnum::UIGRID) {
auto grid = static_cast<UIGrid*>(target);
if (grid->last_clicked_cell.has_value()) {
grid->fireCellClick(grid->last_clicked_cell.value(), button, type);
grid->last_clicked_cell = std::nullopt;
}
}
target->dispatchCellClick(button, type); // #355
return; // Stop after first handler
}
// Fire grid cell click even if no on_click handler (but has cell click handler)
if (target->derived_type() == PyObjectsEnum::UIGRID) {
auto grid = static_cast<UIGrid*>(target);
if (grid->last_clicked_cell.has_value()) {
bool handled = grid->fireCellClick(grid->last_clicked_cell.value(), button, type);
grid->last_clicked_cell = std::nullopt;
if (handled) {
return; // Stop after handling cell click
}
}
// #355: cell click fires even without a whole-grid on_click handler
if (target->dispatchCellClick(button, type)) {
return; // Stop after handling cell click
}
// Element claimed the click but had no handler - still stop propagation
@ -326,11 +304,49 @@ void PyScene::do_mouse_hover(int x, int y)
mousepos = game->windowToGameCoords(sf::Vector2f(static_cast<float>(x), static_cast<float>(y)));
}
// Helper function to process hover for a single drawable and its children
std::function<void(UIDrawable*)> processHover = [&](UIDrawable* drawable) {
last_mouse_pos = mousepos;
dispatch_hover(mousepos, true);
}
// #363 - The cursor left the window. sf::Event::MouseLeft carries no coordinates,
// and no further MouseMoved will arrive, so without this every drawable the mouse
// was over stays hovered forever: on_exit / on_cell_exit never fire and
// grid.hovered_cell keeps reporting a cell the cursor is nowhere near.
//
// This is the ordinary hover walk with hit_allowed=false at the root, which is
// exactly "the mouse is over nothing": every hovered drawable takes its normal exit
// path and none can enter. Exit callbacks get the last known cursor position -- the
// same convention the DOM's mouseleave uses; there is no more recent position to
// report, and inventing one would be a lie.
void PyScene::do_mouse_leave()
{
dispatch_hover(last_mouse_pos, false);
}
void PyScene::dispatch_hover(sf::Vector2f mousepos, bool in_window)
{
// Helper function to process hover for a single drawable and its children.
//
// `point` is in the drawable's PARENT-local coordinate space -- the same
// convention click_at() uses. At the top level that is the global/scene space
// (identical to the old contains_point() behavior, since global position is
// just the sum of ancestor positions). Descending into a Frame subtracts the
// frame's position; descending into a Grid applies the view's camera
// (localToGridWorld), which is what makes grid children hoverable at all (#355).
//
// `hit_allowed` is false while walking the children of a grid the mouse is NOT
// over: the camera transform is affine, so an outside point still maps to some
// grid-world coordinate, which could land on an off-screen child. Those subtrees
// must only ever fire exits, never enters.
std::function<void(UIDrawable*, sf::Vector2f, bool)> processHover =
[&](UIDrawable* drawable, sf::Vector2f point, bool hit_allowed) {
if (!drawable || !drawable->visible) return;
bool is_inside = drawable->contains_point(mousepos.x, mousepos.y);
// Same rect get_global_bounds() builds (position + bounds SIZE), just
// expressed in parent-local space instead of accumulated global space.
sf::FloatRect b = drawable->get_bounds();
bool is_inside = hit_allowed &&
sf::FloatRect(drawable->position.x, drawable->position.y, b.width, b.height).contains(point);
bool was_hovered = drawable->hovered;
if (is_inside && !was_hovered) {
@ -367,34 +383,43 @@ void PyScene::do_mouse_hover(int x, int y)
}
}
// Process children for Frame elements
// #355 - cell enter/exit (no-op for non-grid drawables)
drawable->updateHover(point, hit_allowed);
// Process children for Frame elements (children are positioned relative
// to the frame's own position).
if (drawable->derived_type() == PyObjectsEnum::UIFRAME) {
auto frame = static_cast<UIFrame*>(drawable);
if (frame->children) {
sf::Vector2f child_point = point - frame->position;
for (auto& child : *frame->children) {
processHover(child.get());
processHover(child.get(), child_point, hit_allowed);
}
}
}
// Process children for Grid elements
else if (drawable->derived_type() == PyObjectsEnum::UIGRID) {
auto grid = static_cast<UIGrid*>(drawable);
// #142 - Update cell hover tracking for grid
// Pass "none" for button and "move" for action during hover
grid->updateCellHover(mousepos, "none", "move");
if (grid->children) {
for (auto& child : *grid->children) {
processHover(child.get());
// #355 - Grid overlay children live in GRID-WORLD PIXEL coordinates, so the
// mouse point must go through the view's camera exactly as UIGridView::click_at
// does. Children are always visited (even when the mouse is outside the view)
// so a child that was hovered still receives its on_exit.
else if (drawable->derived_type() == PyObjectsEnum::UIGRIDVIEW) {
auto view = static_cast<UIGridView*>(drawable);
if (view->children) {
sf::Vector2f local = point - view->box.getPosition();
sf::Vector2f grid_world = view->localToGridWorld(local);
bool inside_view = hit_allowed &&
sf::FloatRect(sf::Vector2f(0.f, 0.f), view->box.getSize()).contains(local);
for (auto& child : *view->children) {
processHover(child.get(), grid_world, inside_view);
}
}
}
};
// Process all top-level UI elements
// Process all top-level UI elements. in_window is false only on the #363
// mouse-left-the-window path, where it propagates down as hit_allowed=false and
// turns the whole walk into a pure exit sweep.
for (auto& element : *ui_elements) {
processHover(element.get());
processHover(element.get(), mousepos, in_window);
}
}

View file

@ -15,7 +15,17 @@ public:
void do_mouse_input(std::string, std::string);
void do_mouse_hover(int x, int y); // #140 - Mouse enter/exit tracking
void do_mouse_leave(); // #363 - cursor left the window
// Dirty flag for z_index sorting optimization
bool ui_elements_need_sort = true;
private:
// #363 - Shared hover walk. in_window=false makes it an exit-only sweep (no
// drawable may become hovered), which is what a window-leave means.
void dispatch_hover(sf::Vector2f mousepos, bool in_window);
// #363 - Last cursor position seen by do_mouse_hover, reported to the exit
// callbacks fired by do_mouse_leave (MouseLeft carries no coordinates).
sf::Vector2f last_mouse_pos{0.f, 0.f};
};

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,14 +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<UIGridView> view; // #252: auto-created rendering view (shim)
std::shared_ptr<GridData> data;
PyObject* weakreflist; // Weak reference support
} PyUIGridObject;
} PyGridDataObject;
class UISprite;
typedef struct {
@ -167,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; \
@ -328,7 +327,7 @@ static int UIDrawable_set_opacity(T* self, PyObject* value, void* closure)
MCRF_PROPERTY(rotate_with_camera, \
"Whether to rotate visually with parent Grid's camera_rotation (bool). " \
"False (default): stay screen-aligned. True: tilt with camera. " \
"Only affects children of UIGrid; ignored for other parents." \
"Only affects children of a Grid; ignored for other parents." \
), (void*)type_enum}
// #106: Shader support - GPU-accelerated visual effects

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))
@ -1127,12 +1117,15 @@ PyObject* UICollection::repr(PyUICollectionObject* self)
// Count each type
int frame_count = 0, caption_count = 0, sprite_count = 0, grid_count = 0, other_count = 0;
for (auto& item : *self->data) {
// #366: detect grid-ness with the asGridData() virtual (#355), not
// derived_type(). Every mcrfpy.Grid in a collection is a UIGRIDVIEW --
// the UIGRID arm this used to key on has not appeared in a scene graph
// since #252, so grids were tallied as "other".
if (item->asGridData()) { grid_count++; continue; }
switch(item->derived_type()) {
case PyObjectsEnum::UIFRAME: frame_count++; break;
case PyObjectsEnum::UICAPTION: caption_count++; break;
case PyObjectsEnum::UISPRITE: sprite_count++; break;
case PyObjectsEnum::UIGRID: grid_count++; break;
case PyObjectsEnum::UIGRIDVIEW: other_count++; break;
default: other_count++; break;
}
}

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;
@ -1007,29 +998,19 @@ void UIDrawable::removeFromParent() {
}
frame->children_need_sort = true;
}
else if (p->derived_type() == PyObjectsEnum::UIGRID) {
auto grid = std::static_pointer_cast<UIGrid>(p);
auto& children = *grid->children;
for (auto it = children.begin(); it != children.end(); ++it) {
if (it->get() == this) {
children.erase(it);
break;
}
}
grid->children_need_sort = true;
}
// #364: a grid child's parent is the UIGridView. The internal UIGrid (_GridData)
// holds no drawables, so it can never be a parent and needs no arm here.
else if (p->derived_type() == PyObjectsEnum::UIGRIDVIEW) {
auto view = std::static_pointer_cast<UIGridView>(p);
if (view->grid_data && view->grid_data->children) {
auto& children = *view->grid_data->children;
if (view->children) {
auto& children = *view->children;
for (auto it = children.begin(); it != children.end(); ++it) {
if (it->get() == this) {
children.erase(it);
break;
}
}
view->grid_data->children_need_sort = true;
view->children_need_sort = true;
}
}
@ -1065,15 +1046,32 @@ bool UIDrawable::contains_point(float x, float y) const {
}
// #144: Content dirty - texture needs rebuild
//
// #368 - The walk up the parent chain is UNCONDITIONAL. It used to be gated on
// `(!was_dirty || !p->render_dirty)`, which is sound only if render_dirty is reliably
// cleared once a drawable has been drawn. It is not: clearDirty() is called by the two
// classes that actually *cache* a raster (UIFrame's render-texture path, UIGridView),
// and by nobody else. For every ordinary Frame/Caption/Sprite/Line/Circle/Arc,
// render_dirty is a write-once latch -- true from the first mutation until death.
//
// With the flag stuck true, `was_dirty` was always true and the parent's render_dirty
// was always true, so the guard was always false and content invalidation propagated
// NOWHERE. A caching ancestor (Frame(cache_subtree=True), or a GridView) went on
// re-blitting a stale composite: text never updated, colors never changed. Only
// position changes survived, because markCompositeDirty (below) already walked
// unconditionally.
//
// Restoring the guard would mean making every drawable clear the flag honestly, and
// then trusting that every future drawable remembers to. The invariant here is worth
// more than the walk it saves: parent chains are shallow, and this is exactly the cost
// markCompositeDirty has always paid on every move without anyone noticing.
void UIDrawable::markContentDirty() {
bool was_dirty = render_dirty;
render_dirty = true;
composite_dirty = true; // If content changed, composite also needs update
// Propagate to parent - parent's composite is dirty (child content changed)
// Propagate if: we weren't already dirty, OR parent was cleared (rendered) since last propagation
auto p = parent.lock();
if (p && (!was_dirty || !p->render_dirty)) {
if (p) {
p->markContentDirty(); // Parent also needs to rebuild to include our changes
}
}
@ -1346,17 +1344,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;
@ -1391,9 +1378,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;
@ -1417,13 +1401,14 @@ int UIDrawable::set_parent(PyObject* self, PyObject* value, void* closure) {
return 0;
}
// Value must be a Frame, Grid, or Scene (things with children collections)
// Value must be a Frame, Grid, or Scene (things with children collections).
// #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_grid = PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType);
bool is_gridview = PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType);
bool is_scene = PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PySceneType);
if (!is_frame && !is_grid && !is_gridview && !is_scene) {
if (!is_frame && !is_gridview && !is_scene) {
PyErr_SetString(PyExc_TypeError, "parent must be a Frame, Grid, Scene, or None");
return -1;
}
@ -1468,16 +1453,10 @@ int UIDrawable::set_parent(PyObject* self, PyObject* value, void* closure) {
children_ptr = &frame->children;
new_parent = frame;
} else if (is_gridview) {
// #252: GridView (unified Grid) - access children through grid_data
// #364: overlay children belong to the view itself, not to the grid data.
auto view = ((PyUIGridViewObject*)value)->data;
if (view->grid_data) {
children_ptr = &view->grid_data->children;
}
children_ptr = &view->children;
new_parent = view;
} else if (is_grid) {
auto grid = ((PyUIGridObject*)value)->data;
children_ptr = &grid->children;
new_parent = grid;
}
if (children_ptr && *children_ptr) {
@ -1592,10 +1571,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();
@ -1637,9 +1612,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;
@ -1683,10 +1655,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();
@ -1728,9 +1696,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;
@ -1783,10 +1748,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();
@ -1828,9 +1789,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;
@ -2195,7 +2153,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 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,
@ -55,6 +56,27 @@ public:
void click_register(PyObject*);
void click_unregister();
// #355 - Grid cell input. Defaults are inert: no caller may switch on grid-ness.
//
// dispatchCellClick: called by PyScene after click_at() resolved this drawable as
// the click target. Takes no point: click_at() already resolved the cell in the
// coordinate space of the hit (parent-local), which PyScene does not have.
// Returns true if a cell callback consumed the click.
virtual bool dispatchCellClick(const std::string& button, const std::string& action)
{ (void)button; (void)action; return false; }
// updateHover: called by PyScene::do_mouse_hover for every drawable, with the
// mouse position in this drawable's PARENT-local coordinate space (the same
// convention click_at() uses; at the top level that is global space).
// hit_allowed is false when an ancestor already determined the mouse cannot be
// over this subtree -- such a call may only clear hover state, never set it.
virtual void updateHover(sf::Vector2f point, bool hit_allowed)
{ (void)point; (void)hit_allowed; }
// asGridData: non-null only for drawables that own or view grid data.
// (#357 find/findAll and #358 ImGuiSceneExplorer will use this same primitive.)
virtual GridData* asGridData() { return nullptr; }
// #140 - Mouse enter/exit callbacks
void on_enter_register(PyObject*);
void on_enter_unregister();
@ -109,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);
@ -134,7 +156,7 @@ public:
sf::Vector2f origin;
// Whether to rotate visually with parent Grid's camera_rotation
// Only affects children of UIGrid; ignored for other parents
// Only affects children of a Grid (UIGridView); ignored for other parents
bool rotate_with_camera = false;
// Parent-child hierarchy (#122)
@ -359,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");
@ -388,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,65 +810,20 @@ PyObject* UIEntity::get_grid(PyUIEntityObject* self, void* closure)
Py_RETURN_NONE;
}
auto& grid = self->data->grid;
// #252: If the grid has an owning GridView, return that instead.
// This preserves the unified Grid API where entity.grid returns the same
// object the user created via mcrfpy.Grid(...).
auto owning_view = grid->owning_view.lock();
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)
@ -920,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;
@ -1259,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;
}
@ -1278,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);
@ -1303,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"
@ -548,7 +548,16 @@ PyGetSetDef UIFrame::getsetters[] = {
"For animation, use 'outline_color.r', 'outline_color.g', etc."
), (void*)1},
{"children", (getter)UIFrame::get_children, NULL,
MCRF_PROPERTY(children, "UICollection of child drawable objects rendered on top of this frame (UICollection, read-only)."),
MCRF_PROPERTY(children,
"UICollection of child drawable objects rendered on top of this frame (UICollection, "
"read-only)."
MCRF_NOTE(
"Child positions are frame-local (relative to this Frame's top-left corner). A "
"Frame has no camera, so it cannot pan its content -- frame-local is effectively "
"screen space. Contrast with Grid.children, which are positioned in the grid's "
"pixel-world coordinates and pan/zoom with the grid camera."
)
),
NULL},
{"on_click", (getter)UIDrawable::get_click, (setter)UIDrawable::set_click,
MCRF_PROPERTY(on_click,
@ -780,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");
@ -797,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,318 +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;
// 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;
// Perspective system
std::weak_ptr<UIEntity> perspective_entity;
bool perspective_enabled;
// Cell callback firing (needs UIDrawable::is_python_subclass, serial_number)
bool fireCellClick(sf::Vector2i cell, const std::string& button, const std::string& action);
bool fireCellEnter(sf::Vector2i cell);
bool fireCellExit(sf::Vector2i cell);
void refreshCellCallbackCache(PyObject* pyObj);
// #142 - Cell coordinate conversion (needs texture for cell size)
std::optional<sf::Vector2i> screenToCell(sf::Vector2f screen_pos) const;
sf::Vector2f getEffectiveCellSize() const;
void updateCellHover(sf::Vector2f mousepos, const std::string& button, const std::string& action);
// 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_perspective(PyUIGridObject* self, void* closure);
static int set_perspective(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* get_perspective_enabled(PyUIGridObject* self, void* closure);
static int set_perspective_enabled(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* 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);
static PyObject* get_children(PyUIGridObject* self, void* closure);
static PyObject* repr(PyUIGridObject* self);
static PyObject* get_on_cell_enter(PyUIGridObject* self, void* closure);
static int set_on_cell_enter(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* get_on_cell_exit(PyUIGridObject* self, void* closure);
static int set_on_cell_exit(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* get_on_cell_click(PyUIGridObject* self, void* closure);
static int set_on_cell_click(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* get_hovered_cell(PyUIGridObject* self, void* closure);
static PyObject* get_view(PyUIGridObject* self, void* closure); // #252 shim
static PyObject* py_add_layer(PyUIGridObject* self, PyObject* args);
static PyObject* py_remove_layer(PyUIGridObject* self, PyObject* args);
static PyObject* get_layers(PyUIGridObject* self, void* closure);
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();
// Grid-specific cell callbacks (now on GridData base)
obj->data->on_cell_enter_callable.reset();
obj->data->on_cell_exit_callable.reset();
obj->data->on_cell_click_callable.reset();
}
obj->view.reset(); // #252: release GridView shim
obj->data.reset();
Py_TYPE(self)->tp_free(self);
},
.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"
" perspective (int): Entity perspective index (-1 for omniscient). Default: -1\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"
" perspective (int): Entity perspective index\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);
}
if (obj->data->on_cell_enter_callable) {
PyObject* callback = obj->data->on_cell_enter_callable->borrow();
if (callback && callback != Py_None) Py_VISIT(callback);
}
if (obj->data->on_cell_exit_callable) {
PyObject* callback = obj->data->on_cell_exit_callable->borrow();
if (callback && callback != Py_None) Py_VISIT(callback);
}
if (obj->data->on_cell_click_callable) {
PyObject* callback = obj->data->on_cell_click_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();
obj->data->on_cell_enter_callable.reset();
obj->data->on_cell_exit_callable.reset();
obj->data->on_cell_click_callable.reset();
}
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>();
// Placement-new the shared_ptr<UIGridView> (tp_alloc zero-fills, not construct)
new (&self->view) std::shared_ptr<UIGridView>();
}
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,611 +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 (self->view) {
if (member_ptr == 0)
self->view->box.setPosition(val, self->view->box.getPosition().y);
else if (member_ptr == 1)
self->view->box.setPosition(self->view->box.getPosition().x, val);
else if (member_ptr == 2)
self->view->box.setSize(sf::Vector2f(val, self->view->box.getSize().y));
else if (member_ptr == 3)
self->view->box.setSize(sf::Vector2f(self->view->box.getSize().x, val));
else if (member_ptr == 4) self->view->center_x = val;
else if (member_ptr == 5) self->view->center_y = val;
else if (member_ptr == 6) self->view->zoom = val;
else if (member_ptr == 7) self->view->camera_rotation = val;
self->view->position = self->view->box.getPosition();
}
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;
}
// =========================================================================
// Perspective properties
// =========================================================================
PyObject* UIGrid::get_perspective(PyUIGridObject* self, void* closure)
{
auto locked = self->data->perspective_entity.lock();
if (locked) {
if (locked->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(locked->serial_number);
if (cached) {
return cached;
}
}
auto type = &mcrfpydef::PyUIEntityType;
auto o = (PyUIEntityObject*)type->tp_alloc(type, 0);
if (o) {
o->data = locked;
o->weakreflist = NULL;
return (PyObject*)o;
}
}
Py_RETURN_NONE;
}
int UIGrid::set_perspective(PyUIGridObject* self, PyObject* value, void* closure)
{
if (value == Py_None) {
self->data->perspective_entity.reset();
self->data->markDirty();
return 0;
}
if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIEntityType)) {
PyErr_SetString(PyExc_TypeError, "perspective must be a UIEntity or None");
return -1;
}
PyUIEntityObject* entity_obj = (PyUIEntityObject*)value;
self->data->perspective_entity = entity_obj->data;
self->data->perspective_enabled = true;
self->data->markDirty();
return 0;
}
PyObject* UIGrid::get_perspective_enabled(PyUIGridObject* self, void* closure)
{
return PyBool_FromLong(self->data->perspective_enabled);
}
int UIGrid::set_perspective_enabled(PyUIGridObject* self, PyObject* value, void* closure)
{
int enabled = PyObject_IsTrue(value);
if (enabled == -1) {
return -1;
}
self->data->perspective_enabled = enabled;
self->data->markDirty();
return 0;
}
// =========================================================================
// FOV properties
// =========================================================================
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;
}
PyObject* UIGrid::get_children(PyUIGridObject* self, void* closure)
{
PyTypeObject* type = &mcrfpydef::PyUICollectionType;
auto o = (PyUICollectionObject*)type->tp_alloc(type, 0);
if (o) {
o->data = self->data->children;
o->owner = self->data;
}
return (PyObject*)o;
}
PyObject* UIGrid::get_view(PyUIGridObject* self, void* closure)
{
if (!self->view) Py_RETURN_NONE;
auto type = &mcrfpydef::PyUIGridViewType;
auto obj = (PyUIGridViewObject*)type->tp_alloc(type, 0);
if (!obj) return PyErr_NoMemory();
obj->data = self->view;
obj->weakreflist = NULL;
return (PyObject*)obj;
}
PyObject* UIGrid::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");
}
// =========================================================================
// Cell callback properties
// =========================================================================
PyObject* UIGrid::get_on_cell_enter(PyUIGridObject* self, void* closure) {
if (self->data->on_cell_enter_callable) {
PyObject* cb = self->data->on_cell_enter_callable->borrow();
Py_INCREF(cb);
return cb;
}
Py_RETURN_NONE;
}
int UIGrid::set_on_cell_enter(PyUIGridObject* self, PyObject* value, void* closure) {
if (value == Py_None) {
self->data->on_cell_enter_callable.reset();
} else {
self->data->on_cell_enter_callable = std::make_unique<PyCellHoverCallable>(value);
}
return 0;
}
PyObject* UIGrid::get_on_cell_exit(PyUIGridObject* self, void* closure) {
if (self->data->on_cell_exit_callable) {
PyObject* cb = self->data->on_cell_exit_callable->borrow();
Py_INCREF(cb);
return cb;
}
Py_RETURN_NONE;
}
int UIGrid::set_on_cell_exit(PyUIGridObject* self, PyObject* value, void* closure) {
if (value == Py_None) {
self->data->on_cell_exit_callable.reset();
} else {
self->data->on_cell_exit_callable = std::make_unique<PyCellHoverCallable>(value);
}
return 0;
}
PyObject* UIGrid::get_on_cell_click(PyUIGridObject* self, void* closure) {
if (self->data->on_cell_click_callable) {
PyObject* cb = self->data->on_cell_click_callable->borrow();
Py_INCREF(cb);
return cb;
}
Py_RETURN_NONE;
}
int UIGrid::set_on_cell_click(PyUIGridObject* self, PyObject* value, void* closure) {
if (value == Py_None) {
self->data->on_cell_click_callable.reset();
} else {
self->data->on_cell_click_callable = std::make_unique<PyClickCallable>(value);
}
return 0;
}
PyObject* UIGrid::get_hovered_cell(PyUIGridObject* self, void* closure) {
if (self->data->hovered_cell.has_value()) {
return Py_BuildValue("(ii)", self->data->hovered_cell->x, self->data->hovered_cell->y);
}
Py_RETURN_NONE;
}
// =========================================================================
// 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},
{"children", (getter)UIGrid::get_children, NULL,
MCRF_PROPERTY(children, "UICollection of UIDrawable children such as speech bubbles, effects, and overlays (UICollection, read-only)."), NULL},
{"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},
{"perspective", (getter)UIGrid::get_perspective, (setter)UIGrid::set_perspective,
MCRF_PROPERTY(perspective,
"Entity whose perspective to use for FOV rendering (Entity | None). "
"Setting an entity automatically enables perspective mode. "
"Set to None for omniscient view."
), NULL},
{"perspective_enabled", (getter)UIGrid::get_perspective_enabled, (setter)UIGrid::set_perspective_enabled,
MCRF_PROPERTY(perspective_enabled,
"Whether to use perspective-based FOV rendering (bool). "
"When True with no valid entity, all cells appear undiscovered."
), 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),
{"on_cell_enter", (getter)UIGrid::get_on_cell_enter, (setter)UIGrid::set_on_cell_enter,
MCRF_PROPERTY(on_cell_enter, "Callback when mouse enters a grid cell (Callable | None). Called with (cell_pos: Vector)."), NULL},
{"on_cell_exit", (getter)UIGrid::get_on_cell_exit, (setter)UIGrid::set_on_cell_exit,
MCRF_PROPERTY(on_cell_exit, "Callback when mouse exits a grid cell (Callable | None). Called with (cell_pos: Vector)."), NULL},
{"on_cell_click", (getter)UIGrid::get_on_cell_click, (setter)UIGrid::set_on_cell_click,
MCRF_PROPERTY(on_cell_click, "Callback when a grid cell is clicked (Callable | None). Called with (cell_pos: Vector, button: MouseButton, action: InputState)."), NULL},
{"hovered_cell", (getter)UIGrid::get_hovered_cell, NULL,
MCRF_PROPERTY(hovered_cell, "Currently hovered cell as (x, y) tuple, or None if not hovering (tuple | None, read-only)."), NULL},
UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UIGRID),
{"view", (getter)UIGrid::get_view, NULL,
MCRF_PROPERTY(view,
"Auto-created GridView for rendering (GridView | None, read-only). "
"When Grid is appended to a scene, this view is what actually renders."
), NULL},
{NULL}
};

File diff suppressed because it is too large Load diff

View file

@ -41,6 +41,7 @@ public:
sf::FloatRect get_bounds() const override;
void move(float dx, float dy) override;
void resize(float w, float h) override;
void onPositionChanged() override; // #355: keep box in sync with position
// The grid data this view renders
std::shared_ptr<GridData> grid_data;
@ -53,6 +54,20 @@ public:
// is a different object/control block than the view's own PyObject.
PyObject* cached_grid_wrapper = nullptr;
// =====================================================================
// #364 - Overlay children (speech bubbles, markers, range indicators).
// Owned by the VIEW, not GridData: a child is an annotation drawn over the
// map through THIS camera, not world content. Two views over one GridData
// therefore have independent children (a minimap does not want the main
// view's speech bubbles) while sharing every entity.
//
// Positions are in GRID-WORLD PIXEL coordinates (#360) -- not view-local,
// not screen -- so panning the camera does not move a child's logical
// position. The view's camera maps them to screen at render/hit-test time.
// =====================================================================
std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>> children;
bool children_need_sort = true;
// Rendering state (independent per view)
std::shared_ptr<PyTexture> ptex;
sf::RectangleShape box;
@ -64,6 +79,42 @@ public:
std::weak_ptr<UIEntity> perspective_entity;
bool perspective_enabled = false;
// =====================================================================
// #355 - Cell input. Owned by the VIEW, not GridData: two views over one
// GridData must each track their own hover cell (a shared hovered_cell
// would ping-pong exit/enter between views), and the subclass-dispatch
// path must resolve against the VIEW's serial_number, because the public
// subclassable type (mcrfpy.Grid) IS UIGridView.
// =====================================================================
std::unique_ptr<PyCellHoverCallable> on_cell_enter_callable;
std::unique_ptr<PyCellHoverCallable> on_cell_exit_callable;
std::unique_ptr<PyClickCallable> on_cell_click_callable;
std::optional<sf::Vector2i> hovered_cell; // Python-visible (read-only)
std::optional<sf::Vector2i> last_clicked_cell; // C++ only: click_at -> dispatchCellClick
struct CellCallbackCache {
uint32_t generation = 0;
bool valid = false;
bool has_on_cell_click = false;
bool has_on_cell_enter = false;
bool has_on_cell_exit = false;
};
CellCallbackCache cell_callback_cache;
bool fireCellClick(sf::Vector2i cell, const std::string& button, const std::string& action);
bool fireCellEnter(sf::Vector2i cell);
bool fireCellExit(sf::Vector2i cell);
void refreshCellCallbackCache(PyObject* pyObj);
// UIDrawable input virtuals (#355)
bool dispatchCellClick(const std::string& button, const std::string& action) override;
void updateHover(sf::Vector2f point, bool hit_allowed) override;
GridData* asGridData() override { return grid_data.get(); }
// Cell math. local_point is relative to the widget's top-left.
sf::Vector2f localToGridWorld(sf::Vector2f local_point) const;
std::optional<sf::Vector2i> cellAtLocal(sf::Vector2f local_point) const;
// #351 - clean-state render early-out cache. These record the inputs the
// current RenderTexture was rasterized from; render() re-blits the cached
// texture instead of clear+redraw when none changed. Camera params are
@ -76,6 +127,7 @@ public:
sf::Vector2f last_box_size{-1.f, -1.f};
sf::Color last_fill_color{0, 0, 0, 0};
sf::Vector2u last_render_tex_size{0, 0};
bool last_perspective_enabled = false; // #355: fog baked into the cached raster
// Render textures
sf::Sprite sprite_proto, output;
@ -96,8 +148,9 @@ public:
void center_camera();
void center_camera(float tile_x, float tile_y);
// Cell coordinate conversion
std::optional<sf::Vector2i> screenToCell(sf::Vector2f screen_pos) const;
// #355: no screenToCell() -- hover/click both arrive in parent-local space and
// go through cellAtLocal(). A global-coords helper would be a second, wrong
// coordinate path for grids nested inside frames/grids.
sf::Vector2f getEffectiveCellSize() const;
static constexpr int DEFAULT_CELL_WIDTH = 16;
@ -107,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
@ -124,12 +177,35 @@ 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);
static PyObject* get_float_member_gv(PyUIGridViewObject* self, void* closure);
static int set_float_member_gv(PyUIGridViewObject* self, PyObject* value, void* closure);
// #364 - overlay children (moved from GridData)
static PyObject* get_children(PyUIGridViewObject* self, void* closure);
// #355 - cell callback properties (moved from UIGrid)
static PyObject* get_on_cell_enter(PyUIGridViewObject* self, void* closure);
static int set_on_cell_enter(PyUIGridViewObject* self, PyObject* value, void* closure);
static PyObject* get_on_cell_exit(PyUIGridViewObject* self, void* closure);
static int set_on_cell_exit(PyUIGridViewObject* self, PyObject* value, void* closure);
static PyObject* get_on_cell_click(PyUIGridViewObject* self, void* closure);
static int set_on_cell_click(PyUIGridViewObject* self, PyObject* value, void* closure);
static PyObject* get_hovered_cell(PyUIGridViewObject* self, void* closure);
// #355 - perspective properties (moved from UIGrid: the view renders the overlay)
static PyObject* get_perspective(PyUIGridViewObject* self, void* closure);
static int set_perspective(PyUIGridViewObject* self, PyObject* value, void* closure);
static PyObject* get_perspective_enabled(PyUIGridViewObject* self, void* closure);
static int set_perspective_enabled(PyUIGridViewObject* self, PyObject* value, void* closure);
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
@ -149,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)
@ -159,17 +237,16 @@ namespace mcrfpydef {
if (obj->weakreflist != NULL) {
PyObject_ClearWeakRefs(self);
}
// Clear owning_view back-reference before releasing grid_data --
// but only when this wrapper is the LAST owner of the view (#251
// pattern, mirrors PyUIGridType) AND the dying view is actually
// the one owning_view points at. The previous ungated reset
// severed the back-reference whenever ANY Python wrapper was
// #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 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
// dirty notifications.
if (obj->data && obj->data->grid_data && obj->data.use_count() <= 1 &&
obj->data->grid_data->owning_view.lock() == obj->data) {
obj->data->grid_data->owning_view.reset();
// dirty notifications. unregisterView matches by identity, so it's
// harmless (a no-op) if this view was never registered.
if (obj->data && obj->data->grid_data && obj->data.use_count() <= 1) {
obj->data->grid_data->unregisterView(obj->data.get());
}
obj->data.reset();
Py_TYPE(self)->tp_free(self);
@ -212,6 +289,21 @@ namespace mcrfpydef {
if (obj->data && obj->data->cached_grid_wrapper) {
Py_VISIT(obj->data->cached_grid_wrapper);
}
// #355: cell callbacks now live on the view.
if (obj->data) {
if (obj->data->on_cell_enter_callable) {
PyObject* cb = obj->data->on_cell_enter_callable->borrow();
if (cb && cb != Py_None) Py_VISIT(cb);
}
if (obj->data->on_cell_exit_callable) {
PyObject* cb = obj->data->on_cell_exit_callable->borrow();
if (cb && cb != Py_None) Py_VISIT(cb);
}
if (obj->data->on_cell_click_callable) {
PyObject* cb = obj->data->on_cell_click_callable->borrow();
if (cb && cb != Py_None) Py_VISIT(cb);
}
}
return 0;
},
.tp_clear = [](PyObject* self) -> int {
@ -220,6 +312,10 @@ namespace mcrfpydef {
obj->data->click_unregister();
// #348: drop the persistent wrapper; get_grid rebuilds on demand.
Py_CLEAR(obj->data->cached_grid_wrapper);
// #355: release cell callbacks (breaks closure->grid cycles).
obj->data->on_cell_enter_callable.reset();
obj->data->on_cell_exit_callable.reset();
obj->data->on_cell_click_callable.reset();
}
return 0;
},

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

@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Regression test for #355 - cell hit testing under camera_rotation.
UIGridView::localToGridWorld() rasterizes-then-rotates in render(), but the hit
test used to skip the rotation entirely (and used the widget size instead of the
rotated AABB). With camera_rotation != 0 every cell handed to on_cell_click /
on_cell_enter / on_cell_exit / hovered_cell was the cell that WOULD be under the
mouse if the camera were unrotated -- plausible-looking, silently wrong data.
Before #355 all grid input was dead, so this ships as new behavior; it has to be
right.
GROUND TRUTH IS THE RENDERED IMAGE, not a re-derivation of the camera math: one
cell is painted pure red by a ColorLayer, the frame is screenshotted, the red
pixels are located by decoding the PNG, and the click is aimed at their centroid.
The cell reported back by on_cell_click must be the red cell.
"""
import sys
import os
import zlib
import struct
import tempfile
import mcrfpy
from mcrfpy import automation
CELL = 16
TMPDIR = tempfile.mkdtemp(prefix="mcrf355rot_")
FAILURES = []
def check(cond, msg):
if not cond:
print("FAIL: %s" % msg, file=sys.stderr)
FAILURES.append(msg)
# --------------------------------------------------------------------------- #
# Minimal PNG reader (stdlib only): 8-bit RGB/RGBA, non-interlaced -- what the
# engine's screenshot encoder emits.
# --------------------------------------------------------------------------- #
def read_png(path):
with open(path, "rb") as f:
data = f.read()
assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG"
pos = 8
idat = b""
width = height = depth = color = None
while pos < len(data):
(length,) = struct.unpack(">I", data[pos:pos + 4])
ctype = data[pos + 4:pos + 8]
chunk = data[pos + 8:pos + 8 + length]
pos += 12 + length
if ctype == b"IHDR":
width, height, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", chunk)
assert depth == 8, "unexpected bit depth %d" % depth
assert color in (2, 6), "unexpected color type %d" % color
assert interlace == 0, "interlaced PNG not supported"
elif ctype == b"IDAT":
idat += chunk
elif ctype == b"IEND":
break
raw = zlib.decompress(idat)
channels = 3 if color == 2 else 4
stride = width * channels
out = bytearray(height * stride)
prev = bytearray(stride)
p = 0
for y in range(height):
filt = raw[p]
p += 1
line = bytearray(raw[p:p + stride])
p += stride
if filt == 1: # Sub
for i in range(channels, stride):
line[i] = (line[i] + line[i - channels]) & 0xFF
elif filt == 2: # Up
for i in range(stride):
line[i] = (line[i] + prev[i]) & 0xFF
elif filt == 3: # Average
for i in range(stride):
a = line[i - channels] if i >= channels else 0
line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF
elif filt == 4: # Paeth
for i in range(stride):
a = line[i - channels] if i >= channels else 0
b = prev[i]
c = prev[i - channels] if i >= channels else 0
pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c)
pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
line[i] = (line[i] + pr) & 0xFF
elif filt != 0:
raise AssertionError("bad PNG filter %d" % filt)
out[y * stride:(y + 1) * stride] = line
prev = line
return width, height, channels, bytes(out)
def red_centroid(path):
"""Centroid of pure-red pixels in the screenshot, or None."""
w, h, ch, px = read_png(path)
sx = sy = n = 0
for y in range(h):
row = y * w * ch
for x in range(w):
i = row + x * ch
if px[i] > 200 and px[i + 1] < 60 and px[i + 2] < 60:
sx += x
sy += y
n += 1
if n == 0:
return None
return (sx / n, sy / n, n)
def build_scene(rotation):
scene = mcrfpy.Scene("rot%d" % int(rotation))
scene.activate()
# 5x5 cells of 16px = 80x80 of content; widget is 80x80 and the camera is
# centered, so content exactly fills the widget at zoom 1.
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(80, 80))
scene.children.append(grid)
grid.center = (40, 40)
grid.zoom = 1.0
layer = mcrfpy.ColorLayer(z_index=-1, grid_size=(5, 5))
grid.add_layer(layer)
for x in range(5):
for y in range(5):
layer.set((x, y), mcrfpy.Color(20, 20, 20))
layer.set(RED_CELL, mcrfpy.Color(255, 0, 0))
grid.camera_rotation = rotation
return scene, grid
RED_CELL = (0, 1) # off-center and off-diagonal: distinguishable under any rotation
def run_case(rotation):
scene, grid = build_scene(rotation)
clicks = []
grid.on_cell_click = lambda pos, button, action: clicks.append((int(pos.x), int(pos.y)))
path = os.path.join(TMPDIR, "rot%d.png" % int(rotation))
automation.screenshot(path) # headless screenshot is synchronous (#153)
c = red_centroid(path)
check(c is not None, "[rot=%s] red marker cell was not rendered at all" % rotation)
if c is None:
return
cx, cy, n = c
check(n > 100, "[rot=%s] red marker too small (%d px) to aim at" % (rotation, n))
automation.click((int(round(cx)), int(round(cy))))
check(len(clicks) >= 1,
"[rot=%s] clicking the visible red cell fired no on_cell_click" % rotation)
if clicks:
check(clicks[0] == RED_CELL,
"[rot=%s] clicked the pixel where cell %s is DRAWN (%.1f,%.1f); "
"on_cell_click reported %s" % (rotation, RED_CELL, cx, cy, clicks[0]))
automation.moveTo((int(round(cx)), int(round(cy))))
check(grid.hovered_cell is not None and tuple(grid.hovered_cell) == RED_CELL,
"[rot=%s] hovered_cell over the drawn red cell should be %s, got %s"
% (rotation, RED_CELL, grid.hovered_cell))
def main():
for rotation in (0.0, 90.0, 180.0, 270.0, 45.0):
run_case(rotation)
if FAILURES:
print("\n%d FAILURE(S)" % len(FAILURES), file=sys.stderr)
sys.exit(1)
print("PASS")
sys.exit(0)
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception as e:
print("\nTEST CRASHED: %s" % e, file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)

View file

@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Regression test for #355: grid cell input dispatch (click + hover).
Before #355, cell input was owned by GridData/UIGrid and PyScene gated it on
derived_type() == UIGRID. But mcrfpy.Grid IS a UIGridView (UIGRIDVIEW), so every
one of those gates was dead: on_cell_click / on_cell_enter / on_cell_exit /
hovered_cell never fired for any user-reachable Grid.
Input now lives on UIGridView (the object that owns the camera), dispatched via
the UIDrawable::dispatchCellClick / updateHover virtuals.
automation.click()/moveTo() reach PyScene::do_mouse_input / do_mouse_hover
synchronously in headless mode, so no mcrfpy.step() is needed for dispatch.
NOT covered (deliberately): entity clicks. UIEntity exposes no on_click/sprite
binding, so entity->sprite.click_callable is unreachable from Python. The entity
branch of UIGridView::click_at is implemented but dead until that binding exists.
"""
import sys
import mcrfpy
from mcrfpy import automation
CELL = 16 # default texture cell size (px)
FAILURES = []
def check(cond, msg):
if not cond:
print(f"FAIL: {msg}", file=sys.stderr)
FAILURES.append(msg)
def cell_tuple(hovered):
"""hovered_cell as a comparable tuple; None stays None (never raises)."""
return None if hovered is None else tuple(hovered)
def screen_of_cell(grid, cx, cy, zoom=1.0):
"""Screen coords of the center of cell (cx, cy), using the view's own camera
formula: left_spritepixels = center_x - w/(2*zoom); screen = pos + (gw - lsp)*zoom
"""
ox, oy = grid.x, grid.y
w, h = grid.w, grid.h
left_sp = int(grid.center_x - (w / 2.0 / zoom))
top_sp = int(grid.center_y - (h / 2.0 / zoom))
gwx = (cx + 0.5) * CELL
gwy = (cy + 0.5) * CELL
return (ox + (gwx - left_sp) * zoom, oy + (gwy - top_sp) * zoom)
def case_a_property_click():
scene = mcrfpy.Scene("a_click")
scene.activate()
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(grid)
grid.center_camera((2.5, 2.5))
events = []
grid.on_cell_click = lambda pos, button, action: events.append((pos.x, pos.y, button, action))
for cell in [(0, 0), (2, 3), (4, 4)]:
events.clear()
automation.click(screen_of_cell(grid, *cell))
check(len(events) >= 1, f"[a] on_cell_click did not fire for cell {cell}")
if not events:
continue
cx, cy, button, action = events[0]
check((cx, cy) == (float(cell[0]), float(cell[1])),
f"[a] expected cell {cell}, got ({cx},{cy})")
check(button == mcrfpy.MouseButton.LEFT, f"[a] expected LEFT, got {button}")
check(action == mcrfpy.InputState.PRESSED, f"[a] expected PRESSED first, got {action}")
def case_b_panned_zoomed():
scene = mcrfpy.Scene("b_zoom")
scene.activate()
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(100, 100), size=(200, 200))
scene.children.append(grid)
grid.zoom = 2.0
grid.center = (7 * CELL, 5 * CELL) # pan the camera off the default
events = []
grid.on_cell_click = lambda pos, button, action: events.append((pos.x, pos.y))
for cell in [(6, 4), (7, 5), (8, 6)]:
events.clear()
automation.click(screen_of_cell(grid, *cell, zoom=2.0))
check(len(events) >= 1, f"[b] on_cell_click did not fire for panned+zoomed cell {cell}")
if events:
check(events[0] == (float(cell[0]), float(cell[1])),
f"[b] panned+zoomed: expected cell {cell}, got {events[0]}")
def case_c_hover():
scene = mcrfpy.Scene("c_hover")
scene.activate()
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(grid)
entered, exited = [], []
grid.on_cell_enter = lambda pos: entered.append((pos.x, pos.y))
grid.on_cell_exit = lambda pos: exited.append((pos.x, pos.y))
A, B = (1, 1), (3, 2)
automation.moveTo(screen_of_cell(grid, *A))
check(entered == [(1.0, 1.0)], f"[c] expected one enter for {A}, got {entered}")
check(cell_tuple(grid.hovered_cell) == A, f"[c] hovered_cell should be {A}, got {grid.hovered_cell}")
entered.clear()
automation.moveTo(screen_of_cell(grid, *B))
check(exited == [(1.0, 1.0)], f"[c] expected exit for {A}, got {exited}")
check(entered == [(3.0, 2.0)], f"[c] expected enter for {B}, got {entered}")
check(cell_tuple(grid.hovered_cell) == B, f"[c] hovered_cell should be {B}, got {grid.hovered_cell}")
entered.clear()
exited.clear()
automation.moveTo((10, 10)) # outside the grid
check(exited == [(3.0, 2.0)], f"[c] expected exit for {B} on leaving grid, got {exited}")
check(entered == [], f"[c] no enter expected outside the grid, got {entered}")
check(grid.hovered_cell is None, f"[c] hovered_cell should be None outside, got {grid.hovered_cell}")
def case_d_subclass():
calls = []
class MyGrid(mcrfpy.Grid):
def on_cell_click(self, pos, button, action):
calls.append(("click", pos.x, pos.y))
def on_cell_enter(self, pos):
calls.append(("enter", pos.x, pos.y))
def on_cell_exit(self, pos):
calls.append(("exit", pos.x, pos.y))
scene = mcrfpy.Scene("d_subclass")
scene.activate()
grid = MyGrid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(grid)
automation.moveTo(screen_of_cell(grid, 1, 1))
automation.moveTo(screen_of_cell(grid, 2, 2))
automation.click(screen_of_cell(grid, 2, 2))
check(("enter", 1.0, 1.0) in calls, f"[d] subclass on_cell_enter(1,1) did not fire: {calls}")
check(("exit", 1.0, 1.0) in calls, f"[d] subclass on_cell_exit(1,1) did not fire: {calls}")
check(("enter", 2.0, 2.0) in calls, f"[d] subclass on_cell_enter(2,2) did not fire: {calls}")
check(("click", 2.0, 2.0) in calls, f"[d] subclass on_cell_click(2,2) did not fire: {calls}")
def case_e_child_consumes_click():
scene = mcrfpy.Scene("e_child")
scene.activate()
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(grid)
cell_clicks, frame_clicks = [], []
grid.on_cell_click = lambda pos, button, action: cell_clicks.append((pos.x, pos.y))
# #360: grid children are positioned in GRID-WORLD PIXEL coordinates.
gx, gy = 3, 1
frame = mcrfpy.Frame(pos=(gx * CELL, gy * CELL), size=(CELL, CELL))
frame.on_click = lambda pos, button, action: frame_clicks.append((pos.x, pos.y))
grid.children.append(frame)
automation.click(screen_of_cell(grid, gx, gy))
check(len(frame_clicks) >= 1,
f"[e] grid child's on_click did not fire (grid-world child coords broken?): {frame_clicks}")
check(cell_clicks == [],
f"[e] child consumed the click, so on_cell_click must NOT fire; got {cell_clicks}")
# And a click on a cell NOT covered by the child still reaches on_cell_click.
automation.click(screen_of_cell(grid, 0, 4))
check(cell_clicks and cell_clicks[0] == (0.0, 4.0),
f"[e] cell outside the child should still fire on_cell_click; got {cell_clicks}")
def case_f_two_views_one_griddata():
scene = mcrfpy.Scene("f_views")
scene.activate()
g = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(g)
v2 = mcrfpy.GridView(grid=g.grid_data, pos=(400, 100), size=(200, 200))
scene.children.append(v2)
g_events, v_events = [], []
g.on_cell_enter = lambda pos: g_events.append(("enter", pos.x, pos.y))
g.on_cell_exit = lambda pos: g_events.append(("exit", pos.x, pos.y))
v2.on_cell_enter = lambda pos: v_events.append(("enter", pos.x, pos.y))
v2.on_cell_exit = lambda pos: v_events.append(("exit", pos.x, pos.y))
# Hover over v2 only.
automation.moveTo(screen_of_cell(v2, 2, 2))
check(v_events == [("enter", 2.0, 2.0)], f"[f] v2 should have entered (2,2); got {v_events}")
check(g_events == [], f"[f] hovering v2 must not fire events on g; got {g_events}")
check(g.hovered_cell is None, f"[f] g.hovered_cell must stay None; got {g.hovered_cell}")
check(cell_tuple(v2.hovered_cell) == (2, 2), f"[f] v2.hovered_cell should be (2,2); got {v2.hovered_cell}")
# Move to g: v2 exits, g enters. No spurious cross-talk.
v_events.clear()
g_events.clear()
automation.moveTo(screen_of_cell(g, 1, 3))
check(v_events == [("exit", 2.0, 2.0)], f"[f] v2 should have exited (2,2); got {v_events}")
check(g_events == [("enter", 1.0, 3.0)], f"[f] g should have entered (1,3); got {g_events}")
check(v2.hovered_cell is None, f"[f] v2.hovered_cell should be None now; got {v2.hovered_cell}")
check(cell_tuple(g.hovered_cell) == (1, 3), f"[f] g.hovered_cell should be (1,3); got {g.hovered_cell}")
# Back to v2: each view tracks its own hover, no ping-pong on the other.
v_events.clear()
g_events.clear()
automation.moveTo(screen_of_cell(v2, 2, 2))
check(v_events == [("enter", 2.0, 2.0)], f"[f] v2 re-enter (2,2) expected; got {v_events}")
check(g_events == [("exit", 1.0, 3.0)], f"[f] g exit (1,3) expected; got {g_events}")
def case_g_child_hover():
"""Hover must reach grid children, exactly as click already does.
do_mouse_hover used to recurse only into UIFRAME children, so a drawable in
grid.children could receive on_click but never on_enter/on_exit/on_move, and
its .hovered flag was stuck False -- callbacks that exist on the API surface
but can never fire. The hover walk now descends through the view's camera
(grid-world pixel coords), the same transform click_at uses.
"""
scene = mcrfpy.Scene("g_child_hover")
scene.activate()
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(grid)
entered, exited, moved = [], [], []
gx, gy = 3, 1
frame = mcrfpy.Frame(pos=(gx * CELL, gy * CELL), size=(CELL, CELL)) # grid-world coords
frame.on_enter = lambda pos: entered.append((pos.x, pos.y))
frame.on_exit = lambda pos: exited.append((pos.x, pos.y))
frame.on_move = lambda pos: moved.append((pos.x, pos.y))
grid.children.append(frame)
# Mouse onto the cell the child covers.
automation.moveTo(screen_of_cell(grid, gx, gy))
check(len(entered) == 1, f"[g] grid child's on_enter did not fire: {entered}")
check(frame.hovered is True, f"[g] grid child's .hovered should be True, got {frame.hovered}")
check(len(moved) == 1, f"[g] grid child's on_move did not fire while inside: {moved}")
# Mouse to a different cell inside the grid but outside the child.
automation.moveTo(screen_of_cell(grid, 0, 4))
check(len(exited) == 1, f"[g] grid child's on_exit did not fire on leaving it: {exited}")
check(frame.hovered is False, f"[g] grid child's .hovered should be False, got {frame.hovered}")
# Re-enter, then leave the grid widget entirely: the child must still exit,
# and must NOT be re-entered by a point outside the view that happens to map
# into grid-world space over the child.
entered.clear()
exited.clear()
automation.moveTo(screen_of_cell(grid, gx, gy))
check(len(entered) == 1, f"[g] grid child re-enter expected, got {entered}")
automation.moveTo((10, 10)) # far outside the grid widget
check(len(exited) == 1, f"[g] leaving the grid must exit the hovered child: {exited}")
check(frame.hovered is False, "[g] child still hovered after mouse left the grid")
# Panning the camera moves the child under a DIFFERENT screen pixel; hover
# must follow the camera (the whole point of routing through localToGridWorld).
entered.clear()
grid.zoom = 2.0
grid.center = (gx * CELL, gy * CELL) # child centered in the widget
automation.moveTo((int(grid.x + grid.w / 2), int(grid.y + grid.h / 2)))
check(len(entered) == 1,
f"[g] after pan+zoom, hover did not track the child through the camera: {entered}")
check(frame.hovered is True, "[g] child not hovered at its new on-screen position")
def main():
case_a_property_click()
case_b_panned_zoomed()
case_c_hover()
case_d_subclass()
case_e_child_consumes_click()
case_f_two_views_one_griddata()
case_g_child_hover()
if FAILURES:
print(f"\n{len(FAILURES)} FAILURE(S)", file=sys.stderr)
sys.exit(1)
print("PASS")
sys.exit(0)
if __name__ == "__main__":
# --exec does NOT turn an unhandled exception into a non-zero exit code, so a
# crashing test would otherwise report success. Translate explicitly.
try:
main()
except SystemExit:
raise
except Exception as e:
print(f"\nTEST CRASHED: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)

View file

@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Regression test for #355 - mcrfpy.Grid input is entirely dead.
Since the #252 Grid/GridView split, mcrfpy.Grid(...) constructs a
UIGridView (PyObjectsEnum::UIGRIDVIEW). Every input dispatch site that
matters for grid cell/child/entity interactivity (PyScene::do_mouse_input,
PyScene::do_mouse_hover's processHover, and the subclass-method lookup in
UIGrid::fireCellClick) still gates on derived_type() == UIGRID -- the
*internal* _GridData type nothing user-visible is ever an instance of. As a
result:
- on_cell_click / on_cell_enter / on_cell_exit never fire on a real Grid.
- Child drawables appended to grid.children never receive their own
on_click.
- A Python subclass of mcrfpy.Grid that overrides on_cell_click as a
*method* (rather than assigning the on_cell_click property) is doubly
broken: is_python_subclass/serial_number tracking for that lookup is
keyed off the internal _GridData wrapper, not the public Grid/GridView
instance a subclass is actually built from.
Each check below is a hard assertion (sys.exit via unhandled AssertionError
on failure) -- no "PARTIAL" outs.
Coordinate math: with an unmodified camera and an explicit `size=`,
UIGridView's default center is size/(2*zoom) == the viewport's own
half-width/half-height, which cancels the half-width term in the
screen->cell formula, so grid_space == local screen-relative offset
whenever zoom==1. cell = floor(grid_space / cell_pixel_size), with the
16x16 default texture. Case (b) explicitly overrides grid.center/grid.zoom
to non-default values and uses the full formula
(grid_space = (local - half_width) / zoom + center_x) so it is a genuine
discriminator against a fix that reads the wrong (stale, internal
_GridData-owned) camera copy instead of the public Grid/GridView's own
center_x/center_y/zoom.
"""
import sys
import mcrfpy
from mcrfpy import automation
def test_a_plain_cell_click():
"""(a) on_cell_click fires with the correct cell for a click on a plain Grid."""
print("(a) plain grid cell click...")
scene = mcrfpy.Scene("issue355_a")
ui = scene.children
mcrfpy.current_scene = scene
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
ui.append(grid)
clicks = []
def on_cell_click(cell_pos, button, action):
clicks.append((cell_pos.x, cell_pos.y, button, action))
grid.on_cell_click = on_cell_click
# local=(36,52) -> cell (2,3), see module docstring for the math.
automation.click((136, 152))
mcrfpy.step(0.05)
assert len(clicks) >= 1, f"on_cell_click never fired; clicks={clicks}"
cx, cy, button, action = clicks[0]
assert (cx, cy) == (2, 3), f"expected cell (2,3), got ({cx},{cy})"
assert button == mcrfpy.MouseButton.LEFT
assert action == mcrfpy.InputState.PRESSED
print(" OK")
def test_b_panned_zoomed_cell_click():
"""(b) on_cell_click reports the CORRECT cell after grid.center/grid.zoom
are set to non-default values -- discriminates against a fix that reads
a stale internal camera copy instead of the public Grid's own."""
print("(b) panned+zoomed grid cell click...")
scene = mcrfpy.Scene("issue355_b")
ui = scene.children
mcrfpy.current_scene = scene
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(50, 50), size=(200, 200))
ui.append(grid)
grid.zoom = 2.0
grid.center = (88, 88) # pixel-space camera center (== tile (5.5,5.5) at 16px cells)
assert grid.zoom == 2.0
assert (grid.center.x, grid.center.y) == (88, 88), \
f"grid.center did not take the assigned value: {grid.center}"
clicks = []
def on_cell_click(cell_pos, button, action):
clicks.append((cell_pos.x, cell_pos.y))
grid.on_cell_click = on_cell_click
# local=(60,60); grid_space = (60-100)/2.0 + 88 = 68; cell = floor(68/16) = 4
automation.click((110, 110))
mcrfpy.step(0.05)
assert len(clicks) >= 1, f"on_cell_click never fired under pan/zoom; clicks={clicks}"
assert clicks[0] == (4, 4), (
f"expected cell (4,4) under grid.center=(88,88)/grid.zoom=2.0, got {clicks[0]} "
"(a stale-camera fix would report (3,3) instead)"
)
print(" OK")
def test_c_hover_boundary():
"""(c) on_cell_enter / on_cell_exit fire on hover across a cell boundary."""
print("(c) cell hover enter/exit across a boundary...")
scene = mcrfpy.Scene("issue355_c")
ui = scene.children
mcrfpy.current_scene = scene
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
ui.append(grid)
enters = []
exits = []
grid.on_cell_enter = lambda pos: enters.append((pos.x, pos.y))
grid.on_cell_exit = lambda pos: exits.append((pos.x, pos.y))
# local=(40,40) -> cell (2,2); local=(56,40) -> cell (3,2)
automation.moveTo((140, 140))
mcrfpy.step(0.05)
automation.moveTo((156, 140))
mcrfpy.step(0.05)
assert (2, 2) in enters, f"on_cell_enter never fired for (2,2); enters={enters}"
assert (2, 2) in exits, f"on_cell_exit never fired for (2,2); exits={exits}"
assert (3, 2) in enters, f"on_cell_enter never fired for (3,2); enters={enters}"
print(" OK")
def test_d_child_drawable_click():
"""(d) A Frame child of grid.children receives its own on_click when
clicked, at the screen pixel derived from the grid's camera transform
(children live in grid-world pixel coordinates, not screen pixels)."""
print("(d) child drawable click (grid-world pixel coords via camera)...")
scene = mcrfpy.Scene("issue355_d")
ui = scene.children
mcrfpy.current_scene = scene
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(50, 50), size=(200, 200))
ui.append(grid)
# Grid-world pixel box [90,110]x[90,110]. Default camera (center=(100,100),
# zoom=1) makes grid_space == local, so:
# screen (150,150) -> local (100,100) -> world (100,100): INSIDE the frame
# screen (120,120) -> local (70,70) -> world (70,70): OUTSIDE the frame
frame = mcrfpy.Frame(pos=(90, 90), size=(20, 20))
grid.children.append(frame)
frame_clicks = []
def on_frame_click(pos, button, action):
frame_clicks.append((pos.x, pos.y))
frame.on_click = on_frame_click
# Miss first: proves the screen point isn't just always hitting the frame.
automation.click((120, 120))
mcrfpy.step(0.05)
assert len(frame_clicks) == 0, (
f"frame.on_click fired for a screen point outside its grid-world bounds: {frame_clicks}"
)
automation.click((150, 150))
mcrfpy.step(0.05)
assert len(frame_clicks) >= 1, (
"frame.on_click never fired for a click on a Frame in grid.children "
"at the correctly camera-transformed screen pixel"
)
print(" OK")
def test_e_subclass_method_override():
"""(e) A Python subclass of mcrfpy.Grid overriding on_cell_click as a
METHOD (not the property) has that method invoked."""
print("(e) Grid subclass on_cell_click method override...")
class ClickCountingGrid(mcrfpy.Grid):
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.clicked_cells = []
def on_cell_click(self, cell_pos, button, action):
self.clicked_cells.append((cell_pos.x, cell_pos.y))
scene = mcrfpy.Scene("issue355_e")
ui = scene.children
mcrfpy.current_scene = scene
mg = ClickCountingGrid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
ui.append(mg)
# local=(36,52) -> cell (2,3), same math as (a).
automation.click((136, 152))
mcrfpy.step(0.05)
assert len(mg.clicked_cells) >= 1, (
"ClickCountingGrid.on_cell_click (method override) never fired"
)
assert mg.clicked_cells[0] == (2, 3), \
f"expected cell (2,3), got {mg.clicked_cells[0]}"
print(" OK")
def main():
test_a_plain_cell_click()
test_b_panned_zoomed_cell_click()
test_c_hover_boundary()
test_d_child_drawable_click()
test_e_subclass_method_override()
print("PASS")
if __name__ == "__main__":
# NOTE: --exec does not turn an unhandled Python exception into a
# non-zero process exit code (verified empirically), so failures MUST
# be caught and translated to sys.exit(1) explicitly here -- letting a
# bare AssertionError propagate would silently "pass" the suite, which
# is exactly the masking anti-pattern #355 shipped behind.
try:
main()
sys.exit(0)
except Exception as e:
print(f"\nTEST FAILED: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

View file

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Regression test for #355 (prerequisite): UIGridView box/position desync.
UIGridView overrode move()/resize() but NOT onPositionChanged(), so assigning
grid.pos / grid.x / grid.y updated UIDrawable::position while the sf::RectangleShape
`box` -- which render() and get_bounds() and every hit test read -- stayed at the
construction-time origin. A repositioned Grid rendered at the stale spot and its
cell hit testing was meaningless.
"""
import sys
import mcrfpy
from mcrfpy import automation
CELL = 16 # default texture cell size
def fail(msg):
print(f"FAIL: {msg}", file=sys.stderr)
sys.exit(1)
def main():
scene = mcrfpy.Scene("pos_sync")
scene.activate()
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(grid)
# 1. box follows position
grid.pos = (300, 50)
origin, size = grid.bounds # bounds == (pos: Vector, size: Vector), read from `box`
if (origin.x, origin.y) != (300, 50):
fail(f"grid.bounds should follow grid.pos; expected origin (300,50), got ({origin.x},{origin.y})")
if (size.x, size.y) != (200, 200):
fail(f"grid.bounds size changed unexpectedly: ({size.x},{size.y})")
clicks = []
grid.on_cell_click = lambda pos, button, action: clicks.append((pos.x, pos.y))
# 2. a click at the NEW location resolves the correct cell.
# center defaults to size/2 => left_spritepixels == 0 => grid-world == local.
# local (40, 40) -> cell (2, 2)
automation.click((340, 90))
if not clicks:
fail("on_cell_click did not fire for a click inside the repositioned grid")
if clicks[0] != (2.0, 2.0):
fail(f"expected cell (2,2) at the new position, got {clicks[0]}")
# 3. a click at the OLD location resolves nothing.
clicks.clear()
automation.click((140, 140))
if clicks:
fail(f"click at the old (vacated) position should not hit the grid; got {clicks}")
print("PASS")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Regression test for #355 - grid.perspective actually renders fog-of-war.
After the #252 Grid/GridView split, mcrfpy.Grid IS a UIGridView and the FOV
overlay is drawn by UIGridView::render(). But `grid.perspective = entity` fell
through getattro delegation to the internal _GridData and wrote UIGrid's copy of
the perspective state, which render() never reads -- the view gated the overlay
on its OWN perspective_enabled member, which nothing ever set. Result:
perspective_map computed correctly, grid.perspective_enabled read True, and not
a single fog pixel was drawn.
The state now lives on the view (single owner). This test asserts on PIXELS, not
on the property round-trip -- the property round-trip is exactly what lied.
"""
import sys
import os
import tempfile
import mcrfpy
from mcrfpy import automation
TMPDIR = tempfile.mkdtemp(prefix="mcrf355persp_")
_counter = 0
def shot():
"""Headless screenshot is synchronous (#153): render + capture inline."""
global _counter
path = os.path.join(TMPDIR, "p%d.png" % _counter)
_counter += 1
automation.screenshot(path)
with open(path, "rb") as f:
return f.read()
def main():
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
scene = mcrfpy.Scene("persp355")
scene.activate()
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160), texture=texture)
scene.children.append(grid)
tiles = mcrfpy.TileLayer(name="terrain", z_index=-2, texture=texture)
grid.add_layer(tiles)
tiles.fill(5)
# Opaque everywhere except the entity's own cell -> FOV is a tiny island, so
# the vast majority of the map must be covered by the unknown/black overlay.
for x in range(10):
for y in range(10):
grid.at(x, y).transparent = False
e = mcrfpy.Entity((1, 1), grid=grid)
e.sprite_index = 5
omniscient = shot()
grid.perspective = e
e.update_visibility()
assert grid.perspective is e, "grid.perspective did not round-trip the entity"
assert grid.perspective_enabled is True, "assigning perspective must enable it"
visible = sum(1 for x in range(10) for y in range(10)
if e.perspective_map[x, y] == mcrfpy.Perspective.VISIBLE)
assert 0 < visible < 100, "test setup: expected a partial FOV, got %d visible" % visible
fogged = shot()
assert fogged != omniscient, (
"grid.perspective is set and perspective_map has %d/100 visible cells, but the "
"rendered frame is byte-identical to the omniscient one -- the FOV overlay is "
"not being drawn (view's perspective state never set)" % visible
)
# Turning perspective back off must restore the omniscient image exactly.
grid.perspective = None
assert grid.perspective_enabled is False, "perspective=None must disable the overlay"
cleared = shot()
assert cleared == omniscient, (
"clearing grid.perspective did not restore the un-fogged render"
)
# perspective_enabled toggles the overlay without clearing the entity.
grid.perspective = e
assert shot() != omniscient, "re-enabling perspective did not re-draw the overlay"
grid.perspective_enabled = False
assert grid.perspective is e, "perspective_enabled=False must not clear the entity"
assert shot() == omniscient, "perspective_enabled=False did not remove the overlay"
print("PASS")
if __name__ == "__main__":
try:
main()
sys.exit(0)
except Exception as e:
print("\nTEST FAILED: %s" % e)
import traceback
traceback.print_exc()
sys.exit(1)

View file

@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Regression test for #357 - mcrfpy.find()/find_all() never descend into a
Grid's entities or child drawables.
Root cause (same #252 GridView-split fracture family as #355):
- McRFPy_API::_find / _findAll gate their grid-entity search on
`drawable->derived_type() == PyObjectsEnum::UIGRID`, but a real
mcrfpy.Grid() is a UIGridView (PyObjectsEnum::UIGRIDVIEW) -- that branch
is structurally unreachable for anything a Python script can build.
- find_in_collection()'s child-recursion only descends into UIFRAME
children; it never recurses into a grid's own overlay children
(grid.children) at all, regardless of the UIGRID/UIGRIDVIEW question.
Both are exercised here as hard assertions.
"""
import sys
import mcrfpy
def test_find_entity_in_grid():
print("find()/find_all() locate a named Entity inside a Grid...")
scene = mcrfpy.Scene("issue357_entity")
ui = scene.children
mcrfpy.current_scene = scene
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(0, 0), size=(80, 80))
ui.append(grid)
e = mcrfpy.Entity(grid_pos=(2, 2), name="findme_entity")
grid.entities.append(e)
found = mcrfpy.find("findme_entity")
assert found is not None, "mcrfpy.find() failed to locate a named Entity inside a Grid"
assert found.name == "findme_entity", f"found wrong object: name={getattr(found, 'name', None)!r}"
assert (found.grid_pos.x, found.grid_pos.y) == (2, 2), (
f"find() returned an entity with the wrong grid_pos: {found.grid_pos.x},{found.grid_pos.y}"
)
all_found = mcrfpy.find_all("findme_entity")
assert len(all_found) >= 1, "mcrfpy.find_all() failed to locate a named Entity inside a Grid"
print(" OK")
def test_find_child_drawable_in_grid():
print("find()/find_all() locate a named child drawable inside a Grid...")
scene = mcrfpy.Scene("issue357_child")
ui = scene.children
mcrfpy.current_scene = scene
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(0, 0), size=(80, 80))
ui.append(grid)
child = mcrfpy.Frame(pos=(10, 10), size=(5, 5), name="findme_child")
grid.children.append(child)
found = mcrfpy.find("findme_child")
assert found is not None, "mcrfpy.find() failed to locate a named child drawable inside a Grid"
assert found.name == "findme_child", f"found wrong object: name={getattr(found, 'name', None)!r}"
all_found = mcrfpy.find_all("findme_child")
assert len(all_found) >= 1, "mcrfpy.find_all() failed to locate a named child drawable inside a Grid"
print(" OK")
def test_find_preserves_entity_identity():
"""find() must return the SAME Python object the user created (the #266
identity ref via PythonObjectCache), not a fresh base-class wrapper.
Two bugs in one, if it doesn't:
* a Grid entity that is an Entity SUBCLASS comes back as a plain
mcrfpy.Entity -- attributes on the subclass instance are gone;
* disposing that duplicate wrapper runs PyUIEntityType's tp_dealloc,
which clears data->pyobject WITHOUT a Py_DECREF -- permanently leaking
the entity's strong self-reference, so the subclass instance (and its
__dict__/closures) is never reclaimed, even after it leaves the grid.
"""
print("find() returns the entity's real Python object (identity + no leak)...")
import gc
import weakref
scene = mcrfpy.Scene("issue357_identity")
mcrfpy.current_scene = scene
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(0, 0), size=(80, 80))
scene.children.append(grid)
class Hero(mcrfpy.Entity):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.hp = 10
hero = Hero(grid_pos=(2, 2), name="hero")
grid.entities.append(hero)
assert grid.entities[0] is hero, "collection lookup lost identity (test setup broken)"
found = mcrfpy.find("hero")
assert found is hero, (
f"find() returned a duplicate wrapper ({type(found).__name__}), not the "
"Hero instance -- PythonObjectCache lookup skipped"
)
assert found.hp == 10, "subclass attributes lost -- find() built a base Entity"
all_found = mcrfpy.find_all("hero")
assert all_found and all_found[0] is hero, f"find_all() lost identity: {all_found}"
# Now prove the identity reference was not leaked: dropping every Python
# handle after the entity leaves the grid must reclaim the Hero object.
del found
del all_found
ref = weakref.ref(hero)
grid.entities.remove(hero) # releasePyIdentity() -> DECREF the strong ref
del hero
gc.collect()
assert ref() is None, (
"Hero object survived removal from the grid -- the entity's strong "
"identity reference was dropped without a DECREF (leak)"
)
print(" OK")
def test_find_dedupes_shared_griddata():
"""N views can share ONE GridData (#359 split-screen/minimap). The grid's
entities and overlay children must be walked once per GridData, not once
per view, or find_all() returns N duplicates of every match."""
print("find_all() does not duplicate entities across views of one GridData...")
scene = mcrfpy.Scene("issue357_shared")
mcrfpy.current_scene = scene
g = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(g)
v2 = mcrfpy.GridView(grid=g.grid_data, pos=(400, 100), size=(200, 200))
scene.children.append(v2)
enemy = mcrfpy.Entity(grid_pos=(1, 1), name="enemy")
g.entities.append(enemy)
child = mcrfpy.Frame(pos=(16, 16), size=(16, 16), name="marker")
g.children.append(child)
hits = mcrfpy.find_all("enemy")
assert len(hits) == 1, (
f"find_all('enemy') returned {len(hits)} results for one entity -- the "
"shared GridData was walked once per view (a 'for e in find_all(...)' loop "
"would apply its effect twice)"
)
assert hits[0] is enemy, "find_all() lost entity identity"
child_hits = mcrfpy.find_all("marker")
assert len(child_hits) == 1, (
f"find_all('marker') returned {len(child_hits)} results for one grid child"
)
print(" OK")
def main():
test_find_entity_in_grid()
test_find_child_drawable_in_grid()
test_find_preserves_entity_identity()
test_find_dedupes_shared_griddata()
print("PASS")
if __name__ == "__main__":
# See issue_355_grid_input_test.py: --exec does not turn an unhandled
# exception into a non-zero process exit code, so translate explicitly.
try:
main()
sys.exit(0)
except Exception as e:
print(f"\nTEST FAILED: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

View file

@ -0,0 +1,194 @@
"""Regression test for #359 - the shared-data grid path is unwired.
Two concrete defects, both in the `Grid(grid=other)` shared-GridData path
(the whole point of the #252 split -- split-screen, minimap, multiple
cameras over one dataset):
(a) UIGridView::init_explicit_view never registered the new view with the
GridData it attaches to, so GridData::markDirty()/markCompositeDirty()
(which push markContentDirty/markCompositeDirty up a view's OWN
ancestor chain -- e.g. a Frame(cache_subtree=True) wrapping that view)
never reached a secondary view's ancestors. Note this is NOT the same
thing as the #351 render early-out itself: UIGridView::render() polls
GridData::content_generation directly every time it actually runs, so
a bare secondary view (no cached ancestor) already re-rasters
correctly regardless of this bug. The bug only surfaces when a
secondary view sits under a render-texture-caching ancestor, because
that ancestor decides whether to invoke the child's render() AT ALL --
a push notification, not a poll, is required to reach it. Test 3 below
is the discriminator: it fails (stale cache) on the pre-fix code and
passes once init_explicit_view registers the view.
(b) PyUIGridObject::view (the internal `_GridData.view` shim, meant to
point back at "the" GridView) was placement-new'd but never assigned
anywhere reachable from Python -- `_GridData.view` was always None.
Given `_GridData` is internal (never exported to the mcrfpy module
namespace), the fix deletes the field/getter outright rather than
wiring it up (a single view back-pointer is structurally wrong once N
views can share one GridData; see GridData::views / primaryView() for
the replacement, exercised implicitly by test 2). Test 4 confirms the
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.
#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
import tempfile
import mcrfpy
from mcrfpy import automation
TMPDIR = tempfile.mkdtemp(prefix="mcrf359_")
_counter = 0
def shot():
"""Render the active scene to PNG and return its bytes (headless
screenshot is synchronous -- see #351's test for the same pattern)."""
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 test_1_two_views_render_shared_mutations():
"""Two Grid views over one GridData, with different camera params, both
reflect a mutation made through a third path (this is the exact scenario
the #359 issue's own test description asks for)."""
print("(1) two views over one GridData both render mutations...")
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
scene_a = mcrfpy.Scene("issue359_1a")
grid_a = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160), texture=texture)
scene_a.children.append(grid_a)
scene_b = mcrfpy.Scene("issue359_1b")
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(160, 160))
grid_b.zoom = 1.5
grid_b.center = (64, 64)
scene_b.children.append(grid_b)
mcrfpy.current_scene = scene_a
a1 = shot()
mcrfpy.current_scene = scene_b
b1 = shot()
# Mutate through the shared data (add a visible entity at cell (3,3)).
e = mcrfpy.Entity((3, 3), grid=grid_a)
e.sprite_index = 5
mcrfpy.current_scene = scene_a
a2 = shot()
assert a2 != a1, "primary view (grid_a) did not reflect the mutation"
mcrfpy.current_scene = scene_b
b2 = shot()
assert b2 != b1, "secondary view (grid_b) did not reflect the mutation"
print(" OK")
def test_2_entity_grid_identity():
"""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))
e = mcrfpy.Entity((1, 1), grid=grid_a)
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")
def test_3_ancestor_cache_invalidation_through_secondary_view():
"""The discriminating regression: a SECONDARY view nested inside a
Frame(cache_subtree=True) must have its ancestor's cached subtree
invalidated when the shared GridData mutates, even though the mutation
happens through a DIFFERENT (primary) view/handle. This requires
init_explicit_view to register the secondary view with GridData -- pure
content_generation polling (#351) is not enough, because the caching
Frame decides whether to call the child's render() at all."""
print("(3) ancestor render-texture cache invalidated via secondary view...")
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
scene = mcrfpy.Scene("issue359_3")
grid_a = mcrfpy.Grid(grid_size=(6, 6), pos=(200, 200), size=(96, 96), texture=texture)
# grid_a deliberately NOT added to this scene -- it exists only to own
# the data and be the mutation path; only the secondary view is visible.
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(96, 96))
frame = mcrfpy.Frame(pos=(0, 0), size=(96, 96))
frame.cache_subtree = True
frame.children.append(grid_b)
scene.children.append(frame)
mcrfpy.current_scene = scene
# First render bakes the Frame's cached subtree (render_dirty starts True).
shot1 = shot()
# Idle render: cache should be stable (sanity check the test methodology).
shot2 = shot()
assert shot1 == shot2, "cache_subtree Frame was not stable across an idle frame (test setup problem)"
# Mutate the shared GridData through the PRIMARY handle grid_a, NOT the
# secondary view. If grid_b is registered on the GridData, this reaches
# grid_b's own ancestor chain (the cache_subtree Frame) and invalidates it.
e = mcrfpy.Entity((3, 3), grid=grid_a)
e.sprite_index = 5
shot3 = shot()
assert shot3 != shot2, (
"Frame(cache_subtree=True) wrapping the SECONDARY view did not invalidate "
"when the shared GridData mutated through the primary handle -- "
"init_explicit_view is not registering the view with GridData (#359 defect a)"
)
print(" OK")
def test_4_griddata_view_property_removed():
"""_GridData.view (PyUIGridObject::view) is dead: nothing in the
codebase ever assigned it, and _GridData is internal/not exported. The
fix deletes the field and getter outright rather than wiring a
structurally-wrong single back-pointer; confirm it's actually gone, not
silently still None."""
print("(4) _GridData.view property deleted (not left dangling)...")
grid_a = mcrfpy.Grid(grid_size=(3, 3))
gd = grid_a.grid_data
assert not hasattr(gd, "view"), (
"_GridData.view still exists -- #359 defect (b) says delete it "
"(internal type, never wired, N-view sharing makes a single "
"back-pointer wrong) rather than leave it dangling"
)
print(" OK")
def main():
test_1_two_views_render_shared_mutations()
test_2_entity_grid_identity()
test_3_ancestor_cache_invalidation_through_secondary_view()
test_4_griddata_view_property_removed()
print("PASS")
if __name__ == "__main__":
try:
main()
sys.exit(0)
except Exception as e:
print(f"\nTEST FAILED: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

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

@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Regression test for #363: cursor leaving the window never fired exit callbacks.
Hover was driven exclusively by sf::Event::MouseMoved -> PyScene::do_mouse_hover.
Nothing handled sf::Event::MouseLeft, so when the cursor left the window while over
a drawable:
- no further MouseMoved arrived,
- on_exit / on_cell_exit never fired for whatever was hovered, and
- grid.hovered_cell stayed stuck reporting a cell the mouse was nowhere near.
A hover-driven affordance (tile highlight, tooltip, range preview) got stranded in
its hovered visual state until the player re-entered the window AND crossed a
boundary. hovered_cell was a lying read.
The fix routes MouseLeft to PyScene::do_mouse_leave(), which re-runs the ordinary
hover walk with hit_allowed=false at the root -- so every drawable that thinks it is
hovered gets its normal exit path, and no drawable can enter. Exit callbacks receive
the last known cursor position (the same convention the DOM's mouseleave uses).
automation.mouseLeave() injects the event; there was no way to simulate it before.
"""
import sys
import mcrfpy
from mcrfpy import automation
CELL = 16
FAILURES = []
def check(cond, msg):
if not cond:
print(f"FAIL: {msg}", file=sys.stderr)
FAILURES.append(msg)
def screen_of_cell(grid, cx, cy):
"""Screen coords of the center of cell (cx, cy) under the view's camera."""
left_sp = int(grid.center_x - (grid.w / 2.0))
top_sp = int(grid.center_y - (grid.h / 2.0))
return (grid.x + ((cx + 0.5) * CELL - left_sp),
grid.y + ((cy + 0.5) * CELL - top_sp))
def case_grid_cell_exit_fires_on_leave():
scene = mcrfpy.Scene("leave_grid")
scene.activate()
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(grid)
enters, exits = [], []
grid.on_cell_enter = lambda pos: enters.append((pos.x, pos.y))
grid.on_cell_exit = lambda pos: exits.append((pos.x, pos.y))
automation.moveTo(screen_of_cell(grid, 2, 2))
check(enters == [(2, 2)], f"moving onto cell (2,2) should fire on_cell_enter; got {enters}")
check(grid.hovered_cell == (2, 2),
f"hovered_cell should be (2, 2) while hovering; got {grid.hovered_cell}")
check(exits == [], f"nothing should have exited yet; got {exits}")
# The cursor leaves the window without any further MouseMoved.
automation.mouseLeave()
check(exits == [(2, 2)],
f"leaving the window must fire on_cell_exit for the hovered cell; got {exits}")
check(grid.hovered_cell is None,
f"hovered_cell must be None once the cursor has left the window; "
f"got {grid.hovered_cell}")
check(enters == [(2, 2)],
f"leaving the window must not fire any on_cell_enter; got {enters}")
def case_frame_on_exit_fires_on_leave():
"""#363 is an input-layer gap: Frame.on_exit was stranded the same way."""
scene = mcrfpy.Scene("leave_frame")
scene.activate()
frame = mcrfpy.Frame(pos=(50, 50), size=(100, 100))
scene.children.append(frame)
entered, exited = [], []
frame.on_enter = lambda pos: entered.append((pos.x, pos.y))
frame.on_exit = lambda pos: exited.append((pos.x, pos.y))
automation.moveTo((100, 100))
check(len(entered) == 1, f"moving into the frame should fire on_enter; got {entered}")
check(exited == [], f"nothing should have exited yet; got {exited}")
automation.mouseLeave()
check(len(exited) == 1,
f"leaving the window must fire the frame's on_exit; got {exited}")
# A second leave is a no-op: nothing is hovered any more.
automation.mouseLeave()
check(len(exited) == 1,
f"a redundant leave must not fire on_exit twice; got {exited}")
def case_reenter_after_leave():
"""Hover state must be genuinely cleared, not just reported clear."""
scene = mcrfpy.Scene("leave_reenter")
scene.activate()
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(grid)
enters = []
grid.on_cell_enter = lambda pos: enters.append((pos.x, pos.y))
target = screen_of_cell(grid, 3, 3)
automation.moveTo(target)
automation.mouseLeave()
# Re-entering onto the SAME cell must fire on_cell_enter again -- if the leave
# had only faked the clear, the cell would compare equal and be swallowed.
automation.moveTo(target)
check(enters == [(3, 3), (3, 3)],
f"re-entering the same cell after a leave must fire on_cell_enter again; "
f"got {enters}")
def main():
case_grid_cell_exit_fires_on_leave()
case_frame_on_exit_fires_on_leave()
case_reenter_after_leave()
if __name__ == "__main__":
try:
main()
if FAILURES:
print(f"\n{len(FAILURES)} FAILURE(S)", file=sys.stderr)
sys.exit(1)
print("PASS")
sys.exit(0)
except SystemExit:
raise
except Exception as e:
print(f"\nTEST CRASHED: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)

View file

@ -0,0 +1,171 @@
"""Regression test for #364 - grid children belong to the GridView, not the GridData.
Children (speech bubbles, markers, range indicators) are OVERLAYS painted on top of
a grid through one camera. They are not world contents -- entities are. So they live
on the view, not on the shared data:
* a child's parent is the Grid (i.e. the UIGridView), a real scene-graph drawable,
so its dirty push reaches the view and every caching ancestor above it (#364);
* two views over one GridData have independent children, and shared entities;
* the GridData is not a drawable container at all, so it cannot be a parent.
The load-bearing case is the last one: a grid inside a Frame(cache_subtree=True).
Before this change a child's parent was the internal _GridData, which is in no scene,
so markCompositeDirty() dead-ended there and the Frame re-blitted a STALE composite.
That is invisible in a bare scene (the view re-renders itself) and only shows up under
a caching ancestor -- which is exactly what case 5 builds.
"""
import mcrfpy
from mcrfpy import automation
import sys, os, tempfile
TMPDIR = tempfile.mkdtemp(prefix="mcrf364_")
_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 with the deterministic PNG encoder). Headless screenshots are synchronous
(#153): render-then-capture, so no Timer dance is required."""
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_child_parent_is_the_view():
"""The child's parent is the Grid (UIGridView), not the internal _GridData.
Identity (`child.parent is grid`) is NOT asserted: `.parent` allocates a fresh
wrapper on every read, so `is` fails for Frames too -- even `kid.parent is
kid.parent` is False. That is a pre-existing engine-wide gap, not this bug, and
it is filed separately. Here we assert the parent's TYPE and identify the view by
name, which is what #364 actually turns on.
"""
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(200, 200), name="the_view")
bubble = mcrfpy.Frame(pos=(32, 32), size=(20, 20))
grid.children.append(bubble)
check("1a: child.parent is the grid view", bubble.parent.name == "the_view")
# #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():
"""Two views over one GridData: children are per-view, entities are shared."""
v1 = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(200, 200), name="v1")
v2 = mcrfpy.Grid(grid=v1.grid_data, pos=(220, 0), size=(200, 200), name="v2")
check("2a: both views share one GridData", v1.grid_data is v2.grid_data)
marker = mcrfpy.Frame(pos=(32, 32), size=(20, 20))
v1.children.append(marker)
check("2b: child appended to v1 is in v1.children", len(v1.children) == 1)
check("2c: child appended to v1 is NOT in v2.children", len(v2.children) == 0)
check("2d: the child's parent is v1, not v2", marker.parent.name == "v1")
v2.children.append(mcrfpy.Frame(pos=(64, 64), size=(20, 20)))
check("2e: v2 keeps its own children", len(v2.children) == 1 and len(v1.children) == 1)
# Entities are world contents: both cameras must see the same ones.
mcrfpy.Entity((3, 3), grid=v1)
check("2f: entity added through v1 is visible through v2",
len(v1.entities) == 1 and len(v2.entities) == 1)
def case_griddata_is_not_a_parent():
"""GridData holds no drawables, so it cannot be assigned as a parent."""
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(200, 200))
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)
except TypeError:
check("3a: assigning a GridData as parent raises TypeError", True)
check("3b: GridData exposes no children collection",
not hasattr(grid.grid_data, "children"))
def case_parent_none_removes_from_the_view():
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(200, 200))
child = mcrfpy.Frame(pos=(32, 32), size=(20, 20))
grid.children.append(child)
check("4a: appended", len(grid.children) == 1)
child.parent = None
check("4b: parent=None removes the child from the view", len(grid.children) == 0)
check("4c: the detached child has no parent", child.parent is None)
def case_child_mutation_invalidates_a_caching_ancestor():
"""#364 proper: a grid child's dirty push must reach a Frame(cache_subtree=True).
Pre-fix the child's parent was the internal _GridData -- in no scene, with no
parent of its own -- so markCompositeDirty() stopped there, the Frame stayed
clean, and it re-blitted its cached (stale) composite. The screenshots below
would come back byte-identical even though the child had moved.
"""
scene = mcrfpy.Scene("t364")
scene.activate()
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300),
fill_color=mcrfpy.Color(20, 20, 20),
cache_subtree=True)
scene.children.append(cache)
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(280, 280))
cache.children.append(grid)
bubble = mcrfpy.Frame(pos=(32, 32), size=(48, 48),
fill_color=mcrfpy.Color(255, 0, 0))
grid.children.append(bubble)
a = shot()
bubble.x = 128.0
b = shot()
check("5a: moving a grid child re-composites the caching ancestor", a != b)
bubble.fill_color = mcrfpy.Color(0, 255, 0)
c = shot()
check("5b: recoloring a grid child re-composites the caching ancestor", b != c)
grid.children.remove(bubble)
d = shot()
check("5c: removing a grid child re-composites the caching ancestor", c != d)
# And the skip path itself stays honest: no mutation -> byte-identical frames.
e = shot()
check("5d: idle frames are stable", d == e)
def run_tests():
case_child_parent_is_the_view()
case_children_are_per_view_entities_are_shared()
case_griddata_is_not_a_parent()
case_parent_none_removes_from_the_view()
case_child_mutation_invalidates_a_caching_ancestor()
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

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Regression test for #365: stale last_clicked_cell dispatched for a dataless view.
#355 made UIGridView::last_clicked_cell the channel by which click_at hands the
resolved cell to dispatchCellClick (the point cannot be recomputed later --
click_at works in parent-local space, PyScene only has global coords).
Two halves let a stale cell survive into a later dispatch:
1. click_at stashed the cell (step 3) even when it went on to return nullptr
because the view had no handlers. Nothing consumed that stash --
dispatchCellClick only runs on the drawable PyScene got back, and it got
nullptr. The cell sat there indefinitely.
2. click_at's early return for the no-grid_data case returns `this` (when
click_callable || is_python_subclass) WITHOUT clearing last_clicked_cell.
So: click a cell on a grid with no handlers, then enable interactivity and drop
grid_data, then click the (now cell-less) view -- and on_cell_click fired with a
cell from a grid that no longer exists. A wrong-data dispatch, not a missing one.
NOTE: the issue as filed described this as "click a cell, set grid = None, click
again," which does NOT reproduce -- dispatchCellClick *consumes*
last_clicked_cell (sets it to nullopt after reading), so a cell that was actually
dispatched cannot survive. The unconsumed no-handler stash is the real path.
"""
import sys
import mcrfpy
from mcrfpy import automation
CELL = 16
FAILURES = []
def check(cond, msg):
if not cond:
print(f"FAIL: {msg}", file=sys.stderr)
FAILURES.append(msg)
def screen_of_cell(grid, cx, cy):
"""Screen coords of the center of cell (cx, cy) under the view's camera."""
left_sp = int(grid.center_x - (grid.w / 2.0))
top_sp = int(grid.center_y - (grid.h / 2.0))
return (grid.x + ((cx + 0.5) * CELL - left_sp),
grid.y + ((cy + 0.5) * CELL - top_sp))
def case_stale_survives_into_dataless_view():
scene = mcrfpy.Scene("stale_cell")
scene.activate()
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(grid)
target = screen_of_cell(grid, 2, 2)
# 1. Click with NO handlers: click_at resolves cell (2,2), stashes it, then
# returns nullptr. PyScene has no target, so nothing consumes the stash.
automation.click(target)
# 2. Enable interactivity, then drop the grid data. on_click is what makes
# click_at return `this` on the !grid_data path at all.
cell_clicks, plain_clicks = [], []
grid.on_cell_click = lambda pos, button, action: cell_clicks.append((pos.x, pos.y))
grid.on_click = lambda pos, button, action: plain_clicks.append((pos.x, pos.y))
grid.grid_data = None # property is `grid_data`; the ctor kwarg is `grid`
check(grid.grid_data is None, "grid_data should be None after assignment")
# 3. Click the cell-less view. It must still deliver on_click, but a grid with
# no cells cannot produce a cell event.
automation.click(target)
check(len(plain_clicks) >= 1,
f"the dataless view should still deliver on_click; got {plain_clicks}")
check(cell_clicks == [],
f"on_cell_click must NOT fire for a view with no grid data "
f"(stale last_clicked_cell dispatched); got {cell_clicks}")
def case_no_handler_click_leaves_no_stash():
"""The stash itself must not outlive a click that had nowhere to dispatch."""
scene = mcrfpy.Scene("stale_cell_2")
scene.activate()
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(grid)
# Click cell (1,1) with no handlers -> nothing may be retained.
automation.click(screen_of_cell(grid, 1, 1))
# Now attach a cell handler and click a spot that resolves to NO cell (well
# outside the grid's cells but still inside the widget box, reached by
# shrinking the camera so the 5x5 grid does not fill the 200x200 widget).
cell_clicks = []
grid.on_cell_click = lambda pos, button, action: cell_clicks.append((pos.x, pos.y))
grid.zoom = 0.5 # 5*16*0.5 = 40px of content inside a 200px widget
grid.center = (2.5 * CELL, 2.5 * CELL)
# Bottom-right corner of the widget: inside the box, outside every cell.
automation.click((grid.x + grid.w - 3, grid.y + grid.h - 3))
check(cell_clicks == [],
f"a click inside the widget but on no cell must not fire on_cell_click "
f"(stale stash from the earlier handler-less click); got {cell_clicks}")
def main():
case_stale_survives_into_dataless_view()
case_no_handler_click_leaves_no_stash()
if __name__ == "__main__":
try:
main()
if FAILURES:
print(f"\n{len(FAILURES)} FAILURE(S)", file=sys.stderr)
sys.exit(1)
print("PASS")
sys.exit(0)
except SystemExit:
raise
except Exception as e:
print(f"\nTEST CRASHED: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)

View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Regression test for #366: UICollection.repr counted grids as "UIDrawable".
UICollection::repr tallies collection contents by derived_type() and routed
PyObjectsEnum::UIGRIDVIEW into other_count, while the "Grid" bucket was fed only
by UIGRID -- the internal _GridData type, which no scene graph ever contains
since #252. So repr(scene.children) on a scene holding grids reported
"<UICollection (3 objects: 1 Frame, 2 UIDrawables)>" and never named the grids.
Last surviving instance of the bug class #355 eliminated (a caller switching on
derived_type() to detect grid-ness, silently dead since the type it names stopped
appearing in scene graphs). Now dispatched on the asGridData() virtual instead.
"""
import sys
import mcrfpy
FAILURES = []
def check(cond, msg):
if not cond:
print(f"FAIL: {msg}", file=sys.stderr)
FAILURES.append(msg)
def main():
scene = mcrfpy.Scene("repr_grid")
ui = scene.children
ui.append(mcrfpy.Frame(pos=(0, 0), size=(10, 10)))
ui.append(mcrfpy.Grid(grid_size=(4, 4), pos=(20, 20), size=(64, 64)))
ui.append(mcrfpy.Grid(grid_size=(4, 4), pos=(100, 20), size=(64, 64)))
r = repr(ui)
print(f"repr: {r}")
check("2 Grids" in r,
f"repr should name the 2 Grids (grids counted as 'other'?); got {r}")
check("1 Frame" in r, f"repr should still name the Frame; got {r}")
check("UIDrawable" not in r,
f"no element here is an unclassified UIDrawable; got {r}")
# A single grid uses the singular form.
scene2 = mcrfpy.Scene("repr_grid_one")
scene2.children.append(mcrfpy.Grid(grid_size=(4, 4), pos=(0, 0), size=(64, 64)))
r2 = repr(scene2.children)
print(f"repr: {r2}")
check("1 Grid" in r2 and "Grids" not in r2,
f"a single grid should read '1 Grid'; got {r2}")
if __name__ == "__main__":
try:
main()
if FAILURES:
print(f"\n{len(FAILURES)} FAILURE(S)", file=sys.stderr)
sys.exit(1)
print("PASS")
sys.exit(0)
except SystemExit:
raise
except Exception as e:
print(f"\nTEST CRASHED: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)

View file

@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Regression test for #367: losing window focus stranded hover state (#363's sibling).
#363 fixed the case where the cursor physically LEAVES the window. But alt-tabbing
away strands hover in exactly the same way and for exactly the same reason: while the
window is unfocused no MouseMoved arrives, so the hover walk never runs, so whatever
was hovered when focus was lost stays hovered. on_exit / on_cell_exit never fire and
grid.hovered_cell keeps reporting a cell the user is no longer pointing at -- while
the game sits in the background.
sf::Event::LostFocus was already being produced by every backend (SDL2Renderer even
translates SDL_WINDOWEVENT_FOCUS_LOST into it) and, like MouseLeft before #363, had
NOBODY LISTENING.
The fix routes LostFocus to the same PyScene::do_mouse_leave() that #363 added. That
is the honest response: once unfocused, the engine genuinely does not know where the
pointer is, and "hovering nothing" is the only truthful state. Hover is restored by
the next MouseMoved after focus returns.
automation.loseFocus() injects the event.
"""
import sys
import mcrfpy
from mcrfpy import automation
CELL = 16
FAILURES = []
def check(cond, msg):
if not cond:
print(f"FAIL: {msg}", file=sys.stderr)
FAILURES.append(msg)
def screen_of_cell(grid, cx, cy):
"""Screen coords of the center of cell (cx, cy) under the view's camera."""
left_sp = int(grid.center_x - (grid.w / 2.0))
top_sp = int(grid.center_y - (grid.h / 2.0))
return (grid.x + ((cx + 0.5) * CELL - left_sp),
grid.y + ((cy + 0.5) * CELL - top_sp))
def case_grid_cell_exit_fires_on_focus_loss():
scene = mcrfpy.Scene("blur_grid")
scene.activate()
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(grid)
enters, exits = [], []
grid.on_cell_enter = lambda pos: enters.append((pos.x, pos.y))
grid.on_cell_exit = lambda pos: exits.append((pos.x, pos.y))
automation.moveTo(screen_of_cell(grid, 2, 2))
check(enters == [(2, 2)], f"moving onto cell (2,2) should fire on_cell_enter; got {enters}")
check(grid.hovered_cell == (2, 2),
f"hovered_cell should be (2, 2) while hovering; got {grid.hovered_cell}")
# The player alt-tabs away. The cursor may still be physically over the window,
# but no further MouseMoved will be delivered to us.
automation.loseFocus()
check(exits == [(2, 2)],
f"losing focus must fire on_cell_exit for the hovered cell; got {exits}")
check(grid.hovered_cell is None,
f"hovered_cell must be None while the window is unfocused; got {grid.hovered_cell}")
check(enters == [(2, 2)],
f"losing focus must not fire any on_cell_enter; got {enters}")
def case_frame_on_exit_fires_on_focus_loss():
"""Like #363, this is an input-layer gap, not a grid one: Frame.on_exit too."""
scene = mcrfpy.Scene("blur_frame")
scene.activate()
frame = mcrfpy.Frame(pos=(50, 50), size=(100, 100))
scene.children.append(frame)
entered, exited = [], []
frame.on_enter = lambda pos: entered.append((pos.x, pos.y))
frame.on_exit = lambda pos: exited.append((pos.x, pos.y))
automation.moveTo((100, 100))
check(len(entered) == 1, f"moving into the frame should fire on_enter; got {entered}")
check(exited == [], f"nothing should have exited yet; got {exited}")
automation.loseFocus()
check(len(exited) == 1, f"losing focus must fire the frame's on_exit; got {exited}")
# Already unfocused: a second blur has nothing left to exit.
automation.loseFocus()
check(len(exited) == 1, f"a redundant focus loss must not fire on_exit twice; got {exited}")
def case_reenter_after_focus_loss():
"""Hover must be genuinely cleared, not merely reported clear.
Re-entering the SAME cell after focus returns has to fire on_cell_enter again. If
loseFocus had only nulled the reported value while leaving the internal hovered
cell set, the incoming cell would compare equal and the enter would be swallowed.
"""
scene = mcrfpy.Scene("blur_reenter")
scene.activate()
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
scene.children.append(grid)
enters = []
grid.on_cell_enter = lambda pos: enters.append((pos.x, pos.y))
target = screen_of_cell(grid, 3, 3)
automation.moveTo(target)
automation.loseFocus()
automation.moveTo(target)
check(enters == [(3, 3), (3, 3)],
f"pointing at the same cell after focus returns must fire on_cell_enter again; "
f"got {enters}")
def main():
case_grid_cell_exit_fires_on_focus_loss()
case_frame_on_exit_fires_on_focus_loss()
case_reenter_after_focus_loss()
if __name__ == "__main__":
try:
main()
if FAILURES:
print(f"\n{len(FAILURES)} FAILURE(S)", file=sys.stderr)
sys.exit(1)
print("PASS")
sys.exit(0)
except SystemExit:
raise
except Exception as e:
print(f"\nTEST CRASHED: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)

View file

@ -0,0 +1,176 @@
"""Regression test for #368 - Frame(cache_subtree=True) froze its subtree's content.
`UIDrawable::markContentDirty()` guarded its walk up the parent chain with
bool was_dirty = render_dirty;
...
if (p && (!was_dirty || !p->render_dirty)) p->markContentDirty();
That guard is sound only if `render_dirty` is reliably cleared once a drawable has
been drawn. It was not: `clearDirty()` had exactly ONE call site in the entire
engine (UIGridView, added by #364). UIFrame/UICaption/UISprite/UILine/UICircle/UIArc
never cleared it, so `render_dirty` was a WRITE-ONCE LATCH -- true from the first
mutation until the object died.
With the flag permanently true, `was_dirty` was permanently true, the parent's
`render_dirty` was permanently true, and the guard was permanently false. Content
invalidation stopped propagating entirely, so a caching ancestor kept re-blitting a
stale composite: text never updated, colors never changed, sprites never changed.
Only MOVEMENT survived, because `x`/`y` route through `markCompositeDirty()` -- a
different function whose walk was already unconditional. That asymmetry is what made
the bug look intermittent rather than total, and it is why every case below mutates
CONTENT (color, text, sprite index) rather than position.
The fix makes markContentDirty's walk unconditional too, matching markCompositeDirty.
Correctness then does not depend on any drawable remembering to clear a flag.
"""
import mcrfpy
from mcrfpy import automation
import sys, os, tempfile
TMPDIR = tempfile.mkdtemp(prefix="mcrf368_")
_counter = 0
_results = []
def check(label, ok):
_results.append((label, bool(ok)))
print(" [%s] %s" % ("PASS" if ok else "FAIL", label))
def shot():
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_recolor_under_cache():
"""Every recolor of a nested child must reach the caching ancestor.
Not just the first: on master ZERO of these propagated, because render_dirty had
already latched by the time the first one ran.
"""
scene = mcrfpy.Scene("t368_recolor")
scene.activate()
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300), cache_subtree=True)
scene.children.append(cache)
inner = mcrfpy.Frame(pos=(0, 0), size=(280, 280))
cache.children.append(inner)
kid = mcrfpy.Frame(pos=(32, 32), size=(48, 48), fill_color=mcrfpy.Color(255, 0, 0))
inner.children.append(kid)
prev = shot()
for i, rgb in enumerate([(0, 255, 0), (0, 0, 255), (255, 255, 0), (255, 0, 255)]):
kid.fill_color = mcrfpy.Color(*rgb)
cur = shot()
check("1.%d: recolor -> %s repaints through the cache" % (i + 1, rgb), cur != prev)
prev = cur
def case_caption_text_under_cache():
"""The most visible form of this bug: a label inside a cached panel never updates."""
scene = mcrfpy.Scene("t368_text")
scene.activate()
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300), cache_subtree=True)
scene.children.append(cache)
inner = mcrfpy.Frame(pos=(0, 0), size=(280, 280))
cache.children.append(inner)
cap = mcrfpy.Caption(text="one", pos=(20, 20))
inner.children.append(cap)
prev = shot()
for i, txt in enumerate(["two", "three", "four"]):
cap.text = txt
cur = shot()
check("2.%d: caption text -> %r repaints through the cache" % (i + 1, txt), cur != prev)
prev = cur
def case_deep_nesting():
"""The walk must reach the cache from arbitrary depth, not just one level down."""
scene = mcrfpy.Scene("t368_deep")
scene.activate()
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300), cache_subtree=True)
scene.children.append(cache)
node = cache
for d in range(4):
nxt = mcrfpy.Frame(pos=(5, 5), size=(260 - d * 10, 260 - d * 10))
node.children.append(nxt)
node = nxt
leaf = mcrfpy.Frame(pos=(10, 10), size=(40, 40), fill_color=mcrfpy.Color(255, 0, 0))
node.children.append(leaf)
prev = shot()
for i, rgb in enumerate([(0, 255, 0), (0, 0, 255), (255, 255, 0)]):
leaf.fill_color = mcrfpy.Color(*rgb)
cur = shot()
check("3.%d: depth-5 leaf recolor reaches the cache" % (i + 1), cur != prev)
prev = cur
def case_idle_is_still_cheap():
"""The fix must not defeat the cache: an untouched frame stays byte-identical.
If markContentDirty had been "fixed" by marking everything dirty every frame, the
tests above would pass and the cache would be worthless. This is the counterweight.
"""
scene = mcrfpy.Scene("t368_idle")
scene.activate()
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300), cache_subtree=True)
scene.children.append(cache)
kid = mcrfpy.Frame(pos=(32, 32), size=(48, 48), fill_color=mcrfpy.Color(255, 0, 0))
cache.children.append(kid)
a = shot()
b = shot()
c = shot()
check("4a: idle frames under a cache are byte-identical", a == b == c)
def case_uncached_still_works():
"""Sanity: the ordinary (uncached) path must be unaffected."""
scene = mcrfpy.Scene("t368_plain")
scene.activate()
holder = mcrfpy.Frame(pos=(10, 10), size=(300, 300))
scene.children.append(holder)
kid = mcrfpy.Frame(pos=(32, 32), size=(48, 48), fill_color=mcrfpy.Color(255, 0, 0))
holder.children.append(kid)
prev = shot()
for i, rgb in enumerate([(0, 255, 0), (0, 0, 255)]):
kid.fill_color = mcrfpy.Color(*rgb)
cur = shot()
check("5.%d: recolor repaints without a cache" % (i + 1), cur != prev)
prev = cur
def run_tests():
case_recolor_under_cache()
case_caption_text_under_cache()
case_deep_nesting()
case_idle_is_still_cheap()
case_uncached_still_works()
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

@ -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)
@ -540,6 +540,7 @@ submodule automation
prop center: Vector (rw)
prop center_x: float (rw)
prop center_y: float (rw)
prop children: UICollection (ro)
prop fill_color: Color (rw)
prop global_bounds: tuple (ro)
prop global_position: Any (ro)
@ -547,8 +548,12 @@ submodule automation
prop h: float (rw)
prop horiz_margin: float (rw)
prop hovered: Any (ro)
prop hovered_cell: tuple | None (ro)
prop margin: float (rw)
prop name: str (rw)
prop on_cell_click: Callable | None (rw)
prop on_cell_enter: Callable | None (rw)
prop on_cell_exit: Callable | None (rw)
prop on_click: Callable | None (rw)
prop on_enter: Any (rw)
prop on_exit: Any (rw)
@ -556,10 +561,13 @@ submodule automation
prop opacity: Any (rw)
prop origin: Any (rw)
prop parent: Any (rw)
prop perspective: Entity | None (rw)
prop perspective_enabled: bool (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)
@ -570,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)
@ -580,6 +611,7 @@ submodule automation
prop center: Vector (rw)
prop center_x: float (rw)
prop center_y: float (rw)
prop children: UICollection (ro)
prop fill_color: Color (rw)
prop global_bounds: tuple (ro)
prop global_position: Any (ro)
@ -587,8 +619,12 @@ submodule automation
prop h: float (rw)
prop horiz_margin: float (rw)
prop hovered: Any (ro)
prop hovered_cell: tuple | None (ro)
prop margin: float (rw)
prop name: str (rw)
prop on_cell_click: Callable | None (rw)
prop on_cell_enter: Callable | None (rw)
prop on_cell_exit: Callable | None (rw)
prop on_click: Callable | None (rw)
prop on_enter: Any (rw)
prop on_exit: Any (rw)
@ -596,10 +632,13 @@ submodule automation
prop opacity: Any (rw)
prop origin: Any (rw)
prop parent: Any (rw)
prop perspective: Entity | None (rw)
prop perspective_enabled: bool (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)
@ -610,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
@ -1106,64 +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)
prop children: UICollection (ro)
[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 hovered_cell: tuple | None (ro)
prop layers: tuple (ro)
prop margin: float (rw)
prop name: str (rw)
prop on_cell_click: Callable | None (rw)
prop on_cell_enter: Callable | None (rw)
prop on_cell_exit: Callable | None (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 perspective: Entity | None (rw)
prop perspective_enabled: bool (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 view: GridView | None (ro)
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
@ -1171,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)
@ -1213,8 +1205,10 @@ func dragTo :: dragTo(pos: tuple | list | Vector, duration: float = 0.0, button:
func hotkey :: hotkey(*keys: str) -> None
func keyDown :: keyDown(key: str) -> None
func keyUp :: keyUp(key: str) -> None
func loseFocus :: loseFocus() -> None
func middleClick :: middleClick(pos: tuple | list | Vector | None = None) -> None
func mouseDown :: mouseDown(pos: tuple | list | Vector | None = None, button: str = 'left') -> None
func mouseLeave :: mouseLeave() -> None
func mouseUp :: mouseUp(pos: tuple | list | Vector | None = None, button: str = 'left') -> None
func moveRel :: moveRel(offset: tuple | list | Vector, duration: float = 0.0) -> None
func moveTo :: moveTo(pos: tuple | list | Vector, duration: float = 0.0) -> None
@ -1227,8 +1221,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: 69/69
=== 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("")

View file

@ -1,5 +1,22 @@
#!/usr/bin/env python3
"""Test #142: Grid Cell Mouse Events"""
"""Test #142: Grid Cell Mouse Events
#355 follow-up: this file used to treat "zero events fired" as a soft
"PARTIAL" print and still exit(0). That masked the #355 regression (grid
cell click/hover dispatch is entirely dead for mcrfpy.Grid, which is a
UIGridView under the hood) as a passing test suite. Every path below now
hard-asserts; a callback that fails to fire is a test failure, not a
"maybe interactive mode" shrug.
Coordinate math: with an unmodified (default) camera and an explicit
`size=`, UIGridView's default center is exactly size/(2*zoom), which
equals the viewport's own half-width/half-height. That makes grid_space
== local screen-relative position when zoom==1 (the two halves cancel),
so cell = floor(local_offset / cell_pixel_size) with the default 16x16
texture. All screen coordinates below are chosen with that arithmetic,
landing deliberately mid-cell (not on a boundary) to avoid float-edge
ambiguity.
"""
import sys
import mcrfpy
from mcrfpy import automation
@ -47,7 +64,7 @@ def test_properties():
def test_cell_hover():
"""Test cell hover events"""
"""Test cell hover events fire with the correct cells, across a boundary."""
print("Testing cell hover events...")
test_hover = mcrfpy.Scene("test_hover")
@ -70,23 +87,31 @@ def test_cell_hover():
grid.on_cell_enter = on_enter
grid.on_cell_exit = on_exit
# Move into grid and between cells
automation.moveTo((150, 150))
# local=(40,40) -> cell (2,2); local=(56,40) -> cell (3,2) (adjacent, same row)
automation.moveTo((140, 140))
mcrfpy.step(0.05)
automation.moveTo((200, 200))
automation.moveTo((156, 140))
mcrfpy.step(0.05)
print(f" Enter events: {len(enter_events)}, Exit events: {len(exit_events)}")
print(f" Enter events: {enter_events}, Exit events: {exit_events}")
print(f" Hovered cell: {grid.hovered_cell}")
if len(enter_events) >= 1:
print(" - Hover: PASS")
else:
print(" - Hover: PARTIAL (events may require interactive mode)")
assert (2, 2) in enter_events, \
f"expected on_cell_enter to fire for cell (2,2); got {enter_events}"
assert (3, 2) in enter_events, \
f"expected on_cell_enter to fire for cell (3,2) after crossing the boundary; got {enter_events}"
assert (2, 2) in exit_events, \
f"expected on_cell_exit to fire for cell (2,2) when leaving it; got {exit_events}"
assert grid.hovered_cell is not None, "grid.hovered_cell should be set after hovering"
# hovered_cell is an (x, y) tuple (see its docstring / api_surface golden).
assert tuple(grid.hovered_cell) == (3, 2), \
f"expected grid.hovered_cell == (3,2), got {grid.hovered_cell}"
print(" - Hover: PASS")
def test_cell_click():
"""Test cell click events"""
"""Test cell click events fire with the correct cell."""
print("Testing cell click events...")
test_click = mcrfpy.Scene("test_click")
@ -100,19 +125,23 @@ def test_cell_click():
# #230 - cell click receives (cell_pos: Vector, button: MouseButton, action: InputState)
def on_click(pos, button, action):
click_events.append((pos.x, pos.y))
click_events.append((pos.x, pos.y, button, action))
grid.on_cell_click = on_click
automation.click((200, 200))
# local=(36,52) -> cell (2,3)
automation.click((136, 152))
mcrfpy.step(0.05)
print(f" Click events: {len(click_events)}")
print(f" Click events: {click_events}")
if len(click_events) >= 1:
print(" - Click: PASS")
else:
print(" - Click: PARTIAL (events may require interactive mode)")
assert len(click_events) >= 1, \
"expected on_cell_click to fire at least once for a click inside the grid"
cx, cy, button, action = click_events[0]
assert (cx, cy) == (2, 3), f"expected cell (2,3), got ({cx},{cy})"
assert button == mcrfpy.MouseButton.LEFT, f"expected LEFT button, got {button}"
print(" - Click: PASS")
if __name__ == "__main__":