diff --git a/src/GameEngine.cpp b/src/GameEngine.cpp index b133457..47519a6 100644 --- a/src/GameEngine.cpp +++ b/src/GameEngine.cpp @@ -136,6 +136,10 @@ void GameEngine::cleanup() } Scene* GameEngine::currentScene() { return scenes[scene]; } +Scene* GameEngine::getScene(const std::string& name) { + auto it = scenes.find(name); + return (it != scenes.end()) ? it->second : nullptr; +} void GameEngine::changeScene(std::string s) { changeScene(s, TransitionType::None, 0.0f); diff --git a/src/GameEngine.h b/src/GameEngine.h index ed70bd5..884305e 100644 --- a/src/GameEngine.h +++ b/src/GameEngine.h @@ -139,6 +139,7 @@ public: GameEngine(const McRogueFaceConfig& cfg); ~GameEngine(); Scene* currentScene(); + Scene* getScene(const std::string& name); // #118: Get scene by name void changeScene(std::string); void changeScene(std::string sceneName, TransitionType transitionType, float duration); void createScene(std::string); diff --git a/src/PyScene.cpp b/src/PyScene.cpp index 84b92a7..ee07a3f 100644 --- a/src/PyScene.cpp +++ b/src/PyScene.cpp @@ -64,18 +64,23 @@ void PyScene::doAction(std::string name, std::string type) void PyScene::render() { + // #118: Skip rendering if scene is not visible + if (!visible) { + return; + } + game->getRenderTarget().clear(); - + // Only sort if z_index values have changed if (ui_elements_need_sort) { - std::sort(ui_elements->begin(), ui_elements->end(), + std::sort(ui_elements->begin(), ui_elements->end(), [](const std::shared_ptr& a, const std::shared_ptr& b) { return a->z_index < b->z_index; }); ui_elements_need_sort = false; } - - // Render in sorted order (no need to copy anymore) + + // Render in sorted order with scene-level transformations for (auto e: *ui_elements) { if (e) { @@ -86,9 +91,22 @@ void PyScene::render() // Count this as a draw call (each visible element = 1+ draw calls) game->metrics.drawCalls++; } - e->render(); + + // #118: Apply scene-level opacity to element + float original_opacity = e->opacity; + if (opacity < 1.0f) { + e->opacity = original_opacity * opacity; + } + + // #118: Render with scene position offset + e->render(position, game->getRenderTarget()); + + // #118: Restore original opacity + if (opacity < 1.0f) { + e->opacity = original_opacity; + } } } - + // Display is handled by GameEngine } diff --git a/src/PySceneObject.cpp b/src/PySceneObject.cpp index 43489a8..571d55a 100644 --- a/src/PySceneObject.cpp +++ b/src/PySceneObject.cpp @@ -133,10 +133,159 @@ PyObject* PySceneClass::get_active(PySceneObject* self, void* closure) if (!game) { Py_RETURN_FALSE; } - + return PyBool_FromLong(game->scene == self->name); } +// #118: Scene position getter +static PyObject* PySceneClass_get_pos(PySceneObject* self, void* closure) +{ + GameEngine* game = McRFPy_API::game; + if (!game) { + Py_RETURN_NONE; + } + + // Get the scene by name using the public accessor + auto scene = game->getScene(self->name); + if (!scene) { + Py_RETURN_NONE; + } + + // Create a Vector object + auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector"); + if (!type) return NULL; + PyObject* args = Py_BuildValue("(ff)", scene->position.x, scene->position.y); + PyObject* result = PyObject_CallObject((PyObject*)type, args); + Py_DECREF(type); + Py_DECREF(args); + return result; +} + +// #118: Scene position setter +static int PySceneClass_set_pos(PySceneObject* self, PyObject* value, void* closure) +{ + GameEngine* game = McRFPy_API::game; + if (!game) { + PyErr_SetString(PyExc_RuntimeError, "No game engine"); + return -1; + } + + auto scene = game->getScene(self->name); + if (!scene) { + PyErr_SetString(PyExc_RuntimeError, "Scene not found"); + return -1; + } + + // Accept tuple or Vector + float x, y; + if (PyTuple_Check(value) && PyTuple_Size(value) == 2) { + x = PyFloat_AsDouble(PyTuple_GetItem(value, 0)); + y = PyFloat_AsDouble(PyTuple_GetItem(value, 1)); + } else if (PyObject_HasAttrString(value, "x") && PyObject_HasAttrString(value, "y")) { + PyObject* xobj = PyObject_GetAttrString(value, "x"); + PyObject* yobj = PyObject_GetAttrString(value, "y"); + x = PyFloat_AsDouble(xobj); + y = PyFloat_AsDouble(yobj); + Py_DECREF(xobj); + Py_DECREF(yobj); + } else { + PyErr_SetString(PyExc_TypeError, "pos must be a tuple (x, y) or Vector"); + return -1; + } + + scene->position = sf::Vector2f(x, y); + return 0; +} + +// #118: Scene visible getter +static PyObject* PySceneClass_get_visible(PySceneObject* self, void* closure) +{ + GameEngine* game = McRFPy_API::game; + if (!game) { + Py_RETURN_TRUE; + } + + auto scene = game->getScene(self->name); + if (!scene) { + Py_RETURN_TRUE; + } + + return PyBool_FromLong(scene->visible); +} + +// #118: Scene visible setter +static int PySceneClass_set_visible(PySceneObject* self, PyObject* value, void* closure) +{ + GameEngine* game = McRFPy_API::game; + if (!game) { + PyErr_SetString(PyExc_RuntimeError, "No game engine"); + return -1; + } + + auto scene = game->getScene(self->name); + if (!scene) { + PyErr_SetString(PyExc_RuntimeError, "Scene not found"); + return -1; + } + + if (!PyBool_Check(value)) { + PyErr_SetString(PyExc_TypeError, "visible must be a boolean"); + return -1; + } + + scene->visible = PyObject_IsTrue(value); + return 0; +} + +// #118: Scene opacity getter +static PyObject* PySceneClass_get_opacity(PySceneObject* self, void* closure) +{ + GameEngine* game = McRFPy_API::game; + if (!game) { + return PyFloat_FromDouble(1.0); + } + + auto scene = game->getScene(self->name); + if (!scene) { + return PyFloat_FromDouble(1.0); + } + + return PyFloat_FromDouble(scene->opacity); +} + +// #118: Scene opacity setter +static int PySceneClass_set_opacity(PySceneObject* self, PyObject* value, void* closure) +{ + GameEngine* game = McRFPy_API::game; + if (!game) { + PyErr_SetString(PyExc_RuntimeError, "No game engine"); + return -1; + } + + auto scene = game->getScene(self->name); + if (!scene) { + PyErr_SetString(PyExc_RuntimeError, "Scene not found"); + return -1; + } + + double opacity; + if (PyFloat_Check(value)) { + opacity = PyFloat_AsDouble(value); + } else if (PyLong_Check(value)) { + opacity = PyLong_AsDouble(value); + } else { + PyErr_SetString(PyExc_TypeError, "opacity must be a number"); + return -1; + } + + // Clamp to valid range + if (opacity < 0.0) opacity = 0.0; + if (opacity > 1.0) opacity = 1.0; + + scene->opacity = opacity; + return 0; +} + // Lifecycle callbacks void PySceneClass::call_on_enter(PySceneObject* self) { @@ -148,8 +297,12 @@ void PySceneClass::call_on_enter(PySceneObject* self) } else { PyErr_Print(); } + Py_DECREF(method); + } else { + // Clear AttributeError if method doesn't exist + PyErr_Clear(); + Py_XDECREF(method); } - Py_XDECREF(method); } void PySceneClass::call_on_exit(PySceneObject* self) @@ -162,14 +315,18 @@ void PySceneClass::call_on_exit(PySceneObject* self) } else { PyErr_Print(); } + Py_DECREF(method); + } else { + // Clear AttributeError if method doesn't exist + PyErr_Clear(); + Py_XDECREF(method); } - Py_XDECREF(method); } void PySceneClass::call_on_keypress(PySceneObject* self, std::string key, std::string action) { PyGILState_STATE gstate = PyGILState_Ensure(); - + PyObject* method = PyObject_GetAttrString((PyObject*)self, "on_keypress"); if (method && PyCallable_Check(method)) { PyObject* result = PyObject_CallFunction(method, "ss", key.c_str(), action.c_str()); @@ -178,9 +335,13 @@ void PySceneClass::call_on_keypress(PySceneObject* self, std::string key, std::s } else { PyErr_Print(); } + Py_DECREF(method); + } else { + // Clear AttributeError if method doesn't exist + PyErr_Clear(); + Py_XDECREF(method); } - Py_XDECREF(method); - + PyGILState_Release(gstate); } @@ -194,8 +355,12 @@ void PySceneClass::call_update(PySceneObject* self, float dt) } else { PyErr_Print(); } + Py_DECREF(method); + } else { + // Clear AttributeError if method doesn't exist + PyErr_Clear(); + Py_XDECREF(method); } - Py_XDECREF(method); } void PySceneClass::call_on_resize(PySceneObject* self, int width, int height) @@ -208,8 +373,12 @@ void PySceneClass::call_on_resize(PySceneObject* self, int width, int height) } else { PyErr_Print(); } + Py_DECREF(method); + } else { + // Clear AttributeError if method doesn't exist + PyErr_Clear(); + Py_XDECREF(method); } - Py_XDECREF(method); } // Properties @@ -218,6 +387,13 @@ PyGetSetDef PySceneClass::getsetters[] = { MCRF_PROPERTY(name, "Scene name (str, read-only). Unique identifier for this scene."), NULL}, {"active", (getter)get_active, NULL, MCRF_PROPERTY(active, "Whether this scene is currently active (bool, read-only). Only one scene can be active at a time."), NULL}, + // #118: Scene-level UIDrawable-like properties + {"pos", (getter)PySceneClass_get_pos, (setter)PySceneClass_set_pos, + MCRF_PROPERTY(pos, "Scene position offset (Vector). Applied to all UI elements during rendering."), NULL}, + {"visible", (getter)PySceneClass_get_visible, (setter)PySceneClass_set_visible, + MCRF_PROPERTY(visible, "Scene visibility (bool). If False, scene is not rendered."), NULL}, + {"opacity", (getter)PySceneClass_get_opacity, (setter)PySceneClass_set_opacity, + MCRF_PROPERTY(opacity, "Scene opacity (0.0-1.0). Applied to all UI elements during rendering."), NULL}, {NULL} }; diff --git a/src/Scene.cpp b/src/Scene.cpp index 928e6d9..0b6abab 100644 --- a/src/Scene.cpp +++ b/src/Scene.cpp @@ -54,3 +54,43 @@ void Scene::key_unregister() */ key_callable.reset(); } + +// #118: Scene animation property support +bool Scene::setProperty(const std::string& name, float value) +{ + if (name == "x") { + position.x = value; + return true; + } + if (name == "y") { + position.y = value; + return true; + } + if (name == "opacity") { + opacity = std::max(0.0f, std::min(1.0f, value)); + return true; + } + if (name == "visible") { + visible = (value != 0.0f); + return true; + } + return false; +} + +bool Scene::setProperty(const std::string& name, const sf::Vector2f& value) +{ + if (name == "pos" || name == "position") { + position = value; + return true; + } + return false; +} + +float Scene::getProperty(const std::string& name) const +{ + if (name == "x") return position.x; + if (name == "y") return position.y; + if (name == "opacity") return opacity; + if (name == "visible") return visible ? 1.0f : 0.0f; + return 0.0f; +} diff --git a/src/Scene.h b/src/Scene.h index e8d322c..a87b5c7 100644 --- a/src/Scene.h +++ b/src/Scene.h @@ -35,12 +35,22 @@ public: bool hasAction(std::string); bool hasAction(int); std::string action(int); - - + + std::shared_ptr>> ui_elements; //PyObject* key_callable; std::unique_ptr key_callable; void key_register(PyObject*); void key_unregister(); + + // #118: Scene-level UIDrawable-like properties for animations/transitions + sf::Vector2f position{0.0f, 0.0f}; // Offset applied to all ui_elements + bool visible = true; // Controls rendering of scene + float opacity = 1.0f; // Applied to all ui_elements (0.0-1.0) + + // Animation support for scene properties + bool setProperty(const std::string& name, float value); + bool setProperty(const std::string& name, const sf::Vector2f& value); + float getProperty(const std::string& name) const; }; diff --git a/src/UIArc.cpp b/src/UIArc.cpp index 71031ec..fbb9339 100644 --- a/src/UIArc.cpp +++ b/src/UIArc.cpp @@ -413,6 +413,7 @@ PyGetSetDef UIArc::getsetters[] = { {"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, "Position as a Vector (same as center).", (void*)PyObjectsEnum::UIARC}, UIDRAWABLE_GETSETTERS, + UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIARC), {NULL} }; diff --git a/src/UIBase.h b/src/UIBase.h index f746168..f1711b3 100644 --- a/src/UIBase.h +++ b/src/UIBase.h @@ -162,4 +162,18 @@ static int UIDrawable_set_opacity(T* self, PyObject* value, void* closure) "Automatically clamped to valid range [0.0, 1.0]." \ ), NULL} +// #122 & #102: Macro for parent/global_position properties (requires closure with type enum) +// These need the PyObjectsEnum value in closure, so they're added separately in each class +#define UIDRAWABLE_PARENT_GETSETTERS(type_enum) \ + {"parent", (getter)UIDrawable::get_parent, NULL, \ + MCRF_PROPERTY(parent, \ + "Parent drawable (read-only). " \ + "Returns the parent Frame/Grid if nested, or None if at scene level." \ + ), (void*)type_enum}, \ + {"global_position", (getter)UIDrawable::get_global_pos, NULL, \ + MCRF_PROPERTY(global_position, \ + "Global screen position (read-only). " \ + "Calculates absolute position by walking up the parent chain." \ + ), (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 33cff43..43afa57 100644 --- a/src/UICaption.cpp +++ b/src/UICaption.cpp @@ -285,6 +285,7 @@ PyGetSetDef UICaption::getsetters[] = { ), (void*)PyObjectsEnum::UICAPTION}, {"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UICAPTION}, UIDRAWABLE_GETSETTERS, + UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UICAPTION), {NULL} }; diff --git a/src/UICircle.cpp b/src/UICircle.cpp index a8affb1..a834d97 100644 --- a/src/UICircle.cpp +++ b/src/UICircle.cpp @@ -369,6 +369,7 @@ PyGetSetDef UICircle::getsetters[] = { {"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, "Position as a Vector (same as center).", (void*)PyObjectsEnum::UICIRCLE}, UIDRAWABLE_GETSETTERS, + UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UICIRCLE), {NULL} }; diff --git a/src/UICollection.cpp b/src/UICollection.cpp index b29d22c..78eedc5 100644 --- a/src/UICollection.cpp +++ b/src/UICollection.cpp @@ -220,6 +220,8 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject // Handle deletion if (value == NULL) { + // #122: Clear the parent before removing + (*self->data)[index]->setParent(nullptr); self->data->erase(self->data->begin() + index); return 0; } @@ -255,16 +257,27 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject PyErr_SetString(PyExc_RuntimeError, "Failed to extract C++ object from Python object"); return -1; } - + + // #122: Clear parent of old element + (*vec)[index]->setParent(nullptr); + + // #122: Remove new drawable from its old parent if it has one + if (auto old_parent = new_drawable->getParent()) { + new_drawable->removeFromParent(); + } + // Preserve the z_index of the replaced element new_drawable->z_index = old_z_index; - + + // #122: Set new parent + new_drawable->setParent(self->owner.lock()); + // Replace the element (*vec)[index] = new_drawable; - + // Mark scene as needing resort after replacing element McRFPy_API::markSceneNeedsSort(); - + return 0; } @@ -638,47 +651,51 @@ PyObject* UICollection::append(PyUICollectionObject* self, PyObject* o) } } + // #122: Get the owner as parent for this drawable + std::shared_ptr owner_ptr = self->owner.lock(); + + // Helper lambda to add drawable with parent tracking + auto addDrawable = [&](std::shared_ptr drawable) { + // #122: Remove from old parent if it has one + if (auto old_parent = drawable->getParent()) { + drawable->removeFromParent(); + } + + drawable->z_index = new_z_index; + + // #122: Set new parent (owner of this collection) + drawable->setParent(owner_ptr); + + self->data->push_back(drawable); + }; + if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame"))) { - PyUIFrameObject* frame = (PyUIFrameObject*)o; - frame->data->z_index = new_z_index; - self->data->push_back(frame->data); + addDrawable(((PyUIFrameObject*)o)->data); } if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption"))) { - PyUICaptionObject* caption = (PyUICaptionObject*)o; - caption->data->z_index = new_z_index; - self->data->push_back(caption->data); + addDrawable(((PyUICaptionObject*)o)->data); } if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite"))) { - PyUISpriteObject* sprite = (PyUISpriteObject*)o; - sprite->data->z_index = new_z_index; - self->data->push_back(sprite->data); + addDrawable(((PyUISpriteObject*)o)->data); } if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid"))) { - PyUIGridObject* grid = (PyUIGridObject*)o; - grid->data->z_index = new_z_index; - self->data->push_back(grid->data); + addDrawable(((PyUIGridObject*)o)->data); } if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Line"))) { - PyUILineObject* line = (PyUILineObject*)o; - line->data->z_index = new_z_index; - self->data->push_back(line->data); + addDrawable(((PyUILineObject*)o)->data); } if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Circle"))) { - PyUICircleObject* circle = (PyUICircleObject*)o; - circle->data->z_index = new_z_index; - self->data->push_back(circle->data); + addDrawable(((PyUICircleObject*)o)->data); } if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Arc"))) { - PyUIArcObject* arc = (PyUIArcObject*)o; - arc->data->z_index = new_z_index; - self->data->push_back(arc->data); + addDrawable(((PyUIArcObject*)o)->data); } // Mark scene as needing resort after adding element @@ -734,41 +751,41 @@ PyObject* UICollection::extend(PyUICollectionObject* self, PyObject* iterable) current_z_index = INT_MAX; } + // #122: Get the owner as parent for this drawable + std::shared_ptr owner_ptr = self->owner.lock(); + + // Helper lambda to add drawable with parent tracking + auto addDrawable = [&](std::shared_ptr drawable) { + // #122: Remove from old parent if it has one + if (auto old_parent = drawable->getParent()) { + drawable->removeFromParent(); + } + drawable->z_index = current_z_index; + drawable->setParent(owner_ptr); + self->data->push_back(drawable); + }; + // Add the item based on its type if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame"))) { - PyUIFrameObject* frame = (PyUIFrameObject*)item; - frame->data->z_index = current_z_index; - self->data->push_back(frame->data); + addDrawable(((PyUIFrameObject*)item)->data); } else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption"))) { - PyUICaptionObject* caption = (PyUICaptionObject*)item; - caption->data->z_index = current_z_index; - self->data->push_back(caption->data); + addDrawable(((PyUICaptionObject*)item)->data); } else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite"))) { - PyUISpriteObject* sprite = (PyUISpriteObject*)item; - sprite->data->z_index = current_z_index; - self->data->push_back(sprite->data); + addDrawable(((PyUISpriteObject*)item)->data); } else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid"))) { - PyUIGridObject* grid = (PyUIGridObject*)item; - grid->data->z_index = current_z_index; - self->data->push_back(grid->data); + addDrawable(((PyUIGridObject*)item)->data); } else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Line"))) { - PyUILineObject* line = (PyUILineObject*)item; - line->data->z_index = current_z_index; - self->data->push_back(line->data); + addDrawable(((PyUILineObject*)item)->data); } else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Circle"))) { - PyUICircleObject* circle = (PyUICircleObject*)item; - circle->data->z_index = current_z_index; - self->data->push_back(circle->data); + addDrawable(((PyUICircleObject*)item)->data); } else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Arc"))) { - PyUIArcObject* arc = (PyUIArcObject*)item; - arc->data->z_index = current_z_index; - self->data->push_back(arc->data); + addDrawable(((PyUIArcObject*)item)->data); } Py_DECREF(item); @@ -825,6 +842,8 @@ PyObject* UICollection::remove(PyUICollectionObject* self, PyObject* o) // Search for the object and remove first occurrence for (auto it = vec->begin(); it != vec->end(); ++it) { if (it->get() == search_drawable.get()) { + // #122: Clear the parent before removing + (*it)->setParent(nullptr); vec->erase(it); McRFPy_API::markSceneNeedsSort(); Py_RETURN_NONE; @@ -868,6 +887,9 @@ PyObject* UICollection::pop(PyUICollectionObject* self, PyObject* args) // Get the element before removing std::shared_ptr drawable = (*vec)[index]; + // #122: Clear the parent before removing + drawable->setParent(nullptr); + // Remove from vector vec->erase(vec->begin() + index); @@ -929,6 +951,14 @@ PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args) index = size; } + // #122: Remove from old parent if it has one + if (auto old_parent = drawable->getParent()) { + drawable->removeFromParent(); + } + + // #122: Set new parent + drawable->setParent(self->owner.lock()); + // Insert at position vec->insert(vec->begin() + index, drawable); diff --git a/src/UIDrawable.cpp b/src/UIDrawable.cpp index 44bcfaf..448e712 100644 --- a/src/UIDrawable.cpp +++ b/src/UIDrawable.cpp @@ -685,3 +685,215 @@ int UIDrawable::set_pos(PyObject* self, PyObject* value, void* closure) { drawable->onPositionChanged(); return 0; } + +// #122 - Parent-child hierarchy implementation +void UIDrawable::setParent(std::shared_ptr new_parent) { + parent = new_parent; +} + +std::shared_ptr UIDrawable::getParent() const { + return parent.lock(); +} + +void UIDrawable::removeFromParent() { + auto p = parent.lock(); + if (!p) return; + + // Check if parent is a UIFrame (has children vector) + if (p->derived_type() == PyObjectsEnum::UIFRAME) { + auto frame = std::static_pointer_cast(p); + auto& children = *frame->children; + + // Find and remove this drawable from parent's children + // We need to find ourselves - but we don't have shared_from_this + // Instead, compare raw pointers + for (auto it = children.begin(); it != children.end(); ++it) { + if (it->get() == this) { + children.erase(it); + break; + } + } + frame->children_need_sort = true; + } + // TODO: Handle UIGrid children when needed + + parent.reset(); +} + +// #102 - Global position calculation +sf::Vector2f UIDrawable::get_global_position() const { + sf::Vector2f global_pos = position; + + auto p = parent.lock(); + while (p) { + global_pos += p->position; + p = p->parent.lock(); + } + + return global_pos; +} + +// #116 - Dirty flag propagation up parent chain +void UIDrawable::markDirty() { + if (render_dirty) return; // Already dirty, no need to propagate + + render_dirty = true; + + // Propagate to parent + auto p = parent.lock(); + if (p) { + p->markDirty(); + } +} + +// Python API - get parent drawable +PyObject* UIDrawable::get_parent(PyObject* self, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = nullptr; + + switch (objtype) { + case PyObjectsEnum::UIFRAME: + drawable = ((PyUIFrameObject*)self)->data.get(); + break; + case PyObjectsEnum::UICAPTION: + drawable = ((PyUICaptionObject*)self)->data.get(); + break; + case PyObjectsEnum::UISPRITE: + drawable = ((PyUISpriteObject*)self)->data.get(); + break; + case PyObjectsEnum::UIGRID: + drawable = ((PyUIGridObject*)self)->data.get(); + break; + case PyObjectsEnum::UILINE: + drawable = ((PyUILineObject*)self)->data.get(); + break; + case PyObjectsEnum::UICIRCLE: + drawable = ((PyUICircleObject*)self)->data.get(); + break; + case PyObjectsEnum::UIARC: + drawable = ((PyUIArcObject*)self)->data.get(); + break; + default: + PyErr_SetString(PyExc_TypeError, "Invalid UIDrawable derived instance"); + return NULL; + } + + auto parent_ptr = drawable->getParent(); + if (!parent_ptr) { + Py_RETURN_NONE; + } + + // Convert parent to Python object using the cache/conversion system + // Re-use the pattern from UICollection + PyTypeObject* type = nullptr; + PyObject* obj = nullptr; + + switch (parent_ptr->derived_type()) { + case PyObjectsEnum::UIFRAME: + { + type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame"); + if (!type) return nullptr; + auto pyObj = (PyUIFrameObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(parent_ptr); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UICAPTION: + { + type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption"); + if (!type) return nullptr; + auto pyObj = (PyUICaptionObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(parent_ptr); + pyObj->font = nullptr; + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UISPRITE: + { + type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite"); + if (!type) return nullptr; + auto pyObj = (PyUISpriteObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(parent_ptr); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UIGRID: + { + type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid"); + if (!type) return nullptr; + auto pyObj = (PyUIGridObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(parent_ptr); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + default: + Py_RETURN_NONE; + } + + if (type) { + Py_DECREF(type); + } + return obj; +} + +// Python API - get global position (read-only) +PyObject* UIDrawable::get_global_pos(PyObject* self, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = nullptr; + + switch (objtype) { + case PyObjectsEnum::UIFRAME: + drawable = ((PyUIFrameObject*)self)->data.get(); + break; + case PyObjectsEnum::UICAPTION: + drawable = ((PyUICaptionObject*)self)->data.get(); + break; + case PyObjectsEnum::UISPRITE: + drawable = ((PyUISpriteObject*)self)->data.get(); + break; + case PyObjectsEnum::UIGRID: + drawable = ((PyUIGridObject*)self)->data.get(); + break; + case PyObjectsEnum::UILINE: + drawable = ((PyUILineObject*)self)->data.get(); + break; + case PyObjectsEnum::UICIRCLE: + drawable = ((PyUICircleObject*)self)->data.get(); + break; + case PyObjectsEnum::UIARC: + drawable = ((PyUIArcObject*)self)->data.get(); + break; + default: + PyErr_SetString(PyExc_TypeError, "Invalid UIDrawable derived instance"); + return NULL; + } + + sf::Vector2f global_pos = drawable->get_global_position(); + + // Create a Python Vector object + PyObject* module = PyImport_ImportModule("mcrfpy"); + if (!module) return NULL; + + PyObject* vector_type = PyObject_GetAttrString(module, "Vector"); + Py_DECREF(module); + if (!vector_type) return NULL; + + PyObject* args = Py_BuildValue("(ff)", global_pos.x, global_pos.y); + PyObject* result = PyObject_CallObject(vector_type, args); + Py_DECREF(vector_type); + Py_DECREF(args); + + return result; +} diff --git a/src/UIDrawable.h b/src/UIDrawable.h index 12ddeee..9aea7d0 100644 --- a/src/UIDrawable.h +++ b/src/UIDrawable.h @@ -5,6 +5,7 @@ #include "IndexTexture.h" #include "Resources.h" #include +#include #include "PyCallable.h" #include "PyTexture.h" @@ -73,9 +74,28 @@ public: // Name for finding elements std::string name; - + // Position in pixel coordinates (moved from derived classes) sf::Vector2f position; + + // Parent-child hierarchy (#122) + std::weak_ptr parent; + + // Set the parent of this drawable (called by collections when adding) + void setParent(std::shared_ptr new_parent); + + // Get the parent drawable (returns nullptr if no parent or expired) + std::shared_ptr getParent() const; + + // Remove this drawable from its current parent's children + void removeFromParent(); + + // Get the global (screen) position by walking up the parent chain (#102) + sf::Vector2f get_global_position() const; + + // Python API for parent/global_position + static PyObject* get_parent(PyObject* self, void* closure); + static PyObject* get_global_pos(PyObject* self, void* closure); // New properties for Phase 1 bool visible = true; // #87 - visibility flag @@ -117,13 +137,20 @@ protected: void updateRenderTexture(); public: - // Mark this drawable as needing redraw - void markDirty() { render_dirty = true; } + // Mark this drawable as needing redraw (#116 - propagates up parent chain) + void markDirty(); + + // Check if this drawable needs redraw + bool isDirty() const { return render_dirty; } + + // Clear dirty flag (called after rendering) + void clearDirty() { render_dirty = false; } }; typedef struct { PyObject_HEAD std::shared_ptr>> data; + std::weak_ptr owner; // #122: Parent drawable (for Frame.children, Grid.children) } PyUICollectionObject; typedef struct { diff --git a/src/UIFrame.cpp b/src/UIFrame.cpp index 8eb0522..75cadd3 100644 --- a/src/UIFrame.cpp +++ b/src/UIFrame.cpp @@ -168,11 +168,13 @@ void UIFrame::render(sf::Vector2f offset, sf::RenderTarget& target) PyObject* UIFrame::get_children(PyUIFrameObject* self, void* closure) { // create PyUICollection instance pointing to self->data->children - //PyUICollectionObject* o = (PyUICollectionObject*)mcrfpydef::PyUICollectionType.tp_alloc(&mcrfpydef::PyUICollectionType, 0); auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "UICollection"); auto o = (PyUICollectionObject*)type->tp_alloc(type, 0); - if (o) + Py_DECREF(type); + if (o) { o->data = self->data->children; + o->owner = self->data; // #122: Set owner for parent tracking + } return (PyObject*)o; } @@ -412,6 +414,7 @@ PyGetSetDef UIFrame::getsetters[] = { {"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, "Position as a Vector", (void*)PyObjectsEnum::UIFRAME}, {"clip_children", (getter)UIFrame::get_clip_children, (setter)UIFrame::set_clip_children, "Whether to clip children to frame bounds", NULL}, UIDRAWABLE_GETSETTERS, + UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIFRAME), {NULL} }; diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 8c57a3c..033b542 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -1499,6 +1499,7 @@ PyGetSetDef UIGrid::getsetters[] = { ), (void*)PyObjectsEnum::UIGRID}, {"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UIGRID}, UIDRAWABLE_GETSETTERS, + UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIGRID), {NULL} /* Sentinel */ }; @@ -1519,8 +1520,10 @@ PyObject* UIGrid::get_children(PyUIGridObject* self, void* closure) // Returns UICollection for UIDrawable children (speech bubbles, effects, overlays) auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "UICollection"); auto o = (PyUICollectionObject*)type->tp_alloc(type, 0); + Py_DECREF(type); if (o) { o->data = self->data->children; + o->owner = self->data; // #122: Set owner for parent tracking } return (PyObject*)o; } diff --git a/src/UILine.cpp b/src/UILine.cpp index 6b5718b..24ed39a 100644 --- a/src/UILine.cpp +++ b/src/UILine.cpp @@ -435,6 +435,7 @@ PyGetSetDef UILine::getsetters[] = { MCRF_PROPERTY(pos, "Position as a Vector (midpoint of line)."), (void*)PyObjectsEnum::UILINE}, UIDRAWABLE_GETSETTERS, + UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UILINE), {NULL} }; diff --git a/src/UISprite.cpp b/src/UISprite.cpp index a1d697b..d46331e 100644 --- a/src/UISprite.cpp +++ b/src/UISprite.cpp @@ -352,6 +352,7 @@ PyGetSetDef UISprite::getsetters[] = { {"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UISPRITE}, {"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, "Position as a Vector", (void*)PyObjectsEnum::UISPRITE}, UIDRAWABLE_GETSETTERS, + UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UISPRITE), {NULL} }; diff --git a/tests/unit/test_parent_child_system.py b/tests/unit/test_parent_child_system.py new file mode 100644 index 0000000..c336928 --- /dev/null +++ b/tests/unit/test_parent_child_system.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +Test #122: Parent-Child UI System +Test #102: Global Position Property +Test #116: Dirty Flag System (partial - propagation) +""" + +import mcrfpy +import sys + +def test_parent_property(): + """Test that children get parent reference when added to Frame""" + print("Testing parent property...") + + # Create scene and get UI + mcrfpy.createScene("test") + ui = mcrfpy.sceneUI("test") + + # Create a parent frame + parent = mcrfpy.Frame(pos=(100, 100), size=(200, 200)) + ui.append(parent) + + # Create a child caption + child = mcrfpy.Caption(text="Child", pos=(10, 10)) + + # Before adding, parent should be None + assert child.parent is None, f"Child should have no parent before adding, got: {child.parent}" + + # Add child to parent + parent.children.append(child) + + # After adding, parent should be set + assert child.parent is not None, "Child should have parent after adding" + # The parent should be the same Frame we added to + # (checking by position since identity comparison is tricky) + assert child.parent.x == parent.x, f"Parent x mismatch: {child.parent.x} vs {parent.x}" + assert child.parent.y == parent.y, f"Parent y mismatch: {child.parent.y} vs {parent.y}" + + print(" - Parent property: PASS") + + +def test_global_position(): + """Test global position calculation through parent chain""" + print("Testing global_position property...") + + # Create scene and get UI + mcrfpy.createScene("test2") + ui = mcrfpy.sceneUI("test2") + + # Create nested hierarchy: + # root (50, 50) + # -> child1 (20, 20) -> global (70, 70) + # -> child2 (10, 10) -> global (80, 80) + + root = mcrfpy.Frame(pos=(50, 50), size=(200, 200)) + ui.append(root) + + child1 = mcrfpy.Frame(pos=(20, 20), size=(100, 100)) + root.children.append(child1) + + child2 = mcrfpy.Caption(text="Deep", pos=(10, 10)) + child1.children.append(child2) + + # Check global positions + # root has no parent, global should equal local + assert root.global_position.x == 50, f"Root global x: expected 50, got {root.global_position.x}" + assert root.global_position.y == 50, f"Root global y: expected 50, got {root.global_position.y}" + + # child1 is at (20, 20) inside root at (50, 50) -> global (70, 70) + assert child1.global_position.x == 70, f"Child1 global x: expected 70, got {child1.global_position.x}" + assert child1.global_position.y == 70, f"Child1 global y: expected 70, got {child1.global_position.y}" + + # child2 is at (10, 10) inside child1 at global (70, 70) -> global (80, 80) + assert child2.global_position.x == 80, f"Child2 global x: expected 80, got {child2.global_position.x}" + assert child2.global_position.y == 80, f"Child2 global y: expected 80, got {child2.global_position.y}" + + print(" - Global position: PASS") + + +def test_parent_changes_on_move(): + """Test that moving child to different parent updates parent reference""" + print("Testing parent changes on move...") + + mcrfpy.createScene("test3") + ui = mcrfpy.sceneUI("test3") + + parent1 = mcrfpy.Frame(pos=(0, 0), size=(100, 100), fill_color=(255, 0, 0, 255)) + parent2 = mcrfpy.Frame(pos=(200, 0), size=(100, 100), fill_color=(0, 255, 0, 255)) + ui.append(parent1) + ui.append(parent2) + + child = mcrfpy.Caption(text="Movable", pos=(5, 5)) + parent1.children.append(child) + + # Child should be in parent1 + assert child.parent is not None, "Child should have parent" + assert child.parent.x == 0, f"Child parent should be parent1, x={child.parent.x}" + + # Move child to parent2 (should auto-remove from parent1) + parent2.children.append(child) + + # Child should now be in parent2 + assert child.parent is not None, "Child should still have parent" + assert child.parent.x == 200, f"Child parent should be parent2, x={child.parent.x}" + + # parent1 should have no children + assert len(parent1.children) == 0, f"parent1 should have 0 children, has {len(parent1.children)}" + + # parent2 should have one child + assert len(parent2.children) == 1, f"parent2 should have 1 child, has {len(parent2.children)}" + + print(" - Parent changes on move: PASS") + + +def test_remove_clears_parent(): + """Test that removing child clears parent reference""" + print("Testing remove clears parent...") + + mcrfpy.createScene("test4") + ui = mcrfpy.sceneUI("test4") + + parent = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + ui.append(parent) + + child = mcrfpy.Caption(text="Removable", pos=(5, 5)) + parent.children.append(child) + + assert child.parent is not None, "Child should have parent" + + # Remove child + parent.children.remove(child) + + assert child.parent is None, f"Child should have no parent after remove, got: {child.parent}" + assert len(parent.children) == 0, f"Parent should have no children after remove" + + print(" - Remove clears parent: PASS") + + +def test_scene_level_elements(): + """Test that scene-level elements have no parent""" + print("Testing scene-level elements...") + + mcrfpy.createScene("test5") + ui = mcrfpy.sceneUI("test5") + + frame = mcrfpy.Frame(pos=(10, 10), size=(50, 50)) + ui.append(frame) + + # Scene-level elements should have no parent + assert frame.parent is None, f"Scene-level element should have no parent, got: {frame.parent}" + + # Global position should equal local position + assert frame.global_position.x == 10, f"Global x should equal local x" + assert frame.global_position.y == 10, f"Global y should equal local y" + + print(" - Scene-level elements: PASS") + + +def test_all_drawable_types(): + """Test parent/global_position on all drawable types""" + print("Testing all drawable types...") + + mcrfpy.createScene("test6") + ui = mcrfpy.sceneUI("test6") + + parent = mcrfpy.Frame(pos=(100, 100), size=(300, 300)) + ui.append(parent) + + # Test all types + types_to_test = [ + ("Frame", mcrfpy.Frame(pos=(10, 10), size=(50, 50))), + ("Caption", mcrfpy.Caption(text="Test", pos=(10, 70))), + ("Sprite", mcrfpy.Sprite(pos=(10, 130))), # May need texture + ("Grid", mcrfpy.Grid(grid_size=(5, 5), pos=(10, 190), size=(80, 80))), + ] + + for name, child in types_to_test: + parent.children.append(child) + assert child.parent is not None, f"{name} should have parent" + # Global position should be local + parent's position + expected_x = child.x + 100 + expected_y = child.y + 100 + assert child.global_position.x == expected_x, f"{name} global_x: expected {expected_x}, got {child.global_position.x}" + assert child.global_position.y == expected_y, f"{name} global_y: expected {expected_y}, got {child.global_position.y}" + + print(" - All drawable types: PASS") + + +if __name__ == "__main__": + try: + test_parent_property() + test_global_position() + test_parent_changes_on_move() + test_remove_clears_parent() + test_scene_level_elements() + test_all_drawable_types() + + print("\n=== All tests passed! ===") + sys.exit(0) + except Exception as e: + print(f"\nTEST FAILED: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tests/unit/test_scene_properties.py b/tests/unit/test_scene_properties.py new file mode 100644 index 0000000..dde21b8 --- /dev/null +++ b/tests/unit/test_scene_properties.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Test Scene properties (#118: Scene as Drawable)""" +import mcrfpy +import sys + +# Create test scenes +mcrfpy.createScene("test_scene") + +def test_scene_pos(): + """Test Scene pos property""" + print("Testing scene pos property...") + + # Create a Scene subclass to test + class TestScene(mcrfpy.Scene): + def __init__(self, name): + super().__init__(name) + + scene = TestScene("scene_pos_test") + + # Test initial position + pos = scene.pos + assert pos.x == 0.0, f"Initial pos.x should be 0.0, got {pos.x}" + assert pos.y == 0.0, f"Initial pos.y should be 0.0, got {pos.y}" + + # Test setting position with tuple + scene.pos = (100.0, 200.0) + pos = scene.pos + assert pos.x == 100.0, f"pos.x should be 100.0, got {pos.x}" + assert pos.y == 200.0, f"pos.y should be 200.0, got {pos.y}" + + # Test setting position with Vector + scene.pos = mcrfpy.Vector(50.0, 75.0) + pos = scene.pos + assert pos.x == 50.0, f"pos.x should be 50.0, got {pos.x}" + assert pos.y == 75.0, f"pos.y should be 75.0, got {pos.y}" + + print(" - Scene pos property: PASS") + +def test_scene_visible(): + """Test Scene visible property""" + print("Testing scene visible property...") + + class TestScene(mcrfpy.Scene): + def __init__(self, name): + super().__init__(name) + + scene = TestScene("scene_vis_test") + + # Test initial visibility (should be True) + assert scene.visible == True, f"Initial visible should be True, got {scene.visible}" + + # Test setting to False + scene.visible = False + assert scene.visible == False, f"visible should be False, got {scene.visible}" + + # Test setting back to True + scene.visible = True + assert scene.visible == True, f"visible should be True, got {scene.visible}" + + print(" - Scene visible property: PASS") + +def test_scene_opacity(): + """Test Scene opacity property""" + print("Testing scene opacity property...") + + class TestScene(mcrfpy.Scene): + def __init__(self, name): + super().__init__(name) + + scene = TestScene("scene_opa_test") + + # Test initial opacity (should be 1.0) + assert abs(scene.opacity - 1.0) < 0.001, f"Initial opacity should be 1.0, got {scene.opacity}" + + # Test setting opacity + scene.opacity = 0.5 + assert abs(scene.opacity - 0.5) < 0.001, f"opacity should be 0.5, got {scene.opacity}" + + # Test clamping to 0.0 + scene.opacity = -0.5 + assert scene.opacity >= 0.0, f"opacity should be clamped to >= 0.0, got {scene.opacity}" + + # Test clamping to 1.0 + scene.opacity = 1.5 + assert scene.opacity <= 1.0, f"opacity should be clamped to <= 1.0, got {scene.opacity}" + + print(" - Scene opacity property: PASS") + +def test_scene_name(): + """Test Scene name property (read-only)""" + print("Testing scene name property...") + + class TestScene(mcrfpy.Scene): + def __init__(self, name): + super().__init__(name) + + scene = TestScene("my_test_scene") + + # Test name + assert scene.name == "my_test_scene", f"name should be 'my_test_scene', got {scene.name}" + + # Name should be read-only (trying to set should raise) + try: + scene.name = "other_name" + print(" - Scene name should be read-only: FAIL") + sys.exit(1) + except AttributeError: + pass # Expected + + print(" - Scene name property: PASS") + +def test_scene_active(): + """Test Scene active property""" + print("Testing scene active property...") + + class TestScene(mcrfpy.Scene): + def __init__(self, name): + super().__init__(name) + + scene1 = TestScene("active_test_1") + scene2 = TestScene("active_test_2") + + # Activate scene1 + scene1.activate() + assert scene1.active == True, f"scene1.active should be True after activation" + assert scene2.active == False, f"scene2.active should be False" + + # Activate scene2 + scene2.activate() + assert scene1.active == False, f"scene1.active should be False after activating scene2" + assert scene2.active == True, f"scene2.active should be True" + + print(" - Scene active property: PASS") + +def test_scene_get_ui(): + """Test Scene get_ui method""" + print("Testing scene get_ui method...") + + class TestScene(mcrfpy.Scene): + def __init__(self, name): + super().__init__(name) + + scene = TestScene("ui_test_scene") + + # Get UI collection + ui = scene.get_ui() + assert ui is not None, "get_ui() should return a collection" + + # Add some elements + ui.append(mcrfpy.Frame(pos=(10, 20), size=(100, 100))) + ui.append(mcrfpy.Caption(text="Test", pos=(50, 50))) + + # Verify length + assert len(ui) == 2, f"UI should have 2 elements, got {len(ui)}" + + print(" - Scene get_ui method: PASS") + +# Run all tests +if __name__ == "__main__": + try: + test_scene_pos() + test_scene_visible() + test_scene_opacity() + test_scene_name() + test_scene_active() + test_scene_get_ui() + + print("\n=== All Scene property tests passed! ===") + sys.exit(0) + except Exception as e: + print(f"\nFAIL: {e}") + import traceback + traceback.print_exc() + sys.exit(1)