Compare commits
12 commits
4c0953c56a
...
36e74e0dc3
| Author | SHA1 | Date | |
|---|---|---|---|
| 36e74e0dc3 | |||
| 9d67e33370 | |||
| c696b4ef41 | |||
| 3e18c1fbf6 | |||
| 4bb1966b97 | |||
| 461dcf7f29 | |||
| 11f8394f9b | |||
| 7952d25cad | |||
| 540c793e4e | |||
| 521d99916a | |||
| 086c886d70 | |||
| 85e4bbec07 |
68 changed files with 4823 additions and 3604 deletions
File diff suppressed because one or more lines are too long
91
docs/grid-coordinate-spaces.md
Normal file
91
docs/grid-coordinate-spaces.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Grid Coordinate Spaces & the Overlay Pattern
|
||||
|
||||
Clarifies which coordinate space `UIDrawable` children are positioned in depending on
|
||||
their parent, and how to build screen-space UI (HUDs, dialogs) that sits on top of a
|
||||
panning/zooming `Grid`. See issue #360.
|
||||
|
||||
## The short version
|
||||
|
||||
- **`Frame.children`** are positioned **frame-local**: relative to the Frame's
|
||||
top-left corner. A `Frame` has no camera and cannot pan its content, so
|
||||
frame-local is effectively already screen space.
|
||||
- **`Grid.children`** are positioned in the grid's **pixel-world coordinates** — the
|
||||
same origin as `Entity` positions — **not** in the grid widget's on-screen pixels.
|
||||
They pan and zoom with the grid's camera (`grid.center`, `grid.zoom`), exactly like
|
||||
entities and tiles do.
|
||||
|
||||
This is not an inconsistency to "fix": a `Frame` genuinely has no notion of a camera,
|
||||
so there is no other coordinate space its children could reasonably use. A `Grid`
|
||||
does have a camera, and its children are deliberately anchored to grid content (the
|
||||
world), not to the viewport. Entities are positioned by cell; grid-world pixel
|
||||
coordinates share that same origin, so a `Grid` child can be placed precisely next to
|
||||
an entity (using `entity.pos`, itself documented as "pixel position relative to
|
||||
grid") and will continue to track that entity's neighborhood as the camera moves.
|
||||
|
||||
The use case for `Grid.children` is **diegetic** UI: speech bubbles, damage numbers,
|
||||
range indicators, path markers — things that belong to a place or an entity in the
|
||||
world and should move/zoom with it.
|
||||
|
||||
```python
|
||||
# A speech bubble anchored near an entity, in grid-world pixels.
|
||||
# entity.pos is already "pixel position relative to grid" -- the same origin
|
||||
# Grid.children use.
|
||||
bubble = mcrfpy.Caption(text="Hello!", pos=(entity.pos.x, entity.pos.y - 20))
|
||||
grid.children.append(bubble)
|
||||
```
|
||||
|
||||
If you instead want UI that floats over the grid at a fixed screen position
|
||||
regardless of where the camera is pointed — a health bar HUD, an inventory panel, a
|
||||
modal dialog — **`Grid.children` is the wrong tool**, because those children would
|
||||
pan and zoom right along with everything else and drift off screen.
|
||||
|
||||
## The overlay pattern
|
||||
|
||||
Put a `Frame` as a **sibling** of the `Grid` (not a child of it), sized and
|
||||
positioned to match the `Grid`, and draw your screen-space UI as children of that
|
||||
Frame instead. Give the overlay Frame a later `z_index` (or simply append it after
|
||||
the Grid) so it draws on top.
|
||||
|
||||
```
|
||||
Scene (or a container Frame)
|
||||
├── Grid # pan, zoom, entities, diegetic children
|
||||
│ ├── entities: positioned by cell
|
||||
│ └── children: positioned in grid-world pixels (pan/zoom with camera)
|
||||
└── Frame (overlay) # same pos/size as the Grid, drawn after it
|
||||
└── children: positioned in frame-local coordinates (screen space)
|
||||
```
|
||||
|
||||
```python
|
||||
import mcrfpy
|
||||
|
||||
scene = mcrfpy.Scene("game")
|
||||
ui = scene.children
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(40, 30), pos=(0, 0), size=(640, 480))
|
||||
ui.append(grid)
|
||||
|
||||
# Diegetic marker: lives in the grid's world, pans/zooms with the camera.
|
||||
marker = mcrfpy.Caption(text="!", pos=(160, 96))
|
||||
grid.children.append(marker)
|
||||
|
||||
# Screen-space HUD: same pos/size as the grid, but its own children are
|
||||
# frame-local, so they stay put no matter how grid.center/grid.zoom change.
|
||||
overlay = mcrfpy.Frame(pos=(0, 0), size=(640, 480))
|
||||
overlay.fill_color = mcrfpy.Color(0, 0, 0, 0) # transparent container
|
||||
ui.append(overlay) # appended after grid -> draws on top
|
||||
|
||||
health_bar = mcrfpy.Frame(pos=(10, 10), size=(200, 20))
|
||||
health_bar.fill_color = mcrfpy.Color(200, 40, 40)
|
||||
overlay.children.append(health_bar)
|
||||
```
|
||||
|
||||
Panning or zooming `grid` (`grid.center = ...`, `grid.zoom = ...`) moves `marker`
|
||||
along with the world; `health_bar` never moves, because it belongs to `overlay`, not
|
||||
to `grid`.
|
||||
|
||||
## See also
|
||||
|
||||
- `Grid.children` / `Frame.children` docstrings (`mcrfpy.Grid.children.__doc__`,
|
||||
`mcrfpy.Frame.children.__doc__`) for the API-level statement of this contract.
|
||||
- `Entity.draw_pos` / `Entity.cell_pos` for the cell-vs-pixel distinction on the
|
||||
entity side of the same coordinate system.
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
#include "UIFrame.h"
|
||||
#include "UICaption.h"
|
||||
#include "UISprite.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UIGridView.h"
|
||||
#include "UILine.h"
|
||||
#include "UICircle.h"
|
||||
|
|
@ -574,17 +574,6 @@ static PyObject* convertDrawableToPython(std::shared_ptr<UIDrawable> drawable) {
|
|||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIGRID:
|
||||
{
|
||||
type = &mcrfpydef::PyUIGridType;
|
||||
auto pyObj = (PyUIGridObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UIGrid>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIGRIDVIEW:
|
||||
{
|
||||
type = &mcrfpydef::PyUIGridViewType;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#include "EntityBehavior.h"
|
||||
#include "UIEntity.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UIGridPathfinding.h"
|
||||
#include "PathProvider.h"
|
||||
#include <random>
|
||||
|
|
@ -25,21 +25,21 @@ static thread_local std::mt19937 rng{std::random_device{}()};
|
|||
// Per-behavior execution functions
|
||||
// =============================================================================
|
||||
|
||||
static BehaviorOutput executeIdle(UIEntity& entity, UIGrid& grid) {
|
||||
static BehaviorOutput executeIdle(UIEntity& entity, GridData& grid) {
|
||||
return {BehaviorResult::NO_ACTION, {}};
|
||||
}
|
||||
|
||||
static BehaviorOutput executeCustom(UIEntity& entity, UIGrid& grid) {
|
||||
static BehaviorOutput executeCustom(UIEntity& entity, GridData& grid) {
|
||||
// CUSTOM does nothing built-in — step callback handles everything
|
||||
return {BehaviorResult::NO_ACTION, {}};
|
||||
}
|
||||
|
||||
static bool isCellWalkable(UIGrid& grid, int x, int y) {
|
||||
static bool isCellWalkable(GridData& grid, int x, int y) {
|
||||
if (x < 0 || x >= grid.grid_w || y < 0 || y >= grid.grid_h) return false;
|
||||
return grid.isWalkable(x, y); // #332
|
||||
}
|
||||
|
||||
static BehaviorOutput executeNoise(UIEntity& entity, UIGrid& grid, bool include_diagonals) {
|
||||
static BehaviorOutput executeNoise(UIEntity& entity, GridData& grid, bool include_diagonals) {
|
||||
int cx = entity.cell_position.x;
|
||||
int cy = entity.cell_position.y;
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ static BehaviorOutput executeNoise(UIEntity& entity, UIGrid& grid, bool include_
|
|||
return {BehaviorResult::MOVED, target};
|
||||
}
|
||||
|
||||
static BehaviorOutput executePath(UIEntity& entity, UIGrid& grid) {
|
||||
static BehaviorOutput executePath(UIEntity& entity, GridData& grid) {
|
||||
auto& behavior = entity.behavior;
|
||||
|
||||
if (behavior.path_step_index >= static_cast<int>(behavior.current_path.size())) {
|
||||
|
|
@ -87,7 +87,7 @@ static BehaviorOutput executePath(UIEntity& entity, UIGrid& grid) {
|
|||
return {BehaviorResult::MOVED, target};
|
||||
}
|
||||
|
||||
static BehaviorOutput executeWaypoint(UIEntity& entity, UIGrid& grid) {
|
||||
static BehaviorOutput executeWaypoint(UIEntity& entity, GridData& grid) {
|
||||
auto& behavior = entity.behavior;
|
||||
|
||||
if (behavior.waypoints.empty()) {
|
||||
|
|
@ -136,7 +136,7 @@ static BehaviorOutput executeWaypoint(UIEntity& entity, UIGrid& grid) {
|
|||
return {BehaviorResult::MOVED, target};
|
||||
}
|
||||
|
||||
static BehaviorOutput executePatrol(UIEntity& entity, UIGrid& grid) {
|
||||
static BehaviorOutput executePatrol(UIEntity& entity, GridData& grid) {
|
||||
auto& behavior = entity.behavior;
|
||||
|
||||
if (behavior.waypoints.empty()) {
|
||||
|
|
@ -184,7 +184,7 @@ static BehaviorOutput executePatrol(UIEntity& entity, UIGrid& grid) {
|
|||
return {BehaviorResult::MOVED, target};
|
||||
}
|
||||
|
||||
static BehaviorOutput executeLoop(UIEntity& entity, UIGrid& grid) {
|
||||
static BehaviorOutput executeLoop(UIEntity& entity, GridData& grid) {
|
||||
auto& behavior = entity.behavior;
|
||||
|
||||
if (behavior.waypoints.empty()) {
|
||||
|
|
@ -227,7 +227,7 @@ static BehaviorOutput executeLoop(UIEntity& entity, UIGrid& grid) {
|
|||
return {BehaviorResult::MOVED, target};
|
||||
}
|
||||
|
||||
static BehaviorOutput executeSleep(UIEntity& entity, UIGrid& grid) {
|
||||
static BehaviorOutput executeSleep(UIEntity& entity, GridData& grid) {
|
||||
auto& behavior = entity.behavior;
|
||||
|
||||
if (behavior.sleep_turns_remaining > 0) {
|
||||
|
|
@ -243,7 +243,7 @@ static BehaviorOutput executeSleep(UIEntity& entity, UIGrid& grid) {
|
|||
// PathProvider. FLEE differs only in which map is stored in the provider -
|
||||
// DijkstraProvider over an inverted DijkstraMap descends away from the threat,
|
||||
// which matches the old max-distance-neighbor behavior.
|
||||
static BehaviorOutput executeProviderStep(UIEntity& entity, UIGrid& grid) {
|
||||
static BehaviorOutput executeProviderStep(UIEntity& entity, GridData& grid) {
|
||||
auto& behavior = entity.behavior;
|
||||
if (!behavior.path_provider) {
|
||||
return {BehaviorResult::NO_ACTION, {}};
|
||||
|
|
@ -266,7 +266,7 @@ static BehaviorOutput executeProviderStep(UIEntity& entity, UIGrid& grid) {
|
|||
// =============================================================================
|
||||
// Main dispatch
|
||||
// =============================================================================
|
||||
BehaviorOutput executeBehavior(UIEntity& entity, UIGrid& grid) {
|
||||
BehaviorOutput executeBehavior(UIEntity& entity, GridData& grid) {
|
||||
switch (entity.behavior.type) {
|
||||
case BehaviorType::IDLE: return executeIdle(entity, grid);
|
||||
case BehaviorType::CUSTOM: return executeCustom(entity, grid);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
// Forward declarations
|
||||
class UIEntity;
|
||||
class UIGrid;
|
||||
class GridData;
|
||||
class DijkstraMap;
|
||||
class PathProvider;
|
||||
|
||||
|
|
@ -71,4 +71,4 @@ struct EntityBehavior {
|
|||
// =============================================================================
|
||||
// Behavior execution - does NOT modify entity position, just returns intent
|
||||
// =============================================================================
|
||||
BehaviorOutput executeBehavior(UIEntity& entity, UIGrid& grid);
|
||||
BehaviorOutput executeBehavior(UIEntity& entity, GridData& grid);
|
||||
|
|
|
|||
|
|
@ -621,6 +621,22 @@ void GameEngine::processEvent(const sf::Event& event)
|
|||
}
|
||||
return;
|
||||
}
|
||||
// #363 - The cursor left the window. No further MouseMoved will arrive, so
|
||||
// whatever is hovered must be exited now or it stays hovered forever.
|
||||
//
|
||||
// #367 - Losing focus strands hover identically. The cursor may still be physically
|
||||
// over the window, but an unfocused window is delivered no MouseMoved, so the hover
|
||||
// walk never runs and the last hovered drawable stays lit while the game sits in the
|
||||
// background. Once unfocused we genuinely do not know where the pointer is, and
|
||||
// "hovering nothing" is the only truthful state to hold; the next MouseMoved after
|
||||
// focus returns restores hover.
|
||||
else if (event.type == sf::Event::MouseLeft || event.type == sf::Event::LostFocus)
|
||||
{
|
||||
if (auto* pyscene = dynamic_cast<PyScene*>(currentScene())) {
|
||||
pyscene->do_mouse_leave();
|
||||
}
|
||||
return;
|
||||
}
|
||||
else
|
||||
return;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,37 +2,80 @@
|
|||
#include "GridData.h"
|
||||
#include "UIEntity.h"
|
||||
#include "PyTexture.h"
|
||||
#include "UIGrid.h" // #313 - markDirty forwards to the UIGrid subobject
|
||||
#include "UIGridView.h" // #313 - and notifies owning_view
|
||||
#include "UIGridView.h" // #313/#359 - markDirty notifies registered views
|
||||
#include "PythonObjectCache.h" // #361 - GridData owns its own cache serial
|
||||
#include <algorithm>
|
||||
|
||||
// #313 - Render invalidation from the data layer (see GridData.h).
|
||||
// GridData is never independently heap-allocated (always a UIGrid base
|
||||
// subobject), so the downcast is valid; remove once #252 allows pure GridData.
|
||||
// #313/#361 - Render invalidation from the data layer (see GridData.h).
|
||||
// Notifies every registered view; there is no longer a drawable half of `this`
|
||||
// to notify.
|
||||
void GridData::markDirty() {
|
||||
content_generation++; // #351 - content changed; invalidate view early-out
|
||||
static_cast<UIGrid*>(this)->UIDrawable::markDirty();
|
||||
if (auto view = owning_view.lock()) {
|
||||
view->markDirty();
|
||||
for (auto& weak_view : views) {
|
||||
if (auto view = weak_view.lock()) view->markDirty();
|
||||
}
|
||||
}
|
||||
|
||||
void GridData::markCompositeDirty() {
|
||||
content_generation++; // #351 - content changed; invalidate view early-out
|
||||
static_cast<UIGrid*>(this)->UIDrawable::markCompositeDirty();
|
||||
if (auto view = owning_view.lock()) {
|
||||
view->markCompositeDirty();
|
||||
for (auto& weak_view : views) {
|
||||
if (auto view = weak_view.lock()) view->markCompositeDirty();
|
||||
}
|
||||
}
|
||||
|
||||
// #359 - View registry (see GridData.h). Kept small (typically 1-2 entries:
|
||||
// split-screen / minimap use cases), so linear scan is fine.
|
||||
void GridData::registerView(const std::shared_ptr<UIGridView>& view) {
|
||||
if (!view) return;
|
||||
for (auto& weak_view : views) {
|
||||
if (weak_view.lock() == view) return; // already registered
|
||||
}
|
||||
views.push_back(view);
|
||||
}
|
||||
|
||||
void GridData::unregisterView(UIGridView* view) {
|
||||
views.erase(
|
||||
std::remove_if(views.begin(), views.end(),
|
||||
[view](const std::weak_ptr<UIGridView>& weak_view) {
|
||||
auto locked = weak_view.lock();
|
||||
return !locked || locked.get() == view; // prune expired too
|
||||
}),
|
||||
views.end());
|
||||
}
|
||||
|
||||
std::shared_ptr<UIGridView> GridData::primaryView() const {
|
||||
for (auto& weak_view : views) {
|
||||
if (auto view = weak_view.lock()) return view;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
GridData::GridData()
|
||||
{
|
||||
entities = std::make_shared<std::vector<std::shared_ptr<UIEntity>>>(); // #329
|
||||
children = std::make_shared<std::vector<std::shared_ptr<UIDrawable>>>();
|
||||
// #364: children live on UIGridView, not here -- see GridData.h.
|
||||
}
|
||||
|
||||
// #361 - The real constructor. Cell size comes from the texture and is mirrored
|
||||
// into the plane dimensions; a GridData with no texture falls back to 16x16 so
|
||||
// tile<->pixel math stays defined for a map that is never drawn.
|
||||
GridData::GridData(int gx, int gy, std::shared_ptr<PyTexture> texture)
|
||||
: ptex(texture)
|
||||
{
|
||||
entities = std::make_shared<std::vector<std::shared_ptr<UIEntity>>>(); // #329
|
||||
cell_width_px = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
|
||||
cell_height_px = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
|
||||
initStorage(gx, gy, this);
|
||||
}
|
||||
|
||||
GridData::~GridData()
|
||||
{
|
||||
// #361: GridData carries its own PythonObjectCache serial now that it is
|
||||
// not a UIDrawable (which used to do this in ~UIDrawable).
|
||||
if (serial_number != 0) {
|
||||
PythonObjectCache::getInstance().remove(serial_number);
|
||||
}
|
||||
|
||||
// #270: Null out parent_grid in all layers so surviving shared_ptrs
|
||||
// (held by Python wrappers) don't dangle after grid destruction
|
||||
for (auto& layer : layers) {
|
||||
|
|
|
|||
112
src/GridData.h
112
src/GridData.h
|
|
@ -28,9 +28,14 @@ class UIDrawable;
|
|||
class UIGridView;
|
||||
class PyTexture;
|
||||
|
||||
// GridData is a MAP, not a widget (#361). It has no position, no size, no
|
||||
// camera, and no render(). Only UIGridView -- which holds a camera and points
|
||||
// at a GridData -- is a UIDrawable. `mcrfpy.Grid` is a GridView that creates
|
||||
// its own GridData; N views may share one GridData (split-screen, minimap).
|
||||
class GridData {
|
||||
public:
|
||||
GridData();
|
||||
GridData(int gx, int gy, std::shared_ptr<PyTexture> texture);
|
||||
virtual ~GridData();
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -38,9 +43,25 @@ public:
|
|||
// =========================================================================
|
||||
int grid_w = 0, grid_h = 0;
|
||||
|
||||
// #313 - Cell pixel dimensions, mirrored from the owning UIGrid's texture
|
||||
// at construction (UIGrid::ptex is write-once, ctor only). Lets the data
|
||||
// layer do tile<->pixel math without reaching into rendering state.
|
||||
// #361 - The tile atlas. This is DATA, not rendering state: it is what
|
||||
// defines the cell size in pixels, which every tile<->pixel conversion in
|
||||
// the engine (entity positions, layer geometry, a view's camera math)
|
||||
// depends on. Views copy it to draw with; they do not own it. Write-once
|
||||
// at construction, so cell_width_px/cell_height_px never go stale.
|
||||
std::shared_ptr<PyTexture> ptex;
|
||||
std::shared_ptr<PyTexture> getTexture() const { return ptex; }
|
||||
|
||||
static constexpr int DEFAULT_CELL_WIDTH = 16;
|
||||
static constexpr int DEFAULT_CELL_HEIGHT = 16;
|
||||
|
||||
// #361 - PythonObjectCache identity. GridData used to inherit this from
|
||||
// UIDrawable; now that it is not a drawable it carries its own, so that
|
||||
// `entity.grid`, `view.grid_data` and the GridPoint/layer back-references
|
||||
// all resolve to ONE Python wrapper instead of a fresh one per access.
|
||||
uint64_t serial_number = 0;
|
||||
|
||||
// #313 - Cell pixel dimensions, mirrored from ptex at construction. Lets
|
||||
// the data layer do tile<->pixel math without reaching into a view.
|
||||
int cell_width_px = 16;
|
||||
int cell_height_px = 16;
|
||||
int cell_width() const { return cell_width_px; }
|
||||
|
|
@ -119,37 +140,47 @@ public:
|
|||
std::shared_ptr<GridLayer> getLayerByName(const std::string& name);
|
||||
static bool isProtectedLayerName(const std::string& name);
|
||||
|
||||
// =========================================================================
|
||||
// Cell callbacks (#142, #230)
|
||||
// =========================================================================
|
||||
std::unique_ptr<PyCellHoverCallable> on_cell_enter_callable;
|
||||
std::unique_ptr<PyCellHoverCallable> on_cell_exit_callable;
|
||||
std::unique_ptr<PyClickCallable> on_cell_click_callable;
|
||||
std::optional<sf::Vector2i> hovered_cell;
|
||||
std::optional<sf::Vector2i> last_clicked_cell;
|
||||
// #355 - Cell input (callbacks, hovered/last-clicked cell, subclass-dispatch
|
||||
// cache) moved to UIGridView: input is a property of the VIEW (camera), not of
|
||||
// the data. Two views over one GridData must track hover independently, and
|
||||
// the subclassable Python type (mcrfpy.Grid) IS UIGridView.
|
||||
|
||||
struct CellCallbackCache {
|
||||
uint32_t generation = 0;
|
||||
bool valid = false;
|
||||
bool has_on_cell_click = false;
|
||||
bool has_on_cell_enter = false;
|
||||
bool has_on_cell_exit = false;
|
||||
};
|
||||
CellCallbackCache cell_callback_cache;
|
||||
|
||||
// fireCellClick/Enter/Exit and refreshCellCallbackCache are on UIGrid
|
||||
// because they need access to UIDrawable::serial_number/is_python_subclass
|
||||
// #364 - UIDrawable children (speech bubbles, markers, range indicators) moved
|
||||
// to UIGridView. They are OVERLAYS painted over the map through one camera, not
|
||||
// world contents: nothing collides with them, they occupy no cell, and the turn
|
||||
// manager cannot see them. Entities are the world contents, and those stay here,
|
||||
// shared by every view.
|
||||
//
|
||||
// Ownership follows from that. A UIDrawable has exactly one `parent`, but a
|
||||
// GridData may have N views -- so a child owned by the data could only ever name
|
||||
// one of them as parent, arbitrarily, and would dangle if that view died while
|
||||
// the others kept rendering it. Owned by the view, a child's parent is a real
|
||||
// scene-graph drawable, so its dirty push reaches the view and every caching
|
||||
// ancestor above it, and its grid-world position resolves through exactly one
|
||||
// camera.
|
||||
|
||||
// =========================================================================
|
||||
// UIDrawable children (speech bubbles, effects, overlays)
|
||||
// #252/#359 - GridView back-references. Multiple GridViews can share one
|
||||
// GridData (split-screen, minimap, multiple cameras); a single weak_ptr
|
||||
// cannot represent that, so this is a vector. A fresh GridData is only
|
||||
// ever produced by UIGridView::init_with_data (the Grid() factory path --
|
||||
// UIGridView::init_explicit_view / set_grid can only ATTACH to an
|
||||
// EXISTING GridData, never create one), so views.front() is always the
|
||||
// view that created this data: that ordering guarantee is what lets
|
||||
// primaryView() give UIEntity::get_grid a deterministic (not arbitrary)
|
||||
// answer for API identity, even once secondary views are registered.
|
||||
// =========================================================================
|
||||
std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>> children;
|
||||
bool children_need_sort = true;
|
||||
std::vector<std::weak_ptr<UIGridView>> views;
|
||||
|
||||
// =========================================================================
|
||||
// #252 - Owning GridView back-reference (for Entity.grid → GridView lookup)
|
||||
// =========================================================================
|
||||
std::weak_ptr<UIGridView> owning_view;
|
||||
// Append view (idempotent -- a view already present is not duplicated).
|
||||
void registerView(const std::shared_ptr<UIGridView>& view);
|
||||
// Remove view by identity (prunes expired entries too). Safe to call even
|
||||
// if view was never registered.
|
||||
void unregisterView(UIGridView* view);
|
||||
// The view that created this GridData (views.front()), or the first
|
||||
// still-alive view if the creator has since been destroyed while
|
||||
// secondary views live on. nullptr if no view is alive.
|
||||
std::shared_ptr<UIGridView> primaryView() const;
|
||||
|
||||
// #351 - Monotonic counter bumped whenever grid content drawn into a view's
|
||||
// RenderTexture changes (entities added/removed/moved, sprite changes, layer
|
||||
|
|
@ -158,14 +189,23 @@ public:
|
|||
// live on the view and are compared there directly, not counted here.
|
||||
uint64_t content_generation = 0;
|
||||
|
||||
// #313 - Render invalidation from the data layer. Entities hold
|
||||
// #313/#359 - Render invalidation from the data layer. Entities hold
|
||||
// shared_ptr<GridData> but still need to invalidate rendering when their
|
||||
// visual state changes. These set the dirty flags on the UIGrid subobject
|
||||
// (GridData is never independently heap-allocated -- always a UIGrid base)
|
||||
// AND notify owning_view, covering both render paths (a bare _GridData
|
||||
// rendered directly, and the normal GridView). Within UIGrid itself the
|
||||
// UIDrawable versions win via using-declarations (see UIGrid.h).
|
||||
// Multi-view broadcast (secondary views) is deferred to #252.
|
||||
// visual state changes, so the data notifies every registered view (see
|
||||
// `views` above) -- and each view then pushes up its OWN ancestor chain,
|
||||
// since a Frame(cache_subtree=True) wrapping ANY of the N views needs its
|
||||
// cache invalidated, not just the creator's.
|
||||
//
|
||||
// #361: these used to ALSO downcast `this` to UIGrid and set the dirty
|
||||
// flags on its UIDrawable half, feeding a second, stale render path (a
|
||||
// bare _GridData appended to a scene drew the map through a frozen camera).
|
||||
// That path is gone: GridData is not a drawable, so there is nothing to
|
||||
// notify but the views.
|
||||
//
|
||||
// Note the #351 render early-out does NOT depend on this push -- it polls
|
||||
// content_generation directly in UIGridView::render(). This is only for
|
||||
// bottom-up propagation through a view's parent chain, which a top-down
|
||||
// render traversal cannot poll for.
|
||||
void markDirty();
|
||||
void markCompositeDirty();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#include "GridLayers.h"
|
||||
#include "McRFPy_Doc.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UIGridView.h"
|
||||
#include "UIEntity.h"
|
||||
#include "PyColor.h"
|
||||
|
|
@ -941,7 +941,7 @@ int PyGridLayerAPI::ColorLayer_init(PyColorLayerObject* self, PyObject* args, Py
|
|||
|
||||
// Validate grid kwarg type up-front (before allocating data).
|
||||
if (grid_obj && grid_obj != Py_None &&
|
||||
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridType) &&
|
||||
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyGridDataType) &&
|
||||
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
|
||||
PyErr_SetString(PyExc_TypeError, "grid must be a mcrfpy.Grid instance");
|
||||
return -1;
|
||||
|
|
@ -1773,8 +1773,8 @@ PyObject* PyGridLayerAPI::ColorLayer_get_grid(PyColorLayerObject* self, void* cl
|
|||
}
|
||||
|
||||
// No cached wrapper — allocate a new one
|
||||
auto* grid_type = &mcrfpydef::PyUIGridType;
|
||||
PyUIGridObject* py_grid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
|
||||
auto* grid_type = &mcrfpydef::PyGridDataType;
|
||||
PyGridDataObject* py_grid = (PyGridDataObject*)grid_type->tp_alloc(grid_type, 0);
|
||||
if (!py_grid) return NULL;
|
||||
|
||||
py_grid->data = self->grid;
|
||||
|
|
@ -1810,21 +1810,21 @@ int PyGridLayerAPI::ColorLayer_set_grid(PyColorLayerObject* self, PyObject* valu
|
|||
|
||||
// Validate it's a Grid (GridView) or internal _GridData
|
||||
if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType) &&
|
||||
!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) {
|
||||
!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyGridDataType)) {
|
||||
PyErr_SetString(PyExc_TypeError, "grid must be a Grid object or None");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Extract UIGrid shared_ptr from Grid (UIGridView) or _GridData (UIGrid)
|
||||
std::shared_ptr<UIGrid> target_grid;
|
||||
std::shared_ptr<GridData> target_grid;
|
||||
if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
|
||||
auto* pyview = (PyUIGridViewObject*)value;
|
||||
if (pyview->data->grid_data) {
|
||||
// GridView's grid_data is aliased from a UIGrid, cast back
|
||||
target_grid = std::static_pointer_cast<UIGrid>(pyview->data->grid_data);
|
||||
target_grid = std::static_pointer_cast<GridData>(pyview->data->grid_data);
|
||||
}
|
||||
} else if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) {
|
||||
target_grid = ((PyUIGridObject*)value)->data;
|
||||
} else if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyGridDataType)) {
|
||||
target_grid = ((PyGridDataObject*)value)->data;
|
||||
}
|
||||
if (!target_grid) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "Grid has no data");
|
||||
|
|
@ -1843,7 +1843,7 @@ int PyGridLayerAPI::ColorLayer_set_grid(PyColorLayerObject* self, PyObject* valu
|
|||
}
|
||||
|
||||
// Check for protected names
|
||||
if (!self->data->name.empty() && UIGrid::isProtectedLayerName(self->data->name)) {
|
||||
if (!self->data->name.empty() && GridData::isProtectedLayerName(self->data->name)) {
|
||||
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", self->data->name.c_str());
|
||||
self->grid.reset();
|
||||
return -1;
|
||||
|
|
@ -2005,7 +2005,7 @@ int PyGridLayerAPI::TileLayer_init(PyTileLayerObject* self, PyObject* args, PyOb
|
|||
|
||||
// Validate grid kwarg type up-front (before allocating data).
|
||||
if (grid_obj && grid_obj != Py_None &&
|
||||
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridType) &&
|
||||
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyGridDataType) &&
|
||||
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
|
||||
PyErr_SetString(PyExc_TypeError, "grid must be a mcrfpy.Grid instance");
|
||||
return -1;
|
||||
|
|
@ -2497,8 +2497,8 @@ PyObject* PyGridLayerAPI::TileLayer_get_grid(PyTileLayerObject* self, void* clos
|
|||
}
|
||||
|
||||
// No cached wrapper — allocate a new one
|
||||
auto* grid_type = &mcrfpydef::PyUIGridType;
|
||||
PyUIGridObject* py_grid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
|
||||
auto* grid_type = &mcrfpydef::PyGridDataType;
|
||||
PyGridDataObject* py_grid = (PyGridDataObject*)grid_type->tp_alloc(grid_type, 0);
|
||||
if (!py_grid) return NULL;
|
||||
|
||||
py_grid->data = self->grid;
|
||||
|
|
@ -2534,20 +2534,20 @@ int PyGridLayerAPI::TileLayer_set_grid(PyTileLayerObject* self, PyObject* value,
|
|||
|
||||
// Validate it's a Grid (GridView) or internal _GridData
|
||||
if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType) &&
|
||||
!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) {
|
||||
!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyGridDataType)) {
|
||||
PyErr_SetString(PyExc_TypeError, "grid must be a Grid object or None");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Extract UIGrid shared_ptr from Grid (UIGridView) or _GridData (UIGrid)
|
||||
std::shared_ptr<UIGrid> target_grid;
|
||||
std::shared_ptr<GridData> target_grid;
|
||||
if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
|
||||
auto* pyview = (PyUIGridViewObject*)value;
|
||||
if (pyview->data->grid_data) {
|
||||
target_grid = std::static_pointer_cast<UIGrid>(pyview->data->grid_data);
|
||||
target_grid = std::static_pointer_cast<GridData>(pyview->data->grid_data);
|
||||
}
|
||||
} else if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) {
|
||||
target_grid = ((PyUIGridObject*)value)->data;
|
||||
} else if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyGridDataType)) {
|
||||
target_grid = ((PyGridDataObject*)value)->data;
|
||||
}
|
||||
if (!target_grid) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "Grid has no data");
|
||||
|
|
@ -2566,7 +2566,7 @@ int PyGridLayerAPI::TileLayer_set_grid(PyTileLayerObject* self, PyObject* value,
|
|||
}
|
||||
|
||||
// Check for protected names
|
||||
if (!self->data->name.empty() && UIGrid::isProtectedLayerName(self->data->name)) {
|
||||
if (!self->data->name.empty() && GridData::isProtectedLayerName(self->data->name)) {
|
||||
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", self->data->name.c_str());
|
||||
self->grid.reset();
|
||||
return -1;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
#include <string>
|
||||
|
||||
// Forward declarations
|
||||
class UIGrid;
|
||||
class GridData;
|
||||
class GridData;
|
||||
class PyTexture;
|
||||
class UIEntity;
|
||||
|
|
@ -178,19 +178,19 @@ public:
|
|||
typedef struct {
|
||||
PyObject_HEAD
|
||||
std::shared_ptr<GridLayer> data;
|
||||
std::shared_ptr<UIGrid> grid; // Parent grid reference
|
||||
std::shared_ptr<GridData> grid; // Parent grid reference
|
||||
} PyGridLayerObject;
|
||||
|
||||
typedef struct {
|
||||
PyObject_HEAD
|
||||
std::shared_ptr<ColorLayer> data;
|
||||
std::shared_ptr<UIGrid> grid;
|
||||
std::shared_ptr<GridData> grid;
|
||||
} PyColorLayerObject;
|
||||
|
||||
typedef struct {
|
||||
PyObject_HEAD
|
||||
std::shared_ptr<TileLayer> data;
|
||||
std::shared_ptr<UIGrid> grid;
|
||||
std::shared_ptr<GridData> grid;
|
||||
} PyTileLayerObject;
|
||||
|
||||
// #335 - context manager returned by ColorLayer.edit()/TileLayer.edit().
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
#include "UIFrame.h"
|
||||
#include "UICaption.h"
|
||||
#include "UISprite.h"
|
||||
#include "UIGrid.h"
|
||||
#include "UIGridView.h"
|
||||
#include "GridData.h"
|
||||
#include "UIEntity.h"
|
||||
#include "ImGuiConsole.h"
|
||||
#include "PythonObjectCache.h"
|
||||
|
|
@ -118,22 +118,30 @@ void ImGuiSceneExplorer::renderDrawableNode(std::shared_ptr<UIDrawable> drawable
|
|||
// Check if this node has children
|
||||
bool hasChildren = false;
|
||||
UIFrame* frame = nullptr;
|
||||
UIGrid* grid = nullptr;
|
||||
// #358: dispatch on the asGridData() virtual (#355), not derived_type() --
|
||||
// nothing in a scene graph is ever a bare UIGRID since #252 (Grid nodes are
|
||||
// UIGridView instances), so a UIGRID enum arm here is always dead code.
|
||||
GridData* gridData = drawable->asGridData();
|
||||
// #364: overlay children hang off the VIEW; entities off the shared data.
|
||||
UIGridView* gridView = nullptr;
|
||||
|
||||
switch (drawable->derived_type()) {
|
||||
case PyObjectsEnum::UIFRAME:
|
||||
frame = static_cast<UIFrame*>(drawable.get());
|
||||
hasChildren = frame->children && !frame->children->empty();
|
||||
break;
|
||||
case PyObjectsEnum::UIGRID:
|
||||
grid = static_cast<UIGrid*>(drawable.get());
|
||||
hasChildren = (grid->entities && !grid->entities->empty()) ||
|
||||
(grid->children && !grid->children->empty());
|
||||
case PyObjectsEnum::UIGRIDVIEW:
|
||||
gridView = static_cast<UIGridView*>(drawable.get());
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (gridData) {
|
||||
hasChildren = (gridData->entities && !gridData->entities->empty()) ||
|
||||
(gridView && gridView->children && !gridView->children->empty());
|
||||
}
|
||||
|
||||
if (!hasChildren) {
|
||||
flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen;
|
||||
}
|
||||
|
|
@ -168,14 +176,14 @@ void ImGuiSceneExplorer::renderDrawableNode(std::shared_ptr<UIDrawable> drawable
|
|||
}
|
||||
}
|
||||
|
||||
if (grid) {
|
||||
if (gridData) {
|
||||
// Render entities
|
||||
if (grid->entities && !grid->entities->empty()) {
|
||||
if (gridData->entities && !gridData->entities->empty()) {
|
||||
ImGuiTreeNodeFlags entityGroupFlags = ImGuiTreeNodeFlags_OpenOnArrow;
|
||||
bool entitiesOpen = ImGui::TreeNodeEx("Entities", entityGroupFlags, "Entities (%zu)",
|
||||
grid->entities->size());
|
||||
gridData->entities->size());
|
||||
if (entitiesOpen) {
|
||||
for (auto& entity : *grid->entities) {
|
||||
for (auto& entity : *gridData->entities) {
|
||||
if (entity) {
|
||||
renderEntityNode(entity);
|
||||
}
|
||||
|
|
@ -184,13 +192,14 @@ void ImGuiSceneExplorer::renderDrawableNode(std::shared_ptr<UIDrawable> drawable
|
|||
}
|
||||
}
|
||||
|
||||
// Render grid's drawable children (overlays)
|
||||
if (grid->children && !grid->children->empty()) {
|
||||
// Render the view's drawable children (overlays) -- #364: owned by the
|
||||
// view, not the shared data, so a second view lists its own.
|
||||
if (gridView && gridView->children && !gridView->children->empty()) {
|
||||
ImGuiTreeNodeFlags overlayGroupFlags = ImGuiTreeNodeFlags_OpenOnArrow;
|
||||
bool overlaysOpen = ImGui::TreeNodeEx("Overlays", overlayGroupFlags, "Overlays (%zu)",
|
||||
grid->children->size());
|
||||
gridView->children->size());
|
||||
if (overlaysOpen) {
|
||||
for (auto& child : *grid->children) {
|
||||
for (auto& child : *gridView->children) {
|
||||
if (child) {
|
||||
renderDrawableNode(child, depth + 1);
|
||||
}
|
||||
|
|
@ -285,8 +294,7 @@ const char* ImGuiSceneExplorer::getTypeName(UIDrawable* drawable) {
|
|||
case PyObjectsEnum::UIFRAME: return "Frame";
|
||||
case PyObjectsEnum::UICAPTION: return "Caption";
|
||||
case PyObjectsEnum::UISPRITE: return "Sprite";
|
||||
case PyObjectsEnum::UIGRID: return "Grid";
|
||||
case PyObjectsEnum::UIGRIDVIEW: return "GridView";
|
||||
case PyObjectsEnum::UIGRIDVIEW: return "Grid";
|
||||
case PyObjectsEnum::UILINE: return "Line";
|
||||
case PyObjectsEnum::UICIRCLE: return "Circle";
|
||||
case PyObjectsEnum::UIARC: return "Arc";
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class GameEngine;
|
|||
class UIDrawable;
|
||||
class UIEntity;
|
||||
class UIFrame;
|
||||
class UIGrid;
|
||||
class GridData;
|
||||
|
||||
/**
|
||||
* @brief ImGui-based scene tree explorer for debugging
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@
|
|||
#include <emscripten.h>
|
||||
#endif
|
||||
#include <sys/stat.h> // mkdir
|
||||
#include <unordered_set> // #359 - dedupe shared GridData during find/findAll walk
|
||||
// ImGui is only available for SFML builds
|
||||
#if !defined(MCRF_HEADLESS) && !defined(MCRF_SDL2)
|
||||
#include "ImGuiConsole.h"
|
||||
|
|
@ -471,7 +472,7 @@ PyObject* PyInit_mcrfpy()
|
|||
// #184: Set the metaclass for UI types that support callback methods
|
||||
// This must be done BEFORE PyType_Ready is called on these types
|
||||
PyTypeObject* ui_types_with_callbacks[] = {
|
||||
&PyUIFrameType, &PyUICaptionType, &PyUISpriteType, &PyUIGridType,
|
||||
&PyUIFrameType, &PyUICaptionType, &PyUISpriteType, &PyGridDataType,
|
||||
&PyUILineType, &PyUICircleType, &PyUIArcType, &PyViewport3DType,
|
||||
&mcrfpydef::PyUIGridViewType,
|
||||
nullptr
|
||||
|
|
@ -491,7 +492,8 @@ PyObject* PyInit_mcrfpy()
|
|||
/*UI widgets*/
|
||||
&PyUICaptionType, &PyUISpriteType, &PyUIFrameType, &PyUIEntityType,
|
||||
&PyUILineType, &PyUICircleType, &PyUIArcType, &PyViewport3DType,
|
||||
&mcrfpydef::PyUIGridViewType, // #252: GridView IS the primary "Grid" type
|
||||
&mcrfpydef::PyUIGridViewType, // #252/#361: the widget. "Grid" is an alias.
|
||||
&PyGridDataType, // #361: the map. Public, and NOT a drawable.
|
||||
|
||||
/*3D entities*/
|
||||
&mcrfpydef::PyEntity3DType, &mcrfpydef::PyEntityCollection3DType,
|
||||
|
|
@ -551,9 +553,6 @@ PyObject* PyInit_mcrfpy()
|
|||
// Types that are used internally but NOT exported to module namespace (#189)
|
||||
// These still need PyType_Ready() but are not added to module
|
||||
PyTypeObject* internal_types[] = {
|
||||
/*#252: internal grid data type - UIGrid is now internal, GridView is "Grid"*/
|
||||
&PyUIGridType,
|
||||
|
||||
/*game map data - returned by Grid.at() but not directly instantiable*/
|
||||
&PyUIGridPointType,
|
||||
|
||||
|
|
@ -651,7 +650,7 @@ PyObject* PyInit_mcrfpy()
|
|||
PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist);
|
||||
PyUICaptionType.tp_weaklistoffset = offsetof(PyUICaptionObject, weakreflist);
|
||||
PyUISpriteType.tp_weaklistoffset = offsetof(PyUISpriteObject, weakreflist);
|
||||
PyUIGridType.tp_weaklistoffset = offsetof(PyUIGridObject, weakreflist);
|
||||
PyGridDataType.tp_weaklistoffset = offsetof(PyGridDataObject, weakreflist);
|
||||
mcrfpydef::PyUIGridViewType.tp_weaklistoffset = offsetof(PyUIGridViewObject, weakreflist);
|
||||
PyUIEntityType.tp_weaklistoffset = offsetof(PyUIEntityObject, weakreflist);
|
||||
PyUILineType.tp_weaklistoffset = offsetof(PyUILineObject, weakreflist);
|
||||
|
|
@ -696,10 +695,13 @@ PyObject* PyInit_mcrfpy()
|
|||
t = internal_types[i];
|
||||
}
|
||||
|
||||
// #252: Add "GridView" as an alias for the Grid type (which is PyUIGridViewType)
|
||||
// This allows both mcrfpy.Grid(...) and mcrfpy.GridView(...) to work
|
||||
// #361: "Grid" is an alias for GridView -- the SAME type object, not a subclass,
|
||||
// so isinstance() works in both directions and there is no MRO to explain.
|
||||
// It is NOT deprecated: now that GridData is a separate public type,
|
||||
// Grid(grid_size=...) is simply "a GridView that creates its own GridData" --
|
||||
// a constructor overload, not legacy behavior.
|
||||
Py_INCREF(&mcrfpydef::PyUIGridViewType);
|
||||
PyModule_AddObject(m, "GridView", (PyObject*)&mcrfpydef::PyUIGridViewType);
|
||||
PyModule_AddObject(m, "Grid", (PyObject*)&mcrfpydef::PyUIGridViewType);
|
||||
|
||||
// Add default_font and default_texture to module
|
||||
McRFPy_API::default_font = std::make_shared<PyFont>("assets/JetbrainsMono.ttf");
|
||||
|
|
@ -1587,11 +1589,21 @@ static bool name_matches_pattern(const std::string& name, const std::string& pat
|
|||
return pattern_pos == pattern.length() && name_pos == name.length();
|
||||
}
|
||||
|
||||
// Helper to recursively search a collection for named elements
|
||||
static void find_in_collection(std::vector<std::shared_ptr<UIDrawable>>* collection, const std::string& pattern,
|
||||
bool find_all, PyObject* results) {
|
||||
// Also search Grid entities (forward-declared: find_in_collection recurses
|
||||
// into grid children via GridData, and the two helpers are mutually called).
|
||||
static void find_in_grid_entities(GridData* grid, const std::string& pattern,
|
||||
bool find_all, PyObject* results);
|
||||
|
||||
// Helper to recursively search a collection for named elements.
|
||||
// visited_grids: #359 - N views can share ONE GridData (split-screen/minimap).
|
||||
// asGridData() returns the same GridData* from each of them, so without this set
|
||||
// the shared grid's entities and overlay children are walked (and appended) once
|
||||
// per view -- findAll() would return N duplicate wrappers for the same object.
|
||||
static void find_in_collection(std::vector<std::shared_ptr<UIDrawable>>* collection, const std::string& pattern,
|
||||
bool find_all, PyObject* results,
|
||||
std::unordered_set<GridData*>& visited_grids) {
|
||||
if (!collection) return;
|
||||
|
||||
|
||||
for (auto& drawable : *collection) {
|
||||
if (!drawable) continue;
|
||||
|
||||
|
|
@ -1631,16 +1643,6 @@ static void find_in_collection(std::vector<std::shared_ptr<UIDrawable>>* collect
|
|||
}
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIGRID: {
|
||||
auto grid = std::static_pointer_cast<UIGrid>(drawable);
|
||||
auto type = &mcrfpydef::PyUIGridType;
|
||||
auto o = (PyUIGridObject*)type->tp_alloc(type, 0);
|
||||
if (o) {
|
||||
o->data = grid;
|
||||
py_obj = (PyObject*)o;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIGRIDVIEW: {
|
||||
auto gridview = std::static_pointer_cast<UIGridView>(drawable);
|
||||
auto type = &mcrfpydef::PyUIGridViewType;
|
||||
|
|
@ -1671,39 +1673,74 @@ static void find_in_collection(std::vector<std::shared_ptr<UIDrawable>>* collect
|
|||
// Recursively search in Frame children
|
||||
if (drawable->derived_type() == PyObjectsEnum::UIFRAME) {
|
||||
auto frame = std::static_pointer_cast<UIFrame>(drawable);
|
||||
find_in_collection(frame->children.get(), pattern, find_all, results);
|
||||
find_in_collection(frame->children.get(), pattern, find_all, results, visited_grids);
|
||||
if (!find_all && PyList_Size(results) > 0) {
|
||||
return; // Found one, stop searching
|
||||
}
|
||||
}
|
||||
|
||||
// #364 - A grid's overlay children belong to the VIEW, so they are searched
|
||||
// per view and NOT deduplicated: two views over one GridData hold different
|
||||
// children, and each must be visited.
|
||||
if (drawable->derived_type() == PyObjectsEnum::UIGRIDVIEW) {
|
||||
auto view = std::static_pointer_cast<UIGridView>(drawable);
|
||||
find_in_collection(view->children.get(), pattern, find_all, results, visited_grids);
|
||||
if (!find_all && PyList_Size(results) > 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// #357 - Recursively search a grid's entities. Dispatch via asGridData()
|
||||
// (#355), not derived_type(): a real mcrfpy.Grid() is a UIGridView
|
||||
// (PyObjectsEnum::UIGRIDVIEW), so gating on UIGRID here would be
|
||||
// structurally unreachable again.
|
||||
// #359 - entities are shared world content, so visit each GridData exactly
|
||||
// once per search: several views may point at the same data.
|
||||
if (GridData* gd = drawable->asGridData()) {
|
||||
if (visited_grids.insert(gd).second) {
|
||||
find_in_grid_entities(gd, pattern, find_all, results);
|
||||
if (!find_all && PyList_Size(results) > 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also search Grid entities
|
||||
static void find_in_grid_entities(UIGrid* grid, const std::string& pattern,
|
||||
static void find_in_grid_entities(GridData* grid, const std::string& pattern,
|
||||
bool find_all, PyObject* results) {
|
||||
if (!grid || !grid->entities) return;
|
||||
|
||||
|
||||
for (auto& entity : *grid->entities) {
|
||||
if (!entity) continue;
|
||||
|
||||
|
||||
// Entities delegate name to their sprite
|
||||
if (name_matches_pattern(entity->sprite.name, pattern)) {
|
||||
auto type = &mcrfpydef::PyUIEntityType;
|
||||
auto o = (PyUIEntityObject*)type->tp_alloc(type, 0);
|
||||
if (o) {
|
||||
o->data = entity;
|
||||
PyObject* py_obj = (PyObject*)o;
|
||||
|
||||
if (find_all) {
|
||||
PyList_Append(results, py_obj);
|
||||
Py_DECREF(py_obj);
|
||||
} else {
|
||||
PyList_Append(results, py_obj);
|
||||
Py_DECREF(py_obj);
|
||||
return;
|
||||
// #266/#357: the cache is the ONLY correct way to wrap an existing
|
||||
// entity. A fresh tp_alloc would (a) return a base-class Entity for
|
||||
// a Python subclass instance, losing identity and its __dict__, and
|
||||
// (b) leak the entity's strong self-reference when that duplicate
|
||||
// wrapper is deallocated -- PyUIEntityType's tp_dealloc clears
|
||||
// data->pyobject unconditionally. Mirrors UIEntityCollection::getitem.
|
||||
PyObject* py_obj = nullptr;
|
||||
if (entity->serial_number != 0) {
|
||||
py_obj = PythonObjectCache::getInstance().lookup(entity->serial_number); // new ref
|
||||
}
|
||||
if (!py_obj) {
|
||||
auto type = &mcrfpydef::PyUIEntityType;
|
||||
auto o = (PyUIEntityObject*)type->tp_alloc(type, 0);
|
||||
if (o) {
|
||||
o->data = entity;
|
||||
o->weakreflist = NULL;
|
||||
py_obj = (PyObject*)o;
|
||||
}
|
||||
}
|
||||
|
||||
if (py_obj) {
|
||||
PyList_Append(results, py_obj);
|
||||
Py_DECREF(py_obj);
|
||||
if (!find_all) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1739,20 +1776,11 @@ PyObject* McRFPy_API::_find(PyObject* self, PyObject* args) {
|
|||
ui_elements = current->ui_elements;
|
||||
}
|
||||
|
||||
// Search the scene's UI elements
|
||||
find_in_collection(ui_elements.get(), name, false, results);
|
||||
|
||||
// Also search all grids in the scene for entities
|
||||
if (PyList_Size(results) == 0 && ui_elements) {
|
||||
for (auto& drawable : *ui_elements) {
|
||||
if (drawable && drawable->derived_type() == PyObjectsEnum::UIGRID) {
|
||||
auto grid = std::static_pointer_cast<UIGrid>(drawable);
|
||||
find_in_grid_entities(grid.get(), name, false, results);
|
||||
if (PyList_Size(results) > 0) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search the scene's UI elements. find_in_collection recurses into a
|
||||
// grid's entities and overlay children via asGridData() (#355/#357).
|
||||
std::unordered_set<GridData*> visited_grids; // #359 - shared GridData visited once
|
||||
find_in_collection(ui_elements.get(), name, false, results, visited_grids);
|
||||
|
||||
// Return the first result or None
|
||||
if (PyList_Size(results) > 0) {
|
||||
PyObject* result = PyList_GetItem(results, 0);
|
||||
|
|
@ -1796,19 +1824,11 @@ PyObject* McRFPy_API::_findAll(PyObject* self, PyObject* args) {
|
|||
ui_elements = current->ui_elements;
|
||||
}
|
||||
|
||||
// Search the scene's UI elements
|
||||
find_in_collection(ui_elements.get(), pattern, true, results);
|
||||
|
||||
// Also search all grids in the scene for entities
|
||||
if (ui_elements) {
|
||||
for (auto& drawable : *ui_elements) {
|
||||
if (drawable && drawable->derived_type() == PyObjectsEnum::UIGRID) {
|
||||
auto grid = std::static_pointer_cast<UIGrid>(drawable);
|
||||
find_in_grid_entities(grid.get(), pattern, true, results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search the scene's UI elements. find_in_collection recurses into a
|
||||
// grid's entities and overlay children via asGridData() (#355/#357).
|
||||
std::unordered_set<GridData*> visited_grids; // #359 - shared GridData visited once
|
||||
find_in_collection(ui_elements.get(), pattern, true, results, visited_grids);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,17 @@ sf::Keyboard::Key McRFPy_Automation::stringToKey(const std::string& keyName) {
|
|||
return sf::Keyboard::Unknown;
|
||||
}
|
||||
|
||||
// #367 - Inject a window-level event. These carry no coordinates and no button, which
|
||||
// is why they get their own entry point rather than riding injectMouseEvent.
|
||||
void McRFPy_Automation::injectWindowEvent(sf::Event::EventType type) {
|
||||
auto engine = getGameEngine();
|
||||
if (!engine) return;
|
||||
|
||||
sf::Event event;
|
||||
event.type = type;
|
||||
engine->processEvent(event);
|
||||
}
|
||||
|
||||
// Inject mouse event into the game engine
|
||||
void McRFPy_Automation::injectMouseEvent(sf::Event::EventType type, int x, int y, sf::Mouse::Button button, float scrollDelta) {
|
||||
auto engine = getGameEngine();
|
||||
|
|
@ -326,6 +337,24 @@ PyObject* McRFPy_Automation::_moveTo(PyObject* self, PyObject* args, PyObject* k
|
|||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
// #363 - Simulate the cursor leaving the window. sf::Event::MouseLeft carries no
|
||||
// coordinates, so this takes none; the engine reports the last known position to the
|
||||
// exit callbacks it fires. The simulated position is deliberately NOT moved -- the
|
||||
// cursor is outside the window, not at some new point inside it.
|
||||
PyObject* McRFPy_Automation::_mouseLeave(PyObject* self, PyObject* args) {
|
||||
injectMouseEvent(sf::Event::MouseLeft, simulated_mouse_pos.x, simulated_mouse_pos.y);
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
// #367 - Simulate the window losing focus (alt-tab). Like a window-leave, this clears
|
||||
// all hover state: an unfocused window receives no MouseMoved, so any drawable still
|
||||
// marked hovered would stay lit indefinitely. The simulated cursor position is left
|
||||
// alone -- focus and pointer location are independent, and the pointer has not moved.
|
||||
PyObject* McRFPy_Automation::_loseFocus(PyObject* self, PyObject* args) {
|
||||
injectWindowEvent(sf::Event::LostFocus);
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
// Move mouse relative - accepts moveRel(offset, duration)
|
||||
PyObject* McRFPy_Automation::_moveRel(PyObject* self, PyObject* args, PyObject* kwargs) {
|
||||
static const char* kwlist[] = {"offset", "duration", NULL};
|
||||
|
|
@ -972,6 +1001,25 @@ static PyMethodDef automationMethods[] = {
|
|||
MCRF_ARG("pos", "Position as (x, y) tuple, [x, y] list, Vector, or None for current position")
|
||||
MCRF_ARG("button", "Mouse button: 'left', 'right', or 'middle' (default 'left')")
|
||||
)},
|
||||
{"mouseLeave", (PyCFunction)McRFPy_Automation::_mouseLeave, METH_NOARGS,
|
||||
MCRF_METHOD(automation, mouseLeave,
|
||||
MCRF_SIG("()", "None"),
|
||||
MCRF_DESC("Simulate the cursor leaving the window, clearing all hover state. "
|
||||
"Every hovered drawable fires its on_exit, every hovered grid fires "
|
||||
"on_cell_exit, and Grid.hovered_cell becomes None."),
|
||||
MCRF_NOTE("Takes no position: the cursor is outside the window, so there is none. "
|
||||
"Exit callbacks receive the last position passed to moveTo/click.")
|
||||
)},
|
||||
{"loseFocus", (PyCFunction)McRFPy_Automation::_loseFocus, METH_NOARGS,
|
||||
MCRF_METHOD(automation, loseFocus,
|
||||
MCRF_SIG("()", "None"),
|
||||
MCRF_DESC("Simulate the window losing focus (alt-tab), clearing all hover state. "
|
||||
"Every hovered drawable fires its on_exit, every hovered grid fires "
|
||||
"on_cell_exit, and Grid.hovered_cell becomes None."),
|
||||
MCRF_NOTE("An unfocused window is delivered no mouse motion, so the engine cannot "
|
||||
"know where the pointer is; hovering nothing is the only truthful state. "
|
||||
"Hover is restored by the next mouse movement after focus returns.")
|
||||
)},
|
||||
|
||||
{"typewrite", (PyCFunction)McRFPy_Automation::_typewrite, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(automation, typewrite,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ public:
|
|||
static PyObject* _scroll(PyObject* self, PyObject* args, PyObject* kwargs);
|
||||
static PyObject* _mouseDown(PyObject* self, PyObject* args, PyObject* kwargs);
|
||||
static PyObject* _mouseUp(PyObject* self, PyObject* args, PyObject* kwargs);
|
||||
static PyObject* _mouseLeave(PyObject* self, PyObject* args); // #363
|
||||
static PyObject* _loseFocus(PyObject* self, PyObject* args); // #367
|
||||
|
||||
// Keyboard
|
||||
static PyObject* _typewrite(PyObject* self, PyObject* args, PyObject* kwargs);
|
||||
|
|
@ -44,6 +46,9 @@ public:
|
|||
|
||||
// Helper functions
|
||||
static void injectMouseEvent(sf::Event::EventType type, int x, int y, sf::Mouse::Button button = sf::Mouse::Left, float scrollDelta = 0.0f);
|
||||
// #367 - Window-level events (LostFocus, GainedFocus) carry no payload at all, so
|
||||
// they do not belong in injectMouseEvent's coordinate-bearing signature.
|
||||
static void injectWindowEvent(sf::Event::EventType type);
|
||||
static void injectKeyEvent(sf::Event::EventType type, sf::Keyboard::Key key);
|
||||
static void injectTextEvent(sf::Uint32 unicode);
|
||||
static sf::Keyboard::Key stringToKey(const std::string& keyName);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
#include "PathProvider.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UIGridPathfinding.h"
|
||||
#include "UIGridPoint.h"
|
||||
|
||||
static bool cellWalkable(UIGrid& grid, int x, int y) {
|
||||
static bool cellWalkable(GridData& grid, int x, int y) {
|
||||
if (x < 0 || x >= grid.grid_w || y < 0 || y >= grid.grid_h) return false;
|
||||
return grid.isWalkable(x, y); // #332
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ static bool cellWalkable(UIGrid& grid, int x, int y) {
|
|||
DijkstraProvider::DijkstraProvider(std::shared_ptr<DijkstraMap> map)
|
||||
: map_(std::move(map)) {}
|
||||
|
||||
sf::Vector2i DijkstraProvider::nextStep(sf::Vector2i from, UIGrid& /*grid*/, bool* ok) {
|
||||
sf::Vector2i DijkstraProvider::nextStep(sf::Vector2i from, GridData& /*grid*/, bool* ok) {
|
||||
if (!map_) {
|
||||
if (ok) *ok = false;
|
||||
return {-1, -1};
|
||||
|
|
@ -31,7 +31,7 @@ sf::Vector2i DijkstraProvider::nextStep(sf::Vector2i from, UIGrid& /*grid*/, boo
|
|||
AStarProvider::AStarProvider(std::vector<sf::Vector2i> path)
|
||||
: path_(std::move(path)) {}
|
||||
|
||||
sf::Vector2i AStarProvider::nextStep(sf::Vector2i /*from*/, UIGrid& /*grid*/, bool* ok) {
|
||||
sf::Vector2i AStarProvider::nextStep(sf::Vector2i /*from*/, GridData& /*grid*/, bool* ok) {
|
||||
if (index_ >= path_.size()) {
|
||||
if (ok) *ok = false;
|
||||
return {-1, -1};
|
||||
|
|
@ -46,7 +46,7 @@ sf::Vector2i AStarProvider::nextStep(sf::Vector2i /*from*/, UIGrid& /*grid*/, bo
|
|||
TargetProvider::TargetProvider(sf::Vector2i target)
|
||||
: target_(target) {}
|
||||
|
||||
sf::Vector2i TargetProvider::nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) {
|
||||
sf::Vector2i TargetProvider::nextStep(sf::Vector2i from, GridData& grid, bool* ok) {
|
||||
int dx = target_.x - from.x;
|
||||
int dy = target_.y - from.y;
|
||||
if (dx == 0 && dy == 0) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
class UIGrid;
|
||||
class GridData;
|
||||
class DijkstraMap;
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -22,7 +22,7 @@ public:
|
|||
// DijkstraProvider relies on the TCOD map used at compute time,
|
||||
// TargetProvider re-queries the live grid, AStarProvider trusts the
|
||||
// pre-computed path.
|
||||
virtual sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) = 0;
|
||||
virtual sf::Vector2i nextStep(sf::Vector2i from, GridData& grid, bool* ok) = 0;
|
||||
|
||||
// Hint for providers that hold iteration state (currently only A*).
|
||||
virtual void reset() {}
|
||||
|
|
@ -33,7 +33,7 @@ public:
|
|||
class DijkstraProvider : public PathProvider {
|
||||
public:
|
||||
explicit DijkstraProvider(std::shared_ptr<DijkstraMap> map);
|
||||
sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) override;
|
||||
sf::Vector2i nextStep(sf::Vector2i from, GridData& grid, bool* ok) override;
|
||||
|
||||
private:
|
||||
std::shared_ptr<DijkstraMap> map_;
|
||||
|
|
@ -43,7 +43,7 @@ private:
|
|||
class AStarProvider : public PathProvider {
|
||||
public:
|
||||
explicit AStarProvider(std::vector<sf::Vector2i> path);
|
||||
sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) override;
|
||||
sf::Vector2i nextStep(sf::Vector2i from, GridData& grid, bool* ok) override;
|
||||
void reset() override { index_ = 0; }
|
||||
|
||||
private:
|
||||
|
|
@ -57,7 +57,7 @@ private:
|
|||
class TargetProvider : public PathProvider {
|
||||
public:
|
||||
explicit TargetProvider(sf::Vector2i target);
|
||||
sf::Vector2i nextStep(sf::Vector2i from, UIGrid& grid, bool* ok) override;
|
||||
sf::Vector2i nextStep(sf::Vector2i from, GridData& grid, bool* ok) override;
|
||||
|
||||
private:
|
||||
sf::Vector2i target_;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
#include "UIFrame.h"
|
||||
#include "UICaption.h"
|
||||
#include "UISprite.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UIEntity.h"
|
||||
#include "UI.h" // For the PyTypeObject definitions
|
||||
#include <cstring> // For strcmp in parseConflictMode
|
||||
|
|
@ -253,14 +253,8 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args, PyObject*
|
|||
handled = true;
|
||||
}
|
||||
}
|
||||
else if (PyObject_IsInstance(target_obj, (PyObject*)&mcrfpydef::PyUIGridType)) {
|
||||
PyUIGridObject* grid = (PyUIGridObject*)target_obj;
|
||||
if (grid->data) {
|
||||
self->data->start(grid->data);
|
||||
AnimationManager::getInstance().addAnimation(self->data, conflict_mode);
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
// #361: a GridData is NOT an animation target -- it has no position, size,
|
||||
// color or camera to tween. Animate the Grid (GridView) that displays it.
|
||||
else if (PyObject_IsInstance(target_obj, (PyObject*)&mcrfpydef::PyUIEntityType)) {
|
||||
// Special handling for Entity since it doesn't inherit from UIDrawable
|
||||
PyUIEntityObject* entity = (PyUIEntityObject*)target_obj;
|
||||
|
|
|
|||
263
src/PyGridData.cpp
Normal file
263
src/PyGridData.cpp
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
// PyGridData.cpp - Python bindings for GridData (mcrfpy.GridData).
|
||||
//
|
||||
// #361: this file used to define `class UIGrid : public UIDrawable, public
|
||||
// GridData` -- a map that was also a widget, carrying a second camera and
|
||||
// RenderTexture that no user ever saw. GridData is now pure data (GridData.cpp)
|
||||
// and UIGridView is the only widget. What remains here is the binding layer:
|
||||
// construction, properties, and the shared helpers the Grid() factory path in
|
||||
// UIGridView needs.
|
||||
#include "PyGridData.h"
|
||||
#include "UIGridView.h"
|
||||
#include "UIGridPathfinding.h"
|
||||
#include "GameEngine.h"
|
||||
#include "McRFPy_API.h"
|
||||
#include "PythonObjectCache.h"
|
||||
#include "UIEntity.h"
|
||||
#include "PyFOV.h"
|
||||
#include "PyVector.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
|
||||
// =========================================================================
|
||||
// Shared construction helpers (used by GridData() and by Grid()/GridView)
|
||||
// =========================================================================
|
||||
|
||||
int PyGridData::parse_grid_shape(PyObject* grid_size_obj, PyObject* texture_obj,
|
||||
int& grid_w, int& grid_h,
|
||||
std::shared_ptr<PyTexture>& texture_out)
|
||||
{
|
||||
if (grid_size_obj) {
|
||||
if (!PyTuple_Check(grid_size_obj) || PyTuple_Size(grid_size_obj) != 2) {
|
||||
PyErr_SetString(PyExc_TypeError, "grid_size must be a tuple (grid_w, grid_h)");
|
||||
return -1;
|
||||
}
|
||||
PyObject* gx_val = PyTuple_GetItem(grid_size_obj, 0);
|
||||
PyObject* gy_val = PyTuple_GetItem(grid_size_obj, 1);
|
||||
if (!PyLong_Check(gx_val) || !PyLong_Check(gy_val)) {
|
||||
PyErr_SetString(PyExc_TypeError, "grid_size tuple must contain integers");
|
||||
return -1;
|
||||
}
|
||||
grid_w = PyLong_AsLong(gx_val);
|
||||
grid_h = PyLong_AsLong(gy_val);
|
||||
}
|
||||
|
||||
if (grid_w <= 0 || grid_h <= 0) {
|
||||
PyErr_SetString(PyExc_ValueError, "Grid dimensions must be positive integers");
|
||||
return -1;
|
||||
}
|
||||
if (grid_w > GRID_MAX || grid_h > GRID_MAX) { // #212
|
||||
PyErr_Format(PyExc_ValueError,
|
||||
"Grid dimensions cannot exceed %d (got %dx%d)", GRID_MAX, grid_w, grid_h);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (texture_obj && texture_obj != Py_None) {
|
||||
if (!PyObject_IsInstance(texture_obj, (PyObject*)&mcrfpydef::PyTextureType)) {
|
||||
PyErr_SetString(PyExc_TypeError, "texture must be a mcrfpy.Texture instance or None");
|
||||
return -1;
|
||||
}
|
||||
texture_out = reinterpret_cast<PyTextureObject*>(texture_obj)->data;
|
||||
} else {
|
||||
texture_out = McRFPy_API::default_texture;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Attach one layer object (already type-checked) to `grid`. Returns -1 with an
|
||||
// exception set on failure.
|
||||
static int attach_layer(const std::shared_ptr<GridData>& grid,
|
||||
const std::shared_ptr<GridLayer>& layer)
|
||||
{
|
||||
if (!layer->name.empty() && GridData::isProtectedLayerName(layer->name)) {
|
||||
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
|
||||
return -1;
|
||||
}
|
||||
if (!layer->name.empty()) {
|
||||
auto existing = grid->getLayerByName(layer->name);
|
||||
if (existing) {
|
||||
existing->parent_grid = nullptr;
|
||||
grid->removeLayer(existing);
|
||||
}
|
||||
}
|
||||
// Lazy allocation: a layer left at (0,0) adopts the grid's dimensions.
|
||||
if (layer->grid_x == 0 && layer->grid_y == 0) {
|
||||
layer->resize(grid->grid_w, grid->grid_h);
|
||||
} else if (layer->grid_x != grid->grid_w || layer->grid_y != grid->grid_h) {
|
||||
PyErr_Format(PyExc_ValueError,
|
||||
"Layer size (%d, %d) does not match Grid size (%d, %d)",
|
||||
layer->grid_x, layer->grid_y, grid->grid_w, grid->grid_h);
|
||||
return -1;
|
||||
}
|
||||
layer->parent_grid = grid.get();
|
||||
grid->layers.push_back(layer);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int PyGridData::apply_layers_arg(const std::shared_ptr<GridData>& grid,
|
||||
PyObject* layers_obj,
|
||||
std::shared_ptr<PyTexture> default_texture)
|
||||
{
|
||||
// Omitted -> one default TileLayer named "tilesprite" (z_index -1, below entities).
|
||||
if (layers_obj == nullptr) {
|
||||
grid->addTileLayer(-1, default_texture, "tilesprite");
|
||||
return 0;
|
||||
}
|
||||
// Explicit None -> no rendering layers (entity storage + pathfinding only).
|
||||
if (layers_obj == Py_None) return 0;
|
||||
|
||||
PyObject* iterator = PyObject_GetIter(layers_obj);
|
||||
if (!iterator) {
|
||||
PyErr_SetString(PyExc_TypeError,
|
||||
"layers must be an iterable of ColorLayer or TileLayer objects");
|
||||
return -1;
|
||||
}
|
||||
|
||||
auto* color_layer_type = (PyObject*)&mcrfpydef::PyColorLayerType;
|
||||
auto* tile_layer_type = (PyObject*)&mcrfpydef::PyTileLayerType;
|
||||
|
||||
PyObject* item;
|
||||
while ((item = PyIter_Next(iterator)) != NULL) {
|
||||
bool is_color = PyObject_IsInstance(item, color_layer_type);
|
||||
bool is_tile = PyObject_IsInstance(item, tile_layer_type);
|
||||
if (!is_color && !is_tile) {
|
||||
Py_DECREF(item);
|
||||
Py_DECREF(iterator);
|
||||
PyErr_SetString(PyExc_TypeError,
|
||||
"layers must contain only ColorLayer or TileLayer objects");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Both wrapper types share the same {data, grid} prefix layout, but read
|
||||
// them through their own types rather than punning.
|
||||
std::shared_ptr<GridLayer> layer;
|
||||
bool already_attached = false;
|
||||
if (is_color) {
|
||||
auto* py_layer = (PyColorLayerObject*)item;
|
||||
if (!py_layer->data) {
|
||||
Py_DECREF(item); Py_DECREF(iterator);
|
||||
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
||||
return -1;
|
||||
}
|
||||
already_attached = (bool)py_layer->grid;
|
||||
layer = py_layer->data;
|
||||
} else {
|
||||
auto* py_layer = (PyTileLayerObject*)item;
|
||||
if (!py_layer->data) {
|
||||
Py_DECREF(item); Py_DECREF(iterator);
|
||||
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
||||
return -1;
|
||||
}
|
||||
already_attached = (bool)py_layer->grid;
|
||||
layer = py_layer->data;
|
||||
}
|
||||
|
||||
if (already_attached) {
|
||||
Py_DECREF(item); Py_DECREF(iterator);
|
||||
PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (attach_layer(grid, layer) < 0) {
|
||||
Py_DECREF(item); Py_DECREF(iterator);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (is_color) {
|
||||
((PyColorLayerObject*)item)->grid = grid;
|
||||
} else {
|
||||
auto* py_layer = (PyTileLayerObject*)item;
|
||||
py_layer->grid = grid;
|
||||
// #254 - a TileLayer with no texture of its own inherits the grid's.
|
||||
auto tile_layer_ptr = std::static_pointer_cast<TileLayer>(layer);
|
||||
if (!tile_layer_ptr->texture) {
|
||||
tile_layer_ptr->texture = grid->getTexture();
|
||||
}
|
||||
}
|
||||
|
||||
Py_DECREF(item);
|
||||
}
|
||||
|
||||
Py_DECREF(iterator);
|
||||
if (PyErr_Occurred()) return -1;
|
||||
|
||||
grid->layers_need_sort = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// #348/#361 - One Python wrapper per GridData. `entity.grid`, `view.grid_data`
|
||||
// and the layer/GridPoint back-references must all hand back the SAME object,
|
||||
// so identity (`a.grid is b.grid`) holds and repeated attribute access does not
|
||||
// churn allocations.
|
||||
PyObject* PyGridData::pyobject_for(const std::shared_ptr<GridData>& grid)
|
||||
{
|
||||
if (!grid) Py_RETURN_NONE;
|
||||
|
||||
if (grid->serial_number != 0) {
|
||||
PyObject* cached = PythonObjectCache::getInstance().lookup(grid->serial_number);
|
||||
if (cached) return cached; // new strong ref
|
||||
}
|
||||
|
||||
auto* type = &mcrfpydef::PyGridDataType;
|
||||
auto* obj = (PyGridDataObject*)type->tp_alloc(type, 0);
|
||||
if (!obj) return nullptr;
|
||||
obj->data = grid;
|
||||
obj->weakreflist = NULL;
|
||||
|
||||
if (grid->serial_number == 0) {
|
||||
grid->serial_number = PythonObjectCache::getInstance().assignSerial();
|
||||
}
|
||||
PyObject* weakref = PyWeakref_NewRef((PyObject*)obj, NULL);
|
||||
if (weakref) {
|
||||
PythonObjectCache::getInstance().registerObject(grid->serial_number, weakref);
|
||||
Py_DECREF(weakref);
|
||||
}
|
||||
return (PyObject*)obj;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GridData(grid_size=..., texture=..., layers=...)
|
||||
// =========================================================================
|
||||
|
||||
int PyGridData::init(PyGridDataObject* self, PyObject* args, PyObject* kwds)
|
||||
{
|
||||
PyObject* grid_size_obj = nullptr;
|
||||
PyObject* texture_obj = nullptr;
|
||||
PyObject* layers_obj = nullptr;
|
||||
int grid_w = 2, grid_h = 2;
|
||||
|
||||
static const char* kwlist[] = {
|
||||
"grid_size", "texture", "layers", "grid_w", "grid_h", nullptr
|
||||
};
|
||||
|
||||
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOii", const_cast<char**>(kwlist),
|
||||
&grid_size_obj, &texture_obj, &layers_obj,
|
||||
&grid_w, &grid_h)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::shared_ptr<PyTexture> texture;
|
||||
if (parse_grid_shape(grid_size_obj, texture_obj, grid_w, grid_h, texture) < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
self->data = std::make_shared<GridData>(grid_w, grid_h, texture);
|
||||
|
||||
if (apply_layers_arg(self->data, layers_obj, texture) < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
self->weakreflist = NULL;
|
||||
|
||||
if (self->data->serial_number == 0) {
|
||||
self->data->serial_number = PythonObjectCache::getInstance().assignSerial();
|
||||
PyObject* weakref = PyWeakref_NewRef((PyObject*)self, NULL);
|
||||
if (weakref) {
|
||||
PythonObjectCache::getInstance().registerObject(self->data->serial_number, weakref);
|
||||
Py_DECREF(weakref);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
169
src/PyGridData.h
Normal file
169
src/PyGridData.h
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#pragma once
|
||||
#include "Common.h"
|
||||
#include "Python.h"
|
||||
#include "structmember.h"
|
||||
#include "IndexTexture.h"
|
||||
#include "Resources.h"
|
||||
#include <list>
|
||||
#include <libtcod.h>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
|
||||
#include "PyCallable.h"
|
||||
#include "PyTexture.h"
|
||||
#include "PyDrawable.h"
|
||||
#include "PyColor.h"
|
||||
#include "PyVector.h"
|
||||
#include "PyFont.h"
|
||||
|
||||
#include "UIGridPoint.h"
|
||||
#include "UIEntity.h"
|
||||
#include "UIDrawable.h"
|
||||
#include "UIBase.h"
|
||||
#include "GridLayers.h"
|
||||
#include "SpatialHash.h"
|
||||
#include "UIEntityCollection.h" // EntityCollection types (extracted from UIGrid)
|
||||
#include "GridData.h" // #252 - Data layer base class
|
||||
#include "UIGridView.h" // #252 - GridView shim
|
||||
|
||||
// Forward declaration for pathfinding
|
||||
class DijkstraMap;
|
||||
|
||||
// PyGridData - the Python binding layer for GridData (mcrfpy.GridData).
|
||||
//
|
||||
// #361: there is no longer a C++ `UIGrid` class. It used to inherit BOTH
|
||||
// UIDrawable and GridData, which meant every map dragged a second, redundant
|
||||
// camera and RenderTexture around, and appending one to a scene drew the map a
|
||||
// second time through that frozen camera. GridData is a MAP -- no position, no
|
||||
// size, no render() -- and UIGridView is the only widget. This struct holds
|
||||
// nothing but the static binding functions; it is never instantiated.
|
||||
struct PyGridData
|
||||
{
|
||||
// Shared by GridData::init and UIGridView::init (the Grid() factory path):
|
||||
// attaches an iterable of ColorLayer/TileLayer objects to a fresh GridData,
|
||||
// lazily sizing any layer left at (0,0). Returns -1 with a Python exception
|
||||
// set on failure. layers_obj == nullptr means "default tilesprite layer";
|
||||
// Py_None means "no layers at all" (entity storage + pathfinding only).
|
||||
static int apply_layers_arg(const std::shared_ptr<GridData>& grid,
|
||||
PyObject* layers_obj,
|
||||
std::shared_ptr<PyTexture> default_texture);
|
||||
|
||||
// Parses grid_size/grid_w/grid_h/texture into a validated (w, h, texture)
|
||||
// triple. Returns -1 with an exception set on failure.
|
||||
static int parse_grid_shape(PyObject* grid_size_obj, PyObject* texture_obj,
|
||||
int& grid_w, int& grid_h,
|
||||
std::shared_ptr<PyTexture>& texture_out);
|
||||
|
||||
// Builds (or reuses) the one Python wrapper for a GridData (#348 identity).
|
||||
static PyObject* pyobject_for(const std::shared_ptr<GridData>& grid);
|
||||
|
||||
static int init(PyGridDataObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* get_grid_size(PyGridDataObject* self, void* closure);
|
||||
static PyObject* get_grid_w(PyGridDataObject* self, void* closure);
|
||||
static PyObject* get_grid_h(PyGridDataObject* self, void* closure);
|
||||
static PyObject* get_texture(PyGridDataObject* self, void* closure);
|
||||
static PyObject* get_fov(PyGridDataObject* self, void* closure);
|
||||
static int set_fov(PyGridDataObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_fov_radius(PyGridDataObject* self, void* closure);
|
||||
static int set_fov_radius(PyGridDataObject* self, PyObject* value, void* closure);
|
||||
static PyObject* py_at(PyGridDataObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* py_compute_fov(PyGridDataObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* py_is_in_fov(PyGridDataObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* py_entities_in_radius(PyGridDataObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* py_apply_threshold(PyGridDataObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* py_apply_ranges(PyGridDataObject* self, PyObject* args);
|
||||
static PyObject* py_step(PyGridDataObject* self, PyObject* args, PyObject* kwds);
|
||||
|
||||
static PyGetSetDef getsetters[];
|
||||
static PyMappingMethods mpmethods;
|
||||
static PyObject* subscript(PyGridDataObject* self, PyObject* key);
|
||||
static PyObject* get_entities(PyGridDataObject* self, void* closure);
|
||||
// #364: get_children lives on UIGridView -- overlay children belong to the view.
|
||||
static PyObject* repr(PyGridDataObject* self);
|
||||
|
||||
static PyObject* py_add_layer(PyGridDataObject* self, PyObject* args);
|
||||
static PyObject* py_remove_layer(PyGridDataObject* self, PyObject* args);
|
||||
static PyObject* get_layers(PyGridDataObject* self, void* closure);
|
||||
static PyObject* py_layer(PyGridDataObject* self, PyObject* args);
|
||||
};
|
||||
|
||||
// UIEntityCollection types are now in UIEntityCollection.h
|
||||
|
||||
// Forward declaration of methods array
|
||||
extern PyMethodDef PyGridData_all_methods[];
|
||||
|
||||
namespace mcrfpydef {
|
||||
inline PyTypeObject PyGridDataType = {
|
||||
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||
// #361: public, and NOT a Drawable subclass -- tp_base is left null
|
||||
// (i.e. `object`). Appending one to scene.children raises TypeError.
|
||||
.tp_name = "mcrfpy.GridData",
|
||||
.tp_basicsize = sizeof(PyGridDataObject),
|
||||
.tp_itemsize = 0,
|
||||
.tp_dealloc = (destructor)[](PyObject* self)
|
||||
{
|
||||
PyGridDataObject* obj = (PyGridDataObject*)self;
|
||||
PyObject_GC_UnTrack(self);
|
||||
if (obj->weakreflist != NULL) {
|
||||
PyObject_ClearWeakRefs(self);
|
||||
}
|
||||
// #361: no click/hover callbacks to unregister -- a map is not a
|
||||
// widget and receives no input. Cell callbacks live on UIGridView
|
||||
// (#355), overlay children on UIGridView (#364).
|
||||
obj->data.reset();
|
||||
Py_TYPE(self)->tp_free(self);
|
||||
},
|
||||
.tp_repr = (reprfunc)PyGridData::repr,
|
||||
.tp_as_mapping = &PyGridData::mpmethods,
|
||||
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC,
|
||||
.tp_doc = PyDoc_STR("GridData(grid_size=None, texture=None, layers=None)\n\n"
|
||||
"A tile map: cells, entities, layers, FOV and pathfinding state.\n\n"
|
||||
"GridData is DATA, not a widget -- it has no position, no size, no\n"
|
||||
"camera, and is never drawn. Appending one to a scene raises TypeError.\n"
|
||||
"To display it, point one or more Grid (GridView) cameras at it:\n\n"
|
||||
" data = mcrfpy.GridData(grid_size=(80, 40))\n"
|
||||
" main = mcrfpy.Grid(grid=data, pos=(0, 0), size=(800, 400))\n"
|
||||
" mini = mcrfpy.Grid(grid=data, pos=(820, 0), size=(160, 80))\n\n"
|
||||
"Both views show the same map and the same entities, each through its\n"
|
||||
"own camera. A map with no view at all is perfectly valid (an offscreen\n"
|
||||
"level, still steppable and queryable).\n\n"
|
||||
"mcrfpy.Grid(grid_size=...) is sugar for a GridView that creates its own\n"
|
||||
"GridData; reach it with `grid.grid_data`.\n\n"
|
||||
"Args:\n"
|
||||
" grid_size (tuple): Grid dimensions as (grid_w, grid_h). Default: (2, 2)\n"
|
||||
" texture (Texture): Tile atlas; defines the cell size in pixels.\n"
|
||||
" layers (list): ColorLayer/TileLayer objects. Omit for a default\n"
|
||||
" 'tilesprite' TileLayer; pass None for no layers at all.\n\n"
|
||||
"Attributes:\n"
|
||||
" grid_size (Vector, read-only): Dimensions in tiles\n"
|
||||
" grid_w, grid_h (int, read-only): Dimensions in tiles\n"
|
||||
" texture (Texture, read-only): Tile atlas\n"
|
||||
" entities (EntityCollection): Entities on this map\n"
|
||||
" layers (tuple, read-only): Layers sorted by z_index\n"
|
||||
" fov (FOV): Default field-of-view algorithm\n"
|
||||
" fov_radius (int): Default field-of-view radius"),
|
||||
.tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int {
|
||||
// #361: a GridData holds no Python callbacks -- nothing to visit.
|
||||
(void)self; (void)visit; (void)arg;
|
||||
return 0;
|
||||
},
|
||||
.tp_clear = [](PyObject* self) -> int {
|
||||
(void)self;
|
||||
return 0;
|
||||
},
|
||||
.tp_methods = PyGridData_all_methods,
|
||||
.tp_getset = PyGridData::getsetters,
|
||||
.tp_init = (initproc)PyGridData::init,
|
||||
.tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject*
|
||||
{
|
||||
PyGridDataObject* self = (PyGridDataObject*)type->tp_alloc(type, 0);
|
||||
if (self) {
|
||||
self->data = std::make_shared<GridData>();
|
||||
self->weakreflist = nullptr;
|
||||
}
|
||||
return (PyObject*)self;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
// UIGridPyMethods.cpp — Python method implementations for UIGrid
|
||||
// Extracted from UIGrid.cpp (#149) for maintainability.
|
||||
// PyGridDataMethods.cpp - Python method implementations for GridData.
|
||||
// #361: center_camera() moved to UIGridView (a map has no camera), and the
|
||||
// UIDrawable method set is gone (a map is not a widget).
|
||||
// Contains: FOV, layer management, spatial queries, camera, heightmap ops,
|
||||
// step/behavior system, py_at/subscript, and method table arrays.
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UIGridView.h"
|
||||
#include "UIGridPathfinding.h"
|
||||
#include "McRFPy_API.h"
|
||||
|
|
@ -21,7 +22,7 @@
|
|||
// Cell access: py_at, subscript, mpmethods
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds)
|
||||
PyObject* PyGridData::py_at(PyGridDataObject* self, PyObject* args, PyObject* kwds)
|
||||
{
|
||||
int x, y;
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ PyObject* UIGrid::py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds)
|
|||
return (PyObject*)obj;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::subscript(PyUIGridObject* self, PyObject* key)
|
||||
PyObject* PyGridData::subscript(PyGridDataObject* self, PyObject* key)
|
||||
{
|
||||
if (!PyTuple_Check(key) || PyTuple_Size(key) != 2) {
|
||||
PyErr_SetString(PyExc_TypeError, "Grid indices must be a tuple of (x, y)");
|
||||
|
|
@ -84,7 +85,7 @@ PyObject* UIGrid::subscript(PyUIGridObject* self, PyObject* key)
|
|||
}
|
||||
|
||||
// Setitem on _GridData / Grid: GridPoints are views, not assignable.
|
||||
static int UIGrid_subscript_assign(PyUIGridObject* self, PyObject* key, PyObject* value)
|
||||
static int UIGrid_subscript_assign(PyGridDataObject* self, PyObject* key, PyObject* value)
|
||||
{
|
||||
(void)self; (void)key; (void)value;
|
||||
PyErr_SetString(PyExc_TypeError,
|
||||
|
|
@ -92,9 +93,9 @@ static int UIGrid_subscript_assign(PyUIGridObject* self, PyObject* key, PyObject
|
|||
return -1;
|
||||
}
|
||||
|
||||
PyMappingMethods UIGrid::mpmethods = {
|
||||
PyMappingMethods PyGridData::mpmethods = {
|
||||
.mp_length = NULL,
|
||||
.mp_subscript = (binaryfunc)UIGrid::subscript,
|
||||
.mp_subscript = (binaryfunc)PyGridData::subscript,
|
||||
.mp_ass_subscript = (objobjargproc)UIGrid_subscript_assign,
|
||||
};
|
||||
|
||||
|
|
@ -102,7 +103,7 @@ PyMappingMethods UIGrid::mpmethods = {
|
|||
// FOV methods
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds)
|
||||
PyObject* PyGridData::py_compute_fov(PyGridDataObject* self, PyObject* args, PyObject* kwds)
|
||||
{
|
||||
static const char* kwlist[] = {"pos", "radius", "light_walls", "algorithm", NULL};
|
||||
PyObject* pos_obj = NULL;
|
||||
|
|
@ -135,7 +136,7 @@ PyObject* UIGrid::py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject*
|
|||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds)
|
||||
PyObject* PyGridData::py_is_in_fov(PyGridDataObject* self, PyObject* args, PyObject* kwds)
|
||||
{
|
||||
int x, y;
|
||||
if (!PyPosition_ParseInt(args, kwds, &x, &y)) {
|
||||
|
|
@ -150,7 +151,7 @@ PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* k
|
|||
// Layer management
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args) {
|
||||
PyObject* PyGridData::py_add_layer(PyGridDataObject* self, PyObject* args) {
|
||||
PyObject* layer_obj;
|
||||
if (!PyArg_ParseTuple(args, "O", &layer_obj)) {
|
||||
return NULL;
|
||||
|
|
@ -177,7 +178,7 @@ PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args) {
|
|||
layer = py_layer->data;
|
||||
py_layer_ref = layer_obj;
|
||||
|
||||
if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) {
|
||||
if (!layer->name.empty() && GridData::isProtectedLayerName(layer->name)) {
|
||||
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
|
||||
return NULL;
|
||||
}
|
||||
|
|
@ -219,7 +220,7 @@ PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args) {
|
|||
layer = py_layer->data;
|
||||
py_layer_ref = layer_obj;
|
||||
|
||||
if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) {
|
||||
if (!layer->name.empty() && GridData::isProtectedLayerName(layer->name)) {
|
||||
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
|
||||
return NULL;
|
||||
}
|
||||
|
|
@ -260,7 +261,7 @@ PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args) {
|
|||
return py_layer_ref;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) {
|
||||
PyObject* PyGridData::py_remove_layer(PyGridDataObject* self, PyObject* args) {
|
||||
PyObject* layer_obj;
|
||||
if (!PyArg_ParseTuple(args, "O", &layer_obj)) {
|
||||
return NULL;
|
||||
|
|
@ -305,7 +306,7 @@ PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) {
|
|||
return NULL;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) {
|
||||
PyObject* PyGridData::py_layer(PyGridDataObject* self, PyObject* args) {
|
||||
const char* name_str;
|
||||
if (!PyArg_ParseTuple(args, "s", &name_str)) {
|
||||
return NULL;
|
||||
|
|
@ -339,7 +340,7 @@ PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) {
|
|||
// Spatial queries
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds)
|
||||
PyObject* PyGridData::py_entities_in_radius(PyGridDataObject* self, PyObject* args, PyObject* kwds)
|
||||
{
|
||||
static const char* kwlist[] = {"pos", "radius", NULL};
|
||||
PyObject* pos_obj;
|
||||
|
|
@ -390,74 +391,11 @@ PyObject* UIGrid::py_entities_in_radius(PyUIGridObject* self, PyObject* args, Py
|
|||
return result;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Camera positioning
|
||||
// =========================================================================
|
||||
|
||||
void UIGrid::center_camera() {
|
||||
int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
|
||||
int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
|
||||
center_x = (grid_w / 2.0f) * cell_width;
|
||||
center_y = (grid_h / 2.0f) * cell_height;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void UIGrid::center_camera(float tile_x, float tile_y) {
|
||||
int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
|
||||
int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
|
||||
float half_viewport_x = box.getSize().x / zoom / 2.0f;
|
||||
float half_viewport_y = box.getSize().y / zoom / 2.0f;
|
||||
center_x = tile_x * cell_width + half_viewport_x;
|
||||
center_y = tile_y * cell_height + half_viewport_y;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
PyObject* UIGrid::py_center_camera(PyUIGridObject* self, PyObject* args) {
|
||||
PyObject* pos_arg = nullptr;
|
||||
|
||||
if (!PyArg_ParseTuple(args, "|O", &pos_arg)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (pos_arg == nullptr || pos_arg == Py_None) {
|
||||
self->data->center_camera();
|
||||
} else if (PyTuple_Check(pos_arg) && PyTuple_Size(pos_arg) == 2) {
|
||||
PyObject* x_obj = PyTuple_GetItem(pos_arg, 0);
|
||||
PyObject* y_obj = PyTuple_GetItem(pos_arg, 1);
|
||||
|
||||
float tile_x, tile_y;
|
||||
if (PyFloat_Check(x_obj)) {
|
||||
tile_x = PyFloat_AsDouble(x_obj);
|
||||
} else if (PyLong_Check(x_obj)) {
|
||||
tile_x = (float)PyLong_AsLong(x_obj);
|
||||
} else {
|
||||
PyErr_SetString(PyExc_TypeError, "tile coordinates must be numeric");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (PyFloat_Check(y_obj)) {
|
||||
tile_y = PyFloat_AsDouble(y_obj);
|
||||
} else if (PyLong_Check(y_obj)) {
|
||||
tile_y = (float)PyLong_AsLong(y_obj);
|
||||
} else {
|
||||
PyErr_SetString(PyExc_TypeError, "tile coordinates must be numeric");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
self->data->center_camera(tile_x, tile_y);
|
||||
} else {
|
||||
PyErr_SetString(PyExc_TypeError, "center_camera() takes an optional tuple (tile_x, tile_y)");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HeightMap application methods
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::py_apply_threshold(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
|
||||
PyObject* PyGridData::py_apply_threshold(PyGridDataObject* self, PyObject* args, PyObject* kwds) {
|
||||
static const char* keywords[] = {"source", "range", "walkable", "transparent", nullptr};
|
||||
PyObject* source_obj = nullptr;
|
||||
PyObject* range_obj = nullptr;
|
||||
|
|
@ -533,7 +471,7 @@ PyObject* UIGrid::py_apply_threshold(PyUIGridObject* self, PyObject* args, PyObj
|
|||
return (PyObject*)self;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::py_apply_ranges(PyUIGridObject* self, PyObject* args) {
|
||||
PyObject* PyGridData::py_apply_ranges(PyGridDataObject* self, PyObject* args) {
|
||||
PyObject* source_obj = nullptr;
|
||||
PyObject* ranges_obj = nullptr;
|
||||
|
||||
|
|
@ -696,7 +634,7 @@ static void fireStepCallback(std::shared_ptr<UIEntity>& entity, int trigger_int,
|
|||
Py_DECREF(trigger_obj);
|
||||
}
|
||||
|
||||
PyObject* UIGrid::py_step(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
|
||||
PyObject* PyGridData::py_step(PyGridDataObject* self, PyObject* args, PyObject* kwds) {
|
||||
static const char* kwlist[] = {"n", "turn_order", nullptr};
|
||||
int n = 1;
|
||||
PyObject* turn_order_filter = nullptr;
|
||||
|
|
@ -827,10 +765,7 @@ PyObject* UIGrid::py_step(PyUIGridObject* self, PyObject* args, PyObject* kwds)
|
|||
}
|
||||
|
||||
// #351 - invalidate the view's render early-out once if anything moved.
|
||||
// Must call the GridData override explicitly: UIGrid's `using
|
||||
// UIDrawable::markCompositeDirty` would otherwise shadow it and skip the
|
||||
// content_generation bump + owning_view notification.
|
||||
if (content_changed) grid->GridData::markCompositeDirty();
|
||||
if (content_changed) grid->markCompositeDirty();
|
||||
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
|
@ -839,9 +774,11 @@ PyObject* UIGrid::py_step(PyUIGridObject* self, PyObject* args, PyObject* kwds)
|
|||
// Method tables
|
||||
// =========================================================================
|
||||
|
||||
PyMethodDef UIGrid::methods[] = {
|
||||
{"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, at,
|
||||
typedef PyGridDataObject PyObjectType;
|
||||
|
||||
PyMethodDef PyGridData_all_methods[] = {
|
||||
{"at", (PyCFunction)PyGridData::py_at, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(GridData, at,
|
||||
MCRF_SIG("(x: int, y: int)", "GridPoint"),
|
||||
MCRF_DESC("Return the GridPoint cell at grid coordinates (x, y)."),
|
||||
MCRF_ARGS_START
|
||||
|
|
@ -851,8 +788,8 @@ PyMethodDef UIGrid::methods[] = {
|
|||
MCRF_RAISES("IndexError", "If x or y is out of range")
|
||||
MCRF_NOTE("Also accepts a single positional tuple/list/Vector: at((x, y)) or at(vec), or keyword form: at(pos=(x, y)).")
|
||||
)},
|
||||
{"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, compute_fov,
|
||||
{"compute_fov", (PyCFunction)PyGridData::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(GridData, compute_fov,
|
||||
MCRF_SIG("(pos, radius: int = 0, light_walls: bool = True, algorithm: FOV | int = FOV.BASIC)", "None"),
|
||||
MCRF_DESC("Compute field of view from a position. Updates the internal FOV state; use is_in_fov() to query visibility."),
|
||||
MCRF_ARGS_START
|
||||
|
|
@ -862,8 +799,8 @@ PyMethodDef UIGrid::methods[] = {
|
|||
MCRF_ARG("algorithm", "FOV algorithm to use (FOV.BASIC, FOV.DIAMOND, FOV.SHADOW, FOV.PERMISSIVE_0-8)")
|
||||
MCRF_RETURNS("None")
|
||||
)},
|
||||
{"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, is_in_fov,
|
||||
{"is_in_fov", (PyCFunction)PyGridData::py_is_in_fov, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(GridData, is_in_fov,
|
||||
MCRF_SIG("(x: int, y: int)", "bool"),
|
||||
MCRF_DESC("Check if a cell is in the field of view. Must call compute_fov() first to calculate visibility."),
|
||||
MCRF_ARGS_START
|
||||
|
|
@ -873,7 +810,7 @@ PyMethodDef UIGrid::methods[] = {
|
|||
MCRF_NOTE("Also accepts a single positional tuple/list/Vector: is_in_fov((x, y)) or is_in_fov(vec), or keyword form: is_in_fov(pos=(x, y)).")
|
||||
)},
|
||||
{"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, find_path,
|
||||
MCRF_METHOD(GridData, find_path,
|
||||
MCRF_SIG("(start, end, diagonal_cost: float = 1.41, collide: str = None, heuristic = None, weight: float = 1.0)", "AStarPath | None"),
|
||||
MCRF_DESC("Compute A* path between two points. The returned AStarPath can be iterated or walked step-by-step."),
|
||||
MCRF_ARGS_START
|
||||
|
|
@ -886,7 +823,7 @@ PyMethodDef UIGrid::methods[] = {
|
|||
MCRF_RETURNS("AStarPath object if path exists, None otherwise")
|
||||
)},
|
||||
{"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, get_dijkstra_map,
|
||||
MCRF_METHOD(GridData, get_dijkstra_map,
|
||||
MCRF_SIG("(root=None, diagonal_cost: float = 1.41, collide: str = None, roots=None)", "DijkstraMap"),
|
||||
MCRF_DESC("Get or create a cached Dijkstra distance map for a root position. Call clear_dijkstra_maps() after changing grid walkability to invalidate."),
|
||||
MCRF_ARGS_START
|
||||
|
|
@ -897,12 +834,12 @@ PyMethodDef UIGrid::methods[] = {
|
|||
MCRF_RETURNS("DijkstraMap object for querying distances and paths")
|
||||
)},
|
||||
{"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS,
|
||||
MCRF_METHOD(Grid, clear_dijkstra_maps,
|
||||
MCRF_METHOD(GridData, clear_dijkstra_maps,
|
||||
MCRF_SIG("()", "None"),
|
||||
MCRF_DESC("Clear all cached Dijkstra maps. Call this after modifying grid cell walkability to ensure pathfinding uses updated walkability data.")
|
||||
)},
|
||||
{"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS,
|
||||
MCRF_METHOD(Grid, add_layer,
|
||||
{"add_layer", (PyCFunction)PyGridData::py_add_layer, METH_VARARGS,
|
||||
MCRF_METHOD(GridData, add_layer,
|
||||
MCRF_SIG("(layer: ColorLayer | TileLayer)", "ColorLayer | TileLayer"),
|
||||
MCRF_DESC("Add a layer to the grid. Layers with size (0, 0) are automatically resized to match the grid."),
|
||||
MCRF_ARGS_START
|
||||
|
|
@ -911,8 +848,8 @@ PyMethodDef UIGrid::methods[] = {
|
|||
MCRF_RAISES("ValueError", "If layer is already attached to another grid, or if layer size doesn't match grid (and isn't (0,0))")
|
||||
MCRF_RAISES("TypeError", "If argument is not a ColorLayer or TileLayer")
|
||||
)},
|
||||
{"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS,
|
||||
MCRF_METHOD(Grid, remove_layer,
|
||||
{"remove_layer", (PyCFunction)PyGridData::py_remove_layer, METH_VARARGS,
|
||||
MCRF_METHOD(GridData, remove_layer,
|
||||
MCRF_SIG("(name_or_layer: str | ColorLayer | TileLayer)", "None"),
|
||||
MCRF_DESC("Remove a layer from the grid by name or by object reference."),
|
||||
MCRF_ARGS_START
|
||||
|
|
@ -920,16 +857,16 @@ PyMethodDef UIGrid::methods[] = {
|
|||
MCRF_RAISES("KeyError", "If name is provided but no layer with that name exists")
|
||||
MCRF_RAISES("TypeError", "If argument is not a string, ColorLayer, or TileLayer")
|
||||
)},
|
||||
{"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS,
|
||||
MCRF_METHOD(Grid, layer,
|
||||
{"layer", (PyCFunction)PyGridData::py_layer, METH_VARARGS,
|
||||
MCRF_METHOD(GridData, layer,
|
||||
MCRF_SIG("(name: str)", "ColorLayer | TileLayer | None"),
|
||||
MCRF_DESC("Get a layer by its name."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("name", "The name of the layer to find")
|
||||
MCRF_RETURNS("The layer with the specified name, or None if not found")
|
||||
)},
|
||||
{"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, entities_in_radius,
|
||||
{"entities_in_radius", (PyCFunction)PyGridData::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(GridData, entities_in_radius,
|
||||
MCRF_SIG("(pos: tuple | Vector, radius: float)", "list"),
|
||||
MCRF_DESC("Query entities within radius using spatial hash (O(k) where k = nearby entities)."),
|
||||
MCRF_ARGS_START
|
||||
|
|
@ -937,168 +874,29 @@ PyMethodDef UIGrid::methods[] = {
|
|||
MCRF_ARG("radius", "Search radius")
|
||||
MCRF_RETURNS("List of Entity objects within the radius")
|
||||
)},
|
||||
{"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS,
|
||||
MCRF_METHOD(Grid, center_camera,
|
||||
MCRF_SIG("(pos: tuple = None)", "None"),
|
||||
MCRF_DESC("Center the camera on a tile coordinate. If pos is None, centers on the grid's middle tile."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("pos", "Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.")
|
||||
)},
|
||||
{"apply_threshold", (PyCFunction)UIGrid::py_apply_threshold, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, apply_threshold,
|
||||
MCRF_SIG("(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None)", "Grid"),
|
||||
{"apply_threshold", (PyCFunction)PyGridData::py_apply_threshold, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(GridData, apply_threshold,
|
||||
MCRF_SIG("(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None)", "GridData"),
|
||||
MCRF_DESC("Apply walkable/transparent properties to cells where heightmap values fall within range."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("source", "HeightMap with values to check. Must match grid size.")
|
||||
MCRF_ARG("range", "Tuple of (min, max) - cells with values in this range are affected")
|
||||
MCRF_ARG("walkable", "If not None, set walkable to this value for cells in range")
|
||||
MCRF_ARG("transparent", "If not None, set transparent to this value for cells in range")
|
||||
MCRF_RETURNS("Grid: self, for method chaining")
|
||||
MCRF_RETURNS("GridData: self, for method chaining")
|
||||
MCRF_RAISES("ValueError", "If HeightMap size doesn't match grid size")
|
||||
)},
|
||||
{"apply_ranges", (PyCFunction)UIGrid::py_apply_ranges, METH_VARARGS,
|
||||
MCRF_METHOD(Grid, apply_ranges,
|
||||
MCRF_SIG("(source: HeightMap, ranges: list)", "Grid"),
|
||||
{"apply_ranges", (PyCFunction)PyGridData::py_apply_ranges, METH_VARARGS,
|
||||
MCRF_METHOD(GridData, apply_ranges,
|
||||
MCRF_SIG("(source: HeightMap, ranges: list)", "GridData"),
|
||||
MCRF_DESC("Apply multiple walkability thresholds to the grid in a single pass."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("source", "HeightMap with values to check. Must match grid size.")
|
||||
MCRF_ARG("ranges", "List of (range_tuple, properties_dict) tuples where range_tuple=(min,max) and properties_dict has 'walkable'/'transparent' keys")
|
||||
MCRF_RETURNS("Grid: self, for method chaining")
|
||||
MCRF_RETURNS("GridData: self, for method chaining")
|
||||
)},
|
||||
{NULL, NULL, 0, NULL}
|
||||
};
|
||||
|
||||
typedef PyUIGridObject PyObjectType;
|
||||
|
||||
PyMethodDef UIGrid_all_methods[] = {
|
||||
UIDRAWABLE_METHODS,
|
||||
{"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, at,
|
||||
MCRF_SIG("(x: int, y: int)", "GridPoint"),
|
||||
MCRF_DESC("Return the GridPoint cell at grid coordinates (x, y)."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("x", "Column index (0-based)")
|
||||
MCRF_ARG("y", "Row index (0-based)")
|
||||
MCRF_RETURNS("GridPoint: the cell at the given position")
|
||||
MCRF_RAISES("IndexError", "If x or y is out of range")
|
||||
MCRF_NOTE("Also accepts a single positional tuple/list/Vector: at((x, y)) or at(vec), or keyword form: at(pos=(x, y)).")
|
||||
)},
|
||||
{"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, compute_fov,
|
||||
MCRF_SIG("(pos, radius: int = 0, light_walls: bool = True, algorithm: FOV | int = FOV.BASIC)", "None"),
|
||||
MCRF_DESC("Compute field of view from a position. Updates the internal FOV state; use is_in_fov() to query visibility."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("pos", "Position as (x, y) tuple, list, or Vector")
|
||||
MCRF_ARG("radius", "Maximum view distance (0 = unlimited)")
|
||||
MCRF_ARG("light_walls", "Whether walls are lit when visible")
|
||||
MCRF_ARG("algorithm", "FOV algorithm to use (FOV.BASIC, FOV.DIAMOND, FOV.SHADOW, FOV.PERMISSIVE_0-8)")
|
||||
MCRF_RETURNS("None")
|
||||
)},
|
||||
{"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, is_in_fov,
|
||||
MCRF_SIG("(x: int, y: int)", "bool"),
|
||||
MCRF_DESC("Check if a cell is in the field of view. Must call compute_fov() first to calculate visibility."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("x", "Column index (0-based)")
|
||||
MCRF_ARG("y", "Row index (0-based)")
|
||||
MCRF_RETURNS("True if the cell is visible, False otherwise")
|
||||
MCRF_NOTE("Also accepts a single positional tuple/list/Vector: is_in_fov((x, y)) or is_in_fov(vec), or keyword form: is_in_fov(pos=(x, y)).")
|
||||
)},
|
||||
{"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, find_path,
|
||||
MCRF_SIG("(start, end, diagonal_cost: float = 1.41, collide: str = None, heuristic = None, weight: float = 1.0)", "AStarPath | None"),
|
||||
MCRF_DESC("Compute A* path between two points. The returned AStarPath can be iterated or walked step-by-step."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("start", "Starting position as Vector, Entity, or (x, y) tuple")
|
||||
MCRF_ARG("end", "Target position as Vector, Entity, or (x, y) tuple")
|
||||
MCRF_ARG("diagonal_cost", "Cost of diagonal movement (default: 1.41)")
|
||||
MCRF_ARG("collide", "Label string. Entities with this label block pathfinding.")
|
||||
MCRF_ARG("heuristic", "Heuristic enum member, string name, or int (EUCLIDEAN=0, MANHATTAN=1, CHEBYSHEV=2). None uses default (Euclidean).")
|
||||
MCRF_ARG("weight", "Heuristic weight multiplier. Values > 1.0 trade optimality for speed (weighted A*).")
|
||||
MCRF_RETURNS("AStarPath object if path exists, None otherwise")
|
||||
)},
|
||||
{"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, get_dijkstra_map,
|
||||
MCRF_SIG("(root=None, diagonal_cost: float = 1.41, collide: str = None, roots=None)", "DijkstraMap"),
|
||||
MCRF_DESC("Get or create a cached Dijkstra distance map for a root position. Call clear_dijkstra_maps() after changing grid walkability to invalidate."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("root", "Root position as Vector, Entity, or (x, y) tuple. Use 'root' for single-source maps (cached by position).")
|
||||
MCRF_ARG("diagonal_cost", "Cost of diagonal movement (default: 1.41)")
|
||||
MCRF_ARG("collide", "Label string. Entities with this label block pathfinding.")
|
||||
MCRF_ARG("roots", "Sequence of root positions or a DiscreteMap mask for multi-source Dijkstra. Pass 'roots' instead of 'root' for multi-source maps (not cached).")
|
||||
MCRF_RETURNS("DijkstraMap object for querying distances and paths")
|
||||
)},
|
||||
{"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS,
|
||||
MCRF_METHOD(Grid, clear_dijkstra_maps,
|
||||
MCRF_SIG("()", "None"),
|
||||
MCRF_DESC("Clear all cached Dijkstra maps. Call this after modifying grid cell walkability to ensure pathfinding uses updated walkability data.")
|
||||
)},
|
||||
{"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS,
|
||||
MCRF_METHOD(Grid, add_layer,
|
||||
MCRF_SIG("(layer: ColorLayer | TileLayer)", "ColorLayer | TileLayer"),
|
||||
MCRF_DESC("Add a layer to the grid. Layers with size (0, 0) are automatically resized to match the grid."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("layer", "A ColorLayer or TileLayer object. Named layers replace any existing layer with the same name.")
|
||||
MCRF_RETURNS("The added layer object")
|
||||
MCRF_RAISES("ValueError", "If layer is already attached to another grid, or if layer size doesn't match grid (and isn't (0,0))")
|
||||
MCRF_RAISES("TypeError", "If argument is not a ColorLayer or TileLayer")
|
||||
)},
|
||||
{"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS,
|
||||
MCRF_METHOD(Grid, remove_layer,
|
||||
MCRF_SIG("(name_or_layer: str | ColorLayer | TileLayer)", "None"),
|
||||
MCRF_DESC("Remove a layer from the grid by name or by object reference."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("name_or_layer", "Either a layer name (str) or the layer object itself")
|
||||
MCRF_RAISES("KeyError", "If name is provided but no layer with that name exists")
|
||||
MCRF_RAISES("TypeError", "If argument is not a string, ColorLayer, or TileLayer")
|
||||
)},
|
||||
{"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS,
|
||||
MCRF_METHOD(Grid, layer,
|
||||
MCRF_SIG("(name: str)", "ColorLayer | TileLayer | None"),
|
||||
MCRF_DESC("Get a layer by its name."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("name", "The name of the layer to find")
|
||||
MCRF_RETURNS("The layer with the specified name, or None if not found")
|
||||
)},
|
||||
{"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, entities_in_radius,
|
||||
MCRF_SIG("(pos: tuple | Vector, radius: float)", "list"),
|
||||
MCRF_DESC("Query entities within radius using spatial hash (O(k) where k = nearby entities)."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("pos", "Center position as (x, y) tuple, Vector, or other 2-element sequence")
|
||||
MCRF_ARG("radius", "Search radius")
|
||||
MCRF_RETURNS("List of Entity objects within the radius")
|
||||
)},
|
||||
{"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS,
|
||||
MCRF_METHOD(Grid, center_camera,
|
||||
MCRF_SIG("(pos: tuple = None)", "None"),
|
||||
MCRF_DESC("Center the camera on a tile coordinate. If pos is None, centers on the grid's middle tile."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("pos", "Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.")
|
||||
)},
|
||||
{"apply_threshold", (PyCFunction)UIGrid::py_apply_threshold, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, apply_threshold,
|
||||
MCRF_SIG("(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None)", "Grid"),
|
||||
MCRF_DESC("Apply walkable/transparent properties to cells where heightmap values fall within range."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("source", "HeightMap with values to check. Must match grid size.")
|
||||
MCRF_ARG("range", "Tuple of (min, max) - cells with values in this range are affected")
|
||||
MCRF_ARG("walkable", "If not None, set walkable to this value for cells in range")
|
||||
MCRF_ARG("transparent", "If not None, set transparent to this value for cells in range")
|
||||
MCRF_RETURNS("Grid: self, for method chaining")
|
||||
MCRF_RAISES("ValueError", "If HeightMap size doesn't match grid size")
|
||||
)},
|
||||
{"apply_ranges", (PyCFunction)UIGrid::py_apply_ranges, METH_VARARGS,
|
||||
MCRF_METHOD(Grid, apply_ranges,
|
||||
MCRF_SIG("(source: HeightMap, ranges: list)", "Grid"),
|
||||
MCRF_DESC("Apply multiple walkability thresholds to the grid in a single pass."),
|
||||
MCRF_ARGS_START
|
||||
MCRF_ARG("source", "HeightMap with values to check. Must match grid size.")
|
||||
MCRF_ARG("ranges", "List of (range_tuple, properties_dict) tuples where range_tuple=(min,max) and properties_dict has 'walkable'/'transparent' keys")
|
||||
MCRF_RETURNS("Grid: self, for method chaining")
|
||||
)},
|
||||
{"step", (PyCFunction)UIGrid::py_step, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(Grid, step,
|
||||
{"step", (PyCFunction)PyGridData::py_step, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(GridData, step,
|
||||
MCRF_SIG("(n: int = 1, turn_order: int = None)", "None"),
|
||||
MCRF_DESC("Execute n rounds of turn-based entity behavior. Each round: entities grouped by turn_order (ascending), behaviors executed, triggers fired (TARGET, DONE, BLOCKED), movement animated."),
|
||||
MCRF_ARGS_START
|
||||
214
src/PyGridDataProperties.cpp
Normal file
214
src/PyGridDataProperties.cpp
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// PyGridDataProperties.cpp - Python getter/setter implementations for GridData.
|
||||
// #361: the widget properties (pos/size/center/zoom/camera_rotation/fill_color/
|
||||
// x/y/w/h/on_click/z_index/name/visible/opacity/align/rotation/shader) are gone --
|
||||
// a map has no position and is never drawn. They live on UIGridView, which is the
|
||||
// only widget. What is left is what a MAP actually has.
|
||||
#include "PyGridData.h"
|
||||
#include "UIGridView.h"
|
||||
#include "McRFPy_API.h"
|
||||
#include "PythonObjectCache.h"
|
||||
#include "PyColor.h"
|
||||
#include "PyVector.h"
|
||||
#include "PyFOV.h"
|
||||
#include "UIBase.h"
|
||||
#include "UICollection.h"
|
||||
#include "McRFPy_Doc.h"
|
||||
|
||||
// =========================================================================
|
||||
// Grid dimension properties
|
||||
// =========================================================================
|
||||
|
||||
PyObject* PyGridData::get_grid_size(PyGridDataObject* self, void* closure) {
|
||||
return PyVector(sf::Vector2f(static_cast<float>(self->data->grid_w),
|
||||
static_cast<float>(self->data->grid_h))).pyObject();
|
||||
}
|
||||
|
||||
PyObject* PyGridData::get_grid_w(PyGridDataObject* self, void* closure) {
|
||||
return PyLong_FromLong(self->data->grid_w);
|
||||
}
|
||||
|
||||
PyObject* PyGridData::get_grid_h(PyGridDataObject* self, void* closure) {
|
||||
return PyLong_FromLong(self->data->grid_h);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Texture property
|
||||
// =========================================================================
|
||||
|
||||
PyObject* PyGridData::get_texture(PyGridDataObject* self, void* closure) {
|
||||
auto texture = self->data->getTexture();
|
||||
if (!texture) {
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
auto type = &mcrfpydef::PyTextureType;
|
||||
auto obj = (PyTextureObject*)type->tp_alloc(type, 0);
|
||||
obj->data = texture;
|
||||
return (PyObject*)obj;
|
||||
}
|
||||
|
||||
PyObject* PyGridData::get_fov(PyGridDataObject* self, void* closure)
|
||||
{
|
||||
if (PyFOV::fov_enum_class) {
|
||||
PyObject* value = PyLong_FromLong(self->data->fov_algorithm);
|
||||
if (!value) return NULL;
|
||||
|
||||
PyObject* args = PyTuple_Pack(1, value);
|
||||
Py_DECREF(value);
|
||||
if (!args) return NULL;
|
||||
|
||||
PyObject* result = PyObject_Call(PyFOV::fov_enum_class, args, NULL);
|
||||
Py_DECREF(args);
|
||||
return result;
|
||||
}
|
||||
return PyLong_FromLong(self->data->fov_algorithm);
|
||||
}
|
||||
|
||||
int PyGridData::set_fov(PyGridDataObject* self, PyObject* value, void* closure)
|
||||
{
|
||||
TCOD_fov_algorithm_t algo;
|
||||
if (!PyFOV::from_arg(value, &algo, nullptr)) {
|
||||
return -1;
|
||||
}
|
||||
self->data->fov_algorithm = algo;
|
||||
self->data->markDirty();
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* PyGridData::get_fov_radius(PyGridDataObject* self, void* closure)
|
||||
{
|
||||
return PyLong_FromLong(self->data->fov_radius);
|
||||
}
|
||||
|
||||
int PyGridData::set_fov_radius(PyGridDataObject* self, PyObject* value, void* closure)
|
||||
{
|
||||
if (!PyLong_Check(value)) {
|
||||
PyErr_SetString(PyExc_TypeError, "fov_radius must be an integer");
|
||||
return -1;
|
||||
}
|
||||
long radius = PyLong_AsLong(value);
|
||||
if (radius == -1 && PyErr_Occurred()) {
|
||||
return -1;
|
||||
}
|
||||
if (radius < 0) {
|
||||
PyErr_SetString(PyExc_ValueError, "fov_radius must be non-negative");
|
||||
return -1;
|
||||
}
|
||||
self->data->fov_radius = (int)radius;
|
||||
self->data->markDirty();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Collection getters
|
||||
// =========================================================================
|
||||
|
||||
PyObject* PyGridData::get_entities(PyGridDataObject* self, void* closure)
|
||||
{
|
||||
PyTypeObject* type = &mcrfpydef::PyUIEntityCollectionType;
|
||||
auto o = (PyUIEntityCollectionObject*)type->tp_alloc(type, 0);
|
||||
if (o) {
|
||||
o->data = self->data->entities;
|
||||
o->grid = self->data;
|
||||
}
|
||||
return (PyObject*)o;
|
||||
}
|
||||
|
||||
// #364: get_children moved to UIGridView. GridData holds no drawables, so the
|
||||
// internal _GridData no longer exposes a children collection at all.
|
||||
|
||||
PyObject* PyGridData::get_layers(PyGridDataObject* self, void* closure) {
|
||||
self->data->sortLayers();
|
||||
|
||||
PyObject* tuple = PyTuple_New(self->data->layers.size());
|
||||
if (!tuple) return NULL;
|
||||
|
||||
auto* color_layer_type = &mcrfpydef::PyColorLayerType;
|
||||
auto* tile_layer_type = &mcrfpydef::PyTileLayerType;
|
||||
|
||||
for (size_t i = 0; i < self->data->layers.size(); ++i) {
|
||||
auto& layer = self->data->layers[i];
|
||||
PyObject* py_layer = nullptr;
|
||||
|
||||
if (layer->type == GridLayerType::Color) {
|
||||
PyColorLayerObject* obj = (PyColorLayerObject*)color_layer_type->tp_alloc(color_layer_type, 0);
|
||||
if (obj) {
|
||||
obj->data = std::static_pointer_cast<ColorLayer>(layer);
|
||||
obj->grid = self->data;
|
||||
py_layer = (PyObject*)obj;
|
||||
}
|
||||
} else {
|
||||
PyTileLayerObject* obj = (PyTileLayerObject*)tile_layer_type->tp_alloc(tile_layer_type, 0);
|
||||
if (obj) {
|
||||
obj->data = std::static_pointer_cast<TileLayer>(layer);
|
||||
obj->grid = self->data;
|
||||
py_layer = (PyObject*)obj;
|
||||
}
|
||||
}
|
||||
|
||||
if (!py_layer) {
|
||||
Py_DECREF(tuple);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
PyTuple_SET_ITEM(tuple, i, py_layer);
|
||||
}
|
||||
|
||||
return tuple;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// repr
|
||||
// =========================================================================
|
||||
|
||||
PyObject* PyGridData::repr(PyGridDataObject* self)
|
||||
{
|
||||
std::ostringstream ss;
|
||||
if (!self->data) ss << "<GridData (invalid internal object)>";
|
||||
else {
|
||||
auto grid = self->data;
|
||||
ss << "<GridData (" << grid->grid_w << "x" << grid->grid_h << ", "
|
||||
<< grid->entities->size() << " entities, "
|
||||
<< grid->layers.size() << " layers)>";
|
||||
}
|
||||
std::string repr_str = ss.str();
|
||||
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
|
||||
}
|
||||
|
||||
// #355 - cell callback properties moved to UIGridView (src/UIGridView.cpp)
|
||||
|
||||
// =========================================================================
|
||||
// getsetters[] table
|
||||
//
|
||||
// #361: a map has no pos/size/center/zoom/camera_rotation/fill_color/x/y/w/h/
|
||||
// on_click/z_index/name/visible/opacity/align/rotation/shader. Every one of those
|
||||
// was a widget property that happened to hang off the data because UIGrid was
|
||||
// both. They are on UIGridView now. What a GridData exposes is its shape, its
|
||||
// contents, and its FOV defaults.
|
||||
// =========================================================================
|
||||
|
||||
PyGetSetDef PyGridData::getsetters[] = {
|
||||
|
||||
{"grid_size", (getter)PyGridData::get_grid_size, NULL,
|
||||
MCRF_PROPERTY(grid_size, "Grid dimensions as (grid_w, grid_h) (Vector, read-only)."), NULL},
|
||||
{"grid_w", (getter)PyGridData::get_grid_w, NULL,
|
||||
MCRF_PROPERTY(grid_w, "Grid width in cells (int, read-only)."), NULL},
|
||||
{"grid_h", (getter)PyGridData::get_grid_h, NULL,
|
||||
MCRF_PROPERTY(grid_h, "Grid height in cells (int, read-only)."), NULL},
|
||||
|
||||
{"entities", (getter)PyGridData::get_entities, NULL,
|
||||
MCRF_PROPERTY(entities, "EntityCollection of entities on this map (EntityCollection, read-only). Shared by every view of this data."), NULL},
|
||||
{"layers", (getter)PyGridData::get_layers, NULL,
|
||||
MCRF_PROPERTY(layers, "Tuple of grid layers sorted by z_index (tuple, read-only). Contains ColorLayer and TileLayer objects."), NULL},
|
||||
|
||||
{"texture", (getter)PyGridData::get_texture, NULL,
|
||||
MCRF_PROPERTY(texture, "Tile atlas for this map (Texture | None, read-only). Defines the cell size in pixels."), NULL},
|
||||
{"fov", (getter)PyGridData::get_fov, (setter)PyGridData::set_fov,
|
||||
MCRF_PROPERTY(fov,
|
||||
"FOV algorithm for this map (FOV enum). "
|
||||
"Used by entity.updateVisibility() and layer methods when fov=None."
|
||||
), NULL},
|
||||
{"fov_radius", (getter)PyGridData::get_fov_radius, (setter)PyGridData::set_fov_radius,
|
||||
MCRF_PROPERTY(fov_radius, "Default FOV radius for this map (int). Used when radius is not specified."), NULL},
|
||||
{NULL}
|
||||
};
|
||||
117
src/PyScene.cpp
117
src/PyScene.cpp
|
|
@ -3,7 +3,7 @@
|
|||
#include "Resources.h"
|
||||
#include "PyCallable.h"
|
||||
#include "UIFrame.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UIGridView.h"
|
||||
#include "McRFPy_Automation.h" // #111 - For simulated mouse position
|
||||
#include "PythonObjectCache.h" // #184 - For subclass callback support
|
||||
|
|
@ -257,41 +257,19 @@ void PyScene::do_mouse_input(std::string button, std::string type)
|
|||
// #184: Try property-assigned callable first (fast path)
|
||||
if (target->click_callable && !target->click_callable->isNone()) {
|
||||
target->click_callable->call(mousepos, button, type);
|
||||
|
||||
// Also fire grid cell click if applicable
|
||||
if (target->derived_type() == PyObjectsEnum::UIGRID) {
|
||||
auto grid = static_cast<UIGrid*>(target);
|
||||
if (grid->last_clicked_cell.has_value()) {
|
||||
grid->fireCellClick(grid->last_clicked_cell.value(), button, type);
|
||||
grid->last_clicked_cell = std::nullopt;
|
||||
}
|
||||
}
|
||||
target->dispatchCellClick(button, type); // #355 (no-op unless grid view)
|
||||
return; // Stop after first handler
|
||||
}
|
||||
|
||||
// #184: Try Python subclass method
|
||||
if (tryCallPythonMethod(target, "on_click", mousepos, button.c_str(), type.c_str())) {
|
||||
// Also fire grid cell click if applicable
|
||||
if (target->derived_type() == PyObjectsEnum::UIGRID) {
|
||||
auto grid = static_cast<UIGrid*>(target);
|
||||
if (grid->last_clicked_cell.has_value()) {
|
||||
grid->fireCellClick(grid->last_clicked_cell.value(), button, type);
|
||||
grid->last_clicked_cell = std::nullopt;
|
||||
}
|
||||
}
|
||||
target->dispatchCellClick(button, type); // #355
|
||||
return; // Stop after first handler
|
||||
}
|
||||
|
||||
// Fire grid cell click even if no on_click handler (but has cell click handler)
|
||||
if (target->derived_type() == PyObjectsEnum::UIGRID) {
|
||||
auto grid = static_cast<UIGrid*>(target);
|
||||
if (grid->last_clicked_cell.has_value()) {
|
||||
bool handled = grid->fireCellClick(grid->last_clicked_cell.value(), button, type);
|
||||
grid->last_clicked_cell = std::nullopt;
|
||||
if (handled) {
|
||||
return; // Stop after handling cell click
|
||||
}
|
||||
}
|
||||
// #355: cell click fires even without a whole-grid on_click handler
|
||||
if (target->dispatchCellClick(button, type)) {
|
||||
return; // Stop after handling cell click
|
||||
}
|
||||
|
||||
// Element claimed the click but had no handler - still stop propagation
|
||||
|
|
@ -326,11 +304,49 @@ void PyScene::do_mouse_hover(int x, int y)
|
|||
mousepos = game->windowToGameCoords(sf::Vector2f(static_cast<float>(x), static_cast<float>(y)));
|
||||
}
|
||||
|
||||
// Helper function to process hover for a single drawable and its children
|
||||
std::function<void(UIDrawable*)> processHover = [&](UIDrawable* drawable) {
|
||||
last_mouse_pos = mousepos;
|
||||
dispatch_hover(mousepos, true);
|
||||
}
|
||||
|
||||
// #363 - The cursor left the window. sf::Event::MouseLeft carries no coordinates,
|
||||
// and no further MouseMoved will arrive, so without this every drawable the mouse
|
||||
// was over stays hovered forever: on_exit / on_cell_exit never fire and
|
||||
// grid.hovered_cell keeps reporting a cell the cursor is nowhere near.
|
||||
//
|
||||
// This is the ordinary hover walk with hit_allowed=false at the root, which is
|
||||
// exactly "the mouse is over nothing": every hovered drawable takes its normal exit
|
||||
// path and none can enter. Exit callbacks get the last known cursor position -- the
|
||||
// same convention the DOM's mouseleave uses; there is no more recent position to
|
||||
// report, and inventing one would be a lie.
|
||||
void PyScene::do_mouse_leave()
|
||||
{
|
||||
dispatch_hover(last_mouse_pos, false);
|
||||
}
|
||||
|
||||
void PyScene::dispatch_hover(sf::Vector2f mousepos, bool in_window)
|
||||
{
|
||||
// Helper function to process hover for a single drawable and its children.
|
||||
//
|
||||
// `point` is in the drawable's PARENT-local coordinate space -- the same
|
||||
// convention click_at() uses. At the top level that is the global/scene space
|
||||
// (identical to the old contains_point() behavior, since global position is
|
||||
// just the sum of ancestor positions). Descending into a Frame subtracts the
|
||||
// frame's position; descending into a Grid applies the view's camera
|
||||
// (localToGridWorld), which is what makes grid children hoverable at all (#355).
|
||||
//
|
||||
// `hit_allowed` is false while walking the children of a grid the mouse is NOT
|
||||
// over: the camera transform is affine, so an outside point still maps to some
|
||||
// grid-world coordinate, which could land on an off-screen child. Those subtrees
|
||||
// must only ever fire exits, never enters.
|
||||
std::function<void(UIDrawable*, sf::Vector2f, bool)> processHover =
|
||||
[&](UIDrawable* drawable, sf::Vector2f point, bool hit_allowed) {
|
||||
if (!drawable || !drawable->visible) return;
|
||||
|
||||
bool is_inside = drawable->contains_point(mousepos.x, mousepos.y);
|
||||
// Same rect get_global_bounds() builds (position + bounds SIZE), just
|
||||
// expressed in parent-local space instead of accumulated global space.
|
||||
sf::FloatRect b = drawable->get_bounds();
|
||||
bool is_inside = hit_allowed &&
|
||||
sf::FloatRect(drawable->position.x, drawable->position.y, b.width, b.height).contains(point);
|
||||
bool was_hovered = drawable->hovered;
|
||||
|
||||
if (is_inside && !was_hovered) {
|
||||
|
|
@ -367,34 +383,43 @@ void PyScene::do_mouse_hover(int x, int y)
|
|||
}
|
||||
}
|
||||
|
||||
// Process children for Frame elements
|
||||
// #355 - cell enter/exit (no-op for non-grid drawables)
|
||||
drawable->updateHover(point, hit_allowed);
|
||||
|
||||
// Process children for Frame elements (children are positioned relative
|
||||
// to the frame's own position).
|
||||
if (drawable->derived_type() == PyObjectsEnum::UIFRAME) {
|
||||
auto frame = static_cast<UIFrame*>(drawable);
|
||||
if (frame->children) {
|
||||
sf::Vector2f child_point = point - frame->position;
|
||||
for (auto& child : *frame->children) {
|
||||
processHover(child.get());
|
||||
processHover(child.get(), child_point, hit_allowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Process children for Grid elements
|
||||
else if (drawable->derived_type() == PyObjectsEnum::UIGRID) {
|
||||
auto grid = static_cast<UIGrid*>(drawable);
|
||||
|
||||
// #142 - Update cell hover tracking for grid
|
||||
// Pass "none" for button and "move" for action during hover
|
||||
grid->updateCellHover(mousepos, "none", "move");
|
||||
|
||||
if (grid->children) {
|
||||
for (auto& child : *grid->children) {
|
||||
processHover(child.get());
|
||||
// #355 - Grid overlay children live in GRID-WORLD PIXEL coordinates, so the
|
||||
// mouse point must go through the view's camera exactly as UIGridView::click_at
|
||||
// does. Children are always visited (even when the mouse is outside the view)
|
||||
// so a child that was hovered still receives its on_exit.
|
||||
else if (drawable->derived_type() == PyObjectsEnum::UIGRIDVIEW) {
|
||||
auto view = static_cast<UIGridView*>(drawable);
|
||||
if (view->children) {
|
||||
sf::Vector2f local = point - view->box.getPosition();
|
||||
sf::Vector2f grid_world = view->localToGridWorld(local);
|
||||
bool inside_view = hit_allowed &&
|
||||
sf::FloatRect(sf::Vector2f(0.f, 0.f), view->box.getSize()).contains(local);
|
||||
for (auto& child : *view->children) {
|
||||
processHover(child.get(), grid_world, inside_view);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Process all top-level UI elements
|
||||
// Process all top-level UI elements. in_window is false only on the #363
|
||||
// mouse-left-the-window path, where it propagates down as hit_allowed=false and
|
||||
// turns the whole walk into a pure exit sweep.
|
||||
for (auto& element : *ui_elements) {
|
||||
processHover(element.get());
|
||||
processHover(element.get(), mousepos, in_window);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,17 @@ public:
|
|||
|
||||
void do_mouse_input(std::string, std::string);
|
||||
void do_mouse_hover(int x, int y); // #140 - Mouse enter/exit tracking
|
||||
void do_mouse_leave(); // #363 - cursor left the window
|
||||
|
||||
// Dirty flag for z_index sorting optimization
|
||||
bool ui_elements_need_sort = true;
|
||||
|
||||
private:
|
||||
// #363 - Shared hover walk. in_window=false makes it an exit-only sweep (no
|
||||
// drawable may become hovered), which is what a window-leave means.
|
||||
void dispatch_hover(sf::Vector2f mousepos, bool in_window);
|
||||
|
||||
// #363 - Last cursor position seen by do_mouse_hover, reported to the exit
|
||||
// callbacks fired by do_mouse_leave (MouseLeft carries no coordinates).
|
||||
sf::Vector2f last_mouse_pos{0.f, 0.f};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
#include "UIFrame.h"
|
||||
#include "UICaption.h"
|
||||
#include "UISprite.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UILine.h"
|
||||
#include "UICircle.h"
|
||||
#include "UIArc.h"
|
||||
|
|
@ -162,8 +162,15 @@ int PyPropertyBindingType::init(PyPropertyBindingObject* self, PyObject* args, P
|
|||
target_ptr = ((PyUICaptionObject*)target_obj)->data;
|
||||
} else if (strcmp(type_name, "mcrfpy.Sprite") == 0) {
|
||||
target_ptr = ((PyUISpriteObject*)target_obj)->data;
|
||||
} else if (strcmp(type_name, "mcrfpy.Grid") == 0) {
|
||||
target_ptr = ((PyUIGridObject*)target_obj)->data;
|
||||
} else if (strcmp(type_name, "mcrfpy.Grid") == 0 ||
|
||||
strcmp(type_name, "mcrfpy.GridView") == 0) {
|
||||
// #361: mcrfpy.Grid IS the GridView. This arm used to cast the object to
|
||||
// PyUIGridObject (the _GridData wrapper) and read its shared_ptr<UIGrid>
|
||||
// out of a PyUIGridViewObject -- type confusion that only "worked" because
|
||||
// both wrappers had identical layout and both C++ classes happened to put
|
||||
// UIDrawable at offset 0. Now that GridData is not a drawable the compiler
|
||||
// rejects it, which is how it surfaced.
|
||||
target_ptr = ((PyUIGridViewObject*)target_obj)->data;
|
||||
} else if (strcmp(type_name, "mcrfpy.Line") == 0) {
|
||||
target_ptr = ((PyUILineObject*)target_obj)->data;
|
||||
} else if (strcmp(type_name, "mcrfpy.Circle") == 0) {
|
||||
|
|
|
|||
2
src/UI.h
2
src/UI.h
|
|
@ -19,5 +19,5 @@
|
|||
#include "UISprite.h"
|
||||
#include "UIGridPoint.h"
|
||||
#include "UIEntity.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UICollection.h"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
#include "PyAlignment.h"
|
||||
#include "UIFrame.h" // parent= kwarg: Frame parent type
|
||||
#include "UICaption.h" // parent= kwarg: needed for ATTACH macro instantiation
|
||||
#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type
|
||||
#include "PyGridData.h" // parent= kwarg: Grid/GridView parent type
|
||||
#include "PySceneObject.h" // parent= kwarg: Scene parent type
|
||||
#include <cmath>
|
||||
#include <sstream>
|
||||
|
|
|
|||
11
src/UIBase.h
11
src/UIBase.h
|
|
@ -26,14 +26,13 @@ typedef struct {
|
|||
PyObject* weakreflist; // Weak reference support
|
||||
} PyUICaptionObject;
|
||||
|
||||
class UIGrid;
|
||||
class GridData;
|
||||
class UIGridView;
|
||||
typedef struct {
|
||||
PyObject_HEAD
|
||||
std::shared_ptr<UIGrid> data;
|
||||
std::shared_ptr<UIGridView> view; // #252: auto-created rendering view (shim)
|
||||
std::shared_ptr<GridData> data;
|
||||
PyObject* weakreflist; // Weak reference support
|
||||
} PyUIGridObject;
|
||||
} PyGridDataObject;
|
||||
|
||||
class UISprite;
|
||||
typedef struct {
|
||||
|
|
@ -167,7 +166,7 @@ static PyObject* UIDrawable_animate(T* self, PyObject* args, PyObject* kwds)
|
|||
if ((parent_obj) && (parent_obj) != Py_None) { \
|
||||
if (!PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PyUIFrameType) && \
|
||||
!PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PySceneType) && \
|
||||
!PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PyUIGridType) && \
|
||||
!PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PyGridDataType) && \
|
||||
!PyObject_IsInstance((parent_obj), (PyObject*)&mcrfpydef::PyUIGridViewType)) { \
|
||||
PyErr_SetString(PyExc_TypeError, "parent must be a Frame, Scene, or Grid"); \
|
||||
return -1; \
|
||||
|
|
@ -328,7 +327,7 @@ static int UIDrawable_set_opacity(T* self, PyObject* value, void* closure)
|
|||
MCRF_PROPERTY(rotate_with_camera, \
|
||||
"Whether to rotate visually with parent Grid's camera_rotation (bool). " \
|
||||
"False (default): stay screen-aligned. True: tilt with camera. " \
|
||||
"Only affects children of UIGrid; ignored for other parents." \
|
||||
"Only affects children of a Grid; ignored for other parents." \
|
||||
), (void*)type_enum}
|
||||
|
||||
// #106: Shader support - GPU-accelerated visual effects
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
#include "PyShader.h" // #106: Shader support
|
||||
#include "PyUniformCollection.h" // #106: Uniform collection support
|
||||
#include "UIFrame.h" // parent= kwarg: Frame parent type
|
||||
#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type
|
||||
#include "PyGridData.h" // parent= kwarg: Grid/GridView parent type
|
||||
#include "PySceneObject.h" // parent= kwarg: Scene parent type
|
||||
// UIDrawable methods now in UIBase.h
|
||||
#include "McRFPy_Doc.h"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
#include "PyAlignment.h"
|
||||
#include "UIFrame.h" // parent= kwarg: Frame parent type
|
||||
#include "UICaption.h" // parent= kwarg: needed for ATTACH macro instantiation
|
||||
#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type
|
||||
#include "PyGridData.h" // parent= kwarg: Grid/GridView parent type
|
||||
#include "PySceneObject.h" // parent= kwarg: Scene parent type
|
||||
#include <cmath>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
#include "UIFrame.h"
|
||||
#include "UICaption.h"
|
||||
#include "UISprite.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UIGridView.h"
|
||||
#include "UILine.h"
|
||||
#include "UICircle.h"
|
||||
|
|
@ -69,17 +69,6 @@ static PyObject* convertDrawableToPython(std::shared_ptr<UIDrawable> drawable) {
|
|||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIGRID:
|
||||
{
|
||||
type = &PyUIGridType;
|
||||
auto pyObj = (PyUIGridObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UIGrid>(drawable);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIGRIDVIEW:
|
||||
{
|
||||
type = &PyUIGridViewType;
|
||||
|
|
@ -163,8 +152,9 @@ static std::shared_ptr<UIDrawable> extractDrawable(PyObject* o) {
|
|||
return ((PyUICaptionObject*)o)->data;
|
||||
if (PyObject_IsInstance(o, (PyObject*)&PyUISpriteType))
|
||||
return ((PyUISpriteObject*)o)->data;
|
||||
if (PyObject_IsInstance(o, (PyObject*)&PyUIGridType))
|
||||
return ((PyUIGridObject*)o)->data;
|
||||
// #361: a GridData is NOT a drawable and cannot be appended to a collection.
|
||||
// It falls through to the caller's TypeError -- appending a map to a scene used
|
||||
// to draw the whole grid a second time through a frozen, invisible camera.
|
||||
if (PyObject_IsInstance(o, (PyObject*)&PyUIGridViewType))
|
||||
return ((PyUIGridViewObject*)o)->data;
|
||||
if (PyObject_IsInstance(o, (PyObject*)&PyUILineType))
|
||||
|
|
@ -1127,12 +1117,15 @@ PyObject* UICollection::repr(PyUICollectionObject* self)
|
|||
// Count each type
|
||||
int frame_count = 0, caption_count = 0, sprite_count = 0, grid_count = 0, other_count = 0;
|
||||
for (auto& item : *self->data) {
|
||||
// #366: detect grid-ness with the asGridData() virtual (#355), not
|
||||
// derived_type(). Every mcrfpy.Grid in a collection is a UIGRIDVIEW --
|
||||
// the UIGRID arm this used to key on has not appeared in a scene graph
|
||||
// since #252, so grids were tallied as "other".
|
||||
if (item->asGridData()) { grid_count++; continue; }
|
||||
switch(item->derived_type()) {
|
||||
case PyObjectsEnum::UIFRAME: frame_count++; break;
|
||||
case PyObjectsEnum::UICAPTION: caption_count++; break;
|
||||
case PyObjectsEnum::UISPRITE: sprite_count++; break;
|
||||
case PyObjectsEnum::UIGRID: grid_count++; break;
|
||||
case PyObjectsEnum::UIGRIDVIEW: other_count++; break;
|
||||
default: other_count++; break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
#include "UIFrame.h"
|
||||
#include "UICaption.h"
|
||||
#include "UISprite.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UIGridView.h"
|
||||
#include "UILine.h"
|
||||
#include "UICircle.h"
|
||||
|
|
@ -28,8 +28,6 @@ static UIDrawable* extractDrawable(PyObject* self, PyObjectsEnum objtype) {
|
|||
return ((PyUICaptionObject*)self)->data.get();
|
||||
case PyObjectsEnum::UISPRITE:
|
||||
return ((PyUISpriteObject*)self)->data.get();
|
||||
case PyObjectsEnum::UIGRID:
|
||||
return ((PyUIGridObject*)self)->data.get();
|
||||
case PyObjectsEnum::UILINE:
|
||||
return ((PyUILineObject*)self)->data.get();
|
||||
case PyObjectsEnum::UICIRCLE:
|
||||
|
|
@ -53,8 +51,6 @@ static std::shared_ptr<UIDrawable> extractDrawableShared(PyObject* self, PyObjec
|
|||
return ((PyUICaptionObject*)self)->data;
|
||||
case PyObjectsEnum::UISPRITE:
|
||||
return ((PyUISpriteObject*)self)->data;
|
||||
case PyObjectsEnum::UIGRID:
|
||||
return ((PyUIGridObject*)self)->data;
|
||||
case PyObjectsEnum::UILINE:
|
||||
return ((PyUILineObject*)self)->data;
|
||||
case PyObjectsEnum::UICIRCLE:
|
||||
|
|
@ -259,12 +255,6 @@ PyObject* UIDrawable::get_click(PyObject* self, void* closure) {
|
|||
else
|
||||
ptr = NULL;
|
||||
break;
|
||||
case PyObjectsEnum::UIGRID:
|
||||
if (((PyUIGridObject*)self)->data->click_callable)
|
||||
ptr = ((PyUIGridObject*)self)->data->click_callable->borrow();
|
||||
else
|
||||
ptr = NULL;
|
||||
break;
|
||||
case PyObjectsEnum::UILINE:
|
||||
if (((PyUILineObject*)self)->data->click_callable)
|
||||
ptr = ((PyUILineObject*)self)->data->click_callable->borrow();
|
||||
|
|
@ -314,9 +304,6 @@ int UIDrawable::set_click(PyObject* self, PyObject* value, void* closure) {
|
|||
case PyObjectsEnum::UISPRITE:
|
||||
target = (((PyUISpriteObject*)self)->data.get());
|
||||
break;
|
||||
case PyObjectsEnum::UIGRID:
|
||||
target = (((PyUIGridObject*)self)->data.get());
|
||||
break;
|
||||
case PyObjectsEnum::UILINE:
|
||||
target = (((PyUILineObject*)self)->data.get());
|
||||
break;
|
||||
|
|
@ -727,20 +714,21 @@ int UIDrawable::set_rotate_with_camera(PyObject* self, PyObject* value, void* cl
|
|||
return 0;
|
||||
}
|
||||
|
||||
// #221 - Grid coordinate properties (only valid when parent is UIGrid)
|
||||
// #221 - Grid coordinate properties (only valid when the parent is a Grid, i.e. a UIGridView)
|
||||
PyObject* UIDrawable::get_grid_pos(PyObject* self, void* closure) {
|
||||
PyObjectsEnum objtype = static_cast<PyObjectsEnum>(reinterpret_cast<intptr_t>(closure));
|
||||
UIDrawable* drawable = extractDrawable(self, objtype);
|
||||
if (!drawable) return NULL;
|
||||
|
||||
// Check if parent is a UIGrid
|
||||
// Check if parent is a Grid (UIGridView) -- #361: GridData is not a drawable
|
||||
// and can never be a parent.
|
||||
auto parent_ptr = drawable->getParent();
|
||||
if (!parent_ptr) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "drawable is not a child of a Grid");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
UIGrid* grid = dynamic_cast<UIGrid*>(parent_ptr.get());
|
||||
UIGridView* grid = dynamic_cast<UIGridView*>(parent_ptr.get());
|
||||
if (!grid) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "drawable is not a direct child of a Grid");
|
||||
return NULL;
|
||||
|
|
@ -766,14 +754,15 @@ int UIDrawable::set_grid_pos(PyObject* self, PyObject* value, void* closure) {
|
|||
UIDrawable* drawable = extractDrawable(self, objtype);
|
||||
if (!drawable) return -1;
|
||||
|
||||
// Check if parent is a UIGrid
|
||||
// Check if parent is a Grid (UIGridView) -- #361: GridData is not a drawable
|
||||
// and can never be a parent.
|
||||
auto parent_ptr = drawable->getParent();
|
||||
if (!parent_ptr) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "drawable is not a child of a Grid");
|
||||
return -1;
|
||||
}
|
||||
|
||||
UIGrid* grid = dynamic_cast<UIGrid*>(parent_ptr.get());
|
||||
UIGridView* grid = dynamic_cast<UIGridView*>(parent_ptr.get());
|
||||
if (!grid) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "drawable is not a direct child of a Grid");
|
||||
return -1;
|
||||
|
|
@ -843,14 +832,15 @@ PyObject* UIDrawable::get_grid_size(PyObject* self, void* closure) {
|
|||
UIDrawable* drawable = extractDrawable(self, objtype);
|
||||
if (!drawable) return NULL;
|
||||
|
||||
// Check if parent is a UIGrid
|
||||
// Check if parent is a Grid (UIGridView) -- #361: GridData is not a drawable
|
||||
// and can never be a parent.
|
||||
auto parent_ptr = drawable->getParent();
|
||||
if (!parent_ptr) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "drawable is not a child of a Grid");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
UIGrid* grid = dynamic_cast<UIGrid*>(parent_ptr.get());
|
||||
UIGridView* grid = dynamic_cast<UIGridView*>(parent_ptr.get());
|
||||
if (!grid) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "drawable is not a direct child of a Grid");
|
||||
return NULL;
|
||||
|
|
@ -877,14 +867,15 @@ int UIDrawable::set_grid_size(PyObject* self, PyObject* value, void* closure) {
|
|||
UIDrawable* drawable = extractDrawable(self, objtype);
|
||||
if (!drawable) return -1;
|
||||
|
||||
// Check if parent is a UIGrid
|
||||
// Check if parent is a Grid (UIGridView) -- #361: GridData is not a drawable
|
||||
// and can never be a parent.
|
||||
auto parent_ptr = drawable->getParent();
|
||||
if (!parent_ptr) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "drawable is not a child of a Grid");
|
||||
return -1;
|
||||
}
|
||||
|
||||
UIGrid* grid = dynamic_cast<UIGrid*>(parent_ptr.get());
|
||||
UIGridView* grid = dynamic_cast<UIGridView*>(parent_ptr.get());
|
||||
if (!grid) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "drawable is not a direct child of a Grid");
|
||||
return -1;
|
||||
|
|
@ -1007,29 +998,19 @@ void UIDrawable::removeFromParent() {
|
|||
}
|
||||
frame->children_need_sort = true;
|
||||
}
|
||||
else if (p->derived_type() == PyObjectsEnum::UIGRID) {
|
||||
auto grid = std::static_pointer_cast<UIGrid>(p);
|
||||
auto& children = *grid->children;
|
||||
|
||||
for (auto it = children.begin(); it != children.end(); ++it) {
|
||||
if (it->get() == this) {
|
||||
children.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
grid->children_need_sort = true;
|
||||
}
|
||||
// #364: a grid child's parent is the UIGridView. The internal UIGrid (_GridData)
|
||||
// holds no drawables, so it can never be a parent and needs no arm here.
|
||||
else if (p->derived_type() == PyObjectsEnum::UIGRIDVIEW) {
|
||||
auto view = std::static_pointer_cast<UIGridView>(p);
|
||||
if (view->grid_data && view->grid_data->children) {
|
||||
auto& children = *view->grid_data->children;
|
||||
if (view->children) {
|
||||
auto& children = *view->children;
|
||||
for (auto it = children.begin(); it != children.end(); ++it) {
|
||||
if (it->get() == this) {
|
||||
children.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
view->grid_data->children_need_sort = true;
|
||||
view->children_need_sort = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1065,15 +1046,32 @@ bool UIDrawable::contains_point(float x, float y) const {
|
|||
}
|
||||
|
||||
// #144: Content dirty - texture needs rebuild
|
||||
//
|
||||
// #368 - The walk up the parent chain is UNCONDITIONAL. It used to be gated on
|
||||
// `(!was_dirty || !p->render_dirty)`, which is sound only if render_dirty is reliably
|
||||
// cleared once a drawable has been drawn. It is not: clearDirty() is called by the two
|
||||
// classes that actually *cache* a raster (UIFrame's render-texture path, UIGridView),
|
||||
// and by nobody else. For every ordinary Frame/Caption/Sprite/Line/Circle/Arc,
|
||||
// render_dirty is a write-once latch -- true from the first mutation until death.
|
||||
//
|
||||
// With the flag stuck true, `was_dirty` was always true and the parent's render_dirty
|
||||
// was always true, so the guard was always false and content invalidation propagated
|
||||
// NOWHERE. A caching ancestor (Frame(cache_subtree=True), or a GridView) went on
|
||||
// re-blitting a stale composite: text never updated, colors never changed. Only
|
||||
// position changes survived, because markCompositeDirty (below) already walked
|
||||
// unconditionally.
|
||||
//
|
||||
// Restoring the guard would mean making every drawable clear the flag honestly, and
|
||||
// then trusting that every future drawable remembers to. The invariant here is worth
|
||||
// more than the walk it saves: parent chains are shallow, and this is exactly the cost
|
||||
// markCompositeDirty has always paid on every move without anyone noticing.
|
||||
void UIDrawable::markContentDirty() {
|
||||
bool was_dirty = render_dirty;
|
||||
render_dirty = true;
|
||||
composite_dirty = true; // If content changed, composite also needs update
|
||||
|
||||
// Propagate to parent - parent's composite is dirty (child content changed)
|
||||
// Propagate if: we weren't already dirty, OR parent was cleared (rendered) since last propagation
|
||||
auto p = parent.lock();
|
||||
if (p && (!was_dirty || !p->render_dirty)) {
|
||||
if (p) {
|
||||
p->markContentDirty(); // Parent also needs to rebuild to include our changes
|
||||
}
|
||||
}
|
||||
|
|
@ -1346,17 +1344,6 @@ PyObject* UIDrawable::get_parent(PyObject* self, void* closure) {
|
|||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIGRID:
|
||||
{
|
||||
type = &mcrfpydef::PyUIGridType;
|
||||
auto pyObj = (PyUIGridObject*)type->tp_alloc(type, 0);
|
||||
if (pyObj) {
|
||||
pyObj->data = std::static_pointer_cast<UIGrid>(parent_ptr);
|
||||
pyObj->weakreflist = NULL;
|
||||
}
|
||||
obj = (PyObject*)pyObj;
|
||||
break;
|
||||
}
|
||||
case PyObjectsEnum::UIGRIDVIEW:
|
||||
{
|
||||
type = &mcrfpydef::PyUIGridViewType;
|
||||
|
|
@ -1391,9 +1378,6 @@ int UIDrawable::set_parent(PyObject* self, PyObject* value, void* closure) {
|
|||
case PyObjectsEnum::UISPRITE:
|
||||
drawable = ((PyUISpriteObject*)self)->data;
|
||||
break;
|
||||
case PyObjectsEnum::UIGRID:
|
||||
drawable = ((PyUIGridObject*)self)->data;
|
||||
break;
|
||||
case PyObjectsEnum::UILINE:
|
||||
drawable = ((PyUILineObject*)self)->data;
|
||||
break;
|
||||
|
|
@ -1417,13 +1401,14 @@ int UIDrawable::set_parent(PyObject* self, PyObject* value, void* closure) {
|
|||
return 0;
|
||||
}
|
||||
|
||||
// Value must be a Frame, Grid, or Scene (things with children collections)
|
||||
// Value must be a Frame, Grid, or Scene (things with children collections).
|
||||
// #364: the internal _GridData (PyGridDataType) is NOT among them -- it holds
|
||||
// entities and cells, never drawables -- so it falls through to the TypeError.
|
||||
bool is_frame = PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIFrameType);
|
||||
bool is_grid = PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType);
|
||||
bool is_gridview = PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType);
|
||||
bool is_scene = PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PySceneType);
|
||||
|
||||
if (!is_frame && !is_grid && !is_gridview && !is_scene) {
|
||||
if (!is_frame && !is_gridview && !is_scene) {
|
||||
PyErr_SetString(PyExc_TypeError, "parent must be a Frame, Grid, Scene, or None");
|
||||
return -1;
|
||||
}
|
||||
|
|
@ -1468,16 +1453,10 @@ int UIDrawable::set_parent(PyObject* self, PyObject* value, void* closure) {
|
|||
children_ptr = &frame->children;
|
||||
new_parent = frame;
|
||||
} else if (is_gridview) {
|
||||
// #252: GridView (unified Grid) - access children through grid_data
|
||||
// #364: overlay children belong to the view itself, not to the grid data.
|
||||
auto view = ((PyUIGridViewObject*)value)->data;
|
||||
if (view->grid_data) {
|
||||
children_ptr = &view->grid_data->children;
|
||||
}
|
||||
children_ptr = &view->children;
|
||||
new_parent = view;
|
||||
} else if (is_grid) {
|
||||
auto grid = ((PyUIGridObject*)value)->data;
|
||||
children_ptr = &grid->children;
|
||||
new_parent = grid;
|
||||
}
|
||||
|
||||
if (children_ptr && *children_ptr) {
|
||||
|
|
@ -1592,10 +1571,6 @@ PyObject* UIDrawable::get_on_enter(PyObject* self, void* closure) {
|
|||
if (((PyUISpriteObject*)self)->data->on_enter_callable)
|
||||
ptr = ((PyUISpriteObject*)self)->data->on_enter_callable->borrow();
|
||||
break;
|
||||
case PyObjectsEnum::UIGRID:
|
||||
if (((PyUIGridObject*)self)->data->on_enter_callable)
|
||||
ptr = ((PyUIGridObject*)self)->data->on_enter_callable->borrow();
|
||||
break;
|
||||
case PyObjectsEnum::UILINE:
|
||||
if (((PyUILineObject*)self)->data->on_enter_callable)
|
||||
ptr = ((PyUILineObject*)self)->data->on_enter_callable->borrow();
|
||||
|
|
@ -1637,9 +1612,6 @@ int UIDrawable::set_on_enter(PyObject* self, PyObject* value, void* closure) {
|
|||
case PyObjectsEnum::UISPRITE:
|
||||
target = ((PyUISpriteObject*)self)->data.get();
|
||||
break;
|
||||
case PyObjectsEnum::UIGRID:
|
||||
target = ((PyUIGridObject*)self)->data.get();
|
||||
break;
|
||||
case PyObjectsEnum::UILINE:
|
||||
target = ((PyUILineObject*)self)->data.get();
|
||||
break;
|
||||
|
|
@ -1683,10 +1655,6 @@ PyObject* UIDrawable::get_on_exit(PyObject* self, void* closure) {
|
|||
if (((PyUISpriteObject*)self)->data->on_exit_callable)
|
||||
ptr = ((PyUISpriteObject*)self)->data->on_exit_callable->borrow();
|
||||
break;
|
||||
case PyObjectsEnum::UIGRID:
|
||||
if (((PyUIGridObject*)self)->data->on_exit_callable)
|
||||
ptr = ((PyUIGridObject*)self)->data->on_exit_callable->borrow();
|
||||
break;
|
||||
case PyObjectsEnum::UILINE:
|
||||
if (((PyUILineObject*)self)->data->on_exit_callable)
|
||||
ptr = ((PyUILineObject*)self)->data->on_exit_callable->borrow();
|
||||
|
|
@ -1728,9 +1696,6 @@ int UIDrawable::set_on_exit(PyObject* self, PyObject* value, void* closure) {
|
|||
case PyObjectsEnum::UISPRITE:
|
||||
target = ((PyUISpriteObject*)self)->data.get();
|
||||
break;
|
||||
case PyObjectsEnum::UIGRID:
|
||||
target = ((PyUIGridObject*)self)->data.get();
|
||||
break;
|
||||
case PyObjectsEnum::UILINE:
|
||||
target = ((PyUILineObject*)self)->data.get();
|
||||
break;
|
||||
|
|
@ -1783,10 +1748,6 @@ PyObject* UIDrawable::get_on_move(PyObject* self, void* closure) {
|
|||
if (((PyUISpriteObject*)self)->data->on_move_callable)
|
||||
ptr = ((PyUISpriteObject*)self)->data->on_move_callable->borrow();
|
||||
break;
|
||||
case PyObjectsEnum::UIGRID:
|
||||
if (((PyUIGridObject*)self)->data->on_move_callable)
|
||||
ptr = ((PyUIGridObject*)self)->data->on_move_callable->borrow();
|
||||
break;
|
||||
case PyObjectsEnum::UILINE:
|
||||
if (((PyUILineObject*)self)->data->on_move_callable)
|
||||
ptr = ((PyUILineObject*)self)->data->on_move_callable->borrow();
|
||||
|
|
@ -1828,9 +1789,6 @@ int UIDrawable::set_on_move(PyObject* self, PyObject* value, void* closure) {
|
|||
case PyObjectsEnum::UISPRITE:
|
||||
target = ((PyUISpriteObject*)self)->data.get();
|
||||
break;
|
||||
case PyObjectsEnum::UIGRID:
|
||||
target = ((PyUIGridObject*)self)->data.get();
|
||||
break;
|
||||
case PyObjectsEnum::UILINE:
|
||||
target = ((PyUILineObject*)self)->data.get();
|
||||
break;
|
||||
|
|
@ -2195,7 +2153,6 @@ PyObject* UIDrawable::py_realign(PyObject* self, PyObject* args) {
|
|||
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUICaptionType)) objtype = PyObjectsEnum::UICAPTION;
|
||||
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUISpriteType)) objtype = PyObjectsEnum::UISPRITE;
|
||||
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUIGridViewType)) objtype = PyObjectsEnum::UIGRIDVIEW;
|
||||
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUIGridType)) objtype = PyObjectsEnum::UIGRID;
|
||||
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUILineType)) objtype = PyObjectsEnum::UILINE;
|
||||
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUICircleType)) objtype = PyObjectsEnum::UICIRCLE;
|
||||
else if (PyObject_IsInstance(self, (PyObject*)&mcrfpydef::PyUIArcType)) objtype = PyObjectsEnum::UIARC;
|
||||
|
|
|
|||
|
|
@ -22,14 +22,15 @@ class UniformCollection;
|
|||
// PyShaderObject is a typedef, forward declare as a struct with explicit typedef
|
||||
typedef struct PyShaderObjectStruct PyShaderObject;
|
||||
|
||||
class UIFrame; class UICaption; class UISprite; class UIEntity; class UIGrid;
|
||||
class UIFrame; class UICaption; class UISprite; class UIEntity; class GridData;
|
||||
|
||||
enum PyObjectsEnum : int
|
||||
{
|
||||
UIFRAME = 1,
|
||||
UICAPTION,
|
||||
UISPRITE,
|
||||
UIGRID,
|
||||
// #361: no UIGRID. GridData is not a UIDrawable, so it has no derived_type()
|
||||
// and can never appear in a scene graph. Every grid node IS a UIGRIDVIEW.
|
||||
UILINE,
|
||||
UICIRCLE,
|
||||
UIARC,
|
||||
|
|
@ -55,6 +56,27 @@ public:
|
|||
void click_register(PyObject*);
|
||||
void click_unregister();
|
||||
|
||||
// #355 - Grid cell input. Defaults are inert: no caller may switch on grid-ness.
|
||||
//
|
||||
// dispatchCellClick: called by PyScene after click_at() resolved this drawable as
|
||||
// the click target. Takes no point: click_at() already resolved the cell in the
|
||||
// coordinate space of the hit (parent-local), which PyScene does not have.
|
||||
// Returns true if a cell callback consumed the click.
|
||||
virtual bool dispatchCellClick(const std::string& button, const std::string& action)
|
||||
{ (void)button; (void)action; return false; }
|
||||
|
||||
// updateHover: called by PyScene::do_mouse_hover for every drawable, with the
|
||||
// mouse position in this drawable's PARENT-local coordinate space (the same
|
||||
// convention click_at() uses; at the top level that is global space).
|
||||
// hit_allowed is false when an ancestor already determined the mouse cannot be
|
||||
// over this subtree -- such a call may only clear hover state, never set it.
|
||||
virtual void updateHover(sf::Vector2f point, bool hit_allowed)
|
||||
{ (void)point; (void)hit_allowed; }
|
||||
|
||||
// asGridData: non-null only for drawables that own or view grid data.
|
||||
// (#357 find/findAll and #358 ImGuiSceneExplorer will use this same primitive.)
|
||||
virtual GridData* asGridData() { return nullptr; }
|
||||
|
||||
// #140 - Mouse enter/exit callbacks
|
||||
void on_enter_register(PyObject*);
|
||||
void on_enter_unregister();
|
||||
|
|
@ -109,7 +131,7 @@ public:
|
|||
static PyObject* get_rotate_with_camera(PyObject* self, void* closure);
|
||||
static int set_rotate_with_camera(PyObject* self, PyObject* value, void* closure);
|
||||
|
||||
// #221 - Grid coordinate properties (only valid when parent is UIGrid)
|
||||
// #221 - Grid coordinate properties (only valid when the parent is a Grid/UIGridView)
|
||||
static PyObject* get_grid_pos(PyObject* self, void* closure);
|
||||
static int set_grid_pos(PyObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_grid_size(PyObject* self, void* closure);
|
||||
|
|
@ -134,7 +156,7 @@ public:
|
|||
sf::Vector2f origin;
|
||||
|
||||
// Whether to rotate visually with parent Grid's camera_rotation
|
||||
// Only affects children of UIGrid; ignored for other parents
|
||||
// Only affects children of a Grid (UIGridView); ignored for other parents
|
||||
bool rotate_with_camera = false;
|
||||
|
||||
// Parent-child hierarchy (#122)
|
||||
|
|
@ -359,7 +381,7 @@ static PyObject* PyUIDrawable_get_click(PyObject* self, void* closure) {
|
|||
ptr = ((PyUISpriteObject*)self)->data->click_callable->borrow();
|
||||
break;
|
||||
case PyObjectsEnum::UIGRID:
|
||||
ptr = ((PyUIGridObject*)self)->data->click_callable->borrow();
|
||||
ptr = ((PyGridDataObject*)self)->data->click_callable->borrow();
|
||||
break;
|
||||
default:
|
||||
PyErr_SetString(PyExc_TypeError, "no idea how you did that; invalid UIDrawable derived instance for _get_click");
|
||||
|
|
@ -388,7 +410,7 @@ static int PyUIDrawable_set_click(PyObject* self, PyObject* value, void* closure
|
|||
target = (((PyUISpriteObject*)self)->data.get());
|
||||
break;
|
||||
case PyObjectsEnum::UIGRID:
|
||||
target = (((PyUIGridObject*)self)->data.get());
|
||||
target = (((PyGridDataObject*)self)->data.get());
|
||||
break;
|
||||
default:
|
||||
PyErr_SetString(PyExc_TypeError, "no idea how you did that; invalid UIDrawable derived instance for _set_click");
|
||||
|
|
|
|||
117
src/UIEntity.cpp
117
src/UIEntity.cpp
|
|
@ -1,5 +1,5 @@
|
|||
#include "UIEntity.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UIGridView.h" // #252: Entity.grid accepts GridView
|
||||
#include "UIGridPathfinding.h"
|
||||
#include "PathProvider.h"
|
||||
|
|
@ -23,19 +23,10 @@
|
|||
#include "McRFPy_Doc.h"
|
||||
#include <cassert>
|
||||
|
||||
// #313: UIEntity::grid holds the GridData base, but some Python wrappers
|
||||
// (PyUIGridObject, PyUIGridPointObject) and pathfinding helpers still take the
|
||||
// full UIGrid. GridData is never independently heap-allocated -- it is always
|
||||
// a UIGrid base subobject (see GridData.h) -- so this aliasing downcast is
|
||||
// valid and shares the original control block (never mints a new one, which
|
||||
// would double-free and break the #251 use_count dealloc gate).
|
||||
// TODO(#252): remove once those wrappers accept pure GridData.
|
||||
static std::shared_ptr<UIGrid> grid_as_uigrid(const std::shared_ptr<GridData>& grid)
|
||||
{
|
||||
if (!grid) return nullptr;
|
||||
assert(dynamic_cast<UIGrid*>(grid.get()) != nullptr);
|
||||
return std::shared_ptr<UIGrid>(grid, static_cast<UIGrid*>(grid.get()));
|
||||
}
|
||||
// #361: grid_as_uigrid() is gone. It existed to recover the "full UIGrid" that
|
||||
// every GridData was secretly a base subobject of, so that Python wrappers and
|
||||
// pathfinding helpers could get at the drawable half. There is no drawable half
|
||||
// any more -- GridData is the whole object, and every wrapper takes it directly.
|
||||
|
||||
UIEntity::UIEntity()
|
||||
: grid(nullptr), position(0.0f, 0.0f), sprite_offset(0.0f, 0.0f)
|
||||
|
|
@ -195,7 +186,7 @@ PyObject* UIEntity::at(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
|
|||
auto type = &mcrfpydef::PyUIGridPointType;
|
||||
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
|
||||
if (!obj) return NULL;
|
||||
obj->grid = grid_as_uigrid(entity->grid); // #313: wrapper still holds UIGrid
|
||||
obj->grid = entity->grid;
|
||||
obj->x = x;
|
||||
obj->y = y;
|
||||
return (PyObject*)obj;
|
||||
|
|
@ -290,7 +281,7 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
|
|||
}
|
||||
|
||||
// Handle grid argument - accept both internal _GridData and GridView (unified Grid)
|
||||
if (grid_obj && !PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridType) &&
|
||||
if (grid_obj && !PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyGridDataType) &&
|
||||
!PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
|
||||
PyErr_SetString(PyExc_TypeError, "grid must be a mcrfpy.Grid instance");
|
||||
return -1;
|
||||
|
|
@ -379,7 +370,7 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) {
|
|||
grid_ptr = pyview->data->grid_data;
|
||||
} else {
|
||||
// Internal _GridData type
|
||||
PyUIGridObject* pygrid = (PyUIGridObject*)grid_obj;
|
||||
PyGridDataObject* pygrid = (PyGridDataObject*)grid_obj;
|
||||
grid_ptr = pygrid->data;
|
||||
}
|
||||
if (grid_ptr) {
|
||||
|
|
@ -819,65 +810,20 @@ PyObject* UIEntity::get_grid(PyUIEntityObject* self, void* closure)
|
|||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
auto& grid = self->data->grid;
|
||||
|
||||
// #252: If the grid has an owning GridView, return that instead.
|
||||
// This preserves the unified Grid API where entity.grid returns the same
|
||||
// object the user created via mcrfpy.Grid(...).
|
||||
auto owning_view = grid->owning_view.lock();
|
||||
if (owning_view) {
|
||||
// Check cache for the GridView
|
||||
if (owning_view->serial_number != 0) {
|
||||
PyObject* cached = PythonObjectCache::getInstance().lookup(owning_view->serial_number);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
auto view_type = &mcrfpydef::PyUIGridViewType;
|
||||
auto pyView = (PyUIGridViewObject*)view_type->tp_alloc(view_type, 0);
|
||||
if (pyView) {
|
||||
pyView->data = owning_view;
|
||||
pyView->weakreflist = NULL;
|
||||
|
||||
if (owning_view->serial_number == 0) {
|
||||
owning_view->serial_number = PythonObjectCache::getInstance().assignSerial();
|
||||
}
|
||||
PyObject* weakref = PyWeakref_NewRef((PyObject*)pyView, NULL);
|
||||
if (weakref) {
|
||||
PythonObjectCache::getInstance().registerObject(owning_view->serial_number, weakref);
|
||||
Py_DECREF(weakref);
|
||||
}
|
||||
}
|
||||
return (PyObject*)pyView;
|
||||
}
|
||||
|
||||
// Fallback: return internal _GridData wrapper (no owning view)
|
||||
// #313: serial_number lives on the UIDrawable side; recover the full
|
||||
// UIGrid via the aliasing helper (same control block, no new ownership).
|
||||
auto uigrid = grid_as_uigrid(grid);
|
||||
if (uigrid->serial_number != 0) {
|
||||
PyObject* cached = PythonObjectCache::getInstance().lookup(uigrid->serial_number);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
auto grid_type = &mcrfpydef::PyUIGridType;
|
||||
auto pyGrid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
|
||||
|
||||
if (pyGrid) {
|
||||
pyGrid->data = uigrid;
|
||||
pyGrid->weakreflist = NULL;
|
||||
|
||||
if (uigrid->serial_number == 0) {
|
||||
uigrid->serial_number = PythonObjectCache::getInstance().assignSerial();
|
||||
}
|
||||
PyObject* weakref = PyWeakref_NewRef((PyObject*)pyGrid, NULL);
|
||||
if (weakref) {
|
||||
PythonObjectCache::getInstance().registerObject(uigrid->serial_number, weakref);
|
||||
Py_DECREF(weakref);
|
||||
}
|
||||
}
|
||||
return (PyObject*)pyGrid;
|
||||
// #361 - Returns the GridData (the MAP), not a view.
|
||||
//
|
||||
// This is a deliberate, user-visible break. It used to return primaryView() --
|
||||
// the Grid whose constructor happened to create this data -- falling back to a
|
||||
// bare _GridData wrapper when no view existed. But "the grid an entity is in"
|
||||
// is not a camera: an entity may live on a map with NO view (an offscreen
|
||||
// level, still steppable and queryable) or with SEVERAL, and picking
|
||||
// views.front() was an arbitrary answer dressed up as a deterministic one.
|
||||
//
|
||||
// Migration: `player.grid.center_camera(player.pos)` becomes
|
||||
// `view.center_camera(player.pos)` on the view you are actually rendering.
|
||||
// Everything that is genuinely about the map -- at(), entities, layers,
|
||||
// compute_fov(), find_path(), step() -- is unchanged.
|
||||
return PyGridData::pyobject_for(self->data->grid);
|
||||
}
|
||||
|
||||
int UIEntity::set_grid(PyUIEntityObject* self, PyObject* value, void* closure)
|
||||
|
|
@ -920,8 +866,8 @@ int UIEntity::set_grid(PyUIEntityObject* self, PyObject* value, void* closure)
|
|||
PyErr_SetString(PyExc_ValueError, "Grid has no data");
|
||||
return -1;
|
||||
}
|
||||
} else if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) {
|
||||
new_grid = ((PyUIGridObject*)value)->data;
|
||||
} else if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyGridDataType)) {
|
||||
new_grid = ((PyGridDataObject*)value)->data;
|
||||
} else {
|
||||
PyErr_SetString(PyExc_TypeError, "grid must be a Grid or None");
|
||||
return -1;
|
||||
|
|
@ -1259,12 +1205,9 @@ PyObject* UIEntity::find_path(PyUIEntityObject* self, PyObject* args, PyObject*
|
|||
|
||||
auto grid = self->data->grid;
|
||||
|
||||
// Extract target position
|
||||
// #313: ExtractPosition still takes UIGrid*; the downcast is valid because
|
||||
// GridData is always a UIGrid base subobject (see grid_as_uigrid).
|
||||
int target_x, target_y;
|
||||
if (!UIGridPathfinding::ExtractPosition(target_obj, &target_x, &target_y,
|
||||
static_cast<UIGrid*>(grid.get()), "target")) {
|
||||
grid.get(), "target")) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
|
|
@ -1278,13 +1221,9 @@ PyObject* UIEntity::find_path(PyUIEntityObject* self, PyObject* args, PyObject*
|
|||
return NULL;
|
||||
}
|
||||
|
||||
// Build args to delegate to Grid.find_path
|
||||
// Create a temporary PyUIGridObject wrapper for the grid (internal _GridData type)
|
||||
// #313: wrapper holds shared_ptr<UIGrid>; alias-cast from the data ptr.
|
||||
auto* grid_type = &mcrfpydef::PyUIGridType;
|
||||
auto pyGrid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
|
||||
// Delegate to GridData.find_path on the map's one Python wrapper (#361).
|
||||
PyObject* pyGrid = PyGridData::pyobject_for(grid);
|
||||
if (!pyGrid) return NULL;
|
||||
new (&pyGrid->data) std::shared_ptr<UIGrid>(grid_as_uigrid(grid));
|
||||
|
||||
// Build keyword args for Grid.find_path
|
||||
PyObject* start_tuple = Py_BuildValue("(ii)", start_x, start_y);
|
||||
|
|
@ -1303,7 +1242,7 @@ PyObject* UIEntity::find_path(PyUIEntityObject* self, PyObject* args, PyObject*
|
|||
Py_DECREF(py_collide);
|
||||
}
|
||||
|
||||
PyObject* result = UIGridPathfinding::Grid_find_path(pyGrid, fwd_args, fwd_kwds);
|
||||
PyObject* result = UIGridPathfinding::Grid_find_path((PyGridDataObject*)pyGrid, fwd_args, fwd_kwds);
|
||||
|
||||
Py_DECREF(fwd_args);
|
||||
Py_DECREF(fwd_kwds);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
#include "DiscreteMap.h"
|
||||
#include <memory>
|
||||
|
||||
class UIGrid;
|
||||
class GridData;
|
||||
class GridData;
|
||||
|
||||
// UIEntity
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
#include "UIEntityCollection.h"
|
||||
#include "UIEntity.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "McRFPy_API.h"
|
||||
#include "McRFPy_Doc.h"
|
||||
#include "PythonObjectCache.h"
|
||||
|
|
|
|||
|
|
@ -18,13 +18,13 @@
|
|||
|
||||
// Forward declarations
|
||||
class UIEntity;
|
||||
class UIGrid;
|
||||
class GridData;
|
||||
|
||||
// Python object for EntityCollection
|
||||
typedef struct {
|
||||
PyObject_HEAD
|
||||
std::shared_ptr<std::vector<std::shared_ptr<UIEntity>>> data;
|
||||
std::shared_ptr<UIGrid> grid;
|
||||
std::shared_ptr<GridData> grid;
|
||||
} PyUIEntityCollectionObject;
|
||||
|
||||
// Python object for EntityCollection iterator
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
#include "PyVector.h"
|
||||
#include "UICaption.h"
|
||||
#include "UISprite.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "PySceneObject.h" // parent= kwarg: Scene parent type
|
||||
#include "McRFPy_API.h"
|
||||
#include "PythonObjectCache.h"
|
||||
|
|
@ -548,7 +548,16 @@ PyGetSetDef UIFrame::getsetters[] = {
|
|||
"For animation, use 'outline_color.r', 'outline_color.g', etc."
|
||||
), (void*)1},
|
||||
{"children", (getter)UIFrame::get_children, NULL,
|
||||
MCRF_PROPERTY(children, "UICollection of child drawable objects rendered on top of this frame (UICollection, read-only)."),
|
||||
MCRF_PROPERTY(children,
|
||||
"UICollection of child drawable objects rendered on top of this frame (UICollection, "
|
||||
"read-only)."
|
||||
MCRF_NOTE(
|
||||
"Child positions are frame-local (relative to this Frame's top-left corner). A "
|
||||
"Frame has no camera, so it cannot pan its content -- frame-local is effectively "
|
||||
"screen space. Contrast with Grid.children, which are positioned in the grid's "
|
||||
"pixel-world coordinates and pan/zoom with the grid camera."
|
||||
)
|
||||
),
|
||||
NULL},
|
||||
{"on_click", (getter)UIDrawable::get_click, (setter)UIDrawable::set_click,
|
||||
MCRF_PROPERTY(on_click,
|
||||
|
|
@ -780,7 +789,6 @@ int UIFrame::init(PyUIFrameObject* self, PyObject* args, PyObject* kwds)
|
|||
if (!PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUIFrameType) &&
|
||||
!PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUICaptionType) &&
|
||||
!PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUISpriteType) &&
|
||||
!PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUIGridType) &&
|
||||
!PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
|
||||
Py_DECREF(child);
|
||||
PyErr_SetString(PyExc_TypeError, "children must contain only Frame, Caption, Sprite, or Grid objects");
|
||||
|
|
@ -797,9 +805,8 @@ int UIFrame::init(PyUIFrameObject* self, PyObject* args, PyObject* kwds)
|
|||
drawable = ((PyUISpriteObject*)child)->data;
|
||||
} else if (PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUIGridViewType)) {
|
||||
drawable = ((PyUIGridViewObject*)child)->data;
|
||||
} else if (PyObject_IsInstance(child, (PyObject*)&mcrfpydef::PyUIGridType)) {
|
||||
drawable = ((PyUIGridObject*)child)->data;
|
||||
}
|
||||
// #361: a GridData is not a drawable -- it is rejected above.
|
||||
|
||||
if (drawable) {
|
||||
drawable->setParent(self->data); // Set parent before adding (enables alignment)
|
||||
|
|
|
|||
1573
src/UIGrid.cpp
1573
src/UIGrid.cpp
File diff suppressed because it is too large
Load diff
318
src/UIGrid.h
318
src/UIGrid.h
|
|
@ -1,318 +0,0 @@
|
|||
#pragma once
|
||||
#include "Common.h"
|
||||
#include "Python.h"
|
||||
#include "structmember.h"
|
||||
#include "IndexTexture.h"
|
||||
#include "Resources.h"
|
||||
#include <list>
|
||||
#include <libtcod.h>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
|
||||
#include "PyCallable.h"
|
||||
#include "PyTexture.h"
|
||||
#include "PyDrawable.h"
|
||||
#include "PyColor.h"
|
||||
#include "PyVector.h"
|
||||
#include "PyFont.h"
|
||||
|
||||
#include "UIGridPoint.h"
|
||||
#include "UIEntity.h"
|
||||
#include "UIDrawable.h"
|
||||
#include "UIBase.h"
|
||||
#include "GridLayers.h"
|
||||
#include "SpatialHash.h"
|
||||
#include "UIEntityCollection.h" // EntityCollection types (extracted from UIGrid)
|
||||
#include "GridData.h" // #252 - Data layer base class
|
||||
#include "UIGridView.h" // #252 - GridView shim
|
||||
|
||||
// Forward declaration for pathfinding
|
||||
class DijkstraMap;
|
||||
|
||||
// UIGrid inherits both UIDrawable (rendering) and GridData (state).
|
||||
// This allows GridData to be shared with GridView for multi-view support (#252).
|
||||
class UIGrid: public UIDrawable, public GridData
|
||||
{
|
||||
private:
|
||||
std::shared_ptr<PyTexture> ptex;
|
||||
// Default cell dimensions when no texture is provided
|
||||
static constexpr int DEFAULT_CELL_WIDTH = 16;
|
||||
static constexpr int DEFAULT_CELL_HEIGHT = 16;
|
||||
|
||||
public:
|
||||
UIGrid();
|
||||
UIGrid(int, int, std::shared_ptr<PyTexture>, sf::Vector2f, sf::Vector2f);
|
||||
~UIGrid();
|
||||
|
||||
// #313: GridData also declares markDirty/markCompositeDirty (data-layer
|
||||
// invalidation for entities holding shared_ptr<GridData>). Within UIGrid,
|
||||
// keep resolving unqualified calls to the UIDrawable flag-setters --
|
||||
// identical to pre-#313 behavior.
|
||||
using UIDrawable::markDirty;
|
||||
using UIDrawable::markCompositeDirty;
|
||||
void update();
|
||||
void render(sf::Vector2f, sf::RenderTarget&) override final;
|
||||
PyObjectsEnum derived_type() override final;
|
||||
virtual UIDrawable* click_at(sf::Vector2f point) override final;
|
||||
|
||||
// Phase 1 virtual method implementations
|
||||
sf::FloatRect get_bounds() const override;
|
||||
void move(float dx, float dy) override;
|
||||
void resize(float w, float h) override;
|
||||
void onPositionChanged() override;
|
||||
|
||||
// =========================================================================
|
||||
// Rendering-only members (NOT in GridData)
|
||||
// =========================================================================
|
||||
sf::RectangleShape box;
|
||||
float center_x, center_y, zoom;
|
||||
float camera_rotation = 0.0f;
|
||||
std::shared_ptr<PyTexture> getTexture();
|
||||
sf::Sprite sprite, output;
|
||||
sf::RenderTexture renderTexture;
|
||||
sf::Vector2u renderTextureSize{0, 0};
|
||||
void ensureRenderTextureSize();
|
||||
std::unique_ptr<sf::RenderTexture> rotationTexture; // #338 - lazy: only rotating grids allocate it
|
||||
unsigned int rotationTextureSize = 0;
|
||||
|
||||
// Background rendering
|
||||
sf::Color fill_color;
|
||||
|
||||
// Perspective system
|
||||
std::weak_ptr<UIEntity> perspective_entity;
|
||||
bool perspective_enabled;
|
||||
|
||||
// Cell callback firing (needs UIDrawable::is_python_subclass, serial_number)
|
||||
bool fireCellClick(sf::Vector2i cell, const std::string& button, const std::string& action);
|
||||
bool fireCellEnter(sf::Vector2i cell);
|
||||
bool fireCellExit(sf::Vector2i cell);
|
||||
void refreshCellCallbackCache(PyObject* pyObj);
|
||||
|
||||
// #142 - Cell coordinate conversion (needs texture for cell size)
|
||||
std::optional<sf::Vector2i> screenToCell(sf::Vector2f screen_pos) const;
|
||||
sf::Vector2f getEffectiveCellSize() const;
|
||||
void updateCellHover(sf::Vector2f mousepos, const std::string& button, const std::string& action);
|
||||
|
||||
// Property system for animations
|
||||
bool setProperty(const std::string& name, float value) override;
|
||||
bool setProperty(const std::string& name, const sf::Vector2f& value) override;
|
||||
bool getProperty(const std::string& name, float& value) const override;
|
||||
bool getProperty(const std::string& name, sf::Vector2f& value) const override;
|
||||
bool hasProperty(const std::string& name) const override;
|
||||
|
||||
// #169 - Camera positioning
|
||||
void center_camera();
|
||||
void center_camera(float tile_x, float tile_y);
|
||||
|
||||
// =========================================================================
|
||||
// Python API (static methods)
|
||||
// =========================================================================
|
||||
static int init(PyUIGridObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* get_grid_size(PyUIGridObject* self, void* closure);
|
||||
static PyObject* get_grid_w(PyUIGridObject* self, void* closure);
|
||||
static PyObject* get_grid_h(PyUIGridObject* self, void* closure);
|
||||
static PyObject* get_size(PyUIGridObject* self, void* closure);
|
||||
static int set_size(PyUIGridObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_center(PyUIGridObject* self, void* closure);
|
||||
static int set_center(PyUIGridObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_float_member(PyUIGridObject* self, void* closure);
|
||||
static int set_float_member(PyUIGridObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_texture(PyUIGridObject* self, void* closure);
|
||||
static PyObject* get_fill_color(PyUIGridObject* self, void* closure);
|
||||
static int set_fill_color(PyUIGridObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_perspective(PyUIGridObject* self, void* closure);
|
||||
static int set_perspective(PyUIGridObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_perspective_enabled(PyUIGridObject* self, void* closure);
|
||||
static int set_perspective_enabled(PyUIGridObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_fov(PyUIGridObject* self, void* closure);
|
||||
static int set_fov(PyUIGridObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_fov_radius(PyUIGridObject* self, void* closure);
|
||||
static int set_fov_radius(PyUIGridObject* self, PyObject* value, void* closure);
|
||||
static PyObject* py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* py_center_camera(PyUIGridObject* self, PyObject* args);
|
||||
static PyObject* get_camera_rotation(PyUIGridObject* self, void* closure);
|
||||
static int set_camera_rotation(PyUIGridObject* self, PyObject* value, void* closure);
|
||||
static PyObject* py_apply_threshold(PyUIGridObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* py_apply_ranges(PyUIGridObject* self, PyObject* args);
|
||||
static PyObject* py_step(PyUIGridObject* self, PyObject* args, PyObject* kwds);
|
||||
|
||||
static PyMethodDef methods[];
|
||||
static PyGetSetDef getsetters[];
|
||||
static PyMappingMethods mpmethods;
|
||||
static PyObject* subscript(PyUIGridObject* self, PyObject* key);
|
||||
static PyObject* get_entities(PyUIGridObject* self, void* closure);
|
||||
static PyObject* get_children(PyUIGridObject* self, void* closure);
|
||||
static PyObject* repr(PyUIGridObject* self);
|
||||
|
||||
static PyObject* get_on_cell_enter(PyUIGridObject* self, void* closure);
|
||||
static int set_on_cell_enter(PyUIGridObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_on_cell_exit(PyUIGridObject* self, void* closure);
|
||||
static int set_on_cell_exit(PyUIGridObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_on_cell_click(PyUIGridObject* self, void* closure);
|
||||
static int set_on_cell_click(PyUIGridObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_hovered_cell(PyUIGridObject* self, void* closure);
|
||||
|
||||
static PyObject* get_view(PyUIGridObject* self, void* closure); // #252 shim
|
||||
static PyObject* py_add_layer(PyUIGridObject* self, PyObject* args);
|
||||
static PyObject* py_remove_layer(PyUIGridObject* self, PyObject* args);
|
||||
static PyObject* get_layers(PyUIGridObject* self, void* closure);
|
||||
static PyObject* py_layer(PyUIGridObject* self, PyObject* args);
|
||||
};
|
||||
|
||||
// UIEntityCollection types are now in UIEntityCollection.h
|
||||
|
||||
// Forward declaration of methods array
|
||||
extern PyMethodDef UIGrid_all_methods[];
|
||||
|
||||
namespace mcrfpydef {
|
||||
inline PyTypeObject PyUIGridType = {
|
||||
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||
.tp_name = "mcrfpy._GridData", // #252: internal type, "Grid" is now GridView
|
||||
.tp_basicsize = sizeof(PyUIGridObject),
|
||||
.tp_itemsize = 0,
|
||||
.tp_dealloc = (destructor)[](PyObject* self)
|
||||
{
|
||||
PyUIGridObject* obj = (PyUIGridObject*)self;
|
||||
// Untrack from GC before destroying
|
||||
PyObject_GC_UnTrack(self);
|
||||
// Clear weak references
|
||||
if (obj->weakreflist != NULL) {
|
||||
PyObject_ClearWeakRefs(self);
|
||||
}
|
||||
// Only unregister callbacks if we're the last owner (#251)
|
||||
if (obj->data && obj->data.use_count() <= 1) {
|
||||
obj->data->click_unregister();
|
||||
obj->data->on_enter_unregister();
|
||||
obj->data->on_exit_unregister();
|
||||
obj->data->on_move_unregister();
|
||||
// Grid-specific cell callbacks (now on GridData base)
|
||||
obj->data->on_cell_enter_callable.reset();
|
||||
obj->data->on_cell_exit_callable.reset();
|
||||
obj->data->on_cell_click_callable.reset();
|
||||
}
|
||||
obj->view.reset(); // #252: release GridView shim
|
||||
obj->data.reset();
|
||||
Py_TYPE(self)->tp_free(self);
|
||||
},
|
||||
.tp_repr = (reprfunc)UIGrid::repr,
|
||||
.tp_as_mapping = &UIGrid::mpmethods,
|
||||
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC,
|
||||
.tp_doc = PyDoc_STR("Grid(pos=None, size=None, grid_size=None, texture=None, **kwargs)\n\n"
|
||||
"A grid-based UI element for tile-based rendering and entity management.\n\n"
|
||||
"Args:\n"
|
||||
" pos (tuple, optional): Position as (x, y) tuple. Default: (0, 0)\n"
|
||||
" size (tuple, optional): Size as (width, height) tuple. Default: auto-calculated from grid_size\n"
|
||||
" grid_size (tuple, optional): Grid dimensions as (grid_w, grid_h) tuple. Default: (2, 2)\n"
|
||||
" texture (Texture, optional): Texture containing tile sprites. Default: default texture\n\n"
|
||||
"Keyword Args:\n"
|
||||
" fill_color (Color): Background fill color. Default: None\n"
|
||||
" click (callable): Click event handler. Default: None\n"
|
||||
" center_x (float): X coordinate of center point. Default: 0\n"
|
||||
" center_y (float): Y coordinate of center point. Default: 0\n"
|
||||
" zoom (float): Zoom level for rendering. Default: 1.0\n"
|
||||
" perspective (int): Entity perspective index (-1 for omniscient). Default: -1\n"
|
||||
" visible (bool): Visibility state. Default: True\n"
|
||||
" opacity (float): Opacity (0.0-1.0). Default: 1.0\n"
|
||||
" z_index (int): Rendering order. Default: 0\n"
|
||||
" name (str): Element name for finding. Default: None\n"
|
||||
" x (float): X position override. Default: 0\n"
|
||||
" y (float): Y position override. Default: 0\n"
|
||||
" w (float): Width override. Default: auto-calculated\n"
|
||||
" h (float): Height override. Default: auto-calculated\n"
|
||||
" grid_w (int): Grid width override. Default: 2\n"
|
||||
" grid_h (int): Grid height override. Default: 2\n"
|
||||
" align (Alignment): Alignment relative to parent. Default: None\n"
|
||||
" margin (float): Margin from parent edge when aligned. Default: 0\n"
|
||||
" horiz_margin (float): Horizontal margin override. Default: 0 (use margin)\n"
|
||||
" vert_margin (float): Vertical margin override. Default: 0 (use margin)\n\n"
|
||||
"Attributes:\n"
|
||||
" x, y (float): Position in pixels\n"
|
||||
" w, h (float): Size in pixels\n"
|
||||
" pos (Vector): Position as a Vector object\n"
|
||||
" size (Vector): Size as (width, height) Vector\n"
|
||||
" center (Vector): Center point as (x, y) Vector\n"
|
||||
" center_x, center_y (float): Center point coordinates\n"
|
||||
" zoom (float): Zoom level for rendering\n"
|
||||
" grid_size (Vector): Grid dimensions (width, height) in tiles\n"
|
||||
" grid_w, grid_h (int): Grid dimensions\n"
|
||||
" texture (Texture): Tile texture atlas\n"
|
||||
" fill_color (Color): Background color\n"
|
||||
" entities (EntityCollection): Collection of entities in the grid\n"
|
||||
" perspective (int): Entity perspective index\n"
|
||||
" click (callable): Click event handler\n"
|
||||
" visible (bool): Visibility state\n"
|
||||
" opacity (float): Opacity value\n"
|
||||
" z_index (int): Rendering order\n"
|
||||
" name (str): Element name\n"
|
||||
" align (Alignment): Alignment relative to parent (or None)\n"
|
||||
" margin (float): General margin for alignment\n"
|
||||
" horiz_margin (float): Horizontal margin override\n"
|
||||
" vert_margin (float): Vertical margin override"),
|
||||
.tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int {
|
||||
PyUIGridObject* obj = (PyUIGridObject*)self;
|
||||
if (obj->data) {
|
||||
if (obj->data->click_callable) {
|
||||
PyObject* callback = obj->data->click_callable->borrow();
|
||||
if (callback && callback != Py_None) Py_VISIT(callback);
|
||||
}
|
||||
if (obj->data->on_enter_callable) {
|
||||
PyObject* callback = obj->data->on_enter_callable->borrow();
|
||||
if (callback && callback != Py_None) Py_VISIT(callback);
|
||||
}
|
||||
if (obj->data->on_exit_callable) {
|
||||
PyObject* callback = obj->data->on_exit_callable->borrow();
|
||||
if (callback && callback != Py_None) Py_VISIT(callback);
|
||||
}
|
||||
if (obj->data->on_move_callable) {
|
||||
PyObject* callback = obj->data->on_move_callable->borrow();
|
||||
if (callback && callback != Py_None) Py_VISIT(callback);
|
||||
}
|
||||
if (obj->data->on_cell_enter_callable) {
|
||||
PyObject* callback = obj->data->on_cell_enter_callable->borrow();
|
||||
if (callback && callback != Py_None) Py_VISIT(callback);
|
||||
}
|
||||
if (obj->data->on_cell_exit_callable) {
|
||||
PyObject* callback = obj->data->on_cell_exit_callable->borrow();
|
||||
if (callback && callback != Py_None) Py_VISIT(callback);
|
||||
}
|
||||
if (obj->data->on_cell_click_callable) {
|
||||
PyObject* callback = obj->data->on_cell_click_callable->borrow();
|
||||
if (callback && callback != Py_None) Py_VISIT(callback);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
.tp_clear = [](PyObject* self) -> int {
|
||||
PyUIGridObject* obj = (PyUIGridObject*)self;
|
||||
if (obj->data) {
|
||||
obj->data->click_unregister();
|
||||
obj->data->on_enter_unregister();
|
||||
obj->data->on_exit_unregister();
|
||||
obj->data->on_move_unregister();
|
||||
obj->data->on_cell_enter_callable.reset();
|
||||
obj->data->on_cell_exit_callable.reset();
|
||||
obj->data->on_cell_click_callable.reset();
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
.tp_methods = UIGrid_all_methods,
|
||||
.tp_getset = UIGrid::getsetters,
|
||||
.tp_base = &mcrfpydef::PyDrawableType,
|
||||
.tp_init = (initproc)UIGrid::init,
|
||||
.tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject*
|
||||
{
|
||||
PyUIGridObject* self = (PyUIGridObject*)type->tp_alloc(type, 0);
|
||||
if (self) {
|
||||
self->data = std::make_shared<UIGrid>();
|
||||
// Placement-new the shared_ptr<UIGridView> (tp_alloc zero-fills, not construct)
|
||||
new (&self->view) std::shared_ptr<UIGridView>();
|
||||
}
|
||||
return (PyObject*)self;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
#include "UIGridPathfinding.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UIEntity.h"
|
||||
#include "PyVector.h"
|
||||
#include "McRFPy_API.h"
|
||||
|
|
@ -130,7 +130,7 @@ sf::Vector2i DijkstraMap::descentStep(int x, int y, bool* valid) const {
|
|||
//=============================================================================
|
||||
|
||||
bool UIGridPathfinding::ExtractPosition(PyObject* obj, int* x, int* y,
|
||||
UIGrid* expected_grid,
|
||||
GridData* expected_grid,
|
||||
const char* arg_name) {
|
||||
// Check if it's an Entity
|
||||
if (PyObject_IsInstance(obj, (PyObject*)&mcrfpydef::PyUIEntityType)) {
|
||||
|
|
@ -610,7 +610,7 @@ static void restoreCollisionLabel(GridData* grid,
|
|||
}
|
||||
}
|
||||
|
||||
PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
|
||||
PyObject* UIGridPathfinding::Grid_find_path(PyGridDataObject* self, PyObject* args, PyObject* kwds) {
|
||||
static const char* kwlist[] = {"start", "end", "diagonal_cost", "collide",
|
||||
"heuristic", "weight", NULL};
|
||||
PyObject* start_obj = NULL;
|
||||
|
|
@ -715,7 +715,7 @@ PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args
|
|||
// - a DiscreteMap mask (non-zero cells become roots)
|
||||
// Returns true on success; populates `out_roots` and `out_mask_used`.
|
||||
// If a DiscreteMap mask is used, caller should prefer the masked C path.
|
||||
static bool collectRoots(PyObject* root_obj, UIGrid* grid,
|
||||
static bool collectRoots(PyObject* root_obj, GridData* grid,
|
||||
std::vector<sf::Vector2i>* out_roots,
|
||||
PyDiscreteMapObject** out_mask)
|
||||
{
|
||||
|
|
@ -795,7 +795,7 @@ static bool collectRoots(PyObject* root_obj, UIGrid* grid,
|
|||
return false;
|
||||
}
|
||||
|
||||
PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
|
||||
PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyGridDataObject* self, PyObject* args, PyObject* kwds) {
|
||||
static const char* kwlist[] = {"root", "diagonal_cost", "collide", "roots", NULL};
|
||||
PyObject* root_obj = NULL;
|
||||
PyObject* roots_obj = NULL;
|
||||
|
|
@ -896,7 +896,7 @@ PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObjec
|
|||
return (PyObject*)result;
|
||||
}
|
||||
|
||||
PyObject* UIGridPathfinding::Grid_clear_dijkstra_maps(PyUIGridObject* self, PyObject* args) {
|
||||
PyObject* UIGridPathfinding::Grid_clear_dijkstra_maps(PyGridDataObject* self, PyObject* args) {
|
||||
if (!self->data) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "Grid is invalid");
|
||||
return NULL;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
#pragma once
|
||||
#include "Common.h"
|
||||
#include "Python.h"
|
||||
#include "UIBase.h" // For PyUIGridObject typedef
|
||||
#include "UIBase.h" // For PyGridDataObject typedef
|
||||
#include <libtcod.h>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <map>
|
||||
|
||||
// Forward declarations
|
||||
class UIGrid;
|
||||
class GridData;
|
||||
|
||||
//=============================================================================
|
||||
// AStarPath - A computed A* path result, consumed like an iterator
|
||||
|
|
@ -91,7 +91,7 @@ namespace UIGridPathfinding {
|
|||
// Sets Python error and returns false on failure
|
||||
// If expected_grid is provided and obj is Entity, validates grid membership
|
||||
bool ExtractPosition(PyObject* obj, int* x, int* y,
|
||||
UIGrid* expected_grid = nullptr,
|
||||
GridData* expected_grid = nullptr,
|
||||
const char* arg_name = "position");
|
||||
|
||||
//=========================================================================
|
||||
|
|
@ -143,13 +143,13 @@ namespace UIGridPathfinding {
|
|||
//=========================================================================
|
||||
|
||||
// Grid.find_path() -> AStarPath | None
|
||||
PyObject* Grid_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds);
|
||||
PyObject* Grid_find_path(PyGridDataObject* self, PyObject* args, PyObject* kwds);
|
||||
|
||||
// Grid.get_dijkstra_map() -> DijkstraMap
|
||||
PyObject* Grid_get_dijkstra_map(PyUIGridObject* self, PyObject* args, PyObject* kwds);
|
||||
PyObject* Grid_get_dijkstra_map(PyGridDataObject* self, PyObject* args, PyObject* kwds);
|
||||
|
||||
// Grid.clear_dijkstra_maps() -> None
|
||||
PyObject* Grid_clear_dijkstra_maps(PyUIGridObject* self, PyObject* args);
|
||||
PyObject* Grid_clear_dijkstra_maps(PyGridDataObject* self, PyObject* args);
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#include "UIGridPoint.h"
|
||||
#include "UIGrid.h"
|
||||
#include "PyGridData.h"
|
||||
#include "UIEntity.h" // #114 - for GridPoint.entities
|
||||
#include "GridLayers.h" // #150 - for GridLayerType, ColorLayer, TileLayer
|
||||
#include "McRFPy_Doc.h" // #177 - for MCRF_PROPERTY macro
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class UIGridPoint;
|
|||
|
||||
typedef struct {
|
||||
PyObject_HEAD
|
||||
std::shared_ptr<UIGrid> grid;
|
||||
std::shared_ptr<GridData> grid;
|
||||
int x, y; // Grid coordinates - compute data pointer on access
|
||||
} PyUIGridPointObject;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,611 +0,0 @@
|
|||
// UIGridPyProperties.cpp — Python getter/setter implementations for UIGrid
|
||||
// Extracted from UIGrid.cpp (#149) for maintainability.
|
||||
// Contains: all PyGetSetDef getters/setters and the getsetters[] array.
|
||||
#include "UIGrid.h"
|
||||
#include "UIGridView.h"
|
||||
#include "McRFPy_API.h"
|
||||
#include "PythonObjectCache.h"
|
||||
#include "PyColor.h"
|
||||
#include "PyVector.h"
|
||||
#include "PyFOV.h"
|
||||
#include "UIBase.h"
|
||||
#include "UICollection.h"
|
||||
#include "McRFPy_Doc.h"
|
||||
|
||||
// =========================================================================
|
||||
// Grid dimension properties
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::get_grid_size(PyUIGridObject* self, void* closure) {
|
||||
return PyVector(sf::Vector2f(static_cast<float>(self->data->grid_w),
|
||||
static_cast<float>(self->data->grid_h))).pyObject();
|
||||
}
|
||||
|
||||
PyObject* UIGrid::get_grid_w(PyUIGridObject* self, void* closure) {
|
||||
return PyLong_FromLong(self->data->grid_w);
|
||||
}
|
||||
|
||||
PyObject* UIGrid::get_grid_h(PyUIGridObject* self, void* closure) {
|
||||
return PyLong_FromLong(self->data->grid_h);
|
||||
}
|
||||
|
||||
PyObject* UIGrid::get_size(PyUIGridObject* self, void* closure) {
|
||||
auto& box = self->data->box;
|
||||
return PyVector(box.getSize()).pyObject();
|
||||
}
|
||||
|
||||
int UIGrid::set_size(PyUIGridObject* self, PyObject* value, void* closure) {
|
||||
float w, h;
|
||||
PyVectorObject* vec = PyVector::from_arg(value);
|
||||
if (vec) {
|
||||
w = vec->data.x;
|
||||
h = vec->data.y;
|
||||
Py_DECREF(vec);
|
||||
} else {
|
||||
PyErr_Clear();
|
||||
if (!PyArg_ParseTuple(value, "ff", &w, &h)) {
|
||||
PyErr_SetString(PyExc_TypeError, "size must be a Vector or tuple (w, h)");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
self->data->box.setSize(sf::Vector2f(w, h));
|
||||
|
||||
unsigned int tex_width = static_cast<unsigned int>(w * 1.5f);
|
||||
unsigned int tex_height = static_cast<unsigned int>(h * 1.5f);
|
||||
|
||||
tex_width = std::min(tex_width, 4096u);
|
||||
tex_height = std::min(tex_height, 4096u);
|
||||
|
||||
self->data->renderTexture.create(tex_width, tex_height);
|
||||
self->data->markDirty();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Camera/view properties
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::get_center(PyUIGridObject* self, void* closure) {
|
||||
return PyVector(sf::Vector2f(self->data->center_x, self->data->center_y)).pyObject();
|
||||
}
|
||||
|
||||
int UIGrid::set_center(PyUIGridObject* self, PyObject* value, void* closure) {
|
||||
float x, y;
|
||||
if (!PyArg_ParseTuple(value, "ff", &x, &y)) {
|
||||
PyErr_SetString(PyExc_ValueError, "Size must be a tuple of two floats");
|
||||
return -1;
|
||||
}
|
||||
self->data->center_x = x;
|
||||
self->data->center_y = y;
|
||||
self->data->markDirty();
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::get_float_member(PyUIGridObject* self, void* closure)
|
||||
{
|
||||
auto member_ptr = reinterpret_cast<intptr_t>(closure);
|
||||
if (member_ptr == 0)
|
||||
return PyFloat_FromDouble(self->data->box.getPosition().x);
|
||||
else if (member_ptr == 1)
|
||||
return PyFloat_FromDouble(self->data->box.getPosition().y);
|
||||
else if (member_ptr == 2)
|
||||
return PyFloat_FromDouble(self->data->box.getSize().x);
|
||||
else if (member_ptr == 3)
|
||||
return PyFloat_FromDouble(self->data->box.getSize().y);
|
||||
else if (member_ptr == 4)
|
||||
return PyFloat_FromDouble(self->data->center_x);
|
||||
else if (member_ptr == 5)
|
||||
return PyFloat_FromDouble(self->data->center_y);
|
||||
else if (member_ptr == 6)
|
||||
return PyFloat_FromDouble(self->data->zoom);
|
||||
else if (member_ptr == 7)
|
||||
return PyFloat_FromDouble(self->data->camera_rotation);
|
||||
else
|
||||
{
|
||||
PyErr_SetString(PyExc_AttributeError, "Invalid attribute");
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
int UIGrid::set_float_member(PyUIGridObject* self, PyObject* value, void* closure)
|
||||
{
|
||||
float val;
|
||||
auto member_ptr = reinterpret_cast<intptr_t>(closure);
|
||||
if (PyFloat_Check(value))
|
||||
{
|
||||
val = PyFloat_AsDouble(value);
|
||||
}
|
||||
else if (PyLong_Check(value))
|
||||
{
|
||||
val = PyLong_AsLong(value);
|
||||
}
|
||||
else
|
||||
{
|
||||
PyErr_SetString(PyExc_TypeError, "Value must be a number (int or float)");
|
||||
return -1;
|
||||
}
|
||||
if (member_ptr == 0)
|
||||
self->data->box.setPosition(val, self->data->box.getPosition().y);
|
||||
else if (member_ptr == 1)
|
||||
self->data->box.setPosition(self->data->box.getPosition().x, val);
|
||||
else if (member_ptr == 2)
|
||||
{
|
||||
self->data->box.setSize(sf::Vector2f(val, self->data->box.getSize().y));
|
||||
unsigned int tex_width = static_cast<unsigned int>(val * 1.5f);
|
||||
unsigned int tex_height = static_cast<unsigned int>(self->data->box.getSize().y * 1.5f);
|
||||
tex_width = std::min(tex_width, 4096u);
|
||||
tex_height = std::min(tex_height, 4096u);
|
||||
self->data->renderTexture.create(tex_width, tex_height);
|
||||
}
|
||||
else if (member_ptr == 3)
|
||||
{
|
||||
self->data->box.setSize(sf::Vector2f(self->data->box.getSize().x, val));
|
||||
unsigned int tex_width = static_cast<unsigned int>(self->data->box.getSize().x * 1.5f);
|
||||
unsigned int tex_height = static_cast<unsigned int>(val * 1.5f);
|
||||
tex_width = std::min(tex_width, 4096u);
|
||||
tex_height = std::min(tex_height, 4096u);
|
||||
self->data->renderTexture.create(tex_width, tex_height);
|
||||
}
|
||||
else if (member_ptr == 4)
|
||||
self->data->center_x = val;
|
||||
else if (member_ptr == 5)
|
||||
self->data->center_y = val;
|
||||
else if (member_ptr == 6)
|
||||
self->data->zoom = val;
|
||||
else if (member_ptr == 7)
|
||||
self->data->camera_rotation = val;
|
||||
|
||||
if (self->view) {
|
||||
if (member_ptr == 0)
|
||||
self->view->box.setPosition(val, self->view->box.getPosition().y);
|
||||
else if (member_ptr == 1)
|
||||
self->view->box.setPosition(self->view->box.getPosition().x, val);
|
||||
else if (member_ptr == 2)
|
||||
self->view->box.setSize(sf::Vector2f(val, self->view->box.getSize().y));
|
||||
else if (member_ptr == 3)
|
||||
self->view->box.setSize(sf::Vector2f(self->view->box.getSize().x, val));
|
||||
else if (member_ptr == 4) self->view->center_x = val;
|
||||
else if (member_ptr == 5) self->view->center_y = val;
|
||||
else if (member_ptr == 6) self->view->zoom = val;
|
||||
else if (member_ptr == 7) self->view->camera_rotation = val;
|
||||
self->view->position = self->view->box.getPosition();
|
||||
}
|
||||
|
||||
if (member_ptr == 0 || member_ptr == 1) {
|
||||
self->data->markCompositeDirty();
|
||||
} else {
|
||||
self->data->markDirty();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Texture property
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::get_texture(PyUIGridObject* self, void* closure) {
|
||||
auto texture = self->data->getTexture();
|
||||
if (!texture) {
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
auto type = &mcrfpydef::PyTextureType;
|
||||
auto obj = (PyTextureObject*)type->tp_alloc(type, 0);
|
||||
obj->data = texture;
|
||||
return (PyObject*)obj;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Fill color
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::get_fill_color(PyUIGridObject* self, void* closure)
|
||||
{
|
||||
auto& color = self->data->fill_color;
|
||||
auto type = &mcrfpydef::PyColorType;
|
||||
PyObject* args = Py_BuildValue("(iiii)", color.r, color.g, color.b, color.a);
|
||||
PyObject* obj = PyObject_CallObject((PyObject*)type, args);
|
||||
Py_DECREF(args);
|
||||
return obj;
|
||||
}
|
||||
|
||||
int UIGrid::set_fill_color(PyUIGridObject* self, PyObject* value, void* closure)
|
||||
{
|
||||
if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyColorType)) {
|
||||
PyErr_SetString(PyExc_TypeError, "fill_color must be a Color object");
|
||||
return -1;
|
||||
}
|
||||
|
||||
PyColorObject* color = (PyColorObject*)value;
|
||||
self->data->fill_color = color->data;
|
||||
self->data->markDirty();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Perspective properties
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::get_perspective(PyUIGridObject* self, void* closure)
|
||||
{
|
||||
auto locked = self->data->perspective_entity.lock();
|
||||
if (locked) {
|
||||
if (locked->serial_number != 0) {
|
||||
PyObject* cached = PythonObjectCache::getInstance().lookup(locked->serial_number);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
auto type = &mcrfpydef::PyUIEntityType;
|
||||
auto o = (PyUIEntityObject*)type->tp_alloc(type, 0);
|
||||
if (o) {
|
||||
o->data = locked;
|
||||
o->weakreflist = NULL;
|
||||
return (PyObject*)o;
|
||||
}
|
||||
}
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
int UIGrid::set_perspective(PyUIGridObject* self, PyObject* value, void* closure)
|
||||
{
|
||||
if (value == Py_None) {
|
||||
self->data->perspective_entity.reset();
|
||||
self->data->markDirty();
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIEntityType)) {
|
||||
PyErr_SetString(PyExc_TypeError, "perspective must be a UIEntity or None");
|
||||
return -1;
|
||||
}
|
||||
|
||||
PyUIEntityObject* entity_obj = (PyUIEntityObject*)value;
|
||||
self->data->perspective_entity = entity_obj->data;
|
||||
self->data->perspective_enabled = true;
|
||||
self->data->markDirty();
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::get_perspective_enabled(PyUIGridObject* self, void* closure)
|
||||
{
|
||||
return PyBool_FromLong(self->data->perspective_enabled);
|
||||
}
|
||||
|
||||
int UIGrid::set_perspective_enabled(PyUIGridObject* self, PyObject* value, void* closure)
|
||||
{
|
||||
int enabled = PyObject_IsTrue(value);
|
||||
if (enabled == -1) {
|
||||
return -1;
|
||||
}
|
||||
self->data->perspective_enabled = enabled;
|
||||
self->data->markDirty();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FOV properties
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::get_fov(PyUIGridObject* self, void* closure)
|
||||
{
|
||||
if (PyFOV::fov_enum_class) {
|
||||
PyObject* value = PyLong_FromLong(self->data->fov_algorithm);
|
||||
if (!value) return NULL;
|
||||
|
||||
PyObject* args = PyTuple_Pack(1, value);
|
||||
Py_DECREF(value);
|
||||
if (!args) return NULL;
|
||||
|
||||
PyObject* result = PyObject_Call(PyFOV::fov_enum_class, args, NULL);
|
||||
Py_DECREF(args);
|
||||
return result;
|
||||
}
|
||||
return PyLong_FromLong(self->data->fov_algorithm);
|
||||
}
|
||||
|
||||
int UIGrid::set_fov(PyUIGridObject* self, PyObject* value, void* closure)
|
||||
{
|
||||
TCOD_fov_algorithm_t algo;
|
||||
if (!PyFOV::from_arg(value, &algo, nullptr)) {
|
||||
return -1;
|
||||
}
|
||||
self->data->fov_algorithm = algo;
|
||||
self->data->markDirty();
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::get_fov_radius(PyUIGridObject* self, void* closure)
|
||||
{
|
||||
return PyLong_FromLong(self->data->fov_radius);
|
||||
}
|
||||
|
||||
int UIGrid::set_fov_radius(PyUIGridObject* self, PyObject* value, void* closure)
|
||||
{
|
||||
if (!PyLong_Check(value)) {
|
||||
PyErr_SetString(PyExc_TypeError, "fov_radius must be an integer");
|
||||
return -1;
|
||||
}
|
||||
long radius = PyLong_AsLong(value);
|
||||
if (radius == -1 && PyErr_Occurred()) {
|
||||
return -1;
|
||||
}
|
||||
if (radius < 0) {
|
||||
PyErr_SetString(PyExc_ValueError, "fov_radius must be non-negative");
|
||||
return -1;
|
||||
}
|
||||
self->data->fov_radius = (int)radius;
|
||||
self->data->markDirty();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Collection getters
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::get_entities(PyUIGridObject* self, void* closure)
|
||||
{
|
||||
PyTypeObject* type = &mcrfpydef::PyUIEntityCollectionType;
|
||||
auto o = (PyUIEntityCollectionObject*)type->tp_alloc(type, 0);
|
||||
if (o) {
|
||||
o->data = self->data->entities;
|
||||
o->grid = self->data;
|
||||
}
|
||||
return (PyObject*)o;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::get_children(PyUIGridObject* self, void* closure)
|
||||
{
|
||||
PyTypeObject* type = &mcrfpydef::PyUICollectionType;
|
||||
auto o = (PyUICollectionObject*)type->tp_alloc(type, 0);
|
||||
if (o) {
|
||||
o->data = self->data->children;
|
||||
o->owner = self->data;
|
||||
}
|
||||
return (PyObject*)o;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::get_view(PyUIGridObject* self, void* closure)
|
||||
{
|
||||
if (!self->view) Py_RETURN_NONE;
|
||||
auto type = &mcrfpydef::PyUIGridViewType;
|
||||
auto obj = (PyUIGridViewObject*)type->tp_alloc(type, 0);
|
||||
if (!obj) return PyErr_NoMemory();
|
||||
obj->data = self->view;
|
||||
obj->weakreflist = NULL;
|
||||
return (PyObject*)obj;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::get_layers(PyUIGridObject* self, void* closure) {
|
||||
self->data->sortLayers();
|
||||
|
||||
PyObject* tuple = PyTuple_New(self->data->layers.size());
|
||||
if (!tuple) return NULL;
|
||||
|
||||
auto* color_layer_type = &mcrfpydef::PyColorLayerType;
|
||||
auto* tile_layer_type = &mcrfpydef::PyTileLayerType;
|
||||
|
||||
for (size_t i = 0; i < self->data->layers.size(); ++i) {
|
||||
auto& layer = self->data->layers[i];
|
||||
PyObject* py_layer = nullptr;
|
||||
|
||||
if (layer->type == GridLayerType::Color) {
|
||||
PyColorLayerObject* obj = (PyColorLayerObject*)color_layer_type->tp_alloc(color_layer_type, 0);
|
||||
if (obj) {
|
||||
obj->data = std::static_pointer_cast<ColorLayer>(layer);
|
||||
obj->grid = self->data;
|
||||
py_layer = (PyObject*)obj;
|
||||
}
|
||||
} else {
|
||||
PyTileLayerObject* obj = (PyTileLayerObject*)tile_layer_type->tp_alloc(tile_layer_type, 0);
|
||||
if (obj) {
|
||||
obj->data = std::static_pointer_cast<TileLayer>(layer);
|
||||
obj->grid = self->data;
|
||||
py_layer = (PyObject*)obj;
|
||||
}
|
||||
}
|
||||
|
||||
if (!py_layer) {
|
||||
Py_DECREF(tuple);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
PyTuple_SET_ITEM(tuple, i, py_layer);
|
||||
}
|
||||
|
||||
return tuple;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// repr
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::repr(PyUIGridObject* self)
|
||||
{
|
||||
std::ostringstream ss;
|
||||
if (!self->data) ss << "<Grid (invalid internal object)>";
|
||||
else {
|
||||
auto grid = self->data;
|
||||
auto box = grid->box;
|
||||
ss << "<Grid (x=" << box.getPosition().x << ", y=" << box.getPosition().y << ", w=" << box.getSize().x << ", h=" << box.getSize().y << ", " <<
|
||||
"center=(" << grid->center_x << ", " << grid->center_y << "), zoom=" << grid->zoom <<
|
||||
")>";
|
||||
}
|
||||
std::string repr_str = ss.str();
|
||||
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Cell callback properties
|
||||
// =========================================================================
|
||||
|
||||
PyObject* UIGrid::get_on_cell_enter(PyUIGridObject* self, void* closure) {
|
||||
if (self->data->on_cell_enter_callable) {
|
||||
PyObject* cb = self->data->on_cell_enter_callable->borrow();
|
||||
Py_INCREF(cb);
|
||||
return cb;
|
||||
}
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
int UIGrid::set_on_cell_enter(PyUIGridObject* self, PyObject* value, void* closure) {
|
||||
if (value == Py_None) {
|
||||
self->data->on_cell_enter_callable.reset();
|
||||
} else {
|
||||
self->data->on_cell_enter_callable = std::make_unique<PyCellHoverCallable>(value);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::get_on_cell_exit(PyUIGridObject* self, void* closure) {
|
||||
if (self->data->on_cell_exit_callable) {
|
||||
PyObject* cb = self->data->on_cell_exit_callable->borrow();
|
||||
Py_INCREF(cb);
|
||||
return cb;
|
||||
}
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
int UIGrid::set_on_cell_exit(PyUIGridObject* self, PyObject* value, void* closure) {
|
||||
if (value == Py_None) {
|
||||
self->data->on_cell_exit_callable.reset();
|
||||
} else {
|
||||
self->data->on_cell_exit_callable = std::make_unique<PyCellHoverCallable>(value);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::get_on_cell_click(PyUIGridObject* self, void* closure) {
|
||||
if (self->data->on_cell_click_callable) {
|
||||
PyObject* cb = self->data->on_cell_click_callable->borrow();
|
||||
Py_INCREF(cb);
|
||||
return cb;
|
||||
}
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
int UIGrid::set_on_cell_click(PyUIGridObject* self, PyObject* value, void* closure) {
|
||||
if (value == Py_None) {
|
||||
self->data->on_cell_click_callable.reset();
|
||||
} else {
|
||||
self->data->on_cell_click_callable = std::make_unique<PyClickCallable>(value);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* UIGrid::get_hovered_cell(PyUIGridObject* self, void* closure) {
|
||||
if (self->data->hovered_cell.has_value()) {
|
||||
return Py_BuildValue("(ii)", self->data->hovered_cell->x, self->data->hovered_cell->y);
|
||||
}
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// getsetters[] table
|
||||
// =========================================================================
|
||||
|
||||
typedef PyUIGridObject PyObjectType;
|
||||
|
||||
PyGetSetDef UIGrid::getsetters[] = {
|
||||
|
||||
{"grid_size", (getter)UIGrid::get_grid_size, NULL,
|
||||
MCRF_PROPERTY(grid_size, "Grid dimensions as (grid_w, grid_h) (Vector, read-only)."), NULL},
|
||||
{"grid_w", (getter)UIGrid::get_grid_w, NULL,
|
||||
MCRF_PROPERTY(grid_w, "Grid width in cells (int, read-only)."), NULL},
|
||||
{"grid_h", (getter)UIGrid::get_grid_h, NULL,
|
||||
MCRF_PROPERTY(grid_h, "Grid height in cells (int, read-only)."), NULL},
|
||||
{"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos,
|
||||
MCRF_PROPERTY(pos, "Position of the grid as Vector (Vector)."), (void*)PyObjectsEnum::UIGRID},
|
||||
{"grid_pos", (getter)UIDrawable::get_grid_pos, (setter)UIDrawable::set_grid_pos,
|
||||
MCRF_PROPERTY(grid_pos, "Position in parent grid's tile coordinates (Vector). Only valid when parent is a Grid."), (void*)PyObjectsEnum::UIGRID},
|
||||
{"size", (getter)UIGrid::get_size, (setter)UIGrid::set_size,
|
||||
MCRF_PROPERTY(size, "Size of the grid widget as Vector (Vector, width x height in pixels)."), NULL},
|
||||
{"center", (getter)UIGrid::get_center, (setter)UIGrid::set_center,
|
||||
MCRF_PROPERTY(center, "Camera center point in pixel coordinates (Vector). Controls which part of the grid world is visible (pan)."), NULL},
|
||||
|
||||
{"entities", (getter)UIGrid::get_entities, NULL,
|
||||
MCRF_PROPERTY(entities, "EntityCollection of entities on this grid (EntityCollection, read-only)."), NULL},
|
||||
{"children", (getter)UIGrid::get_children, NULL,
|
||||
MCRF_PROPERTY(children, "UICollection of UIDrawable children such as speech bubbles, effects, and overlays (UICollection, read-only)."), NULL},
|
||||
{"layers", (getter)UIGrid::get_layers, NULL,
|
||||
MCRF_PROPERTY(layers, "Tuple of grid layers sorted by z_index (tuple, read-only). Contains ColorLayer and TileLayer objects."), NULL},
|
||||
|
||||
{"x", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member,
|
||||
MCRF_PROPERTY(x, "Top-left corner X-coordinate in pixels (float)."), (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 0)},
|
||||
{"y", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member,
|
||||
MCRF_PROPERTY(y, "Top-left corner Y-coordinate in pixels (float)."), (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 1)},
|
||||
{"w", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member,
|
||||
MCRF_PROPERTY(w, "Visible widget width in pixels (float)."), (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 2)},
|
||||
{"h", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member,
|
||||
MCRF_PROPERTY(h, "Visible widget height in pixels (float)."), (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 3)},
|
||||
{"center_x", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member,
|
||||
MCRF_PROPERTY(center_x, "Camera center X-coordinate in pixels (float)."), (void*)4},
|
||||
{"center_y", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member,
|
||||
MCRF_PROPERTY(center_y, "Camera center Y-coordinate in pixels (float)."), (void*)5},
|
||||
{"zoom", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member,
|
||||
MCRF_PROPERTY(zoom, "Zoom factor for rendering the grid (float). Values > 1 zoom in; values < 1 zoom out."), (void*)6},
|
||||
{"camera_rotation", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member,
|
||||
MCRF_PROPERTY(camera_rotation, "Rotation of grid contents around camera center in degrees (float). The grid widget stays axis-aligned; only the view into the world rotates."), (void*)7},
|
||||
|
||||
{"on_click", (getter)UIDrawable::get_click, (setter)UIDrawable::set_click,
|
||||
MCRF_PROPERTY(on_click,
|
||||
"Callable executed when object is clicked. "
|
||||
"Function receives (pos: Vector, button: MouseButton, action: InputState)."
|
||||
), (void*)PyObjectsEnum::UIGRID},
|
||||
|
||||
{"texture", (getter)UIGrid::get_texture, NULL,
|
||||
MCRF_PROPERTY(texture, "Texture used for tile rendering (Texture | None, read-only)."), NULL},
|
||||
{"fill_color", (getter)UIGrid::get_fill_color, (setter)UIGrid::set_fill_color,
|
||||
MCRF_PROPERTY(fill_color,
|
||||
"Background fill color of the grid (Color). "
|
||||
"Returns a copy; modifying components requires reassignment. "
|
||||
"For animation, use 'fill_color.r', 'fill_color.g', etc."
|
||||
), NULL},
|
||||
{"perspective", (getter)UIGrid::get_perspective, (setter)UIGrid::set_perspective,
|
||||
MCRF_PROPERTY(perspective,
|
||||
"Entity whose perspective to use for FOV rendering (Entity | None). "
|
||||
"Setting an entity automatically enables perspective mode. "
|
||||
"Set to None for omniscient view."
|
||||
), NULL},
|
||||
{"perspective_enabled", (getter)UIGrid::get_perspective_enabled, (setter)UIGrid::set_perspective_enabled,
|
||||
MCRF_PROPERTY(perspective_enabled,
|
||||
"Whether to use perspective-based FOV rendering (bool). "
|
||||
"When True with no valid entity, all cells appear undiscovered."
|
||||
), NULL},
|
||||
{"fov", (getter)UIGrid::get_fov, (setter)UIGrid::set_fov,
|
||||
MCRF_PROPERTY(fov,
|
||||
"FOV algorithm for this grid (FOV enum). "
|
||||
"Used by entity.updateVisibility() and layer methods when fov=None."
|
||||
), NULL},
|
||||
{"fov_radius", (getter)UIGrid::get_fov_radius, (setter)UIGrid::set_fov_radius,
|
||||
MCRF_PROPERTY(fov_radius, "Default FOV radius for this grid (int). Used when radius is not specified."), NULL},
|
||||
{"z_index", (getter)UIDrawable::get_int, (setter)UIDrawable::set_int,
|
||||
MCRF_PROPERTY(z_index,
|
||||
"Z-order for rendering (lower values rendered first). "
|
||||
"Automatically triggers scene resort when changed."
|
||||
), (void*)PyObjectsEnum::UIGRID},
|
||||
{"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name,
|
||||
MCRF_PROPERTY(name, "Name for finding elements (str)."), (void*)PyObjectsEnum::UIGRID},
|
||||
UIDRAWABLE_GETSETTERS,
|
||||
UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIGRID),
|
||||
UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIGRID),
|
||||
UIDRAWABLE_ROTATION_GETSETTERS(PyObjectsEnum::UIGRID),
|
||||
{"on_cell_enter", (getter)UIGrid::get_on_cell_enter, (setter)UIGrid::set_on_cell_enter,
|
||||
MCRF_PROPERTY(on_cell_enter, "Callback when mouse enters a grid cell (Callable | None). Called with (cell_pos: Vector)."), NULL},
|
||||
{"on_cell_exit", (getter)UIGrid::get_on_cell_exit, (setter)UIGrid::set_on_cell_exit,
|
||||
MCRF_PROPERTY(on_cell_exit, "Callback when mouse exits a grid cell (Callable | None). Called with (cell_pos: Vector)."), NULL},
|
||||
{"on_cell_click", (getter)UIGrid::get_on_cell_click, (setter)UIGrid::set_on_cell_click,
|
||||
MCRF_PROPERTY(on_cell_click, "Callback when a grid cell is clicked (Callable | None). Called with (cell_pos: Vector, button: MouseButton, action: InputState)."), NULL},
|
||||
{"hovered_cell", (getter)UIGrid::get_hovered_cell, NULL,
|
||||
MCRF_PROPERTY(hovered_cell, "Currently hovered cell as (x, y) tuple, or None if not hovering (tuple | None, read-only)."), NULL},
|
||||
UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UIGRID),
|
||||
{"view", (getter)UIGrid::get_view, NULL,
|
||||
MCRF_PROPERTY(view,
|
||||
"Auto-created GridView for rendering (GridView | None, read-only). "
|
||||
"When Grid is appended to a scene, this view is what actually renders."
|
||||
), NULL},
|
||||
{NULL}
|
||||
};
|
||||
1157
src/UIGridView.cpp
1157
src/UIGridView.cpp
File diff suppressed because it is too large
Load diff
124
src/UIGridView.h
124
src/UIGridView.h
|
|
@ -41,6 +41,7 @@ public:
|
|||
sf::FloatRect get_bounds() const override;
|
||||
void move(float dx, float dy) override;
|
||||
void resize(float w, float h) override;
|
||||
void onPositionChanged() override; // #355: keep box in sync with position
|
||||
|
||||
// The grid data this view renders
|
||||
std::shared_ptr<GridData> grid_data;
|
||||
|
|
@ -53,6 +54,20 @@ public:
|
|||
// is a different object/control block than the view's own PyObject.
|
||||
PyObject* cached_grid_wrapper = nullptr;
|
||||
|
||||
// =====================================================================
|
||||
// #364 - Overlay children (speech bubbles, markers, range indicators).
|
||||
// Owned by the VIEW, not GridData: a child is an annotation drawn over the
|
||||
// map through THIS camera, not world content. Two views over one GridData
|
||||
// therefore have independent children (a minimap does not want the main
|
||||
// view's speech bubbles) while sharing every entity.
|
||||
//
|
||||
// Positions are in GRID-WORLD PIXEL coordinates (#360) -- not view-local,
|
||||
// not screen -- so panning the camera does not move a child's logical
|
||||
// position. The view's camera maps them to screen at render/hit-test time.
|
||||
// =====================================================================
|
||||
std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>> children;
|
||||
bool children_need_sort = true;
|
||||
|
||||
// Rendering state (independent per view)
|
||||
std::shared_ptr<PyTexture> ptex;
|
||||
sf::RectangleShape box;
|
||||
|
|
@ -64,6 +79,42 @@ public:
|
|||
std::weak_ptr<UIEntity> perspective_entity;
|
||||
bool perspective_enabled = false;
|
||||
|
||||
// =====================================================================
|
||||
// #355 - Cell input. Owned by the VIEW, not GridData: two views over one
|
||||
// GridData must each track their own hover cell (a shared hovered_cell
|
||||
// would ping-pong exit/enter between views), and the subclass-dispatch
|
||||
// path must resolve against the VIEW's serial_number, because the public
|
||||
// subclassable type (mcrfpy.Grid) IS UIGridView.
|
||||
// =====================================================================
|
||||
std::unique_ptr<PyCellHoverCallable> on_cell_enter_callable;
|
||||
std::unique_ptr<PyCellHoverCallable> on_cell_exit_callable;
|
||||
std::unique_ptr<PyClickCallable> on_cell_click_callable;
|
||||
std::optional<sf::Vector2i> hovered_cell; // Python-visible (read-only)
|
||||
std::optional<sf::Vector2i> last_clicked_cell; // C++ only: click_at -> dispatchCellClick
|
||||
|
||||
struct CellCallbackCache {
|
||||
uint32_t generation = 0;
|
||||
bool valid = false;
|
||||
bool has_on_cell_click = false;
|
||||
bool has_on_cell_enter = false;
|
||||
bool has_on_cell_exit = false;
|
||||
};
|
||||
CellCallbackCache cell_callback_cache;
|
||||
|
||||
bool fireCellClick(sf::Vector2i cell, const std::string& button, const std::string& action);
|
||||
bool fireCellEnter(sf::Vector2i cell);
|
||||
bool fireCellExit(sf::Vector2i cell);
|
||||
void refreshCellCallbackCache(PyObject* pyObj);
|
||||
|
||||
// UIDrawable input virtuals (#355)
|
||||
bool dispatchCellClick(const std::string& button, const std::string& action) override;
|
||||
void updateHover(sf::Vector2f point, bool hit_allowed) override;
|
||||
GridData* asGridData() override { return grid_data.get(); }
|
||||
|
||||
// Cell math. local_point is relative to the widget's top-left.
|
||||
sf::Vector2f localToGridWorld(sf::Vector2f local_point) const;
|
||||
std::optional<sf::Vector2i> cellAtLocal(sf::Vector2f local_point) const;
|
||||
|
||||
// #351 - clean-state render early-out cache. These record the inputs the
|
||||
// current RenderTexture was rasterized from; render() re-blits the cached
|
||||
// texture instead of clear+redraw when none changed. Camera params are
|
||||
|
|
@ -76,6 +127,7 @@ public:
|
|||
sf::Vector2f last_box_size{-1.f, -1.f};
|
||||
sf::Color last_fill_color{0, 0, 0, 0};
|
||||
sf::Vector2u last_render_tex_size{0, 0};
|
||||
bool last_perspective_enabled = false; // #355: fog baked into the cached raster
|
||||
|
||||
// Render textures
|
||||
sf::Sprite sprite_proto, output;
|
||||
|
|
@ -96,8 +148,9 @@ public:
|
|||
void center_camera();
|
||||
void center_camera(float tile_x, float tile_y);
|
||||
|
||||
// Cell coordinate conversion
|
||||
std::optional<sf::Vector2i> screenToCell(sf::Vector2f screen_pos) const;
|
||||
// #355: no screenToCell() -- hover/click both arrive in parent-local space and
|
||||
// go through cellAtLocal(). A global-coords helper would be a second, wrong
|
||||
// coordinate path for grids nested inside frames/grids.
|
||||
sf::Vector2f getEffectiveCellSize() const;
|
||||
|
||||
static constexpr int DEFAULT_CELL_WIDTH = 16;
|
||||
|
|
@ -107,8 +160,8 @@ public:
|
|||
// Python API
|
||||
// =========================================================================
|
||||
static int init(PyUIGridViewObject* self, PyObject* args, PyObject* kwds);
|
||||
static int init_explicit_view(PyUIGridViewObject* self, PyObject* args, PyObject* kwds);
|
||||
static int init_with_data(PyUIGridViewObject* self, PyObject* args, PyObject* kwds);
|
||||
// #361: one init() for both modes -- Grid(grid_size=...) creates its own
|
||||
// GridData, Grid(grid=data) attaches to an existing one.
|
||||
static PyObject* repr(PyUIGridViewObject* self);
|
||||
|
||||
// #252 - Attribute delegation to underlying Grid
|
||||
|
|
@ -124,12 +177,35 @@ public:
|
|||
static int set_center(PyUIGridViewObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_zoom(PyUIGridViewObject* self, void* closure);
|
||||
static int set_zoom(PyUIGridViewObject* self, PyObject* value, void* closure);
|
||||
// #361 - `size` and `center_camera()` used to delegate to the internal UIGrid's
|
||||
// ghost camera, making them silent no-ops on the widget being rendered.
|
||||
static PyObject* get_size(PyUIGridViewObject* self, void* closure);
|
||||
static int set_size(PyUIGridViewObject* self, PyObject* value, void* closure);
|
||||
static PyObject* py_center_camera(PyUIGridViewObject* self, PyObject* args);
|
||||
static PyObject* get_fill_color(PyUIGridViewObject* self, void* closure);
|
||||
static int set_fill_color(PyUIGridViewObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_texture(PyUIGridViewObject* self, void* closure);
|
||||
static PyObject* get_float_member_gv(PyUIGridViewObject* self, void* closure);
|
||||
static int set_float_member_gv(PyUIGridViewObject* self, PyObject* value, void* closure);
|
||||
|
||||
// #364 - overlay children (moved from GridData)
|
||||
static PyObject* get_children(PyUIGridViewObject* self, void* closure);
|
||||
|
||||
// #355 - cell callback properties (moved from UIGrid)
|
||||
static PyObject* get_on_cell_enter(PyUIGridViewObject* self, void* closure);
|
||||
static int set_on_cell_enter(PyUIGridViewObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_on_cell_exit(PyUIGridViewObject* self, void* closure);
|
||||
static int set_on_cell_exit(PyUIGridViewObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_on_cell_click(PyUIGridViewObject* self, void* closure);
|
||||
static int set_on_cell_click(PyUIGridViewObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_hovered_cell(PyUIGridViewObject* self, void* closure);
|
||||
|
||||
// #355 - perspective properties (moved from UIGrid: the view renders the overlay)
|
||||
static PyObject* get_perspective(PyUIGridViewObject* self, void* closure);
|
||||
static int set_perspective(PyUIGridViewObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_perspective_enabled(PyUIGridViewObject* self, void* closure);
|
||||
static int set_perspective_enabled(PyUIGridViewObject* self, PyObject* value, void* closure);
|
||||
|
||||
static PyMethodDef methods[];
|
||||
static PyGetSetDef getsetters[];
|
||||
|
||||
|
|
@ -149,7 +225,9 @@ namespace mcrfpydef {
|
|||
// Attribute access delegates to underlying Grid for data properties/methods.
|
||||
inline PyTypeObject PyUIGridViewType = {
|
||||
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||
.tp_name = "mcrfpy.Grid", // #252: primary name is Grid
|
||||
// #361: the canonical name is GridView -- it IS the camera/widget. "Grid"
|
||||
// is bound to this same type object as an alias (see McRFPy_API.cpp).
|
||||
.tp_name = "mcrfpy.GridView",
|
||||
.tp_basicsize = sizeof(PyUIGridViewObject),
|
||||
.tp_itemsize = 0,
|
||||
.tp_dealloc = (destructor)[](PyObject* self)
|
||||
|
|
@ -159,17 +237,16 @@ namespace mcrfpydef {
|
|||
if (obj->weakreflist != NULL) {
|
||||
PyObject_ClearWeakRefs(self);
|
||||
}
|
||||
// Clear owning_view back-reference before releasing grid_data --
|
||||
// but only when this wrapper is the LAST owner of the view (#251
|
||||
// pattern, mirrors PyUIGridType) AND the dying view is actually
|
||||
// the one owning_view points at. The previous ungated reset
|
||||
// severed the back-reference whenever ANY Python wrapper was
|
||||
// #359: Unregister from the GridData's view list before releasing
|
||||
// grid_data -- but only when this wrapper is the LAST owner of the
|
||||
// view (#251 pattern, mirrors PyGridDataType). An ungated unregister
|
||||
// would sever the back-reference whenever ANY Python wrapper was
|
||||
// GC'd while the C++ view lived on (e.g. held by scene.children),
|
||||
// breaking entity.grid -> Grid identity and the #313 data-layer
|
||||
// dirty notifications.
|
||||
if (obj->data && obj->data->grid_data && obj->data.use_count() <= 1 &&
|
||||
obj->data->grid_data->owning_view.lock() == obj->data) {
|
||||
obj->data->grid_data->owning_view.reset();
|
||||
// dirty notifications. unregisterView matches by identity, so it's
|
||||
// harmless (a no-op) if this view was never registered.
|
||||
if (obj->data && obj->data->grid_data && obj->data.use_count() <= 1) {
|
||||
obj->data->grid_data->unregisterView(obj->data.get());
|
||||
}
|
||||
obj->data.reset();
|
||||
Py_TYPE(self)->tp_free(self);
|
||||
|
|
@ -212,6 +289,21 @@ namespace mcrfpydef {
|
|||
if (obj->data && obj->data->cached_grid_wrapper) {
|
||||
Py_VISIT(obj->data->cached_grid_wrapper);
|
||||
}
|
||||
// #355: cell callbacks now live on the view.
|
||||
if (obj->data) {
|
||||
if (obj->data->on_cell_enter_callable) {
|
||||
PyObject* cb = obj->data->on_cell_enter_callable->borrow();
|
||||
if (cb && cb != Py_None) Py_VISIT(cb);
|
||||
}
|
||||
if (obj->data->on_cell_exit_callable) {
|
||||
PyObject* cb = obj->data->on_cell_exit_callable->borrow();
|
||||
if (cb && cb != Py_None) Py_VISIT(cb);
|
||||
}
|
||||
if (obj->data->on_cell_click_callable) {
|
||||
PyObject* cb = obj->data->on_cell_click_callable->borrow();
|
||||
if (cb && cb != Py_None) Py_VISIT(cb);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
.tp_clear = [](PyObject* self) -> int {
|
||||
|
|
@ -220,6 +312,10 @@ namespace mcrfpydef {
|
|||
obj->data->click_unregister();
|
||||
// #348: drop the persistent wrapper; get_grid rebuilds on demand.
|
||||
Py_CLEAR(obj->data->cached_grid_wrapper);
|
||||
// #355: release cell callbacks (breaks closure->grid cycles).
|
||||
obj->data->on_cell_enter_callable.reset();
|
||||
obj->data->on_cell_exit_callable.reset();
|
||||
obj->data->on_cell_click_callable.reset();
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
#include "PyAlignment.h"
|
||||
#include "UIFrame.h" // parent= kwarg: Frame parent type
|
||||
#include "UICaption.h" // parent= kwarg: needed for ATTACH macro instantiation
|
||||
#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type
|
||||
#include "PyGridData.h" // parent= kwarg: Grid/GridView parent type
|
||||
#include "PySceneObject.h" // parent= kwarg: Scene parent type
|
||||
#include <cmath>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
#include "PythonObjectCache.h"
|
||||
#include "UIFrame.h" // #144: For snapshot= parameter
|
||||
#include "UICaption.h" // parent= kwarg: needed for ATTACH macro instantiation
|
||||
#include "UIGrid.h" // parent= kwarg: Grid/GridView parent type
|
||||
#include "PyGridData.h" // parent= kwarg: Grid/GridView parent type
|
||||
#include "PySceneObject.h" // parent= kwarg: Scene parent type
|
||||
#include "PyAlignment.h"
|
||||
#include "PyShader.h" // #106: Shader support
|
||||
|
|
|
|||
|
|
@ -104,10 +104,19 @@ UITestScene::UITestScene(GameEngine* g) : Scene(g)
|
|||
std::cout << "pointer to ui_elements now shows size=" << ui->size() << std::endl;
|
||||
*/
|
||||
|
||||
// UIGrid test: (in grid cells) ( in screen pixels )
|
||||
// constructor args: w h texture x y w h
|
||||
auto e5 = std::make_shared<UIGrid>(4, 4, ptex, sf::Vector2f(550, 150), sf::Vector2f(200, 200));
|
||||
e5->zoom=2.0;
|
||||
// Grid test. #361: the map (GridData) and the camera onto it (UIGridView) are
|
||||
// separate objects now -- only the view goes in the scene.
|
||||
auto e5 = std::make_shared<GridData>(4, 4, ptex);
|
||||
auto e5_view = std::make_shared<UIGridView>();
|
||||
e5_view->grid_data = e5;
|
||||
e5_view->ptex = ptex;
|
||||
e5_view->position = sf::Vector2f(550, 150);
|
||||
e5_view->box.setPosition(e5_view->position);
|
||||
e5_view->box.setSize(sf::Vector2f(200, 200));
|
||||
e5_view->zoom = 2.0;
|
||||
e5_view->center_camera();
|
||||
e5_view->ensureRenderTextureSize();
|
||||
e5->registerView(e5_view);
|
||||
|
||||
// #150 - GridPoint no longer has color/tilesprite properties
|
||||
// Use layers for visual rendering; GridPoint only has walkable/transparent
|
||||
|
|
@ -116,7 +125,7 @@ UITestScene::UITestScene(GameEngine* g) : Scene(g)
|
|||
e5->setWalkable(0, 0, true); // #332
|
||||
e5->setTransparent(0, 0, true);
|
||||
|
||||
ui_elements->push_back(e5);
|
||||
ui_elements->push_back(e5_view);
|
||||
|
||||
//UIEntity test:
|
||||
// asdf
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@
|
|||
# (c) entity pixel position (x/y/pos) is derived from the GRID's cell size,
|
||||
# NOT the entity's own texture -- setting a different-sized texture on the
|
||||
# entity must not move it
|
||||
# (d) entity.grid returns the same Grid (GridView) object across step/FOV cycles
|
||||
# (PythonObjectCache identity)
|
||||
# (d) entity.grid returns the same GridData object across step/FOV cycles
|
||||
# (PythonObjectCache identity). #361 changed WHAT it returns -- the map, not
|
||||
# a view -- but the identity guarantee this test exists for is unchanged.
|
||||
# (e) find_path() and at() still work, and constructing their temporary internal
|
||||
# grid wrappers does not destroy the grid's cell callbacks (#251 guard)
|
||||
# (f) a GridPoint obtained from entity.at() outlives the entity and the grid
|
||||
|
|
@ -79,10 +80,15 @@ def main():
|
|||
"(c) pixel pos unchanged after setting a different-sized entity texture")
|
||||
|
||||
# --- (d) entity.grid identity across a step/FOV cycle ---
|
||||
check(entity.grid is grid, "(d) entity.grid is the user-created Grid object")
|
||||
# #361: entity.grid is the GridData (the map), not the Grid (a camera onto it).
|
||||
# An entity can be on a map with no view at all, or with several; "the grid an
|
||||
# entity is in" is not a camera. It is still one stable object, which is what
|
||||
# the PythonObjectCache identity guarantee here is about.
|
||||
check(entity.grid is grid.grid_data, "(d) entity.grid is the map this Grid views")
|
||||
check(isinstance(entity.grid, mcrfpy.GridData), "(d) entity.grid is a GridData")
|
||||
grid.compute_fov((2, 2), radius=5)
|
||||
grid.step(1)
|
||||
check(entity.grid is grid, "(d) entity.grid identity survives step + compute_fov")
|
||||
check(entity.grid is grid.grid_data, "(d) entity.grid identity survives step + compute_fov")
|
||||
|
||||
# --- (e) find_path / at() work; cell callbacks survive temp grid wrappers ---
|
||||
grid.on_cell_click = lambda pos, btn, action: None
|
||||
|
|
@ -131,18 +137,22 @@ def main():
|
|||
except RuntimeError:
|
||||
check(True, "(g) uninitialized Entity texture set raises RuntimeError")
|
||||
|
||||
# --- (h) owning_view survives Python wrapper GC while the C++ view lives ---
|
||||
# The canonical idiom: append the Grid to a scene, drop the Python
|
||||
# reference. The data-layer dirty notifications and entity.grid -> Grid
|
||||
# identity both depend on owning_view staying intact.
|
||||
# --- (h) entity.grid survives GC of the view's Python wrapper ---
|
||||
# The canonical idiom: append the Grid to a scene, drop the Python reference.
|
||||
# The C++ view lives on in the scene graph; the entity's map must stay valid
|
||||
# and identical. (#361: entity.grid is the GridData, so this no longer depends
|
||||
# on a view existing at all -- but the wrapper-GC hazard is the same one.)
|
||||
scene = mcrfpy.Scene("issue313_ov")
|
||||
g2 = mcrfpy.Grid(grid_size=(4, 4), texture=tex16, pos=(0, 0), size=(64, 64))
|
||||
e2 = mcrfpy.Entity(grid_pos=(1, 1), texture=tex16, grid=g2)
|
||||
data2 = g2.grid_data
|
||||
scene.children.append(g2)
|
||||
del g2
|
||||
gc.collect()
|
||||
check(type(e2.grid).__name__ == "Grid",
|
||||
"(h) entity.grid still returns Grid after wrapper GC (owning_view intact)")
|
||||
check(e2.grid is data2,
|
||||
"(h) entity.grid is the same GridData after the view wrapper is GC'd")
|
||||
check(e2.grid.at(1, 1) is not None,
|
||||
"(h) that GridData is still usable after the view wrapper is GC'd")
|
||||
|
||||
# --- (f) GridPoint outlives entity + grid references ---
|
||||
entity.die()
|
||||
|
|
|
|||
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)
|
||||
175
tests/regression/issue_357_find_in_grid_test.py
Normal file
175
tests/regression/issue_357_find_in_grid_test.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test for #357 - mcrfpy.find()/find_all() never descend into a
|
||||
Grid's entities or child drawables.
|
||||
|
||||
Root cause (same #252 GridView-split fracture family as #355):
|
||||
- McRFPy_API::_find / _findAll gate their grid-entity search on
|
||||
`drawable->derived_type() == PyObjectsEnum::UIGRID`, but a real
|
||||
mcrfpy.Grid() is a UIGridView (PyObjectsEnum::UIGRIDVIEW) -- that branch
|
||||
is structurally unreachable for anything a Python script can build.
|
||||
- find_in_collection()'s child-recursion only descends into UIFRAME
|
||||
children; it never recurses into a grid's own overlay children
|
||||
(grid.children) at all, regardless of the UIGRID/UIGRIDVIEW question.
|
||||
|
||||
Both are exercised here as hard assertions.
|
||||
"""
|
||||
import sys
|
||||
import mcrfpy
|
||||
|
||||
|
||||
def test_find_entity_in_grid():
|
||||
print("find()/find_all() locate a named Entity inside a Grid...")
|
||||
scene = mcrfpy.Scene("issue357_entity")
|
||||
ui = scene.children
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(0, 0), size=(80, 80))
|
||||
ui.append(grid)
|
||||
|
||||
e = mcrfpy.Entity(grid_pos=(2, 2), name="findme_entity")
|
||||
grid.entities.append(e)
|
||||
|
||||
found = mcrfpy.find("findme_entity")
|
||||
assert found is not None, "mcrfpy.find() failed to locate a named Entity inside a Grid"
|
||||
assert found.name == "findme_entity", f"found wrong object: name={getattr(found, 'name', None)!r}"
|
||||
assert (found.grid_pos.x, found.grid_pos.y) == (2, 2), (
|
||||
f"find() returned an entity with the wrong grid_pos: {found.grid_pos.x},{found.grid_pos.y}"
|
||||
)
|
||||
|
||||
all_found = mcrfpy.find_all("findme_entity")
|
||||
assert len(all_found) >= 1, "mcrfpy.find_all() failed to locate a named Entity inside a Grid"
|
||||
print(" OK")
|
||||
|
||||
|
||||
def test_find_child_drawable_in_grid():
|
||||
print("find()/find_all() locate a named child drawable inside a Grid...")
|
||||
scene = mcrfpy.Scene("issue357_child")
|
||||
ui = scene.children
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(0, 0), size=(80, 80))
|
||||
ui.append(grid)
|
||||
|
||||
child = mcrfpy.Frame(pos=(10, 10), size=(5, 5), name="findme_child")
|
||||
grid.children.append(child)
|
||||
|
||||
found = mcrfpy.find("findme_child")
|
||||
assert found is not None, "mcrfpy.find() failed to locate a named child drawable inside a Grid"
|
||||
assert found.name == "findme_child", f"found wrong object: name={getattr(found, 'name', None)!r}"
|
||||
|
||||
all_found = mcrfpy.find_all("findme_child")
|
||||
assert len(all_found) >= 1, "mcrfpy.find_all() failed to locate a named child drawable inside a Grid"
|
||||
print(" OK")
|
||||
|
||||
|
||||
def test_find_preserves_entity_identity():
|
||||
"""find() must return the SAME Python object the user created (the #266
|
||||
identity ref via PythonObjectCache), not a fresh base-class wrapper.
|
||||
|
||||
Two bugs in one, if it doesn't:
|
||||
* a Grid entity that is an Entity SUBCLASS comes back as a plain
|
||||
mcrfpy.Entity -- attributes on the subclass instance are gone;
|
||||
* disposing that duplicate wrapper runs PyUIEntityType's tp_dealloc,
|
||||
which clears data->pyobject WITHOUT a Py_DECREF -- permanently leaking
|
||||
the entity's strong self-reference, so the subclass instance (and its
|
||||
__dict__/closures) is never reclaimed, even after it leaves the grid.
|
||||
"""
|
||||
print("find() returns the entity's real Python object (identity + no leak)...")
|
||||
import gc
|
||||
import weakref
|
||||
|
||||
scene = mcrfpy.Scene("issue357_identity")
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(0, 0), size=(80, 80))
|
||||
scene.children.append(grid)
|
||||
|
||||
class Hero(mcrfpy.Entity):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.hp = 10
|
||||
|
||||
hero = Hero(grid_pos=(2, 2), name="hero")
|
||||
grid.entities.append(hero)
|
||||
|
||||
assert grid.entities[0] is hero, "collection lookup lost identity (test setup broken)"
|
||||
|
||||
found = mcrfpy.find("hero")
|
||||
assert found is hero, (
|
||||
f"find() returned a duplicate wrapper ({type(found).__name__}), not the "
|
||||
"Hero instance -- PythonObjectCache lookup skipped"
|
||||
)
|
||||
assert found.hp == 10, "subclass attributes lost -- find() built a base Entity"
|
||||
|
||||
all_found = mcrfpy.find_all("hero")
|
||||
assert all_found and all_found[0] is hero, f"find_all() lost identity: {all_found}"
|
||||
|
||||
# Now prove the identity reference was not leaked: dropping every Python
|
||||
# handle after the entity leaves the grid must reclaim the Hero object.
|
||||
del found
|
||||
del all_found
|
||||
ref = weakref.ref(hero)
|
||||
grid.entities.remove(hero) # releasePyIdentity() -> DECREF the strong ref
|
||||
del hero
|
||||
gc.collect()
|
||||
assert ref() is None, (
|
||||
"Hero object survived removal from the grid -- the entity's strong "
|
||||
"identity reference was dropped without a DECREF (leak)"
|
||||
)
|
||||
print(" OK")
|
||||
|
||||
|
||||
def test_find_dedupes_shared_griddata():
|
||||
"""N views can share ONE GridData (#359 split-screen/minimap). The grid's
|
||||
entities and overlay children must be walked once per GridData, not once
|
||||
per view, or find_all() returns N duplicates of every match."""
|
||||
print("find_all() does not duplicate entities across views of one GridData...")
|
||||
scene = mcrfpy.Scene("issue357_shared")
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
g = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
|
||||
scene.children.append(g)
|
||||
|
||||
v2 = mcrfpy.GridView(grid=g.grid_data, pos=(400, 100), size=(200, 200))
|
||||
scene.children.append(v2)
|
||||
|
||||
enemy = mcrfpy.Entity(grid_pos=(1, 1), name="enemy")
|
||||
g.entities.append(enemy)
|
||||
|
||||
child = mcrfpy.Frame(pos=(16, 16), size=(16, 16), name="marker")
|
||||
g.children.append(child)
|
||||
|
||||
hits = mcrfpy.find_all("enemy")
|
||||
assert len(hits) == 1, (
|
||||
f"find_all('enemy') returned {len(hits)} results for one entity -- the "
|
||||
"shared GridData was walked once per view (a 'for e in find_all(...)' loop "
|
||||
"would apply its effect twice)"
|
||||
)
|
||||
assert hits[0] is enemy, "find_all() lost entity identity"
|
||||
|
||||
child_hits = mcrfpy.find_all("marker")
|
||||
assert len(child_hits) == 1, (
|
||||
f"find_all('marker') returned {len(child_hits)} results for one grid child"
|
||||
)
|
||||
print(" OK")
|
||||
|
||||
|
||||
def main():
|
||||
test_find_entity_in_grid()
|
||||
test_find_child_drawable_in_grid()
|
||||
test_find_preserves_entity_identity()
|
||||
test_find_dedupes_shared_griddata()
|
||||
print("PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# See issue_355_grid_input_test.py: --exec does not turn an unhandled
|
||||
# exception into a non-zero process exit code, so translate explicitly.
|
||||
try:
|
||||
main()
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\nTEST FAILED: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
194
tests/regression/issue_359_shared_griddata_test.py
Normal file
194
tests/regression/issue_359_shared_griddata_test.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""Regression test for #359 - the shared-data grid path is unwired.
|
||||
|
||||
Two concrete defects, both in the `Grid(grid=other)` shared-GridData path
|
||||
(the whole point of the #252 split -- split-screen, minimap, multiple
|
||||
cameras over one dataset):
|
||||
|
||||
(a) UIGridView::init_explicit_view never registered the new view with the
|
||||
GridData it attaches to, so GridData::markDirty()/markCompositeDirty()
|
||||
(which push markContentDirty/markCompositeDirty up a view's OWN
|
||||
ancestor chain -- e.g. a Frame(cache_subtree=True) wrapping that view)
|
||||
never reached a secondary view's ancestors. Note this is NOT the same
|
||||
thing as the #351 render early-out itself: UIGridView::render() polls
|
||||
GridData::content_generation directly every time it actually runs, so
|
||||
a bare secondary view (no cached ancestor) already re-rasters
|
||||
correctly regardless of this bug. The bug only surfaces when a
|
||||
secondary view sits under a render-texture-caching ancestor, because
|
||||
that ancestor decides whether to invoke the child's render() AT ALL --
|
||||
a push notification, not a poll, is required to reach it. Test 3 below
|
||||
is the discriminator: it fails (stale cache) on the pre-fix code and
|
||||
passes once init_explicit_view registers the view.
|
||||
|
||||
(b) PyUIGridObject::view (the internal `_GridData.view` shim, meant to
|
||||
point back at "the" GridView) was placement-new'd but never assigned
|
||||
anywhere reachable from Python -- `_GridData.view` was always None.
|
||||
Given `_GridData` is internal (never exported to the mcrfpy module
|
||||
namespace), the fix deletes the field/getter outright rather than
|
||||
wiring it up (a single view back-pointer is structurally wrong once N
|
||||
views can share one GridData; see GridData::views / primaryView() for
|
||||
the replacement, exercised implicitly by test 2). Test 4 confirms the
|
||||
dead property is gone, not silently still None.
|
||||
|
||||
Design note carried over from the fix: GridData::views is a *list*, not a
|
||||
single pointer, specifically because N views can share one GridData.
|
||||
|
||||
#361 update: entity.grid no longer returns a view at all -- it returns the
|
||||
GridData. The "which view is primary?" question that views.front() answered for
|
||||
identity purposes is gone, because the answer was always arbitrary dressed up as
|
||||
deterministic. The registry itself remains, and is still load-bearing for
|
||||
dirty-notification fan-out (test 3).
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import tempfile
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
|
||||
TMPDIR = tempfile.mkdtemp(prefix="mcrf359_")
|
||||
_counter = 0
|
||||
|
||||
|
||||
def shot():
|
||||
"""Render the active scene to PNG and return its bytes (headless
|
||||
screenshot is synchronous -- see #351's test for the same pattern)."""
|
||||
global _counter
|
||||
path = os.path.join(TMPDIR, "s%d.png" % _counter)
|
||||
_counter += 1
|
||||
automation.screenshot(path)
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_1_two_views_render_shared_mutations():
|
||||
"""Two Grid views over one GridData, with different camera params, both
|
||||
reflect a mutation made through a third path (this is the exact scenario
|
||||
the #359 issue's own test description asks for)."""
|
||||
print("(1) two views over one GridData both render mutations...")
|
||||
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
|
||||
scene_a = mcrfpy.Scene("issue359_1a")
|
||||
grid_a = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160), texture=texture)
|
||||
scene_a.children.append(grid_a)
|
||||
|
||||
scene_b = mcrfpy.Scene("issue359_1b")
|
||||
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(160, 160))
|
||||
grid_b.zoom = 1.5
|
||||
grid_b.center = (64, 64)
|
||||
scene_b.children.append(grid_b)
|
||||
|
||||
mcrfpy.current_scene = scene_a
|
||||
a1 = shot()
|
||||
mcrfpy.current_scene = scene_b
|
||||
b1 = shot()
|
||||
|
||||
# Mutate through the shared data (add a visible entity at cell (3,3)).
|
||||
e = mcrfpy.Entity((3, 3), grid=grid_a)
|
||||
e.sprite_index = 5
|
||||
|
||||
mcrfpy.current_scene = scene_a
|
||||
a2 = shot()
|
||||
assert a2 != a1, "primary view (grid_a) did not reflect the mutation"
|
||||
|
||||
mcrfpy.current_scene = scene_b
|
||||
b2 = shot()
|
||||
assert b2 != b1, "secondary view (grid_b) did not reflect the mutation"
|
||||
print(" OK")
|
||||
|
||||
|
||||
def test_2_entity_grid_identity():
|
||||
"""entity.grid is the shared GridData -- one stable object, the same one both
|
||||
views are looking at, and NOT either view (#361)."""
|
||||
print("(2) entity.grid identity with a secondary view registered...")
|
||||
grid_a = mcrfpy.Grid(grid_size=(5, 5))
|
||||
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(80, 80))
|
||||
|
||||
e = mcrfpy.Entity((1, 1), grid=grid_a)
|
||||
|
||||
data = grid_a.grid_data
|
||||
assert grid_b.grid_data is data, "the two views do not share one GridData"
|
||||
assert e.grid is data, f"entity.grid identity broken: {e.grid!r} is not {data!r}"
|
||||
assert e.grid is e.grid, "entity.grid is not stable across reads"
|
||||
assert e.grid is not grid_a and e.grid is not grid_b, \
|
||||
"entity.grid returned a view; it must return the map (#361)"
|
||||
print(" OK")
|
||||
|
||||
|
||||
def test_3_ancestor_cache_invalidation_through_secondary_view():
|
||||
"""The discriminating regression: a SECONDARY view nested inside a
|
||||
Frame(cache_subtree=True) must have its ancestor's cached subtree
|
||||
invalidated when the shared GridData mutates, even though the mutation
|
||||
happens through a DIFFERENT (primary) view/handle. This requires
|
||||
init_explicit_view to register the secondary view with GridData -- pure
|
||||
content_generation polling (#351) is not enough, because the caching
|
||||
Frame decides whether to call the child's render() at all."""
|
||||
print("(3) ancestor render-texture cache invalidated via secondary view...")
|
||||
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
|
||||
scene = mcrfpy.Scene("issue359_3")
|
||||
grid_a = mcrfpy.Grid(grid_size=(6, 6), pos=(200, 200), size=(96, 96), texture=texture)
|
||||
# grid_a deliberately NOT added to this scene -- it exists only to own
|
||||
# the data and be the mutation path; only the secondary view is visible.
|
||||
|
||||
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(96, 96))
|
||||
|
||||
frame = mcrfpy.Frame(pos=(0, 0), size=(96, 96))
|
||||
frame.cache_subtree = True
|
||||
frame.children.append(grid_b)
|
||||
scene.children.append(frame)
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
# First render bakes the Frame's cached subtree (render_dirty starts True).
|
||||
shot1 = shot()
|
||||
# Idle render: cache should be stable (sanity check the test methodology).
|
||||
shot2 = shot()
|
||||
assert shot1 == shot2, "cache_subtree Frame was not stable across an idle frame (test setup problem)"
|
||||
|
||||
# Mutate the shared GridData through the PRIMARY handle grid_a, NOT the
|
||||
# secondary view. If grid_b is registered on the GridData, this reaches
|
||||
# grid_b's own ancestor chain (the cache_subtree Frame) and invalidates it.
|
||||
e = mcrfpy.Entity((3, 3), grid=grid_a)
|
||||
e.sprite_index = 5
|
||||
|
||||
shot3 = shot()
|
||||
assert shot3 != shot2, (
|
||||
"Frame(cache_subtree=True) wrapping the SECONDARY view did not invalidate "
|
||||
"when the shared GridData mutated through the primary handle -- "
|
||||
"init_explicit_view is not registering the view with GridData (#359 defect a)"
|
||||
)
|
||||
print(" OK")
|
||||
|
||||
|
||||
def test_4_griddata_view_property_removed():
|
||||
"""_GridData.view (PyUIGridObject::view) is dead: nothing in the
|
||||
codebase ever assigned it, and _GridData is internal/not exported. The
|
||||
fix deletes the field and getter outright rather than wiring a
|
||||
structurally-wrong single back-pointer; confirm it's actually gone, not
|
||||
silently still None."""
|
||||
print("(4) _GridData.view property deleted (not left dangling)...")
|
||||
grid_a = mcrfpy.Grid(grid_size=(3, 3))
|
||||
gd = grid_a.grid_data
|
||||
assert not hasattr(gd, "view"), (
|
||||
"_GridData.view still exists -- #359 defect (b) says delete it "
|
||||
"(internal type, never wired, N-view sharing makes a single "
|
||||
"back-pointer wrong) rather than leave it dangling"
|
||||
)
|
||||
print(" OK")
|
||||
|
||||
|
||||
def main():
|
||||
test_1_two_views_render_shared_mutations()
|
||||
test_2_entity_grid_identity()
|
||||
test_3_ancestor_cache_invalidation_through_secondary_view()
|
||||
test_4_griddata_view_property_removed()
|
||||
print("PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\nTEST FAILED: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
228
tests/regression/issue_361_griddata_is_not_a_widget_test.py
Normal file
228
tests/regression/issue_361_griddata_is_not_a_widget_test.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
"""Regression test for #361 - GridData is a map, not a widget.
|
||||
|
||||
Before this change `UIGrid` inherited BOTH UIDrawable and GridData, so every map
|
||||
secretly carried a second camera and RenderTexture that nothing on screen
|
||||
corresponded to. Three consequences, all tested here:
|
||||
|
||||
1. `scene.children.append(some_grid_data)` DREW THE MAP A SECOND TIME, through
|
||||
that ghost camera, frozen at construction values. It must now raise TypeError.
|
||||
|
||||
2. Anything that wrote to the ghost camera was a silent no-op on the widget the
|
||||
user was actually looking at. `Grid.center_camera(...)` and `Grid.size = ...`
|
||||
both delegated to it and did nothing (case 4) -- that is not a hypothetical:
|
||||
it was the behavior on master until this fix.
|
||||
|
||||
3. A GridData could never be independently heap-allocated (`GridData::markDirty`
|
||||
did `static_cast<UIGrid*>(this)`), so a map with no view was inexpressible.
|
||||
Now `mcrfpy.GridData(...)` is public and standalone: an offscreen level that
|
||||
is still steppable, queryable and pathable (case 2).
|
||||
|
||||
Also covers the accepted API break: `entity.grid` returns the GridData (the map),
|
||||
not a view. An entity may be on a map with no view at all, or with several -- "the
|
||||
grid an entity is in" simply is not a camera.
|
||||
"""
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import sys, os, tempfile
|
||||
|
||||
TMPDIR = tempfile.mkdtemp(prefix="mcrf361_")
|
||||
_counter = 0
|
||||
_results = []
|
||||
|
||||
|
||||
def check(label, ok):
|
||||
_results.append((label, bool(ok)))
|
||||
print(" [%s] %s" % ("PASS" if ok else "FAIL", label))
|
||||
|
||||
|
||||
def shot():
|
||||
"""Render a frame to PNG and return its bytes. Identical pixels -> identical
|
||||
bytes; headless screenshots are synchronous (#153)."""
|
||||
global _counter
|
||||
path = os.path.join(TMPDIR, "s%d.png" % _counter)
|
||||
_counter += 1
|
||||
automation.screenshot(path)
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def case_griddata_is_not_drawable():
|
||||
"""A map has no position, no size, no camera, and cannot enter a scene graph."""
|
||||
data = mcrfpy.GridData(grid_size=(10, 10))
|
||||
scene = mcrfpy.Scene("t361_reject")
|
||||
|
||||
try:
|
||||
scene.children.append(data)
|
||||
check("1a: appending a GridData to a scene raises TypeError", False)
|
||||
except TypeError:
|
||||
check("1a: appending a GridData to a scene raises TypeError", True)
|
||||
|
||||
frame = mcrfpy.Frame(pos=(0, 0), size=(10, 10))
|
||||
try:
|
||||
frame.children.append(data)
|
||||
check("1b: appending a GridData to a Frame raises TypeError", False)
|
||||
except TypeError:
|
||||
check("1b: appending a GridData to a Frame raises TypeError", True)
|
||||
|
||||
# The widget properties are gone, not merely unused: keeping them would mean
|
||||
# keeping the ghost camera they wrote to.
|
||||
for prop in ("x", "y", "w", "h", "pos", "size", "center", "center_x", "zoom",
|
||||
"camera_rotation", "fill_color", "visible", "opacity", "z_index",
|
||||
"on_click", "parent", "children"):
|
||||
if hasattr(data, prop):
|
||||
check("1c: GridData exposes no widget property (found %r)" % prop, False)
|
||||
return
|
||||
check("1c: GridData exposes no widget properties", True)
|
||||
|
||||
check("1d: GridData is not a Drawable", not isinstance(data, mcrfpy.Drawable))
|
||||
|
||||
|
||||
def case_map_with_no_view():
|
||||
"""A GridData with no view at all: mutable, steppable, queryable, pathable.
|
||||
|
||||
This is what the old `static_cast<UIGrid*>(this)` in GridData::markDirty made
|
||||
impossible -- a standalone GridData would have been undefined behavior on the
|
||||
first cell write.
|
||||
"""
|
||||
data = mcrfpy.GridData(grid_size=(12, 12))
|
||||
check("2a: standalone GridData constructs", data.grid_w == 12 and data.grid_h == 12)
|
||||
|
||||
for x in range(12):
|
||||
for y in range(12):
|
||||
data.at(x, y).walkable = True
|
||||
data.at(x, y).transparent = True
|
||||
check("2b: cells are writable with no view attached", data.at(5, 5).walkable)
|
||||
|
||||
e = mcrfpy.Entity((1, 1), grid=data)
|
||||
check("2c: an entity can live on a viewless map", len(data.entities) == 1)
|
||||
check("2d: entity.grid is that map", e.grid is data)
|
||||
|
||||
data.step(1)
|
||||
check("2e: a viewless map can be stepped", True)
|
||||
|
||||
data.compute_fov((5, 5), radius=4)
|
||||
check("2f: a viewless map computes FOV", data.is_in_fov(5, 6))
|
||||
|
||||
path = data.find_path((0, 0), (11, 11))
|
||||
check("2g: a viewless map is pathable", path is not None and len(list(path)) > 0)
|
||||
|
||||
|
||||
def case_two_views_one_map():
|
||||
"""N cameras, one map: independent cameras, shared entities and cells."""
|
||||
data = mcrfpy.GridData(grid_size=(20, 20))
|
||||
main = mcrfpy.Grid(grid=data, pos=(0, 0), size=(200, 200))
|
||||
mini = mcrfpy.Grid(grid=data, pos=(220, 0), size=(100, 100))
|
||||
|
||||
check("3a: both views share one GridData",
|
||||
main.grid_data is data and mini.grid_data is data)
|
||||
|
||||
main.center_camera((5, 5))
|
||||
mini.center_camera((15, 15))
|
||||
check("3b: the two cameras pan independently",
|
||||
main.center_x != mini.center_x and main.center_y != mini.center_y)
|
||||
|
||||
main.zoom = 2.0
|
||||
check("3c: the two cameras zoom independently", mini.zoom == 1.0)
|
||||
|
||||
mcrfpy.Entity((3, 3), grid=main)
|
||||
check("3d: an entity added through one view is on the shared map",
|
||||
len(main.entities) == 1 and len(mini.entities) == 1 and len(data.entities) == 1)
|
||||
|
||||
data.at(4, 4).walkable = True
|
||||
check("3e: a cell written on the map is seen through both views",
|
||||
main.at(4, 4).walkable and mini.at(4, 4).walkable)
|
||||
|
||||
|
||||
def case_grid_is_gridview():
|
||||
"""mcrfpy.Grid IS mcrfpy.GridView -- one type object, two names, no subclassing."""
|
||||
check("4a: Grid is GridView (the same type object)", mcrfpy.Grid is mcrfpy.GridView)
|
||||
|
||||
g = mcrfpy.Grid(grid_size=(8, 8), pos=(0, 0), size=(80, 80))
|
||||
check("4b: isinstance(Grid(...), GridView)", isinstance(g, mcrfpy.GridView))
|
||||
check("4c: Grid() creates its own GridData",
|
||||
isinstance(g.grid_data, mcrfpy.GridData) and g.grid_data.grid_w == 8)
|
||||
|
||||
# grid= names an existing map; grid_size= describes a new one. Asking for both
|
||||
# is a contradiction, and silently ignoring one would hand back a view of a
|
||||
# different map than the caller asked for.
|
||||
try:
|
||||
mcrfpy.Grid(grid=g.grid_data, grid_size=(4, 4))
|
||||
check("4d: Grid(grid=..., grid_size=...) raises TypeError", False)
|
||||
except TypeError:
|
||||
check("4d: Grid(grid=..., grid_size=...) raises TypeError", True)
|
||||
|
||||
|
||||
def case_camera_writes_are_not_no_ops():
|
||||
"""#361's smoking gun: center_camera() and size= used to write to the ghost.
|
||||
|
||||
On master both of these silently did nothing to the rendered widget -- they
|
||||
resolved, through attribute delegation, to the internal UIGrid's copy of the
|
||||
camera, which no view reads. The assertions below fail on master.
|
||||
"""
|
||||
g = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(100, 100))
|
||||
|
||||
before = g.center_x
|
||||
g.center_camera((5.0, 5.0))
|
||||
check("5a: center_camera() moves THIS view's camera", g.center_x != before)
|
||||
|
||||
g.size = (300, 300)
|
||||
check("5b: size= resizes THIS view's widget",
|
||||
g.w == 300.0 and g.h == 300.0 and g.size.x == 300.0)
|
||||
|
||||
|
||||
def case_no_ghost_render():
|
||||
"""The ghost path is gone: only the view draws, and only where the view is.
|
||||
|
||||
Constructing a second view of the same map must change the frame; destroying
|
||||
the reference to a map must not (the view holds it alive).
|
||||
"""
|
||||
scene = mcrfpy.Scene("t361_render")
|
||||
scene.activate()
|
||||
|
||||
data = mcrfpy.GridData(grid_size=(6, 6))
|
||||
for x in range(6):
|
||||
for y in range(6):
|
||||
data.at(x, y).walkable = True
|
||||
|
||||
view = mcrfpy.Grid(grid=data, pos=(10, 10), size=(96, 96),
|
||||
fill_color=mcrfpy.Color(200, 30, 30))
|
||||
scene.children.append(view)
|
||||
a = shot()
|
||||
|
||||
# A second camera on the same map paints a second region of the screen.
|
||||
view2 = mcrfpy.Grid(grid=data, pos=(120, 10), size=(96, 96),
|
||||
fill_color=mcrfpy.Color(30, 200, 30))
|
||||
scene.children.append(view2)
|
||||
b = shot()
|
||||
check("6a: adding a second view of the same map changes the frame", a != b)
|
||||
|
||||
# Mutating the shared map repaints BOTH views.
|
||||
mcrfpy.Entity((2, 2), grid=data)
|
||||
c = shot()
|
||||
check("6b: mutating the shared map repaints the views", b != c)
|
||||
|
||||
# Idle frames are stable -- the #351 early-out still holds.
|
||||
d = shot()
|
||||
check("6c: idle frames are byte-identical", c == d)
|
||||
|
||||
|
||||
def run_tests():
|
||||
case_griddata_is_not_drawable()
|
||||
case_map_with_no_view()
|
||||
case_two_views_one_map()
|
||||
case_grid_is_gridview()
|
||||
case_camera_writes_are_not_no_ops()
|
||||
case_no_ghost_render()
|
||||
return all(ok for _, ok in _results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
ok = run_tests()
|
||||
print("PASS" if ok else "FAIL")
|
||||
sys.exit(0 if ok else 1)
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print("FAIL")
|
||||
sys.exit(1)
|
||||
140
tests/regression/issue_363_hover_exit_on_window_leave_test.py
Normal file
140
tests/regression/issue_363_hover_exit_on_window_leave_test.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test for #363: cursor leaving the window never fired exit callbacks.
|
||||
|
||||
Hover was driven exclusively by sf::Event::MouseMoved -> PyScene::do_mouse_hover.
|
||||
Nothing handled sf::Event::MouseLeft, so when the cursor left the window while over
|
||||
a drawable:
|
||||
|
||||
- no further MouseMoved arrived,
|
||||
- on_exit / on_cell_exit never fired for whatever was hovered, and
|
||||
- grid.hovered_cell stayed stuck reporting a cell the mouse was nowhere near.
|
||||
|
||||
A hover-driven affordance (tile highlight, tooltip, range preview) got stranded in
|
||||
its hovered visual state until the player re-entered the window AND crossed a
|
||||
boundary. hovered_cell was a lying read.
|
||||
|
||||
The fix routes MouseLeft to PyScene::do_mouse_leave(), which re-runs the ordinary
|
||||
hover walk with hit_allowed=false at the root -- so every drawable that thinks it is
|
||||
hovered gets its normal exit path, and no drawable can enter. Exit callbacks receive
|
||||
the last known cursor position (the same convention the DOM's mouseleave uses).
|
||||
|
||||
automation.mouseLeave() injects the event; there was no way to simulate it before.
|
||||
"""
|
||||
import sys
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
|
||||
CELL = 16
|
||||
FAILURES = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
if not cond:
|
||||
print(f"FAIL: {msg}", file=sys.stderr)
|
||||
FAILURES.append(msg)
|
||||
|
||||
|
||||
def screen_of_cell(grid, cx, cy):
|
||||
"""Screen coords of the center of cell (cx, cy) under the view's camera."""
|
||||
left_sp = int(grid.center_x - (grid.w / 2.0))
|
||||
top_sp = int(grid.center_y - (grid.h / 2.0))
|
||||
return (grid.x + ((cx + 0.5) * CELL - left_sp),
|
||||
grid.y + ((cy + 0.5) * CELL - top_sp))
|
||||
|
||||
|
||||
def case_grid_cell_exit_fires_on_leave():
|
||||
scene = mcrfpy.Scene("leave_grid")
|
||||
scene.activate()
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
|
||||
scene.children.append(grid)
|
||||
|
||||
enters, exits = [], []
|
||||
grid.on_cell_enter = lambda pos: enters.append((pos.x, pos.y))
|
||||
grid.on_cell_exit = lambda pos: exits.append((pos.x, pos.y))
|
||||
|
||||
automation.moveTo(screen_of_cell(grid, 2, 2))
|
||||
check(enters == [(2, 2)], f"moving onto cell (2,2) should fire on_cell_enter; got {enters}")
|
||||
check(grid.hovered_cell == (2, 2),
|
||||
f"hovered_cell should be (2, 2) while hovering; got {grid.hovered_cell}")
|
||||
check(exits == [], f"nothing should have exited yet; got {exits}")
|
||||
|
||||
# The cursor leaves the window without any further MouseMoved.
|
||||
automation.mouseLeave()
|
||||
|
||||
check(exits == [(2, 2)],
|
||||
f"leaving the window must fire on_cell_exit for the hovered cell; got {exits}")
|
||||
check(grid.hovered_cell is None,
|
||||
f"hovered_cell must be None once the cursor has left the window; "
|
||||
f"got {grid.hovered_cell}")
|
||||
check(enters == [(2, 2)],
|
||||
f"leaving the window must not fire any on_cell_enter; got {enters}")
|
||||
|
||||
|
||||
def case_frame_on_exit_fires_on_leave():
|
||||
"""#363 is an input-layer gap: Frame.on_exit was stranded the same way."""
|
||||
scene = mcrfpy.Scene("leave_frame")
|
||||
scene.activate()
|
||||
frame = mcrfpy.Frame(pos=(50, 50), size=(100, 100))
|
||||
scene.children.append(frame)
|
||||
|
||||
entered, exited = [], []
|
||||
frame.on_enter = lambda pos: entered.append((pos.x, pos.y))
|
||||
frame.on_exit = lambda pos: exited.append((pos.x, pos.y))
|
||||
|
||||
automation.moveTo((100, 100))
|
||||
check(len(entered) == 1, f"moving into the frame should fire on_enter; got {entered}")
|
||||
check(exited == [], f"nothing should have exited yet; got {exited}")
|
||||
|
||||
automation.mouseLeave()
|
||||
check(len(exited) == 1,
|
||||
f"leaving the window must fire the frame's on_exit; got {exited}")
|
||||
|
||||
# A second leave is a no-op: nothing is hovered any more.
|
||||
automation.mouseLeave()
|
||||
check(len(exited) == 1,
|
||||
f"a redundant leave must not fire on_exit twice; got {exited}")
|
||||
|
||||
|
||||
def case_reenter_after_leave():
|
||||
"""Hover state must be genuinely cleared, not just reported clear."""
|
||||
scene = mcrfpy.Scene("leave_reenter")
|
||||
scene.activate()
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
|
||||
scene.children.append(grid)
|
||||
|
||||
enters = []
|
||||
grid.on_cell_enter = lambda pos: enters.append((pos.x, pos.y))
|
||||
|
||||
target = screen_of_cell(grid, 3, 3)
|
||||
automation.moveTo(target)
|
||||
automation.mouseLeave()
|
||||
|
||||
# Re-entering onto the SAME cell must fire on_cell_enter again -- if the leave
|
||||
# had only faked the clear, the cell would compare equal and be swallowed.
|
||||
automation.moveTo(target)
|
||||
check(enters == [(3, 3), (3, 3)],
|
||||
f"re-entering the same cell after a leave must fire on_cell_enter again; "
|
||||
f"got {enters}")
|
||||
|
||||
|
||||
def main():
|
||||
case_grid_cell_exit_fires_on_leave()
|
||||
case_frame_on_exit_fires_on_leave()
|
||||
case_reenter_after_leave()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
if FAILURES:
|
||||
print(f"\n{len(FAILURES)} FAILURE(S)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"\nTEST CRASHED: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
171
tests/regression/issue_364_grid_children_on_view_test.py
Normal file
171
tests/regression/issue_364_grid_children_on_view_test.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""Regression test for #364 - grid children belong to the GridView, not the GridData.
|
||||
|
||||
Children (speech bubbles, markers, range indicators) are OVERLAYS painted on top of
|
||||
a grid through one camera. They are not world contents -- entities are. So they live
|
||||
on the view, not on the shared data:
|
||||
|
||||
* a child's parent is the Grid (i.e. the UIGridView), a real scene-graph drawable,
|
||||
so its dirty push reaches the view and every caching ancestor above it (#364);
|
||||
* two views over one GridData have independent children, and shared entities;
|
||||
* the GridData is not a drawable container at all, so it cannot be a parent.
|
||||
|
||||
The load-bearing case is the last one: a grid inside a Frame(cache_subtree=True).
|
||||
Before this change a child's parent was the internal _GridData, which is in no scene,
|
||||
so markCompositeDirty() dead-ended there and the Frame re-blitted a STALE composite.
|
||||
That is invisible in a bare scene (the view re-renders itself) and only shows up under
|
||||
a caching ancestor -- which is exactly what case 5 builds.
|
||||
"""
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import sys, os, tempfile
|
||||
|
||||
TMPDIR = tempfile.mkdtemp(prefix="mcrf364_")
|
||||
_counter = 0
|
||||
_results = []
|
||||
|
||||
|
||||
def check(label, ok):
|
||||
_results.append((label, bool(ok)))
|
||||
print(" [%s] %s" % ("PASS" if ok else "FAIL", label))
|
||||
|
||||
|
||||
def shot():
|
||||
"""Render a frame to PNG and return its bytes (identical pixels -> identical
|
||||
bytes with the deterministic PNG encoder). Headless screenshots are synchronous
|
||||
(#153): render-then-capture, so no Timer dance is required."""
|
||||
global _counter
|
||||
path = os.path.join(TMPDIR, "s%d.png" % _counter)
|
||||
_counter += 1
|
||||
automation.screenshot(path)
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def case_child_parent_is_the_view():
|
||||
"""The child's parent is the Grid (UIGridView), not the internal _GridData.
|
||||
|
||||
Identity (`child.parent is grid`) is NOT asserted: `.parent` allocates a fresh
|
||||
wrapper on every read, so `is` fails for Frames too -- even `kid.parent is
|
||||
kid.parent` is False. That is a pre-existing engine-wide gap, not this bug, and
|
||||
it is filed separately. Here we assert the parent's TYPE and identify the view by
|
||||
name, which is what #364 actually turns on.
|
||||
"""
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(200, 200), name="the_view")
|
||||
bubble = mcrfpy.Frame(pos=(32, 32), size=(20, 20))
|
||||
grid.children.append(bubble)
|
||||
|
||||
check("1a: child.parent is the grid view", bubble.parent.name == "the_view")
|
||||
# #361: mcrfpy.Grid IS mcrfpy.GridView (one type object, two names), and the
|
||||
# canonical tp_name is GridView.
|
||||
check("1b: child.parent is a Grid, not a GridData",
|
||||
isinstance(bubble.parent, mcrfpy.Grid))
|
||||
|
||||
|
||||
def case_children_are_per_view_entities_are_shared():
|
||||
"""Two views over one GridData: children are per-view, entities are shared."""
|
||||
v1 = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(200, 200), name="v1")
|
||||
v2 = mcrfpy.Grid(grid=v1.grid_data, pos=(220, 0), size=(200, 200), name="v2")
|
||||
|
||||
check("2a: both views share one GridData", v1.grid_data is v2.grid_data)
|
||||
|
||||
marker = mcrfpy.Frame(pos=(32, 32), size=(20, 20))
|
||||
v1.children.append(marker)
|
||||
check("2b: child appended to v1 is in v1.children", len(v1.children) == 1)
|
||||
check("2c: child appended to v1 is NOT in v2.children", len(v2.children) == 0)
|
||||
check("2d: the child's parent is v1, not v2", marker.parent.name == "v1")
|
||||
|
||||
v2.children.append(mcrfpy.Frame(pos=(64, 64), size=(20, 20)))
|
||||
check("2e: v2 keeps its own children", len(v2.children) == 1 and len(v1.children) == 1)
|
||||
|
||||
# Entities are world contents: both cameras must see the same ones.
|
||||
mcrfpy.Entity((3, 3), grid=v1)
|
||||
check("2f: entity added through v1 is visible through v2",
|
||||
len(v1.entities) == 1 and len(v2.entities) == 1)
|
||||
|
||||
|
||||
def case_griddata_is_not_a_parent():
|
||||
"""GridData holds no drawables, so it cannot be assigned as a parent."""
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(200, 200))
|
||||
orphan = mcrfpy.Frame(pos=(0, 0), size=(10, 10))
|
||||
try:
|
||||
orphan.parent = grid.grid_data
|
||||
check("3a: assigning a GridData as parent raises TypeError", False)
|
||||
except TypeError:
|
||||
check("3a: assigning a GridData as parent raises TypeError", True)
|
||||
|
||||
check("3b: GridData exposes no children collection",
|
||||
not hasattr(grid.grid_data, "children"))
|
||||
|
||||
|
||||
def case_parent_none_removes_from_the_view():
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(200, 200))
|
||||
child = mcrfpy.Frame(pos=(32, 32), size=(20, 20))
|
||||
grid.children.append(child)
|
||||
check("4a: appended", len(grid.children) == 1)
|
||||
|
||||
child.parent = None
|
||||
check("4b: parent=None removes the child from the view", len(grid.children) == 0)
|
||||
check("4c: the detached child has no parent", child.parent is None)
|
||||
|
||||
|
||||
def case_child_mutation_invalidates_a_caching_ancestor():
|
||||
"""#364 proper: a grid child's dirty push must reach a Frame(cache_subtree=True).
|
||||
|
||||
Pre-fix the child's parent was the internal _GridData -- in no scene, with no
|
||||
parent of its own -- so markCompositeDirty() stopped there, the Frame stayed
|
||||
clean, and it re-blitted its cached (stale) composite. The screenshots below
|
||||
would come back byte-identical even though the child had moved.
|
||||
"""
|
||||
scene = mcrfpy.Scene("t364")
|
||||
scene.activate()
|
||||
|
||||
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300),
|
||||
fill_color=mcrfpy.Color(20, 20, 20),
|
||||
cache_subtree=True)
|
||||
scene.children.append(cache)
|
||||
|
||||
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(280, 280))
|
||||
cache.children.append(grid)
|
||||
|
||||
bubble = mcrfpy.Frame(pos=(32, 32), size=(48, 48),
|
||||
fill_color=mcrfpy.Color(255, 0, 0))
|
||||
grid.children.append(bubble)
|
||||
|
||||
a = shot()
|
||||
|
||||
bubble.x = 128.0
|
||||
b = shot()
|
||||
check("5a: moving a grid child re-composites the caching ancestor", a != b)
|
||||
|
||||
bubble.fill_color = mcrfpy.Color(0, 255, 0)
|
||||
c = shot()
|
||||
check("5b: recoloring a grid child re-composites the caching ancestor", b != c)
|
||||
|
||||
grid.children.remove(bubble)
|
||||
d = shot()
|
||||
check("5c: removing a grid child re-composites the caching ancestor", c != d)
|
||||
|
||||
# And the skip path itself stays honest: no mutation -> byte-identical frames.
|
||||
e = shot()
|
||||
check("5d: idle frames are stable", d == e)
|
||||
|
||||
|
||||
def run_tests():
|
||||
case_child_parent_is_the_view()
|
||||
case_children_are_per_view_entities_are_shared()
|
||||
case_griddata_is_not_a_parent()
|
||||
case_parent_none_removes_from_the_view()
|
||||
case_child_mutation_invalidates_a_caching_ancestor()
|
||||
return all(ok for _, ok in _results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
ok = run_tests()
|
||||
print("PASS" if ok else "FAIL")
|
||||
sys.exit(0 if ok else 1)
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print("FAIL")
|
||||
sys.exit(1)
|
||||
121
tests/regression/issue_365_stale_clicked_cell_test.py
Normal file
121
tests/regression/issue_365_stale_clicked_cell_test.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test for #365: stale last_clicked_cell dispatched for a dataless view.
|
||||
|
||||
#355 made UIGridView::last_clicked_cell the channel by which click_at hands the
|
||||
resolved cell to dispatchCellClick (the point cannot be recomputed later --
|
||||
click_at works in parent-local space, PyScene only has global coords).
|
||||
|
||||
Two halves let a stale cell survive into a later dispatch:
|
||||
|
||||
1. click_at stashed the cell (step 3) even when it went on to return nullptr
|
||||
because the view had no handlers. Nothing consumed that stash --
|
||||
dispatchCellClick only runs on the drawable PyScene got back, and it got
|
||||
nullptr. The cell sat there indefinitely.
|
||||
2. click_at's early return for the no-grid_data case returns `this` (when
|
||||
click_callable || is_python_subclass) WITHOUT clearing last_clicked_cell.
|
||||
|
||||
So: click a cell on a grid with no handlers, then enable interactivity and drop
|
||||
grid_data, then click the (now cell-less) view -- and on_cell_click fired with a
|
||||
cell from a grid that no longer exists. A wrong-data dispatch, not a missing one.
|
||||
|
||||
NOTE: the issue as filed described this as "click a cell, set grid = None, click
|
||||
again," which does NOT reproduce -- dispatchCellClick *consumes*
|
||||
last_clicked_cell (sets it to nullopt after reading), so a cell that was actually
|
||||
dispatched cannot survive. The unconsumed no-handler stash is the real path.
|
||||
"""
|
||||
import sys
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
|
||||
CELL = 16
|
||||
FAILURES = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
if not cond:
|
||||
print(f"FAIL: {msg}", file=sys.stderr)
|
||||
FAILURES.append(msg)
|
||||
|
||||
|
||||
def screen_of_cell(grid, cx, cy):
|
||||
"""Screen coords of the center of cell (cx, cy) under the view's camera."""
|
||||
left_sp = int(grid.center_x - (grid.w / 2.0))
|
||||
top_sp = int(grid.center_y - (grid.h / 2.0))
|
||||
return (grid.x + ((cx + 0.5) * CELL - left_sp),
|
||||
grid.y + ((cy + 0.5) * CELL - top_sp))
|
||||
|
||||
|
||||
def case_stale_survives_into_dataless_view():
|
||||
scene = mcrfpy.Scene("stale_cell")
|
||||
scene.activate()
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
|
||||
scene.children.append(grid)
|
||||
target = screen_of_cell(grid, 2, 2)
|
||||
|
||||
# 1. Click with NO handlers: click_at resolves cell (2,2), stashes it, then
|
||||
# returns nullptr. PyScene has no target, so nothing consumes the stash.
|
||||
automation.click(target)
|
||||
|
||||
# 2. Enable interactivity, then drop the grid data. on_click is what makes
|
||||
# click_at return `this` on the !grid_data path at all.
|
||||
cell_clicks, plain_clicks = [], []
|
||||
grid.on_cell_click = lambda pos, button, action: cell_clicks.append((pos.x, pos.y))
|
||||
grid.on_click = lambda pos, button, action: plain_clicks.append((pos.x, pos.y))
|
||||
grid.grid_data = None # property is `grid_data`; the ctor kwarg is `grid`
|
||||
check(grid.grid_data is None, "grid_data should be None after assignment")
|
||||
|
||||
# 3. Click the cell-less view. It must still deliver on_click, but a grid with
|
||||
# no cells cannot produce a cell event.
|
||||
automation.click(target)
|
||||
check(len(plain_clicks) >= 1,
|
||||
f"the dataless view should still deliver on_click; got {plain_clicks}")
|
||||
check(cell_clicks == [],
|
||||
f"on_cell_click must NOT fire for a view with no grid data "
|
||||
f"(stale last_clicked_cell dispatched); got {cell_clicks}")
|
||||
|
||||
|
||||
def case_no_handler_click_leaves_no_stash():
|
||||
"""The stash itself must not outlive a click that had nowhere to dispatch."""
|
||||
scene = mcrfpy.Scene("stale_cell_2")
|
||||
scene.activate()
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
|
||||
scene.children.append(grid)
|
||||
|
||||
# Click cell (1,1) with no handlers -> nothing may be retained.
|
||||
automation.click(screen_of_cell(grid, 1, 1))
|
||||
|
||||
# Now attach a cell handler and click a spot that resolves to NO cell (well
|
||||
# outside the grid's cells but still inside the widget box, reached by
|
||||
# shrinking the camera so the 5x5 grid does not fill the 200x200 widget).
|
||||
cell_clicks = []
|
||||
grid.on_cell_click = lambda pos, button, action: cell_clicks.append((pos.x, pos.y))
|
||||
grid.zoom = 0.5 # 5*16*0.5 = 40px of content inside a 200px widget
|
||||
grid.center = (2.5 * CELL, 2.5 * CELL)
|
||||
|
||||
# Bottom-right corner of the widget: inside the box, outside every cell.
|
||||
automation.click((grid.x + grid.w - 3, grid.y + grid.h - 3))
|
||||
check(cell_clicks == [],
|
||||
f"a click inside the widget but on no cell must not fire on_cell_click "
|
||||
f"(stale stash from the earlier handler-less click); got {cell_clicks}")
|
||||
|
||||
|
||||
def main():
|
||||
case_stale_survives_into_dataless_view()
|
||||
case_no_handler_click_leaves_no_stash()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
if FAILURES:
|
||||
print(f"\n{len(FAILURES)} FAILURE(S)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"\nTEST CRASHED: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
66
tests/regression/issue_366_collection_repr_grid_test.py
Normal file
66
tests/regression/issue_366_collection_repr_grid_test.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test for #366: UICollection.repr counted grids as "UIDrawable".
|
||||
|
||||
UICollection::repr tallies collection contents by derived_type() and routed
|
||||
PyObjectsEnum::UIGRIDVIEW into other_count, while the "Grid" bucket was fed only
|
||||
by UIGRID -- the internal _GridData type, which no scene graph ever contains
|
||||
since #252. So repr(scene.children) on a scene holding grids reported
|
||||
"<UICollection (3 objects: 1 Frame, 2 UIDrawables)>" and never named the grids.
|
||||
|
||||
Last surviving instance of the bug class #355 eliminated (a caller switching on
|
||||
derived_type() to detect grid-ness, silently dead since the type it names stopped
|
||||
appearing in scene graphs). Now dispatched on the asGridData() virtual instead.
|
||||
"""
|
||||
import sys
|
||||
import mcrfpy
|
||||
|
||||
FAILURES = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
if not cond:
|
||||
print(f"FAIL: {msg}", file=sys.stderr)
|
||||
FAILURES.append(msg)
|
||||
|
||||
|
||||
def main():
|
||||
scene = mcrfpy.Scene("repr_grid")
|
||||
ui = scene.children
|
||||
|
||||
ui.append(mcrfpy.Frame(pos=(0, 0), size=(10, 10)))
|
||||
ui.append(mcrfpy.Grid(grid_size=(4, 4), pos=(20, 20), size=(64, 64)))
|
||||
ui.append(mcrfpy.Grid(grid_size=(4, 4), pos=(100, 20), size=(64, 64)))
|
||||
|
||||
r = repr(ui)
|
||||
print(f"repr: {r}")
|
||||
|
||||
check("2 Grids" in r,
|
||||
f"repr should name the 2 Grids (grids counted as 'other'?); got {r}")
|
||||
check("1 Frame" in r, f"repr should still name the Frame; got {r}")
|
||||
check("UIDrawable" not in r,
|
||||
f"no element here is an unclassified UIDrawable; got {r}")
|
||||
|
||||
# A single grid uses the singular form.
|
||||
scene2 = mcrfpy.Scene("repr_grid_one")
|
||||
scene2.children.append(mcrfpy.Grid(grid_size=(4, 4), pos=(0, 0), size=(64, 64)))
|
||||
r2 = repr(scene2.children)
|
||||
print(f"repr: {r2}")
|
||||
check("1 Grid" in r2 and "Grids" not in r2,
|
||||
f"a single grid should read '1 Grid'; got {r2}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
if FAILURES:
|
||||
print(f"\n{len(FAILURES)} FAILURE(S)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"\nTEST CRASHED: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
139
tests/regression/issue_367_hover_exit_on_focus_loss_test.py
Normal file
139
tests/regression/issue_367_hover_exit_on_focus_loss_test.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test for #367: losing window focus stranded hover state (#363's sibling).
|
||||
|
||||
#363 fixed the case where the cursor physically LEAVES the window. But alt-tabbing
|
||||
away strands hover in exactly the same way and for exactly the same reason: while the
|
||||
window is unfocused no MouseMoved arrives, so the hover walk never runs, so whatever
|
||||
was hovered when focus was lost stays hovered. on_exit / on_cell_exit never fire and
|
||||
grid.hovered_cell keeps reporting a cell the user is no longer pointing at -- while
|
||||
the game sits in the background.
|
||||
|
||||
sf::Event::LostFocus was already being produced by every backend (SDL2Renderer even
|
||||
translates SDL_WINDOWEVENT_FOCUS_LOST into it) and, like MouseLeft before #363, had
|
||||
NOBODY LISTENING.
|
||||
|
||||
The fix routes LostFocus to the same PyScene::do_mouse_leave() that #363 added. That
|
||||
is the honest response: once unfocused, the engine genuinely does not know where the
|
||||
pointer is, and "hovering nothing" is the only truthful state. Hover is restored by
|
||||
the next MouseMoved after focus returns.
|
||||
|
||||
automation.loseFocus() injects the event.
|
||||
"""
|
||||
import sys
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
|
||||
CELL = 16
|
||||
FAILURES = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
if not cond:
|
||||
print(f"FAIL: {msg}", file=sys.stderr)
|
||||
FAILURES.append(msg)
|
||||
|
||||
|
||||
def screen_of_cell(grid, cx, cy):
|
||||
"""Screen coords of the center of cell (cx, cy) under the view's camera."""
|
||||
left_sp = int(grid.center_x - (grid.w / 2.0))
|
||||
top_sp = int(grid.center_y - (grid.h / 2.0))
|
||||
return (grid.x + ((cx + 0.5) * CELL - left_sp),
|
||||
grid.y + ((cy + 0.5) * CELL - top_sp))
|
||||
|
||||
|
||||
def case_grid_cell_exit_fires_on_focus_loss():
|
||||
scene = mcrfpy.Scene("blur_grid")
|
||||
scene.activate()
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
|
||||
scene.children.append(grid)
|
||||
|
||||
enters, exits = [], []
|
||||
grid.on_cell_enter = lambda pos: enters.append((pos.x, pos.y))
|
||||
grid.on_cell_exit = lambda pos: exits.append((pos.x, pos.y))
|
||||
|
||||
automation.moveTo(screen_of_cell(grid, 2, 2))
|
||||
check(enters == [(2, 2)], f"moving onto cell (2,2) should fire on_cell_enter; got {enters}")
|
||||
check(grid.hovered_cell == (2, 2),
|
||||
f"hovered_cell should be (2, 2) while hovering; got {grid.hovered_cell}")
|
||||
|
||||
# The player alt-tabs away. The cursor may still be physically over the window,
|
||||
# but no further MouseMoved will be delivered to us.
|
||||
automation.loseFocus()
|
||||
|
||||
check(exits == [(2, 2)],
|
||||
f"losing focus must fire on_cell_exit for the hovered cell; got {exits}")
|
||||
check(grid.hovered_cell is None,
|
||||
f"hovered_cell must be None while the window is unfocused; got {grid.hovered_cell}")
|
||||
check(enters == [(2, 2)],
|
||||
f"losing focus must not fire any on_cell_enter; got {enters}")
|
||||
|
||||
|
||||
def case_frame_on_exit_fires_on_focus_loss():
|
||||
"""Like #363, this is an input-layer gap, not a grid one: Frame.on_exit too."""
|
||||
scene = mcrfpy.Scene("blur_frame")
|
||||
scene.activate()
|
||||
frame = mcrfpy.Frame(pos=(50, 50), size=(100, 100))
|
||||
scene.children.append(frame)
|
||||
|
||||
entered, exited = [], []
|
||||
frame.on_enter = lambda pos: entered.append((pos.x, pos.y))
|
||||
frame.on_exit = lambda pos: exited.append((pos.x, pos.y))
|
||||
|
||||
automation.moveTo((100, 100))
|
||||
check(len(entered) == 1, f"moving into the frame should fire on_enter; got {entered}")
|
||||
check(exited == [], f"nothing should have exited yet; got {exited}")
|
||||
|
||||
automation.loseFocus()
|
||||
check(len(exited) == 1, f"losing focus must fire the frame's on_exit; got {exited}")
|
||||
|
||||
# Already unfocused: a second blur has nothing left to exit.
|
||||
automation.loseFocus()
|
||||
check(len(exited) == 1, f"a redundant focus loss must not fire on_exit twice; got {exited}")
|
||||
|
||||
|
||||
def case_reenter_after_focus_loss():
|
||||
"""Hover must be genuinely cleared, not merely reported clear.
|
||||
|
||||
Re-entering the SAME cell after focus returns has to fire on_cell_enter again. If
|
||||
loseFocus had only nulled the reported value while leaving the internal hovered
|
||||
cell set, the incoming cell would compare equal and the enter would be swallowed.
|
||||
"""
|
||||
scene = mcrfpy.Scene("blur_reenter")
|
||||
scene.activate()
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
|
||||
scene.children.append(grid)
|
||||
|
||||
enters = []
|
||||
grid.on_cell_enter = lambda pos: enters.append((pos.x, pos.y))
|
||||
|
||||
target = screen_of_cell(grid, 3, 3)
|
||||
automation.moveTo(target)
|
||||
automation.loseFocus()
|
||||
|
||||
automation.moveTo(target)
|
||||
check(enters == [(3, 3), (3, 3)],
|
||||
f"pointing at the same cell after focus returns must fire on_cell_enter again; "
|
||||
f"got {enters}")
|
||||
|
||||
|
||||
def main():
|
||||
case_grid_cell_exit_fires_on_focus_loss()
|
||||
case_frame_on_exit_fires_on_focus_loss()
|
||||
case_reenter_after_focus_loss()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
if FAILURES:
|
||||
print(f"\n{len(FAILURES)} FAILURE(S)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"\nTEST CRASHED: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
176
tests/regression/issue_368_cache_subtree_content_dirty_test.py
Normal file
176
tests/regression/issue_368_cache_subtree_content_dirty_test.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Regression test for #368 - Frame(cache_subtree=True) froze its subtree's content.
|
||||
|
||||
`UIDrawable::markContentDirty()` guarded its walk up the parent chain with
|
||||
|
||||
bool was_dirty = render_dirty;
|
||||
...
|
||||
if (p && (!was_dirty || !p->render_dirty)) p->markContentDirty();
|
||||
|
||||
That guard is sound only if `render_dirty` is reliably cleared once a drawable has
|
||||
been drawn. It was not: `clearDirty()` had exactly ONE call site in the entire
|
||||
engine (UIGridView, added by #364). UIFrame/UICaption/UISprite/UILine/UICircle/UIArc
|
||||
never cleared it, so `render_dirty` was a WRITE-ONCE LATCH -- true from the first
|
||||
mutation until the object died.
|
||||
|
||||
With the flag permanently true, `was_dirty` was permanently true, the parent's
|
||||
`render_dirty` was permanently true, and the guard was permanently false. Content
|
||||
invalidation stopped propagating entirely, so a caching ancestor kept re-blitting a
|
||||
stale composite: text never updated, colors never changed, sprites never changed.
|
||||
|
||||
Only MOVEMENT survived, because `x`/`y` route through `markCompositeDirty()` -- a
|
||||
different function whose walk was already unconditional. That asymmetry is what made
|
||||
the bug look intermittent rather than total, and it is why every case below mutates
|
||||
CONTENT (color, text, sprite index) rather than position.
|
||||
|
||||
The fix makes markContentDirty's walk unconditional too, matching markCompositeDirty.
|
||||
Correctness then does not depend on any drawable remembering to clear a flag.
|
||||
"""
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import sys, os, tempfile
|
||||
|
||||
TMPDIR = tempfile.mkdtemp(prefix="mcrf368_")
|
||||
_counter = 0
|
||||
_results = []
|
||||
|
||||
|
||||
def check(label, ok):
|
||||
_results.append((label, bool(ok)))
|
||||
print(" [%s] %s" % ("PASS" if ok else "FAIL", label))
|
||||
|
||||
|
||||
def shot():
|
||||
global _counter
|
||||
path = os.path.join(TMPDIR, "s%d.png" % _counter)
|
||||
_counter += 1
|
||||
automation.screenshot(path)
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def case_recolor_under_cache():
|
||||
"""Every recolor of a nested child must reach the caching ancestor.
|
||||
|
||||
Not just the first: on master ZERO of these propagated, because render_dirty had
|
||||
already latched by the time the first one ran.
|
||||
"""
|
||||
scene = mcrfpy.Scene("t368_recolor")
|
||||
scene.activate()
|
||||
|
||||
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300), cache_subtree=True)
|
||||
scene.children.append(cache)
|
||||
inner = mcrfpy.Frame(pos=(0, 0), size=(280, 280))
|
||||
cache.children.append(inner)
|
||||
kid = mcrfpy.Frame(pos=(32, 32), size=(48, 48), fill_color=mcrfpy.Color(255, 0, 0))
|
||||
inner.children.append(kid)
|
||||
|
||||
prev = shot()
|
||||
for i, rgb in enumerate([(0, 255, 0), (0, 0, 255), (255, 255, 0), (255, 0, 255)]):
|
||||
kid.fill_color = mcrfpy.Color(*rgb)
|
||||
cur = shot()
|
||||
check("1.%d: recolor -> %s repaints through the cache" % (i + 1, rgb), cur != prev)
|
||||
prev = cur
|
||||
|
||||
|
||||
def case_caption_text_under_cache():
|
||||
"""The most visible form of this bug: a label inside a cached panel never updates."""
|
||||
scene = mcrfpy.Scene("t368_text")
|
||||
scene.activate()
|
||||
|
||||
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300), cache_subtree=True)
|
||||
scene.children.append(cache)
|
||||
inner = mcrfpy.Frame(pos=(0, 0), size=(280, 280))
|
||||
cache.children.append(inner)
|
||||
cap = mcrfpy.Caption(text="one", pos=(20, 20))
|
||||
inner.children.append(cap)
|
||||
|
||||
prev = shot()
|
||||
for i, txt in enumerate(["two", "three", "four"]):
|
||||
cap.text = txt
|
||||
cur = shot()
|
||||
check("2.%d: caption text -> %r repaints through the cache" % (i + 1, txt), cur != prev)
|
||||
prev = cur
|
||||
|
||||
|
||||
def case_deep_nesting():
|
||||
"""The walk must reach the cache from arbitrary depth, not just one level down."""
|
||||
scene = mcrfpy.Scene("t368_deep")
|
||||
scene.activate()
|
||||
|
||||
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300), cache_subtree=True)
|
||||
scene.children.append(cache)
|
||||
|
||||
node = cache
|
||||
for d in range(4):
|
||||
nxt = mcrfpy.Frame(pos=(5, 5), size=(260 - d * 10, 260 - d * 10))
|
||||
node.children.append(nxt)
|
||||
node = nxt
|
||||
|
||||
leaf = mcrfpy.Frame(pos=(10, 10), size=(40, 40), fill_color=mcrfpy.Color(255, 0, 0))
|
||||
node.children.append(leaf)
|
||||
|
||||
prev = shot()
|
||||
for i, rgb in enumerate([(0, 255, 0), (0, 0, 255), (255, 255, 0)]):
|
||||
leaf.fill_color = mcrfpy.Color(*rgb)
|
||||
cur = shot()
|
||||
check("3.%d: depth-5 leaf recolor reaches the cache" % (i + 1), cur != prev)
|
||||
prev = cur
|
||||
|
||||
|
||||
def case_idle_is_still_cheap():
|
||||
"""The fix must not defeat the cache: an untouched frame stays byte-identical.
|
||||
|
||||
If markContentDirty had been "fixed" by marking everything dirty every frame, the
|
||||
tests above would pass and the cache would be worthless. This is the counterweight.
|
||||
"""
|
||||
scene = mcrfpy.Scene("t368_idle")
|
||||
scene.activate()
|
||||
|
||||
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300), cache_subtree=True)
|
||||
scene.children.append(cache)
|
||||
kid = mcrfpy.Frame(pos=(32, 32), size=(48, 48), fill_color=mcrfpy.Color(255, 0, 0))
|
||||
cache.children.append(kid)
|
||||
|
||||
a = shot()
|
||||
b = shot()
|
||||
c = shot()
|
||||
check("4a: idle frames under a cache are byte-identical", a == b == c)
|
||||
|
||||
|
||||
def case_uncached_still_works():
|
||||
"""Sanity: the ordinary (uncached) path must be unaffected."""
|
||||
scene = mcrfpy.Scene("t368_plain")
|
||||
scene.activate()
|
||||
|
||||
holder = mcrfpy.Frame(pos=(10, 10), size=(300, 300))
|
||||
scene.children.append(holder)
|
||||
kid = mcrfpy.Frame(pos=(32, 32), size=(48, 48), fill_color=mcrfpy.Color(255, 0, 0))
|
||||
holder.children.append(kid)
|
||||
|
||||
prev = shot()
|
||||
for i, rgb in enumerate([(0, 255, 0), (0, 0, 255)]):
|
||||
kid.fill_color = mcrfpy.Color(*rgb)
|
||||
cur = shot()
|
||||
check("5.%d: recolor repaints without a cache" % (i + 1), cur != prev)
|
||||
prev = cur
|
||||
|
||||
|
||||
def run_tests():
|
||||
case_recolor_under_cache()
|
||||
case_caption_text_under_cache()
|
||||
case_deep_nesting()
|
||||
case_idle_is_still_cheap()
|
||||
case_uncached_still_works()
|
||||
return all(ok for _, ok in _results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
ok = run_tests()
|
||||
print("PASS" if ok else "FAIL")
|
||||
sys.exit(0 if ok else 1)
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print("FAIL")
|
||||
sys.exit(1)
|
||||
|
|
@ -244,7 +244,7 @@ submodule automation
|
|||
DONE = 0
|
||||
TARGET = 2
|
||||
|
||||
=== FROZEN TYPES (30) ===
|
||||
=== FROZEN TYPES (31) ===
|
||||
[AStarPath]
|
||||
prop destination: Vector (ro)
|
||||
prop origin: Vector (ro)
|
||||
|
|
@ -540,6 +540,7 @@ submodule automation
|
|||
prop center: Vector (rw)
|
||||
prop center_x: float (rw)
|
||||
prop center_y: float (rw)
|
||||
prop children: UICollection (ro)
|
||||
prop fill_color: Color (rw)
|
||||
prop global_bounds: tuple (ro)
|
||||
prop global_position: Any (ro)
|
||||
|
|
@ -547,8 +548,12 @@ submodule automation
|
|||
prop h: float (rw)
|
||||
prop horiz_margin: float (rw)
|
||||
prop hovered: Any (ro)
|
||||
prop hovered_cell: tuple | None (ro)
|
||||
prop margin: float (rw)
|
||||
prop name: str (rw)
|
||||
prop on_cell_click: Callable | None (rw)
|
||||
prop on_cell_enter: Callable | None (rw)
|
||||
prop on_cell_exit: Callable | None (rw)
|
||||
prop on_click: Callable | None (rw)
|
||||
prop on_enter: Any (rw)
|
||||
prop on_exit: Any (rw)
|
||||
|
|
@ -556,10 +561,13 @@ submodule automation
|
|||
prop opacity: Any (rw)
|
||||
prop origin: Any (rw)
|
||||
prop parent: Any (rw)
|
||||
prop perspective: Entity | None (rw)
|
||||
prop perspective_enabled: bool (rw)
|
||||
prop pos: Vector (rw)
|
||||
prop rotate_with_camera: bool (rw)
|
||||
prop rotation: Any (rw)
|
||||
prop shader: Any (rw)
|
||||
prop size: Vector (rw)
|
||||
prop texture: Texture | None (ro)
|
||||
prop uniforms: Any (ro)
|
||||
prop vert_margin: float (rw)
|
||||
|
|
@ -570,9 +578,32 @@ submodule automation
|
|||
prop z_index: int (rw)
|
||||
prop zoom: float (rw)
|
||||
meth animate :: animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation
|
||||
meth center_camera :: center_camera(pos: tuple = None) -> None
|
||||
meth move :: move(dx, dy) or (delta) -> None
|
||||
meth realign :: realign() -> None
|
||||
meth resize :: resize(width, height) or (size) -> None
|
||||
[GridData]
|
||||
prop entities: EntityCollection (ro)
|
||||
prop fov: Any (rw)
|
||||
prop fov_radius: int (rw)
|
||||
prop grid_h: int (ro)
|
||||
prop grid_size: Vector (ro)
|
||||
prop grid_w: int (ro)
|
||||
prop layers: tuple (ro)
|
||||
prop texture: Texture | None (ro)
|
||||
meth add_layer :: add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer
|
||||
meth apply_ranges :: apply_ranges(source: HeightMap, ranges: list) -> GridData
|
||||
meth apply_threshold :: apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> GridData
|
||||
meth at :: at(x: int, y: int) -> GridPoint
|
||||
meth clear_dijkstra_maps :: clear_dijkstra_maps() -> None
|
||||
meth compute_fov :: compute_fov(pos, radius: int = 0, light_walls: bool = True, algorithm: FOV | int = FOV.BASIC) -> None
|
||||
meth entities_in_radius :: entities_in_radius(pos: tuple | Vector, radius: float) -> list
|
||||
meth find_path :: find_path(start, end, diagonal_cost: float = 1.41, collide: str = None, heuristic = None, weight: float = 1.0) -> AStarPath | None
|
||||
meth get_dijkstra_map :: get_dijkstra_map(root=None, diagonal_cost: float = 1.41, collide: str = None, roots=None) -> DijkstraMap
|
||||
meth is_in_fov :: is_in_fov(x: int, y: int) -> bool
|
||||
meth layer :: layer(name: str) -> ColorLayer | TileLayer | None
|
||||
meth remove_layer :: remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None
|
||||
meth step :: step(n: int = 1, turn_order: int = None) -> None
|
||||
[GridView]
|
||||
prop align: Any (rw)
|
||||
prop bounds: tuple (ro)
|
||||
|
|
@ -580,6 +611,7 @@ submodule automation
|
|||
prop center: Vector (rw)
|
||||
prop center_x: float (rw)
|
||||
prop center_y: float (rw)
|
||||
prop children: UICollection (ro)
|
||||
prop fill_color: Color (rw)
|
||||
prop global_bounds: tuple (ro)
|
||||
prop global_position: Any (ro)
|
||||
|
|
@ -587,8 +619,12 @@ submodule automation
|
|||
prop h: float (rw)
|
||||
prop horiz_margin: float (rw)
|
||||
prop hovered: Any (ro)
|
||||
prop hovered_cell: tuple | None (ro)
|
||||
prop margin: float (rw)
|
||||
prop name: str (rw)
|
||||
prop on_cell_click: Callable | None (rw)
|
||||
prop on_cell_enter: Callable | None (rw)
|
||||
prop on_cell_exit: Callable | None (rw)
|
||||
prop on_click: Callable | None (rw)
|
||||
prop on_enter: Any (rw)
|
||||
prop on_exit: Any (rw)
|
||||
|
|
@ -596,10 +632,13 @@ submodule automation
|
|||
prop opacity: Any (rw)
|
||||
prop origin: Any (rw)
|
||||
prop parent: Any (rw)
|
||||
prop perspective: Entity | None (rw)
|
||||
prop perspective_enabled: bool (rw)
|
||||
prop pos: Vector (rw)
|
||||
prop rotate_with_camera: bool (rw)
|
||||
prop rotation: Any (rw)
|
||||
prop shader: Any (rw)
|
||||
prop size: Vector (rw)
|
||||
prop texture: Texture | None (ro)
|
||||
prop uniforms: Any (ro)
|
||||
prop vert_margin: float (rw)
|
||||
|
|
@ -610,6 +649,7 @@ submodule automation
|
|||
prop z_index: int (rw)
|
||||
prop zoom: float (rw)
|
||||
meth animate :: animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation
|
||||
meth center_camera :: center_camera(pos: tuple = None) -> None
|
||||
meth move :: move(dx, dy) or (delta) -> None
|
||||
meth realign :: realign() -> None
|
||||
meth resize :: resize(width, height) or (size) -> None
|
||||
|
|
@ -1106,64 +1146,19 @@ submodule automation
|
|||
meth terrain_enum :: terrain_enum() -> IntEnum
|
||||
|
||||
=== INTERNAL TYPES (reached via live instances) ===
|
||||
[_GridData]
|
||||
prop align: Any (rw)
|
||||
prop bounds: tuple (ro)
|
||||
prop camera_rotation: float (rw)
|
||||
prop center: Vector (rw)
|
||||
prop center_x: float (rw)
|
||||
prop center_y: float (rw)
|
||||
prop children: UICollection (ro)
|
||||
[GridData]
|
||||
prop entities: EntityCollection (ro)
|
||||
prop fill_color: Color (rw)
|
||||
prop fov: Any (rw)
|
||||
prop fov_radius: int (rw)
|
||||
prop global_bounds: tuple (ro)
|
||||
prop global_position: Any (ro)
|
||||
prop grid_h: int (ro)
|
||||
prop grid_pos: Vector (rw)
|
||||
prop grid_size: Vector (ro)
|
||||
prop grid_w: int (ro)
|
||||
prop h: float (rw)
|
||||
prop horiz_margin: float (rw)
|
||||
prop hovered: Any (ro)
|
||||
prop hovered_cell: tuple | None (ro)
|
||||
prop layers: tuple (ro)
|
||||
prop margin: float (rw)
|
||||
prop name: str (rw)
|
||||
prop on_cell_click: Callable | None (rw)
|
||||
prop on_cell_enter: Callable | None (rw)
|
||||
prop on_cell_exit: Callable | None (rw)
|
||||
prop on_click: Any (rw)
|
||||
prop on_enter: Any (rw)
|
||||
prop on_exit: Any (rw)
|
||||
prop on_move: Any (rw)
|
||||
prop opacity: Any (rw)
|
||||
prop origin: Any (rw)
|
||||
prop parent: Any (rw)
|
||||
prop perspective: Entity | None (rw)
|
||||
prop perspective_enabled: bool (rw)
|
||||
prop pos: Vector (rw)
|
||||
prop rotate_with_camera: bool (rw)
|
||||
prop rotation: Any (rw)
|
||||
prop shader: Any (rw)
|
||||
prop size: Vector (rw)
|
||||
prop texture: Texture | None (ro)
|
||||
prop uniforms: Any (ro)
|
||||
prop vert_margin: float (rw)
|
||||
prop view: GridView | None (ro)
|
||||
prop visible: bool (rw)
|
||||
prop w: float (rw)
|
||||
prop x: float (rw)
|
||||
prop y: float (rw)
|
||||
prop z_index: Any (rw)
|
||||
prop zoom: float (rw)
|
||||
meth add_layer :: add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer
|
||||
meth animate :: animate(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace') -> Animation
|
||||
meth apply_ranges :: apply_ranges(source: HeightMap, ranges: list) -> Grid
|
||||
meth apply_threshold :: apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> Grid
|
||||
meth apply_ranges :: apply_ranges(source: HeightMap, ranges: list) -> GridData
|
||||
meth apply_threshold :: apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> GridData
|
||||
meth at :: at(x: int, y: int) -> GridPoint
|
||||
meth center_camera :: center_camera(pos: tuple = None) -> None
|
||||
meth clear_dijkstra_maps :: clear_dijkstra_maps() -> None
|
||||
meth compute_fov :: compute_fov(pos, radius: int = 0, light_walls: bool = True, algorithm: FOV | int = FOV.BASIC) -> None
|
||||
meth entities_in_radius :: entities_in_radius(pos: tuple | Vector, radius: float) -> list
|
||||
|
|
@ -1171,10 +1166,7 @@ submodule automation
|
|||
meth get_dijkstra_map :: get_dijkstra_map(root=None, diagonal_cost: float = 1.41, collide: str = None, roots=None) -> DijkstraMap
|
||||
meth is_in_fov :: is_in_fov(x: int, y: int) -> bool
|
||||
meth layer :: layer(name: str) -> ColorLayer | TileLayer | None
|
||||
meth move :: move(dx, dy) or (delta) -> None
|
||||
meth realign :: realign() -> None
|
||||
meth remove_layer :: remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None
|
||||
meth resize :: resize(width, height) or (size) -> None
|
||||
meth step :: step(n: int = 1, turn_order: int = None) -> None
|
||||
[GridPoint]
|
||||
prop entities: list (ro)
|
||||
|
|
@ -1213,8 +1205,10 @@ func dragTo :: dragTo(pos: tuple | list | Vector, duration: float = 0.0, button:
|
|||
func hotkey :: hotkey(*keys: str) -> None
|
||||
func keyDown :: keyDown(key: str) -> None
|
||||
func keyUp :: keyUp(key: str) -> None
|
||||
func loseFocus :: loseFocus() -> None
|
||||
func middleClick :: middleClick(pos: tuple | list | Vector | None = None) -> None
|
||||
func mouseDown :: mouseDown(pos: tuple | list | Vector | None = None, button: str = 'left') -> None
|
||||
func mouseLeave :: mouseLeave() -> None
|
||||
func mouseUp :: mouseUp(pos: tuple | list | Vector | None = None, button: str = 'left') -> None
|
||||
func moveRel :: moveRel(offset: tuple | list | Vector, duration: float = 0.0) -> None
|
||||
func moveTo :: moveTo(pos: tuple | list | Vector, duration: float = 0.0) -> None
|
||||
|
|
@ -1227,8 +1221,8 @@ func size :: size() -> Vector
|
|||
func tripleClick :: tripleClick(pos: tuple | list | Vector | None = None) -> None
|
||||
func typewrite :: typewrite(message: str, interval: float = 0.0) -> None
|
||||
|
||||
=== DELEGATION INTEGRITY (Grid instance -> _GridData) ===
|
||||
delegated-resolved: 69/69
|
||||
=== DELEGATION INTEGRITY (Grid instance -> GridData) ===
|
||||
delegated-resolved: 21/21
|
||||
|
||||
=== WRITABILITY PROBES (#313-touched properties) ===
|
||||
Entity.grid: writable
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Intentional re-baseline (after a deliberate, reviewed API change):
|
|||
|
||||
Design notes (see docs/plan-313-314.md):
|
||||
* mcrfpy.Grid IS mcrfpy.GridView and delegates its real API to an internal
|
||||
_GridData via tp_getattro -- that surface is INVISIBLE to dir(). We therefore
|
||||
GridData via tp_getattro -- that surface is INVISIBLE to dir(). We therefore
|
||||
walk grid.grid_data explicitly AND probe delegation integrity on a live instance.
|
||||
* Singleton/constant VALUES are never captured (build/platform/object dependent);
|
||||
only their type name is recorded.
|
||||
|
|
@ -60,7 +60,7 @@ def _internal_type_probes():
|
|||
g = mcrfpy.Grid(grid_size=(3, 3))
|
||||
gd = getattr(g, "grid_data", None)
|
||||
if gd is not None:
|
||||
probes.append(("_GridData", type(gd)))
|
||||
probes.append(("GridData", type(gd)))
|
||||
try:
|
||||
probes.append(("GridPoint", type(g.at(0, 0))))
|
||||
except Exception:
|
||||
|
|
@ -200,8 +200,8 @@ def enum_lines(cls):
|
|||
# Delegation + writability probes (catch breaks invisible to type introspection)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def delegation_probe_lines():
|
||||
"""Assert the GridView instance still resolves its delegated _GridData surface
|
||||
via tp_getattro. A break here leaves the _GridData type unchanged (invisible to
|
||||
"""Assert the GridView instance still resolves its delegated GridData surface
|
||||
via tp_getattro. A break here leaves the GridData type unchanged (invisible to
|
||||
the type snapshot) but breaks the user-facing Grid contract."""
|
||||
lines = []
|
||||
try:
|
||||
|
|
@ -337,7 +337,7 @@ def build_snapshot():
|
|||
out.append("func %s :: %s" % (n, first_doc_line(v.__doc__) or "<no-doc>"))
|
||||
|
||||
out.append("")
|
||||
out.append("=== DELEGATION INTEGRITY (Grid instance -> _GridData) ===")
|
||||
out.append("=== DELEGATION INTEGRITY (Grid instance -> GridData) ===")
|
||||
out.extend(delegation_probe_lines())
|
||||
|
||||
out.append("")
|
||||
|
|
|
|||
|
|
@ -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