From 486087b9cbae6fe537ed4d5f25d717b633fb3b6e Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sun, 25 Jan 2026 21:04:01 -0500 Subject: [PATCH] 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()