fix(grid): revive Grid input — move cell callbacks and hit-testing to GridView; N-view registry
Since the #252 Grid/GridView split, mcrfpy.Grid(...) constructs a UIGridView
(PyObjectsEnum::UIGRIDVIEW), but every input dispatch site gated on
derived_type() == UIGRID — the internal mcrfpy._GridData type that no scene-graph
node is ever an instance of. All grid input was dead: cell click/enter/exit
callbacks never fired, clicks on a grid's child drawables and entities never
dispatched, and hover inside grids did nothing.
The cell-callback family was split three ways, which is the actual bug: storage
on GridData, firing on UIGrid (it needed UIDrawable::serial_number), and the
camera required to resolve a screen pixel into a cell on UIGridView. So merely
re-pointing the enum gate at UIGRIDVIEW would still report wrong cells for any
panned or zoomed grid, and UIGrid::fireCellClick's subclass lookup resolved the
serial of the throwaway _GridData wrapper that init_with_data DECREFs — meaning
a Python subclass overriding on_cell_click as a method could never dispatch.
Input ownership now lives entirely on UIGridView:
- on_cell_click/enter/exit, hovered_cell, last_clicked_cell and the subclass
dispatch cache move off GridData/UIGrid (hovered_cell must be per-view: two
views over one GridData would otherwise ping-pong exit/enter).
- UIGridView::click_at descends children (reverse z) -> entities -> cell.
Children keep grid-world pixel coordinates: that is by design (#360), not an
inconsistency with Frame's frame-local children.
- The six derived_type()==UIGRID gates are replaced by virtuals on UIDrawable
(dispatchCellClick, updateHover, asGridData) so no caller switches on
grid-ness again.
- Fixes a prerequisite bug: UIGridView never overrode onPositionChanged(), so
box and position diverged and a repositioned Grid rendered at the stale spot
while hit-testing against the new one. Any cell hit-test was meaningless
until these agreed.
- grid.perspective was silently dead (the FOV overlay gated on a
UIGridView::perspective_enabled that nothing ever set); perspective state
moves to the view with the rest.
#359 is fused into this commit rather than sequenced after it: the shared-data
back-reference is rewritten by the same hunks that move the callbacks off
GridData, and separating them would mean authoring an intermediate GridData.h /
UIGridView.cpp that never existed. GridData::owning_view (a single weak_ptr, for
a relationship that is N views to one GridData) becomes a vector<weak_ptr>
registry with register/unregister. The registry is kept rather than deleted: the
markCompositeDirty push is NOT redundant with #351's content_generation poll,
because it propagates bottom-up through each view's ancestor chain (e.g. a
Frame(use_render_texture=True) wrapping a view), which a top-down render-time
poll cannot do.
Grid subclass method dispatch, panned/zoomed cell resolution, grid-child clicks,
and cell hover are all covered by new regression tests. tests/unit/
test_grid_cell_events.py previously printed "PARTIAL (events may require
interactive mode)" and exited 0 having fired zero events — that dishonest pass is
why this shipped broken; it now asserts.
Suite: 322/322.
closes #355
closes #359
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:01:40 -04:00
|
|
|
"""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
|
refactor(grid): GridData is a map, not a widget -- delete UIGrid's drawable half
closes #361
closes #370
closes #371
UIGrid inherited both UIDrawable and GridData, so every map carried a second
camera and RenderTexture that nothing on screen corresponded to. Appending a
_GridData to a scene drew the whole map again through that ghost, frozen at
construction values. GridData is now pure data -- no position, no size, no
render() -- and UIGridView is the only widget.
UIGrid is deleted rather than demoted: shedding UIDrawable left an empty
subclass whose only job was to be what an aliasing shared_ptr pointed into.
GridData absorbs the texture (it defines the cell size, which every tile<->pixel
conversion depends on) and its own PythonObjectCache serial; the binding layer
becomes struct PyGridData. PyObjectsEnum::UIGRID and its 14 case arms go with it.
* mcrfpy.GridData is public and standalone-constructible. A map with no view
at all -- an offscreen level, still steppable and pathable -- was previously
undefined behavior: GridData::markDirty did static_cast<UIGrid*>(this).
* UIGridView::init constructs the GridData directly instead of building a
throwaway Python _GridData, stealing its base subobject, and copying ~15
fields of rendering state out of the discarded wrapper. One init() serves
both modes, so a second view is no longer a crippled kwarg subset
(Grid(grid=other, z_index=5) used to raise TypeError).
* entity.grid returns the GridData. Accepted break: an entity may be on a map
with no view or with several, so "the grid an entity is in" is not a camera.
Migrate player.grid.center_camera(p) -> view.center_camera(p).
* tp_name is mcrfpy.GridView; mcrfpy.Grid is an alias to the same type object.
Two latent bugs surfaced and are fixed here:
#370 Grid.center_camera() and Grid.size = ... were SILENT NO-OPS -- neither
was defined on the view, so both delegated to the ghost camera. On
master, center_camera((5,5)) leaves center_x at 50.0.
#371 The shader uniform binder strcmp'd tp_name == "mcrfpy.Grid" (the VIEW's
name) then cast to PyUIGridObject. UB that only worked because both
wrappers had identical layout and both classes put UIDrawable at offset 0.
Suite 327/327.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 07:14:43 -04:00
|
|
|
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).
|
fix(grid): revive Grid input — move cell callbacks and hit-testing to GridView; N-view registry
Since the #252 Grid/GridView split, mcrfpy.Grid(...) constructs a UIGridView
(PyObjectsEnum::UIGRIDVIEW), but every input dispatch site gated on
derived_type() == UIGRID — the internal mcrfpy._GridData type that no scene-graph
node is ever an instance of. All grid input was dead: cell click/enter/exit
callbacks never fired, clicks on a grid's child drawables and entities never
dispatched, and hover inside grids did nothing.
The cell-callback family was split three ways, which is the actual bug: storage
on GridData, firing on UIGrid (it needed UIDrawable::serial_number), and the
camera required to resolve a screen pixel into a cell on UIGridView. So merely
re-pointing the enum gate at UIGRIDVIEW would still report wrong cells for any
panned or zoomed grid, and UIGrid::fireCellClick's subclass lookup resolved the
serial of the throwaway _GridData wrapper that init_with_data DECREFs — meaning
a Python subclass overriding on_cell_click as a method could never dispatch.
Input ownership now lives entirely on UIGridView:
- on_cell_click/enter/exit, hovered_cell, last_clicked_cell and the subclass
dispatch cache move off GridData/UIGrid (hovered_cell must be per-view: two
views over one GridData would otherwise ping-pong exit/enter).
- UIGridView::click_at descends children (reverse z) -> entities -> cell.
Children keep grid-world pixel coordinates: that is by design (#360), not an
inconsistency with Frame's frame-local children.
- The six derived_type()==UIGRID gates are replaced by virtuals on UIDrawable
(dispatchCellClick, updateHover, asGridData) so no caller switches on
grid-ness again.
- Fixes a prerequisite bug: UIGridView never overrode onPositionChanged(), so
box and position diverged and a repositioned Grid rendered at the stale spot
while hit-testing against the new one. Any cell hit-test was meaningless
until these agreed.
- grid.perspective was silently dead (the FOV overlay gated on a
UIGridView::perspective_enabled that nothing ever set); perspective state
moves to the view with the rest.
#359 is fused into this commit rather than sequenced after it: the shared-data
back-reference is rewritten by the same hunks that move the callbacks off
GridData, and separating them would mean authoring an intermediate GridData.h /
UIGridView.cpp that never existed. GridData::owning_view (a single weak_ptr, for
a relationship that is N views to one GridData) becomes a vector<weak_ptr>
registry with register/unregister. The registry is kept rather than deleted: the
markCompositeDirty push is NOT redundant with #351's content_generation poll,
because it propagates bottom-up through each view's ancestor chain (e.g. a
Frame(use_render_texture=True) wrapping a view), which a top-down render-time
poll cannot do.
Grid subclass method dispatch, panned/zoomed cell resolution, grid-child clicks,
and cell hover are all covered by new regression tests. tests/unit/
test_grid_cell_events.py previously printed "PARTIAL (events may require
interactive mode)" and exited 0 having fired zero events — that dishonest pass is
why this shipped broken; it now asserts.
Suite: 322/322.
closes #355
closes #359
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:01:40 -04:00
|
|
|
"""
|
|
|
|
|
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():
|
refactor(grid): GridData is a map, not a widget -- delete UIGrid's drawable half
closes #361
closes #370
closes #371
UIGrid inherited both UIDrawable and GridData, so every map carried a second
camera and RenderTexture that nothing on screen corresponded to. Appending a
_GridData to a scene drew the whole map again through that ghost, frozen at
construction values. GridData is now pure data -- no position, no size, no
render() -- and UIGridView is the only widget.
UIGrid is deleted rather than demoted: shedding UIDrawable left an empty
subclass whose only job was to be what an aliasing shared_ptr pointed into.
GridData absorbs the texture (it defines the cell size, which every tile<->pixel
conversion depends on) and its own PythonObjectCache serial; the binding layer
becomes struct PyGridData. PyObjectsEnum::UIGRID and its 14 case arms go with it.
* mcrfpy.GridData is public and standalone-constructible. A map with no view
at all -- an offscreen level, still steppable and pathable -- was previously
undefined behavior: GridData::markDirty did static_cast<UIGrid*>(this).
* UIGridView::init constructs the GridData directly instead of building a
throwaway Python _GridData, stealing its base subobject, and copying ~15
fields of rendering state out of the discarded wrapper. One init() serves
both modes, so a second view is no longer a crippled kwarg subset
(Grid(grid=other, z_index=5) used to raise TypeError).
* entity.grid returns the GridData. Accepted break: an entity may be on a map
with no view or with several, so "the grid an entity is in" is not a camera.
Migrate player.grid.center_camera(p) -> view.center_camera(p).
* tp_name is mcrfpy.GridView; mcrfpy.Grid is an alias to the same type object.
Two latent bugs surfaced and are fixed here:
#370 Grid.center_camera() and Grid.size = ... were SILENT NO-OPS -- neither
was defined on the view, so both delegated to the ghost camera. On
master, center_camera((5,5)) leaves center_x at 50.0.
#371 The shader uniform binder strcmp'd tp_name == "mcrfpy.Grid" (the VIEW's
name) then cast to PyUIGridObject. UB that only worked because both
wrappers had identical layout and both classes put UIDrawable at offset 0.
Suite 327/327.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 07:14:43 -04:00
|
|
|
"""entity.grid is the shared GridData -- one stable object, the same one both
|
|
|
|
|
views are looking at, and NOT either view (#361)."""
|
fix(grid): revive Grid input — move cell callbacks and hit-testing to GridView; N-view registry
Since the #252 Grid/GridView split, mcrfpy.Grid(...) constructs a UIGridView
(PyObjectsEnum::UIGRIDVIEW), but every input dispatch site gated on
derived_type() == UIGRID — the internal mcrfpy._GridData type that no scene-graph
node is ever an instance of. All grid input was dead: cell click/enter/exit
callbacks never fired, clicks on a grid's child drawables and entities never
dispatched, and hover inside grids did nothing.
The cell-callback family was split three ways, which is the actual bug: storage
on GridData, firing on UIGrid (it needed UIDrawable::serial_number), and the
camera required to resolve a screen pixel into a cell on UIGridView. So merely
re-pointing the enum gate at UIGRIDVIEW would still report wrong cells for any
panned or zoomed grid, and UIGrid::fireCellClick's subclass lookup resolved the
serial of the throwaway _GridData wrapper that init_with_data DECREFs — meaning
a Python subclass overriding on_cell_click as a method could never dispatch.
Input ownership now lives entirely on UIGridView:
- on_cell_click/enter/exit, hovered_cell, last_clicked_cell and the subclass
dispatch cache move off GridData/UIGrid (hovered_cell must be per-view: two
views over one GridData would otherwise ping-pong exit/enter).
- UIGridView::click_at descends children (reverse z) -> entities -> cell.
Children keep grid-world pixel coordinates: that is by design (#360), not an
inconsistency with Frame's frame-local children.
- The six derived_type()==UIGRID gates are replaced by virtuals on UIDrawable
(dispatchCellClick, updateHover, asGridData) so no caller switches on
grid-ness again.
- Fixes a prerequisite bug: UIGridView never overrode onPositionChanged(), so
box and position diverged and a repositioned Grid rendered at the stale spot
while hit-testing against the new one. Any cell hit-test was meaningless
until these agreed.
- grid.perspective was silently dead (the FOV overlay gated on a
UIGridView::perspective_enabled that nothing ever set); perspective state
moves to the view with the rest.
#359 is fused into this commit rather than sequenced after it: the shared-data
back-reference is rewritten by the same hunks that move the callbacks off
GridData, and separating them would mean authoring an intermediate GridData.h /
UIGridView.cpp that never existed. GridData::owning_view (a single weak_ptr, for
a relationship that is N views to one GridData) becomes a vector<weak_ptr>
registry with register/unregister. The registry is kept rather than deleted: the
markCompositeDirty push is NOT redundant with #351's content_generation poll,
because it propagates bottom-up through each view's ancestor chain (e.g. a
Frame(use_render_texture=True) wrapping a view), which a top-down render-time
poll cannot do.
Grid subclass method dispatch, panned/zoomed cell resolution, grid-child clicks,
and cell hover are all covered by new regression tests. tests/unit/
test_grid_cell_events.py previously printed "PARTIAL (events may require
interactive mode)" and exited 0 having fired zero events — that dishonest pass is
why this shipped broken; it now asserts.
Suite: 322/322.
closes #355
closes #359
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:01:40 -04:00
|
|
|
print("(2) entity.grid identity with a secondary view registered...")
|
|
|
|
|
grid_a = mcrfpy.Grid(grid_size=(5, 5))
|
refactor(grid): GridData is a map, not a widget -- delete UIGrid's drawable half
closes #361
closes #370
closes #371
UIGrid inherited both UIDrawable and GridData, so every map carried a second
camera and RenderTexture that nothing on screen corresponded to. Appending a
_GridData to a scene drew the whole map again through that ghost, frozen at
construction values. GridData is now pure data -- no position, no size, no
render() -- and UIGridView is the only widget.
UIGrid is deleted rather than demoted: shedding UIDrawable left an empty
subclass whose only job was to be what an aliasing shared_ptr pointed into.
GridData absorbs the texture (it defines the cell size, which every tile<->pixel
conversion depends on) and its own PythonObjectCache serial; the binding layer
becomes struct PyGridData. PyObjectsEnum::UIGRID and its 14 case arms go with it.
* mcrfpy.GridData is public and standalone-constructible. A map with no view
at all -- an offscreen level, still steppable and pathable -- was previously
undefined behavior: GridData::markDirty did static_cast<UIGrid*>(this).
* UIGridView::init constructs the GridData directly instead of building a
throwaway Python _GridData, stealing its base subobject, and copying ~15
fields of rendering state out of the discarded wrapper. One init() serves
both modes, so a second view is no longer a crippled kwarg subset
(Grid(grid=other, z_index=5) used to raise TypeError).
* entity.grid returns the GridData. Accepted break: an entity may be on a map
with no view or with several, so "the grid an entity is in" is not a camera.
Migrate player.grid.center_camera(p) -> view.center_camera(p).
* tp_name is mcrfpy.GridView; mcrfpy.Grid is an alias to the same type object.
Two latent bugs surfaced and are fixed here:
#370 Grid.center_camera() and Grid.size = ... were SILENT NO-OPS -- neither
was defined on the view, so both delegated to the ghost camera. On
master, center_camera((5,5)) leaves center_x at 50.0.
#371 The shader uniform binder strcmp'd tp_name == "mcrfpy.Grid" (the VIEW's
name) then cast to PyUIGridObject. UB that only worked because both
wrappers had identical layout and both classes put UIDrawable at offset 0.
Suite 327/327.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 07:14:43 -04:00
|
|
|
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(80, 80))
|
fix(grid): revive Grid input — move cell callbacks and hit-testing to GridView; N-view registry
Since the #252 Grid/GridView split, mcrfpy.Grid(...) constructs a UIGridView
(PyObjectsEnum::UIGRIDVIEW), but every input dispatch site gated on
derived_type() == UIGRID — the internal mcrfpy._GridData type that no scene-graph
node is ever an instance of. All grid input was dead: cell click/enter/exit
callbacks never fired, clicks on a grid's child drawables and entities never
dispatched, and hover inside grids did nothing.
The cell-callback family was split three ways, which is the actual bug: storage
on GridData, firing on UIGrid (it needed UIDrawable::serial_number), and the
camera required to resolve a screen pixel into a cell on UIGridView. So merely
re-pointing the enum gate at UIGRIDVIEW would still report wrong cells for any
panned or zoomed grid, and UIGrid::fireCellClick's subclass lookup resolved the
serial of the throwaway _GridData wrapper that init_with_data DECREFs — meaning
a Python subclass overriding on_cell_click as a method could never dispatch.
Input ownership now lives entirely on UIGridView:
- on_cell_click/enter/exit, hovered_cell, last_clicked_cell and the subclass
dispatch cache move off GridData/UIGrid (hovered_cell must be per-view: two
views over one GridData would otherwise ping-pong exit/enter).
- UIGridView::click_at descends children (reverse z) -> entities -> cell.
Children keep grid-world pixel coordinates: that is by design (#360), not an
inconsistency with Frame's frame-local children.
- The six derived_type()==UIGRID gates are replaced by virtuals on UIDrawable
(dispatchCellClick, updateHover, asGridData) so no caller switches on
grid-ness again.
- Fixes a prerequisite bug: UIGridView never overrode onPositionChanged(), so
box and position diverged and a repositioned Grid rendered at the stale spot
while hit-testing against the new one. Any cell hit-test was meaningless
until these agreed.
- grid.perspective was silently dead (the FOV overlay gated on a
UIGridView::perspective_enabled that nothing ever set); perspective state
moves to the view with the rest.
#359 is fused into this commit rather than sequenced after it: the shared-data
back-reference is rewritten by the same hunks that move the callbacks off
GridData, and separating them would mean authoring an intermediate GridData.h /
UIGridView.cpp that never existed. GridData::owning_view (a single weak_ptr, for
a relationship that is N views to one GridData) becomes a vector<weak_ptr>
registry with register/unregister. The registry is kept rather than deleted: the
markCompositeDirty push is NOT redundant with #351's content_generation poll,
because it propagates bottom-up through each view's ancestor chain (e.g. a
Frame(use_render_texture=True) wrapping a view), which a top-down render-time
poll cannot do.
Grid subclass method dispatch, panned/zoomed cell resolution, grid-child clicks,
and cell hover are all covered by new regression tests. tests/unit/
test_grid_cell_events.py previously printed "PARTIAL (events may require
interactive mode)" and exited 0 having fired zero events — that dishonest pass is
why this shipped broken; it now asserts.
Suite: 322/322.
closes #355
closes #359
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:01:40 -04:00
|
|
|
|
|
|
|
|
e = mcrfpy.Entity((1, 1), grid=grid_a)
|
|
|
|
|
|
refactor(grid): GridData is a map, not a widget -- delete UIGrid's drawable half
closes #361
closes #370
closes #371
UIGrid inherited both UIDrawable and GridData, so every map carried a second
camera and RenderTexture that nothing on screen corresponded to. Appending a
_GridData to a scene drew the whole map again through that ghost, frozen at
construction values. GridData is now pure data -- no position, no size, no
render() -- and UIGridView is the only widget.
UIGrid is deleted rather than demoted: shedding UIDrawable left an empty
subclass whose only job was to be what an aliasing shared_ptr pointed into.
GridData absorbs the texture (it defines the cell size, which every tile<->pixel
conversion depends on) and its own PythonObjectCache serial; the binding layer
becomes struct PyGridData. PyObjectsEnum::UIGRID and its 14 case arms go with it.
* mcrfpy.GridData is public and standalone-constructible. A map with no view
at all -- an offscreen level, still steppable and pathable -- was previously
undefined behavior: GridData::markDirty did static_cast<UIGrid*>(this).
* UIGridView::init constructs the GridData directly instead of building a
throwaway Python _GridData, stealing its base subobject, and copying ~15
fields of rendering state out of the discarded wrapper. One init() serves
both modes, so a second view is no longer a crippled kwarg subset
(Grid(grid=other, z_index=5) used to raise TypeError).
* entity.grid returns the GridData. Accepted break: an entity may be on a map
with no view or with several, so "the grid an entity is in" is not a camera.
Migrate player.grid.center_camera(p) -> view.center_camera(p).
* tp_name is mcrfpy.GridView; mcrfpy.Grid is an alias to the same type object.
Two latent bugs surfaced and are fixed here:
#370 Grid.center_camera() and Grid.size = ... were SILENT NO-OPS -- neither
was defined on the view, so both delegated to the ghost camera. On
master, center_camera((5,5)) leaves center_x at 50.0.
#371 The shader uniform binder strcmp'd tp_name == "mcrfpy.Grid" (the VIEW's
name) then cast to PyUIGridObject. UB that only worked because both
wrappers had identical layout and both classes put UIDrawable at offset 0.
Suite 327/327.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 07:14:43 -04:00
|
|
|
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)"
|
fix(grid): revive Grid input — move cell callbacks and hit-testing to GridView; N-view registry
Since the #252 Grid/GridView split, mcrfpy.Grid(...) constructs a UIGridView
(PyObjectsEnum::UIGRIDVIEW), but every input dispatch site gated on
derived_type() == UIGRID — the internal mcrfpy._GridData type that no scene-graph
node is ever an instance of. All grid input was dead: cell click/enter/exit
callbacks never fired, clicks on a grid's child drawables and entities never
dispatched, and hover inside grids did nothing.
The cell-callback family was split three ways, which is the actual bug: storage
on GridData, firing on UIGrid (it needed UIDrawable::serial_number), and the
camera required to resolve a screen pixel into a cell on UIGridView. So merely
re-pointing the enum gate at UIGRIDVIEW would still report wrong cells for any
panned or zoomed grid, and UIGrid::fireCellClick's subclass lookup resolved the
serial of the throwaway _GridData wrapper that init_with_data DECREFs — meaning
a Python subclass overriding on_cell_click as a method could never dispatch.
Input ownership now lives entirely on UIGridView:
- on_cell_click/enter/exit, hovered_cell, last_clicked_cell and the subclass
dispatch cache move off GridData/UIGrid (hovered_cell must be per-view: two
views over one GridData would otherwise ping-pong exit/enter).
- UIGridView::click_at descends children (reverse z) -> entities -> cell.
Children keep grid-world pixel coordinates: that is by design (#360), not an
inconsistency with Frame's frame-local children.
- The six derived_type()==UIGRID gates are replaced by virtuals on UIDrawable
(dispatchCellClick, updateHover, asGridData) so no caller switches on
grid-ness again.
- Fixes a prerequisite bug: UIGridView never overrode onPositionChanged(), so
box and position diverged and a repositioned Grid rendered at the stale spot
while hit-testing against the new one. Any cell hit-test was meaningless
until these agreed.
- grid.perspective was silently dead (the FOV overlay gated on a
UIGridView::perspective_enabled that nothing ever set); perspective state
moves to the view with the rest.
#359 is fused into this commit rather than sequenced after it: the shared-data
back-reference is rewritten by the same hunks that move the callbacks off
GridData, and separating them would mean authoring an intermediate GridData.h /
UIGridView.cpp that never existed. GridData::owning_view (a single weak_ptr, for
a relationship that is N views to one GridData) becomes a vector<weak_ptr>
registry with register/unregister. The registry is kept rather than deleted: the
markCompositeDirty push is NOT redundant with #351's content_generation poll,
because it propagates bottom-up through each view's ancestor chain (e.g. a
Frame(use_render_texture=True) wrapping a view), which a top-down render-time
poll cannot do.
Grid subclass method dispatch, panned/zoomed cell resolution, grid-child clicks,
and cell hover are all covered by new regression tests. tests/unit/
test_grid_cell_events.py previously printed "PARTIAL (events may require
interactive mode)" and exited 0 having fired zero events — that dishonest pass is
why this shipped broken; it now asserts.
Suite: 322/322.
closes #355
closes #359
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:01:40 -04:00
|
|
|
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)
|