diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp index 23efc4d..557f5a9 100644 --- a/src/UIEntity.cpp +++ b/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 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(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(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). " diff --git a/src/UIEntity.h b/src/UIEntity.h index 7c58201..7c3bc3b 100644 --- a/src/UIEntity.h +++ b/src/UIEntity.h @@ -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 sprite_grid; // #237: per-tile sprite indices (row-major, -1 = empty) 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 @@ -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" diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index d681ebe..0c7da06 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -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++; } diff --git a/tests/unit/sprite_grid_test.py b/tests/unit/sprite_grid_test.py new file mode 100644 index 0000000..18d153a --- /dev/null +++ b/tests/unit/sprite_grid_test.py @@ -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()