From da434dcc64b9f596adb86da3419350a7f450e327 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sun, 25 Jan 2026 23:20:52 -0500 Subject: [PATCH] Rotation --- src/UIArc.cpp | 59 +++++++- src/UIBase.h | 19 +++ src/UICaption.cpp | 79 ++++++++++- src/UICircle.cpp | 61 +++++++- src/UIDrawable.cpp | 138 +++++++++++++++++++ src/UIDrawable.h | 18 +++ src/UIFrame.cpp | 81 +++++++++-- src/UIGrid.cpp | 176 ++++++++++++++++++++---- src/UIGrid.h | 7 + src/UILine.cpp | 60 +++++++- src/UISprite.cpp | 75 +++++++++- tests/unit/grid_camera_rotation_test.py | 86 ++++++++++++ tests/unit/rotation_test.py | 163 ++++++++++++++++++++++ tests/unit/rotation_visual_test.py | 114 +++++++++++++++ 14 files changed, 1076 insertions(+), 60 deletions(-) create mode 100644 tests/unit/grid_camera_rotation_test.py create mode 100644 tests/unit/rotation_test.py create mode 100644 tests/unit/rotation_visual_test.py diff --git a/src/UIArc.cpp b/src/UIArc.cpp index 2425d48..2cf19df 100644 --- a/src/UIArc.cpp +++ b/src/UIArc.cpp @@ -134,9 +134,13 @@ void UIArc::render(sf::Vector2f offset, sf::RenderTarget& target) { rebuildVertices(); } - // Apply offset by creating a transformed copy + // Apply offset and rotation by creating a transform sf::Transform transform; transform.translate(offset); + // Apply rotation around origin + transform.translate(origin); + transform.rotate(rotation); + transform.translate(-origin); target.draw(vertices, transform); } @@ -146,9 +150,25 @@ UIDrawable* UIArc::click_at(sf::Vector2f point) { // #184: Also check for Python subclass (might have on_click method) if (!click_callable && !is_python_subclass) return nullptr; - // Calculate distance from center - float dx = point.x - center.x; - float dy = point.y - center.y; + // Transform click point to local coordinates accounting for rotation + sf::Vector2f localPoint; + if (rotation != 0.0f) { + // Build transform: rotate around origin (matches render transform) + sf::Transform transform; + transform.translate(origin); + transform.rotate(rotation); + transform.translate(-origin); + + // Apply inverse transform to get local coordinates + sf::Transform inverse = transform.getInverse(); + localPoint = inverse.transformPoint(point); + } else { + localPoint = point; + } + + // Calculate distance from center in local (unrotated) space + float dx = localPoint.x - center.x; + float dy = localPoint.y - center.y; float dist = std::sqrt(dx * dx + dy * dy); // Check if within the arc's radial range @@ -249,6 +269,21 @@ bool UIArc::setProperty(const std::string& name, float value) { markCompositeDirty(); // #144 - Position change, texture still valid return true; } + else if (name == "rotation") { + rotation = value; + markDirty(); + return true; + } + else if (name == "origin_x") { + origin.x = value; + markDirty(); + return true; + } + else if (name == "origin_y") { + origin.y = value; + markDirty(); + return true; + } return false; } @@ -295,6 +330,18 @@ bool UIArc::getProperty(const std::string& name, float& value) const { value = center.y; return true; } + else if (name == "rotation") { + value = rotation; + return true; + } + else if (name == "origin_x") { + value = origin.x; + return true; + } + else if (name == "origin_y") { + value = origin.y; + return true; + } return false; } @@ -317,7 +364,8 @@ bool UIArc::getProperty(const std::string& name, sf::Vector2f& value) const { bool UIArc::hasProperty(const std::string& name) const { // Float properties if (name == "radius" || name == "start_angle" || name == "end_angle" || - name == "thickness" || name == "x" || name == "y") { + name == "thickness" || name == "x" || name == "y" || + name == "rotation" || name == "origin_x" || name == "origin_y") { return true; } // Color properties @@ -453,6 +501,7 @@ PyGetSetDef UIArc::getsetters[] = { UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIARC), UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIARC), + UIDRAWABLE_ROTATION_GETSETTERS(PyObjectsEnum::UIARC), {NULL} }; diff --git a/src/UIBase.h b/src/UIBase.h index e21e608..9e4ae3b 100644 --- a/src/UIBase.h +++ b/src/UIBase.h @@ -282,6 +282,25 @@ static int UIDrawable_set_opacity(T* self, PyObject* value, void* closure) "Invalid for horizontally-centered alignments (CENTER_LEFT, CENTER_RIGHT, CENTER)." \ ), (void*)type_enum} +// Rotation support - rotation angle and transform origin +#define UIDRAWABLE_ROTATION_GETSETTERS(type_enum) \ + {"rotation", (getter)UIDrawable::get_rotation, (setter)UIDrawable::set_rotation, \ + MCRF_PROPERTY(rotation, \ + "Rotation angle in degrees (clockwise around origin). " \ + "Animatable property." \ + ), (void*)type_enum}, \ + {"origin", (getter)UIDrawable::get_origin, (setter)UIDrawable::set_origin, \ + MCRF_PROPERTY(origin, \ + "Transform origin as Vector (pivot point for rotation). " \ + "Default (0,0) is top-left; set to (w/2, h/2) to rotate around center." \ + ), (void*)type_enum}, \ + {"rotate_with_camera", (getter)UIDrawable::get_rotate_with_camera, (setter)UIDrawable::set_rotate_with_camera, \ + MCRF_PROPERTY(rotate_with_camera, \ + "Whether to rotate visually with parent Grid's camera_rotation (bool). " \ + "False (default): stay screen-aligned. True: tilt with camera. " \ + "Only affects children of UIGrid; ignored for other parents." \ + ), (void*)type_enum} + // #106: Shader support - GPU-accelerated visual effects #define UIDRAWABLE_SHADER_GETSETTERS(type_enum) \ {"shader", (getter)UIDrawable::get_shader, (setter)UIDrawable::set_shader, \ diff --git a/src/UICaption.cpp b/src/UICaption.cpp index eed94b8..df64285 100644 --- a/src/UICaption.cpp +++ b/src/UICaption.cpp @@ -25,11 +25,40 @@ UICaption::UICaption() UIDrawable* UICaption::click_at(sf::Vector2f point) { // #184: Also check for Python subclass (might have on_click method) - if (click_callable || is_python_subclass) - { - if (text.getGlobalBounds().contains(point)) return this; + if (!click_callable && !is_python_subclass) return nullptr; + + // Get text dimensions from local bounds + sf::FloatRect localBounds = text.getLocalBounds(); + float w = localBounds.width; + float h = localBounds.height; + // Account for text origin offset (SFML text has non-zero left/top in local bounds) + float textOffsetX = localBounds.left; + float textOffsetY = localBounds.top; + + // Transform click point to local coordinates accounting for rotation + sf::Vector2f localPoint; + if (rotation != 0.0f) { + // Build transform: translate to position, then rotate around origin + sf::Transform transform; + transform.translate(position); + transform.translate(origin); + transform.rotate(rotation); + transform.translate(-origin); + + // Apply inverse transform to get local coordinates + sf::Transform inverse = transform.getInverse(); + localPoint = inverse.transformPoint(point); + } else { + // No rotation - simple subtraction + localPoint = point - position; } - return NULL; + + // Check if local point is within bounds (accounting for text offset) + if (localPoint.x >= textOffsetX && localPoint.y >= textOffsetY && + localPoint.x < textOffsetX + w && localPoint.y < textOffsetY + h) { + return this; + } + return nullptr; } void UICaption::render(sf::Vector2f offset, sf::RenderTarget& target) @@ -42,6 +71,10 @@ void UICaption::render(sf::Vector2f offset, sf::RenderTarget& target) color.a = static_cast(255 * opacity); text.setFillColor(color); + // Apply rotation and origin + text.setOrigin(origin); + text.setRotation(rotation); + // #106: Shader rendering path if (shader && shader->shader) { // Get the text bounds for rendering @@ -350,6 +383,7 @@ PyGetSetDef UICaption::getsetters[] = { UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UICAPTION), UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UICAPTION), UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UICAPTION), + UIDRAWABLE_ROTATION_GETSETTERS(PyObjectsEnum::UICAPTION), {NULL} }; @@ -631,6 +665,24 @@ bool UICaption::setProperty(const std::string& name, float value) { markDirty(); // #144 - Z-order change affects parent return true; } + else if (name == "rotation") { + rotation = value; + text.setRotation(rotation); + markDirty(); + return true; + } + else if (name == "origin_x") { + origin.x = value; + text.setOrigin(origin); + markDirty(); + return true; + } + else if (name == "origin_y") { + origin.y = value; + text.setOrigin(origin); + markDirty(); + return true; + } // #106: Check for shader uniform properties if (setShaderProperty(name, value)) { return true; @@ -714,6 +766,18 @@ bool UICaption::getProperty(const std::string& name, float& value) const { value = static_cast(z_index); return true; } + else if (name == "rotation") { + value = rotation; + return true; + } + else if (name == "origin_x") { + value = origin.x; + return true; + } + else if (name == "origin_y") { + value = origin.y; + return true; + } // #106: Check for shader uniform properties if (getShaderProperty(name, value)) { return true; @@ -748,7 +812,8 @@ bool UICaption::hasProperty(const std::string& name) const { name == "fill_color.r" || name == "fill_color.g" || name == "fill_color.b" || name == "fill_color.a" || name == "outline_color.r" || name == "outline_color.g" || - name == "outline_color.b" || name == "outline_color.a") { + name == "outline_color.b" || name == "outline_color.a" || + name == "rotation" || name == "origin_x" || name == "origin_y") { return true; } // Color properties @@ -759,6 +824,10 @@ bool UICaption::hasProperty(const std::string& name) const { if (name == "text") { return true; } + // Vector2f properties + if (name == "origin") { + return true; + } // #106: Check for shader uniform properties if (hasShaderProperty(name)) { return true; diff --git a/src/UICircle.cpp b/src/UICircle.cpp index 44f950b..7224a6c 100644 --- a/src/UICircle.cpp +++ b/src/UICircle.cpp @@ -115,6 +115,12 @@ void UICircle::render(sf::Vector2f offset, sf::RenderTarget& target) { // Apply position and offset shape.setPosition(position + offset); + // Apply rotation (using UIDrawable::origin as offset from circle center) + // The shape already has its origin at center (radius, radius) + // UIDrawable::origin provides additional offset from that center + shape.setOrigin(radius + origin.x, radius + origin.y); + shape.setRotation(rotation); + // Apply opacity to colors sf::Color render_fill = fill_color; render_fill.a = static_cast(fill_color.a * opacity); @@ -131,9 +137,30 @@ UIDrawable* UICircle::click_at(sf::Vector2f point) { // #184: Also check for Python subclass (might have on_click method) if (!click_callable && !is_python_subclass) return nullptr; + // Calculate the actual circle center accounting for rotation around origin + // In render(), the circle is drawn at position with origin offset (radius + origin.x/y) + // So the visual center moves when rotated around a non-default origin + sf::Vector2f circleCenter = position; + + if (rotation != 0.0f && (origin.x != 0.0f || origin.y != 0.0f)) { + // The circle center in local space (relative to position) is at (0, 0) + // With rotation around (origin.x, origin.y), the center moves + float rad = rotation * 3.14159265f / 180.0f; + float cos_r = std::cos(rad); + float sin_r = std::sin(rad); + + // Rotate (0,0) around origin + float dx = -origin.x; + float dy = -origin.y; + float rotatedX = dx * cos_r - dy * sin_r + origin.x; + float rotatedY = dx * sin_r + dy * cos_r + origin.y; + + circleCenter = position + sf::Vector2f(rotatedX, rotatedY); + } + // Check if point is within the circle (including outline) - float dx = point.x - position.x; - float dy = point.y - position.y; + float dx = point.x - circleCenter.x; + float dy = point.y - circleCenter.y; float distance = std::sqrt(dx * dx + dy * dy); float effective_radius = radius + outline_thickness; @@ -188,6 +215,21 @@ bool UICircle::setProperty(const std::string& name, float value) { position.y = value; markCompositeDirty(); // #144 - Position change, texture still valid return true; + } else if (name == "rotation") { + rotation = value; + shape.setRotation(rotation); + markDirty(); + return true; + } else if (name == "origin_x") { + origin.x = value; + shape.setOrigin(radius + origin.x, radius + origin.y); + markDirty(); + return true; + } else if (name == "origin_y") { + origin.y = value; + shape.setOrigin(radius + origin.x, radius + origin.y); + markDirty(); + return true; } return false; } @@ -227,6 +269,15 @@ bool UICircle::getProperty(const std::string& name, float& value) const { } else if (name == "y") { value = position.y; return true; + } else if (name == "rotation") { + value = rotation; + return true; + } else if (name == "origin_x") { + value = origin.x; + return true; + } else if (name == "origin_y") { + value = origin.y; + return true; } return false; } @@ -253,7 +304,8 @@ bool UICircle::getProperty(const std::string& name, sf::Vector2f& value) const { bool UICircle::hasProperty(const std::string& name) const { // Float properties if (name == "radius" || name == "outline" || - name == "x" || name == "y") { + name == "x" || name == "y" || + name == "rotation" || name == "origin_x" || name == "origin_y") { return true; } // Color properties @@ -261,7 +313,7 @@ bool UICircle::hasProperty(const std::string& name) const { return true; } // Vector2f properties - if (name == "center" || name == "position") { + if (name == "center" || name == "position" || name == "origin") { return true; } return false; @@ -399,6 +451,7 @@ PyGetSetDef UICircle::getsetters[] = { UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UICIRCLE), UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UICIRCLE), + UIDRAWABLE_ROTATION_GETSETTERS(PyObjectsEnum::UICIRCLE), {NULL} }; diff --git a/src/UIDrawable.cpp b/src/UIDrawable.cpp index 2009d11..a1fe1cd 100644 --- a/src/UIDrawable.cpp +++ b/src/UIDrawable.cpp @@ -46,6 +46,9 @@ UIDrawable::UIDrawable(const UIDrawable& other) : z_index(other.z_index), name(other.name), position(other.position), + rotation(other.rotation), + origin(other.origin), + rotate_with_camera(other.rotate_with_camera), visible(other.visible), opacity(other.opacity), hovered(false), // Don't copy hover state @@ -82,6 +85,9 @@ UIDrawable& UIDrawable::operator=(const UIDrawable& other) { z_index = other.z_index; name = other.name; position = other.position; + rotation = other.rotation; + origin = other.origin; + rotate_with_camera = other.rotate_with_camera; visible = other.visible; opacity = other.opacity; hovered = false; // Don't copy hover state @@ -128,6 +134,9 @@ UIDrawable::UIDrawable(UIDrawable&& other) noexcept : z_index(other.z_index), name(std::move(other.name)), position(other.position), + rotation(other.rotation), + origin(other.origin), + rotate_with_camera(other.rotate_with_camera), visible(other.visible), opacity(other.opacity), hovered(other.hovered), @@ -157,6 +166,9 @@ UIDrawable& UIDrawable::operator=(UIDrawable&& other) noexcept { z_index = other.z_index; name = std::move(other.name); position = other.position; + rotation = other.rotation; + origin = other.origin; + rotate_with_camera = other.rotate_with_camera; visible = other.visible; opacity = other.opacity; hovered = other.hovered; // #140 @@ -589,6 +601,132 @@ int UIDrawable::set_pos(PyObject* self, PyObject* value, void* closure) { return 0; } +// Rotation property getter/setter +PyObject* UIDrawable::get_rotation(PyObject* self, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; + + return PyFloat_FromDouble(drawable->rotation); +} + +int UIDrawable::set_rotation(PyObject* self, PyObject* value, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return -1; + + float val = 0.0f; + if (PyFloat_Check(value)) { + val = PyFloat_AsDouble(value); + } else if (PyLong_Check(value)) { + val = static_cast(PyLong_AsLong(value)); + } else { + PyErr_SetString(PyExc_TypeError, "rotation must be a number (int or float)"); + return -1; + } + + drawable->rotation = val; + drawable->markDirty(); + return 0; +} + +// Origin property getter/setter +PyObject* UIDrawable::get_origin(PyObject* self, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; + + // Create a Python Vector object from origin + PyObject* module = PyImport_ImportModule("mcrfpy"); + if (!module) return NULL; + + PyObject* vector_type = PyObject_GetAttrString(module, "Vector"); + Py_DECREF(module); + if (!vector_type) return NULL; + + PyObject* args = Py_BuildValue("(ff)", drawable->origin.x, drawable->origin.y); + PyObject* result = PyObject_CallObject(vector_type, args); + Py_DECREF(vector_type); + Py_DECREF(args); + + return result; +} + +int UIDrawable::set_origin(PyObject* self, PyObject* value, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return -1; + + // Accept tuple or Vector + float x, y; + if (PyTuple_Check(value) && PyTuple_Size(value) == 2) { + PyObject* x_obj = PyTuple_GetItem(value, 0); + PyObject* y_obj = PyTuple_GetItem(value, 1); + + if (PyFloat_Check(x_obj) || PyLong_Check(x_obj)) { + x = PyFloat_Check(x_obj) ? PyFloat_AsDouble(x_obj) : static_cast(PyLong_AsLong(x_obj)); + } else { + PyErr_SetString(PyExc_TypeError, "origin x must be a number"); + return -1; + } + + if (PyFloat_Check(y_obj) || PyLong_Check(y_obj)) { + y = PyFloat_Check(y_obj) ? PyFloat_AsDouble(y_obj) : static_cast(PyLong_AsLong(y_obj)); + } else { + PyErr_SetString(PyExc_TypeError, "origin y must be a number"); + return -1; + } + } else { + // Try to get as Vector + PyObject* module = PyImport_ImportModule("mcrfpy"); + if (!module) return -1; + + PyObject* vector_type = PyObject_GetAttrString(module, "Vector"); + Py_DECREF(module); + if (!vector_type) return -1; + + int is_vector = PyObject_IsInstance(value, vector_type); + Py_DECREF(vector_type); + + if (is_vector) { + PyVectorObject* vec = (PyVectorObject*)value; + x = vec->data.x; + y = vec->data.y; + } else { + PyErr_SetString(PyExc_TypeError, "origin must be a tuple (x, y) or Vector"); + return -1; + } + } + + drawable->origin = sf::Vector2f(x, y); + drawable->markDirty(); + return 0; +} + +// rotate_with_camera property getter/setter +PyObject* UIDrawable::get_rotate_with_camera(PyObject* self, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; + + return PyBool_FromLong(drawable->rotate_with_camera); +} + +int UIDrawable::set_rotate_with_camera(PyObject* self, PyObject* value, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return -1; + + if (!PyBool_Check(value)) { + PyErr_SetString(PyExc_TypeError, "rotate_with_camera must be a boolean"); + return -1; + } + + drawable->rotate_with_camera = PyObject_IsTrue(value); + drawable->markDirty(); + return 0; +} + // #221 - Grid coordinate properties (only valid when parent is UIGrid) PyObject* UIDrawable::get_grid_pos(PyObject* self, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); diff --git a/src/UIDrawable.h b/src/UIDrawable.h index 4f443f6..7f3be1e 100644 --- a/src/UIDrawable.h +++ b/src/UIDrawable.h @@ -99,6 +99,14 @@ public: static PyObject* get_pos(PyObject* self, void* closure); static int set_pos(PyObject* self, PyObject* value, void* closure); + // Rotation getters/setters for Python API + static PyObject* get_rotation(PyObject* self, void* closure); + static int set_rotation(PyObject* self, PyObject* value, void* closure); + static PyObject* get_origin(PyObject* self, void* closure); + static int set_origin(PyObject* self, PyObject* value, void* closure); + static PyObject* get_rotate_with_camera(PyObject* self, void* closure); + static int set_rotate_with_camera(PyObject* self, PyObject* value, void* closure); + // #221 - Grid coordinate properties (only valid when parent is UIGrid) static PyObject* get_grid_pos(PyObject* self, void* closure); static int set_grid_pos(PyObject* self, PyObject* value, void* closure); @@ -117,6 +125,16 @@ public: // Position in pixel coordinates (moved from derived classes) sf::Vector2f position; + // Rotation in degrees (clockwise around origin) + float rotation = 0.0f; + + // Transform origin point (relative to position, pivot for rotation/scale) + sf::Vector2f origin; + + // Whether to rotate visually with parent Grid's camera_rotation + // Only affects children of UIGrid; ignored for other parents + bool rotate_with_camera = false; + // Parent-child hierarchy (#122) std::weak_ptr parent; diff --git a/src/UIFrame.cpp b/src/UIFrame.cpp index cfc9b97..02a8bde 100644 --- a/src/UIFrame.cpp +++ b/src/UIFrame.cpp @@ -15,14 +15,30 @@ UIDrawable* UIFrame::click_at(sf::Vector2f point) { - // Check bounds first (optimization) - float x = position.x, y = position.y, w = box.getSize().x, h = box.getSize().y; - if (point.x < x || point.y < y || point.x >= x+w || point.y >= y+h) { - return nullptr; + float w = box.getSize().x, h = box.getSize().y; + + // Transform click point to local coordinates accounting for rotation + sf::Vector2f localPoint; + if (rotation != 0.0f) { + // Build transform: translate to position, then rotate around origin + sf::Transform transform; + transform.translate(position); + transform.translate(origin); + transform.rotate(rotation); + transform.translate(-origin); + + // Apply inverse transform to get local coordinates + sf::Transform inverse = transform.getInverse(); + localPoint = inverse.transformPoint(point); + } else { + // No rotation - simple subtraction + localPoint = point - position; } - // Transform to local coordinates for children - sf::Vector2f localPoint = point - position; + // Check if local point is within bounds (0,0 to w,h in local space) + if (localPoint.x < 0 || localPoint.y < 0 || localPoint.x >= w || localPoint.y >= h) { + return nullptr; + } // Check children in reverse order (top to bottom, highest z-index first) for (auto it = children->rbegin(); it != children->rend(); ++it) { @@ -140,8 +156,10 @@ void UIFrame::render(sf::Vector2f offset, sf::RenderTarget& target) // Clear the RenderTexture render_texture->clear(sf::Color::Transparent); - // Draw the frame box to RenderTexture + // Draw the frame box to RenderTexture (without rotation - that's applied to the final sprite) box.setPosition(0, 0); // Render at origin in texture + box.setOrigin(0, 0); // No origin offset in texture + box.setRotation(0); // No rotation in texture render_texture->draw(box); // Sort children by z_index if needed @@ -172,6 +190,10 @@ void UIFrame::render(sf::Vector2f offset, sf::RenderTarget& target) // Use `position` instead of box.getPosition() - box was set to (0,0) for texture rendering render_sprite.setPosition(offset + position); + // Apply rotation to the rendered sprite (children rotate with parent) + render_sprite.setOrigin(origin); + render_sprite.setRotation(rotation); + // #106: Apply shader if set if (shader && shader->shader) { // Apply engine uniforms (time, resolution, mouse, texture) @@ -193,6 +215,8 @@ void UIFrame::render(sf::Vector2f offset, sf::RenderTarget& target) // Standard rendering without caching // Restore box position from `position` - may have been set to (0,0) by previous texture render box.setPosition(offset + position); + box.setOrigin(origin); + box.setRotation(rotation); target.draw(box); box.setPosition(position); // Restore to canonical position @@ -205,6 +229,9 @@ void UIFrame::render(sf::Vector2f offset, sf::RenderTarget& target) children_need_sort = false; } + // Render children - note: in non-texture mode, children don't automatically + // rotate with parent. Use clip_children=True or cache_subtree=True if you need + // children to rotate with the frame. for (auto drawable : *children) { drawable->render(offset + position, target); // Use `position` as source of truth } @@ -512,6 +539,7 @@ PyGetSetDef UIFrame::getsetters[] = { UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIFRAME), UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIFRAME), UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UIFRAME), + UIDRAWABLE_ROTATION_GETSETTERS(PyObjectsEnum::UIFRAME), {NULL} }; @@ -856,6 +884,21 @@ bool UIFrame::setProperty(const std::string& name, float value) { box.setOutlineColor(color); markDirty(); return true; + } else if (name == "rotation") { + rotation = value; + box.setRotation(rotation); + markDirty(); + return true; + } else if (name == "origin_x") { + origin.x = value; + box.setOrigin(origin); + markDirty(); + return true; + } else if (name == "origin_y") { + origin.y = value; + box.setOrigin(origin); + markDirty(); + return true; } // #106: Check for shader uniform properties if (setShaderProperty(name, value)) { @@ -887,11 +930,16 @@ bool UIFrame::setProperty(const std::string& name, const sf::Vector2f& value) { box.setSize(value); if (use_render_texture) { // Need to recreate RenderTexture with new size - enableRenderTexture(static_cast(value.x), + enableRenderTexture(static_cast(value.x), static_cast(value.y)); } markDirty(); return true; + } else if (name == "origin") { + origin = value; + box.setOrigin(origin); + markDirty(); + return true; } return false; } @@ -936,6 +984,15 @@ bool UIFrame::getProperty(const std::string& name, float& value) const { } else if (name == "outline_color.a") { value = box.getOutlineColor().a; return true; + } else if (name == "rotation") { + value = rotation; + return true; + } else if (name == "origin_x") { + value = origin.x; + return true; + } else if (name == "origin_y") { + value = origin.y; + return true; } // #106: Check for shader uniform properties if (getShaderProperty(name, value)) { @@ -962,6 +1019,9 @@ bool UIFrame::getProperty(const std::string& name, sf::Vector2f& value) const { } else if (name == "size") { value = box.getSize(); return true; + } else if (name == "origin") { + value = origin; + return true; } return false; } @@ -973,7 +1033,8 @@ bool UIFrame::hasProperty(const std::string& name) const { name == "fill_color.r" || name == "fill_color.g" || name == "fill_color.b" || name == "fill_color.a" || name == "outline_color.r" || name == "outline_color.g" || - name == "outline_color.b" || name == "outline_color.a") { + name == "outline_color.b" || name == "outline_color.a" || + name == "rotation" || name == "origin_x" || name == "origin_y") { return true; } // Color properties @@ -981,7 +1042,7 @@ bool UIFrame::hasProperty(const std::string& name) const { return true; } // Vector2f properties - if (name == "position" || name == "size") { + if (name == "position" || name == "size" || name == "origin") { return true; } // #106: Check for shader uniform properties diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 008b2ea..7aea414 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -145,28 +145,59 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) // TODO: Apply opacity to output sprite - output.setPosition(box.getPosition() + offset); // output sprite can move; update position when drawing - // output size can change; update size when drawing - output.setTextureRect( - sf::IntRect(0, 0, - box.getSize().x, box.getSize().y)); - renderTexture.clear(fill_color); - // Get cell dimensions - use texture if available, otherwise defaults int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH; int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT; - - // sprites that are visible according to zoom, center_x, center_y, and box width + + // Determine if we need camera rotation handling + bool has_camera_rotation = (camera_rotation != 0.0f); + float grid_w_px = box.getSize().x; + float grid_h_px = box.getSize().y; + + // Calculate AABB for rotated view (if camera rotation is active) + float rad = camera_rotation * (M_PI / 180.0f); + float cos_r = std::cos(rad); + float sin_r = std::sin(rad); + float abs_cos = std::abs(cos_r); + float abs_sin = std::abs(sin_r); + + // AABB dimensions of the rotated viewport + float aabb_w = grid_w_px * abs_cos + grid_h_px * abs_sin; + float aabb_h = grid_w_px * abs_sin + grid_h_px * abs_cos; + + // Choose which texture to render to + sf::RenderTexture* activeTexture = &renderTexture; + + if (has_camera_rotation) { + // Ensure rotation texture is large enough + unsigned int needed_size = static_cast(std::max(aabb_w, aabb_h) + 1); + if (rotationTextureSize < needed_size) { + rotationTexture.create(needed_size, needed_size); + rotationTextureSize = needed_size; + } + activeTexture = &rotationTexture; + activeTexture->clear(fill_color); + } else { + output.setPosition(box.getPosition() + offset); + output.setTextureRect(sf::IntRect(0, 0, grid_w_px, grid_h_px)); + renderTexture.clear(fill_color); + } + + // Calculate visible tile range + // For camera rotation, use AABB dimensions; otherwise use grid dimensions + float render_w = has_camera_rotation ? aabb_w : grid_w_px; + float render_h = has_camera_rotation ? aabb_h : grid_h_px; + float center_x_sq = center_x / cell_width; float center_y_sq = center_y / cell_height; - float width_sq = box.getSize().x / (cell_width * zoom); - float height_sq = box.getSize().y / (cell_height * zoom); + float width_sq = render_w / (cell_width * zoom); + float height_sq = render_h / (cell_height * zoom); float left_edge = center_x_sq - (width_sq / 2.0); float top_edge = center_y_sq - (height_sq / 2.0); - int left_spritepixels = center_x - (box.getSize().x / 2.0 / zoom); - int top_spritepixels = center_y - (box.getSize().y / 2.0 / zoom); + int left_spritepixels = center_x - (render_w / 2.0 / zoom); + int top_spritepixels = center_y - (render_h / 2.0 / zoom); int x_limit = left_edge + width_sq + 2; if (x_limit > grid_w) x_limit = grid_w; @@ -179,7 +210,7 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) sortLayers(); for (auto& layer : layers) { if (layer->z_index >= 0) break; // Stop at layers that go above entities - layer->render(renderTexture, left_spritepixels, top_spritepixels, + layer->render(*activeTexture, left_spritepixels, top_spritepixels, left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height); } @@ -205,9 +236,7 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) auto pixel_pos = sf::Vector2f( (e->position.x*cell_width - left_spritepixels) * zoom, (e->position.y*cell_height - top_spritepixels) * zoom ); - //drawent.setPosition(pixel_pos); - //renderTexture.draw(drawent); - drawent.render(pixel_pos, renderTexture); + drawent.render(pixel_pos, *activeTexture); entitiesRendered++; } @@ -220,7 +249,7 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) // #147 - Render dynamic layers with z_index >= 0 (above entities) for (auto& layer : layers) { if (layer->z_index < 0) continue; // Skip layers below entities - layer->render(renderTexture, left_spritepixels, top_spritepixels, + layer->render(*activeTexture, left_spritepixels, top_spritepixels, left_edge, top_edge, x_limit, y_limit, zoom, cell_width, cell_height); } @@ -252,7 +281,7 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) (child->position.y - top_spritepixels) * zoom ); - child->render(pixel_pos, renderTexture); + child->render(pixel_pos, *activeTexture); } } @@ -294,11 +323,11 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) if (!state.discovered) { // Never seen - black overlay.setFillColor(sf::Color(0, 0, 0, 255)); - renderTexture.draw(overlay); + activeTexture->draw(overlay); } else if (!state.visible) { // Discovered but not currently visible - dark gray overlay.setFillColor(sf::Color(32, 32, 40, 192)); - renderTexture.draw(overlay); + activeTexture->draw(overlay); } // If visible and discovered, no overlay (fully visible) } @@ -324,7 +353,7 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) overlay.setPosition(pixel_pos); overlay.setFillColor(sf::Color(0, 0, 0, 255)); - renderTexture.draw(overlay); + activeTexture->draw(overlay); } } } @@ -351,8 +380,51 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) renderTexture.draw(lineb, 2, sf::Lines); */ - // render to window - renderTexture.display(); + // Finalize the active texture + activeTexture->display(); + + // If camera rotation was used, rotate and blit to the grid's renderTexture + if (has_camera_rotation) { + // Clear the final renderTexture with fill color + renderTexture.clear(fill_color); + + // Create sprite from the larger rotated texture + sf::Sprite rotatedSprite(rotationTexture.getTexture()); + + // Set origin to center of the rendered content + float tex_center_x = aabb_w / 2.0f; + float tex_center_y = aabb_h / 2.0f; + rotatedSprite.setOrigin(tex_center_x, tex_center_y); + + // Apply rotation + rotatedSprite.setRotation(camera_rotation); + + // Position so the rotated center lands at the viewport center + rotatedSprite.setPosition(grid_w_px / 2.0f, grid_h_px / 2.0f); + + // Set texture rect to only use the AABB portion (texture may be larger) + rotatedSprite.setTextureRect(sf::IntRect(0, 0, static_cast(aabb_w), static_cast(aabb_h))); + + // Draw to the grid's renderTexture (which clips to grid bounds) + renderTexture.draw(rotatedSprite); + renderTexture.display(); + + // Set up output sprite + output.setPosition(box.getPosition() + offset); + output.setTextureRect(sf::IntRect(0, 0, grid_w_px, grid_h_px)); + } + + // Apply viewport rotation (UIDrawable::rotation) to the entire grid widget + if (rotation != 0.0f) { + output.setOrigin(origin); + output.setRotation(rotation); + // Adjust position to account for origin offset + output.setPosition(box.getPosition() + offset + origin); + } else { + output.setOrigin(0, 0); + output.setRotation(0); + // Position already set above + } // #106: Apply shader if set if (shader && shader->shader) { @@ -1046,6 +1118,8 @@ PyObject* UIGrid::get_float_member(PyUIGridObject* self, void* closure) return PyFloat_FromDouble(self->data->center_y); else if (member_ptr == 6) // zoom return PyFloat_FromDouble(self->data->zoom); + else if (member_ptr == 7) // camera_rotation + return PyFloat_FromDouble(self->data->camera_rotation); else { PyErr_SetString(PyExc_AttributeError, "Invalid attribute"); @@ -1100,6 +1174,8 @@ int UIGrid::set_float_member(PyUIGridObject* self, PyObject* value, void* closur self->data->center_y = val; else if (member_ptr == 6) // zoom self->data->zoom = val; + else if (member_ptr == 7) // camera_rotation + self->data->camera_rotation = val; return 0; } // TODO (7DRL Day 2, item 5.) return Texture object @@ -2206,6 +2282,7 @@ PyGetSetDef UIGrid::getsetters[] = { {"center_x", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "center of the view X-coordinate", (void*)4}, {"center_y", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "center of the view Y-coordinate", (void*)5}, {"zoom", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "zoom factor for displaying the Grid", (void*)6}, + {"camera_rotation", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "Rotation of grid contents around camera center (degrees). The grid widget stays axis-aligned; only the view into the world rotates.", (void*)7}, {"on_click", (getter)UIDrawable::get_click, (setter)UIDrawable::set_click, MCRF_PROPERTY(on_click, @@ -2237,6 +2314,7 @@ PyGetSetDef UIGrid::getsetters[] = { UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIGRID), UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIGRID), + UIDRAWABLE_ROTATION_GETSETTERS(PyObjectsEnum::UIGRID), // #142 - Grid cell mouse events {"on_cell_enter", (getter)UIGrid::get_on_cell_enter, (setter)UIGrid::set_on_cell_enter, "Callback when mouse enters a grid cell. Called with (cell_pos: Vector).", NULL}, @@ -2507,6 +2585,26 @@ bool UIGrid::setProperty(const std::string& name, float value) { markDirty(); // #144 - View change affects content return true; } + else if (name == "camera_rotation") { + camera_rotation = value; + markDirty(); // View rotation affects content + return true; + } + else if (name == "rotation") { + rotation = value; + markCompositeDirty(); // Viewport rotation doesn't affect internal content + return true; + } + else if (name == "origin_x") { + origin.x = value; + markCompositeDirty(); + return true; + } + else if (name == "origin_y") { + origin.y = value; + markCompositeDirty(); + return true; + } else if (name == "z_index") { z_index = static_cast(value); markDirty(); // #144 - Z-order change affects parent @@ -2559,6 +2657,11 @@ bool UIGrid::setProperty(const std::string& name, const sf::Vector2f& value) { markDirty(); // #144 - View change affects content return true; } + else if (name == "origin") { + origin = value; + markCompositeDirty(); + return true; + } return false; } @@ -2591,6 +2694,22 @@ bool UIGrid::getProperty(const std::string& name, float& value) const { value = zoom; return true; } + else if (name == "camera_rotation") { + value = camera_rotation; + return true; + } + else if (name == "rotation") { + value = rotation; + return true; + } + else if (name == "origin_x") { + value = origin.x; + return true; + } + else if (name == "origin_y") { + value = origin.y; + return true; + } else if (name == "z_index") { value = static_cast(z_index); return true; @@ -2631,6 +2750,10 @@ bool UIGrid::getProperty(const std::string& name, sf::Vector2f& value) const { value = sf::Vector2f(center_x, center_y); return true; } + else if (name == "origin") { + value = origin; + return true; + } return false; } @@ -2639,13 +2762,14 @@ bool UIGrid::hasProperty(const std::string& name) const { if (name == "x" || name == "y" || name == "w" || name == "h" || name == "width" || name == "height" || name == "center_x" || name == "center_y" || name == "zoom" || - name == "z_index" || + name == "camera_rotation" || name == "rotation" || + name == "origin_x" || name == "origin_y" || name == "z_index" || name == "fill_color.r" || name == "fill_color.g" || name == "fill_color.b" || name == "fill_color.a") { return true; } // Vector2f properties - if (name == "position" || name == "size" || name == "center") { + if (name == "position" || name == "size" || name == "center" || name == "origin") { return true; } // #106: Shader uniform properties diff --git a/src/UIGrid.h b/src/UIGrid.h index eb2bb04..e2ab942 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -79,11 +79,16 @@ public: //int grid_size; // grid sizes are implied by IndexTexture now sf::RectangleShape box; float center_x, center_y, zoom; + float camera_rotation = 0.0f; // Rotation of grid contents around camera center (degrees) //IndexTexture* itex; std::shared_ptr getTexture(); sf::Sprite sprite, output; sf::RenderTexture renderTexture; + // Intermediate texture for camera_rotation (larger than viewport to hold rotated content) + sf::RenderTexture rotationTexture; + unsigned int rotationTextureSize = 0; // Track current allocation size + // #123 - Chunk-based storage for large grid support std::unique_ptr chunk_manager; // Legacy flat storage (kept for small grids or compatibility) @@ -181,6 +186,8 @@ public: // py_clear_dijkstra_maps -> UIGridPathfinding::Grid_clear_dijkstra_maps static PyObject* py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds); // #115 static PyObject* py_center_camera(PyUIGridObject* self, PyObject* args); // #169 + static PyObject* get_camera_rotation(PyUIGridObject* self, void* closure); + static int set_camera_rotation(PyUIGridObject* self, PyObject* value, void* closure); // #199 - HeightMap application methods static PyObject* py_apply_threshold(PyUIGridObject* self, PyObject* args, PyObject* kwds); diff --git a/src/UILine.cpp b/src/UILine.cpp index cf5f89d..b98ce2a 100644 --- a/src/UILine.cpp +++ b/src/UILine.cpp @@ -134,6 +134,10 @@ void UILine::render(sf::Vector2f offset, sf::RenderTarget& target) { line_shape.setFillColor(render_color); line_shape.setOutlineThickness(0); + // Apply rotation around origin + line_shape.setOrigin(origin); + line_shape.setRotation(rotation); + target.draw(line_shape); } @@ -141,6 +145,22 @@ UIDrawable* UILine::click_at(sf::Vector2f point) { // #184: Also check for Python subclass (might have on_click method) if (!click_callable && !is_python_subclass) return nullptr; + // Transform click point to local coordinates accounting for rotation + sf::Vector2f localPoint; + if (rotation != 0.0f) { + // Build transform: rotate around origin + sf::Transform transform; + transform.translate(origin); + transform.rotate(rotation); + transform.translate(-origin); + + // Apply inverse transform to get local coordinates + sf::Transform inverse = transform.getInverse(); + localPoint = inverse.transformPoint(point); + } else { + localPoint = point; + } + // Check if point is close enough to the line // Using a simple bounding box check plus distance-to-line calculation sf::FloatRect bounds = get_bounds(); @@ -149,11 +169,12 @@ UIDrawable* UILine::click_at(sf::Vector2f point) { bounds.width += thickness * 2; bounds.height += thickness * 2; - if (!bounds.contains(point)) return nullptr; + // For rotated lines, skip the bounds check (it's an optimization, not required) + if (rotation == 0.0f && !bounds.contains(localPoint)) return nullptr; // Calculate distance from point to line segment sf::Vector2f line_vec = end_pos - start_pos; - sf::Vector2f point_vec = point - start_pos; + sf::Vector2f point_vec = localPoint - start_pos; float line_len_sq = line_vec.x * line_vec.x + line_vec.y * line_vec.y; float t = 0.0f; @@ -164,7 +185,7 @@ UIDrawable* UILine::click_at(sf::Vector2f point) { } sf::Vector2f closest = start_pos + t * line_vec; - sf::Vector2f diff = point - closest; + sf::Vector2f diff = localPoint - closest; float distance = std::sqrt(diff.x * diff.x + diff.y * diff.y); // Click is valid if within thickness + some margin @@ -248,6 +269,21 @@ bool UILine::setProperty(const std::string& name, float value) { markDirty(); // #144 - Content change return true; } + else if (name == "rotation") { + rotation = value; + markDirty(); + return true; + } + else if (name == "origin_x") { + origin.x = value; + markDirty(); + return true; + } + else if (name == "origin_y") { + origin.y = value; + markDirty(); + return true; + } return false; } @@ -306,6 +342,18 @@ bool UILine::getProperty(const std::string& name, float& value) const { value = end_pos.y; return true; } + else if (name == "rotation") { + value = rotation; + return true; + } + else if (name == "origin_x") { + value = origin.x; + return true; + } + else if (name == "origin_y") { + value = origin.y; + return true; + } return false; } @@ -333,7 +381,8 @@ bool UILine::hasProperty(const std::string& name) const { // Float properties if (name == "thickness" || name == "x" || name == "y" || name == "start_x" || name == "start_y" || - name == "end_x" || name == "end_y") { + name == "end_x" || name == "end_y" || + name == "rotation" || name == "origin_x" || name == "origin_y") { return true; } // Color properties @@ -341,7 +390,7 @@ bool UILine::hasProperty(const std::string& name) const { return true; } // Vector2f properties - if (name == "start" || name == "end") { + if (name == "start" || name == "end" || name == "origin") { return true; } return false; @@ -469,6 +518,7 @@ PyGetSetDef UILine::getsetters[] = { UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UILINE), UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UILINE), + UIDRAWABLE_ROTATION_GETSETTERS(PyObjectsEnum::UILINE), {NULL} }; diff --git a/src/UISprite.cpp b/src/UISprite.cpp index 17596ad..4281b2c 100644 --- a/src/UISprite.cpp +++ b/src/UISprite.cpp @@ -11,11 +11,36 @@ UIDrawable* UISprite::click_at(sf::Vector2f point) { // #184: Also check for Python subclass (might have on_click method) - if (click_callable || is_python_subclass) - { - if(sprite.getGlobalBounds().contains(point)) return this; + if (!click_callable && !is_python_subclass) return nullptr; + + // Get sprite dimensions from local bounds + sf::FloatRect localBounds = sprite.getLocalBounds(); + float w = localBounds.width * sprite.getScale().x; + float h = localBounds.height * sprite.getScale().y; + + // Transform click point to local coordinates accounting for rotation + sf::Vector2f localPoint; + if (rotation != 0.0f) { + // Build transform: translate to position, then rotate around origin + sf::Transform transform; + transform.translate(position); + transform.translate(origin); + transform.rotate(rotation); + transform.translate(-origin); + + // Apply inverse transform to get local coordinates + sf::Transform inverse = transform.getInverse(); + localPoint = inverse.transformPoint(point); + } else { + // No rotation - simple subtraction + localPoint = point - position; } - return NULL; + + // Check if local point is within bounds (0,0 to w,h in local space) + if (localPoint.x >= 0 && localPoint.y >= 0 && localPoint.x < w && localPoint.y < h) { + return this; + } + return nullptr; } UISprite::UISprite() @@ -89,6 +114,10 @@ void UISprite::render(sf::Vector2f offset, sf::RenderTarget& target) color.a = static_cast(255 * opacity); sprite.setColor(color); + // Apply rotation and origin + sprite.setOrigin(origin); + sprite.setRotation(rotation); + // #106: Shader rendering path if (shader && shader->shader) { // Get the sprite bounds for rendering @@ -396,6 +425,7 @@ PyGetSetDef UISprite::getsetters[] = { UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UISPRITE), UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UISPRITE), UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UISPRITE), + UIDRAWABLE_ROTATION_GETSETTERS(PyObjectsEnum::UISPRITE), {NULL} }; @@ -628,6 +658,24 @@ bool UISprite::setProperty(const std::string& name, float value) { markDirty(); // #144 - Z-order change affects parent return true; } + else if (name == "rotation") { + rotation = value; + sprite.setRotation(rotation); + markDirty(); + return true; + } + else if (name == "origin_x") { + origin.x = value; + sprite.setOrigin(origin); + markDirty(); + return true; + } + else if (name == "origin_y") { + origin.y = value; + sprite.setOrigin(origin); + markDirty(); + return true; + } // #106: Check for shader uniform properties if (setShaderProperty(name, value)) { return true; @@ -674,6 +722,18 @@ bool UISprite::getProperty(const std::string& name, float& value) const { value = static_cast(z_index); return true; } + else if (name == "rotation") { + value = rotation; + return true; + } + else if (name == "origin_x") { + value = origin.x; + return true; + } + else if (name == "origin_y") { + value = origin.y; + return true; + } // #106: Check for shader uniform properties if (getShaderProperty(name, value)) { return true; @@ -697,13 +757,18 @@ bool UISprite::hasProperty(const std::string& name) const { // Float properties if (name == "x" || name == "y" || name == "scale" || name == "scale_x" || name == "scale_y" || - name == "z_index") { + name == "z_index" || + name == "rotation" || name == "origin_x" || name == "origin_y") { return true; } // Int properties if (name == "sprite_index" || name == "sprite_number") { return true; } + // Vector2f properties + if (name == "origin") { + return true; + } // #106: Check for shader uniform properties if (hasShaderProperty(name)) { return true; diff --git a/tests/unit/grid_camera_rotation_test.py b/tests/unit/grid_camera_rotation_test.py new file mode 100644 index 0000000..7673570 --- /dev/null +++ b/tests/unit/grid_camera_rotation_test.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Test UIGrid camera_rotation functionality""" +import mcrfpy +from mcrfpy import automation +import sys + +# Create test scene +test_scene = mcrfpy.Scene("grid_rotation_test") +ui = test_scene.children + +# Create background +bg = mcrfpy.Frame(pos=(0, 0), size=(800, 600), fill_color=mcrfpy.Color(30, 30, 40)) +ui.append(bg) + +# Create a grid with entities to visualize rotation +grid = mcrfpy.Grid(grid_size=(8, 8), pos=(50, 50), size=(300, 300)) +grid.fill_color = mcrfpy.Color(60, 60, 80) + +# Add some entities to visualize the rotation +for i in range(8): + entity = mcrfpy.Entity((i, 0)) # Top row + grid.entities.append(entity) + +for i in range(1, 8): + entity = mcrfpy.Entity((0, i)) # Left column + grid.entities.append(entity) + +# Apply camera rotation +grid.camera_rotation = 30.0 # 30 degree rotation +grid.center_camera((4, 4)) # Center on middle of grid + +ui.append(grid) + +# Create a second grid without rotation for comparison +grid2 = mcrfpy.Grid(grid_size=(8, 8), pos=(400, 50), size=(300, 300)) +grid2.fill_color = mcrfpy.Color(60, 60, 80) + +# Add same entities pattern +for i in range(8): + entity = mcrfpy.Entity((i, 0)) + grid2.entities.append(entity) + +for i in range(1, 8): + entity = mcrfpy.Entity((0, i)) + grid2.entities.append(entity) + +grid2.camera_rotation = 0.0 # No rotation +grid2.center_camera((4, 4)) + +ui.append(grid2) + +# Labels +label1 = mcrfpy.Caption(text="Grid with camera_rotation=30", pos=(50, 20)) +ui.append(label1) + +label2 = mcrfpy.Caption(text="Grid with camera_rotation=0", pos=(400, 20)) +ui.append(label2) + +# Create a third grid with viewport rotation (different from camera rotation) +grid3 = mcrfpy.Grid(grid_size=(6, 6), pos=(175, 400), size=(200, 150)) +grid3.fill_color = mcrfpy.Color(80, 60, 60) + +# Add entities +for i in range(6): + entity = mcrfpy.Entity((i, 0)) + grid3.entities.append(entity) + +# Apply viewport rotation (entire grid rotates) +grid3.rotation = 15.0 +grid3.origin = (100, 75) # Center origin for rotation +grid3.center_camera((3, 3)) + +ui.append(grid3) + +label3 = mcrfpy.Caption(text="Grid with viewport rotation=15 (rotates entire widget)", pos=(100, 560)) +ui.append(label3) + +# Activate scene +mcrfpy.current_scene = test_scene + +# Advance the game loop to render, then take screenshot +mcrfpy.step(0.1) +automation.screenshot("grid_camera_rotation_test.png") +print("Screenshot saved as grid_camera_rotation_test.png") +print("PASS") +sys.exit(0) diff --git a/tests/unit/rotation_test.py b/tests/unit/rotation_test.py new file mode 100644 index 0000000..c2bcc6c --- /dev/null +++ b/tests/unit/rotation_test.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Test rotation support for UIDrawable subclasses""" +import mcrfpy +import sys + +def test_rotation_properties(): + """Test rotation, origin, rotate_with_camera properties on all UIDrawable types""" + print("Testing rotation properties on all UIDrawable types...") + + # Test UIFrame + frame = mcrfpy.Frame(pos=(100, 100), size=(50, 50)) + assert frame.rotation == 0.0, f"Frame default rotation should be 0, got {frame.rotation}" + frame.rotation = 45.0 + assert frame.rotation == 45.0, f"Frame rotation should be 45, got {frame.rotation}" + + # Test origin as Vector + frame.origin = (25, 25) + assert frame.origin.x == 25.0, f"Frame origin.x should be 25, got {frame.origin.x}" + assert frame.origin.y == 25.0, f"Frame origin.y should be 25, got {frame.origin.y}" + + # Test rotate_with_camera + assert frame.rotate_with_camera == False, "Default rotate_with_camera should be False" + frame.rotate_with_camera = True + assert frame.rotate_with_camera == True, "rotate_with_camera should be True after setting" + print(" Frame: PASS") + + # Test UISprite + sprite = mcrfpy.Sprite(pos=(100, 100)) + assert sprite.rotation == 0.0, f"Sprite default rotation should be 0, got {sprite.rotation}" + sprite.rotation = 90.0 + assert sprite.rotation == 90.0, f"Sprite rotation should be 90, got {sprite.rotation}" + sprite.origin = (8, 8) + assert sprite.origin.x == 8.0, f"Sprite origin.x should be 8, got {sprite.origin.x}" + print(" Sprite: PASS") + + # Test UICaption + caption = mcrfpy.Caption(text="Test", pos=(100, 100)) + assert caption.rotation == 0.0, f"Caption default rotation should be 0, got {caption.rotation}" + caption.rotation = -30.0 + assert caption.rotation == -30.0, f"Caption rotation should be -30, got {caption.rotation}" + caption.origin = (0, 0) + assert caption.origin.x == 0.0, f"Caption origin.x should be 0, got {caption.origin.x}" + print(" Caption: PASS") + + # Test UICircle + circle = mcrfpy.Circle(center=(100, 100), radius=25) + assert circle.rotation == 0.0, f"Circle default rotation should be 0, got {circle.rotation}" + circle.rotation = 180.0 + assert circle.rotation == 180.0, f"Circle rotation should be 180, got {circle.rotation}" + print(" Circle: PASS") + + # Test UILine + line = mcrfpy.Line(start=(0, 0), end=(100, 100)) + assert line.rotation == 0.0, f"Line default rotation should be 0, got {line.rotation}" + line.rotation = 45.0 + assert line.rotation == 45.0, f"Line rotation should be 45, got {line.rotation}" + print(" Line: PASS") + + # Test UIArc + arc = mcrfpy.Arc(center=(100, 100), radius=50, start_angle=0, end_angle=90) + assert arc.rotation == 0.0, f"Arc default rotation should be 0, got {arc.rotation}" + arc.rotation = 270.0 + assert arc.rotation == 270.0, f"Arc rotation should be 270, got {arc.rotation}" + print(" Arc: PASS") + + print("All rotation property tests passed!") + return True + +def test_rotation_animation(): + """Test that rotation can be animated""" + print("\nTesting rotation animation...") + + frame = mcrfpy.Frame(pos=(100, 100), size=(50, 50)) + frame.rotation = 0.0 + + # Test that animate method exists and accepts rotation + try: + frame.animate("rotation", 360.0, 1.0, mcrfpy.Easing.LINEAR) + print(" Animation started successfully") + except Exception as e: + print(f" Animation failed: {e}") + return False + + # Test origin animation + try: + frame.animate("origin_x", 25.0, 0.5, mcrfpy.Easing.LINEAR) + frame.animate("origin_y", 25.0, 0.5, mcrfpy.Easing.LINEAR) + print(" Origin animation started successfully") + except Exception as e: + print(f" Origin animation failed: {e}") + return False + + print("Rotation animation tests passed!") + return True + +def test_grid_camera_rotation(): + """Test UIGrid camera_rotation property""" + print("\nTesting Grid camera_rotation...") + + grid = mcrfpy.Grid(grid_size=(10, 10), pos=(50, 50), size=(200, 200)) + + # Test default camera_rotation + assert grid.camera_rotation == 0.0, f"Grid default camera_rotation should be 0, got {grid.camera_rotation}" + + # Test setting camera_rotation + grid.camera_rotation = 45.0 + assert grid.camera_rotation == 45.0, f"Grid camera_rotation should be 45, got {grid.camera_rotation}" + + # Test negative rotation + grid.camera_rotation = -90.0 + assert grid.camera_rotation == -90.0, f"Grid camera_rotation should be -90, got {grid.camera_rotation}" + + # Test full rotation + grid.camera_rotation = 360.0 + assert grid.camera_rotation == 360.0, f"Grid camera_rotation should be 360, got {grid.camera_rotation}" + + # Grid also has regular rotation (viewport rotation) + assert grid.rotation == 0.0, f"Grid viewport rotation should default to 0, got {grid.rotation}" + grid.rotation = 15.0 + assert grid.rotation == 15.0, f"Grid viewport rotation should be 15, got {grid.rotation}" + + # Test camera_rotation animation + try: + grid.animate("camera_rotation", 90.0, 1.0, mcrfpy.Easing.EASE_IN_OUT) + print(" Camera rotation animation started successfully") + except Exception as e: + print(f" Camera rotation animation failed: {e}") + return False + + print("Grid camera_rotation tests passed!") + return True + +def run_all_tests(): + """Run all rotation tests""" + print("=" * 50) + print("UIDrawable Rotation Tests") + print("=" * 50) + + results = [] + results.append(("Rotation Properties", test_rotation_properties())) + results.append(("Rotation Animation", test_rotation_animation())) + results.append(("Grid Camera Rotation", test_grid_camera_rotation())) + + print("\n" + "=" * 50) + print("Test Results Summary") + print("=" * 50) + + all_passed = True + for name, passed in results: + status = "PASS" if passed else "FAIL" + print(f" {name}: {status}") + if not passed: + all_passed = False + + if all_passed: + print("\nAll tests PASSED!") + return 0 + else: + print("\nSome tests FAILED!") + return 1 + +if __name__ == "__main__": + sys.exit(run_all_tests()) diff --git a/tests/unit/rotation_visual_test.py b/tests/unit/rotation_visual_test.py new file mode 100644 index 0000000..54424d6 --- /dev/null +++ b/tests/unit/rotation_visual_test.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Visual test for rotation support - uses direct screenshot""" +import mcrfpy +from mcrfpy import automation +import sys + +# Create test scene +test_scene = mcrfpy.Scene("rotation_test") +ui = test_scene.children + +# Create background +bg = mcrfpy.Frame(pos=(0, 0), size=(800, 600), fill_color=mcrfpy.Color(40, 40, 50)) +ui.append(bg) + +# Row 1: Frames with different rotations +# Frame at 0 degrees +frame1 = mcrfpy.Frame(pos=(100, 100), size=(60, 60), fill_color=mcrfpy.Color(200, 50, 50)) +frame1.rotation = 0.0 +frame1.origin = (30, 30) # Center origin +ui.append(frame1) + +# Frame at 45 degrees +frame2 = mcrfpy.Frame(pos=(250, 100), size=(60, 60), fill_color=mcrfpy.Color(50, 200, 50)) +frame2.rotation = 45.0 +frame2.origin = (30, 30) +ui.append(frame2) + +# Frame at 90 degrees +frame3 = mcrfpy.Frame(pos=(400, 100), size=(60, 60), fill_color=mcrfpy.Color(50, 50, 200)) +frame3.rotation = 90.0 +frame3.origin = (30, 30) +ui.append(frame3) + +# Label for row 1 +label1 = mcrfpy.Caption(text="Frames: 0, 45, 90 degrees", pos=(100, 50)) +ui.append(label1) + +# Row 2: Captions with rotation +caption1 = mcrfpy.Caption(text="Rotated Text", pos=(100, 250)) +caption1.rotation = 0.0 +ui.append(caption1) + +caption2 = mcrfpy.Caption(text="Rotated Text", pos=(300, 250)) +caption2.rotation = -15.0 +ui.append(caption2) + +caption3 = mcrfpy.Caption(text="Rotated Text", pos=(500, 250)) +caption3.rotation = 30.0 +ui.append(caption3) + +# Label for row 2 +label2 = mcrfpy.Caption(text="Captions: 0, -15, 30 degrees", pos=(100, 200)) +ui.append(label2) + +# Row 3: Circles (rotation with offset origin causes orbiting) +circle1 = mcrfpy.Circle(center=(100, 400), radius=25, fill_color=mcrfpy.Color(200, 200, 50)) +circle1.rotation = 0.0 +ui.append(circle1) + +circle2 = mcrfpy.Circle(center=(250, 400), radius=25, fill_color=mcrfpy.Color(200, 50, 200)) +circle2.rotation = 45.0 +circle2.origin = (20, 0) # Offset origin to show orbiting effect +ui.append(circle2) + +circle3 = mcrfpy.Circle(center=(400, 400), radius=25, fill_color=mcrfpy.Color(50, 200, 200)) +circle3.rotation = 90.0 +circle3.origin = (20, 0) # Same offset +ui.append(circle3) + +# Label for row 3 +label3 = mcrfpy.Caption(text="Circles with offset origin: 0, 45, 90 degrees", pos=(100, 350)) +ui.append(label3) + +# Row 4: Lines with rotation +line1 = mcrfpy.Line(start=(100, 500), end=(150, 500), thickness=3, color=mcrfpy.Color(255, 255, 255)) +line1.rotation = 0.0 +ui.append(line1) + +line2 = mcrfpy.Line(start=(250, 500), end=(300, 500), thickness=3, color=mcrfpy.Color(255, 200, 200)) +line2.rotation = 45.0 +line2.origin = (125, 500) # Rotate around line center +ui.append(line2) + +line3 = mcrfpy.Line(start=(400, 500), end=(450, 500), thickness=3, color=mcrfpy.Color(200, 255, 200)) +line3.rotation = -45.0 +line3.origin = (200, 500) +ui.append(line3) + +# Label for row 4 +label4 = mcrfpy.Caption(text="Lines: 0, 45, -45 degrees", pos=(100, 470)) +ui.append(label4) + +# Arcs with rotation +arc1 = mcrfpy.Arc(center=(600, 100), radius=40, start_angle=0, end_angle=90, thickness=5) +arc1.rotation = 0.0 +ui.append(arc1) + +arc2 = mcrfpy.Arc(center=(700, 100), radius=40, start_angle=0, end_angle=90, thickness=5) +arc2.rotation = 45.0 +ui.append(arc2) + +# Label for arcs +label5 = mcrfpy.Caption(text="Arcs: 0, 45 degrees", pos=(550, 50)) +ui.append(label5) + +# Activate scene +mcrfpy.current_scene = test_scene + +# Advance the game loop to render, then take screenshot +mcrfpy.step(0.1) +automation.screenshot("rotation_visual_test.png") +print("Screenshot saved as rotation_visual_test.png") +print("PASS") +sys.exit(0)