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)
|
||||
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})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
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