fix(grid): revive Grid input — move cell callbacks and hit-testing to GridView; N-view registry

Since the #252 Grid/GridView split, mcrfpy.Grid(...) constructs a UIGridView
(PyObjectsEnum::UIGRIDVIEW), but every input dispatch site gated on
derived_type() == UIGRID — the internal mcrfpy._GridData type that no scene-graph
node is ever an instance of. All grid input was dead: cell click/enter/exit
callbacks never fired, clicks on a grid's child drawables and entities never
dispatched, and hover inside grids did nothing.

The cell-callback family was split three ways, which is the actual bug: storage
on GridData, firing on UIGrid (it needed UIDrawable::serial_number), and the
camera required to resolve a screen pixel into a cell on UIGridView. So merely
re-pointing the enum gate at UIGRIDVIEW would still report wrong cells for any
panned or zoomed grid, and UIGrid::fireCellClick's subclass lookup resolved the
serial of the throwaway _GridData wrapper that init_with_data DECREFs — meaning
a Python subclass overriding on_cell_click as a method could never dispatch.

Input ownership now lives entirely on UIGridView:
  - on_cell_click/enter/exit, hovered_cell, last_clicked_cell and the subclass
    dispatch cache move off GridData/UIGrid (hovered_cell must be per-view: two
    views over one GridData would otherwise ping-pong exit/enter).
  - UIGridView::click_at descends children (reverse z) -> entities -> cell.
    Children keep grid-world pixel coordinates: that is by design (#360), not an
    inconsistency with Frame's frame-local children.
  - The six derived_type()==UIGRID gates are replaced by virtuals on UIDrawable
    (dispatchCellClick, updateHover, asGridData) so no caller switches on
    grid-ness again.
  - Fixes a prerequisite bug: UIGridView never overrode onPositionChanged(), so
    box and position diverged and a repositioned Grid rendered at the stale spot
    while hit-testing against the new one. Any cell hit-test was meaningless
    until these agreed.
  - grid.perspective was silently dead (the FOV overlay gated on a
    UIGridView::perspective_enabled that nothing ever set); perspective state
    moves to the view with the rest.

#359 is fused into this commit rather than sequenced after it: the shared-data
back-reference is rewritten by the same hunks that move the callbacks off
GridData, and separating them would mean authoring an intermediate GridData.h /
UIGridView.cpp that never existed. GridData::owning_view (a single weak_ptr, for
a relationship that is N views to one GridData) becomes a vector<weak_ptr>
registry with register/unregister. The registry is kept rather than deleted: the
markCompositeDirty push is NOT redundant with #351's content_generation poll,
because it propagates bottom-up through each view's ancestor chain (e.g. a
Frame(use_render_texture=True) wrapping a view), which a top-down render-time
poll cannot do.

Grid subclass method dispatch, panned/zoomed cell resolution, grid-child clicks,
and cell hover are all covered by new regression tests. tests/unit/
test_grid_cell_events.py previously printed "PARTIAL (events may require
interactive mode)" and exited 0 having fired zero events — that dishonest pass is
why this shipped broken; it now asserts.

Suite: 322/322.

closes #355
closes #359

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-07-12 18:01:40 -04:00
commit 85e4bbec07
21 changed files with 1941 additions and 898 deletions

File diff suppressed because one or more lines are too long

View file

@ -3,7 +3,7 @@
#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 - and notifies registered views
#include <algorithm>
// #313 - Render invalidation from the data layer (see GridData.h).
@ -12,19 +12,46 @@
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

View file

@ -119,26 +119,10 @@ 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;
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
// #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.
// =========================================================================
// UIDrawable children (speech bubbles, effects, overlays)
@ -147,9 +131,27 @@ public:
bool children_need_sort = true;
// =========================================================================
// #252 - Owning GridView back-reference (for Entity.grid → GridView lookup)
// #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::weak_ptr<UIGridView> owning_view;
std::vector<std::weak_ptr<UIGridView>> views;
// 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 +160,21 @@ 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.
// AND notify every registered view (see `views` above), covering both
// render paths (a bare _GridData rendered directly, and every GridView
// sharing this data) and every view's own ancestor chain (e.g. a
// Frame(use_render_texture=True) wrapping ANY of the N views needs its
// own cache invalidated, not just the creator's). Note the #351 render
// early-out itself does NOT depend on this notification -- it polls
// content_generation directly in UIGridView::render() -- this push is only
// for propagating markContentDirty/markCompositeDirty up a view's parent
// chain, which is a bottom-up push, not something pollable from a
// top-down render traversal. Within UIGrid itself the UIDrawable versions
// win via using-declarations (see UIGrid.h).
void markDirty();
void markCompositeDirty();

View file

@ -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,28 @@ 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) {
// 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,26 +362,33 @@ 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->grid_data && view->grid_data->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->grid_data->children) {
processHover(child.get(), grid_world, inside_view);
}
}
}
@ -394,7 +396,7 @@ void PyScene::do_mouse_hover(int x, int y)
// Process all top-level UI elements
for (auto& element : *ui_elements) {
processHover(element.get());
processHover(element.get(), mousepos, true);
}
}

View file

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

View file

@ -22,7 +22,7 @@ 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 UIGrid; class GridData;
enum PyObjectsEnum : int
{
@ -55,6 +55,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();

View file

@ -821,10 +821,14 @@ PyObject* UIEntity::get_grid(PyUIEntityObject* self, void* closure)
auto& grid = self->data->grid;
// #252: If the grid has an owning GridView, return that instead.
// #252/#359: If the grid has a registered GridView, return that instead.
// This preserves the unified Grid API where entity.grid returns the same
// object the user created via mcrfpy.Grid(...).
auto owning_view = grid->owning_view.lock();
// object the user created via mcrfpy.Grid(...). primaryView() is
// deterministic even with multiple views sharing this GridData: it's
// views.front(), the view that actually created the data (a fresh
// GridData is only ever produced by the Grid() factory path), or the
// first still-alive secondary view if that original view has since died.
auto owning_view = grid->primaryView();
if (owning_view) {
// Check cache for the GridView
if (owning_view->serial_number != 0) {

View file

@ -25,8 +25,7 @@
UIGrid::UIGrid()
: GridData(), // Initialize data layer (entities, children, FOV defaults)
zoom(1.0f), center_x(0.0f), center_y(0.0f), ptex(nullptr),
fill_color(8, 8, 8, 255),
perspective_enabled(false)
fill_color(8, 8, 8, 255)
{
// Initialize box with safe defaults
box.setSize(sf::Vector2f(0, 0));
@ -51,8 +50,7 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr<PyTexture> _ptex, sf::Vector2f _x
: GridData(), // Initialize data layer
zoom(1.0f),
ptex(_ptex),
fill_color(8, 8, 8, 255),
perspective_enabled(false)
fill_color(8, 8, 8, 255)
{
// Use texture dimensions if available, otherwise use defaults
int cell_width = _ptex ? _ptex->sprite_width : DEFAULT_CELL_WIDTH;
@ -286,76 +284,8 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
}
}
// top layer - opacity for discovered / visible status based on perspective
// Only render visibility overlay if perspective is enabled
if (perspective_enabled) {
ScopedTimer fovTimer(Resources::game->metrics.fovOverlayTime);
auto entity = perspective_entity.lock();
// Create rectangle for overlays
sf::RectangleShape overlay;
overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));
if (entity && entity->perspective_map) {
// #294: perspective_map values -- 0=unknown, 1=discovered, 2=visible.
const uint8_t* pm = entity->perspective_map->data();
const int pm_w = entity->perspective_map->width();
const int pm_h = entity->perspective_map->height();
for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0);
x < x_limit;
x+=1)
{
for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0);
y < y_limit;
y+=1)
{
// Skip out-of-bounds cells
if (x < 0 || x >= grid_w || y < 0 || y >= grid_h) continue;
if (x >= pm_w || y >= pm_h) continue;
auto pixel_pos = sf::Vector2f(
(x*cell_width - left_spritepixels) * zoom,
(y*cell_height - top_spritepixels) * zoom );
uint8_t state = pm[y * pm_w + x];
overlay.setPosition(pixel_pos);
if (state == 0) {
overlay.setFillColor(sf::Color(0, 0, 0, 255));
activeTexture->draw(overlay);
} else if (state == 1) {
overlay.setFillColor(sf::Color(32, 32, 40, 192));
activeTexture->draw(overlay);
}
// state == 2: visible -- no overlay
}
}
} else {
// Invalid/destroyed entity with perspective_enabled = true
// Show all cells as undiscovered (black)
for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0);
x < x_limit;
x+=1)
{
for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0);
y < y_limit;
y+=1)
{
// Skip out-of-bounds cells
if (x < 0 || x >= grid_w || y < 0 || y >= grid_h) continue;
auto pixel_pos = sf::Vector2f(
(x*cell_width - left_spritepixels) * zoom,
(y*cell_height - top_spritepixels) * zoom );
overlay.setPosition(pixel_pos);
overlay.setFillColor(sf::Color(0, 0, 0, 255));
activeTexture->draw(overlay);
}
}
}
}
// else: omniscient view (no overlays)
// #355: the perspective/FOV overlay is drawn by UIGridView::render, which owns
// the perspective state. UIGrid (the internal _GridData) no longer duplicates it.
// grid lines for testing & validation
/*
@ -522,101 +452,14 @@ std::shared_ptr<PyTexture> UIGrid::getTexture()
return ptex;
}
UIDrawable* UIGrid::click_at(sf::Vector2f point)
UIDrawable* UIGrid::click_at(sf::Vector2f)
{
// Check grid bounds first
if (!box.getGlobalBounds().contains(point)) {
return nullptr;
}
// Transform to local coordinates
sf::Vector2f localPoint = point - box.getPosition();
// Get cell dimensions
int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
// Calculate visible area parameters (from render function)
float center_x_sq = center_x / cell_width;
float center_y_sq = center_y / cell_height;
float width_sq = box.getSize().x / (cell_width * zoom);
float height_sq = box.getSize().y / (cell_height * zoom);
int left_spritepixels = center_x - (box.getSize().x / 2.0 / zoom);
int top_spritepixels = center_y - (box.getSize().y / 2.0 / zoom);
// Convert click position to grid-world pixel coordinates
float grid_world_x = localPoint.x / zoom + left_spritepixels;
float grid_world_y = localPoint.y / zoom + top_spritepixels;
// Convert to grid cell coordinates
float grid_x = grid_world_x / cell_width;
float grid_y = grid_world_y / cell_height;
// Check children first (they render on top, so they get priority)
// Children are positioned in grid-world pixel coordinates
if (children && !children->empty()) {
// Check in reverse z-order (highest z_index first, rendered last = on top)
for (auto it = children->rbegin(); it != children->rend(); ++it) {
auto& child = *it;
if (!child->visible) continue;
// Transform click to child's local coordinate space
// Children's position is in grid-world pixels
sf::Vector2f childLocalPoint = sf::Vector2f(grid_world_x, grid_world_y);
if (auto target = child->click_at(childLocalPoint)) {
return target;
}
}
}
// Check entities in reverse order (assuming they should be checked top to bottom)
// Note: entities list is not sorted by z-index currently, but we iterate in reverse
// to match the render order assumption
if (entities) {
for (auto it = entities->rbegin(); it != entities->rend(); ++it) {
auto& entity = *it;
if (!entity || !entity->sprite.visible) continue;
// Check if click is within entity's grid cell
// Entities occupy a 1x1 grid cell centered on their position
float dx = grid_x - entity->position.x;
float dy = grid_y - entity->position.y;
if (dx >= -0.5f && dx < 0.5f && dy >= -0.5f && dy < 0.5f) {
// Click is within the entity's cell
// Check if entity sprite has a click handler
// For now, we return the entity's sprite as the click target
// Note: UIEntity doesn't derive from UIDrawable, so we check its sprite
if (entity->sprite.click_callable) {
return &entity->sprite;
}
}
}
}
// No entity handled it, check if grid itself has handler
// #184: Also check for Python subclass (might have on_click or on_cell_click method)
// Store clicked cell for later callback firing (with button/action from PyScene)
int cell_x = static_cast<int>(std::floor(grid_x));
int cell_y = static_cast<int>(std::floor(grid_y));
if (cell_x >= 0 && cell_x < this->grid_w && cell_y >= 0 && cell_y < this->grid_h) {
last_clicked_cell = sf::Vector2i(cell_x, cell_y);
} else {
last_clicked_cell = std::nullopt;
}
// Return this if we have any handler (property callback, subclass method, or cell callback)
if (click_callable || is_python_subclass || on_cell_click_callable) {
return this;
}
// #355: a bare _GridData is never an input target -- the scene graph holds
// UIGridView, which owns the camera and all cell/child/entity hit testing.
// #361 removes UIDrawable from GridData entirely, which deletes this override.
return nullptr;
}
int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
// Define all parameters with defaults
PyObject* pos_obj = nullptr;
@ -782,8 +625,7 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
}
self->data->center_x = center_x;
self->data->center_y = center_y;
// perspective is now handled by perspective_entity and perspective_enabled
// self->data->perspective = perspective;
// #355: perspective lives on UIGridView (the object that renders the overlay).
self->data->visible = visible;
self->data->opacity = opacity;
self->data->z_index = z_index;
@ -984,32 +826,15 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
// #184: Check if this is a Python subclass (for callback method support)
self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != (PyObject*)&mcrfpydef::PyUIGridType;
// #252 shim: auto-create a GridView for rendering
// The GridView shares GridData (via aliasing shared_ptr) and copies rendering state
{
auto view = std::make_shared<UIGridView>();
// Share grid data (aliasing shared_ptr: shares UIGrid ownership, points to GridData base)
view->grid_data = std::shared_ptr<GridData>(
self->data, static_cast<GridData*>(self->data.get()));
// Copy rendering state from UIGrid to GridView
view->ptex = texture_ptr;
view->box.setPosition(self->data->box.getPosition());
view->box.setSize(self->data->box.getSize());
view->position = self->data->position;
view->center_x = self->data->center_x;
view->center_y = self->data->center_y;
view->zoom = self->data->zoom;
view->fill_color = self->data->fill_color;
view->camera_rotation = self->data->camera_rotation;
view->perspective_entity = self->data->perspective_entity;
view->perspective_enabled = self->data->perspective_enabled;
view->visible = self->data->visible;
view->opacity = self->data->opacity;
view->z_index = self->data->z_index;
view->name = self->data->name;
view->ensureRenderTextureSize();
self->view = view;
}
// #359: previously constructed an extra "shim" GridView here (self->view)
// that copied rendering state and allocated its own RenderTexture, but was
// NEVER used -- UIGridView::init_with_data (the only caller of UIGrid::init,
// see UIGridView.cpp) discards this PyUIGridObject wrapper immediately after
// stealing its GridData via an aliasing shared_ptr, and builds the REAL,
// user-visible view directly on itself. The shim was dead weight (a wasted
// RenderTexture allocation on every Grid() construction) backing a field
// (PyUIGridObject::view / `_GridData.view`) nothing ever read. Removed along
// with the field itself.
return 0; // Success
}
@ -1017,45 +842,6 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
// Python property getters/setters moved to UIGridPyProperties.cpp
// Python method implementations moved to UIGridPyMethods.cpp
// #142 - Convert screen coordinates to cell coordinates
std::optional<sf::Vector2i> UIGrid::screenToCell(sf::Vector2f screen_pos) const {
// Get grid's global position
sf::Vector2f global_pos = get_global_position();
sf::Vector2f local_pos = screen_pos - global_pos;
// Check if within grid bounds
sf::FloatRect bounds = box.getGlobalBounds();
if (local_pos.x < 0 || local_pos.y < 0 ||
local_pos.x >= bounds.width || local_pos.y >= bounds.height) {
return std::nullopt;
}
// Get cell size from texture or default
float cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
float cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
// Apply zoom
cell_width *= zoom;
cell_height *= zoom;
// Calculate grid space position (account for center/pan)
float half_width = bounds.width / 2.0f;
float half_height = bounds.height / 2.0f;
float grid_space_x = (local_pos.x - half_width) / zoom + center_x;
float grid_space_y = (local_pos.y - half_height) / zoom + center_y;
// Convert to cell coordinates
int cell_x = static_cast<int>(std::floor(grid_space_x / (ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH)));
int cell_y = static_cast<int>(std::floor(grid_space_y / (ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT)));
// Check if within valid cell range
if (cell_x < 0 || cell_x >= grid_w || cell_y < 0 || cell_y >= grid_h) {
return std::nullopt;
}
return sf::Vector2i(cell_x, cell_y);
}
// #221 - Get effective cell size (texture size * zoom)
sf::Vector2f UIGrid::getEffectiveCellSize() const {
float cell_w = ptex ? static_cast<float>(ptex->sprite_width) : static_cast<float>(DEFAULT_CELL_WIDTH);
@ -1063,287 +849,6 @@ sf::Vector2f UIGrid::getEffectiveCellSize() const {
return sf::Vector2f(cell_w * zoom, cell_h * zoom);
}
// Helper function to convert button string to MouseButton enum value
static int buttonStringToEnum(const std::string& button) {
if (button == "left") return 0; // MouseButton.LEFT
if (button == "right") return 1; // MouseButton.RIGHT
if (button == "middle") return 2; // MouseButton.MIDDLE
if (button == "wheel_up") return 3; // MouseButton.WHEEL_UP
if (button == "wheel_down") return 4; // MouseButton.WHEEL_DOWN
return 0; // Default to LEFT
}
// Helper function to convert action string to InputState enum value
static int actionStringToEnum(const std::string& action) {
if (action == "start" || action == "pressed") return 0; // InputState.PRESSED
if (action == "end" || action == "released") return 1; // InputState.RELEASED
return 0; // Default to PRESSED
}
// #142 - Refresh cell callback cache for Python subclass method support
void UIGrid::refreshCellCallbackCache(PyObject* pyObj) {
if (!pyObj || !is_python_subclass) {
cell_callback_cache.valid = false;
return;
}
// Get the class's callback generation counter
PyObject* cls = (PyObject*)Py_TYPE(pyObj);
uint32_t current_gen = 0;
PyObject* gen_obj = PyObject_GetAttrString(cls, "_mcrf_callback_gen");
if (gen_obj) {
current_gen = static_cast<uint32_t>(PyLong_AsUnsignedLong(gen_obj));
Py_DECREF(gen_obj);
} else {
PyErr_Clear();
}
// Check if cache is still valid
if (cell_callback_cache.valid && cell_callback_cache.generation == current_gen) {
return; // Cache is fresh
}
// Refresh cache - check for each cell callback method
cell_callback_cache.has_on_cell_click = false;
cell_callback_cache.has_on_cell_enter = false;
cell_callback_cache.has_on_cell_exit = false;
// Check class hierarchy for each method
PyTypeObject* type = Py_TYPE(pyObj);
while (type && type != &mcrfpydef::PyUIGridType && type != &PyBaseObject_Type) {
if (type->tp_dict) {
if (!cell_callback_cache.has_on_cell_click) {
PyObject* method = PyDict_GetItemString(type->tp_dict, "on_cell_click");
if (method && PyCallable_Check(method)) {
cell_callback_cache.has_on_cell_click = true;
}
}
if (!cell_callback_cache.has_on_cell_enter) {
PyObject* method = PyDict_GetItemString(type->tp_dict, "on_cell_enter");
if (method && PyCallable_Check(method)) {
cell_callback_cache.has_on_cell_enter = true;
}
}
if (!cell_callback_cache.has_on_cell_exit) {
PyObject* method = PyDict_GetItemString(type->tp_dict, "on_cell_exit");
if (method && PyCallable_Check(method)) {
cell_callback_cache.has_on_cell_exit = true;
}
}
}
type = type->tp_base;
}
cell_callback_cache.generation = current_gen;
cell_callback_cache.valid = true;
}
// Helper to create typed cell callback arguments: (Vector, MouseButton, InputState)
static PyObject* createCellCallbackArgs(sf::Vector2i cell, const std::string& button, const std::string& action) {
// Create Vector object for cell position
PyObject* cell_pos = PyObject_CallFunction((PyObject*)&mcrfpydef::PyVectorType, "ff", (float)cell.x, (float)cell.y);
if (!cell_pos) {
PyErr_Print();
return nullptr;
}
// Create MouseButton enum
int button_val = buttonStringToEnum(button);
PyObject* button_enum = PyObject_CallFunction(PyMouseButton::mouse_button_enum_class, "i", button_val);
if (!button_enum) {
Py_DECREF(cell_pos);
PyErr_Print();
return nullptr;
}
// Create InputState enum
int action_val = actionStringToEnum(action);
PyObject* action_enum = PyObject_CallFunction(PyInputState::input_state_enum_class, "i", action_val);
if (!action_enum) {
Py_DECREF(cell_pos);
Py_DECREF(button_enum);
PyErr_Print();
return nullptr;
}
PyObject* args = Py_BuildValue("(OOO)", cell_pos, button_enum, action_enum);
Py_DECREF(cell_pos);
Py_DECREF(button_enum);
Py_DECREF(action_enum);
return args;
}
// #230 - Helper to create cell hover callback arguments: (Vector) only
static PyObject* createCellHoverArgs(sf::Vector2i cell) {
// Create Vector object for cell position
PyObject* cell_pos = PyObject_CallFunction((PyObject*)&mcrfpydef::PyVectorType, "ii", cell.x, cell.y);
if (!cell_pos) {
PyErr_Print();
return nullptr;
}
PyObject* args = Py_BuildValue("(O)", cell_pos);
Py_DECREF(cell_pos);
return args;
}
// Fire cell click callback with full signature (cell_pos, button, action)
bool UIGrid::fireCellClick(sf::Vector2i cell, const std::string& button, const std::string& action) {
// Try property-assigned callback first
if (on_cell_click_callable && !on_cell_click_callable->isNone()) {
PyObject* args = createCellCallbackArgs(cell, button, action);
if (args) {
PyObject* result = PyObject_CallObject(on_cell_click_callable->borrow(), args);
Py_DECREF(args);
if (!result) {
std::cerr << "Cell click callback raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
return true;
}
}
// Try Python subclass method
if (is_python_subclass) {
PyObject* pyObj = PythonObjectCache::getInstance().lookup(this->serial_number);
if (pyObj) {
refreshCellCallbackCache(pyObj);
if (cell_callback_cache.has_on_cell_click) {
PyObject* method = PyObject_GetAttrString(pyObj, "on_cell_click");
if (method && PyCallable_Check(method)) {
PyObject* args = createCellCallbackArgs(cell, button, action);
if (args) {
PyObject* result = PyObject_CallObject(method, args);
Py_DECREF(args);
Py_DECREF(method);
Py_DECREF(pyObj);
if (!result) {
std::cerr << "Cell click method raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
return true;
}
}
Py_XDECREF(method);
}
Py_DECREF(pyObj);
}
}
return false;
}
// #230 - Fire cell enter callback with position-only signature (cell_pos)
bool UIGrid::fireCellEnter(sf::Vector2i cell) {
// Try property-assigned callback first (now PyCellHoverCallable)
if (on_cell_enter_callable && !on_cell_enter_callable->isNone()) {
on_cell_enter_callable->call(cell);
return true;
}
// Try Python subclass method
if (is_python_subclass) {
PyObject* pyObj = PythonObjectCache::getInstance().lookup(this->serial_number);
if (pyObj) {
refreshCellCallbackCache(pyObj);
if (cell_callback_cache.has_on_cell_enter) {
PyObject* method = PyObject_GetAttrString(pyObj, "on_cell_enter");
if (method && PyCallable_Check(method)) {
// #230: Cell hover takes only (cell_pos)
PyObject* args = createCellHoverArgs(cell);
if (args) {
PyObject* result = PyObject_CallObject(method, args);
Py_DECREF(args);
Py_DECREF(method);
Py_DECREF(pyObj);
if (!result) {
std::cerr << "Cell enter method raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
return true;
}
}
Py_XDECREF(method);
}
Py_DECREF(pyObj);
}
}
return false;
}
// #230 - Fire cell exit callback with position-only signature (cell_pos)
bool UIGrid::fireCellExit(sf::Vector2i cell) {
// Try property-assigned callback first (now PyCellHoverCallable)
if (on_cell_exit_callable && !on_cell_exit_callable->isNone()) {
on_cell_exit_callable->call(cell);
return true;
}
// Try Python subclass method
if (is_python_subclass) {
PyObject* pyObj = PythonObjectCache::getInstance().lookup(this->serial_number);
if (pyObj) {
refreshCellCallbackCache(pyObj);
if (cell_callback_cache.has_on_cell_exit) {
PyObject* method = PyObject_GetAttrString(pyObj, "on_cell_exit");
if (method && PyCallable_Check(method)) {
// #230: Cell hover takes only (cell_pos)
PyObject* args = createCellHoverArgs(cell);
if (args) {
PyObject* result = PyObject_CallObject(method, args);
Py_DECREF(args);
Py_DECREF(method);
Py_DECREF(pyObj);
if (!result) {
std::cerr << "Cell exit method raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
return true;
}
}
Py_XDECREF(method);
}
Py_DECREF(pyObj);
}
}
return false;
}
// #142 - Update cell hover state and fire callbacks
// #230 - Cell hover callbacks now take only (cell_pos), no button/action
void UIGrid::updateCellHover(sf::Vector2f mousepos, const std::string& button, const std::string& action) {
(void)button; // #230 - No longer used for hover callbacks
(void)action; // #230 - No longer used for hover callbacks
auto new_cell = screenToCell(mousepos);
// Check if cell changed
if (new_cell != hovered_cell) {
// Fire exit callback for old cell
if (hovered_cell.has_value()) {
fireCellExit(hovered_cell.value());
}
// Fire enter callback for new cell
if (new_cell.has_value()) {
fireCellEnter(new_cell.value());
}
hovered_cell = new_cell;
}
}
// UIEntityCollection code has been moved to UIEntityCollection.cpp
// Property system implementation for animations

View file

@ -56,6 +56,7 @@ public:
void render(sf::Vector2f, sf::RenderTarget&) override final;
PyObjectsEnum derived_type() override final;
virtual UIDrawable* click_at(sf::Vector2f point) override final;
GridData* asGridData() override { return static_cast<GridData*>(this); } // #355
// Phase 1 virtual method implementations
sf::FloatRect get_bounds() const override;
@ -80,20 +81,12 @@ public:
// Background rendering
sf::Color fill_color;
// Perspective system
std::weak_ptr<UIEntity> perspective_entity;
bool perspective_enabled;
// #355: the perspective system (entity + enabled flag) lives on UIGridView,
// which renders the FOV overlay. Keeping a copy here was a dead duplicate.
// 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;
// #355 - cell callback firing, screenToCell and cell hover moved to UIGridView.
// getEffectiveCellSize stays: UIDrawable uses it for grid_pos <-> pixel math.
sf::Vector2f getEffectiveCellSize() const;
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;
@ -122,10 +115,6 @@ public:
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);
@ -149,15 +138,6 @@ public:
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);
@ -190,12 +170,8 @@ namespace mcrfpydef {
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();
// #355: cell callbacks now live on UIGridView, not GridData.
}
obj->view.reset(); // #252: release GridView shim
obj->data.reset();
Py_TYPE(self)->tp_free(self);
},
@ -215,7 +191,6 @@ namespace mcrfpydef {
" 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"
@ -243,7 +218,12 @@ namespace mcrfpydef {
" 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"
" children (UICollection): UIDrawable children (speech bubbles, markers, "
"range indicators) anchored to grid content -- positioned in the grid's "
"pixel-world coordinates, same origin as entities, and pan/zoom with the "
"camera. NOT the same coordinate space as Frame.children. For screen-space "
"UI that floats over the grid, use a sibling Frame instead; see "
"docs/grid-coordinate-spaces.md\n"
" click (callable): Click event handler\n"
" visible (bool): Visibility state\n"
" opacity (float): Opacity value\n"
@ -272,18 +252,6 @@ namespace mcrfpydef {
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;
},
@ -294,9 +262,6 @@ namespace mcrfpydef {
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;
},
@ -309,8 +274,6 @@ namespace mcrfpydef {
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

@ -829,7 +829,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.
// content_generation bump + registered-views notification.
if (content_changed) grid->GridData::markCompositeDirty();
Py_RETURN_NONE;

View file

@ -156,22 +156,6 @@ int UIGrid::set_float_member(PyUIGridObject* self, PyObject* value, void* closur
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 {
@ -224,72 +208,6 @@ int UIGrid::set_fill_color(PyUIGridObject* self, PyObject* value, void* closure)
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) {
@ -368,17 +286,6 @@ PyObject* UIGrid::get_children(PyUIGridObject* self, void* closure)
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();
@ -438,70 +345,7 @@ PyObject* UIGrid::repr(PyUIGridObject* self)
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;
}
// #355 - cell callback properties moved to UIGridView (src/UIGridView.cpp)
// =========================================================================
// getsetters[] table
@ -564,17 +408,6 @@ PyGetSetDef UIGrid::getsetters[] = {
"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). "
@ -593,19 +426,6 @@ PyGetSetDef UIGrid::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}
};

View file

@ -15,6 +15,8 @@
#include "PythonObjectCache.h"
#include "PySceneObject.h" // parent= kwarg: Scene is a valid parent type
#include "McRFPy_Doc.h"
#include "PyMouseButton.h" // #355 - MouseButton enum for cell click args
#include "PyInputState.h" // #355 - InputState enum for cell click args
#include <cmath>
#include <algorithm>
@ -78,6 +80,14 @@ void UIGridView::resize(float w, float h)
box.setSize(sf::Vector2f(w, h));
}
void UIGridView::onPositionChanged()
{
// #355: UIDrawable::set_pos/set_float_member write `position` and call this;
// without it the rendered box (and every hit test derived from it) stayed at
// the construction-time origin.
box.setPosition(position);
}
sf::Vector2f UIGridView::getEffectiveCellSize() const
{
int cw = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
@ -85,24 +95,53 @@ sf::Vector2f UIGridView::getEffectiveCellSize() const
return sf::Vector2f(cw * zoom, ch * zoom);
}
std::optional<sf::Vector2i> UIGridView::screenToCell(sf::Vector2f screen_pos) const
sf::Vector2f UIGridView::localToGridWorld(sf::Vector2f local) const
{
// Exact inverse of render()'s rasterize-then-composite transform (including
// the int truncation of left/top_spritepixels), so hit testing matches what
// is drawn -- rotated camera included.
//
// render(): grid-world -> texture pixel
// T = (gw - spritepixels) * zoom [texture is aabb_w x aabb_h]
// then the texture is blitted rotated by camera_rotation about its center,
// landing on the widget center:
// L - box_center = R(camera_rotation) * (T - tex_center)
// Inverting: T = R(-camera_rotation) * (L - box_center) + tex_center,
// gw = T / zoom + spritepixels.
const float grid_w_px = box.getSize().x;
const float grid_h_px = box.getSize().y;
const float rad = camera_rotation * (float)(M_PI / 180.0);
const float cos_r = std::cos(rad);
const float sin_r = std::sin(rad);
// Same AABB the renderer rasterizes into (equals the box when unrotated).
const float aabb_w = grid_w_px * std::abs(cos_r) + grid_h_px * std::abs(sin_r);
const float aabb_h = grid_w_px * std::abs(sin_r) + grid_h_px * std::abs(cos_r);
const float dx = local.x - grid_w_px / 2.0f;
const float dy = local.y - grid_h_px / 2.0f;
// R(-camera_rotation) applied to (dx, dy), then re-centered on the AABB.
const float tx = dx * cos_r + dy * sin_r + aabb_w / 2.0f;
const float ty = -dx * sin_r + dy * cos_r + aabb_h / 2.0f;
int left_spritepixels = center_x - (aabb_w / 2.0 / zoom);
int top_spritepixels = center_y - (aabb_h / 2.0 / zoom);
return sf::Vector2f(tx / zoom + left_spritepixels,
ty / zoom + top_spritepixels);
}
std::optional<sf::Vector2i> UIGridView::cellAtLocal(sf::Vector2f local) const
{
if (!grid_data) return std::nullopt;
if (!box.getGlobalBounds().contains(screen_pos)) return std::nullopt;
int cw = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
if (local.x < 0 || local.y < 0 ||
local.x >= box.getSize().x || local.y >= box.getSize().y) {
return std::nullopt;
}
int cw = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
int ch = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
sf::Vector2f local = screen_pos - box.getPosition();
int left_sp = center_x - (box.getSize().x / 2.0 / zoom);
int top_sp = center_y - (box.getSize().y / 2.0 / zoom);
float gx = (local.x / zoom + left_sp) / cw;
float gy = (local.y / zoom + top_sp) / ch;
int cx = static_cast<int>(std::floor(gx));
int cy = static_cast<int>(std::floor(gy));
sf::Vector2f gw = localToGridWorld(local);
int cx = static_cast<int>(std::floor(gw.x / cw));
int cy = static_cast<int>(std::floor(gw.y / ch));
if (cx < 0 || cx >= grid_data->grid_w || cy < 0 || cy >= grid_data->grid_h)
return std::nullopt;
return sf::Vector2i(cx, cy);
@ -137,14 +176,16 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
// #351 - clean-state early-out: re-blit the cached RenderTexture when nothing
// affecting the raster changed since last frame. Camera params are compared
// directly (view-local); grid content changes bump content_generation. The
// perspective overlay (checked authoritatively on the UIGrid, since the view's
// copy is not resynced at runtime) and any grid children are conservative
// perspective overlay (single source of truth: this view's own
// perspective_enabled, see #355 fix) and any grid children are conservative
// always-render carve-outs until tracked precisely (#352).
UIGrid* ugrid = static_cast<UIGrid*>(grid_data.get());
bool perspective_active = perspective_enabled || (ugrid && ugrid->perspective_enabled);
// last_perspective_enabled: the cached raster HAS an overlay baked into it, so
// the frame that turns perspective off must re-rasterize even though every
// other input is unchanged (otherwise the fog stays on screen forever).
bool can_skip =
has_rendered_once
&& !perspective_active
&& !perspective_enabled
&& !last_perspective_enabled
&& (!grid_data->children || grid_data->children->empty())
&& grid_data->content_generation == last_content_gen
&& center_x == last_center_x && center_y == last_center_y
@ -273,8 +314,9 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
}
}
// Perspective overlay
// Perspective overlay (#355: state owned by the view -- see get/set_perspective)
if (perspective_enabled) {
ScopedTimer fovTimer(Resources::game->metrics.fovOverlayTime);
auto entity = perspective_entity.lock();
sf::RectangleShape overlay;
overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));
@ -355,6 +397,7 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
last_box_size = box.getSize();
last_fill_color = fill_color;
last_render_tex_size = renderTextureSize;
last_perspective_enabled = perspective_enabled;
if (shader && shader->shader) {
sf::Vector2f resolution(box.getSize().x, box.getSize().y);
@ -368,8 +411,59 @@ void UIGridView::render(sf::Vector2f offset, sf::RenderTarget& target)
UIDrawable* UIGridView::click_at(sf::Vector2f point)
{
// point is in PARENT-LOCAL space (UIFrame::click_at passes point - position down).
if (!box.getGlobalBounds().contains(point)) return nullptr;
return this; // GridView consumes clicks within its bounds
if (!grid_data) {
return (click_callable || is_python_subclass) ? this : nullptr;
}
sf::Vector2f local = point - box.getPosition();
sf::Vector2f gw = localToGridWorld(local);
int cw = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
int ch = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
float grid_x = gw.x / cw;
float grid_y = gw.y / ch;
// 1. Children first (drawn on top). Children of a grid live in GRID-WORLD PIXEL
// coordinates -- NOT view-local, NOT screen (#360, by design: moving the
// camera must not move a child's logical position). Do not "fix" this.
if (grid_data->children && !grid_data->children->empty()) {
if (grid_data->children_need_sort) {
std::sort(grid_data->children->begin(), grid_data->children->end(),
[](const auto& a, const auto& b) { return a->z_index < b->z_index; });
grid_data->children_need_sort = false;
}
for (auto it = grid_data->children->rbegin(); it != grid_data->children->rend(); ++it) {
auto& child = *it;
if (!child->visible) continue;
if (auto target = child->click_at(gw)) return target;
}
}
// 2. Entities: 1x1 cell hit box matching the renderer, which draws the sprite at
// position.x * cell_width -- i.e. cell [pos, pos+1).
// NOTE: unreachable from Python today -- Entity exposes no on_click/sprite
// binding, so entity->sprite.click_callable can never be set, and this branch
// cannot fire. Implemented per #355; dead until an Entity.on_click binding
// exists (needs its own issue).
if (grid_data->entities) {
for (auto it = grid_data->entities->rbegin(); it != grid_data->entities->rend(); ++it) {
auto& entity = *it;
if (!entity || !entity->sprite.visible) continue;
float dx = grid_x - entity->position.x;
float dy = grid_y - entity->position.y;
if (dx >= 0.0f && dx < 1.0f && dy >= 0.0f && dy < 1.0f) {
if (entity->sprite.click_callable) return &entity->sprite;
}
}
}
// 3. The cell itself. Stash for dispatchCellClick (PyScene has only global coords).
last_clicked_cell = cellAtLocal(local);
if (click_callable || is_python_subclass || on_cell_click_callable) return this;
return nullptr;
}
// Property system
@ -532,6 +626,14 @@ int UIGridView::init_explicit_view(PyUIGridViewObject* self, PyObject* args, PyO
if (self->data->grid_data) {
self->data->center_camera();
self->data->ensureRenderTextureSize();
// #359 - Register this view so it receives markDirty/markCompositeDirty
// notifications (its own ancestor chain -- e.g. a Frame(use_render_texture=
// True) wrapping this view -- needs invalidating too, not just the
// creating view's). Does NOT disturb identity: primaryView() (used by
// UIEntity::get_grid) is views.front(), which is always the view that
// created this GridData via init_with_data, since init_explicit_view can
// only attach to grid_data that already exists.
self->data->grid_data->registerView(self->data);
}
self->weakreflist = NULL;
@ -605,8 +707,8 @@ int UIGridView::init_with_data(PyUIGridViewObject* self, PyObject* args, PyObjec
}
}
// Set owning_view back-reference on the GridData
self->data->grid_data->owning_view = self->data;
// #359 - Register this view on the GridData (dirty-notification + identity)
self->data->grid_data->registerView(self->data);
self->data->ensureRenderTextureSize();
@ -776,35 +878,35 @@ int UIGridView::set_grid(PyUIGridViewObject* self, PyObject* value, void* closur
// #348: the persistent wrapper tracks the *current* grid_data; any reassign
// (including to None) invalidates it so get_grid rebuilds for the new data.
Py_CLEAR(self->data->cached_grid_wrapper);
// #359 - Unregister self from whatever grid_data it was viewing before
// reassignment. unregisterView matches by identity (self->data.get()), so
// this is safe even when other views also share the old grid_data.
if (self->data->grid_data) {
self->data->grid_data->unregisterView(self->data.get());
}
if (value == Py_None) {
if (self->data->grid_data) {
self->data->grid_data->owning_view.reset();
}
self->data->grid_data = nullptr;
return 0;
}
// Accept internal _GridData (UIGrid) objects
if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) {
PyUIGridObject* pygrid = (PyUIGridObject*)value;
if (self->data->grid_data) {
self->data->grid_data->owning_view.reset();
}
self->data->grid_data = std::shared_ptr<GridData>(
pygrid->data, static_cast<GridData*>(pygrid->data.get()));
self->data->ptex = pygrid->data->getTexture();
self->data->grid_data->owning_view = self->data;
self->data->grid_data->registerView(self->data);
return 0;
}
// Accept GridView (unified Grid) objects - share their grid_data
if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
PyUIGridViewObject* pyview = (PyUIGridViewObject*)value;
if (pyview->data->grid_data) {
if (self->data->grid_data) {
self->data->grid_data->owning_view.reset();
}
self->data->grid_data = pyview->data->grid_data;
self->data->ptex = pyview->data->ptex;
// Don't override owning_view - original owner keeps it
// #359: register self alongside the original owner (and any other
// secondary views) -- does not disturb primaryView() identity,
// which stays views.front() (the original creator).
self->data->grid_data->registerView(self->data);
return 0;
}
self->data->grid_data = nullptr;
@ -856,6 +958,68 @@ int UIGridView::set_fill_color(PyUIGridViewObject* self, PyObject* value, void*
return 0;
}
// =========================================================================
// Perspective (#355 fix): OWNED BY THE VIEW. The FOV overlay is drawn by
// UIGridView::render, so the state it gates on must live here -- previously
// `grid.perspective` fell through getattro to the internal _GridData and wrote
// UIGrid::perspective_enabled, which render() never read: the overlay was dead.
// Per-view ownership is also the correct semantics for shared GridData (#359):
// two views of one map can follow two different entities' FOV.
// =========================================================================
PyObject* UIGridView::get_perspective(PyUIGridViewObject* self, void* closure)
{
auto locked = self->data->perspective_entity.lock();
if (!locked) Py_RETURN_NONE;
// Honor the cache so Entity subclass identity survives (#266).
if (locked->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(locked->serial_number);
if (cached) return cached; // new ref
}
auto type = &mcrfpydef::PyUIEntityType;
auto o = (PyUIEntityObject*)type->tp_alloc(type, 0);
if (!o) return PyErr_NoMemory();
o->data = locked;
o->weakreflist = NULL;
return (PyObject*)o;
}
int UIGridView::set_perspective(PyUIGridViewObject* self, PyObject* value, void* closure)
{
if (value == Py_None) {
self->data->perspective_entity.reset();
self->data->perspective_enabled = false;
self->data->markDirty();
return 0;
}
if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIEntityType)) {
PyErr_SetString(PyExc_TypeError, "perspective must be an Entity 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* UIGridView::get_perspective_enabled(PyUIGridViewObject* self, void* closure)
{
return PyBool_FromLong(self->data->perspective_enabled);
}
int UIGridView::set_perspective_enabled(PyUIGridViewObject* 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;
}
PyObject* UIGridView::get_texture(PyUIGridViewObject* self, void* closure)
{
// #318: return a Texture wrapper sharing the underlying shared_ptr<PyTexture>,
@ -904,6 +1068,342 @@ int UIGridView::set_float_member_gv(PyUIGridViewObject* self, PyObject* value, v
return 0;
}
// =========================================================================
// #355 - Cell input machinery (moved verbatim from UIGrid, retargeted to the view)
// =========================================================================
// Helper function to convert button string to MouseButton enum value
static int buttonStringToEnum(const std::string& button) {
if (button == "left") return 0; // MouseButton.LEFT
if (button == "right") return 1; // MouseButton.RIGHT
if (button == "middle") return 2; // MouseButton.MIDDLE
if (button == "wheel_up") return 3; // MouseButton.WHEEL_UP
if (button == "wheel_down") return 4; // MouseButton.WHEEL_DOWN
return 0; // Default to LEFT
}
// Helper function to convert action string to InputState enum value
static int actionStringToEnum(const std::string& action) {
if (action == "start" || action == "pressed") return 0; // InputState.PRESSED
if (action == "end" || action == "released") return 1; // InputState.RELEASED
return 0; // Default to PRESSED
}
// Helper to create typed cell callback arguments: (Vector, MouseButton, InputState)
static PyObject* createCellCallbackArgs(sf::Vector2i cell, const std::string& button, const std::string& action) {
PyObject* cell_pos = PyObject_CallFunction((PyObject*)&mcrfpydef::PyVectorType, "ff", (float)cell.x, (float)cell.y);
if (!cell_pos) {
PyErr_Print();
return nullptr;
}
int button_val = buttonStringToEnum(button);
PyObject* button_enum = PyObject_CallFunction(PyMouseButton::mouse_button_enum_class, "i", button_val);
if (!button_enum) {
Py_DECREF(cell_pos);
PyErr_Print();
return nullptr;
}
int action_val = actionStringToEnum(action);
PyObject* action_enum = PyObject_CallFunction(PyInputState::input_state_enum_class, "i", action_val);
if (!action_enum) {
Py_DECREF(cell_pos);
Py_DECREF(button_enum);
PyErr_Print();
return nullptr;
}
PyObject* args = Py_BuildValue("(OOO)", cell_pos, button_enum, action_enum);
Py_DECREF(cell_pos);
Py_DECREF(button_enum);
Py_DECREF(action_enum);
return args;
}
// #230 - Helper to create cell hover callback arguments: (Vector) only
static PyObject* createCellHoverArgs(sf::Vector2i cell) {
PyObject* cell_pos = PyObject_CallFunction((PyObject*)&mcrfpydef::PyVectorType, "ii", cell.x, cell.y);
if (!cell_pos) {
PyErr_Print();
return nullptr;
}
PyObject* args = Py_BuildValue("(O)", cell_pos);
Py_DECREF(cell_pos);
return args;
}
// #142 - Refresh cell callback cache for Python subclass method support
void UIGridView::refreshCellCallbackCache(PyObject* pyObj) {
if (!pyObj || !is_python_subclass) {
cell_callback_cache.valid = false;
return;
}
// Get the class's callback generation counter
PyObject* cls = (PyObject*)Py_TYPE(pyObj);
uint32_t current_gen = 0;
PyObject* gen_obj = PyObject_GetAttrString(cls, "_mcrf_callback_gen");
if (gen_obj) {
current_gen = static_cast<uint32_t>(PyLong_AsUnsignedLong(gen_obj));
Py_DECREF(gen_obj);
} else {
PyErr_Clear();
}
if (cell_callback_cache.valid && cell_callback_cache.generation == current_gen) {
return; // Cache is fresh
}
cell_callback_cache.has_on_cell_click = false;
cell_callback_cache.has_on_cell_enter = false;
cell_callback_cache.has_on_cell_exit = false;
// Walk the class hierarchy down to (but not including) the base Grid type.
// #355: sentinel is PyUIGridViewType -- mcrfpy.Grid IS UIGridView.
PyTypeObject* type = Py_TYPE(pyObj);
while (type && type != &mcrfpydef::PyUIGridViewType && type != &PyBaseObject_Type) {
if (type->tp_dict) {
if (!cell_callback_cache.has_on_cell_click) {
PyObject* method = PyDict_GetItemString(type->tp_dict, "on_cell_click");
if (method && PyCallable_Check(method)) {
cell_callback_cache.has_on_cell_click = true;
}
}
if (!cell_callback_cache.has_on_cell_enter) {
PyObject* method = PyDict_GetItemString(type->tp_dict, "on_cell_enter");
if (method && PyCallable_Check(method)) {
cell_callback_cache.has_on_cell_enter = true;
}
}
if (!cell_callback_cache.has_on_cell_exit) {
PyObject* method = PyDict_GetItemString(type->tp_dict, "on_cell_exit");
if (method && PyCallable_Check(method)) {
cell_callback_cache.has_on_cell_exit = true;
}
}
}
type = type->tp_base;
}
cell_callback_cache.generation = current_gen;
cell_callback_cache.valid = true;
}
// Fire cell click callback with full signature (cell_pos, button, action)
bool UIGridView::fireCellClick(sf::Vector2i cell, const std::string& button, const std::string& action) {
// Try property-assigned callback first
if (on_cell_click_callable && !on_cell_click_callable->isNone()) {
PyObject* args = createCellCallbackArgs(cell, button, action);
if (args) {
PyObject* result = PyObject_CallObject(on_cell_click_callable->borrow(), args);
Py_DECREF(args);
if (!result) {
std::cerr << "Cell click callback raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
return true;
}
}
// Try Python subclass method
if (is_python_subclass) {
PyObject* pyObj = PythonObjectCache::getInstance().lookup(this->serial_number);
if (pyObj) {
refreshCellCallbackCache(pyObj);
if (cell_callback_cache.has_on_cell_click) {
PyObject* method = PyObject_GetAttrString(pyObj, "on_cell_click");
if (method && PyCallable_Check(method)) {
PyObject* args = createCellCallbackArgs(cell, button, action);
if (args) {
PyObject* result = PyObject_CallObject(method, args);
Py_DECREF(args);
Py_DECREF(method);
Py_DECREF(pyObj);
if (!result) {
std::cerr << "Cell click method raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
return true;
}
}
Py_XDECREF(method);
}
Py_DECREF(pyObj);
}
}
return false;
}
// #230 - Fire cell enter callback with position-only signature (cell_pos)
bool UIGridView::fireCellEnter(sf::Vector2i cell) {
if (on_cell_enter_callable && !on_cell_enter_callable->isNone()) {
on_cell_enter_callable->call(cell);
return true;
}
if (is_python_subclass) {
PyObject* pyObj = PythonObjectCache::getInstance().lookup(this->serial_number);
if (pyObj) {
refreshCellCallbackCache(pyObj);
if (cell_callback_cache.has_on_cell_enter) {
PyObject* method = PyObject_GetAttrString(pyObj, "on_cell_enter");
if (method && PyCallable_Check(method)) {
PyObject* args = createCellHoverArgs(cell);
if (args) {
PyObject* result = PyObject_CallObject(method, args);
Py_DECREF(args);
Py_DECREF(method);
Py_DECREF(pyObj);
if (!result) {
std::cerr << "Cell enter method raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
return true;
}
}
Py_XDECREF(method);
}
Py_DECREF(pyObj);
}
}
return false;
}
// #230 - Fire cell exit callback with position-only signature (cell_pos)
bool UIGridView::fireCellExit(sf::Vector2i cell) {
if (on_cell_exit_callable && !on_cell_exit_callable->isNone()) {
on_cell_exit_callable->call(cell);
return true;
}
if (is_python_subclass) {
PyObject* pyObj = PythonObjectCache::getInstance().lookup(this->serial_number);
if (pyObj) {
refreshCellCallbackCache(pyObj);
if (cell_callback_cache.has_on_cell_exit) {
PyObject* method = PyObject_GetAttrString(pyObj, "on_cell_exit");
if (method && PyCallable_Check(method)) {
PyObject* args = createCellHoverArgs(cell);
if (args) {
PyObject* result = PyObject_CallObject(method, args);
Py_DECREF(args);
Py_DECREF(method);
Py_DECREF(pyObj);
if (!result) {
std::cerr << "Cell exit method raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
return true;
}
}
Py_XDECREF(method);
}
Py_DECREF(pyObj);
}
}
return false;
}
// #355 - UIDrawable input virtuals
bool UIGridView::dispatchCellClick(const std::string& button, const std::string& action)
{
if (!last_clicked_cell.has_value()) return false;
sf::Vector2i cell = last_clicked_cell.value();
last_clicked_cell = std::nullopt;
return fireCellClick(cell, button, action);
}
void UIGridView::updateHover(sf::Vector2f point, bool hit_allowed)
{
// point is PARENT-local (same space as click_at); the view's own box position
// takes it to widget-local, and cellAtLocal applies this view's camera.
std::optional<sf::Vector2i> new_cell = std::nullopt;
if (hit_allowed) new_cell = cellAtLocal(point - box.getPosition());
if (new_cell != hovered_cell) {
if (hovered_cell.has_value()) fireCellExit(hovered_cell.value());
if (new_cell.has_value()) fireCellEnter(new_cell.value());
hovered_cell = new_cell;
}
}
// =========================================================================
// #355 - Cell callback properties (moved from UIGrid)
// =========================================================================
PyObject* UIGridView::get_on_cell_enter(PyUIGridViewObject* 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 UIGridView::set_on_cell_enter(PyUIGridViewObject* 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* UIGridView::get_on_cell_exit(PyUIGridViewObject* 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 UIGridView::set_on_cell_exit(PyUIGridViewObject* 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* UIGridView::get_on_cell_click(PyUIGridViewObject* 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 UIGridView::set_on_cell_click(PyUIGridViewObject* 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* UIGridView::get_hovered_cell(PyUIGridViewObject* 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;
}
// =========================================================================
// Subscript protocol: grid[x, y] -> GridPoint (delegates to GridData).
// Setitem raises TypeError (GridPoints are views, not assignable).
@ -1019,5 +1519,26 @@ PyGetSetDef UIGridView::getsetters[] = {
UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIGRIDVIEW),
UIDRAWABLE_ROTATION_GETSETTERS(PyObjectsEnum::UIGRIDVIEW),
UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UIGRIDVIEW),
// #355 - cell input properties (moved from _GridData to the view)
{"on_cell_enter", (getter)UIGridView::get_on_cell_enter, (setter)UIGridView::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)UIGridView::get_on_cell_exit, (setter)UIGridView::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)UIGridView::get_on_cell_click, (setter)UIGridView::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)UIGridView::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},
// #355 - perspective moved from _GridData to the view: render() draws the FOV
// overlay, so the view must own the state it gates on. Per-view, so two views
// of one grid can follow different entities.
{"perspective", (getter)UIGridView::get_perspective, (setter)UIGridView::set_perspective,
MCRF_PROPERTY(perspective,
"Entity whose perspective_map drives fog-of-war rendering in this view (Entity | None). "
"Setting an entity enables perspective mode; setting None disables it. "
"Cells the entity has never seen are drawn black, discovered-but-not-visible cells dimmed."), NULL},
{"perspective_enabled", (getter)UIGridView::get_perspective_enabled, (setter)UIGridView::set_perspective_enabled,
MCRF_PROPERTY(perspective_enabled,
"Whether this view renders the perspective/FOV overlay (bool). "
"Set automatically when assigning perspective; set False to show the whole map without clearing the entity."), NULL},
{NULL}
};

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;
@ -64,6 +65,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 +113,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 +134,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;
@ -130,6 +169,21 @@ public:
static PyObject* get_float_member_gv(PyUIGridViewObject* self, void* closure);
static int set_float_member_gv(PyUIGridViewObject* self, PyObject* value, 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[];
@ -159,17 +213,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 PyUIGridType). 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 +265,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 +288,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

@ -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,191 @@
"""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. Index 0
is guaranteed (by construction: a fresh GridData is only ever produced by
Grid()'s factory path, `init_with_data`; `init_explicit_view` can only ATTACH
to an already-existing GridData) to be the view that created the data, so
UIEntity.grid identity (test 2) stays deterministic even with secondary
views registered -- not an arbitrary pick from an unordered set.
"""
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 returns the SAME object as the Grid() that actually
created the data (GridData::views.front()), not the secondary view, not
None, and not a fresh throwaway wrapper -- even once a secondary view is
registered on the same GridData."""
print("(2) entity.grid identity with a secondary view registered...")
grid_a = mcrfpy.Grid(grid_size=(5, 5))
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(80, 80)) # noqa: F841 (keep alive)
e = mcrfpy.Entity((1, 1), grid=grid_a)
assert e.grid is grid_a, f"entity.grid identity broken: {e.grid!r} is not grid_a ({grid_a!r})"
assert e.grid is not grid_b, "entity.grid returned the secondary view instead of the creator"
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

@ -547,8 +547,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,6 +560,8 @@ 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)
@ -587,8 +593,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,6 +606,8 @@ 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)
@ -1127,13 +1139,9 @@ submodule automation
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)
@ -1141,8 +1149,6 @@ 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)
@ -1151,7 +1157,6 @@ submodule automation
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)
@ -1228,7 +1233,7 @@ func tripleClick :: tripleClick(pos: tuple | list | Vector | None = None) -> Non
func typewrite :: typewrite(message: str, interval: float = 0.0) -> None
=== DELEGATION INTEGRITY (Grid instance -> _GridData) ===
delegated-resolved: 69/69
delegated-resolved: 62/62
=== WRITABILITY PROBES (#313-touched properties) ===
Entity.grid: writable

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__":