From 486087b9cbae6fe537ed4d5f25d717b633fb3b6e Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sun, 25 Jan 2026 21:04:01 -0500 Subject: [PATCH 1/2] Shaders --- src/GameEngine.cpp | 35 +++ src/GameEngine.h | 9 + src/McRFPy_API.cpp | 22 ++ src/PyShader.cpp | 256 ++++++++++++++++++++++ src/PyShader.h | 94 ++++++++ src/PyUniformBinding.cpp | 341 +++++++++++++++++++++++++++++ src/PyUniformBinding.h | 201 +++++++++++++++++ src/PyUniformCollection.cpp | 405 ++++++++++++++++++++++++++++++++++ src/PyUniformCollection.h | 157 ++++++++++++++ src/UIBase.h | 15 ++ src/UICaption.cpp | 62 +++++- src/UIDrawable.cpp | 191 ++++++++++++++++ src/UIDrawable.h | 28 ++- src/UIEntity.cpp | 22 ++ src/UIEntityPyMethods.h | 71 ++++++ src/UIFrame.cpp | 123 +++-------- src/UIFrame.h | 6 - src/UIGrid.cpp | 31 ++- src/UISprite.cpp | 61 +++++- tests/unit/shader_test.py | 422 ++++++++++++++++++++++++++++++++++++ 20 files changed, 2438 insertions(+), 114 deletions(-) create mode 100644 src/PyShader.cpp create mode 100644 src/PyShader.h create mode 100644 src/PyUniformBinding.cpp create mode 100644 src/PyUniformBinding.h create mode 100644 src/PyUniformCollection.cpp create mode 100644 src/PyUniformCollection.h create mode 100644 tests/unit/shader_test.py diff --git a/src/GameEngine.cpp b/src/GameEngine.cpp index 9ccf273..ae63247 100644 --- a/src/GameEngine.cpp +++ b/src/GameEngine.cpp @@ -12,6 +12,10 @@ #include #include +// Static member definitions for shader intermediate texture (#106) +std::unique_ptr GameEngine::shaderIntermediate; +bool GameEngine::shaderIntermediateInitialized = false; + // #219 - FrameLock implementation for thread-safe UI updates void FrameLock::acquire() { @@ -718,6 +722,37 @@ sf::Vector2f GameEngine::windowToGameCoords(const sf::Vector2f& windowPos) const return render_target->mapPixelToCoords(sf::Vector2i(windowPos), gameView); } +// #106 - Shader intermediate texture: shared texture for shader rendering +void GameEngine::initShaderIntermediate(unsigned int width, unsigned int height) { + if (!sf::Shader::isAvailable()) { + std::cerr << "GameEngine: Shaders not available, skipping intermediate texture init" << std::endl; + return; + } + + if (!shaderIntermediate) { + shaderIntermediate = std::make_unique(); + } + + if (!shaderIntermediate->create(width, height)) { + std::cerr << "GameEngine: Failed to create shader intermediate texture (" + << width << "x" << height << ")" << std::endl; + shaderIntermediate.reset(); + shaderIntermediateInitialized = false; + return; + } + + shaderIntermediate->setSmooth(false); // Pixel-perfect rendering + shaderIntermediateInitialized = true; +} + +sf::RenderTexture& GameEngine::getShaderIntermediate() { + if (!shaderIntermediateInitialized) { + // Initialize with default resolution if not already done + initShaderIntermediate(1024, 768); + } + return *shaderIntermediate; +} + // #153 - Headless simulation control: step() advances simulation time float GameEngine::step(float dt) { // In windowed mode, step() is a no-op diff --git a/src/GameEngine.h b/src/GameEngine.h index 512726d..9e69a24 100644 --- a/src/GameEngine.h +++ b/src/GameEngine.h @@ -185,6 +185,10 @@ private: sf::View gameView; // View for the game content ViewportMode viewportMode = ViewportMode::Fit; + // Shader intermediate texture (#106) - shared texture for shader rendering + static std::unique_ptr shaderIntermediate; + static bool shaderIntermediateInitialized; + // Profiling overlay bool showProfilerOverlay = false; // F3 key toggles this int overlayUpdateCounter = 0; // Only update overlay every N frames @@ -257,6 +261,11 @@ public: std::string getViewportModeString() const; sf::Vector2f windowToGameCoords(const sf::Vector2f& windowPos) const; + // Shader system (#106) - shared intermediate texture for shader rendering + static sf::RenderTexture& getShaderIntermediate(); + static void initShaderIntermediate(unsigned int width, unsigned int height); + static bool isShaderIntermediateReady() { return shaderIntermediateInitialized; } + // #153 - Headless simulation control float step(float dt = -1.0f); // Advance simulation; dt<0 means advance to next event int getSimulationTime() const { return simulation_time; } diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 36d5aff..8514689 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -27,6 +27,9 @@ #include "PyNoiseSource.h" // Procedural generation noise (#207-208) #include "PyLock.h" // Thread synchronization (#219) #include "PyVector.h" // For bresenham Vector support (#215) +#include "PyShader.h" // Shader support (#106) +#include "PyUniformBinding.h" // Shader uniform bindings (#106) +#include "PyUniformCollection.h" // Shader uniform collection (#106) #include "McRogueFaceVersion.h" #include "GameEngine.h" #include "ImGuiConsole.h" @@ -452,6 +455,11 @@ PyObject* PyInit_mcrfpy() &mcrfpydef::PyBSPType, &mcrfpydef::PyNoiseSourceType, + /*shaders (#106)*/ + &mcrfpydef::PyShaderType, + &mcrfpydef::PyPropertyBindingType, + &mcrfpydef::PyCallableBindingType, + nullptr}; // Types that are used internally but NOT exported to module namespace (#189) @@ -473,6 +481,9 @@ PyObject* PyInit_mcrfpy() &mcrfpydef::PyBSPAdjacencyType, // #210: BSP.adjacency wrapper &mcrfpydef::PyBSPAdjacentTilesType, // #210: BSPNode.adjacent_tiles wrapper + /*shader uniform collection - returned by drawable.uniforms but not directly instantiable (#106)*/ + &mcrfpydef::PyUniformCollectionType, + nullptr}; // Set up PyWindowType methods and getsetters before PyType_Ready @@ -497,6 +508,17 @@ PyObject* PyInit_mcrfpy() mcrfpydef::PyNoiseSourceType.tp_methods = PyNoiseSource::methods; mcrfpydef::PyNoiseSourceType.tp_getset = PyNoiseSource::getsetters; + // Set up PyShaderType methods and getsetters (#106) + mcrfpydef::PyShaderType.tp_methods = PyShader::methods; + mcrfpydef::PyShaderType.tp_getset = PyShader::getsetters; + + // Set up PyPropertyBindingType and PyCallableBindingType getsetters (#106) + mcrfpydef::PyPropertyBindingType.tp_getset = PyPropertyBindingType::getsetters; + mcrfpydef::PyCallableBindingType.tp_getset = PyCallableBindingType::getsetters; + + // Set up PyUniformCollectionType methods (#106) + mcrfpydef::PyUniformCollectionType.tp_methods = ::PyUniformCollectionType::methods; + // Set up weakref support for all types that need it PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist); PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist); diff --git a/src/PyShader.cpp b/src/PyShader.cpp new file mode 100644 index 0000000..92ff99d --- /dev/null +++ b/src/PyShader.cpp @@ -0,0 +1,256 @@ +#include "PyShader.h" +#include "McRFPy_API.h" +#include "McRFPy_Doc.h" +#include "GameEngine.h" +#include "Resources.h" +#include + +// Static clock for time uniform +static sf::Clock shader_engine_clock; +static sf::Clock shader_frame_clock; + +// Python method and getset definitions +PyGetSetDef PyShader::getsetters[] = { + {"dynamic", (getter)PyShader::get_dynamic, (setter)PyShader::set_dynamic, + MCRF_PROPERTY(dynamic, + "Whether this shader uses time-varying effects (bool). " + "Dynamic shaders invalidate parent caches each frame."), NULL}, + {"source", (getter)PyShader::get_source, NULL, + MCRF_PROPERTY(source, + "The GLSL fragment shader source code (str, read-only)."), NULL}, + {"is_valid", (getter)PyShader::get_is_valid, NULL, + MCRF_PROPERTY(is_valid, + "True if the shader compiled successfully (bool, read-only)."), NULL}, + {NULL} +}; + +PyMethodDef PyShader::methods[] = { + {"set_uniform", (PyCFunction)PyShader::set_uniform, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(Shader, set_uniform, + MCRF_SIG("(name: str, value: float|tuple)", "None"), + MCRF_DESC("Set a custom uniform value on this shader."), + MCRF_ARGS_START + MCRF_ARG("name", "Uniform variable name in the shader") + MCRF_ARG("value", "Float, vec2 (2-tuple), vec3 (3-tuple), or vec4 (4-tuple)") + MCRF_RAISES("ValueError", "If uniform type cannot be determined") + MCRF_NOTE("Engine uniforms (time, resolution, etc.) are set automatically") + )}, + {NULL} +}; + +// Constructor +PyObject* PyShader::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) +{ + PyShaderObject* self = (PyShaderObject*)type->tp_alloc(type, 0); + if (self) { + self->shader = nullptr; + self->dynamic = false; + self->weakreflist = NULL; + new (&self->fragment_source) std::string(); + } + return (PyObject*)self; +} + +// Destructor +void PyShader::dealloc(PyShaderObject* self) +{ + // Clear weak references + if (self->weakreflist) { + PyObject_ClearWeakRefs((PyObject*)self); + } + + // Destroy C++ objects + self->shader.reset(); + self->fragment_source.~basic_string(); + + // Free Python object + Py_TYPE(self)->tp_free((PyObject*)self); +} + +// Initializer +int PyShader::init(PyShaderObject* self, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"fragment_source", "dynamic", nullptr}; + const char* source = nullptr; + int dynamic = 0; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s|p", const_cast(keywords), + &source, &dynamic)) { + return -1; + } + + // Check if shaders are available + if (!sf::Shader::isAvailable()) { + PyErr_SetString(PyExc_RuntimeError, + "Shaders are not available on this system (no GPU support or OpenGL too old)"); + return -1; + } + + // Store source and dynamic flag + self->fragment_source = source; + self->dynamic = (bool)dynamic; + + // Create and compile the shader + self->shader = std::make_shared(); + + // Capture sf::err() output during shader compilation + std::streambuf* oldBuf = sf::err().rdbuf(); + std::ostringstream errStream; + sf::err().rdbuf(errStream.rdbuf()); + + bool success = self->shader->loadFromMemory(source, sf::Shader::Fragment); + + // Restore sf::err() and check for errors + sf::err().rdbuf(oldBuf); + + if (!success) { + std::string error_msg = errStream.str(); + if (error_msg.empty()) { + error_msg = "Shader compilation failed (unknown error)"; + } + PyErr_Format(PyExc_ValueError, "Shader compilation failed: %s", error_msg.c_str()); + self->shader.reset(); + return -1; + } + + return 0; +} + +// Repr +PyObject* PyShader::repr(PyObject* obj) +{ + PyShaderObject* self = (PyShaderObject*)obj; + std::ostringstream ss; + ss << "shader) { + ss << " valid"; + } else { + ss << " invalid"; + } + if (self->dynamic) { + ss << " dynamic"; + } + ss << ">"; + return PyUnicode_FromString(ss.str().c_str()); +} + +// Property: dynamic +PyObject* PyShader::get_dynamic(PyShaderObject* self, void* closure) +{ + return PyBool_FromLong(self->dynamic); +} + +int PyShader::set_dynamic(PyShaderObject* self, PyObject* value, void* closure) +{ + if (!PyBool_Check(value)) { + PyErr_SetString(PyExc_TypeError, "dynamic must be a boolean"); + return -1; + } + self->dynamic = PyObject_IsTrue(value); + return 0; +} + +// Property: source (read-only) +PyObject* PyShader::get_source(PyShaderObject* self, void* closure) +{ + return PyUnicode_FromString(self->fragment_source.c_str()); +} + +// Property: is_valid (read-only) +PyObject* PyShader::get_is_valid(PyShaderObject* self, void* closure) +{ + return PyBool_FromLong(self->shader != nullptr); +} + +// Method: set_uniform +PyObject* PyShader::set_uniform(PyShaderObject* self, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"name", "value", nullptr}; + const char* name = nullptr; + PyObject* value = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sO", const_cast(keywords), + &name, &value)) { + return NULL; + } + + if (!self->shader) { + PyErr_SetString(PyExc_RuntimeError, "Shader is not valid"); + return NULL; + } + + // Determine the type and set uniform + if (PyFloat_Check(value) || PyLong_Check(value)) { + // Single float + float f = (float)PyFloat_AsDouble(value); + if (PyErr_Occurred()) return NULL; + self->shader->setUniform(name, f); + } + else if (PyTuple_Check(value)) { + Py_ssize_t size = PyTuple_Size(value); + if (size == 2) { + // vec2 + float x = (float)PyFloat_AsDouble(PyTuple_GetItem(value, 0)); + float y = (float)PyFloat_AsDouble(PyTuple_GetItem(value, 1)); + if (PyErr_Occurred()) return NULL; + self->shader->setUniform(name, sf::Glsl::Vec2(x, y)); + } + else if (size == 3) { + // vec3 + float x = (float)PyFloat_AsDouble(PyTuple_GetItem(value, 0)); + float y = (float)PyFloat_AsDouble(PyTuple_GetItem(value, 1)); + float z = (float)PyFloat_AsDouble(PyTuple_GetItem(value, 2)); + if (PyErr_Occurred()) return NULL; + self->shader->setUniform(name, sf::Glsl::Vec3(x, y, z)); + } + else if (size == 4) { + // vec4 + float x = (float)PyFloat_AsDouble(PyTuple_GetItem(value, 0)); + float y = (float)PyFloat_AsDouble(PyTuple_GetItem(value, 1)); + float z = (float)PyFloat_AsDouble(PyTuple_GetItem(value, 2)); + float w = (float)PyFloat_AsDouble(PyTuple_GetItem(value, 3)); + if (PyErr_Occurred()) return NULL; + self->shader->setUniform(name, sf::Glsl::Vec4(x, y, z, w)); + } + else { + PyErr_Format(PyExc_ValueError, + "Tuple must have 2, 3, or 4 elements for vec2/vec3/vec4, got %zd", size); + return NULL; + } + } + else { + PyErr_SetString(PyExc_TypeError, + "Uniform value must be a float or tuple of 2-4 floats"); + return NULL; + } + + Py_RETURN_NONE; +} + +// Static helper: apply engine-provided uniforms +void PyShader::applyEngineUniforms(sf::Shader& shader, sf::Vector2f resolution) +{ + // Time uniforms + shader.setUniform("time", shader_engine_clock.getElapsedTime().asSeconds()); + shader.setUniform("delta_time", shader_frame_clock.restart().asSeconds()); + + // Resolution + shader.setUniform("resolution", resolution); + + // Mouse position - get from GameEngine if available + sf::Vector2f mouse(0.f, 0.f); + if (Resources::game && !Resources::game->isHeadless()) { + sf::Vector2i mousePos = sf::Mouse::getPosition(Resources::game->getWindow()); + mouse = sf::Vector2f(static_cast(mousePos.x), static_cast(mousePos.y)); + } + shader.setUniform("mouse", mouse); + + // CurrentTexture is handled by SFML automatically when drawing + shader.setUniform("texture", sf::Shader::CurrentTexture); +} + +// Static helper: check availability +bool PyShader::isAvailable() +{ + return sf::Shader::isAvailable(); +} diff --git a/src/PyShader.h b/src/PyShader.h new file mode 100644 index 0000000..911dcce --- /dev/null +++ b/src/PyShader.h @@ -0,0 +1,94 @@ +#pragma once +#include "Common.h" +#include "Python.h" + +// Forward declarations +class UIDrawable; + +// Python object structure for Shader +typedef struct PyShaderObjectStruct { + PyObject_HEAD + std::shared_ptr shader; + bool dynamic; // Time-varying shader (affects caching) + std::string fragment_source; // Source code for recompilation + PyObject* weakreflist; // Support weak references +} PyShaderObject; + +class PyShader +{ +public: + // Python type methods + static PyObject* repr(PyObject* self); + static int init(PyShaderObject* self, PyObject* args, PyObject* kwds); + static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); + static void dealloc(PyShaderObject* self); + + // Property getters/setters + static PyObject* get_dynamic(PyShaderObject* self, void* closure); + static int set_dynamic(PyShaderObject* self, PyObject* value, void* closure); + static PyObject* get_source(PyShaderObject* self, void* closure); + static PyObject* get_is_valid(PyShaderObject* self, void* closure); + + // Methods + static PyObject* set_uniform(PyShaderObject* self, PyObject* args, PyObject* kwds); + + // Static helper: apply engine-provided uniforms (time, resolution, etc.) + static void applyEngineUniforms(sf::Shader& shader, sf::Vector2f resolution); + + // Check if shaders are available on this system + static bool isAvailable(); + + // Arrays for Python type definition + static PyGetSetDef getsetters[]; + static PyMethodDef methods[]; +}; + +namespace mcrfpydef { + // Using inline to ensure single definition across translation units (C++17) + inline PyTypeObject PyShaderType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.Shader", + .tp_basicsize = sizeof(PyShaderObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)PyShader::dealloc, + .tp_repr = PyShader::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "Shader(fragment_source: str, dynamic: bool = False)\n" + "\n" + "A GPU shader program for visual effects.\n" + "\n" + "Args:\n" + " fragment_source: GLSL fragment shader source code\n" + " dynamic: If True, shader uses time-varying effects and will\n" + " invalidate parent caches each frame\n" + "\n" + "Shaders enable GPU-accelerated visual effects like glow, distortion,\n" + "color manipulation, and more. Assign to drawable.shader to apply.\n" + "\n" + "Engine-provided uniforms (automatically available):\n" + " - float time: Seconds since engine start\n" + " - float delta_time: Seconds since last frame\n" + " - vec2 resolution: Texture size in pixels\n" + " - vec2 mouse: Mouse position in window coordinates\n" + "\n" + "Example:\n" + " shader = mcrfpy.Shader('''\n" + " uniform sampler2D texture;\n" + " uniform float time;\n" + " void main() {\n" + " vec2 uv = gl_TexCoord[0].xy;\n" + " vec4 color = texture2D(texture, uv);\n" + " color.rgb *= 0.5 + 0.5 * sin(time);\n" + " gl_FragColor = color;\n" + " }\n" + " ''', dynamic=True)\n" + " frame.shader = shader\n" + ), + .tp_weaklistoffset = offsetof(PyShaderObject, weakreflist), + .tp_methods = nullptr, // Set in McRFPy_API.cpp before PyType_Ready + .tp_getset = nullptr, // Set in McRFPy_API.cpp before PyType_Ready + .tp_init = (initproc)PyShader::init, + .tp_new = PyShader::pynew, + }; +} diff --git a/src/PyUniformBinding.cpp b/src/PyUniformBinding.cpp new file mode 100644 index 0000000..27afbcd --- /dev/null +++ b/src/PyUniformBinding.cpp @@ -0,0 +1,341 @@ +#include "PyUniformBinding.h" +#include "UIDrawable.h" +#include "UIFrame.h" +#include "UICaption.h" +#include "UISprite.h" +#include "UIGrid.h" +#include "UILine.h" +#include "UICircle.h" +#include "UIArc.h" +#include "McRFPy_API.h" +#include "McRFPy_Doc.h" +#include +#include + +// ============================================================================ +// PropertyBinding Implementation +// ============================================================================ + +PropertyBinding::PropertyBinding(std::weak_ptr target, const std::string& property) + : target(target), property_name(property) {} + +std::optional PropertyBinding::evaluate() const { + auto ptr = target.lock(); + if (!ptr) return std::nullopt; + + float value = 0.0f; + if (ptr->getProperty(property_name, value)) { + return value; + } + return std::nullopt; +} + +bool PropertyBinding::isValid() const { + auto ptr = target.lock(); + if (!ptr) return false; + return ptr->hasProperty(property_name); +} + +// ============================================================================ +// CallableBinding Implementation +// ============================================================================ + +CallableBinding::CallableBinding(PyObject* callable) + : callable(callable) { + if (callable) { + Py_INCREF(callable); + } +} + +CallableBinding::~CallableBinding() { + if (callable) { + Py_DECREF(callable); + } +} + +CallableBinding::CallableBinding(CallableBinding&& other) noexcept + : callable(other.callable) { + other.callable = nullptr; +} + +CallableBinding& CallableBinding::operator=(CallableBinding&& other) noexcept { + if (this != &other) { + if (callable) { + Py_DECREF(callable); + } + callable = other.callable; + other.callable = nullptr; + } + return *this; +} + +std::optional CallableBinding::evaluate() const { + if (!callable || !PyCallable_Check(callable)) { + return std::nullopt; + } + + PyObject* result = PyObject_CallNoArgs(callable); + if (!result) { + // Python exception occurred - print and clear it + PyErr_Print(); + return std::nullopt; + } + + float value = 0.0f; + if (PyFloat_Check(result)) { + value = static_cast(PyFloat_AsDouble(result)); + } else if (PyLong_Check(result)) { + value = static_cast(PyLong_AsDouble(result)); + } else { + // Try to convert to float + PyObject* float_result = PyNumber_Float(result); + if (float_result) { + value = static_cast(PyFloat_AsDouble(float_result)); + Py_DECREF(float_result); + } else { + PyErr_Clear(); + Py_DECREF(result); + return std::nullopt; + } + } + + Py_DECREF(result); + return value; +} + +bool CallableBinding::isValid() const { + return callable && PyCallable_Check(callable); +} + +// ============================================================================ +// PyPropertyBindingType Python Interface +// ============================================================================ + +PyGetSetDef PyPropertyBindingType::getsetters[] = { + {"target", (getter)PyPropertyBindingType::get_target, NULL, + MCRF_PROPERTY(target, "The drawable this binding reads from (read-only)."), NULL}, + {"property", (getter)PyPropertyBindingType::get_property, NULL, + MCRF_PROPERTY(property, "The property name being read (str, read-only)."), NULL}, + {"value", (getter)PyPropertyBindingType::get_value, NULL, + MCRF_PROPERTY(value, "Current value of the binding (float, read-only). Returns None if invalid."), NULL}, + {"is_valid", (getter)PyPropertyBindingType::is_valid, NULL, + MCRF_PROPERTY(is_valid, "True if the binding target still exists and property is valid (bool, read-only)."), NULL}, + {NULL} +}; + +PyObject* PyPropertyBindingType::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) { + PyPropertyBindingObject* self = (PyPropertyBindingObject*)type->tp_alloc(type, 0); + if (self) { + self->binding = nullptr; + self->weakreflist = NULL; + } + return (PyObject*)self; +} + +void PyPropertyBindingType::dealloc(PyPropertyBindingObject* self) { + if (self->weakreflist) { + PyObject_ClearWeakRefs((PyObject*)self); + } + self->binding.reset(); + Py_TYPE(self)->tp_free((PyObject*)self); +} + +int PyPropertyBindingType::init(PyPropertyBindingObject* self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"target", "property", nullptr}; + PyObject* target_obj = nullptr; + const char* property = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "Os", const_cast(keywords), + &target_obj, &property)) { + return -1; + } + + // Get the shared_ptr from the target drawable by checking the type name + // We can't use PyObject_IsInstance with static type objects from other translation units + // So we check the type name string instead + std::shared_ptr target_ptr; + const char* type_name = Py_TYPE(target_obj)->tp_name; + + if (strcmp(type_name, "mcrfpy.Frame") == 0) { + target_ptr = ((PyUIFrameObject*)target_obj)->data; + } else if (strcmp(type_name, "mcrfpy.Caption") == 0) { + target_ptr = ((PyUICaptionObject*)target_obj)->data; + } else if (strcmp(type_name, "mcrfpy.Sprite") == 0) { + target_ptr = ((PyUISpriteObject*)target_obj)->data; + } else if (strcmp(type_name, "mcrfpy.Grid") == 0) { + target_ptr = ((PyUIGridObject*)target_obj)->data; + } else if (strcmp(type_name, "mcrfpy.Line") == 0) { + target_ptr = ((PyUILineObject*)target_obj)->data; + } else if (strcmp(type_name, "mcrfpy.Circle") == 0) { + target_ptr = ((PyUICircleObject*)target_obj)->data; + } else if (strcmp(type_name, "mcrfpy.Arc") == 0) { + target_ptr = ((PyUIArcObject*)target_obj)->data; + } + + if (!target_ptr) { + PyErr_SetString(PyExc_TypeError, + "PropertyBinding requires a UIDrawable (Frame, Sprite, Caption, Grid, Line, Circle, or Arc)"); + return -1; + } + + // Validate that the property exists + if (!target_ptr->hasProperty(property)) { + PyErr_Format(PyExc_ValueError, + "Property '%s' is not a valid animatable property on this drawable", property); + return -1; + } + + self->binding = std::make_shared(target_ptr, property); + return 0; +} + +PyObject* PyPropertyBindingType::repr(PyObject* obj) { + PyPropertyBindingObject* self = (PyPropertyBindingObject*)obj; + std::ostringstream ss; + ss << "binding) { + ss << " property='" << self->binding->getPropertyName() << "'"; + if (self->binding->isValid()) { + auto val = self->binding->evaluate(); + if (val) { + ss << " value=" << *val; + } + } else { + ss << " (invalid)"; + } + } + ss << ">"; + return PyUnicode_FromString(ss.str().c_str()); +} + +PyObject* PyPropertyBindingType::get_target(PyPropertyBindingObject* self, void* closure) { + if (!self->binding) { + Py_RETURN_NONE; + } + auto ptr = self->binding->getTarget().lock(); + if (!ptr) { + Py_RETURN_NONE; + } + // TODO: Return the actual Python object for the drawable + Py_RETURN_NONE; +} + +PyObject* PyPropertyBindingType::get_property(PyPropertyBindingObject* self, void* closure) { + if (!self->binding) { + Py_RETURN_NONE; + } + return PyUnicode_FromString(self->binding->getPropertyName().c_str()); +} + +PyObject* PyPropertyBindingType::get_value(PyPropertyBindingObject* self, void* closure) { + if (!self->binding) { + Py_RETURN_NONE; + } + auto val = self->binding->evaluate(); + if (!val) { + Py_RETURN_NONE; + } + return PyFloat_FromDouble(*val); +} + +PyObject* PyPropertyBindingType::is_valid(PyPropertyBindingObject* self, void* closure) { + if (!self->binding) { + Py_RETURN_FALSE; + } + return PyBool_FromLong(self->binding->isValid()); +} + +// ============================================================================ +// PyCallableBindingType Python Interface +// ============================================================================ + +PyGetSetDef PyCallableBindingType::getsetters[] = { + {"callable", (getter)PyCallableBindingType::get_callable, NULL, + MCRF_PROPERTY(callable, "The Python callable (read-only)."), NULL}, + {"value", (getter)PyCallableBindingType::get_value, NULL, + MCRF_PROPERTY(value, "Current value from calling the callable (float, read-only). Returns None on error."), NULL}, + {"is_valid", (getter)PyCallableBindingType::is_valid, NULL, + MCRF_PROPERTY(is_valid, "True if the callable is still valid (bool, read-only)."), NULL}, + {NULL} +}; + +PyObject* PyCallableBindingType::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) { + PyCallableBindingObject* self = (PyCallableBindingObject*)type->tp_alloc(type, 0); + if (self) { + self->binding = nullptr; + self->weakreflist = NULL; + } + return (PyObject*)self; +} + +void PyCallableBindingType::dealloc(PyCallableBindingObject* self) { + if (self->weakreflist) { + PyObject_ClearWeakRefs((PyObject*)self); + } + self->binding.reset(); + Py_TYPE(self)->tp_free((PyObject*)self); +} + +int PyCallableBindingType::init(PyCallableBindingObject* self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"callable", nullptr}; + PyObject* callable = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O", const_cast(keywords), + &callable)) { + return -1; + } + + if (!PyCallable_Check(callable)) { + PyErr_SetString(PyExc_TypeError, "Argument must be callable"); + return -1; + } + + self->binding = std::make_shared(callable); + return 0; +} + +PyObject* PyCallableBindingType::repr(PyObject* obj) { + PyCallableBindingObject* self = (PyCallableBindingObject*)obj; + std::ostringstream ss; + ss << "binding && self->binding->isValid()) { + auto val = self->binding->evaluate(); + if (val) { + ss << " value=" << *val; + } + } else { + ss << " (invalid)"; + } + ss << ">"; + return PyUnicode_FromString(ss.str().c_str()); +} + +PyObject* PyCallableBindingType::get_callable(PyCallableBindingObject* self, void* closure) { + if (!self->binding) { + Py_RETURN_NONE; + } + PyObject* callable = self->binding->getCallable(); + if (!callable) { + Py_RETURN_NONE; + } + Py_INCREF(callable); + return callable; +} + +PyObject* PyCallableBindingType::get_value(PyCallableBindingObject* self, void* closure) { + if (!self->binding) { + Py_RETURN_NONE; + } + auto val = self->binding->evaluate(); + if (!val) { + Py_RETURN_NONE; + } + return PyFloat_FromDouble(*val); +} + +PyObject* PyCallableBindingType::is_valid(PyCallableBindingObject* self, void* closure) { + if (!self->binding) { + Py_RETURN_FALSE; + } + return PyBool_FromLong(self->binding->isValid()); +} diff --git a/src/PyUniformBinding.h b/src/PyUniformBinding.h new file mode 100644 index 0000000..97f6334 --- /dev/null +++ b/src/PyUniformBinding.h @@ -0,0 +1,201 @@ +#pragma once +#include "Common.h" +#include "Python.h" +#include +#include +#include +#include + +// Forward declarations +class UIDrawable; + +/** + * @brief Variant type for uniform values + * + * Supports float, vec2, vec3, and vec4 uniform types. + */ +using UniformValue = std::variant< + float, + sf::Glsl::Vec2, + sf::Glsl::Vec3, + sf::Glsl::Vec4 +>; + +/** + * @brief Base class for uniform bindings + * + * Bindings provide dynamic uniform values that are evaluated each frame. + */ +class UniformBinding { +public: + virtual ~UniformBinding() = default; + + /** + * @brief Evaluate the binding and return its current value + * @return The current uniform value, or std::nullopt if binding is invalid + */ + virtual std::optional evaluate() const = 0; + + /** + * @brief Check if the binding is still valid + */ + virtual bool isValid() const = 0; +}; + +/** + * @brief Binding that reads a property from a UIDrawable + * + * Uses a weak_ptr to prevent dangling references if the target is destroyed. + */ +class PropertyBinding : public UniformBinding { +public: + PropertyBinding(std::weak_ptr target, const std::string& property); + + std::optional evaluate() const override; + bool isValid() const override; + + // Accessors for Python + std::weak_ptr getTarget() const { return target; } + const std::string& getPropertyName() const { return property_name; } + +private: + std::weak_ptr target; + std::string property_name; +}; + +/** + * @brief Binding that calls a Python callable to get the value + * + * The callable should return a float value. + */ +class CallableBinding : public UniformBinding { +public: + explicit CallableBinding(PyObject* callable); + ~CallableBinding(); + + // Non-copyable due to PyObject reference management + CallableBinding(const CallableBinding&) = delete; + CallableBinding& operator=(const CallableBinding&) = delete; + + // Move semantics + CallableBinding(CallableBinding&& other) noexcept; + CallableBinding& operator=(CallableBinding&& other) noexcept; + + std::optional evaluate() const override; + bool isValid() const override; + + // Accessor for Python + PyObject* getCallable() const { return callable; } + +private: + PyObject* callable; // Owned reference +}; + +// Python object structures for bindings +typedef struct { + PyObject_HEAD + std::shared_ptr binding; + PyObject* weakreflist; +} PyPropertyBindingObject; + +typedef struct { + PyObject_HEAD + std::shared_ptr binding; + PyObject* weakreflist; +} PyCallableBindingObject; + +// Python type class for PropertyBinding +class PyPropertyBindingType { +public: + static PyObject* repr(PyObject* self); + static int init(PyPropertyBindingObject* self, PyObject* args, PyObject* kwds); + static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); + static void dealloc(PyPropertyBindingObject* self); + + static PyObject* get_target(PyPropertyBindingObject* self, void* closure); + static PyObject* get_property(PyPropertyBindingObject* self, void* closure); + static PyObject* get_value(PyPropertyBindingObject* self, void* closure); + static PyObject* is_valid(PyPropertyBindingObject* self, void* closure); + + static PyGetSetDef getsetters[]; +}; + +// Python type class for CallableBinding +class PyCallableBindingType { +public: + static PyObject* repr(PyObject* self); + static int init(PyCallableBindingObject* self, PyObject* args, PyObject* kwds); + static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); + static void dealloc(PyCallableBindingObject* self); + + static PyObject* get_callable(PyCallableBindingObject* self, void* closure); + static PyObject* get_value(PyCallableBindingObject* self, void* closure); + static PyObject* is_valid(PyCallableBindingObject* self, void* closure); + + static PyGetSetDef getsetters[]; +}; + +namespace mcrfpydef { + // Using inline to ensure single definition across translation units (C++17) + inline PyTypeObject PyPropertyBindingType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.PropertyBinding", + .tp_basicsize = sizeof(PyPropertyBindingObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)::PyPropertyBindingType::dealloc, + .tp_repr = ::PyPropertyBindingType::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "PropertyBinding(target: UIDrawable, property: str)\n" + "\n" + "A binding that reads a property value from a UI drawable.\n" + "\n" + "Args:\n" + " target: The drawable to read the property from\n" + " property: Name of the property to read (e.g., 'x', 'opacity')\n" + "\n" + "Use this to create dynamic shader uniforms that follow a drawable's\n" + "properties. The binding automatically handles cases where the target\n" + "is destroyed.\n" + "\n" + "Example:\n" + " other_frame = mcrfpy.Frame(pos=(100, 100))\n" + " frame.uniforms['offset_x'] = mcrfpy.PropertyBinding(other_frame, 'x')\n" + ), + .tp_weaklistoffset = offsetof(PyPropertyBindingObject, weakreflist), + .tp_getset = nullptr, // Set in McRFPy_API.cpp before PyType_Ready + .tp_init = (initproc)::PyPropertyBindingType::init, + .tp_new = ::PyPropertyBindingType::pynew, + }; + + inline PyTypeObject PyCallableBindingType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.CallableBinding", + .tp_basicsize = sizeof(PyCallableBindingObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)::PyCallableBindingType::dealloc, + .tp_repr = ::PyCallableBindingType::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "CallableBinding(callable: Callable[[], float])\n" + "\n" + "A binding that calls a Python function to get its value.\n" + "\n" + "Args:\n" + " callable: A function that takes no arguments and returns a float\n" + "\n" + "The callable is invoked every frame when the shader is rendered.\n" + "Keep the callable lightweight to avoid performance issues.\n" + "\n" + "Example:\n" + " player_health = 100\n" + " frame.uniforms['health_pct'] = mcrfpy.CallableBinding(\n" + " lambda: player_health / 100.0\n" + " )\n" + ), + .tp_weaklistoffset = offsetof(PyCallableBindingObject, weakreflist), + .tp_getset = nullptr, // Set in McRFPy_API.cpp before PyType_Ready + .tp_init = (initproc)::PyCallableBindingType::init, + .tp_new = ::PyCallableBindingType::pynew, + }; +} diff --git a/src/PyUniformCollection.cpp b/src/PyUniformCollection.cpp new file mode 100644 index 0000000..6d4abfd --- /dev/null +++ b/src/PyUniformCollection.cpp @@ -0,0 +1,405 @@ +#include "PyUniformCollection.h" +#include "UIDrawable.h" +#include "McRFPy_API.h" +#include "McRFPy_Doc.h" +#include + +// ============================================================================ +// UniformCollection Implementation +// ============================================================================ + +void UniformCollection::setFloat(const std::string& name, float value) { + entries[name] = UniformValue(value); +} + +void UniformCollection::setVec2(const std::string& name, float x, float y) { + entries[name] = UniformValue(sf::Glsl::Vec2(x, y)); +} + +void UniformCollection::setVec3(const std::string& name, float x, float y, float z) { + entries[name] = UniformValue(sf::Glsl::Vec3(x, y, z)); +} + +void UniformCollection::setVec4(const std::string& name, float x, float y, float z, float w) { + entries[name] = UniformValue(sf::Glsl::Vec4(x, y, z, w)); +} + +void UniformCollection::setPropertyBinding(const std::string& name, std::shared_ptr binding) { + entries[name] = binding; +} + +void UniformCollection::setCallableBinding(const std::string& name, std::shared_ptr binding) { + entries[name] = binding; +} + +void UniformCollection::remove(const std::string& name) { + entries.erase(name); +} + +bool UniformCollection::contains(const std::string& name) const { + return entries.find(name) != entries.end(); +} + +std::vector UniformCollection::getNames() const { + std::vector names; + names.reserve(entries.size()); + for (const auto& [name, _] : entries) { + names.push_back(name); + } + return names; +} + +void UniformCollection::applyTo(sf::Shader& shader) const { + for (const auto& [name, entry] : entries) { + std::visit([&shader, &name](auto&& arg) { + using T = std::decay_t; + + if constexpr (std::is_same_v) { + // Static value + std::visit([&shader, &name](auto&& val) { + using V = std::decay_t; + if constexpr (std::is_same_v) { + shader.setUniform(name, val); + } else if constexpr (std::is_same_v) { + shader.setUniform(name, val); + } else if constexpr (std::is_same_v) { + shader.setUniform(name, val); + } else if constexpr (std::is_same_v) { + shader.setUniform(name, val); + } + }, arg); + } + else if constexpr (std::is_same_v>) { + // Property binding - evaluate and apply + if (arg && arg->isValid()) { + auto val = arg->evaluate(); + if (val) { + shader.setUniform(name, *val); + } + } + } + else if constexpr (std::is_same_v>) { + // Callable binding - evaluate and apply + if (arg && arg->isValid()) { + auto val = arg->evaluate(); + if (val) { + shader.setUniform(name, *val); + } + } + } + }, entry); + } +} + +bool UniformCollection::hasDynamicBindings() const { + for (const auto& [_, entry] : entries) { + if (std::holds_alternative>(entry)) { + return true; + } + } + return false; +} + +const UniformEntry* UniformCollection::getEntry(const std::string& name) const { + auto it = entries.find(name); + if (it == entries.end()) return nullptr; + return &it->second; +} + +// ============================================================================ +// PyUniformCollectionType Python Interface +// ============================================================================ + +PyMethodDef PyUniformCollectionType::methods[] = { + {"keys", (PyCFunction)PyUniformCollectionType::keys, METH_NOARGS, + "Return list of uniform names"}, + {"values", (PyCFunction)PyUniformCollectionType::values, METH_NOARGS, + "Return list of uniform values"}, + {"items", (PyCFunction)PyUniformCollectionType::items, METH_NOARGS, + "Return list of (name, value) tuples"}, + {"clear", (PyCFunction)PyUniformCollectionType::clear, METH_NOARGS, + "Remove all uniforms"}, + {NULL} +}; + +PyMappingMethods PyUniformCollectionType::mapping_methods = { + .mp_length = PyUniformCollectionType::mp_length, + .mp_subscript = PyUniformCollectionType::mp_subscript, + .mp_ass_subscript = PyUniformCollectionType::mp_ass_subscript, +}; + +PySequenceMethods PyUniformCollectionType::sequence_methods = { + .sq_length = nullptr, + .sq_concat = nullptr, + .sq_repeat = nullptr, + .sq_item = nullptr, + .was_sq_slice = nullptr, + .sq_ass_item = nullptr, + .was_sq_ass_slice = nullptr, + .sq_contains = PyUniformCollectionType::sq_contains, +}; + +void PyUniformCollectionType::dealloc(PyUniformCollectionObject* self) { + if (self->weakreflist) { + PyObject_ClearWeakRefs((PyObject*)self); + } + // Don't delete collection - it's owned by UIDrawable + Py_TYPE(self)->tp_free((PyObject*)self); +} + +PyObject* PyUniformCollectionType::repr(PyObject* obj) { + PyUniformCollectionObject* self = (PyUniformCollectionObject*)obj; + std::ostringstream ss; + ss << "collection) { + auto names = self->collection->getNames(); + ss << " ["; + for (size_t i = 0; i < names.size(); ++i) { + if (i > 0) ss << ", "; + ss << "'" << names[i] << "'"; + } + ss << "]"; + } + ss << ">"; + return PyUnicode_FromString(ss.str().c_str()); +} + +Py_ssize_t PyUniformCollectionType::mp_length(PyObject* obj) { + PyUniformCollectionObject* self = (PyUniformCollectionObject*)obj; + if (!self->collection) return 0; + return static_cast(self->collection->getNames().size()); +} + +PyObject* PyUniformCollectionType::mp_subscript(PyObject* obj, PyObject* key) { + PyUniformCollectionObject* self = (PyUniformCollectionObject*)obj; + + if (!self->collection) { + PyErr_SetString(PyExc_RuntimeError, "UniformCollection is not valid"); + return NULL; + } + + if (!PyUnicode_Check(key)) { + PyErr_SetString(PyExc_TypeError, "Uniform name must be a string"); + return NULL; + } + + const char* name = PyUnicode_AsUTF8(key); + const UniformEntry* entry = self->collection->getEntry(name); + + if (!entry) { + PyErr_Format(PyExc_KeyError, "'%s'", name); + return NULL; + } + + // Convert entry to Python object + return std::visit([](auto&& arg) -> PyObject* { + using T = std::decay_t; + + if constexpr (std::is_same_v) { + return std::visit([](auto&& val) -> PyObject* { + using V = std::decay_t; + if constexpr (std::is_same_v) { + return PyFloat_FromDouble(val); + } else if constexpr (std::is_same_v) { + return Py_BuildValue("(ff)", val.x, val.y); + } else if constexpr (std::is_same_v) { + return Py_BuildValue("(fff)", val.x, val.y, val.z); + } else if constexpr (std::is_same_v) { + return Py_BuildValue("(ffff)", val.x, val.y, val.z, val.w); + } + Py_RETURN_NONE; + }, arg); + } + else if constexpr (std::is_same_v>) { + // Return the current value for now + // TODO: Return the actual PropertyBinding object + if (arg && arg->isValid()) { + auto val = arg->evaluate(); + if (val) { + return PyFloat_FromDouble(*val); + } + } + Py_RETURN_NONE; + } + else if constexpr (std::is_same_v>) { + // Return the current value for now + // TODO: Return the actual CallableBinding object + if (arg && arg->isValid()) { + auto val = arg->evaluate(); + if (val) { + return PyFloat_FromDouble(*val); + } + } + Py_RETURN_NONE; + } + Py_RETURN_NONE; + }, *entry); +} + +int PyUniformCollectionType::mp_ass_subscript(PyObject* obj, PyObject* key, PyObject* value) { + PyUniformCollectionObject* self = (PyUniformCollectionObject*)obj; + + if (!self->collection) { + PyErr_SetString(PyExc_RuntimeError, "UniformCollection is not valid"); + return -1; + } + + if (!PyUnicode_Check(key)) { + PyErr_SetString(PyExc_TypeError, "Uniform name must be a string"); + return -1; + } + + const char* name = PyUnicode_AsUTF8(key); + + // Delete operation + if (value == NULL) { + self->collection->remove(name); + return 0; + } + + // Check for binding types first + // PropertyBinding + if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyPropertyBindingType)) { + PyPropertyBindingObject* binding = (PyPropertyBindingObject*)value; + if (binding->binding) { + self->collection->setPropertyBinding(name, binding->binding); + return 0; + } + PyErr_SetString(PyExc_ValueError, "PropertyBinding is not valid"); + return -1; + } + + // CallableBinding + if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyCallableBindingType)) { + PyCallableBindingObject* binding = (PyCallableBindingObject*)value; + if (binding->binding) { + self->collection->setCallableBinding(name, binding->binding); + return 0; + } + PyErr_SetString(PyExc_ValueError, "CallableBinding is not valid"); + return -1; + } + + // Float value + if (PyFloat_Check(value) || PyLong_Check(value)) { + float f = static_cast(PyFloat_AsDouble(value)); + if (PyErr_Occurred()) return -1; + self->collection->setFloat(name, f); + return 0; + } + + // Tuple for vec2/vec3/vec4 + if (PyTuple_Check(value)) { + Py_ssize_t size = PyTuple_Size(value); + if (size == 2) { + float x = static_cast(PyFloat_AsDouble(PyTuple_GetItem(value, 0))); + float y = static_cast(PyFloat_AsDouble(PyTuple_GetItem(value, 1))); + if (PyErr_Occurred()) return -1; + self->collection->setVec2(name, x, y); + return 0; + } + else if (size == 3) { + float x = static_cast(PyFloat_AsDouble(PyTuple_GetItem(value, 0))); + float y = static_cast(PyFloat_AsDouble(PyTuple_GetItem(value, 1))); + float z = static_cast(PyFloat_AsDouble(PyTuple_GetItem(value, 2))); + if (PyErr_Occurred()) return -1; + self->collection->setVec3(name, x, y, z); + return 0; + } + else if (size == 4) { + float x = static_cast(PyFloat_AsDouble(PyTuple_GetItem(value, 0))); + float y = static_cast(PyFloat_AsDouble(PyTuple_GetItem(value, 1))); + float z = static_cast(PyFloat_AsDouble(PyTuple_GetItem(value, 2))); + float w = static_cast(PyFloat_AsDouble(PyTuple_GetItem(value, 3))); + if (PyErr_Occurred()) return -1; + self->collection->setVec4(name, x, y, z, w); + return 0; + } + else { + PyErr_Format(PyExc_ValueError, + "Tuple must have 2, 3, or 4 elements for vec2/vec3/vec4, got %zd", size); + return -1; + } + } + + PyErr_SetString(PyExc_TypeError, + "Uniform value must be a float, tuple (vec2/vec3/vec4), PropertyBinding, or CallableBinding"); + return -1; +} + +int PyUniformCollectionType::sq_contains(PyObject* obj, PyObject* key) { + PyUniformCollectionObject* self = (PyUniformCollectionObject*)obj; + + if (!self->collection) return 0; + + if (!PyUnicode_Check(key)) return 0; + + const char* name = PyUnicode_AsUTF8(key); + return self->collection->contains(name) ? 1 : 0; +} + +PyObject* PyUniformCollectionType::keys(PyUniformCollectionObject* self, PyObject* Py_UNUSED(ignored)) { + if (!self->collection) { + return PyList_New(0); + } + + auto names = self->collection->getNames(); + PyObject* list = PyList_New(names.size()); + for (size_t i = 0; i < names.size(); ++i) { + PyList_SetItem(list, i, PyUnicode_FromString(names[i].c_str())); + } + return list; +} + +PyObject* PyUniformCollectionType::values(PyUniformCollectionObject* self, PyObject* Py_UNUSED(ignored)) { + if (!self->collection) { + return PyList_New(0); + } + + auto names = self->collection->getNames(); + PyObject* list = PyList_New(names.size()); + for (size_t i = 0; i < names.size(); ++i) { + PyObject* key = PyUnicode_FromString(names[i].c_str()); + PyObject* val = mp_subscript((PyObject*)self, key); + Py_DECREF(key); + if (!val) { + Py_DECREF(list); + return NULL; + } + PyList_SetItem(list, i, val); + } + return list; +} + +PyObject* PyUniformCollectionType::items(PyUniformCollectionObject* self, PyObject* Py_UNUSED(ignored)) { + if (!self->collection) { + return PyList_New(0); + } + + auto names = self->collection->getNames(); + PyObject* list = PyList_New(names.size()); + for (size_t i = 0; i < names.size(); ++i) { + PyObject* key = PyUnicode_FromString(names[i].c_str()); + PyObject* val = mp_subscript((PyObject*)self, key); + if (!val) { + Py_DECREF(key); + Py_DECREF(list); + return NULL; + } + PyObject* tuple = PyTuple_Pack(2, key, val); + Py_DECREF(key); + Py_DECREF(val); + PyList_SetItem(list, i, tuple); + } + return list; +} + +PyObject* PyUniformCollectionType::clear(PyUniformCollectionObject* self, PyObject* Py_UNUSED(ignored)) { + if (self->collection) { + auto names = self->collection->getNames(); + for (const auto& name : names) { + self->collection->remove(name); + } + } + Py_RETURN_NONE; +} diff --git a/src/PyUniformCollection.h b/src/PyUniformCollection.h new file mode 100644 index 0000000..98c8d4f --- /dev/null +++ b/src/PyUniformCollection.h @@ -0,0 +1,157 @@ +#pragma once +#include "Common.h" +#include "Python.h" +#include "PyUniformBinding.h" +#include +#include + +// Forward declarations +class UIDrawable; + +/** + * @brief Entry in UniformCollection - can be static value or binding + */ +using UniformEntry = std::variant< + UniformValue, // Static value (float, vec2, vec3, vec4) + std::shared_ptr, // Property binding + std::shared_ptr // Callable binding +>; + +/** + * @brief Collection of shader uniforms for a UIDrawable + * + * Stores both static uniform values and dynamic bindings. When applying + * uniforms to a shader, static values are used directly while bindings + * are evaluated each frame. + */ +class UniformCollection { +public: + UniformCollection() = default; + + /** + * @brief Set a static uniform value + */ + void setFloat(const std::string& name, float value); + void setVec2(const std::string& name, float x, float y); + void setVec3(const std::string& name, float x, float y, float z); + void setVec4(const std::string& name, float x, float y, float z, float w); + + /** + * @brief Set a property binding + */ + void setPropertyBinding(const std::string& name, std::shared_ptr binding); + + /** + * @brief Set a callable binding + */ + void setCallableBinding(const std::string& name, std::shared_ptr binding); + + /** + * @brief Remove a uniform + */ + void remove(const std::string& name); + + /** + * @brief Check if a uniform exists + */ + bool contains(const std::string& name) const; + + /** + * @brief Get all uniform names + */ + std::vector getNames() const; + + /** + * @brief Apply all uniforms to a shader + */ + void applyTo(sf::Shader& shader) const; + + /** + * @brief Check if any binding is dynamic (callable) + */ + bool hasDynamicBindings() const; + + /** + * @brief Get the entry for a uniform (for Python access) + */ + const UniformEntry* getEntry(const std::string& name) const; + +private: + std::map entries; +}; + +// Python object structure for UniformCollection +typedef struct { + PyObject_HEAD + UniformCollection* collection; // Owned by UIDrawable, not by this object + std::weak_ptr owner; // For checking validity + PyObject* weakreflist; +} PyUniformCollectionObject; + +/** + * @brief Python type for UniformCollection + * + * Supports dict-like access: collection["name"] = value + */ +class PyUniformCollectionType { +public: + static PyObject* repr(PyObject* self); + static void dealloc(PyUniformCollectionObject* self); + + // Mapping protocol (dict-like access) + static Py_ssize_t mp_length(PyObject* self); + static PyObject* mp_subscript(PyObject* self, PyObject* key); + static int mp_ass_subscript(PyObject* self, PyObject* key, PyObject* value); + + // Sequence protocol for 'in' operator + static int sq_contains(PyObject* self, PyObject* key); + + // Methods + static PyObject* keys(PyUniformCollectionObject* self, PyObject* Py_UNUSED(ignored)); + static PyObject* values(PyUniformCollectionObject* self, PyObject* Py_UNUSED(ignored)); + static PyObject* items(PyUniformCollectionObject* self, PyObject* Py_UNUSED(ignored)); + static PyObject* clear(PyUniformCollectionObject* self, PyObject* Py_UNUSED(ignored)); + + static PyMethodDef methods[]; + static PyMappingMethods mapping_methods; + static PySequenceMethods sequence_methods; +}; + +namespace mcrfpydef { + // Using inline to ensure single definition across translation units (C++17) + inline PyTypeObject PyUniformCollectionType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.UniformCollection", + .tp_basicsize = sizeof(PyUniformCollectionObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)::PyUniformCollectionType::dealloc, + .tp_repr = ::PyUniformCollectionType::repr, + .tp_as_sequence = &::PyUniformCollectionType::sequence_methods, + .tp_as_mapping = &::PyUniformCollectionType::mapping_methods, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "UniformCollection - dict-like container for shader uniforms.\n" + "\n" + "This object is accessed via drawable.uniforms and supports:\n" + "- Getting: value = uniforms['name']\n" + "- Setting: uniforms['name'] = value\n" + "- Deleting: del uniforms['name']\n" + "- Checking: 'name' in uniforms\n" + "- Iterating: for name in uniforms.keys()\n" + "\n" + "Values can be:\n" + "- float: Single value uniform\n" + "- tuple: vec2 (2-tuple), vec3 (3-tuple), or vec4 (4-tuple)\n" + "- PropertyBinding: Dynamic value from another drawable's property\n" + "- CallableBinding: Dynamic value from a Python function\n" + "\n" + "Example:\n" + " frame.uniforms['intensity'] = 0.5\n" + " frame.uniforms['color'] = (1.0, 0.5, 0.0, 1.0)\n" + " frame.uniforms['offset'] = mcrfpy.PropertyBinding(other, 'x')\n" + " del frame.uniforms['intensity']\n" + ), + .tp_weaklistoffset = offsetof(PyUniformCollectionObject, weakreflist), + .tp_methods = nullptr, // Set in McRFPy_API.cpp + }; +} diff --git a/src/UIBase.h b/src/UIBase.h index f02fe1c..e21e608 100644 --- a/src/UIBase.h +++ b/src/UIBase.h @@ -282,4 +282,19 @@ static int UIDrawable_set_opacity(T* self, PyObject* value, void* closure) "Invalid for horizontally-centered alignments (CENTER_LEFT, CENTER_RIGHT, CENTER)." \ ), (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, \ + MCRF_PROPERTY(shader, \ + "Shader for GPU visual effects (Shader or None). " \ + "When set, the drawable is rendered through the shader program. " \ + "Set to None to disable shader effects." \ + ), (void*)type_enum}, \ + {"uniforms", (getter)UIDrawable::get_uniforms, NULL, \ + MCRF_PROPERTY(uniforms, \ + "Collection of shader uniforms (read-only access to collection). " \ + "Set uniforms via dict-like syntax: drawable.uniforms['name'] = value. " \ + "Supports float, vec2/3/4 tuples, PropertyBinding, and CallableBinding." \ + ), (void*)type_enum} + // UIEntity specializations are defined in UIEntity.cpp after UIEntity class is complete diff --git a/src/UICaption.cpp b/src/UICaption.cpp index 9270f2e..eed94b8 100644 --- a/src/UICaption.cpp +++ b/src/UICaption.cpp @@ -5,6 +5,8 @@ #include "PyFont.h" #include "PythonObjectCache.h" #include "PyAlignment.h" +#include "PyShader.h" // #106: Shader support +#include "PyUniformCollection.h" // #106: Uniform collection support // UIDrawable methods now in UIBase.h #include @@ -34,17 +36,50 @@ void UICaption::render(sf::Vector2f offset, sf::RenderTarget& target) { // Check visibility if (!visible) return; - + // Apply opacity auto color = text.getFillColor(); color.a = static_cast(255 * opacity); text.setFillColor(color); - - text.move(offset); - //Resources::game->getWindow().draw(text); - target.draw(text); - text.move(-offset); - + + // #106: Shader rendering path + if (shader && shader->shader) { + // Get the text bounds for rendering + auto bounds = text.getGlobalBounds(); + sf::Vector2f screen_pos = offset + position; + + // Get or create intermediate texture + auto& intermediate = GameEngine::getShaderIntermediate(); + intermediate.clear(sf::Color::Transparent); + + // Render text at origin in intermediate texture + sf::Text temp_text = text; + temp_text.setPosition(0, 0); // Render at origin of intermediate texture + intermediate.draw(temp_text); + intermediate.display(); + + // Create result sprite from intermediate texture + sf::Sprite result_sprite(intermediate.getTexture()); + result_sprite.setPosition(screen_pos); + + // Apply engine uniforms + sf::Vector2f resolution(bounds.width, bounds.height); + PyShader::applyEngineUniforms(*shader->shader, resolution); + + // Apply user uniforms + if (uniforms) { + uniforms->applyTo(*shader->shader); + } + + // Draw with shader + target.draw(result_sprite, shader->shader.get()); + } else { + // Standard rendering path (no shader) + text.move(offset); + target.draw(text); + text.move(-offset); + } + // Restore original alpha color.a = 255; text.setFillColor(color); @@ -314,6 +349,7 @@ PyGetSetDef UICaption::getsetters[] = { UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UICAPTION), UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UICAPTION), + UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UICAPTION), {NULL} }; @@ -595,6 +631,10 @@ bool UICaption::setProperty(const std::string& name, float value) { markDirty(); // #144 - Z-order change affects parent return true; } + // #106: Check for shader uniform properties + if (setShaderProperty(name, value)) { + return true; + } return false; } @@ -674,6 +714,10 @@ bool UICaption::getProperty(const std::string& name, float& value) const { value = static_cast(z_index); return true; } + // #106: Check for shader uniform properties + if (getShaderProperty(name, value)) { + return true; + } return false; } @@ -715,6 +759,10 @@ bool UICaption::hasProperty(const std::string& name) const { if (name == "text") { return true; } + // #106: Check for shader uniform properties + if (hasShaderProperty(name)) { + return true; + } return false; } diff --git a/src/UIDrawable.cpp b/src/UIDrawable.cpp index 8ed785d..2009d11 100644 --- a/src/UIDrawable.cpp +++ b/src/UIDrawable.cpp @@ -13,6 +13,8 @@ #include "PyAnimation.h" #include "PyEasing.h" #include "PySceneObject.h" // #183: For scene parent lookup +#include "PyShader.h" // #106: Shader support +#include "PyUniformCollection.h" // #106: Uniform collection support // Helper function to extract UIDrawable* from any Python UI object // Returns nullptr and sets Python error on failure @@ -947,6 +949,195 @@ void UIDrawable::markDirty() { markContentDirty(); } +// #106 - Shader support +void UIDrawable::markShaderDynamic() { + shader_dynamic = true; + + // Propagate to parent to invalidate caches + auto p = parent.lock(); + if (p) { + p->markShaderDynamic(); + } +} + +// #106: Shader uniform property helpers for animation support +bool UIDrawable::setShaderProperty(const std::string& name, float value) { + // Check if name starts with "shader." + if (name.compare(0, 7, "shader.") != 0) { + return false; + } + + // Extract the uniform name after "shader." + std::string uniform_name = name.substr(7); + if (uniform_name.empty()) { + return false; + } + + // Initialize uniforms collection if needed + if (!uniforms) { + uniforms = std::make_unique(); + } + + // Set the uniform value + uniforms->setFloat(uniform_name, value); + markDirty(); + return true; +} + +bool UIDrawable::getShaderProperty(const std::string& name, float& value) const { + // Check if name starts with "shader." + if (name.compare(0, 7, "shader.") != 0) { + return false; + } + + // Extract the uniform name after "shader." + std::string uniform_name = name.substr(7); + if (uniform_name.empty() || !uniforms) { + return false; + } + + // Try to get the value from uniforms + const auto* entry = uniforms->getEntry(uniform_name); + if (!entry) { + return false; + } + + // UniformEntry is variant, shared_ptr> + // UniformValue is variant + // So we need to check for UniformValue first, then extract the float from it + + // Try to extract static UniformValue from the entry + if (const auto* uval = std::get_if(entry)) { + // Now try to extract float from UniformValue + if (const float* fval = std::get_if(uval)) { + value = *fval; + return true; + } + // Could be vec2/vec3/vec4 - not a float, return false + return false; + } + + // For bindings, evaluate and return + if (const auto* prop_binding = std::get_if>(entry)) { + auto opt_val = (*prop_binding)->evaluate(); + if (opt_val) { + value = *opt_val; + return true; + } + } else if (const auto* call_binding = std::get_if>(entry)) { + auto opt_val = (*call_binding)->evaluate(); + if (opt_val) { + value = *opt_val; + return true; + } + } + + return false; +} + +bool UIDrawable::hasShaderProperty(const std::string& name) const { + // Check if name starts with "shader." + if (name.compare(0, 7, "shader.") != 0) { + return false; + } + + // Shader uniforms are always valid property names (they'll be created on set) + return true; +} + +// Python API for shader property +PyObject* UIDrawable::get_shader(PyObject* self, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; + + if (!drawable->shader) { + Py_RETURN_NONE; + } + + // Return the shader object (increment reference) + Py_INCREF(drawable->shader.get()); + return (PyObject*)drawable->shader.get(); +} + +int UIDrawable::set_shader(PyObject* self, PyObject* value, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return -1; + + if (value == Py_None) { + // Clear shader + drawable->shader.reset(); + drawable->shader_dynamic = false; + drawable->markDirty(); + return 0; + } + + // Check if it's a Shader object + if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyShaderType)) { + PyErr_SetString(PyExc_TypeError, "shader must be a Shader object or None"); + return -1; + } + + PyShaderObject* shader_obj = (PyShaderObject*)value; + if (!shader_obj->shader) { + PyErr_SetString(PyExc_ValueError, "Shader is not valid (compilation failed?)"); + return -1; + } + + // Store the shader + drawable->shader = std::shared_ptr(shader_obj, [](PyShaderObject* p) { + // Custom deleter that doesn't delete the Python object + // The Python reference counting handles that + }); + Py_INCREF(shader_obj); // Keep the Python object alive + + // Create uniforms collection if needed + if (!drawable->uniforms) { + drawable->uniforms = std::make_unique(); + } + + // Set dynamic flag if shader is dynamic + if (shader_obj->dynamic) { + drawable->markShaderDynamic(); + } + + // Enable RenderTexture for shader rendering (if not already enabled) + auto bounds = drawable->get_bounds(); + if (bounds.width > 0 && bounds.height > 0) { + drawable->enableRenderTexture( + static_cast(bounds.width), + static_cast(bounds.height) + ); + } + + drawable->markDirty(); + return 0; +} + +PyObject* UIDrawable::get_uniforms(PyObject* self, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; + + // Create uniforms collection if needed + if (!drawable->uniforms) { + drawable->uniforms = std::make_unique(); + } + + // Create and return a Python wrapper for the collection + PyUniformCollectionObject* collection = (PyUniformCollectionObject*) + mcrfpydef::PyUniformCollectionType.tp_alloc(&mcrfpydef::PyUniformCollectionType, 0); + + if (!collection) return NULL; + + collection->collection = drawable->uniforms.get(); + collection->weakreflist = NULL; + // Note: owner weak_ptr could be set here if we had access to shared_ptr + + return (PyObject*)collection; +} + // Python API - get parent drawable PyObject* UIDrawable::get_parent(PyObject* self, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); diff --git a/src/UIDrawable.h b/src/UIDrawable.h index cffcebd..4f443f6 100644 --- a/src/UIDrawable.h +++ b/src/UIDrawable.h @@ -16,6 +16,12 @@ #include "Resources.h" #include "UIBase.h" + +// Forward declarations for shader support (#106) +class UniformCollection; +// PyShaderObject is a typedef, forward declare as a struct with explicit typedef +typedef struct PyShaderObjectStruct PyShaderObject; + class UIFrame; class UICaption; class UISprite; class UIEntity; class UIGrid; enum PyObjectsEnum : int @@ -205,6 +211,12 @@ public: // Check if a property name is valid for animation on this drawable type virtual bool hasProperty(const std::string& name) const { return false; } + // #106: Shader uniform property helpers for animation support + // These methods handle "shader.uniform_name" property paths + bool setShaderProperty(const std::string& name, float value); + bool getShaderProperty(const std::string& name, float& value) const; + bool hasShaderProperty(const std::string& name) const; + // Note: animate_helper is now a free function (UIDrawable_animate_impl) declared in UIBase.h // to avoid incomplete type issues with template instantiation. @@ -253,8 +265,22 @@ protected: public: void disableRenderTexture(); + // Shader support (#106) + std::shared_ptr shader; + std::unique_ptr uniforms; + bool shader_dynamic = false; // True if shader uses time-varying effects + + // Mark this drawable as having dynamic shader effects + // Propagates up to parent to invalidate caches + void markShaderDynamic(); + + // Python API for shader properties + static PyObject* get_shader(PyObject* self, void* closure); + static int set_shader(PyObject* self, PyObject* value, void* closure); + static PyObject* get_uniforms(PyObject* self, void* closure); + protected: - + public: // #144: Dirty flag system - content vs composite // content_dirty: THIS drawable's texture needs rebuild (color/text/sprite changed) diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp index e817a7f..196b6b1 100644 --- a/src/UIEntity.cpp +++ b/src/UIEntity.cpp @@ -12,6 +12,8 @@ #include "PyAnimation.h" #include "PyEasing.h" #include "PyPositionHelper.h" +#include "PyShader.h" // #106: Shader support +#include "PyUniformCollection.h" // #106: Uniform collection support // UIDrawable methods now in UIBase.h #include "UIEntityPyMethods.h" @@ -1035,6 +1037,14 @@ PyGetSetDef UIEntity::getsetters[] = { {"visible", (getter)UIEntity_get_visible, (setter)UIEntity_set_visible, "Visibility flag", NULL}, {"opacity", (getter)UIEntity_get_opacity, (setter)UIEntity_set_opacity, "Opacity (0.0 = transparent, 1.0 = opaque)", NULL}, {"name", (getter)UIEntity_get_name, (setter)UIEntity_set_name, "Name for finding elements", NULL}, + {"shader", (getter)UIEntity_get_shader, (setter)UIEntity_set_shader, + "Shader for GPU visual effects (Shader or None). " + "When set, the entity is rendered through the shader program. " + "Set to None to disable shader effects.", NULL}, + {"uniforms", (getter)UIEntity_get_uniforms, NULL, + "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}, {NULL} /* Sentinel */ }; @@ -1073,6 +1083,10 @@ bool UIEntity::setProperty(const std::string& name, float value) { if (grid) grid->markDirty(); // #144 - Content change return true; } + // #106: Shader uniform properties - delegate to sprite + if (sprite.setShaderProperty(name, value)) { + return true; + } return false; } @@ -1098,6 +1112,10 @@ bool UIEntity::getProperty(const std::string& name, float& value) const { value = sprite.getScale().x; // Assuming uniform scale return true; } + // #106: Shader uniform properties - delegate to sprite + if (sprite.getShaderProperty(name, value)) { + return true; + } return false; } @@ -1110,6 +1128,10 @@ bool UIEntity::hasProperty(const std::string& name) const { if (name == "sprite_index" || name == "sprite_number") { return true; } + // #106: Shader uniform properties - delegate to sprite + if (sprite.hasShaderProperty(name)) { + return true; + } return false; } diff --git a/src/UIEntityPyMethods.h b/src/UIEntityPyMethods.h index 53e5732..88386a2 100644 --- a/src/UIEntityPyMethods.h +++ b/src/UIEntityPyMethods.h @@ -1,6 +1,8 @@ #pragma once #include "UIEntity.h" #include "UIBase.h" +#include "PyShader.h" // #106: Shader support +#include "PyUniformCollection.h" // #106: Uniform collection support // UIEntity-specific property implementations // These delegate to the wrapped sprite member @@ -72,4 +74,73 @@ static int UIEntity_set_name(PyUIEntityObject* self, PyObject* value, void* clos self->data->sprite.name = name_str; return 0; +} + +// #106: Shader property - delegate to sprite +static PyObject* UIEntity_get_shader(PyUIEntityObject* self, void* closure) +{ + auto& shader_ptr = self->data->sprite.shader; + if (!shader_ptr) { + Py_RETURN_NONE; + } + // Return the PyShaderObject (which is also a PyObject) + Py_INCREF((PyObject*)shader_ptr.get()); + return (PyObject*)shader_ptr.get(); +} + +static int UIEntity_set_shader(PyUIEntityObject* self, PyObject* value, void* closure) +{ + if (value == Py_None || value == NULL) { + self->data->sprite.shader.reset(); + self->data->sprite.shader_dynamic = false; + return 0; + } + + // Check if value is a Shader object + if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyShaderType)) { + PyErr_SetString(PyExc_TypeError, "shader must be a Shader object or None"); + return -1; + } + + PyShaderObject* shader_obj = (PyShaderObject*)value; + + // Store a shared_ptr to the PyShaderObject + // We need to increment the refcount since we're storing a reference + Py_INCREF(value); + self->data->sprite.shader = std::shared_ptr(shader_obj, [](PyShaderObject* p) { + Py_DECREF((PyObject*)p); + }); + + // Initialize uniforms collection if needed + if (!self->data->sprite.uniforms) { + self->data->sprite.uniforms = std::make_unique(); + } + + // Propagate dynamic flag + if (shader_obj->dynamic) { + self->data->sprite.markShaderDynamic(); + } + + return 0; +} + +// #106: Uniforms property - delegate to sprite's uniforms collection +static PyObject* UIEntity_get_uniforms(PyUIEntityObject* self, void* closure) +{ + // Initialize uniforms collection if needed + if (!self->data->sprite.uniforms) { + self->data->sprite.uniforms = std::make_unique(); + } + + // Create a Python wrapper for the uniforms collection + PyUniformCollectionObject* uniforms_obj = (PyUniformCollectionObject*)mcrfpydef::PyUniformCollectionType.tp_alloc(&mcrfpydef::PyUniformCollectionType, 0); + if (!uniforms_obj) { + return NULL; + } + + // The collection is owned by the sprite, we just provide a view + uniforms_obj->collection = self->data->sprite.uniforms.get(); + uniforms_obj->weakreflist = NULL; + + return (PyObject*)uniforms_obj; } \ No newline at end of file diff --git a/src/UIFrame.cpp b/src/UIFrame.cpp index 4779efc..cfc9b97 100644 --- a/src/UIFrame.cpp +++ b/src/UIFrame.cpp @@ -8,6 +8,8 @@ #include "McRFPy_API.h" #include "PythonObjectCache.h" #include "PyAlignment.h" +#include "PyShader.h" // #106: Shader support +#include "PyUniformCollection.h" // #106: Uniform collection #include // #106: for shader error output // UIDrawable methods now in UIBase.h @@ -110,11 +112,11 @@ void UIFrame::render(sf::Vector2f offset, sf::RenderTarget& target) // TODO: Apply opacity when SFML supports it on shapes - // #144: Use RenderTexture for clipping OR texture caching OR shaders + // #144: Use RenderTexture for clipping OR texture caching OR shaders (#106) // clip_children: requires texture for clipping effect (only when has children) // cache_subtree: uses texture for performance (always, even without children) - // shader_enabled: requires texture for shader post-processing - bool use_texture = (clip_children && !children->empty()) || cache_subtree || shader_enabled; + // shader: requires texture for shader post-processing + bool use_texture = (clip_children && !children->empty()) || cache_subtree || (shader && shader->shader); if (use_texture) { // Enable RenderTexture if not already enabled @@ -170,13 +172,19 @@ 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); - // #106 POC: Apply shader if enabled - if (shader_enabled && shader) { - // Update time uniform for animated effects - static sf::Clock shader_clock; - shader->setUniform("time", shader_clock.getElapsedTime().asSeconds()); - shader->setUniform("texture", sf::Shader::CurrentTexture); - target.draw(render_sprite, shader.get()); + // #106: Apply shader if set + if (shader && shader->shader) { + // Apply engine uniforms (time, resolution, mouse, texture) + sf::Vector2f resolution(render_sprite.getLocalBounds().width, + render_sprite.getLocalBounds().height); + PyShader::applyEngineUniforms(*shader->shader, resolution); + + // Apply user-defined uniforms + if (uniforms) { + uniforms->applyTo(*shader->shader); + } + + target.draw(render_sprite, shader->shader.get()); } else { target.draw(render_sprite); } @@ -462,87 +470,6 @@ int UIFrame::set_cache_subtree(PyUIFrameObject* self, PyObject* value, void* clo } // #106 - Shader POC: shader_enabled property -PyObject* UIFrame::get_shader_enabled(PyUIFrameObject* self, void* closure) -{ - return PyBool_FromLong(self->data->shader_enabled); -} - -int UIFrame::set_shader_enabled(PyUIFrameObject* self, PyObject* value, void* closure) -{ - if (!PyBool_Check(value)) { - PyErr_SetString(PyExc_TypeError, "shader_enabled must be a boolean"); - return -1; - } - - bool new_shader = PyObject_IsTrue(value); - if (new_shader != self->data->shader_enabled) { - self->data->shader_enabled = new_shader; - - if (new_shader) { - // Initialize the test shader if not already done - if (!self->data->shader) { - self->data->initializeTestShader(); - } - // Shader requires RenderTexture - enable it - auto size = self->data->box.getSize(); - if (size.x > 0 && size.y > 0) { - self->data->enableRenderTexture(static_cast(size.x), - static_cast(size.y)); - } - } - // Note: we don't disable RenderTexture when shader disabled - - // clip_children or cache_subtree may still need it - - self->data->markDirty(); - } - - return 0; -} - -// #106 - Initialize test shader (hardcoded glow/brightness effect) -void UIFrame::initializeTestShader() -{ - // Check if shaders are available - if (!sf::Shader::isAvailable()) { - std::cerr << "Shaders are not available on this system!" << std::endl; - return; - } - - shader = std::make_unique(); - - // Simple color inversion + wave distortion shader for POC - // This makes it obvious the shader is working - const std::string fragmentShader = R"( - uniform sampler2D texture; - uniform float time; - - void main() { - vec2 uv = gl_TexCoord[0].xy; - - // Subtle wave distortion based on time - uv.x += sin(uv.y * 10.0 + time * 2.0) * 0.01; - uv.y += cos(uv.x * 10.0 + time * 2.0) * 0.01; - - vec4 color = texture2D(texture, uv); - - // Glow effect: boost brightness and add slight color shift - float glow = 0.2 + 0.1 * sin(time * 3.0); - color.rgb = color.rgb * (1.0 + glow); - - // Slight hue shift for visual interest - color.r += 0.1 * sin(time); - color.b += 0.1 * cos(time); - - gl_FragColor = color; - } - )"; - - if (!shader->loadFromMemory(fragmentShader, sf::Shader::Fragment)) { - std::cerr << "Failed to load test shader!" << std::endl; - shader.reset(); - } -} - // Define the PyObjectType alias for the macros typedef PyUIFrameObject PyObjectType; @@ -581,10 +508,10 @@ PyGetSetDef UIFrame::getsetters[] = { {"grid_size", (getter)UIDrawable::get_grid_size, (setter)UIDrawable::set_grid_size, "Size in grid tile coordinates (only when parent is Grid)", (void*)PyObjectsEnum::UIFRAME}, {"clip_children", (getter)UIFrame::get_clip_children, (setter)UIFrame::set_clip_children, "Whether to clip children to frame bounds", NULL}, {"cache_subtree", (getter)UIFrame::get_cache_subtree, (setter)UIFrame::set_cache_subtree, "#144: Cache subtree rendering to texture for performance", NULL}, - {"shader_enabled", (getter)UIFrame::get_shader_enabled, (setter)UIFrame::set_shader_enabled, "#106 POC: Enable test shader effect", NULL}, UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIFRAME), UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIFRAME), + UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UIFRAME), {NULL} }; @@ -930,6 +857,10 @@ bool UIFrame::setProperty(const std::string& name, float value) { markDirty(); return true; } + // #106: Check for shader uniform properties + if (setShaderProperty(name, value)) { + return true; + } return false; } @@ -1006,6 +937,10 @@ bool UIFrame::getProperty(const std::string& name, float& value) const { value = box.getOutlineColor().a; return true; } + // #106: Check for shader uniform properties + if (getShaderProperty(name, value)) { + return true; + } return false; } @@ -1049,5 +984,9 @@ bool UIFrame::hasProperty(const std::string& name) const { if (name == "position" || name == "size") { return true; } + // #106: Check for shader uniform properties + if (hasShaderProperty(name)) { + return true; + } return false; } diff --git a/src/UIFrame.h b/src/UIFrame.h index 28b72fb..75e5d48 100644 --- a/src/UIFrame.h +++ b/src/UIFrame.h @@ -33,10 +33,6 @@ public: bool clip_children = false; // Whether to clip children to frame bounds bool cache_subtree = false; // #144: Whether to cache subtree rendering to texture - // Shader POC (#106) - std::unique_ptr shader; - bool shader_enabled = false; - void initializeTestShader(); // Load hardcoded test shader void render(sf::Vector2f, sf::RenderTarget&) override final; void move(sf::Vector2f); PyObjectsEnum derived_type() override final; @@ -60,8 +56,6 @@ public: static int set_clip_children(PyUIFrameObject* self, PyObject* value, void* closure); static PyObject* get_cache_subtree(PyUIFrameObject* self, void* closure); static int set_cache_subtree(PyUIFrameObject* self, PyObject* value, void* closure); - static PyObject* get_shader_enabled(PyUIFrameObject* self, void* closure); - static int set_shader_enabled(PyUIFrameObject* self, PyObject* value, void* closure); static PyGetSetDef getsetters[]; static PyObject* repr(PyUIFrameObject* self); static int init(PyUIFrameObject* self, PyObject* args, PyObject* kwds); diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 683db31..008b2ea 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -11,6 +11,8 @@ #include "PyPositionHelper.h" // For standardized position argument parsing #include "PyVector.h" // #179, #181 - For Vector return types #include "PyHeightMap.h" // #199 - HeightMap application methods +#include "PyShader.h" // #106: Shader support +#include "PyUniformCollection.h" // #106: Uniform collection support #include #include // #142 - for std::floor, std::isnan #include // #150 - for strcmp @@ -351,9 +353,21 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) // render to window renderTexture.display(); - //Resources::game->getWindow().draw(output); - target.draw(output); + // #106: Apply shader if set + 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 { + target.draw(output); + } } UIGridPoint& UIGrid::at(int x, int y) @@ -2232,6 +2246,7 @@ PyGetSetDef UIGrid::getsetters[] = { "Callback when a grid cell is clicked. Called with (cell_pos: Vector).", NULL}, {"hovered_cell", (getter)UIGrid::get_hovered_cell, NULL, "Currently hovered cell as (x, y) tuple, or None if not hovering.", NULL}, + UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UIGRID), {NULL} /* Sentinel */ }; @@ -2517,6 +2532,10 @@ bool UIGrid::setProperty(const std::string& name, float value) { markDirty(); // #144 - Content change return true; } + // #106: Shader uniform properties + if (setShaderProperty(name, value)) { + return true; + } return false; } @@ -2592,6 +2611,10 @@ bool UIGrid::getProperty(const std::string& name, float& value) const { value = static_cast(fill_color.a); return true; } + // #106: Shader uniform properties + if (getShaderProperty(name, value)) { + return true; + } return false; } @@ -2625,5 +2648,9 @@ bool UIGrid::hasProperty(const std::string& name) const { if (name == "position" || name == "size" || name == "center") { return true; } + // #106: Shader uniform properties + if (hasShaderProperty(name)) { + return true; + } return false; } diff --git a/src/UISprite.cpp b/src/UISprite.cpp index 27b7c3f..17596ad 100644 --- a/src/UISprite.cpp +++ b/src/UISprite.cpp @@ -4,6 +4,8 @@ #include "PythonObjectCache.h" #include "UIFrame.h" // #144: For snapshot= parameter #include "PyAlignment.h" +#include "PyShader.h" // #106: Shader support +#include "PyUniformCollection.h" // #106: Uniform collection support // UIDrawable methods now in UIBase.h UIDrawable* UISprite::click_at(sf::Vector2f point) @@ -81,16 +83,50 @@ void UISprite::render(sf::Vector2f offset, sf::RenderTarget& target) { // Check visibility if (!visible) return; - + // Apply opacity auto color = sprite.getColor(); color.a = static_cast(255 * opacity); sprite.setColor(color); - - sprite.move(offset); - target.draw(sprite); - sprite.move(-offset); - + + // #106: Shader rendering path + if (shader && shader->shader) { + // Get the sprite bounds for rendering + auto bounds = sprite.getGlobalBounds(); + sf::Vector2f screen_pos = offset + position; + + // Get or create intermediate texture + auto& intermediate = GameEngine::getShaderIntermediate(); + intermediate.clear(sf::Color::Transparent); + + // Render sprite at origin in intermediate texture + sf::Sprite temp_sprite = sprite; + temp_sprite.setPosition(0, 0); // Render at origin of intermediate texture + intermediate.draw(temp_sprite); + intermediate.display(); + + // Create result sprite from intermediate texture + sf::Sprite result_sprite(intermediate.getTexture()); + result_sprite.setPosition(screen_pos); + + // Apply engine uniforms + sf::Vector2f resolution(bounds.width, bounds.height); + PyShader::applyEngineUniforms(*shader->shader, resolution); + + // Apply user uniforms + if (uniforms) { + uniforms->applyTo(*shader->shader); + } + + // Draw with shader + target.draw(result_sprite, shader->shader.get()); + } else { + // Standard rendering path (no shader) + sprite.move(offset); + target.draw(sprite); + sprite.move(-offset); + } + // Restore original alpha color.a = 255; sprite.setColor(color); @@ -359,6 +395,7 @@ PyGetSetDef UISprite::getsetters[] = { UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UISPRITE), UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UISPRITE), + UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UISPRITE), {NULL} }; @@ -591,6 +628,10 @@ bool UISprite::setProperty(const std::string& name, float value) { markDirty(); // #144 - Z-order change affects parent return true; } + // #106: Check for shader uniform properties + if (setShaderProperty(name, value)) { + return true; + } return false; } @@ -633,6 +674,10 @@ bool UISprite::getProperty(const std::string& name, float& value) const { value = static_cast(z_index); return true; } + // #106: Check for shader uniform properties + if (getShaderProperty(name, value)) { + return true; + } return false; } @@ -659,5 +704,9 @@ bool UISprite::hasProperty(const std::string& name) const { if (name == "sprite_index" || name == "sprite_number") { return true; } + // #106: Check for shader uniform properties + if (hasShaderProperty(name)) { + return true; + } return false; } diff --git a/tests/unit/shader_test.py b/tests/unit/shader_test.py new file mode 100644 index 0000000..174a39e --- /dev/null +++ b/tests/unit/shader_test.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Unit tests for the Shader system (Issue #106) + +Tests cover: +- Shader creation and compilation +- Static uniforms (float, vec2, vec3, vec4) +- PropertyBinding for dynamic uniform values +- CallableBinding for computed uniform values +- Shader assignment to various drawable types +- Dynamic flag propagation +""" + +import mcrfpy +import sys + + +def test_shader_creation(): + """Test basic shader creation""" + print("Testing shader creation...") + + # Valid shader + shader = mcrfpy.Shader(''' + uniform sampler2D texture; + void main() { + gl_FragColor = texture2D(texture, gl_TexCoord[0].xy); + } + ''') + assert shader is not None, "Shader should be created" + assert shader.is_valid, "Shader should be valid" + assert shader.dynamic == False, "Shader should not be dynamic by default" + + # Dynamic shader + dynamic_shader = mcrfpy.Shader(''' + uniform sampler2D texture; + uniform float time; + void main() { + gl_FragColor = texture2D(texture, gl_TexCoord[0].xy); + } + ''', dynamic=True) + assert dynamic_shader.dynamic == True, "Shader should be dynamic when specified" + + print(" PASS: Basic shader creation works") + + +def test_shader_source(): + """Test that shader source is stored correctly""" + print("Testing shader source storage...") + + source = '''uniform sampler2D texture; +void main() { + gl_FragColor = texture2D(texture, gl_TexCoord[0].xy); +}''' + shader = mcrfpy.Shader(source) + assert source in shader.source, "Shader source should be stored" + + print(" PASS: Shader source is stored") + + +def test_static_uniforms(): + """Test static uniform values""" + print("Testing static uniforms...") + + frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + + # Test float uniform + frame.uniforms['intensity'] = 0.5 + assert abs(frame.uniforms['intensity'] - 0.5) < 0.001, "Float uniform should match" + + # Test vec2 uniform + frame.uniforms['offset'] = (10.0, 20.0) + val = frame.uniforms['offset'] + assert len(val) == 2, "Vec2 should have 2 components" + assert abs(val[0] - 10.0) < 0.001, "Vec2.x should match" + assert abs(val[1] - 20.0) < 0.001, "Vec2.y should match" + + # Test vec3 uniform + frame.uniforms['color_rgb'] = (1.0, 0.5, 0.0) + val = frame.uniforms['color_rgb'] + assert len(val) == 3, "Vec3 should have 3 components" + + # Test vec4 uniform + frame.uniforms['color'] = (1.0, 0.5, 0.0, 1.0) + val = frame.uniforms['color'] + assert len(val) == 4, "Vec4 should have 4 components" + + print(" PASS: Static uniforms work") + + +def test_uniform_keys(): + """Test uniform collection keys/values/items""" + print("Testing uniform collection methods...") + + frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + frame.uniforms['a'] = 1.0 + frame.uniforms['b'] = 2.0 + frame.uniforms['c'] = 3.0 + + keys = frame.uniforms.keys() + assert 'a' in keys, "Key 'a' should be present" + assert 'b' in keys, "Key 'b' should be present" + assert 'c' in keys, "Key 'c' should be present" + assert len(keys) == 3, "Should have 3 keys" + + # Test 'in' operator + assert 'a' in frame.uniforms, "'in' operator should work" + assert 'nonexistent' not in frame.uniforms, "'not in' should work" + + # Test deletion + del frame.uniforms['b'] + assert 'b' not in frame.uniforms, "Deleted key should be gone" + assert len(frame.uniforms.keys()) == 2, "Should have 2 keys after deletion" + + print(" PASS: Uniform collection methods work") + + +def test_property_binding(): + """Test PropertyBinding for dynamic uniform values""" + print("Testing PropertyBinding...") + + # Create source and target frames + source_frame = mcrfpy.Frame(pos=(100, 200), size=(50, 50)) + target_frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + + # Create binding to source frame's x position + binding = mcrfpy.PropertyBinding(source_frame, 'x') + assert binding is not None, "PropertyBinding should be created" + assert binding.property == 'x', "Property name should be stored" + assert abs(binding.value - 100.0) < 0.001, "Initial value should be 100" + assert binding.is_valid == True, "Binding should be valid" + + # Assign binding to uniform + target_frame.uniforms['source_x'] = binding + + # Check that value tracks changes + source_frame.x = 300 + assert abs(binding.value - 300.0) < 0.001, "Binding should track changes" + + print(" PASS: PropertyBinding works") + + +def test_callable_binding(): + """Test CallableBinding for computed uniform values""" + print("Testing CallableBinding...") + + counter = [0] # Use list for closure + + def compute_value(): + counter[0] += 1 + return counter[0] * 0.1 + + binding = mcrfpy.CallableBinding(compute_value) + assert binding is not None, "CallableBinding should be created" + assert binding.is_valid == True, "Binding should be valid" + + # Each access should call the function + v1 = binding.value + v2 = binding.value + v3 = binding.value + + assert abs(v1 - 0.1) < 0.001, "First call should return 0.1" + assert abs(v2 - 0.2) < 0.001, "Second call should return 0.2" + assert abs(v3 - 0.3) < 0.001, "Third call should return 0.3" + + # Assign to uniform + frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + frame.uniforms['computed'] = binding + + print(" PASS: CallableBinding works") + + +def test_shader_on_frame(): + """Test shader assignment to Frame""" + print("Testing shader on Frame...") + + shader = mcrfpy.Shader(''' + uniform sampler2D texture; + uniform float intensity; + void main() { + vec4 color = texture2D(texture, gl_TexCoord[0].xy); + color.rgb *= intensity; + gl_FragColor = color; + } + ''') + + frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + assert frame.shader is None, "Shader should be None initially" + + frame.shader = shader + assert frame.shader is not None, "Shader should be assigned" + + frame.uniforms['intensity'] = 0.8 + assert abs(frame.uniforms['intensity'] - 0.8) < 0.001, "Uniform should be set" + + # Test shader removal + frame.shader = None + assert frame.shader is None, "Shader should be removable" + + print(" PASS: Shader on Frame works") + + +def test_shader_on_sprite(): + """Test shader assignment to Sprite""" + print("Testing shader on Sprite...") + + shader = mcrfpy.Shader(''' + uniform sampler2D texture; + void main() { + gl_FragColor = texture2D(texture, gl_TexCoord[0].xy); + } + ''') + + sprite = mcrfpy.Sprite(pos=(0, 0)) + assert sprite.shader is None, "Shader should be None initially" + + sprite.shader = shader + assert sprite.shader is not None, "Shader should be assigned" + + sprite.uniforms['test'] = 1.0 + assert abs(sprite.uniforms['test'] - 1.0) < 0.001, "Uniform should be set" + + print(" PASS: Shader on Sprite works") + + +def test_shader_on_caption(): + """Test shader assignment to Caption""" + print("Testing shader on Caption...") + + shader = mcrfpy.Shader(''' + uniform sampler2D texture; + void main() { + gl_FragColor = texture2D(texture, gl_TexCoord[0].xy); + } + ''') + + caption = mcrfpy.Caption(text="Test", pos=(0, 0)) + assert caption.shader is None, "Shader should be None initially" + + caption.shader = shader + assert caption.shader is not None, "Shader should be assigned" + + caption.uniforms['test'] = 1.0 + assert abs(caption.uniforms['test'] - 1.0) < 0.001, "Uniform should be set" + + print(" PASS: Shader on Caption works") + + +def test_shader_on_grid(): + """Test shader assignment to Grid""" + print("Testing shader on Grid...") + + shader = mcrfpy.Shader(''' + uniform sampler2D texture; + void main() { + gl_FragColor = texture2D(texture, gl_TexCoord[0].xy); + } + ''') + + grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(200, 200)) + assert grid.shader is None, "Shader should be None initially" + + grid.shader = shader + assert grid.shader is not None, "Shader should be assigned" + + grid.uniforms['test'] = 1.0 + assert abs(grid.uniforms['test'] - 1.0) < 0.001, "Uniform should be set" + + print(" PASS: Shader on Grid works") + + +def test_shader_on_entity(): + """Test shader assignment to Entity""" + print("Testing shader on Entity...") + + shader = mcrfpy.Shader(''' + uniform sampler2D texture; + void main() { + gl_FragColor = texture2D(texture, gl_TexCoord[0].xy); + } + ''') + + entity = mcrfpy.Entity() + assert entity.shader is None, "Shader should be None initially" + + entity.shader = shader + assert entity.shader is not None, "Shader should be assigned" + + entity.uniforms['test'] = 1.0 + assert abs(entity.uniforms['test'] - 1.0) < 0.001, "Uniform should be set" + + print(" PASS: Shader on Entity works") + + +def test_shared_shader(): + """Test that multiple drawables can share the same shader""" + print("Testing shared shader...") + + shader = mcrfpy.Shader(''' + uniform sampler2D texture; + uniform float intensity; + void main() { + vec4 color = texture2D(texture, gl_TexCoord[0].xy); + color.rgb *= intensity; + gl_FragColor = color; + } + ''') + + frame1 = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + frame2 = mcrfpy.Frame(pos=(100, 0), size=(100, 100)) + + # Assign same shader to both + frame1.shader = shader + frame2.shader = shader + + # But different uniform values + frame1.uniforms['intensity'] = 0.5 + frame2.uniforms['intensity'] = 1.0 + + assert abs(frame1.uniforms['intensity'] - 0.5) < 0.001, "Frame1 intensity should be 0.5" + assert abs(frame2.uniforms['intensity'] - 1.0) < 0.001, "Frame2 intensity should be 1.0" + + print(" PASS: Shared shader with different uniforms works") + + +def test_shader_animation_properties(): + """Test that shader uniforms can be animated via the animation system""" + print("Testing shader animation properties...") + + frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + + # Set initial uniform value + frame.uniforms['intensity'] = 0.0 + + # Test animate() method with shader.X property syntax + # This uses hasProperty/setProperty internally + try: + frame.animate('shader.intensity', 1.0, 0.5, mcrfpy.Easing.LINEAR) + animation_works = True + except Exception as e: + animation_works = False + print(f" Animation error: {e}") + + assert animation_works, "Animating shader uniforms should work" + + # Test with different drawable types + sprite = mcrfpy.Sprite(pos=(0, 0)) + sprite.uniforms['glow'] = 0.0 + try: + sprite.animate('shader.glow', 2.0, 1.0, mcrfpy.Easing.EASE_IN) + sprite_animation_works = True + except Exception as e: + sprite_animation_works = False + print(f" Sprite animation error: {e}") + + assert sprite_animation_works, "Animating Sprite shader uniforms should work" + + # Test Caption + caption = mcrfpy.Caption(text="Test", pos=(0, 0)) + caption.uniforms['alpha'] = 1.0 + try: + caption.animate('shader.alpha', 0.0, 0.5, mcrfpy.Easing.EASE_OUT) + caption_animation_works = True + except Exception as e: + caption_animation_works = False + print(f" Caption animation error: {e}") + + assert caption_animation_works, "Animating Caption shader uniforms should work" + + # Test Grid + grid = mcrfpy.Grid(grid_size=(5, 5), pos=(0, 0), size=(100, 100)) + grid.uniforms['zoom_effect'] = 1.0 + try: + grid.animate('shader.zoom_effect', 2.0, 1.0, mcrfpy.Easing.LINEAR) + grid_animation_works = True + except Exception as e: + grid_animation_works = False + print(f" Grid animation error: {e}") + + assert grid_animation_works, "Animating Grid shader uniforms should work" + + print(" PASS: Shader animation properties work") + + +def run_all_tests(): + """Run all shader tests""" + print("=" * 50) + print("Shader System Unit Tests") + print("=" * 50) + print() + + try: + test_shader_creation() + test_shader_source() + test_static_uniforms() + test_uniform_keys() + test_property_binding() + test_callable_binding() + test_shader_on_frame() + test_shader_on_sprite() + test_shader_on_caption() + test_shader_on_grid() + test_shader_on_entity() + test_shared_shader() + test_shader_animation_properties() + + print() + print("=" * 50) + print("ALL TESTS PASSED") + print("=" * 50) + sys.exit(0) + + except AssertionError as e: + print(f" FAIL: {e}") + sys.exit(1) + except Exception as e: + print(f" ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + run_all_tests() From da434dcc64b9f596adb86da3419350a7f450e327 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sun, 25 Jan 2026 23:20:52 -0500 Subject: [PATCH 2/2] 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)