Compare commits

..

6 commits

Author SHA1 Message Date
0969f7c2f6 Implement SDL2_mixer audio for WASM builds, closes #247
Replace no-op audio stubs in SDL2Types.h with real SDL2_mixer-backed
implementations of SoundBuffer, Sound, and Music. This enables audio
playback in the browser with zero changes to Python bindings.

- Add -sUSE_SDL_MIXER=2 to Emscripten compile/link flags (CMakeLists.txt)
- Initialize Mix_OpenAudio in SDL2Renderer::init(), Mix_CloseAudio in shutdown()
- SoundBuffer: Mix_LoadWAV/Mix_LoadWAV_RW with duration computation
- Sound: channel-based playback with Mix_ChannelFinished tracking
- Music: global channel streaming via Mix_LoadMUS/Mix_PlayMusic
- Volume conversion: SFML 0-100 scale to SDL_mixer 0-128 scale

Known limitations on web: Music.duration and Music.position getters
return 0 (SDL_mixer 2.0.2 lacks Mix_MusicDuration/Mix_GetMusicPosition).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:16:21 -05:00
ef05152ea0 Implement Entity3D.animate(), closes #242
Replaced the NotImplementedError stub with a full animation
implementation. Entity3D now supports animating: x, y, z,
world_x, world_y, world_z, rotation, rot_y, scale, scale_x,
scale_y, scale_z, sprite_index, visible.

Added Entity3D as a third target type in the Animation system
(alongside UIDrawable and UIEntity), with startEntity3D(),
applyValue(Entity3D*), and proper callback support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:16:02 -05:00
9e2444da69 Add pop/find/extend to EntityCollection3D, closes #243
EntityCollection3D now has API parity with UIEntityCollection:
- pop(index=-1): Remove and return entity at index
- find(name): Search by entity name, return Entity3D or None
- extend(iterable): Append multiple Entity3D objects

Also adds `name` property to Entity3D for use with find().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:15:55 -05:00
f766e9efa2 Add y_plane parameter to screen_to_world(), closes #245
screen_to_world() previously only intersected the Y=0 plane.
Now accepts an optional y_plane parameter (default 0.0) for
intersecting arbitrary horizontal planes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:15:48 -05:00
d195c0e390 Add warning when RenderTexture creation fails, closes #227
Previously enableRenderTexture() silently failed. Now emits a
stderr warning with the requested dimensions, consistent with
the engine's logging pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:15:43 -05:00
2062e4e4ad Fix Entity3D.viewport returning None, closes #244
The root cause was PyViewport3DType being declared `static` in
Viewport3D.h, creating per-translation-unit copies. Entity3D.cpp's
copy was never passed through PyType_Ready, causing segfaults when
tp_alloc was called.

Changed `static` to `inline` (matching PyEntity3DType and
PyModel3DType patterns), and implemented get_viewport using the
standard type->tp_alloc pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:15:38 -05:00
17 changed files with 989 additions and 56 deletions

View file

@ -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/$<IF:$<BOOL:${MCRF_PLAYGROUND}>,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/$<IF:$<BOOL:${MCRF_GAME_SHELL}>,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})

View file

@ -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 <cstdio>
#include <cstring>
// 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<char**>(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<float>(PyFloat_AsDouble(target_value));
}
else if (PyLong_Check(target_value)) {
animValue = static_cast<int>(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<Animation>(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,

View file

@ -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;

View file

@ -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<char**>(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<Py_ssize_t>(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<mcrf::Entity3D>(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<std::shared_ptr<mcrf::Entity3D>> 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<char**>(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<mcrf::Entity3D>(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
};

View file

@ -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

View file

@ -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<char**>(kwlist), &x, &y)) {
if (!PyArg_ParseTupleAndKeywords(args, kwds, "ff|f", const_cast<char**>(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"

View file

@ -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),

View file

@ -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<UIEntity> target) {
}
}
void Animation::startEntity3D(std::shared_ptr<mcrf::Entity3D> 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<decltype(val)>;
if constexpr (std::is_same_v<T, float>) {
float value = 0.0f;
if (target->getProperty(targetProperty, value)) {
startValue = value;
}
}
else if constexpr (std::is_same_v<T, int>) {
// For sprite_index/visible: capture via float and convert
float fvalue = 0.0f;
if (target->getProperty(targetProperty, fvalue)) {
startValue = static_cast<int>(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<UIDrawable> target = targetWeak.lock();
std::shared_ptr<UIEntity> entity = entityTargetWeak.lock();
std::shared_ptr<mcrf::Entity3D> 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<decltype(val)>;
if constexpr (std::is_same_v<T, float>) {
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<decltype(val)>;
if constexpr (std::is_same_v<T, float>) {
entity->setProperty(targetProperty, val);
}
else if constexpr (std::is_same_v<T, int>) {
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<UIDrawable> drawable) {
if (!drawable) {
@ -560,6 +624,37 @@ static PyObject* convertEntityToPython(std::shared_ptr<UIEntity> entity) {
return (PyObject*)pyObj;
}
// Helper to convert Entity3D target to Python object
static PyObject* convertEntity3DToPython(std::shared_ptr<mcrf::Entity3D> 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

View file

@ -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<UIEntity> target);
// Apply this animation to a 3D entity
void startEntity3D(std::shared_ptr<mcrf::Entity3D> 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<UIDrawable> targetWeak;
std::weak_ptr<UIEntity> entityTargetWeak;
std::weak_ptr<mcrf::Entity3D> 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();

View file

@ -1,4 +1,5 @@
#include "UIDrawable.h"
#include <iostream>
#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<sf::RenderTexture>();
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;

View file

@ -17,9 +17,11 @@
#include <emscripten/html5.h>
// Emscripten's USE_SDL=2 port puts headers directly in include path
#include <SDL.h>
#include <SDL_mixer.h>
#include <GLES2/gl2.h>
#else
#include <SDL2/SDL.h>
#include <SDL2/SDL_mixer.h>
#include <SDL2/SDL_opengl.h>
// 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<SDL_Window*>(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
}
}

View file

@ -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;

View file

@ -25,6 +25,7 @@
#include <vector>
#include <functional>
#include <chrono>
#include <algorithm>
// SDL2 headers - conditionally included when actually implementing
// For now, forward declare what we need
@ -33,6 +34,13 @@
#include <GLES2/gl2.h>
#endif
// SDL2_mixer for audio (always needed in SDL2 builds for SoundBuffer/Sound/Music types)
#ifdef __EMSCRIPTEN__
#include <SDL_mixer.h>
#else
#include <SDL2/SDL_mixer.h>
#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<int>(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<float>(totalSamples) / static_cast<float>(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<int>(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<int>(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<int>(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<int>(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<double>(offset.asSeconds()));
}
private:
Mix_Music* music_ = nullptr;
float volume_ = 100.f;
bool loop_ = false;
};
// =============================================================================

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)