diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp index 5852dbd..033f1cf 100644 --- a/src/UIEntity.cpp +++ b/src/UIEntity.cpp @@ -18,8 +18,8 @@ -UIEntity::UIEntity() -: self(nullptr), grid(nullptr), position(0.0f, 0.0f) +UIEntity::UIEntity() +: self(nullptr), grid(nullptr), position(0.0f, 0.0f), sprite_offset(0.0f, 0.0f) { // Initialize sprite with safe defaults (sprite has its own safe constructor now) // gridstate vector starts empty - will be lazily initialized when needed @@ -172,19 +172,20 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) { float opacity = 1.0f; const char* name = nullptr; float x = 0.0f, y = 0.0f; - + PyObject* sprite_offset_obj = nullptr; + // Keywords list matches the new spec: positional args first, then all keyword args static const char* kwlist[] = { "grid_pos", "texture", "sprite_index", // Positional args (as per spec) // Keyword-only args - "grid", "visible", "opacity", "name", "x", "y", + "grid", "visible", "opacity", "name", "x", "y", "sprite_offset", nullptr }; - + // Parse arguments with | for optional positional args - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOiOifzff", const_cast(kwlist), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOiOifzffO", const_cast(kwlist), &grid_pos_obj, &texture, &sprite_index, // Positional - &grid_obj, &visible, &opacity, &name, &x, &y)) { + &grid_obj, &visible, &opacity, &name, &x, &y, &sprite_offset_obj)) { return -1; } @@ -257,7 +258,16 @@ int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) { // Set position using grid coordinates self->data->position = sf::Vector2f(x, y); - + + // Handle sprite_offset argument (optional tuple, default (0,0)) + if (sprite_offset_obj && sprite_offset_obj != Py_None) { + sf::Vector2f offset = PyObject_to_sfVector2f(sprite_offset_obj); + if (PyErr_Occurred()) { + return -1; + } + self->data->sprite_offset = offset; + } + // Set other properties (delegate to sprite) self->data->sprite.visible = visible; self->data->sprite.opacity = opacity; @@ -708,6 +718,47 @@ int UIEntity::set_grid(PyUIEntityObject* self, PyObject* value, void* closure) return 0; } +// sprite_offset property - Vector (tuple) +PyObject* UIEntity::get_sprite_offset(PyUIEntityObject* self, void* closure) { + return sfVector2f_to_PyObject(self->data->sprite_offset); +} + +int UIEntity::set_sprite_offset(PyUIEntityObject* self, PyObject* value, void* closure) { + sf::Vector2f vec = PyObject_to_sfVector2f(value); + if (PyErr_Occurred()) return -1; + self->data->sprite_offset = vec; + if (self->data->grid) self->data->grid->markDirty(); + return 0; +} + +// sprite_offset_x / sprite_offset_y individual components +PyObject* UIEntity::get_sprite_offset_member(PyUIEntityObject* self, void* closure) { + auto member_ptr = reinterpret_cast(closure); + if (member_ptr == 0) + return PyFloat_FromDouble(self->data->sprite_offset.x); + else + return PyFloat_FromDouble(self->data->sprite_offset.y); +} + +int UIEntity::set_sprite_offset_member(PyUIEntityObject* self, PyObject* value, void* closure) { + float val; + if (PyFloat_Check(value)) + val = PyFloat_AsDouble(value); + else if (PyLong_Check(value)) + val = PyLong_AsLong(value); + else { + PyErr_SetString(PyExc_TypeError, "sprite_offset component must be a number"); + return -1; + } + auto member_ptr = reinterpret_cast(closure); + if (member_ptr == 0) + self->data->sprite_offset.x = val; + else + self->data->sprite_offset.y = 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 @@ -1046,6 +1097,12 @@ PyGetSetDef UIEntity::getsetters[] = { "Collection of shader uniforms (read-only access to collection). " "Set uniforms via dict-like syntax: entity.uniforms['name'] = value. " "Supports float, vec2/3/4 tuples, PropertyBinding, and CallableBinding.", NULL}, + {"sprite_offset", (getter)UIEntity::get_sprite_offset, (setter)UIEntity::set_sprite_offset, + "Pixel offset for oversized sprites (Vector). Applied pre-zoom during grid rendering.", NULL}, + {"sprite_offset_x", (getter)UIEntity::get_sprite_offset_member, (setter)UIEntity::set_sprite_offset_member, + "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}, {NULL} /* Sentinel */ }; @@ -1070,18 +1127,28 @@ bool UIEntity::setProperty(const std::string& name, float value) { if (name == "draw_x" || name == "x") { // #176 - draw_x is preferred, x is alias position.x = value; // Don't update sprite position here - UIGrid::render() handles the pixel positioning - if (grid) grid->markDirty(); // #144 - Propagate to parent grid for texture caching + if (grid) grid->markCompositeDirty(); // #144 - Propagate to parent grid for texture caching return true; } else if (name == "draw_y" || name == "y") { // #176 - draw_y is preferred, y is alias position.y = value; // Don't update sprite position here - UIGrid::render() handles the pixel positioning - if (grid) grid->markDirty(); // #144 - Propagate to parent grid for texture caching + if (grid) grid->markCompositeDirty(); // #144 - Propagate to parent grid for texture caching return true; } else if (name == "sprite_scale") { sprite.setScale(sf::Vector2f(value, value)); - if (grid) grid->markDirty(); // #144 - Content change + if (grid) grid->markCompositeDirty(); // #144 - Content change + return true; + } + else if (name == "sprite_offset_x") { + sprite_offset.x = value; + if (grid) grid->markCompositeDirty(); + return true; + } + else if (name == "sprite_offset_y") { + sprite_offset.y = value; + if (grid) grid->markCompositeDirty(); return true; } // #106: Shader uniform properties - delegate to sprite @@ -1113,6 +1180,14 @@ bool UIEntity::getProperty(const std::string& name, float& value) const { value = sprite.getScale().x; // Assuming uniform scale return true; } + else if (name == "sprite_offset_x") { + value = sprite_offset.x; + return true; + } + else if (name == "sprite_offset_y") { + value = sprite_offset.y; + return true; + } // #106: Shader uniform properties - delegate to sprite if (sprite.getShaderProperty(name, value)) { return true; @@ -1122,7 +1197,8 @@ bool UIEntity::getProperty(const std::string& name, float& value) const { bool UIEntity::hasProperty(const std::string& name) const { // #176 - Float properties (draw_x/draw_y preferred, x/y are aliases) - if (name == "draw_x" || name == "draw_y" || name == "x" || name == "y" || name == "sprite_scale") { + if (name == "draw_x" || name == "draw_y" || name == "x" || name == "y" || name == "sprite_scale" + || name == "sprite_offset_x" || name == "sprite_offset_y") { return true; } // Int properties diff --git a/src/UIEntity.h b/src/UIEntity.h index 1eb508c..f7143ce 100644 --- a/src/UIEntity.h +++ b/src/UIEntity.h @@ -66,6 +66,7 @@ public: std::vector gridstate; UISprite sprite; sf::Vector2f position; //(x,y) in grid coordinates; float for animation + sf::Vector2f sprite_offset; // pixel offset for oversized sprites (applied pre-zoom) //void render(sf::Vector2f); //override final; UIEntity(); @@ -115,6 +116,10 @@ public: static int set_grid_int_member(PyUIEntityObject* self, PyObject* value, void* closure); static PyObject* get_grid(PyUIEntityObject* self, void* closure); static int set_grid(PyUIEntityObject* self, PyObject* value, void* closure); + static PyObject* get_sprite_offset(PyUIEntityObject* self, void* closure); + static int set_sprite_offset(PyUIEntityObject* self, PyObject* value, 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 PyMethodDef methods[]; static PyGetSetDef getsetters[]; static PyObject* repr(PyUIEntityObject* self); @@ -143,7 +148,8 @@ namespace mcrfpydef { " opacity (float): Opacity (0.0-1.0). Default: 1.0\n" " 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\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" "Attributes:\n" " pos (Vector): Pixel position relative to grid (requires grid attachment)\n" " x, y (float): Pixel position components (requires grid attachment)\n" @@ -154,7 +160,10 @@ namespace mcrfpydef { " sprite_index (int): Current sprite index\n" " visible (bool): Visibility state\n" " opacity (float): Opacity value\n" - " name (str): Element name"), + " name (str): Element name\n" + " sprite_offset (Vector): Pixel offset for oversized sprites\n" + " sprite_offset_x (float): X component of sprite offset\n" + " sprite_offset_y (float): Y component of sprite offset"), .tp_methods = UIEntity_all_methods, .tp_getset = UIEntity::getsetters, .tp_base = NULL, diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 06d5a37..5756589 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -148,6 +148,26 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) // #228 - Ensure renderTexture matches current game resolution ensureRenderTextureSize(); + if (!render_dirty && !composite_dirty) { + if (shader && shader->shader) { + sf::Vector2f resolution(box.getSize().x, box.getSize().y); + PyShader::applyEngineUniforms(*shader->shader, resolution); + + // Apply user uniforms + if (uniforms) { + uniforms->applyTo(*shader->shader); + } + + target.draw(output, shader->shader.get()); + } + else + { + output.setPosition(box.getPosition() + offset); + target.draw(output); + return; + } + } + // TODO: Apply opacity to output sprite // Get cell dimensions - use texture if available, otherwise defaults @@ -228,9 +248,9 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) for (auto e : *entities) { // Skip out-of-bounds entities for performance - // Check if entity is within visible bounds (with 1 cell margin for partially visible entities) - if (e->position.x < left_edge - 1 || e->position.x >= left_edge + width_sq + 1 || - e->position.y < top_edge - 1 || e->position.y >= top_edge + height_sq + 1) { + // 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) { continue; // Skip this entity as it's not visible } @@ -239,8 +259,8 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) //drawent.setScale(zoom, zoom); drawent.setScale(sf::Vector2f(zoom, zoom)); auto pixel_pos = sf::Vector2f( - (e->position.x*cell_width - left_spritepixels) * zoom, - (e->position.y*cell_height - top_spritepixels) * zoom ); + (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); entitiesRendered++; diff --git a/src/platform/SDL2Renderer.cpp b/src/platform/SDL2Renderer.cpp index 3f8bf23..b94604a 100644 --- a/src/platform/SDL2Renderer.cpp +++ b/src/platform/SDL2Renderer.cpp @@ -1111,11 +1111,16 @@ Texture::~Texture() { } Texture::Texture(const Texture& other) - : size_(other.size_), smooth_(other.smooth_), repeated_(other.repeated_) { - if (other.textureId_) { - // Create new texture with same properties - textureId_ = SDL2Renderer::getInstance().createTexture(size_.x, size_.y, nullptr); - // Note: Would need to copy pixel data for full implementation + : size_(other.size_), smooth_(other.smooth_), repeated_(other.repeated_), + flippedY_(false) { + if (other.textureId_ && size_.x > 0 && size_.y > 0) { + // Read back pixel data from source texture via FBO, then upload to new texture. + // copyToImage() normalizes orientation (flips FBO textures to top-to-bottom), + // so the copy is always in standard orientation: flippedY_ = false. + Image img = other.copyToImage(); + textureId_ = SDL2Renderer::getInstance().createTexture(size_.x, size_.y, img.getPixelsPtr()); + if (smooth_) SDL2Renderer::getInstance().setTextureSmooth(textureId_, true); + if (repeated_) SDL2Renderer::getInstance().setTextureRepeated(textureId_, true); } } @@ -1123,12 +1128,18 @@ Texture& Texture::operator=(const Texture& other) { if (this != &other) { if (textureId_) { SDL2Renderer::getInstance().deleteTexture(textureId_); + textureId_ = 0; } size_ = other.size_; smooth_ = other.smooth_; repeated_ = other.repeated_; - if (other.textureId_) { - textureId_ = SDL2Renderer::getInstance().createTexture(size_.x, size_.y, nullptr); + // copyToImage() normalizes orientation, so the result is always standard. + flippedY_ = false; + if (other.textureId_ && size_.x > 0 && size_.y > 0) { + Image img = other.copyToImage(); + textureId_ = SDL2Renderer::getInstance().createTexture(size_.x, size_.y, img.getPixelsPtr()); + if (smooth_) SDL2Renderer::getInstance().setTextureSmooth(textureId_, true); + if (repeated_) SDL2Renderer::getInstance().setTextureRepeated(textureId_, true); } } return *this; @@ -1211,7 +1222,46 @@ void Texture::setRepeated(bool repeated) { Image Texture::copyToImage() const { Image img; img.create(size_.x, size_.y); - // TODO: Read back from GPU texture + + if (!textureId_ || size_.x == 0 || size_.y == 0) { + return img; + } + + // OpenGL ES 2 / WebGL doesn't have glGetTexImage. + // Workaround: attach the texture to a temporary FBO and use glReadPixels. + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textureId_, 0); + + if (glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE) { + // Read RGBA pixels from the framebuffer + std::vector pixels(size_.x * size_.y * 4); + glReadPixels(0, 0, size_.x, size_.y, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + + // For normal textures (loaded via stbi/loadFromImage), glReadPixels + // returns rows in the same order they were uploaded — no flip needed. + // For FBO/RenderTexture textures (flippedY_), GL renders bottom-to-top, + // so readback is scene-bottom first — flip to get conventional top-to-bottom. + if (flippedY_) { + const unsigned int rowBytes = size_.x * 4; + std::vector rowBuf(rowBytes); + for (unsigned int y = 0; y < size_.y / 2; ++y) { + unsigned int opposite = size_.y - 1 - y; + std::memcpy(rowBuf.data(), &pixels[y * rowBytes], rowBytes); + std::memcpy(&pixels[y * rowBytes], &pixels[opposite * rowBytes], rowBytes); + std::memcpy(&pixels[opposite * rowBytes], rowBuf.data(), rowBytes); + } + } + + img.create(size_.x, size_.y, pixels.data()); + } + + // Restore previous framebuffer binding + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + return img; } diff --git a/tests/unit/entity_sprite_offset_test.py b/tests/unit/entity_sprite_offset_test.py new file mode 100644 index 0000000..26c71da --- /dev/null +++ b/tests/unit/entity_sprite_offset_test.py @@ -0,0 +1,91 @@ +"""Test Entity sprite_offset property (#233 sub-feature 1)""" +import mcrfpy +import sys + +passed = 0 +failed = 0 + +def test(name, condition): + global passed, failed + if condition: + passed += 1 + else: + failed += 1 + print(f"FAIL: {name}") + +# Setup: create a grid and texture for entity tests +scene = mcrfpy.Scene("test") +mcrfpy.current_scene = scene +tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) +grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160)) +scene.children.append(grid) + +# Test 1: Default sprite_offset is (0, 0) +e = mcrfpy.Entity(grid_pos=(3, 3), texture=tex, sprite_index=0, grid=grid) +test("default sprite_offset is (0,0)", + e.sprite_offset.x == 0.0 and e.sprite_offset.y == 0.0) + +# Test 2: Set sprite_offset via tuple +e.sprite_offset = (-8, -16) +test("set sprite_offset via tuple", + e.sprite_offset.x == -8.0 and e.sprite_offset.y == -16.0) + +# Test 3: Read individual components +test("sprite_offset_x component", e.sprite_offset_x == -8.0) +test("sprite_offset_y component", e.sprite_offset_y == -16.0) + +# Test 4: Set individual components +e.sprite_offset_x = 4.5 +e.sprite_offset_y = -3.0 +test("set sprite_offset_x", e.sprite_offset_x == 4.5) +test("set sprite_offset_y", e.sprite_offset_y == -3.0) +test("individual set reflects in vector", + e.sprite_offset.x == 4.5 and e.sprite_offset.y == -3.0) + +# Test 5: Constructor kwarg +e2 = mcrfpy.Entity(grid_pos=(1, 1), texture=tex, sprite_index=0, + grid=grid, sprite_offset=(-8, -16)) +test("constructor kwarg sprite_offset", + e2.sprite_offset.x == -8.0 and e2.sprite_offset.y == -16.0) + +# Test 6: Constructor default when not specified +e3 = mcrfpy.Entity(grid_pos=(2, 2), texture=tex, sprite_index=0, grid=grid) +test("constructor default (0,0)", + e3.sprite_offset.x == 0.0 and e3.sprite_offset.y == 0.0) + +# Test 7: Animation support - verify property is animatable +try: + e.animate("sprite_offset_x", -8.0, 0.5, "linear") + test("animate sprite_offset_x accepted", True) +except Exception as ex: + test(f"animate sprite_offset_x accepted: {ex}", False) + +try: + e.animate("sprite_offset_y", -16.0, 0.5, "linear") + test("animate sprite_offset_y accepted", True) +except Exception as ex: + test(f"animate sprite_offset_y accepted: {ex}", False) + +# Test 8: Animation actually changes value (step the game loop) +e.sprite_offset_x = 0.0 +e.animate("sprite_offset_x", 10.0, 0.1, "linear") +# Step enough to complete the animation +for _ in range(5): + mcrfpy.step(0.05) +test("animation changes sprite_offset_x", abs(e.sprite_offset_x - 10.0) < 0.1) + +# Test 9: Type errors +try: + e.sprite_offset_x = "bad" + test("type error on string", False) +except TypeError: + test("type error on string", True) + +# Summary +print(f"\n{passed} passed, {failed} failed out of {passed + failed} tests") +if failed: + print("FAIL") + sys.exit(1) +else: + print("PASS") + sys.exit(0)