Add composite sprite_grid for multi-tile entities, closes #237
Entities can now specify per-tile sprite indices via the sprite_grid property. When set, each tile in a multi-tile entity renders its own sprite from the texture atlas instead of the single entity sprite. API: entity.tile_size = (3, 2) entity.sprite_grid = [[10, 11, 12], [20, 21, 22]] entity.sprite_grid = None # revert to single sprite Accepts nested lists, flat lists, or tuples. Use -1 for empty tiles. Dimensions must match tile_width x tile_height. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f3ef81cf9c
commit
6bf5c451a3
4 changed files with 248 additions and 6 deletions
104
src/UIEntity.cpp
104
src/UIEntity.cpp
|
|
@ -899,6 +899,105 @@ int UIEntity::set_tile_height(PyUIEntityObject* self, PyObject* value, void* clo
|
|||
return 0;
|
||||
}
|
||||
|
||||
// #237 - Composite sprite grid
|
||||
PyObject* UIEntity::get_sprite_grid(PyUIEntityObject* self, void* closure) {
|
||||
auto& sg = self->data->sprite_grid;
|
||||
if (sg.empty()) {
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
int tw = self->data->tile_width;
|
||||
int th = self->data->tile_height;
|
||||
PyObject* rows = PyList_New(th);
|
||||
if (!rows) return NULL;
|
||||
for (int y = 0; y < th; y++) {
|
||||
PyObject* row = PyList_New(tw);
|
||||
if (!row) { Py_DECREF(rows); return NULL; }
|
||||
for (int x = 0; x < tw; x++) {
|
||||
int idx = sg[y * tw + x];
|
||||
PyList_SET_ITEM(row, x, PyLong_FromLong(idx));
|
||||
}
|
||||
PyList_SET_ITEM(rows, y, row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
int UIEntity::set_sprite_grid(PyUIEntityObject* self, PyObject* value, void* closure) {
|
||||
if (value == Py_None) {
|
||||
self->data->sprite_grid.clear();
|
||||
if (self->data->grid) self->data->grid->markDirty();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int tw = self->data->tile_width;
|
||||
int th = self->data->tile_height;
|
||||
|
||||
// Accept flat list or nested list
|
||||
if (!PyList_Check(value) && !PyTuple_Check(value)) {
|
||||
PyErr_SetString(PyExc_TypeError, "sprite_grid must be a list of lists, a flat list, or None");
|
||||
return -1;
|
||||
}
|
||||
|
||||
Py_ssize_t outer_len = PySequence_Size(value);
|
||||
if (outer_len < 0) return -1;
|
||||
|
||||
std::vector<int> new_grid;
|
||||
|
||||
// Check if it's nested (first element is a sequence)
|
||||
PyObject* first = (outer_len > 0) ? PySequence_GetItem(value, 0) : nullptr;
|
||||
bool nested = first && (PyList_Check(first) || PyTuple_Check(first));
|
||||
Py_XDECREF(first);
|
||||
|
||||
if (nested) {
|
||||
if (outer_len != th) {
|
||||
PyErr_Format(PyExc_ValueError,
|
||||
"sprite_grid has %zd rows, expected %d (tile_height)", outer_len, th);
|
||||
return -1;
|
||||
}
|
||||
new_grid.reserve(tw * th);
|
||||
for (int y = 0; y < th; y++) {
|
||||
PyObject* row = PySequence_GetItem(value, y);
|
||||
if (!row) return -1;
|
||||
Py_ssize_t row_len = PySequence_Size(row);
|
||||
if (row_len != tw) {
|
||||
Py_DECREF(row);
|
||||
PyErr_Format(PyExc_ValueError,
|
||||
"sprite_grid row %d has %zd items, expected %d (tile_width)", y, row_len, tw);
|
||||
return -1;
|
||||
}
|
||||
for (int x = 0; x < tw; x++) {
|
||||
PyObject* item = PySequence_GetItem(row, x);
|
||||
if (!item) { Py_DECREF(row); return -1; }
|
||||
long idx = PyLong_AsLong(item);
|
||||
Py_DECREF(item);
|
||||
if (idx == -1 && PyErr_Occurred()) { Py_DECREF(row); return -1; }
|
||||
new_grid.push_back(static_cast<int>(idx));
|
||||
}
|
||||
Py_DECREF(row);
|
||||
}
|
||||
} else {
|
||||
// Flat list
|
||||
if (outer_len != tw * th) {
|
||||
PyErr_Format(PyExc_ValueError,
|
||||
"sprite_grid has %zd items, expected %d (tile_width * tile_height)",
|
||||
outer_len, tw * th);
|
||||
return -1;
|
||||
}
|
||||
new_grid.reserve(tw * th);
|
||||
for (Py_ssize_t i = 0; i < outer_len; i++) {
|
||||
PyObject* item = PySequence_GetItem(value, i);
|
||||
if (!item) return -1;
|
||||
long idx = PyLong_AsLong(item);
|
||||
Py_DECREF(item);
|
||||
if (idx == -1 && PyErr_Occurred()) return -1;
|
||||
new_grid.push_back(static_cast<int>(idx));
|
||||
}
|
||||
}
|
||||
|
||||
self->data->sprite_grid = std::move(new_grid);
|
||||
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
|
||||
|
|
@ -1657,6 +1756,11 @@ PyGetSetDef UIEntity::getsetters[] = {
|
|||
"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},
|
||||
// #237 - Composite sprite grid
|
||||
{"sprite_grid", (getter)UIEntity::get_sprite_grid, (setter)UIEntity::set_sprite_grid,
|
||||
"Per-tile sprite indices for composite multi-tile entities (list of lists or None). "
|
||||
"Row-major, dimensions must match tile_width x tile_height. Use -1 for empty tiles. "
|
||||
"When set, each tile renders its own sprite index instead of the single entity sprite.", NULL},
|
||||
// #296 - Label system
|
||||
{"labels", (getter)UIEntity::get_labels, (setter)UIEntity::set_labels,
|
||||
"Set of string labels for collision/targeting (frozenset). "
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ public:
|
|||
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::vector<int> sprite_grid; // #237: per-tile sprite indices (row-major, -1 = empty)
|
||||
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
|
||||
|
|
@ -178,6 +179,9 @@ public:
|
|||
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);
|
||||
// #237 - Composite sprite grid
|
||||
static PyObject* get_sprite_grid(PyUIEntityObject* self, void* closure);
|
||||
static int set_sprite_grid(PyUIEntityObject* self, PyObject* value, void* closure);
|
||||
|
||||
// #295 - cell_pos (integer logical position)
|
||||
static PyObject* get_cell_pos(PyUIEntityObject* self, void* closure);
|
||||
|
|
@ -247,7 +251,8 @@ namespace mcrfpydef {
|
|||
" name (str): Element name for finding. Default: None\n"
|
||||
" x (float): X grid position override (tile coords). Default: 0\n"
|
||||
" y (float): Y grid position override (tile coords). Default: 0\n"
|
||||
" sprite_offset (tuple): Pixel offset for oversized sprites. Default: (0, 0)\n\n"
|
||||
" sprite_offset (tuple): Pixel offset for oversized sprites. Default: (0, 0)\n"
|
||||
" sprite_grid (list): Per-tile sprite indices for composite entities. Default: None\n\n"
|
||||
"Attributes:\n"
|
||||
" pos (Vector): Pixel position relative to grid (requires grid attachment)\n"
|
||||
" x, y (float): Pixel position components (requires grid attachment)\n"
|
||||
|
|
|
|||
|
|
@ -207,14 +207,30 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
|
|||
continue; // Skip this entity as it's not visible
|
||||
}
|
||||
|
||||
//auto drawent = e->cGrid->indexsprite.drawable();
|
||||
auto& drawent = e->sprite;
|
||||
//drawent.setScale(zoom, zoom);
|
||||
drawent.setScale(sf::Vector2f(zoom, zoom));
|
||||
auto pixel_pos = sf::Vector2f(
|
||||
(e->position.x*cell_width - left_spritepixels + e->sprite_offset.x) * zoom,
|
||||
(e->position.y*cell_height - top_spritepixels + e->sprite_offset.y) * zoom );
|
||||
drawent.render(pixel_pos, *activeTexture);
|
||||
|
||||
// #237: Composite sprite grid - render per-tile sprites
|
||||
if (!e->sprite_grid.empty() && e->sprite.getTexture()) {
|
||||
auto tex = e->sprite.getTexture();
|
||||
for (int dy = 0; dy < e->tile_height; dy++) {
|
||||
for (int dx = 0; dx < e->tile_width; dx++) {
|
||||
int idx = e->sprite_grid[dy * e->tile_width + dx];
|
||||
if (idx < 0) continue;
|
||||
auto tile_pos = sf::Vector2f(
|
||||
pixel_pos.x + dx * cell_width * zoom,
|
||||
pixel_pos.y + dy * cell_height * zoom);
|
||||
auto spr = tex->sprite(idx, tile_pos, sf::Vector2f(zoom, zoom));
|
||||
activeTexture->draw(spr);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single sprite path
|
||||
auto& drawent = e->sprite;
|
||||
drawent.setScale(sf::Vector2f(zoom, zoom));
|
||||
drawent.render(pixel_pos, *activeTexture);
|
||||
}
|
||||
|
||||
entitiesRendered++;
|
||||
}
|
||||
|
|
|
|||
117
tests/unit/sprite_grid_test.py
Normal file
117
tests/unit/sprite_grid_test.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Test composite sprite_grid for multi-tile entities (#237)"""
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
def main():
|
||||
errors = []
|
||||
|
||||
# Setup: create scene with grid
|
||||
scene = mcrfpy.Scene("test_sprite_grid")
|
||||
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)
|
||||
|
||||
# Test 1: sprite_grid defaults to None
|
||||
e = mcrfpy.Entity(grid_pos=(5, 5), texture=tex, sprite_index=0)
|
||||
grid.entities.append(e)
|
||||
ent = grid.entities[0]
|
||||
if ent.sprite_grid is not None:
|
||||
errors.append(f"Test 1 FAIL: default sprite_grid should be None, got {ent.sprite_grid}")
|
||||
else:
|
||||
print("Test 1 PASS: sprite_grid defaults to None")
|
||||
|
||||
# Test 2: set sprite_grid as nested list
|
||||
ent.tile_width = 3
|
||||
ent.tile_height = 2
|
||||
ent.sprite_grid = [
|
||||
[10, 11, 12],
|
||||
[20, 21, 22],
|
||||
]
|
||||
sg = ent.sprite_grid
|
||||
if sg != [[10, 11, 12], [20, 21, 22]]:
|
||||
errors.append(f"Test 2 FAIL: expected nested list, got {sg}")
|
||||
else:
|
||||
print("Test 2 PASS: set/get nested sprite_grid works")
|
||||
|
||||
# Test 3: set sprite_grid as flat list
|
||||
ent.sprite_grid = [1, 2, 3, 4, 5, 6]
|
||||
sg = ent.sprite_grid
|
||||
if sg != [[1, 2, 3], [4, 5, 6]]:
|
||||
errors.append(f"Test 3 FAIL: flat list should become nested, got {sg}")
|
||||
else:
|
||||
print("Test 3 PASS: flat list sprite_grid works")
|
||||
|
||||
# Test 4: -1 means empty tile
|
||||
ent.sprite_grid = [
|
||||
[10, -1, 12],
|
||||
[-1, 21, -1],
|
||||
]
|
||||
sg = ent.sprite_grid
|
||||
if sg != [[10, -1, 12], [-1, 21, -1]]:
|
||||
errors.append(f"Test 4 FAIL: -1 values not preserved, got {sg}")
|
||||
else:
|
||||
print("Test 4 PASS: -1 empty tiles preserved")
|
||||
|
||||
# Test 5: set to None clears sprite_grid
|
||||
ent.sprite_grid = None
|
||||
if ent.sprite_grid is not None:
|
||||
errors.append(f"Test 5 FAIL: setting None should clear sprite_grid")
|
||||
else:
|
||||
print("Test 5 PASS: setting None clears sprite_grid")
|
||||
|
||||
# Test 6: wrong size raises ValueError
|
||||
ent.tile_width = 2
|
||||
ent.tile_height = 2
|
||||
try:
|
||||
ent.sprite_grid = [1, 2, 3] # 3 items, need 4
|
||||
errors.append("Test 6 FAIL: should raise ValueError for wrong flat size")
|
||||
except ValueError:
|
||||
print("Test 6 PASS: wrong flat size raises ValueError")
|
||||
|
||||
# Test 7: wrong row count raises ValueError
|
||||
try:
|
||||
ent.sprite_grid = [[1, 2], [3, 4], [5, 6]] # 3 rows, need 2
|
||||
errors.append("Test 7 FAIL: should raise ValueError for wrong row count")
|
||||
except ValueError:
|
||||
print("Test 7 PASS: wrong row count raises ValueError")
|
||||
|
||||
# Test 8: wrong column count raises ValueError
|
||||
try:
|
||||
ent.sprite_grid = [[1, 2, 3], [4, 5, 6]] # 3 cols, need 2
|
||||
errors.append("Test 8 FAIL: should raise ValueError for wrong column count")
|
||||
except ValueError:
|
||||
print("Test 8 PASS: wrong column count raises ValueError")
|
||||
|
||||
# Test 9: sprite_grid with 1x1 entity
|
||||
ent.tile_width = 1
|
||||
ent.tile_height = 1
|
||||
ent.sprite_grid = [[5]]
|
||||
sg = ent.sprite_grid
|
||||
if sg != [[5]]:
|
||||
errors.append(f"Test 9 FAIL: 1x1 sprite_grid should work, got {sg}")
|
||||
else:
|
||||
print("Test 9 PASS: 1x1 sprite_grid works")
|
||||
|
||||
# Test 10: tuple input works too
|
||||
ent.tile_width = 2
|
||||
ent.tile_height = 1
|
||||
ent.sprite_grid = (10, 11)
|
||||
sg = ent.sprite_grid
|
||||
if sg != [[10, 11]]:
|
||||
errors.append(f"Test 10 FAIL: tuple input should work, got {sg}")
|
||||
else:
|
||||
print("Test 10 PASS: tuple input works")
|
||||
|
||||
# Summary
|
||||
if errors:
|
||||
print(f"\nFAILED: {len(errors)} errors:")
|
||||
for e in errors:
|
||||
print(f" {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"\nAll tests passed!")
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue