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:
John McCardle 2026-04-10 02:57:47 -04:00
commit a4e0b97ecb
5 changed files with 186 additions and 7 deletions

View file

@ -115,9 +115,11 @@ std::vector<std::shared_ptr<UIEntity>> 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);
}
}

View file

@ -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<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))
{
// 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). "

View file

@ -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<std::string> 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);

View file

@ -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
}