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 <noreply@anthropic.com>
This commit is contained in:
parent
9a06ae5d8e
commit
a4e0b97ecb
5 changed files with 186 additions and 7 deletions
|
|
@ -115,9 +115,11 @@ std::vector<std::shared_ptr<UIEntity>> SpatialHash::queryCell(int x, int y) cons
|
||||||
auto entity = wp.lock();
|
auto entity = wp.lock();
|
||||||
if (!entity) continue;
|
if (!entity) continue;
|
||||||
|
|
||||||
// Match on cell_position (#295)
|
// #236: Match on cell_position footprint for multi-tile entities
|
||||||
if (entity->cell_position.x == x &&
|
if (x >= entity->cell_position.x &&
|
||||||
entity->cell_position.y == y) {
|
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);
|
result.push_back(entity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -845,6 +845,60 @@ int UIEntity::set_sprite_offset_member(PyUIEntityObject* self, PyObject* value,
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #236 - Multi-tile entity size
|
||||||
|
PyObject* UIEntity::get_tile_size(PyUIEntityObject* self, void* closure) {
|
||||||
|
return sfVector2f_to_PyObject(sf::Vector2f(
|
||||||
|
static_cast<float>(self->data->tile_width),
|
||||||
|
static_cast<float>(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<int>(vec.x);
|
||||||
|
int th = static_cast<int>(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))
|
PyObject* UIEntity::die(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored))
|
||||||
{
|
{
|
||||||
// Check if entity has a grid
|
// Check if entity has a grid
|
||||||
|
|
@ -1596,6 +1650,13 @@ PyGetSetDef UIEntity::getsetters[] = {
|
||||||
"X component of sprite pixel offset.", (void*)0},
|
"X component of sprite pixel offset.", (void*)0},
|
||||||
{"sprite_offset_y", (getter)UIEntity::get_sprite_offset_member, (setter)UIEntity::set_sprite_offset_member,
|
{"sprite_offset_y", (getter)UIEntity::get_sprite_offset_member, (setter)UIEntity::set_sprite_offset_member,
|
||||||
"Y component of sprite pixel offset.", (void*)1},
|
"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
|
// #296 - Label system
|
||||||
{"labels", (getter)UIEntity::get_labels, (setter)UIEntity::set_labels,
|
{"labels", (getter)UIEntity::get_labels, (setter)UIEntity::set_labels,
|
||||||
"Set of string labels for collision/targeting (frozenset). "
|
"Set of string labels for collision/targeting (frozenset). "
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ public:
|
||||||
sf::Vector2f position; //(x,y) in grid coordinates; float for animation
|
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::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)
|
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<std::string> labels; // #296: entity label system for collision/targeting
|
std::unordered_set<std::string> labels; // #296: entity label system for collision/targeting
|
||||||
PyObject* step_callback = nullptr; // #299: callback for grid.step() turn management
|
PyObject* step_callback = nullptr; // #299: callback for grid.step() turn management
|
||||||
int default_behavior = 0; // #299: BehaviorType::IDLE - behavior to revert to after DONE
|
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 PyObject* get_sprite_offset_member(PyUIEntityObject* self, void* closure);
|
||||||
static int set_sprite_offset_member(PyUIEntityObject* self, PyObject* value, 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)
|
// #295 - cell_pos (integer logical position)
|
||||||
static PyObject* get_cell_pos(PyUIEntityObject* self, void* closure);
|
static PyObject* get_cell_pos(PyUIEntityObject* self, void* closure);
|
||||||
static int set_cell_pos(PyUIEntityObject* self, PyObject* value, void* closure);
|
static int set_cell_pos(PyUIEntityObject* self, PyObject* value, void* closure);
|
||||||
|
|
|
||||||
|
|
@ -198,10 +198,12 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
|
||||||
int totalEntities = entities->size();
|
int totalEntities = entities->size();
|
||||||
|
|
||||||
for (auto e : *entities) {
|
for (auto e : *entities) {
|
||||||
// Skip out-of-bounds entities for performance
|
// #236: Account for multi-tile entity size in frustum culling
|
||||||
// Check if entity is within visible bounds (with 2 cell margin for offset/oversized sprites)
|
int margin = 2;
|
||||||
if (e->position.x < left_edge - 2 || e->position.x >= left_edge + width_sq + 2 ||
|
if (e->position.x + e->tile_width < left_edge - margin ||
|
||||||
e->position.y < top_edge - 2 || e->position.y >= top_edge + height_sq + 2) {
|
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
|
continue; // Skip this entity as it's not visible
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
104
tests/unit/multi_tile_entity_test.py
Normal file
104
tests/unit/multi_tile_entity_test.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue