Refactor UIEntity::grid to shared_ptr<GridData>; add entity.texture; closes #313

UIEntity now depends on the grid DATA layer only:
- GridData gains cell_width_px/cell_height_px (mirrored from the grid
  texture in UIGrid's 5-arg ctor; texture is write-once) so entity
  tile<->pixel math no longer reaches into rendering (getTexture()).
- GridData gains markDirty()/markCompositeDirty(): set the flag on the
  UIGrid subobject AND notify owning_view, covering both render paths.
  UIGrid disambiguates via 'using UIDrawable::markDirty' so all
  pre-existing UIGrid-receiver calls resolve exactly as before.
- The three Python wrappers that still need the full UIGrid (GridPoint
  from entity.at(), the _GridData fallback in get_grid, find_path's temp
  wrapper) reconstruct it via a single aliasing-downcast helper
  (grid_as_uigrid) that documents the never-independently-allocated
  GridData invariant; init/set_grid simplify (share grid_data directly).
  Removing the casts is deferred to #252.

entity.texture (new, frozen surface +1): thin get/set over the entity's
own UISprite. Entities render with their OWN texture (default_texture
fallback at construction); the grid's texture only determines cell size.
Setter preserves sprite_index; rejects non-Texture (TypeError),
null-data Texture wrappers (ValueError), and deletion.

Adversarial review fixes folded in:
- set_texture/get_texture guard uninitialized Entity wrappers
  (RuntimeError), isinstance errors, and null-data Textures.
- PyUIGridViewType tp_dealloc no longer unconditionally severs
  GridData::owning_view: gated on last-owner (#251 use_count pattern)
  plus owning-view identity. Previously ANY Grid wrapper GC while the
  view lived (e.g. scene.children.append(mcrfpy.Grid(...))) silently
  broke entity.grid -> Grid identity and data-layer dirty notification.

Tests: tests/regression/issue_313_entity_grid_data_test.py (texture
semantics, grid-cell-size invariance, entity.grid identity, #251 gate
survival, GridPoint outliving teardown, review-fix guards, owning_view
survival) + tests/unit/entity_texture_test.py. API snapshot golden
re-baselined: exactly +1 surface line (Entity.texture) + writability
probe flip. Docs/stubs regenerated. Native + Emscripten builds verified.

Known edges recorded in docs/api-audit-2026-04.md: texture read-back is
a fresh wrapper each get (no Texture __eq__); sprite_index not
re-validated against a new atlas. Multi-view markDirty broadcast and
pure-GridData wrappers remain deferred to #252. Addresses #314.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-06-11 00:51:22 -04:00
commit 2d2c333cd7
15 changed files with 428 additions and 60 deletions

View file

@ -653,15 +653,15 @@ class Entity:
"""A game entity that exists on a grid with sprite rendering."""
def __init__(self, grid_pos=None, texture=None, sprite_index=0, **kwargs) -> None: ...
behavior_type: int # Current behavior type (int, read-only). Use set_behavior() to change.
cell_pos: Vector # Integer logical cell position (Vector). Decoupled from draw_pos. Determines which cell this entity logically occupies for collision, pathfinding, etc.
cell_x: Any # Integer X cell coordinate.
cell_y: Any # Integer Y cell coordinate.
cell_pos: Vector # Integer logical cell position (Vector). Alias for grid_pos (the canonical name).
cell_x: Any # Integer X cell coordinate. Alias for grid_x.
cell_y: Any # Integer Y cell coordinate. Alias for grid_y.
default_behavior: int # Default behavior type (int, maps to Behavior enum). Entity reverts to this after DONE trigger. Default: 0 (IDLE).
draw_pos: Vector # Fractional tile position for rendering (Vector). Use for smooth animation between grid cells.
grid: Any # Grid this entity belongs to. Get: Returns the Grid or None. Set: Assign a Grid to move entity, or None to remove from grid.
grid_pos: Vector # Grid position as integer cell coordinates (Vector). Alias for cell_pos.
grid_x: Any # Grid X position as integer cell coordinate. Alias for cell_x.
grid_y: Any # Grid Y position as integer cell coordinate. Alias for cell_y.
grid_pos: Vector # Integer logical cell position (Vector). Canonical cell-position property; matches the 'grid_pos' constructor argument. Decoupled from draw_pos. Determines wh...
grid_x: Any # Integer X cell coordinate. Canonical; matches grid_pos.
grid_y: Any # Integer Y cell coordinate. Canonical; matches grid_pos.
labels: frozenset # Set of string labels for collision/targeting (frozenset). Assign any iterable of strings to replace all labels.
move_speed: float # Animation duration for behavior movement in seconds (float). 0 = instant. Default: 0.15.
name: Any # Name for finding elements
@ -677,6 +677,7 @@ class Entity:
sprite_offset_y: Any # Y component of sprite pixel offset.
step: Any # Step callback for grid.step() turn management. Called with (trigger, data) when behavior triggers fire. Set to None to clear.
target_label: Any # Label to search for with TARGET trigger (str or None). Default: None.
texture: Texture # Sprite texture atlas (Texture). Defaults to mcrfpy.default_texture when the entity is constructed without one. Setting preserves sprite_index (the index is n...
tile_height: int # Entity height in tiles (int). Must be >= 1. Default 1.
tile_size: Any # Entity size in tiles as (width, height) Vector. Default (1, 1).
tile_width: int # Entity width in tiles (int). Must be >= 1. Default 1.