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:
parent
4c0953c56a
commit
85e4bbec07
21 changed files with 1941 additions and 898 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
531
src/UIGrid.cpp
531
src/UIGrid.cpp
|
|
@ -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
|
||||
|
|
|
|||
61
src/UIGrid.h
61
src/UIGrid.h
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
190
tests/regression/issue_355_camera_rotation_hit_test.py
Normal file
190
tests/regression/issue_355_camera_rotation_hit_test.py
Normal 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)
|
||||
300
tests/regression/issue_355_grid_cell_dispatch_test.py
Normal file
300
tests/regression/issue_355_grid_cell_dispatch_test.py
Normal 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)
|
||||
223
tests/regression/issue_355_grid_input_test.py
Normal file
223
tests/regression/issue_355_grid_input_test.py
Normal 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)
|
||||
59
tests/regression/issue_355_gridview_position_sync_test.py
Normal file
59
tests/regression/issue_355_gridview_position_sync_test.py
Normal 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()
|
||||
103
tests/regression/issue_355_perspective_overlay_test.py
Normal file
103
tests/regression/issue_355_perspective_overlay_test.py
Normal 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)
|
||||
191
tests/regression/issue_359_shared_griddata_test.py
Normal file
191
tests/regression/issue_359_shared_griddata_test.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue