diff --git a/CMakeLists.txt b/CMakeLists.txt index 40e4ff0..70a8d4d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,6 +17,9 @@ option(MCRF_SDL2 "Build with SDL2+OpenGL ES 2 backend instead of SFML" OFF) # Playground mode - minimal scripts for web playground (REPL-focused) option(MCRF_PLAYGROUND "Build with minimal playground scripts instead of full game" OFF) +# Game shell mode - fullscreen canvas, no REPL chrome (for itch.io / standalone web games) +option(MCRF_GAME_SHELL "Use minimal game-only HTML shell (no REPL)" OFF) + # Emscripten builds: use SDL2 if specified, otherwise fall back to headless if(EMSCRIPTEN) if(MCRF_SDL2) @@ -286,8 +289,8 @@ if(EMSCRIPTEN) --preload-file=${CMAKE_SOURCE_DIR}/src/$,scripts_playground,scripts>@/scripts # Preload assets --preload-file=${CMAKE_SOURCE_DIR}/assets@/assets - # Use custom HTML shell for crisp pixel rendering - --shell-file=${CMAKE_SOURCE_DIR}/src/shell.html + # Use custom HTML shell - game shell (fullscreen) or playground shell (REPL) + --shell-file=${CMAKE_SOURCE_DIR}/src/$,shell_game.html,shell.html> # Pre-JS to fix browser zoom causing undefined values in events --pre-js=${CMAKE_SOURCE_DIR}/src/emscripten_pre.js ) @@ -296,17 +299,19 @@ if(EMSCRIPTEN) if(MCRF_SDL2) list(APPEND EMSCRIPTEN_LINK_OPTIONS -sUSE_SDL=2 + -sUSE_SDL_MIXER=2 -sFULL_ES2=1 -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2 -sUSE_FREETYPE=1 ) - # SDL2 and FreeType flags are also needed at compile time for headers + # SDL2, SDL2_mixer, and FreeType flags are also needed at compile time for headers target_compile_options(mcrogueface PRIVATE -sUSE_SDL=2 + -sUSE_SDL_MIXER=2 -sUSE_FREETYPE=1 ) - message(STATUS "Emscripten SDL2 options enabled: -sUSE_SDL=2 -sFULL_ES2=1 -sUSE_FREETYPE=1") + message(STATUS "Emscripten SDL2 options enabled: -sUSE_SDL=2 -sUSE_SDL_MIXER=2 -sFULL_ES2=1 -sUSE_FREETYPE=1") endif() target_link_options(mcrogueface PRIVATE ${EMSCRIPTEN_LINK_OPTIONS}) diff --git a/src/3d/Entity3D.cpp b/src/3d/Entity3D.cpp index fe15720..903dc58 100644 --- a/src/3d/Entity3D.cpp +++ b/src/3d/Entity3D.cpp @@ -8,7 +8,12 @@ #include "PyVector.h" #include "PyColor.h" #include "PythonObjectCache.h" +#include "Animation.h" +#include "PyAnimation.h" +#include "PyEasing.h" +#include "McRFPy_API.h" #include +#include // Include appropriate GL headers based on backend #if defined(MCRF_SDL2) @@ -739,6 +744,21 @@ PyObject* Entity3D::repr(PyEntity3DObject* self) // Property getters/setters +PyObject* Entity3D::get_name(PyEntity3DObject* self, void* closure) +{ + return PyUnicode_FromString(self->data->getName().c_str()); +} + +int Entity3D::set_name(PyEntity3DObject* self, PyObject* value, void* closure) +{ + if (!PyUnicode_Check(value)) { + PyErr_SetString(PyExc_TypeError, "name must be a string"); + return -1; + } + self->data->setName(PyUnicode_AsUTF8(value)); + return 0; +} + PyObject* Entity3D::get_pos(PyEntity3DObject* self, void* closure) { return Py_BuildValue("(ii)", self->data->grid_x_, self->data->grid_z_); @@ -841,9 +861,15 @@ PyObject* Entity3D::get_viewport(PyEntity3DObject* self, void* closure) if (!vp) { Py_RETURN_NONE; } - // TODO: Return actual viewport Python object - // For now, return None - Py_RETURN_NONE; + + PyTypeObject* type = &mcrfpydef::PyViewport3DType; + PyViewport3DObject* obj = (PyViewport3DObject*)type->tp_alloc(type, 0); + if (!obj) return NULL; + + obj->data = vp; + obj->weakreflist = nullptr; + + return (PyObject*)obj; } PyObject* Entity3D::get_model(PyEntity3DObject* self, void* closure) @@ -1115,10 +1141,110 @@ PyObject* Entity3D::py_update_visibility(PyEntity3DObject* self, PyObject* args) PyObject* Entity3D::py_animate(PyEntity3DObject* self, PyObject* args, PyObject* kwds) { - // TODO: Implement animation shorthand similar to UIEntity - // For now, return None - PyErr_SetString(PyExc_NotImplementedError, "Entity3D.animate() not yet implemented"); - return NULL; + static const char* keywords[] = {"property", "target", "duration", "easing", "delta", "callback", "conflict_mode", nullptr}; + + const char* property_name; + PyObject* target_value; + float duration; + PyObject* easing_arg = Py_None; + int delta = 0; + PyObject* callback = nullptr; + const char* conflict_mode_str = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|OpOs", const_cast(keywords), + &property_name, &target_value, &duration, + &easing_arg, &delta, &callback, &conflict_mode_str)) { + return NULL; + } + + // Validate property exists on this entity + if (!self->data->hasProperty(property_name)) { + PyErr_Format(PyExc_ValueError, + "Property '%s' is not valid for animation on Entity3D. " + "Valid properties: x, y, z, world_x, world_y, world_z, rotation, rot_y, " + "scale, scale_x, scale_y, scale_z, sprite_index, visible", + property_name); + return NULL; + } + + // Validate callback is callable if provided + if (callback && callback != Py_None && !PyCallable_Check(callback)) { + PyErr_SetString(PyExc_TypeError, "callback must be callable"); + return NULL; + } + + // Convert None to nullptr for C++ + if (callback == Py_None) { + callback = nullptr; + } + + // Convert Python target value to AnimationValue + // Entity3D only supports float and int properties + AnimationValue animValue; + + if (PyFloat_Check(target_value)) { + animValue = static_cast(PyFloat_AsDouble(target_value)); + } + else if (PyLong_Check(target_value)) { + animValue = static_cast(PyLong_AsLong(target_value)); + } + else { + PyErr_SetString(PyExc_TypeError, "Entity3D animations only support float or int target values"); + return NULL; + } + + // Get easing function from argument + EasingFunction easingFunc; + if (!PyEasing::from_arg(easing_arg, &easingFunc, nullptr)) { + return NULL; // Error already set by from_arg + } + + // Parse conflict mode + AnimationConflictMode conflict_mode = AnimationConflictMode::REPLACE; + if (conflict_mode_str) { + if (strcmp(conflict_mode_str, "replace") == 0) { + conflict_mode = AnimationConflictMode::REPLACE; + } else if (strcmp(conflict_mode_str, "queue") == 0) { + conflict_mode = AnimationConflictMode::QUEUE; + } else if (strcmp(conflict_mode_str, "error") == 0) { + conflict_mode = AnimationConflictMode::RAISE_ERROR; + } else { + PyErr_Format(PyExc_ValueError, + "Invalid conflict_mode '%s'. Must be 'replace', 'queue', or 'error'.", conflict_mode_str); + return NULL; + } + } + + // Create the Animation + auto animation = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, callback); + + // Start on this entity (uses startEntity3D) + animation->startEntity3D(self->data); + + // Add to AnimationManager + AnimationManager::getInstance().addAnimation(animation, conflict_mode); + + // Check if ERROR mode raised an exception + if (PyErr_Occurred()) { + return NULL; + } + + // Create and return a PyAnimation wrapper + PyTypeObject* animType = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Animation"); + if (!animType) { + PyErr_SetString(PyExc_RuntimeError, "Could not find Animation type"); + return NULL; + } + + PyAnimationObject* pyAnim = (PyAnimationObject*)animType->tp_alloc(animType, 0); + Py_DECREF(animType); + + if (!pyAnim) { + return NULL; + } + + pyAnim->data = animation; + return (PyObject*)pyAnim; } PyObject* Entity3D::py_follow_path(PyEntity3DObject* self, PyObject* args) @@ -1180,8 +1306,16 @@ PyMethodDef Entity3D::methods[] = { "update_visibility()\n\n" "Recompute field of view from current position."}, {"animate", (PyCFunction)Entity3D::py_animate, METH_VARARGS | METH_KEYWORDS, - "animate(property, target, duration, easing=None, callback=None)\n\n" - "Animate a property over time. (Not yet implemented)"}, + "animate(property, target, duration, easing=None, delta=False, callback=None, conflict_mode=None)\n\n" + "Animate a property over time.\n\n" + "Args:\n" + " property: Property name (x, y, z, rotation, scale, etc.)\n" + " target: Target value (float or int)\n" + " duration: Animation duration in seconds\n" + " easing: Easing function (Easing enum or None for linear)\n" + " delta: If True, target is relative to current value\n" + " callback: Called with (target, property, value) when complete\n" + " conflict_mode: 'replace', 'queue', or 'error'"}, {"follow_path", (PyCFunction)Entity3D::py_follow_path, METH_VARARGS, "follow_path(path)\n\n" "Queue path positions for smooth movement.\n\n" @@ -1194,6 +1328,8 @@ PyMethodDef Entity3D::methods[] = { }; PyGetSetDef Entity3D::getsetters[] = { + {"name", (getter)Entity3D::get_name, (setter)Entity3D::set_name, + "Entity name (str). Used for find() lookup.", NULL}, {"pos", (getter)Entity3D::get_pos, (setter)Entity3D::set_pos, "Grid position (x, z). Setting triggers smooth movement.", NULL}, {"grid_pos", (getter)Entity3D::get_grid_pos, (setter)Entity3D::set_grid_pos, diff --git a/src/3d/Entity3D.h b/src/3d/Entity3D.h index ce1d69a..f089e2c 100644 --- a/src/3d/Entity3D.h +++ b/src/3d/Entity3D.h @@ -88,6 +88,10 @@ public: bool isVisible() const { return visible_; } void setVisible(bool v) { visible_ = v; } + // Name (for find() lookup) + const std::string& getName() const { return name_; } + void setName(const std::string& n) { name_ = n; } + // Color for placeholder cube rendering sf::Color getColor() const { return color_; } void setColor(const sf::Color& c) { color_ = c; } @@ -228,6 +232,8 @@ public: static PyObject* repr(PyEntity3DObject* self); // Property getters/setters + static PyObject* get_name(PyEntity3DObject* self, void* closure); + static int set_name(PyEntity3DObject* self, PyObject* value, void* closure); static PyObject* get_pos(PyEntity3DObject* self, void* closure); static int set_pos(PyEntity3DObject* self, PyObject* value, void* closure); static PyObject* get_world_pos(PyEntity3DObject* self, void* closure); @@ -297,6 +303,7 @@ private: vec3 scale_ = vec3(1.0f, 1.0f, 1.0f); // Appearance + std::string name_; // For find() lookup bool visible_ = true; sf::Color color_ = sf::Color(200, 100, 50); // Default orange int sprite_index_ = 0; diff --git a/src/3d/EntityCollection3D.cpp b/src/3d/EntityCollection3D.cpp index 08eef9d..f38ad04 100644 --- a/src/3d/EntityCollection3D.cpp +++ b/src/3d/EntityCollection3D.cpp @@ -196,6 +196,125 @@ PyObject* EntityCollection3D::clear(PyEntityCollection3DObject* self, PyObject* Py_RETURN_NONE; } +PyObject* EntityCollection3D::pop(PyEntityCollection3DObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"index", NULL}; + Py_ssize_t index = -1; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|n", const_cast(kwlist), &index)) { + return NULL; + } + + if (!self->data || self->data->empty()) { + PyErr_SetString(PyExc_IndexError, "pop from empty EntityCollection3D"); + return NULL; + } + + Py_ssize_t size = static_cast(self->data->size()); + if (index < 0) index += size; + if (index < 0 || index >= size) { + PyErr_SetString(PyExc_IndexError, "EntityCollection3D pop index out of range"); + return NULL; + } + + // Iterate to the index + auto it = self->data->begin(); + std::advance(it, index); + + auto entity = *it; + + // Clear viewport reference before removing + entity->setViewport(nullptr); + self->data->erase(it); + + // Create Python wrapper for the removed entity + auto type = &mcrfpydef::PyEntity3DType; + auto obj = (PyEntity3DObject*)type->tp_alloc(type, 0); + if (!obj) return NULL; + + new (&obj->data) std::shared_ptr(entity); + obj->weakreflist = nullptr; + + return (PyObject*)obj; +} + +PyObject* EntityCollection3D::extend(PyEntityCollection3DObject* self, PyObject* o) +{ + if (!self->data || !self->viewport) { + PyErr_SetString(PyExc_RuntimeError, "Collection has no data"); + return NULL; + } + + PyObject* iterator = PyObject_GetIter(o); + if (!iterator) { + return NULL; + } + + // First pass: validate all items are Entity3D + std::vector> to_add; + PyObject* item; + while ((item = PyIter_Next(iterator)) != NULL) { + if (!PyObject_IsInstance(item, (PyObject*)&mcrfpydef::PyEntity3DType)) { + Py_DECREF(item); + Py_DECREF(iterator); + PyErr_SetString(PyExc_TypeError, "extend() requires an iterable of Entity3D objects"); + return NULL; + } + auto entity_obj = (PyEntity3DObject*)item; + if (!entity_obj->data) { + Py_DECREF(item); + Py_DECREF(iterator); + PyErr_SetString(PyExc_ValueError, "Entity3D has no data"); + return NULL; + } + to_add.push_back(entity_obj->data); + Py_DECREF(item); + } + Py_DECREF(iterator); + + if (PyErr_Occurred()) { + return NULL; + } + + // Second pass: append all validated entities + for (auto& entity : to_add) { + self->data->push_back(entity); + entity->setViewport(self->viewport); + } + + Py_RETURN_NONE; +} + +PyObject* EntityCollection3D::find(PyEntityCollection3DObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"name", NULL}; + const char* name = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast(kwlist), &name)) { + return NULL; + } + + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Collection has no data"); + return NULL; + } + + for (const auto& entity : *self->data) { + if (entity->getName() == name) { + auto type = &mcrfpydef::PyEntity3DType; + auto obj = (PyEntity3DObject*)type->tp_alloc(type, 0); + if (!obj) return NULL; + + new (&obj->data) std::shared_ptr(entity); + obj->weakreflist = nullptr; + + return (PyObject*)obj; + } + } + + Py_RETURN_NONE; +} + PyMethodDef EntityCollection3D::methods[] = { {"append", (PyCFunction)EntityCollection3D::append, METH_O, "append(entity)\n\n" @@ -206,6 +325,15 @@ PyMethodDef EntityCollection3D::methods[] = { {"clear", (PyCFunction)EntityCollection3D::clear, METH_NOARGS, "clear()\n\n" "Remove all entities from the collection."}, + {"pop", (PyCFunction)EntityCollection3D::pop, METH_VARARGS | METH_KEYWORDS, + "pop(index=-1) -> Entity3D\n\n" + "Remove and return Entity3D at index (default: last)."}, + {"extend", (PyCFunction)EntityCollection3D::extend, METH_O, + "extend(iterable)\n\n" + "Add all Entity3D objects from iterable to the collection."}, + {"find", (PyCFunction)EntityCollection3D::find, METH_VARARGS | METH_KEYWORDS, + "find(name) -> Entity3D or None\n\n" + "Find an Entity3D by name. Returns None if not found."}, {NULL} // Sentinel }; diff --git a/src/3d/EntityCollection3D.h b/src/3d/EntityCollection3D.h index f236de2..b4d415a 100644 --- a/src/3d/EntityCollection3D.h +++ b/src/3d/EntityCollection3D.h @@ -43,6 +43,9 @@ public: static PyObject* append(PyEntityCollection3DObject* self, PyObject* o); static PyObject* remove(PyEntityCollection3DObject* self, PyObject* o); static PyObject* clear(PyEntityCollection3DObject* self, PyObject* args); + static PyObject* pop(PyEntityCollection3DObject* self, PyObject* args, PyObject* kwds); + static PyObject* extend(PyEntityCollection3DObject* self, PyObject* o); + static PyObject* find(PyEntityCollection3DObject* self, PyObject* args, PyObject* kwds); static PyMethodDef methods[]; // Python type slots diff --git a/src/3d/Viewport3D.cpp b/src/3d/Viewport3D.cpp index 5800705..481111b 100644 --- a/src/3d/Viewport3D.cpp +++ b/src/3d/Viewport3D.cpp @@ -196,7 +196,7 @@ void Viewport3D::orbitCamera(float angle, float distance, float height) { camera_.setTarget(vec3(0, 0, 0)); } -vec3 Viewport3D::screenToWorld(float screenX, float screenY) { +vec3 Viewport3D::screenToWorld(float screenX, float screenY, float yPlane) { // Convert screen coordinates to normalized device coordinates (-1 to 1) // screenX/Y are relative to the viewport position float ndcX = (2.0f * screenX / size_.x) - 1.0f; @@ -217,10 +217,10 @@ vec3 Viewport3D::screenToWorld(float screenX, float screenY) { vec3 rayDir = vec3(rayWorld4.x, rayWorld4.y, rayWorld4.z).normalized(); vec3 rayOrigin = camera_.getPosition(); - // Intersect with Y=0 plane (ground level) + // Intersect with Y=yPlane horizontal plane // This is a simplification - for hilly terrain, you'd want ray-marching if (std::abs(rayDir.y) > 0.0001f) { - float t = -rayOrigin.y / rayDir.y; + float t = (yPlane - rayOrigin.y) / rayDir.y; if (t > 0) { return rayOrigin + rayDir * t; } @@ -2453,16 +2453,16 @@ static PyObject* Viewport3D_billboard_count(PyViewport3DObject* self, PyObject* // ============================================================================= static PyObject* Viewport3D_screen_to_world(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"x", "y", NULL}; + static const char* kwlist[] = {"x", "y", "y_plane", NULL}; - float x = 0.0f, y = 0.0f; + float x = 0.0f, y = 0.0f, y_plane = 0.0f; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "ff", const_cast(kwlist), &x, &y)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "ff|f", const_cast(kwlist), &x, &y, &y_plane)) { return NULL; } // Adjust for viewport position (user passes screen coords relative to viewport) - vec3 worldPos = self->data->screenToWorld(x, y); + vec3 worldPos = self->data->screenToWorld(x, y, y_plane); // Return None if no intersection (ray parallel to ground or invalid) if (worldPos.x < 0 && worldPos.y < 0 && worldPos.z < 0) { @@ -2833,13 +2833,14 @@ PyMethodDef Viewport3D_methods[] = { // Camera & Input methods (Milestone 8) {"screen_to_world", (PyCFunction)mcrf::Viewport3D_screen_to_world, METH_VARARGS | METH_KEYWORDS, - "screen_to_world(x, y) -> tuple or None\n\n" + "screen_to_world(x, y, y_plane=0.0) -> tuple or None\n\n" "Convert screen coordinates to world position via ray casting.\n\n" "Args:\n" " x: Screen X coordinate relative to viewport\n" - " y: Screen Y coordinate relative to viewport\n\n" + " y: Screen Y coordinate relative to viewport\n" + " y_plane: Y value of horizontal plane to intersect (default: 0.0)\n\n" "Returns:\n" - " (x, y, z) world position tuple, or None if no intersection with ground plane"}, + " (x, y, z) world position tuple, or None if no intersection with the plane"}, {"follow", (PyCFunction)mcrf::Viewport3D_follow, METH_VARARGS | METH_KEYWORDS, "follow(entity, distance=10, height=5, smoothing=1.0)\n\n" "Position camera to follow an entity.\n\n" diff --git a/src/3d/Viewport3D.h b/src/3d/Viewport3D.h index c38901f..63b1677 100644 --- a/src/3d/Viewport3D.h +++ b/src/3d/Viewport3D.h @@ -89,8 +89,9 @@ public: /// Convert screen coordinates to world position via ray casting /// @param screenX X position relative to viewport /// @param screenY Y position relative to viewport - /// @return World position on Y=0 plane, or (-1,-1,-1) if no intersection - vec3 screenToWorld(float screenX, float screenY); + /// @param yPlane Y value of the horizontal plane to intersect (default: 0) + /// @return World position on the given Y plane, or (-1,-1,-1) if no intersection + vec3 screenToWorld(float screenX, float screenY, float yPlane = 0.0f); /// Position camera to follow an entity /// @param entity Entity to follow @@ -402,7 +403,7 @@ extern PyMethodDef Viewport3D_methods[]; namespace mcrfpydef { -static PyTypeObject PyViewport3DType = { +inline PyTypeObject PyViewport3DType = { .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, .tp_name = "mcrfpy.Viewport3D", .tp_basicsize = sizeof(PyViewport3DObject), diff --git a/src/Animation.cpp b/src/Animation.cpp index b59c96e..e369556 100644 --- a/src/Animation.cpp +++ b/src/Animation.cpp @@ -1,6 +1,7 @@ #include "Animation.h" #include "UIDrawable.h" #include "UIEntity.h" +#include "3d/Entity3D.h" #include "PyAnimation.h" #include "McRFPy_API.h" #include "GameEngine.h" @@ -168,8 +169,46 @@ void Animation::startEntity(std::shared_ptr target) { } } +void Animation::startEntity3D(std::shared_ptr target) { + if (!target) return; + + entity3dTargetWeak = target; + elapsed = 0.0f; + callbackTriggered = false; + + // Capture the starting value from the entity + std::visit([this, target](const auto& val) { + using T = std::decay_t; + + if constexpr (std::is_same_v) { + float value = 0.0f; + if (target->getProperty(targetProperty, value)) { + startValue = value; + } + } + else if constexpr (std::is_same_v) { + // For sprite_index/visible: capture via float and convert + float fvalue = 0.0f; + if (target->getProperty(targetProperty, fvalue)) { + startValue = static_cast(fvalue); + } + } + // Entity3D doesn't support other types + }, targetValue); + + // For zero-duration animations, apply final value immediately + if (duration <= 0.0f) { + AnimationValue finalValue = interpolate(1.0f); + applyValue(target.get(), finalValue); + if (pythonCallback && !callbackTriggered) { + triggerCallback(); + } + callbackTriggered = true; + } +} + bool Animation::hasValidTarget() const { - return !targetWeak.expired() || !entityTargetWeak.expired(); + return !targetWeak.expired() || !entityTargetWeak.expired() || !entity3dTargetWeak.expired(); } void Animation::clearCallback() { @@ -198,6 +237,10 @@ void Animation::complete() { AnimationValue finalValue = interpolate(1.0f); applyValue(entity.get(), finalValue); } + else if (auto entity3d = entity3dTargetWeak.lock()) { + AnimationValue finalValue = interpolate(1.0f); + applyValue(entity3d.get(), finalValue); + } } void Animation::stop() { @@ -215,9 +258,10 @@ bool Animation::update(float deltaTime) { // Try to lock weak_ptr to get shared_ptr std::shared_ptr target = targetWeak.lock(); std::shared_ptr entity = entityTargetWeak.lock(); + std::shared_ptr entity3d = entity3dTargetWeak.lock(); - // If both are null, target was destroyed - if (!target && !entity) { + // If all are null, target was destroyed + if (!target && !entity && !entity3d) { return false; // Remove this animation } @@ -231,6 +275,8 @@ bool Animation::update(float deltaTime) { applyValue(target.get(), finalValue); } else if (entity) { applyValue(entity.get(), finalValue); + } else if (entity3d) { + applyValue(entity3d.get(), finalValue); } // Trigger callback if (pythonCallback) { @@ -256,6 +302,8 @@ bool Animation::update(float deltaTime) { applyValue(target.get(), currentValue); } else if (entity) { applyValue(entity.get(), currentValue); + } else if (entity3d) { + applyValue(entity3d.get(), currentValue); } // Trigger callback when animation completes @@ -400,10 +448,10 @@ void Animation::applyValue(UIDrawable* target, const AnimationValue& value) { void Animation::applyValue(UIEntity* entity, const AnimationValue& value) { if (!entity) return; - + std::visit([this, entity](const auto& val) { using T = std::decay_t; - + if constexpr (std::is_same_v) { entity->setProperty(targetProperty, val); } @@ -414,6 +462,22 @@ void Animation::applyValue(UIEntity* entity, const AnimationValue& value) { }, value); } +void Animation::applyValue(mcrf::Entity3D* entity, const AnimationValue& value) { + if (!entity) return; + + std::visit([this, entity](const auto& val) { + using T = std::decay_t; + + if constexpr (std::is_same_v) { + entity->setProperty(targetProperty, val); + } + else if constexpr (std::is_same_v) { + entity->setProperty(targetProperty, val); + } + // Entity3D doesn't support other types + }, value); +} + // #229 - Helper to convert UIDrawable target to Python object static PyObject* convertDrawableToPython(std::shared_ptr drawable) { if (!drawable) { @@ -560,6 +624,37 @@ static PyObject* convertEntityToPython(std::shared_ptr entity) { return (PyObject*)pyObj; } +// Helper to convert Entity3D target to Python object +static PyObject* convertEntity3DToPython(std::shared_ptr entity) { + if (!entity) { + Py_RETURN_NONE; + } + + // Use the entity's cached Python self pointer if available + if (entity->self) { + Py_INCREF(entity->self); + return entity->self; + } + + // Create a new wrapper + PyTypeObject* type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity3D"); + if (!type) { + Py_RETURN_NONE; + } + + auto pyObj = (PyEntity3DObject*)type->tp_alloc(type, 0); + Py_DECREF(type); + + if (!pyObj) { + Py_RETURN_NONE; + } + + pyObj->data = entity; + pyObj->weakreflist = NULL; + + return (PyObject*)pyObj; +} + // #229 - Helper to convert AnimationValue to Python object static PyObject* animationValueToPython(const AnimationValue& value) { return std::visit([](const auto& val) -> PyObject* { @@ -609,6 +704,8 @@ void Animation::triggerCallback() { targetObj = convertDrawableToPython(drawable); } else if (auto entity = entityTargetWeak.lock()) { targetObj = convertEntityToPython(entity); + } else if (auto entity3d = entity3dTargetWeak.lock()) { + targetObj = convertEntity3DToPython(entity3d); } // If target conversion failed, use None diff --git a/src/Animation.h b/src/Animation.h index 40b057f..3fc4e0e 100644 --- a/src/Animation.h +++ b/src/Animation.h @@ -11,6 +11,7 @@ // Forward declarations class UIDrawable; class UIEntity; +namespace mcrf { class Entity3D; } /** * ConflictMode - How to handle multiple animations on the same property (#120) @@ -58,6 +59,9 @@ public: // Apply this animation to an entity (special case since Entity doesn't inherit from UIDrawable) void startEntity(std::shared_ptr target); + + // Apply this animation to a 3D entity + void startEntity3D(std::shared_ptr target); // Complete the animation immediately (jump to final value) void complete(); @@ -90,6 +94,7 @@ public: void* getTargetPtr() const { if (auto sp = targetWeak.lock()) return sp.get(); if (auto sp = entityTargetWeak.lock()) return sp.get(); + if (auto sp = entity3dTargetWeak.lock()) return sp.get(); return nullptr; } @@ -106,6 +111,7 @@ private: // RAII: Use weak_ptr for safe target tracking std::weak_ptr targetWeak; std::weak_ptr entityTargetWeak; + std::weak_ptr entity3dTargetWeak; // Callback support PyObject* pythonCallback = nullptr; // Python callback function (we own a reference) @@ -121,6 +127,7 @@ private: // Helper to apply value to target void applyValue(UIDrawable* target, const AnimationValue& value); void applyValue(UIEntity* entity, const AnimationValue& value); + void applyValue(mcrf::Entity3D* entity, const AnimationValue& value); // Trigger callback when animation completes void triggerCallback(); diff --git a/src/UIDrawable.cpp b/src/UIDrawable.cpp index 996e1bf..346364a 100644 --- a/src/UIDrawable.cpp +++ b/src/UIDrawable.cpp @@ -1,4 +1,5 @@ #include "UIDrawable.h" +#include #include "UIFrame.h" #include "UICaption.h" #include "UISprite.h" @@ -428,6 +429,8 @@ void UIDrawable::enableRenderTexture(unsigned int width, unsigned int height) { if (!render_texture || render_texture->getSize().x != width || render_texture->getSize().y != height) { render_texture = std::make_unique(); if (!render_texture->create(width, height)) { + std::cerr << "[McRogueFace] Warning: Failed to create RenderTexture (" + << width << "x" << height << ")" << std::endl; render_texture.reset(); use_render_texture = false; return; diff --git a/src/platform/SDL2Renderer.cpp b/src/platform/SDL2Renderer.cpp index d73e175..057ea6c 100644 --- a/src/platform/SDL2Renderer.cpp +++ b/src/platform/SDL2Renderer.cpp @@ -17,9 +17,11 @@ #include // Emscripten's USE_SDL=2 port puts headers directly in include path #include +#include #include #else #include +#include #include // Desktop OpenGL - we'll use GL 2.1 compatible subset that matches GLES2 #define GL_GLEXT_PROTOTYPES @@ -132,11 +134,22 @@ bool SDL2Renderer::init() { if (initialized_) return true; // Initialize SDL2 if not already done - if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS) < 0) { + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS | SDL_INIT_AUDIO) < 0) { std::cerr << "SDL2Renderer: Failed to initialize SDL: " << SDL_GetError() << std::endl; return false; } + // Initialize SDL2_mixer for audio (non-fatal if it fails) + if (Mix_OpenAudio(44100, MIX_DEFAULT_FORMAT, 2, 2048) < 0) { + std::cerr << "SDL2Renderer: Failed to initialize audio: " << Mix_GetError() << std::endl; + std::cerr << "SDL2Renderer: Continuing without audio support" << std::endl; + } else { + Mix_AllocateChannels(16); + Mix_ChannelFinished(Sound::onChannelFinished); + audioInitialized_ = true; + std::cout << "SDL2Renderer: Audio initialized (16 channels, 44100 Hz)" << std::endl; + } + // Note: Shaders are initialized in initGL() after GL context is created // Set up initial projection matrix (identity) @@ -170,6 +183,12 @@ void SDL2Renderer::shutdown() { shapeProgram_ = spriteProgram_ = textProgram_ = 0; + // Close audio before SDL_Quit + if (audioInitialized_) { + Mix_CloseAudio(); + audioInitialized_ = false; + } + SDL_Quit(); initialized_ = false; } @@ -673,6 +692,9 @@ void RenderWindow::setSize(const Vector2u& size) { if (sdlWindow_) { SDL_SetWindowSize(static_cast(sdlWindow_), size.x, size.y); glViewport(0, 0, size.x, size.y); +#ifdef __EMSCRIPTEN__ + emscripten_set_canvas_element_size("#canvas", size.x, size.y); +#endif } } diff --git a/src/platform/SDL2Renderer.h b/src/platform/SDL2Renderer.h index f18656a..71690bd 100644 --- a/src/platform/SDL2Renderer.h +++ b/src/platform/SDL2Renderer.h @@ -44,6 +44,7 @@ public: void shutdown(); bool isInitialized() const { return initialized_; } bool isGLInitialized() const { return glInitialized_; } + bool isAudioInitialized() const { return audioInitialized_; } // Built-in shader programs enum class ShaderType { @@ -100,6 +101,7 @@ private: bool initialized_ = false; bool glInitialized_ = false; + bool audioInitialized_ = false; // Built-in shader programs unsigned int shapeProgram_ = 0; diff --git a/src/platform/SDL2Types.h b/src/platform/SDL2Types.h index d5f9bdf..ef8fbec 100644 --- a/src/platform/SDL2Types.h +++ b/src/platform/SDL2Types.h @@ -25,6 +25,7 @@ #include #include #include +#include // SDL2 headers - conditionally included when actually implementing // For now, forward declare what we need @@ -33,6 +34,13 @@ #include #endif +// SDL2_mixer for audio (always needed in SDL2 builds for SoundBuffer/Sound/Music types) +#ifdef __EMSCRIPTEN__ +#include +#else +#include +#endif + namespace sf { // Forward declarations (needed for RenderWindow) @@ -922,34 +930,195 @@ public: }; // ============================================================================= -// Audio Stubs (SDL2_mixer could implement these later) +// Audio (SDL2_mixer backed) // ============================================================================= class SoundBuffer { + Mix_Chunk* chunk_ = nullptr; + Time duration_; + public: SoundBuffer() = default; - bool loadFromFile(const std::string& filename) { return true; } // Stub - bool loadFromMemory(const void* data, size_t sizeInBytes) { return true; } // Stub - Time getDuration() const { return Time(); } + ~SoundBuffer() { + if (chunk_) { + Mix_FreeChunk(chunk_); + chunk_ = nullptr; + } + } + + // No copy (Mix_Chunk ownership) + SoundBuffer(const SoundBuffer&) = delete; + SoundBuffer& operator=(const SoundBuffer&) = delete; + + // Move + SoundBuffer(SoundBuffer&& other) noexcept + : chunk_(other.chunk_), duration_(other.duration_) { + other.chunk_ = nullptr; + } + SoundBuffer& operator=(SoundBuffer&& other) noexcept { + if (this != &other) { + if (chunk_) Mix_FreeChunk(chunk_); + chunk_ = other.chunk_; + duration_ = other.duration_; + other.chunk_ = nullptr; + } + return *this; + } + + bool loadFromFile(const std::string& filename) { + if (chunk_) { Mix_FreeChunk(chunk_); chunk_ = nullptr; } + chunk_ = Mix_LoadWAV(filename.c_str()); + if (!chunk_) return false; + computeDuration(); + return true; + } + + bool loadFromMemory(const void* data, size_t sizeInBytes) { + if (chunk_) { Mix_FreeChunk(chunk_); chunk_ = nullptr; } + SDL_RWops* rw = SDL_RWFromConstMem(data, static_cast(sizeInBytes)); + if (!rw) return false; + chunk_ = Mix_LoadWAV_RW(rw, 1); // 1 = free RWops after load + if (!chunk_) return false; + computeDuration(); + return true; + } + + Time getDuration() const { return duration_; } + Mix_Chunk* getChunk() const { return chunk_; } + +private: + void computeDuration() { + if (!chunk_) { duration_ = Time(); return; } + int freq = 0, channels = 0; + Uint16 format = 0; + Mix_QuerySpec(&freq, &format, &channels); + if (freq == 0 || channels == 0) { duration_ = Time(); return; } + // Compute bytes per sample based on format + int bytesPerSample = 2; // Default 16-bit + if (format == AUDIO_U8 || format == AUDIO_S8) bytesPerSample = 1; + else if (format == AUDIO_S32LSB || format == AUDIO_S32MSB) bytesPerSample = 4; + else if (format == AUDIO_F32LSB || format == AUDIO_F32MSB) bytesPerSample = 4; + int totalSamples = chunk_->alen / (bytesPerSample * channels); + float secs = static_cast(totalSamples) / static_cast(freq); + duration_ = seconds(secs); + } }; +// Forward declare Sound for channel tracking +class Sound; + +// Channel tracking: maps SDL_mixer channel indices to Sound* owners +// Defined as inline to keep header-only and avoid multiple definition issues +inline Sound* g_channelOwners[16] = {}; + class Sound { public: enum Status { Stopped, Paused, Playing }; Sound() = default; - Sound(const SoundBuffer& buffer) {} + Sound(const SoundBuffer& buffer) : chunk_(buffer.getChunk()) {} - void setBuffer(const SoundBuffer& buffer) {} - void play() {} - void pause() {} - void stop() {} + ~Sound() { + // Release our channel claim + if (channel_ >= 0 && channel_ < 16) { + if (g_channelOwners[channel_] == this) { + Mix_HaltChannel(channel_); + g_channelOwners[channel_] = nullptr; + } + channel_ = -1; + } + } - Status getStatus() const { return Stopped; } - void setVolume(float volume) {} - float getVolume() const { return 100.0f; } - void setLoop(bool loop) {} - bool getLoop() const { return false; } + // No copy (channel ownership) + Sound(const Sound&) = delete; + Sound& operator=(const Sound&) = delete; + + // Move + Sound(Sound&& other) noexcept + : chunk_(other.chunk_), channel_(other.channel_), + volume_(other.volume_), loop_(other.loop_) { + if (channel_ >= 0 && channel_ < 16) { + g_channelOwners[channel_] = this; + } + other.channel_ = -1; + other.chunk_ = nullptr; + } + Sound& operator=(Sound&& other) noexcept { + if (this != &other) { + stop(); + chunk_ = other.chunk_; + channel_ = other.channel_; + volume_ = other.volume_; + loop_ = other.loop_; + if (channel_ >= 0 && channel_ < 16) { + g_channelOwners[channel_] = this; + } + other.channel_ = -1; + other.chunk_ = nullptr; + } + return *this; + } + + void setBuffer(const SoundBuffer& buffer) { chunk_ = buffer.getChunk(); } + + void play() { + if (!chunk_) return; + channel_ = Mix_PlayChannel(-1, chunk_, loop_ ? -1 : 0); + if (channel_ >= 0 && channel_ < 16) { + // Clear any previous owner on this channel + if (g_channelOwners[channel_] && g_channelOwners[channel_] != this) { + g_channelOwners[channel_]->channel_ = -1; + } + g_channelOwners[channel_] = this; + Mix_Volume(channel_, static_cast(volume_ * 128.f / 100.f)); + } + } + + void pause() { + if (channel_ >= 0) Mix_Pause(channel_); + } + + void stop() { + if (channel_ >= 0) { + Mix_HaltChannel(channel_); + if (channel_ < 16 && g_channelOwners[channel_] == this) { + g_channelOwners[channel_] = nullptr; + } + channel_ = -1; + } + } + + Status getStatus() const { + if (channel_ < 0) return Stopped; + if (Mix_Paused(channel_)) return Paused; + if (Mix_Playing(channel_)) return Playing; + return Stopped; + } + + void setVolume(float vol) { + volume_ = std::clamp(vol, 0.f, 100.f); + if (channel_ >= 0) { + Mix_Volume(channel_, static_cast(volume_ * 128.f / 100.f)); + } + } + float getVolume() const { return volume_; } + + void setLoop(bool loop) { loop_ = loop; } + bool getLoop() const { return loop_; } + + // Called by Mix_ChannelFinished callback + static void onChannelFinished(int channel) { + if (channel >= 0 && channel < 16 && g_channelOwners[channel]) { + g_channelOwners[channel]->channel_ = -1; + g_channelOwners[channel] = nullptr; + } + } + +private: + Mix_Chunk* chunk_ = nullptr; // Borrowed from SoundBuffer + int channel_ = -1; + float volume_ = 100.f; + bool loop_ = false; }; class Music { @@ -957,20 +1126,83 @@ public: enum Status { Stopped, Paused, Playing }; Music() = default; - bool openFromFile(const std::string& filename) { return true; } // Stub + ~Music() { + if (music_) { + Mix_FreeMusic(music_); + music_ = nullptr; + } + } - void play() {} - void pause() {} - void stop() {} + // No copy (global music channel) + Music(const Music&) = delete; + Music& operator=(const Music&) = delete; - Status getStatus() const { return Stopped; } - void setVolume(float volume) {} - float getVolume() const { return 100.0f; } - void setLoop(bool loop) {} - bool getLoop() const { return false; } + // Move + Music(Music&& other) noexcept + : music_(other.music_), volume_(other.volume_), loop_(other.loop_) { + other.music_ = nullptr; + } + Music& operator=(Music&& other) noexcept { + if (this != &other) { + if (music_) Mix_FreeMusic(music_); + music_ = other.music_; + volume_ = other.volume_; + loop_ = other.loop_; + other.music_ = nullptr; + } + return *this; + } + + bool openFromFile(const std::string& filename) { + if (music_) { Mix_FreeMusic(music_); music_ = nullptr; } + music_ = Mix_LoadMUS(filename.c_str()); + return music_ != nullptr; + } + + void play() { + if (!music_) return; + Mix_PlayMusic(music_, loop_ ? -1 : 0); + Mix_VolumeMusic(static_cast(volume_ * 128.f / 100.f)); + } + + void pause() { + Mix_PauseMusic(); + } + + void stop() { + Mix_HaltMusic(); + } + + Status getStatus() const { + if (Mix_PausedMusic()) return Paused; + if (Mix_PlayingMusic()) return Playing; + return Stopped; + } + + void setVolume(float vol) { + volume_ = std::clamp(vol, 0.f, 100.f); + Mix_VolumeMusic(static_cast(volume_ * 128.f / 100.f)); + } + float getVolume() const { return volume_; } + + void setLoop(bool loop) { loop_ = loop; } + bool getLoop() const { return loop_; } + + // Duration not available in Emscripten's SDL_mixer 2.0.2 Time getDuration() const { return Time(); } + + // Playing offset getter not available in Emscripten's SDL_mixer 2.0.2 Time getPlayingOffset() const { return Time(); } - void setPlayingOffset(Time offset) {} + + // Setter works for OGG via Mix_SetMusicPosition + void setPlayingOffset(Time offset) { + if (music_) Mix_SetMusicPosition(static_cast(offset.asSeconds())); + } + +private: + Mix_Music* music_ = nullptr; + float volume_ = 100.f; + bool loop_ = false; }; // ============================================================================= diff --git a/tests/unit/test_entity3d_animate.py b/tests/unit/test_entity3d_animate.py new file mode 100644 index 0000000..df23945 --- /dev/null +++ b/tests/unit/test_entity3d_animate.py @@ -0,0 +1,94 @@ +"""Test Entity3D.animate() (issue #242)""" +import mcrfpy +import sys + +errors = [] + +vp = mcrfpy.Viewport3D(pos=(0,0), size=(100,100)) +vp.set_grid_size(16, 16) + +# Test 1: Basic x animation +e = mcrfpy.Entity3D(pos=(0,0), scale=1.0) +vp.entities.append(e) +start_x = e.world_pos[0] +anim = e.animate('x', 10.0, 1.0, 'linear') +if anim is None: + errors.append("animate() should return Animation object") +for _ in range(5): + mcrfpy.step(0.25) +if abs(e.world_pos[0] - 10.0) > 0.5: + errors.append(f"x animation: expected ~10.0, got {e.world_pos[0]}") + +# Test 2: Scale animation +e2 = mcrfpy.Entity3D(pos=(0,0), scale=1.0) +vp.entities.append(e2) +e2.animate('scale', 5.0, 0.5, 'linear') +for _ in range(3): + mcrfpy.step(0.25) +if abs(e2.scale - 5.0) > 0.5: + errors.append(f"scale animation: expected ~5.0, got {e2.scale}") + +# Test 3: Rotation animation +e3 = mcrfpy.Entity3D(pos=(0,0), scale=1.0) +vp.entities.append(e3) +e3.animate('rotation', 90.0, 0.5, 'easeInOut') +for _ in range(3): + mcrfpy.step(0.25) +if abs(e3.rotation - 90.0) > 0.5: + errors.append(f"rotation animation: expected ~90.0, got {e3.rotation}") + +# Test 4: Delta animation +e4 = mcrfpy.Entity3D(pos=(3,3), scale=1.0) +vp.entities.append(e4) +start_x = e4.world_pos[0] +e4.animate('x', 2.0, 0.5, 'linear', delta=True) +for _ in range(3): + mcrfpy.step(0.25) +expected = start_x + 2.0 +if abs(e4.world_pos[0] - expected) > 0.5: + errors.append(f"delta animation: expected ~{expected}, got {e4.world_pos[0]}") + +# Test 5: Invalid property raises ValueError +try: + e.animate('nonexistent', 1.0, 1.0, 'linear') + errors.append("Invalid property should raise ValueError") +except ValueError: + pass + +# Test 6: Invalid target type raises TypeError +try: + e.animate('x', "not_a_number", 1.0, 'linear') + errors.append("String target should raise TypeError") +except TypeError: + pass + +# Test 7: Callback +callback_called = [False] +def on_complete(target, prop, value): + callback_called[0] = True + +e5 = mcrfpy.Entity3D(pos=(0,0), scale=1.0) +vp.entities.append(e5) +e5.animate('x', 5.0, 0.25, 'linear', callback=on_complete) +for _ in range(3): + mcrfpy.step(0.15) +if not callback_called[0]: + errors.append("Animation callback was not called") + +# Test 8: Easing enum +e6 = mcrfpy.Entity3D(pos=(0,0), scale=1.0) +vp.entities.append(e6) +try: + e6.animate('x', 5.0, 1.0, mcrfpy.Easing.EASE_IN_OUT) +except Exception as ex: + errors.append(f"Easing enum should work: {ex}") +for _ in range(5): + mcrfpy.step(0.25) + +if errors: + for err in errors: + print(f"FAIL: {err}", file=sys.stderr) + sys.exit(1) +else: + print("PASS: Entity3D.animate() (issue #242)", file=sys.stderr) + sys.exit(0) diff --git a/tests/unit/test_entity3d_viewport.py b/tests/unit/test_entity3d_viewport.py new file mode 100644 index 0000000..4369b04 --- /dev/null +++ b/tests/unit/test_entity3d_viewport.py @@ -0,0 +1,44 @@ +"""Test Entity3D.viewport property (issue #244)""" +import mcrfpy +import sys + +errors = [] + +# Test 1: Detached entity returns None +e = mcrfpy.Entity3D(pos=(0,0), scale=1.0) +if e.viewport is not None: + errors.append("Detached entity viewport should be None") + +# Test 2: Attached entity returns Viewport3D +vp = mcrfpy.Viewport3D(pos=(0,0), size=(100,100)) +vp.set_grid_size(16, 16) +e2 = mcrfpy.Entity3D(pos=(5,5), scale=1.0) +vp.entities.append(e2) +v = e2.viewport +if v is None: + errors.append("Attached entity viewport should not be None") +elif type(v).__name__ != 'Viewport3D': + errors.append(f"Expected Viewport3D, got {type(v).__name__}") + +# Test 3: Viewport properties are accessible +if v is not None: + try: + _ = v.w + _ = v.h + except Exception as ex: + errors.append(f"Viewport properties not accessible: {ex}") + +# Test 4: Multiple entities share same viewport +e3 = mcrfpy.Entity3D(pos=(3,3), scale=1.0) +vp.entities.append(e3) +v2 = e3.viewport +if v2 is None: + errors.append("Second entity viewport should not be None") + +if errors: + for err in errors: + print(f"FAIL: {err}", file=sys.stderr) + sys.exit(1) +else: + print("PASS: Entity3D.viewport (issue #244)", file=sys.stderr) + sys.exit(0) diff --git a/tests/unit/test_entity_collection3d_methods.py b/tests/unit/test_entity_collection3d_methods.py new file mode 100644 index 0000000..b9d6d90 --- /dev/null +++ b/tests/unit/test_entity_collection3d_methods.py @@ -0,0 +1,97 @@ +"""Test EntityCollection3D pop/find/extend (issue #243)""" +import mcrfpy +import sys + +errors = [] + +vp = mcrfpy.Viewport3D(pos=(0,0), size=(100,100)) +vp.set_grid_size(16, 16) + +# Setup: add 5 named entities +for i in range(5): + e = mcrfpy.Entity3D(pos=(i, i), scale=1.0) + e.name = f"entity_{i}" + vp.entities.append(e) + +# Test find - existing +found = vp.entities.find("entity_2") +if found is None: + errors.append("find('entity_2') returned None") +elif found.name != "entity_2": + errors.append(f"find returned wrong entity: {found.name}") + +# Test find - missing +missing = vp.entities.find("nonexistent") +if missing is not None: + errors.append("find('nonexistent') should return None") + +# Test pop() - default (last element) +count_before = len(vp.entities) +popped = vp.entities.pop() +if popped.name != "entity_4": + errors.append(f"pop() should return last, got {popped.name}") +if len(vp.entities) != count_before - 1: + errors.append(f"Length should decrease after pop") + +# Test pop(0) - first element +popped0 = vp.entities.pop(0) +if popped0.name != "entity_0": + errors.append(f"pop(0) should return first, got {popped0.name}") + +# Test pop(1) - middle element +popped1 = vp.entities.pop(1) +if popped1.name != "entity_2": + errors.append(f"pop(1) should return index 1, got {popped1.name}") + +# Current state: [entity_1, entity_3] +if len(vp.entities) != 2: + errors.append(f"Expected 2 remaining, got {len(vp.entities)}") + +# Test pop - out of range +try: + vp.entities.pop(99) + errors.append("pop(99) should raise IndexError") +except IndexError: + pass + +# Test extend +new_entities = [] +for i in range(3): + e = mcrfpy.Entity3D(pos=(10+i, 10+i), scale=1.0) + e.name = f"new_{i}" + new_entities.append(e) +vp.entities.extend(new_entities) +if len(vp.entities) != 5: + errors.append(f"After extend, expected 5, got {len(vp.entities)}") + +# Verify extended entities are findable +if vp.entities.find("new_1") is None: + errors.append("Extended entity not findable") + +# Test extend with invalid type +try: + vp.entities.extend([42]) + errors.append("extend with non-Entity3D should raise TypeError") +except TypeError: + pass + +# Test extend with non-iterable +try: + vp.entities.extend(42) + errors.append("extend with non-iterable should raise TypeError") +except TypeError: + pass + +# Test name property +e_named = mcrfpy.Entity3D(pos=(0,0), scale=1.0) +e_named.name = "test_name" +if e_named.name != "test_name": + errors.append(f"name property: expected 'test_name', got '{e_named.name}'") + +if errors: + for err in errors: + print(f"FAIL: {err}", file=sys.stderr) + sys.exit(1) +else: + print("PASS: EntityCollection3D pop/find/extend (issue #243)", file=sys.stderr) + sys.exit(0) diff --git a/tests/unit/test_screen_to_world_yplane.py b/tests/unit/test_screen_to_world_yplane.py new file mode 100644 index 0000000..98d8ca9 --- /dev/null +++ b/tests/unit/test_screen_to_world_yplane.py @@ -0,0 +1,54 @@ +"""Test screen_to_world y_plane parameter (issue #245)""" +import mcrfpy +import sys + +errors = [] + +vp = mcrfpy.Viewport3D(pos=(0,0), size=(320,240)) +vp.set_grid_size(16, 16) +# Position camera above the Y=0 plane +vp.camera_pos = (0, 5, 5) +vp.camera_target = (0, 0, 0) + +# Test 1: Default y_plane=0 works +r1 = vp.screen_to_world(160, 120) +if r1 is None: + errors.append("screen_to_world with default y_plane returned None") + +# Test 2: Explicit y_plane=0 matches default +r2 = vp.screen_to_world(160, 120, y_plane=0.0) +if r2 is None: + errors.append("screen_to_world with y_plane=0 returned None") +elif r1 is not None: + for i in range(3): + if abs(r1[i] - r2[i]) > 0.001: + errors.append(f"Default and explicit y_plane=0 differ: {r1} vs {r2}") + break + +# Test 3: Different y_plane gives different result +r3 = vp.screen_to_world(160, 120, y_plane=2.0) +if r3 is None: + errors.append("screen_to_world with y_plane=2 returned None") +elif r1 is not None: + if r1 == r3: + errors.append("y_plane=0 and y_plane=2 should give different results") + +# Test 4: y component matches y_plane +if r3 is not None: + if abs(r3[1] - 2.0) > 0.001: + errors.append(f"y component should be 2.0 for y_plane=2.0, got {r3[1]}") + +# Test 5: Positional argument also works +r4 = vp.screen_to_world(160, 120, 3.0) +if r4 is None: + errors.append("Positional y_plane argument returned None") +elif abs(r4[1] - 3.0) > 0.001: + errors.append(f"y component should be 3.0, got {r4[1]}") + +if errors: + for err in errors: + print(f"FAIL: {err}", file=sys.stderr) + sys.exit(1) +else: + print("PASS: screen_to_world y_plane (issue #245)", file=sys.stderr) + sys.exit(0)