Phase 2: Entity data model extensions for behavior system

- Add Behavior enum (IDLE..FLEE, 11 values) and Trigger enum (DONE,
  BLOCKED, TARGET) as runtime IntEnum classes (closes #297, closes #298)
- Add entity label system: labels property (frozenset), add_label(),
  remove_label(), has_label(), constructor kwarg (closes #296)
- Add cell_pos integer logical position decoupled from float draw_pos;
  grid_pos now aliases cell_pos; SpatialHash::updateCell() for cell-based
  bucket management; FOV/visibility uses cell_position (closes #295)
- Add step callback and default_behavior properties to Entity for
  grid.step() turn management (closes #299)
- Update updateVisibility, visible_entities, ColorLayer::updatePerspective
  to use cell_position instead of float position

BREAKING: grid_pos no longer derives from float x/y position. Use
cell_pos/grid_pos for logical position, draw_pos for render position.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-03-15 22:05:06 -04:00
commit 2f1e472245
15 changed files with 886 additions and 34 deletions

View file

@ -50,21 +50,23 @@ def test_entity_positions():
if abs(entity.y - 80.0) > 0.001:
errors.append(f"y: expected 80.0, got {entity.y}")
# Test 6: Setting grid_x/grid_y should update position
# Test 6: Setting grid_x/grid_y should update cell position (#295: decoupled from pixel pos)
entity.grid_x = 7
entity.grid_y = 2
if entity.grid_x != 7 or entity.grid_y != 2:
errors.append(f"After setting grid_x/y: expected (7, 2), got ({entity.grid_x}, {entity.grid_y})")
# Pixel should update too: (7, 2) * 16 = (112, 32)
if abs(entity.x - 112.0) > 0.001 or abs(entity.y - 32.0) > 0.001:
errors.append(f"After grid_x/y set, pixel pos: expected (112, 32), got ({entity.x}, {entity.y})")
# #295: cell_pos (grid_x/y) is decoupled from pixel pos - pixel pos NOT updated
# Pixel pos should remain at the draw_pos * tile_size (3*16=48, 5*16=80 from earlier)
if abs(entity.x - 48.0) > 0.001 or abs(entity.y - 80.0) > 0.001:
errors.append(f"After grid_x/y set, pixel pos should be unchanged: expected (48, 80), got ({entity.x}, {entity.y})")
# Test 7: Setting pos (pixels) should update grid position
# Test 7: Setting pos (pixels) should update draw_pos but NOT grid_pos (#295)
entity.pos = mcrfpy.Vector(64, 96) # (64, 96) / 16 = (4, 6) tiles
if abs(entity.draw_pos.x - 4.0) > 0.001 or abs(entity.draw_pos.y - 6.0) > 0.001:
errors.append(f"After setting pos, draw_pos: expected (4, 6), got ({entity.draw_pos.x}, {entity.draw_pos.y})")
if entity.grid_x != 4 or entity.grid_y != 6:
errors.append(f"After setting pos, grid_x/y: expected (4, 6), got ({entity.grid_x}, {entity.grid_y})")
# #295: grid_pos is cell_pos, not derived from float position - should be (7, 2) from above
if entity.grid_x != 7 or entity.grid_y != 2:
errors.append(f"After setting pos, grid_x/y should be unchanged: expected (7, 2), got ({entity.grid_x}, {entity.grid_y})")
# Test 8: repr should show position info
repr_str = repr(entity)

View file

@ -0,0 +1,78 @@
"""Regression test for #295: Entity cell_pos integer logical position."""
import mcrfpy
import sys
def test_cell_pos_init():
"""cell_pos initialized from grid_pos on construction."""
e = mcrfpy.Entity((5, 7))
assert e.cell_x == 5, f"cell_x should be 5, got {e.cell_x}"
assert e.cell_y == 7, f"cell_y should be 7, got {e.cell_y}"
print("PASS: cell_pos initialized from constructor")
def test_cell_pos_grid_pos_alias():
"""grid_pos is an alias for cell_pos."""
e = mcrfpy.Entity((3, 4))
# grid_pos should match cell_pos
assert e.grid_pos.x == e.cell_pos.x
assert e.grid_pos.y == e.cell_pos.y
# Setting grid_pos should update cell_pos
e.grid_pos = (10, 20)
assert e.cell_x == 10
assert e.cell_y == 20
print("PASS: grid_pos aliases cell_pos")
def test_cell_pos_independent_from_draw_pos():
"""cell_pos does not change when draw_pos (float position) changes."""
e = mcrfpy.Entity((5, 5))
# Change draw_pos (float position for rendering)
e.draw_pos = (5.5, 5.5)
# cell_pos should be unchanged
assert e.cell_x == 5, f"cell_x should still be 5 after draw_pos change, got {e.cell_x}"
assert e.cell_y == 5, f"cell_y should still be 5 after draw_pos change, got {e.cell_y}"
print("PASS: cell_pos independent from draw_pos")
def test_cell_pos_spatial_hash():
"""GridPoint.entities uses cell_pos for matching."""
scene = mcrfpy.Scene("test295")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
scene.children.append(grid)
e = mcrfpy.Entity((5, 5), grid=grid)
# Entity should appear at cell (5, 5)
assert len(grid.at(5, 5).entities) == 1
# Move cell_pos to (10, 10)
e.cell_pos = (10, 10)
assert len(grid.at(5, 5).entities) == 0, "Old cell should be empty"
assert len(grid.at(10, 10).entities) == 1, "New cell should have entity"
print("PASS: spatial hash uses cell_pos")
def test_cell_pos_member_access():
"""cell_x and cell_y read/write correctly."""
e = mcrfpy.Entity((3, 7))
assert e.cell_x == 3
assert e.cell_y == 7
e.cell_x = 15
assert e.cell_x == 15
assert e.cell_y == 7 # y unchanged
e.cell_y = 20
assert e.cell_x == 15 # x unchanged
assert e.cell_y == 20
print("PASS: cell_x/cell_y member access")
if __name__ == "__main__":
test_cell_pos_init()
test_cell_pos_grid_pos_alias()
test_cell_pos_independent_from_draw_pos()
test_cell_pos_spatial_hash()
test_cell_pos_member_access()
print("All #295 tests passed")
sys.exit(0)