Compare commits
6 commits
b9a48a85b0
...
0969f7c2f6
| Author | SHA1 | Date | |
|---|---|---|---|
| 0969f7c2f6 | |||
| ef05152ea0 | |||
| 9e2444da69 | |||
| f766e9efa2 | |||
| d195c0e390 | |||
| 2062e4e4ad |
17 changed files with 989 additions and 56 deletions
|
|
@ -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)
|
# Playground mode - minimal scripts for web playground (REPL-focused)
|
||||||
option(MCRF_PLAYGROUND "Build with minimal playground scripts instead of full game" OFF)
|
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
|
# Emscripten builds: use SDL2 if specified, otherwise fall back to headless
|
||||||
if(EMSCRIPTEN)
|
if(EMSCRIPTEN)
|
||||||
if(MCRF_SDL2)
|
if(MCRF_SDL2)
|
||||||
|
|
@ -286,8 +289,8 @@ if(EMSCRIPTEN)
|
||||||
--preload-file=${CMAKE_SOURCE_DIR}/src/$<IF:$<BOOL:${MCRF_PLAYGROUND}>,scripts_playground,scripts>@/scripts
|
--preload-file=${CMAKE_SOURCE_DIR}/src/$<IF:$<BOOL:${MCRF_PLAYGROUND}>,scripts_playground,scripts>@/scripts
|
||||||
# Preload assets
|
# Preload assets
|
||||||
--preload-file=${CMAKE_SOURCE_DIR}/assets@/assets
|
--preload-file=${CMAKE_SOURCE_DIR}/assets@/assets
|
||||||
# Use custom HTML shell for crisp pixel rendering
|
# Use custom HTML shell - game shell (fullscreen) or playground shell (REPL)
|
||||||
--shell-file=${CMAKE_SOURCE_DIR}/src/shell.html
|
--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 to fix browser zoom causing undefined values in events
|
||||||
--pre-js=${CMAKE_SOURCE_DIR}/src/emscripten_pre.js
|
--pre-js=${CMAKE_SOURCE_DIR}/src/emscripten_pre.js
|
||||||
)
|
)
|
||||||
|
|
@ -296,17 +299,19 @@ if(EMSCRIPTEN)
|
||||||
if(MCRF_SDL2)
|
if(MCRF_SDL2)
|
||||||
list(APPEND EMSCRIPTEN_LINK_OPTIONS
|
list(APPEND EMSCRIPTEN_LINK_OPTIONS
|
||||||
-sUSE_SDL=2
|
-sUSE_SDL=2
|
||||||
|
-sUSE_SDL_MIXER=2
|
||||||
-sFULL_ES2=1
|
-sFULL_ES2=1
|
||||||
-sMIN_WEBGL_VERSION=2
|
-sMIN_WEBGL_VERSION=2
|
||||||
-sMAX_WEBGL_VERSION=2
|
-sMAX_WEBGL_VERSION=2
|
||||||
-sUSE_FREETYPE=1
|
-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
|
target_compile_options(mcrogueface PRIVATE
|
||||||
-sUSE_SDL=2
|
-sUSE_SDL=2
|
||||||
|
-sUSE_SDL_MIXER=2
|
||||||
-sUSE_FREETYPE=1
|
-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()
|
endif()
|
||||||
|
|
||||||
target_link_options(mcrogueface PRIVATE ${EMSCRIPTEN_LINK_OPTIONS})
|
target_link_options(mcrogueface PRIVATE ${EMSCRIPTEN_LINK_OPTIONS})
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,12 @@
|
||||||
#include "PyVector.h"
|
#include "PyVector.h"
|
||||||
#include "PyColor.h"
|
#include "PyColor.h"
|
||||||
#include "PythonObjectCache.h"
|
#include "PythonObjectCache.h"
|
||||||
|
#include "Animation.h"
|
||||||
|
#include "PyAnimation.h"
|
||||||
|
#include "PyEasing.h"
|
||||||
|
#include "McRFPy_API.h"
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
// Include appropriate GL headers based on backend
|
// Include appropriate GL headers based on backend
|
||||||
#if defined(MCRF_SDL2)
|
#if defined(MCRF_SDL2)
|
||||||
|
|
@ -739,6 +744,21 @@ PyObject* Entity3D::repr(PyEntity3DObject* self)
|
||||||
|
|
||||||
// Property getters/setters
|
// 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)
|
PyObject* Entity3D::get_pos(PyEntity3DObject* self, void* closure)
|
||||||
{
|
{
|
||||||
return Py_BuildValue("(ii)", self->data->grid_x_, self->data->grid_z_);
|
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) {
|
if (!vp) {
|
||||||
Py_RETURN_NONE;
|
Py_RETURN_NONE;
|
||||||
}
|
}
|
||||||
// TODO: Return actual viewport Python object
|
|
||||||
// For now, return None
|
PyTypeObject* type = &mcrfpydef::PyViewport3DType;
|
||||||
Py_RETURN_NONE;
|
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)
|
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)
|
PyObject* Entity3D::py_animate(PyEntity3DObject* self, PyObject* args, PyObject* kwds)
|
||||||
{
|
{
|
||||||
// TODO: Implement animation shorthand similar to UIEntity
|
static const char* keywords[] = {"property", "target", "duration", "easing", "delta", "callback", "conflict_mode", nullptr};
|
||||||
// For now, return None
|
|
||||||
PyErr_SetString(PyExc_NotImplementedError, "Entity3D.animate() not yet implemented");
|
const char* property_name;
|
||||||
return NULL;
|
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)
|
PyObject* Entity3D::py_follow_path(PyEntity3DObject* self, PyObject* args)
|
||||||
|
|
@ -1180,8 +1306,16 @@ PyMethodDef Entity3D::methods[] = {
|
||||||
"update_visibility()\n\n"
|
"update_visibility()\n\n"
|
||||||
"Recompute field of view from current position."},
|
"Recompute field of view from current position."},
|
||||||
{"animate", (PyCFunction)Entity3D::py_animate, METH_VARARGS | METH_KEYWORDS,
|
{"animate", (PyCFunction)Entity3D::py_animate, METH_VARARGS | METH_KEYWORDS,
|
||||||
"animate(property, target, duration, easing=None, callback=None)\n\n"
|
"animate(property, target, duration, easing=None, delta=False, callback=None, conflict_mode=None)\n\n"
|
||||||
"Animate a property over time. (Not yet implemented)"},
|
"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", (PyCFunction)Entity3D::py_follow_path, METH_VARARGS,
|
||||||
"follow_path(path)\n\n"
|
"follow_path(path)\n\n"
|
||||||
"Queue path positions for smooth movement.\n\n"
|
"Queue path positions for smooth movement.\n\n"
|
||||||
|
|
@ -1194,6 +1328,8 @@ PyMethodDef Entity3D::methods[] = {
|
||||||
};
|
};
|
||||||
|
|
||||||
PyGetSetDef Entity3D::getsetters[] = {
|
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,
|
{"pos", (getter)Entity3D::get_pos, (setter)Entity3D::set_pos,
|
||||||
"Grid position (x, z). Setting triggers smooth movement.", NULL},
|
"Grid position (x, z). Setting triggers smooth movement.", NULL},
|
||||||
{"grid_pos", (getter)Entity3D::get_grid_pos, (setter)Entity3D::set_grid_pos,
|
{"grid_pos", (getter)Entity3D::get_grid_pos, (setter)Entity3D::set_grid_pos,
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,10 @@ public:
|
||||||
bool isVisible() const { return visible_; }
|
bool isVisible() const { return visible_; }
|
||||||
void setVisible(bool v) { visible_ = v; }
|
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
|
// Color for placeholder cube rendering
|
||||||
sf::Color getColor() const { return color_; }
|
sf::Color getColor() const { return color_; }
|
||||||
void setColor(const sf::Color& c) { color_ = c; }
|
void setColor(const sf::Color& c) { color_ = c; }
|
||||||
|
|
@ -228,6 +232,8 @@ public:
|
||||||
static PyObject* repr(PyEntity3DObject* self);
|
static PyObject* repr(PyEntity3DObject* self);
|
||||||
|
|
||||||
// Property getters/setters
|
// 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 PyObject* get_pos(PyEntity3DObject* self, void* closure);
|
||||||
static int set_pos(PyEntity3DObject* self, PyObject* value, void* closure);
|
static int set_pos(PyEntity3DObject* self, PyObject* value, void* closure);
|
||||||
static PyObject* get_world_pos(PyEntity3DObject* self, 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);
|
vec3 scale_ = vec3(1.0f, 1.0f, 1.0f);
|
||||||
|
|
||||||
// Appearance
|
// Appearance
|
||||||
|
std::string name_; // For find() lookup
|
||||||
bool visible_ = true;
|
bool visible_ = true;
|
||||||
sf::Color color_ = sf::Color(200, 100, 50); // Default orange
|
sf::Color color_ = sf::Color(200, 100, 50); // Default orange
|
||||||
int sprite_index_ = 0;
|
int sprite_index_ = 0;
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,125 @@ PyObject* EntityCollection3D::clear(PyEntityCollection3DObject* self, PyObject*
|
||||||
Py_RETURN_NONE;
|
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[] = {
|
PyMethodDef EntityCollection3D::methods[] = {
|
||||||
{"append", (PyCFunction)EntityCollection3D::append, METH_O,
|
{"append", (PyCFunction)EntityCollection3D::append, METH_O,
|
||||||
"append(entity)\n\n"
|
"append(entity)\n\n"
|
||||||
|
|
@ -206,6 +325,15 @@ PyMethodDef EntityCollection3D::methods[] = {
|
||||||
{"clear", (PyCFunction)EntityCollection3D::clear, METH_NOARGS,
|
{"clear", (PyCFunction)EntityCollection3D::clear, METH_NOARGS,
|
||||||
"clear()\n\n"
|
"clear()\n\n"
|
||||||
"Remove all entities from the collection."},
|
"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
|
{NULL} // Sentinel
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ public:
|
||||||
static PyObject* append(PyEntityCollection3DObject* self, PyObject* o);
|
static PyObject* append(PyEntityCollection3DObject* self, PyObject* o);
|
||||||
static PyObject* remove(PyEntityCollection3DObject* self, PyObject* o);
|
static PyObject* remove(PyEntityCollection3DObject* self, PyObject* o);
|
||||||
static PyObject* clear(PyEntityCollection3DObject* self, PyObject* args);
|
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[];
|
static PyMethodDef methods[];
|
||||||
|
|
||||||
// Python type slots
|
// Python type slots
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,7 @@ void Viewport3D::orbitCamera(float angle, float distance, float height) {
|
||||||
camera_.setTarget(vec3(0, 0, 0));
|
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)
|
// Convert screen coordinates to normalized device coordinates (-1 to 1)
|
||||||
// screenX/Y are relative to the viewport position
|
// screenX/Y are relative to the viewport position
|
||||||
float ndcX = (2.0f * screenX / size_.x) - 1.0f;
|
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 rayDir = vec3(rayWorld4.x, rayWorld4.y, rayWorld4.z).normalized();
|
||||||
vec3 rayOrigin = camera_.getPosition();
|
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
|
// This is a simplification - for hilly terrain, you'd want ray-marching
|
||||||
if (std::abs(rayDir.y) > 0.0001f) {
|
if (std::abs(rayDir.y) > 0.0001f) {
|
||||||
float t = -rayOrigin.y / rayDir.y;
|
float t = (yPlane - rayOrigin.y) / rayDir.y;
|
||||||
if (t > 0) {
|
if (t > 0) {
|
||||||
return rayOrigin + rayDir * t;
|
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 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;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust for viewport position (user passes screen coords relative to viewport)
|
// 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)
|
// Return None if no intersection (ray parallel to ground or invalid)
|
||||||
if (worldPos.x < 0 && worldPos.y < 0 && worldPos.z < 0) {
|
if (worldPos.x < 0 && worldPos.y < 0 && worldPos.z < 0) {
|
||||||
|
|
@ -2833,13 +2833,14 @@ PyMethodDef Viewport3D_methods[] = {
|
||||||
|
|
||||||
// Camera & Input methods (Milestone 8)
|
// Camera & Input methods (Milestone 8)
|
||||||
{"screen_to_world", (PyCFunction)mcrf::Viewport3D_screen_to_world, METH_VARARGS | METH_KEYWORDS,
|
{"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"
|
"Convert screen coordinates to world position via ray casting.\n\n"
|
||||||
"Args:\n"
|
"Args:\n"
|
||||||
" x: Screen X coordinate relative to viewport\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"
|
"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", (PyCFunction)mcrf::Viewport3D_follow, METH_VARARGS | METH_KEYWORDS,
|
||||||
"follow(entity, distance=10, height=5, smoothing=1.0)\n\n"
|
"follow(entity, distance=10, height=5, smoothing=1.0)\n\n"
|
||||||
"Position camera to follow an entity.\n\n"
|
"Position camera to follow an entity.\n\n"
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,9 @@ public:
|
||||||
/// Convert screen coordinates to world position via ray casting
|
/// Convert screen coordinates to world position via ray casting
|
||||||
/// @param screenX X position relative to viewport
|
/// @param screenX X position relative to viewport
|
||||||
/// @param screenY Y 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
|
/// @param yPlane Y value of the horizontal plane to intersect (default: 0)
|
||||||
vec3 screenToWorld(float screenX, float screenY);
|
/// @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
|
/// Position camera to follow an entity
|
||||||
/// @param entity Entity to follow
|
/// @param entity Entity to follow
|
||||||
|
|
@ -402,7 +403,7 @@ extern PyMethodDef Viewport3D_methods[];
|
||||||
|
|
||||||
namespace mcrfpydef {
|
namespace mcrfpydef {
|
||||||
|
|
||||||
static PyTypeObject PyViewport3DType = {
|
inline PyTypeObject PyViewport3DType = {
|
||||||
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||||
.tp_name = "mcrfpy.Viewport3D",
|
.tp_name = "mcrfpy.Viewport3D",
|
||||||
.tp_basicsize = sizeof(PyViewport3DObject),
|
.tp_basicsize = sizeof(PyViewport3DObject),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#include "Animation.h"
|
#include "Animation.h"
|
||||||
#include "UIDrawable.h"
|
#include "UIDrawable.h"
|
||||||
#include "UIEntity.h"
|
#include "UIEntity.h"
|
||||||
|
#include "3d/Entity3D.h"
|
||||||
#include "PyAnimation.h"
|
#include "PyAnimation.h"
|
||||||
#include "McRFPy_API.h"
|
#include "McRFPy_API.h"
|
||||||
#include "GameEngine.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 {
|
bool Animation::hasValidTarget() const {
|
||||||
return !targetWeak.expired() || !entityTargetWeak.expired();
|
return !targetWeak.expired() || !entityTargetWeak.expired() || !entity3dTargetWeak.expired();
|
||||||
}
|
}
|
||||||
|
|
||||||
void Animation::clearCallback() {
|
void Animation::clearCallback() {
|
||||||
|
|
@ -198,6 +237,10 @@ void Animation::complete() {
|
||||||
AnimationValue finalValue = interpolate(1.0f);
|
AnimationValue finalValue = interpolate(1.0f);
|
||||||
applyValue(entity.get(), finalValue);
|
applyValue(entity.get(), finalValue);
|
||||||
}
|
}
|
||||||
|
else if (auto entity3d = entity3dTargetWeak.lock()) {
|
||||||
|
AnimationValue finalValue = interpolate(1.0f);
|
||||||
|
applyValue(entity3d.get(), finalValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Animation::stop() {
|
void Animation::stop() {
|
||||||
|
|
@ -215,9 +258,10 @@ bool Animation::update(float deltaTime) {
|
||||||
// Try to lock weak_ptr to get shared_ptr
|
// Try to lock weak_ptr to get shared_ptr
|
||||||
std::shared_ptr<UIDrawable> target = targetWeak.lock();
|
std::shared_ptr<UIDrawable> target = targetWeak.lock();
|
||||||
std::shared_ptr<UIEntity> entity = entityTargetWeak.lock();
|
std::shared_ptr<UIEntity> entity = entityTargetWeak.lock();
|
||||||
|
std::shared_ptr<mcrf::Entity3D> entity3d = entity3dTargetWeak.lock();
|
||||||
|
|
||||||
// If both are null, target was destroyed
|
// If all are null, target was destroyed
|
||||||
if (!target && !entity) {
|
if (!target && !entity && !entity3d) {
|
||||||
return false; // Remove this animation
|
return false; // Remove this animation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,6 +275,8 @@ bool Animation::update(float deltaTime) {
|
||||||
applyValue(target.get(), finalValue);
|
applyValue(target.get(), finalValue);
|
||||||
} else if (entity) {
|
} else if (entity) {
|
||||||
applyValue(entity.get(), finalValue);
|
applyValue(entity.get(), finalValue);
|
||||||
|
} else if (entity3d) {
|
||||||
|
applyValue(entity3d.get(), finalValue);
|
||||||
}
|
}
|
||||||
// Trigger callback
|
// Trigger callback
|
||||||
if (pythonCallback) {
|
if (pythonCallback) {
|
||||||
|
|
@ -256,6 +302,8 @@ bool Animation::update(float deltaTime) {
|
||||||
applyValue(target.get(), currentValue);
|
applyValue(target.get(), currentValue);
|
||||||
} else if (entity) {
|
} else if (entity) {
|
||||||
applyValue(entity.get(), currentValue);
|
applyValue(entity.get(), currentValue);
|
||||||
|
} else if (entity3d) {
|
||||||
|
applyValue(entity3d.get(), currentValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger callback when animation completes
|
// 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) {
|
void Animation::applyValue(UIEntity* entity, const AnimationValue& value) {
|
||||||
if (!entity) return;
|
if (!entity) return;
|
||||||
|
|
||||||
std::visit([this, entity](const auto& val) {
|
std::visit([this, entity](const auto& val) {
|
||||||
using T = std::decay_t<decltype(val)>;
|
using T = std::decay_t<decltype(val)>;
|
||||||
|
|
||||||
if constexpr (std::is_same_v<T, float>) {
|
if constexpr (std::is_same_v<T, float>) {
|
||||||
entity->setProperty(targetProperty, val);
|
entity->setProperty(targetProperty, val);
|
||||||
}
|
}
|
||||||
|
|
@ -414,6 +462,22 @@ void Animation::applyValue(UIEntity* entity, const AnimationValue& value) {
|
||||||
}, 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
|
// #229 - Helper to convert UIDrawable target to Python object
|
||||||
static PyObject* convertDrawableToPython(std::shared_ptr<UIDrawable> drawable) {
|
static PyObject* convertDrawableToPython(std::shared_ptr<UIDrawable> drawable) {
|
||||||
if (!drawable) {
|
if (!drawable) {
|
||||||
|
|
@ -560,6 +624,37 @@ static PyObject* convertEntityToPython(std::shared_ptr<UIEntity> entity) {
|
||||||
return (PyObject*)pyObj;
|
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
|
// #229 - Helper to convert AnimationValue to Python object
|
||||||
static PyObject* animationValueToPython(const AnimationValue& value) {
|
static PyObject* animationValueToPython(const AnimationValue& value) {
|
||||||
return std::visit([](const auto& val) -> PyObject* {
|
return std::visit([](const auto& val) -> PyObject* {
|
||||||
|
|
@ -609,6 +704,8 @@ void Animation::triggerCallback() {
|
||||||
targetObj = convertDrawableToPython(drawable);
|
targetObj = convertDrawableToPython(drawable);
|
||||||
} else if (auto entity = entityTargetWeak.lock()) {
|
} else if (auto entity = entityTargetWeak.lock()) {
|
||||||
targetObj = convertEntityToPython(entity);
|
targetObj = convertEntityToPython(entity);
|
||||||
|
} else if (auto entity3d = entity3dTargetWeak.lock()) {
|
||||||
|
targetObj = convertEntity3DToPython(entity3d);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If target conversion failed, use None
|
// If target conversion failed, use None
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
// Forward declarations
|
// Forward declarations
|
||||||
class UIDrawable;
|
class UIDrawable;
|
||||||
class UIEntity;
|
class UIEntity;
|
||||||
|
namespace mcrf { class Entity3D; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ConflictMode - How to handle multiple animations on the same property (#120)
|
* 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)
|
// Apply this animation to an entity (special case since Entity doesn't inherit from UIDrawable)
|
||||||
void startEntity(std::shared_ptr<UIEntity> target);
|
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)
|
// Complete the animation immediately (jump to final value)
|
||||||
void complete();
|
void complete();
|
||||||
|
|
@ -90,6 +94,7 @@ public:
|
||||||
void* getTargetPtr() const {
|
void* getTargetPtr() const {
|
||||||
if (auto sp = targetWeak.lock()) return sp.get();
|
if (auto sp = targetWeak.lock()) return sp.get();
|
||||||
if (auto sp = entityTargetWeak.lock()) return sp.get();
|
if (auto sp = entityTargetWeak.lock()) return sp.get();
|
||||||
|
if (auto sp = entity3dTargetWeak.lock()) return sp.get();
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,6 +111,7 @@ private:
|
||||||
// RAII: Use weak_ptr for safe target tracking
|
// RAII: Use weak_ptr for safe target tracking
|
||||||
std::weak_ptr<UIDrawable> targetWeak;
|
std::weak_ptr<UIDrawable> targetWeak;
|
||||||
std::weak_ptr<UIEntity> entityTargetWeak;
|
std::weak_ptr<UIEntity> entityTargetWeak;
|
||||||
|
std::weak_ptr<mcrf::Entity3D> entity3dTargetWeak;
|
||||||
|
|
||||||
// Callback support
|
// Callback support
|
||||||
PyObject* pythonCallback = nullptr; // Python callback function (we own a reference)
|
PyObject* pythonCallback = nullptr; // Python callback function (we own a reference)
|
||||||
|
|
@ -121,6 +127,7 @@ private:
|
||||||
// Helper to apply value to target
|
// Helper to apply value to target
|
||||||
void applyValue(UIDrawable* target, const AnimationValue& value);
|
void applyValue(UIDrawable* target, const AnimationValue& value);
|
||||||
void applyValue(UIEntity* entity, const AnimationValue& value);
|
void applyValue(UIEntity* entity, const AnimationValue& value);
|
||||||
|
void applyValue(mcrf::Entity3D* entity, const AnimationValue& value);
|
||||||
|
|
||||||
// Trigger callback when animation completes
|
// Trigger callback when animation completes
|
||||||
void triggerCallback();
|
void triggerCallback();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#include "UIDrawable.h"
|
#include "UIDrawable.h"
|
||||||
|
#include <iostream>
|
||||||
#include "UIFrame.h"
|
#include "UIFrame.h"
|
||||||
#include "UICaption.h"
|
#include "UICaption.h"
|
||||||
#include "UISprite.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) {
|
if (!render_texture || render_texture->getSize().x != width || render_texture->getSize().y != height) {
|
||||||
render_texture = std::make_unique<sf::RenderTexture>();
|
render_texture = std::make_unique<sf::RenderTexture>();
|
||||||
if (!render_texture->create(width, height)) {
|
if (!render_texture->create(width, height)) {
|
||||||
|
std::cerr << "[McRogueFace] Warning: Failed to create RenderTexture ("
|
||||||
|
<< width << "x" << height << ")" << std::endl;
|
||||||
render_texture.reset();
|
render_texture.reset();
|
||||||
use_render_texture = false;
|
use_render_texture = false;
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,11 @@
|
||||||
#include <emscripten/html5.h>
|
#include <emscripten/html5.h>
|
||||||
// Emscripten's USE_SDL=2 port puts headers directly in include path
|
// Emscripten's USE_SDL=2 port puts headers directly in include path
|
||||||
#include <SDL.h>
|
#include <SDL.h>
|
||||||
|
#include <SDL_mixer.h>
|
||||||
#include <GLES2/gl2.h>
|
#include <GLES2/gl2.h>
|
||||||
#else
|
#else
|
||||||
#include <SDL2/SDL.h>
|
#include <SDL2/SDL.h>
|
||||||
|
#include <SDL2/SDL_mixer.h>
|
||||||
#include <SDL2/SDL_opengl.h>
|
#include <SDL2/SDL_opengl.h>
|
||||||
// Desktop OpenGL - we'll use GL 2.1 compatible subset that matches GLES2
|
// Desktop OpenGL - we'll use GL 2.1 compatible subset that matches GLES2
|
||||||
#define GL_GLEXT_PROTOTYPES
|
#define GL_GLEXT_PROTOTYPES
|
||||||
|
|
@ -132,11 +134,22 @@ bool SDL2Renderer::init() {
|
||||||
if (initialized_) return true;
|
if (initialized_) return true;
|
||||||
|
|
||||||
// Initialize SDL2 if not already done
|
// 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;
|
std::cerr << "SDL2Renderer: Failed to initialize SDL: " << SDL_GetError() << std::endl;
|
||||||
return false;
|
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
|
// Note: Shaders are initialized in initGL() after GL context is created
|
||||||
|
|
||||||
// Set up initial projection matrix (identity)
|
// Set up initial projection matrix (identity)
|
||||||
|
|
@ -170,6 +183,12 @@ void SDL2Renderer::shutdown() {
|
||||||
|
|
||||||
shapeProgram_ = spriteProgram_ = textProgram_ = 0;
|
shapeProgram_ = spriteProgram_ = textProgram_ = 0;
|
||||||
|
|
||||||
|
// Close audio before SDL_Quit
|
||||||
|
if (audioInitialized_) {
|
||||||
|
Mix_CloseAudio();
|
||||||
|
audioInitialized_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
SDL_Quit();
|
SDL_Quit();
|
||||||
initialized_ = false;
|
initialized_ = false;
|
||||||
}
|
}
|
||||||
|
|
@ -673,6 +692,9 @@ void RenderWindow::setSize(const Vector2u& size) {
|
||||||
if (sdlWindow_) {
|
if (sdlWindow_) {
|
||||||
SDL_SetWindowSize(static_cast<SDL_Window*>(sdlWindow_), size.x, size.y);
|
SDL_SetWindowSize(static_cast<SDL_Window*>(sdlWindow_), size.x, size.y);
|
||||||
glViewport(0, 0, size.x, size.y);
|
glViewport(0, 0, size.x, size.y);
|
||||||
|
#ifdef __EMSCRIPTEN__
|
||||||
|
emscripten_set_canvas_element_size("#canvas", size.x, size.y);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ public:
|
||||||
void shutdown();
|
void shutdown();
|
||||||
bool isInitialized() const { return initialized_; }
|
bool isInitialized() const { return initialized_; }
|
||||||
bool isGLInitialized() const { return glInitialized_; }
|
bool isGLInitialized() const { return glInitialized_; }
|
||||||
|
bool isAudioInitialized() const { return audioInitialized_; }
|
||||||
|
|
||||||
// Built-in shader programs
|
// Built-in shader programs
|
||||||
enum class ShaderType {
|
enum class ShaderType {
|
||||||
|
|
@ -100,6 +101,7 @@ private:
|
||||||
|
|
||||||
bool initialized_ = false;
|
bool initialized_ = false;
|
||||||
bool glInitialized_ = false;
|
bool glInitialized_ = false;
|
||||||
|
bool audioInitialized_ = false;
|
||||||
|
|
||||||
// Built-in shader programs
|
// Built-in shader programs
|
||||||
unsigned int shapeProgram_ = 0;
|
unsigned int shapeProgram_ = 0;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
// SDL2 headers - conditionally included when actually implementing
|
// SDL2 headers - conditionally included when actually implementing
|
||||||
// For now, forward declare what we need
|
// For now, forward declare what we need
|
||||||
|
|
@ -33,6 +34,13 @@
|
||||||
#include <GLES2/gl2.h>
|
#include <GLES2/gl2.h>
|
||||||
#endif
|
#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 {
|
namespace sf {
|
||||||
|
|
||||||
// Forward declarations (needed for RenderWindow)
|
// Forward declarations (needed for RenderWindow)
|
||||||
|
|
@ -922,34 +930,195 @@ public:
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Audio Stubs (SDL2_mixer could implement these later)
|
// Audio (SDL2_mixer backed)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
class SoundBuffer {
|
class SoundBuffer {
|
||||||
|
Mix_Chunk* chunk_ = nullptr;
|
||||||
|
Time duration_;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
SoundBuffer() = default;
|
SoundBuffer() = default;
|
||||||
bool loadFromFile(const std::string& filename) { return true; } // Stub
|
~SoundBuffer() {
|
||||||
bool loadFromMemory(const void* data, size_t sizeInBytes) { return true; } // Stub
|
if (chunk_) {
|
||||||
Time getDuration() const { return Time(); }
|
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 {
|
class Sound {
|
||||||
public:
|
public:
|
||||||
enum Status { Stopped, Paused, Playing };
|
enum Status { Stopped, Paused, Playing };
|
||||||
|
|
||||||
Sound() = default;
|
Sound() = default;
|
||||||
Sound(const SoundBuffer& buffer) {}
|
Sound(const SoundBuffer& buffer) : chunk_(buffer.getChunk()) {}
|
||||||
|
|
||||||
void setBuffer(const SoundBuffer& buffer) {}
|
~Sound() {
|
||||||
void play() {}
|
// Release our channel claim
|
||||||
void pause() {}
|
if (channel_ >= 0 && channel_ < 16) {
|
||||||
void stop() {}
|
if (g_channelOwners[channel_] == this) {
|
||||||
|
Mix_HaltChannel(channel_);
|
||||||
|
g_channelOwners[channel_] = nullptr;
|
||||||
|
}
|
||||||
|
channel_ = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Status getStatus() const { return Stopped; }
|
// No copy (channel ownership)
|
||||||
void setVolume(float volume) {}
|
Sound(const Sound&) = delete;
|
||||||
float getVolume() const { return 100.0f; }
|
Sound& operator=(const Sound&) = delete;
|
||||||
void setLoop(bool loop) {}
|
|
||||||
bool getLoop() const { return false; }
|
// 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 {
|
class Music {
|
||||||
|
|
@ -957,20 +1126,83 @@ public:
|
||||||
enum Status { Stopped, Paused, Playing };
|
enum Status { Stopped, Paused, Playing };
|
||||||
|
|
||||||
Music() = default;
|
Music() = default;
|
||||||
bool openFromFile(const std::string& filename) { return true; } // Stub
|
~Music() {
|
||||||
|
if (music_) {
|
||||||
|
Mix_FreeMusic(music_);
|
||||||
|
music_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void play() {}
|
// No copy (global music channel)
|
||||||
void pause() {}
|
Music(const Music&) = delete;
|
||||||
void stop() {}
|
Music& operator=(const Music&) = delete;
|
||||||
|
|
||||||
Status getStatus() const { return Stopped; }
|
// Move
|
||||||
void setVolume(float volume) {}
|
Music(Music&& other) noexcept
|
||||||
float getVolume() const { return 100.0f; }
|
: music_(other.music_), volume_(other.volume_), loop_(other.loop_) {
|
||||||
void setLoop(bool loop) {}
|
other.music_ = nullptr;
|
||||||
bool getLoop() const { return false; }
|
}
|
||||||
|
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(); }
|
Time getDuration() const { return Time(); }
|
||||||
|
|
||||||
|
// Playing offset getter not available in Emscripten's SDL_mixer 2.0.2
|
||||||
Time getPlayingOffset() const { return Time(); }
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
94
tests/unit/test_entity3d_animate.py
Normal file
94
tests/unit/test_entity3d_animate.py
Normal 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)
|
||||||
44
tests/unit/test_entity3d_viewport.py
Normal file
44
tests/unit/test_entity3d_viewport.py
Normal 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)
|
||||||
97
tests/unit/test_entity_collection3d_methods.py
Normal file
97
tests/unit/test_entity_collection3d_methods.py
Normal 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)
|
||||||
54
tests/unit/test_screen_to_world_yplane.py
Normal file
54
tests/unit/test_screen_to_world_yplane.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue