From a4e0b97ecb742aebeb55ae1cb02c258defdf5f60 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 10 Apr 2026 02:57:47 -0400 Subject: [PATCH] Add multi-tile entity support with tile_width/tile_height, closes #236 Entities can now span multiple grid cells via tile_width and tile_height properties (default 1x1). Frustum culling accounts for entity footprint, and spatial hash queries return multi-tile entities for all covered cells. API: entity.tile_size = (2, 2) or entity.tile_width = 2; entity.tile_height = 3 Co-Authored-By: Claude Opus 4.6 --- src/SpatialHash.cpp | 8 ++- src/UIEntity.cpp | 61 ++++++++++++++++ src/UIEntity.h | 10 +++ src/UIGrid.cpp | 10 +-- tests/unit/multi_tile_entity_test.py | 104 +++++++++++++++++++++++++++ 5 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 tests/unit/multi_tile_entity_test.py diff --git a/src/SpatialHash.cpp b/src/SpatialHash.cpp index 7cc1881..c843b6e 100644 --- a/src/SpatialHash.cpp +++ b/src/SpatialHash.cpp @@ -115,9 +115,11 @@ std::vector> SpatialHash::queryCell(int x, int y) cons auto entity = wp.lock(); if (!entity) continue; - // Match on cell_position (#295) - if (entity->cell_position.x == x && - entity->cell_position.y == y) { + // #236: Match on cell_position footprint for multi-tile entities + if (x >= entity->cell_position.x && + x < entity->cell_position.x + entity->tile_width && + y >= entity->cell_position.y && + y < entity->cell_position.y + entity->tile_height) { result.push_back(entity); } } diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp index 3dfa74b..23efc4d 100644 --- a/src/UIEntity.cpp +++ b/src/UIEntity.cpp @@ -845,6 +845,60 @@ int UIEntity::set_sprite_offset_member(PyUIEntityObject* self, PyObject* value, return 0; } +// #236 - Multi-tile entity size +PyObject* UIEntity::get_tile_size(PyUIEntityObject* self, void* closure) { + return sfVector2f_to_PyObject(sf::Vector2f( + static_cast(self->data->tile_width), + static_cast(self->data->tile_height))); +} + +int UIEntity::set_tile_size(PyUIEntityObject* self, PyObject* value, void* closure) { + sf::Vector2f vec = PyObject_to_sfVector2f(value); + if (PyErr_Occurred()) return -1; + int tw = static_cast(vec.x); + int th = static_cast(vec.y); + if (tw < 1 || th < 1) { + PyErr_SetString(PyExc_ValueError, "tile_size components must be >= 1"); + return -1; + } + self->data->tile_width = tw; + self->data->tile_height = th; + if (self->data->grid) self->data->grid->markDirty(); + return 0; +} + +PyObject* UIEntity::get_tile_width(PyUIEntityObject* self, void* closure) { + return PyLong_FromLong(self->data->tile_width); +} + +int UIEntity::set_tile_width(PyUIEntityObject* self, PyObject* value, void* closure) { + int val = PyLong_AsLong(value); + if (val == -1 && PyErr_Occurred()) return -1; + if (val < 1) { + PyErr_SetString(PyExc_ValueError, "tile_width must be >= 1"); + return -1; + } + self->data->tile_width = val; + if (self->data->grid) self->data->grid->markDirty(); + return 0; +} + +PyObject* UIEntity::get_tile_height(PyUIEntityObject* self, void* closure) { + return PyLong_FromLong(self->data->tile_height); +} + +int UIEntity::set_tile_height(PyUIEntityObject* self, PyObject* value, void* closure) { + int val = PyLong_AsLong(value); + if (val == -1 && PyErr_Occurred()) return -1; + if (val < 1) { + PyErr_SetString(PyExc_ValueError, "tile_height must be >= 1"); + return -1; + } + self->data->tile_height = val; + if (self->data->grid) self->data->grid->markDirty(); + return 0; +} + PyObject* UIEntity::die(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored)) { // Check if entity has a grid @@ -1596,6 +1650,13 @@ PyGetSetDef UIEntity::getsetters[] = { "X component of sprite pixel offset.", (void*)0}, {"sprite_offset_y", (getter)UIEntity::get_sprite_offset_member, (setter)UIEntity::set_sprite_offset_member, "Y component of sprite pixel offset.", (void*)1}, + // #236 - Multi-tile entity size + {"tile_size", (getter)UIEntity::get_tile_size, (setter)UIEntity::set_tile_size, + "Entity size in tiles as (width, height) Vector. Default (1, 1).", NULL}, + {"tile_width", (getter)UIEntity::get_tile_width, (setter)UIEntity::set_tile_width, + "Entity width in tiles (int). Must be >= 1. Default 1.", NULL}, + {"tile_height", (getter)UIEntity::get_tile_height, (setter)UIEntity::set_tile_height, + "Entity height in tiles (int). Must be >= 1. Default 1.", NULL}, // #296 - Label system {"labels", (getter)UIEntity::get_labels, (setter)UIEntity::set_labels, "Set of string labels for collision/targeting (frozenset). " diff --git a/src/UIEntity.h b/src/UIEntity.h index 1e5e9df..7c58201 100644 --- a/src/UIEntity.h +++ b/src/UIEntity.h @@ -71,6 +71,8 @@ public: sf::Vector2f position; //(x,y) in grid coordinates; float for animation sf::Vector2i cell_position{0, 0}; // #295: integer logical position (decoupled from float position) sf::Vector2f sprite_offset; // pixel offset for oversized sprites (applied pre-zoom) + int tile_width = 1; // #236: entity size in tiles (for multi-tile entities) + int tile_height = 1; std::unordered_set labels; // #296: entity label system for collision/targeting PyObject* step_callback = nullptr; // #299: callback for grid.step() turn management int default_behavior = 0; // #299: BehaviorType::IDLE - behavior to revert to after DONE @@ -169,6 +171,14 @@ public: static PyObject* get_sprite_offset_member(PyUIEntityObject* self, void* closure); static int set_sprite_offset_member(PyUIEntityObject* self, PyObject* value, void* closure); + // #236 - Multi-tile entity size + static PyObject* get_tile_size(PyUIEntityObject* self, void* closure); + static int set_tile_size(PyUIEntityObject* self, PyObject* value, void* closure); + static PyObject* get_tile_width(PyUIEntityObject* self, void* closure); + static int set_tile_width(PyUIEntityObject* self, PyObject* value, void* closure); + static PyObject* get_tile_height(PyUIEntityObject* self, void* closure); + static int set_tile_height(PyUIEntityObject* self, PyObject* value, void* closure); + // #295 - cell_pos (integer logical position) static PyObject* get_cell_pos(PyUIEntityObject* self, void* closure); static int set_cell_pos(PyUIEntityObject* self, PyObject* value, void* closure); diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 6dea9f1..805dcf3 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -198,10 +198,12 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) int totalEntities = entities->size(); for (auto e : *entities) { - // Skip out-of-bounds entities for performance - // Check if entity is within visible bounds (with 2 cell margin for offset/oversized sprites) - if (e->position.x < left_edge - 2 || e->position.x >= left_edge + width_sq + 2 || - e->position.y < top_edge - 2 || e->position.y >= top_edge + height_sq + 2) { + // #236: Account for multi-tile entity size in frustum culling + int margin = 2; + if (e->position.x + e->tile_width < left_edge - margin || + e->position.x >= left_edge + width_sq + margin || + e->position.y + e->tile_height < top_edge - margin || + e->position.y >= top_edge + height_sq + margin) { continue; // Skip this entity as it's not visible } diff --git a/tests/unit/multi_tile_entity_test.py b/tests/unit/multi_tile_entity_test.py new file mode 100644 index 0000000..891e7d7 --- /dev/null +++ b/tests/unit/multi_tile_entity_test.py @@ -0,0 +1,104 @@ +"""Test multi-tile entity rendering and positioning (#236). + +Verifies that entities can span multiple grid cells using tile_width +and tile_height properties, with correct frustum culling and spatial +hash queries. +""" +import mcrfpy +import sys + +def test_default_tile_size(): + """New entities default to 1x1 tile size.""" + entity = mcrfpy.Entity() + assert entity.tile_width == 1, f"Expected 1, got {entity.tile_width}" + assert entity.tile_height == 1, f"Expected 1, got {entity.tile_height}" + ts = entity.tile_size + assert ts.x == 1.0 and ts.y == 1.0, f"tile_size wrong: ({ts.x}, {ts.y})" + print(" PASS: default tile size") + +def test_set_tile_size_individual(): + """tile_width and tile_height can be set individually.""" + entity = mcrfpy.Entity() + entity.tile_width = 2 + entity.tile_height = 3 + assert entity.tile_width == 2, f"Expected 2, got {entity.tile_width}" + assert entity.tile_height == 3, f"Expected 3, got {entity.tile_height}" + print(" PASS: individual tile size setters") + +def test_set_tile_size_tuple(): + """tile_size can be set as a tuple.""" + entity = mcrfpy.Entity() + entity.tile_size = (4, 2) + assert entity.tile_width == 4, f"Expected 4, got {entity.tile_width}" + assert entity.tile_height == 2, f"Expected 2, got {entity.tile_height}" + print(" PASS: tile_size tuple setter") + +def test_tile_size_validation(): + """tile_width/height must be >= 1.""" + entity = mcrfpy.Entity() + try: + entity.tile_width = 0 + assert False, "Should have raised ValueError" + except ValueError: + pass + + try: + entity.tile_height = -1 + assert False, "Should have raised ValueError" + except ValueError: + pass + + try: + entity.tile_size = (0, 1) + assert False, "Should have raised ValueError" + except ValueError: + pass + + print(" PASS: tile size validation") + +def test_multi_tile_in_grid(): + """Multi-tile entities work correctly in grids.""" + grid = mcrfpy.Grid(grid_size=(20, 20)) + entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid) + entity.tile_width = 2 + entity.tile_height = 2 + + assert entity.tile_width == 2 + assert entity.tile_height == 2 + assert entity.cell_pos.x == 5 + assert entity.cell_pos.y == 5 + print(" PASS: multi-tile entity in grid") + +def test_spatial_hash_multi_tile(): + """Spatial hash queries find multi-tile entities at covered cells.""" + grid = mcrfpy.Grid(grid_size=(20, 20)) + entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid) + entity.tile_width = 2 + entity.tile_height = 2 + + at_origin = grid.at(5, 5).entities + assert len(at_origin) >= 1, f"Entity not found at origin (5,5): {len(at_origin)}" + + at_right = grid.at(6, 5).entities + assert len(at_right) >= 1, f"Entity not found at covered cell (6,5): {len(at_right)}" + + at_below = grid.at(5, 6).entities + assert len(at_below) >= 1, f"Entity not found at covered cell (5,6): {len(at_below)}" + + at_corner = grid.at(6, 6).entities + assert len(at_corner) >= 1, f"Entity not found at covered cell (6,6): {len(at_corner)}" + + at_outside = grid.at(7, 5).entities + assert len(at_outside) == 0, f"Entity found outside footprint (7,5): {len(at_outside)}" + + print(" PASS: spatial hash multi-tile queries") + +print("Testing #236: Multi-tile entities...") +test_default_tile_size() +test_set_tile_size_individual() +test_set_tile_size_tuple() +test_tile_size_validation() +test_multi_tile_in_grid() +test_spatial_hash_multi_tile() +print("All #236 tests passed.") +sys.exit(0)