diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index be70245..e5aec91 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -9,6 +9,10 @@ #include "PyWindow.h" #include "PySceneObject.h" #include "PyFOV.h" +#include "PySound.h" +#include "PyMusic.h" +#include "PyKeyboard.h" +#include "McRogueFaceVersion.h" #include "GameEngine.h" #include "ImGuiConsole.h" #include "BenchmarkLogger.h" @@ -23,10 +27,6 @@ #include #include -std::vector* McRFPy_API::soundbuffers = nullptr; -sf::Music* McRFPy_API::music = nullptr; -sf::Sound* McRFPy_API::sfx = nullptr; - std::shared_ptr McRFPy_API::default_font; std::shared_ptr McRFPy_API::default_texture; PyObject* McRFPy_API::mcrf_module; @@ -102,62 +102,6 @@ static PyTypeObject McRFPyModuleType = { static PyMethodDef mcrfpyMethods[] = { - {"createSoundBuffer", McRFPy_API::_createSoundBuffer, METH_VARARGS, - MCRF_FUNCTION(createSoundBuffer, - MCRF_SIG("(filename: str)", "int"), - MCRF_DESC("Load a sound effect from a file and return its buffer ID."), - MCRF_ARGS_START - MCRF_ARG("filename", "Path to the sound file (WAV, OGG, FLAC)") - MCRF_RETURNS("int: Buffer ID for use with playSound()") - MCRF_RAISES("RuntimeError", "If the file cannot be loaded") - )}, - {"loadMusic", McRFPy_API::_loadMusic, METH_VARARGS, - MCRF_FUNCTION(loadMusic, - MCRF_SIG("(filename: str)", "None"), - MCRF_DESC("Load and immediately play background music from a file."), - MCRF_ARGS_START - MCRF_ARG("filename", "Path to the music file (WAV, OGG, FLAC)") - MCRF_RETURNS("None") - MCRF_NOTE("Only one music track can play at a time. Loading new music stops the current track.") - )}, - {"setMusicVolume", McRFPy_API::_setMusicVolume, METH_VARARGS, - MCRF_FUNCTION(setMusicVolume, - MCRF_SIG("(volume: int)", "None"), - MCRF_DESC("Set the global music volume."), - MCRF_ARGS_START - MCRF_ARG("volume", "Volume level from 0 (silent) to 100 (full volume)") - MCRF_RETURNS("None") - )}, - {"setSoundVolume", McRFPy_API::_setSoundVolume, METH_VARARGS, - MCRF_FUNCTION(setSoundVolume, - MCRF_SIG("(volume: int)", "None"), - MCRF_DESC("Set the global sound effects volume."), - MCRF_ARGS_START - MCRF_ARG("volume", "Volume level from 0 (silent) to 100 (full volume)") - MCRF_RETURNS("None") - )}, - {"playSound", McRFPy_API::_playSound, METH_VARARGS, - MCRF_FUNCTION(playSound, - MCRF_SIG("(buffer_id: int)", "None"), - MCRF_DESC("Play a sound effect using a previously loaded buffer."), - MCRF_ARGS_START - MCRF_ARG("buffer_id", "Sound buffer ID returned by createSoundBuffer()") - MCRF_RETURNS("None") - MCRF_RAISES("RuntimeError", "If the buffer ID is invalid") - )}, - {"getMusicVolume", McRFPy_API::_getMusicVolume, METH_NOARGS, - MCRF_FUNCTION(getMusicVolume, - MCRF_SIG("()", "int"), - MCRF_DESC("Get the current music volume level."), - MCRF_RETURNS("int: Current volume (0-100)") - )}, - {"getSoundVolume", McRFPy_API::_getSoundVolume, METH_NOARGS, - MCRF_FUNCTION(getSoundVolume, - MCRF_SIG("()", "int"), - MCRF_DESC("Get the current sound effects volume level."), - MCRF_RETURNS("int: Current volume (0-100)") - )}, - {"sceneUI", McRFPy_API::_sceneUI, METH_VARARGS, MCRF_FUNCTION(sceneUI, MCRF_SIG("(scene: str = None)", "list"), @@ -412,6 +356,13 @@ PyObject* PyInit_mcrfpy() /*scene class*/ &PySceneType, + /*audio (#66)*/ + &PySoundType, + &PyMusicType, + + /*keyboard state (#160)*/ + &PyKeyboardType, + nullptr}; // Set up PyWindowType methods and getsetters before PyType_Ready @@ -454,7 +405,16 @@ PyObject* PyInit_mcrfpy() // These will be set later when the window is created PyModule_AddObject(m, "default_font", Py_None); PyModule_AddObject(m, "default_texture", Py_None); - + + // Add keyboard singleton (#160) + PyObject* keyboard_instance = PyObject_CallObject((PyObject*)&PyKeyboardType, NULL); + if (keyboard_instance) { + PyModule_AddObject(m, "keyboard", keyboard_instance); + } + + // Add version string (#164) + PyModule_AddStringConstant(m, "__version__", MCRFPY_VERSION); + // Add FOV enum class (uses Python's IntEnum) (#114) PyObject* fov_class = PyFOV::create_enum_class(m); if (!fov_class) { @@ -821,23 +781,7 @@ void McRFPy_API::executeScript(std::string filename) void McRFPy_API::api_shutdown() { - // Clean up audio resources in correct order - if (sfx) { - sfx->stop(); - delete sfx; - sfx = nullptr; - } - if (music) { - music->stop(); - delete music; - music = nullptr; - } - if (soundbuffers) { - soundbuffers->clear(); - delete soundbuffers; - soundbuffers = nullptr; - } - + // Audio cleanup now handled by Python garbage collection (Sound/Music classes) Py_Finalize(); } @@ -869,86 +813,7 @@ PyObject* McRFPy_API::_refreshFov(PyObject* self, PyObject* args) { } */ -PyObject* McRFPy_API::_createSoundBuffer(PyObject* self, PyObject* args) { - const char *fn_cstr; - if (!PyArg_ParseTuple(args, "s", &fn_cstr)) return NULL; - // Initialize soundbuffers if needed - if (!McRFPy_API::soundbuffers) { - McRFPy_API::soundbuffers = new std::vector(); - } - auto b = sf::SoundBuffer(); - b.loadFromFile(fn_cstr); - McRFPy_API::soundbuffers->push_back(b); - Py_INCREF(Py_None); - return Py_None; -} - -PyObject* McRFPy_API::_loadMusic(PyObject* self, PyObject* args) { - const char *fn_cstr; - PyObject* loop_obj = Py_False; - if (!PyArg_ParseTuple(args, "s|O", &fn_cstr, &loop_obj)) return NULL; - // Initialize music if needed - if (!McRFPy_API::music) { - McRFPy_API::music = new sf::Music(); - } - McRFPy_API::music->stop(); - McRFPy_API::music->openFromFile(fn_cstr); - McRFPy_API::music->setLoop(PyObject_IsTrue(loop_obj)); - McRFPy_API::music->play(); - Py_INCREF(Py_None); - return Py_None; -} - -PyObject* McRFPy_API::_setMusicVolume(PyObject* self, PyObject* args) { - int vol; - if (!PyArg_ParseTuple(args, "i", &vol)) return NULL; - if (!McRFPy_API::music) { - McRFPy_API::music = new sf::Music(); - } - McRFPy_API::music->setVolume(vol); - Py_INCREF(Py_None); - return Py_None; -} - -PyObject* McRFPy_API::_setSoundVolume(PyObject* self, PyObject* args) { - float vol; - if (!PyArg_ParseTuple(args, "f", &vol)) return NULL; - if (!McRFPy_API::sfx) { - McRFPy_API::sfx = new sf::Sound(); - } - McRFPy_API::sfx->setVolume(vol); - Py_INCREF(Py_None); - return Py_None; -} - -PyObject* McRFPy_API::_playSound(PyObject* self, PyObject* args) { - float index; - if (!PyArg_ParseTuple(args, "f", &index)) return NULL; - if (!McRFPy_API::soundbuffers || index >= McRFPy_API::soundbuffers->size()) return NULL; - if (!McRFPy_API::sfx) { - McRFPy_API::sfx = new sf::Sound(); - } - McRFPy_API::sfx->stop(); - McRFPy_API::sfx->setBuffer((*McRFPy_API::soundbuffers)[index]); - McRFPy_API::sfx->play(); - Py_INCREF(Py_None); - return Py_None; -} - -PyObject* McRFPy_API::_getMusicVolume(PyObject* self, PyObject* args) { - if (!McRFPy_API::music) { - return Py_BuildValue("f", 0.0f); - } - return Py_BuildValue("f", McRFPy_API::music->getVolume()); -} - -PyObject* McRFPy_API::_getSoundVolume(PyObject* self, PyObject* args) { - if (!McRFPy_API::sfx) { - return Py_BuildValue("f", 0.0f); - } - return Py_BuildValue("f", McRFPy_API::sfx->getVolume()); -} - +// Removed deprecated audio functions - use mcrfpy.Sound and mcrfpy.Music classes instead (#66) // Removed deprecated player_input, computerTurn, playerTurn functions // These were part of the old turn-based system that is no longer used diff --git a/src/McRFPy_API.h b/src/McRFPy_API.h index 4e5103a..e6dfd95 100644 --- a/src/McRFPy_API.h +++ b/src/McRFPy_API.h @@ -37,19 +37,6 @@ public: static void REPL_device(FILE * fp, const char *filename); static void REPL(); - static std::vector* soundbuffers; - static sf::Music* music; - static sf::Sound* sfx; - - - static PyObject* _createSoundBuffer(PyObject*, PyObject*); - static PyObject* _loadMusic(PyObject*, PyObject*); - static PyObject* _setMusicVolume(PyObject*, PyObject*); - static PyObject* _setSoundVolume(PyObject*, PyObject*); - static PyObject* _playSound(PyObject*, PyObject*); - static PyObject* _getMusicVolume(PyObject*, PyObject*); - static PyObject* _getSoundVolume(PyObject*, PyObject*); - static PyObject* _sceneUI(PyObject*, PyObject*); // scene control diff --git a/src/McRogueFaceVersion.h b/src/McRogueFaceVersion.h new file mode 100644 index 0000000..72037d3 --- /dev/null +++ b/src/McRogueFaceVersion.h @@ -0,0 +1,4 @@ +#pragma once + +// McRogueFace version string (#164) +#define MCRFPY_VERSION "1.0.0" diff --git a/src/PyKeyboard.cpp b/src/PyKeyboard.cpp new file mode 100644 index 0000000..255e090 --- /dev/null +++ b/src/PyKeyboard.cpp @@ -0,0 +1,64 @@ +#include "PyKeyboard.h" +#include "McRFPy_Doc.h" + +PyObject* PyKeyboard::repr(PyObject* obj) +{ + // Show current state in repr for debugging + bool shift = sf::Keyboard::isKeyPressed(sf::Keyboard::LShift) || + sf::Keyboard::isKeyPressed(sf::Keyboard::RShift); + bool ctrl = sf::Keyboard::isKeyPressed(sf::Keyboard::LControl) || + sf::Keyboard::isKeyPressed(sf::Keyboard::RControl); + bool alt = sf::Keyboard::isKeyPressed(sf::Keyboard::LAlt) || + sf::Keyboard::isKeyPressed(sf::Keyboard::RAlt); + bool system = sf::Keyboard::isKeyPressed(sf::Keyboard::LSystem) || + sf::Keyboard::isKeyPressed(sf::Keyboard::RSystem); + + std::ostringstream ss; + ss << ""; + + std::string repr_str = ss.str(); + return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace"); +} + +PyObject* PyKeyboard::get_shift(PyObject* self, void* closure) +{ + bool pressed = sf::Keyboard::isKeyPressed(sf::Keyboard::LShift) || + sf::Keyboard::isKeyPressed(sf::Keyboard::RShift); + return PyBool_FromLong(pressed); +} + +PyObject* PyKeyboard::get_ctrl(PyObject* self, void* closure) +{ + bool pressed = sf::Keyboard::isKeyPressed(sf::Keyboard::LControl) || + sf::Keyboard::isKeyPressed(sf::Keyboard::RControl); + return PyBool_FromLong(pressed); +} + +PyObject* PyKeyboard::get_alt(PyObject* self, void* closure) +{ + bool pressed = sf::Keyboard::isKeyPressed(sf::Keyboard::LAlt) || + sf::Keyboard::isKeyPressed(sf::Keyboard::RAlt); + return PyBool_FromLong(pressed); +} + +PyObject* PyKeyboard::get_system(PyObject* self, void* closure) +{ + bool pressed = sf::Keyboard::isKeyPressed(sf::Keyboard::LSystem) || + sf::Keyboard::isKeyPressed(sf::Keyboard::RSystem); + return PyBool_FromLong(pressed); +} + +PyGetSetDef PyKeyboard::getsetters[] = { + {"shift", (getter)PyKeyboard::get_shift, NULL, + MCRF_PROPERTY(shift, "True if either Shift key is currently pressed (read-only)."), NULL}, + {"ctrl", (getter)PyKeyboard::get_ctrl, NULL, + MCRF_PROPERTY(ctrl, "True if either Control key is currently pressed (read-only)."), NULL}, + {"alt", (getter)PyKeyboard::get_alt, NULL, + MCRF_PROPERTY(alt, "True if either Alt key is currently pressed (read-only)."), NULL}, + {"system", (getter)PyKeyboard::get_system, NULL, + MCRF_PROPERTY(system, "True if either System key (Win/Cmd) is currently pressed (read-only)."), NULL}, + {NULL} +}; diff --git a/src/PyKeyboard.h b/src/PyKeyboard.h new file mode 100644 index 0000000..aa749f7 --- /dev/null +++ b/src/PyKeyboard.h @@ -0,0 +1,35 @@ +#pragma once +#include "Common.h" +#include "Python.h" + +// Singleton keyboard state object +typedef struct { + PyObject_HEAD +} PyKeyboardObject; + +class PyKeyboard +{ +public: + // Python getters - query real-time keyboard state via SFML + static PyObject* get_shift(PyObject* self, void* closure); + static PyObject* get_ctrl(PyObject* self, void* closure); + static PyObject* get_alt(PyObject* self, void* closure); + static PyObject* get_system(PyObject* self, void* closure); + + static PyObject* repr(PyObject* obj); + static PyGetSetDef getsetters[]; +}; + +namespace mcrfpydef { + static PyTypeObject PyKeyboardType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.Keyboard", + .tp_basicsize = sizeof(PyKeyboardObject), + .tp_itemsize = 0, + .tp_repr = PyKeyboard::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR("Keyboard state singleton for checking modifier keys"), + .tp_getset = PyKeyboard::getsetters, + .tp_new = PyType_GenericNew, + }; +} diff --git a/src/PyMusic.cpp b/src/PyMusic.cpp new file mode 100644 index 0000000..a8dedab --- /dev/null +++ b/src/PyMusic.cpp @@ -0,0 +1,294 @@ +#include "PyMusic.h" +#include "McRFPy_API.h" +#include "McRFPy_Doc.h" +#include +#include + +PyMusic::PyMusic(const std::string& filename) + : source(filename), loaded(false) +{ + if (music.openFromFile(filename)) { + loaded = true; + } +} + +void PyMusic::play() +{ + if (loaded) { + music.play(); + } +} + +void PyMusic::pause() +{ + if (loaded) { + music.pause(); + } +} + +void PyMusic::stop() +{ + if (loaded) { + music.stop(); + } +} + +float PyMusic::getVolume() const +{ + return music.getVolume(); +} + +void PyMusic::setVolume(float vol) +{ + music.setVolume(std::max(0.0f, std::min(100.0f, vol))); +} + +bool PyMusic::getLoop() const +{ + return music.getLoop(); +} + +void PyMusic::setLoop(bool loop) +{ + music.setLoop(loop); +} + +bool PyMusic::isPlaying() const +{ + return music.getStatus() == sf::Music::Playing; +} + +float PyMusic::getDuration() const +{ + if (!loaded) return 0.0f; + return music.getDuration().asSeconds(); +} + +float PyMusic::getPosition() const +{ + return music.getPlayingOffset().asSeconds(); +} + +void PyMusic::setPosition(float pos) +{ + music.setPlayingOffset(sf::seconds(pos)); +} + +PyObject* PyMusic::pyObject() +{ + auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Music"); + PyObject* obj = PyMusic::pynew(type, Py_None, Py_None); + Py_DECREF(type); + try { + ((PyMusicObject*)obj)->data = shared_from_this(); + } + catch (std::bad_weak_ptr& e) { + std::cerr << "PyMusic::pyObject() - shared_from_this() failed" << std::endl; + } + return obj; +} + +PyObject* PyMusic::repr(PyObject* obj) +{ + PyMusicObject* self = (PyMusicObject*)obj; + std::ostringstream ss; + if (!self->data) { + ss << ""; + } else if (!self->data->loaded) { + ss << "data->source << "]>"; + } else { + ss << ""; + } + std::string repr_str = ss.str(); + return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace"); +} + +Py_hash_t PyMusic::hash(PyObject* obj) +{ + auto self = (PyMusicObject*)obj; + return reinterpret_cast(self->data.get()); +} + +int PyMusic::init(PyMusicObject* self, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"filename", nullptr}; + const char* filename = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast(keywords), &filename)) { + return -1; + } + + self->data = std::make_shared(filename); + + if (!self->data->loaded) { + PyErr_Format(PyExc_RuntimeError, "Failed to load music file: %s", filename); + return -1; + } + + return 0; +} + +PyObject* PyMusic::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) +{ + return (PyObject*)type->tp_alloc(type, 0); +} + +// Python methods +PyObject* PyMusic::py_play(PyMusicObject* self, PyObject* args) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Music object is invalid"); + return NULL; + } + self->data->play(); + Py_RETURN_NONE; +} + +PyObject* PyMusic::py_pause(PyMusicObject* self, PyObject* args) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Music object is invalid"); + return NULL; + } + self->data->pause(); + Py_RETURN_NONE; +} + +PyObject* PyMusic::py_stop(PyMusicObject* self, PyObject* args) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Music object is invalid"); + return NULL; + } + self->data->stop(); + Py_RETURN_NONE; +} + +// Property getters/setters +PyObject* PyMusic::get_volume(PyMusicObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Music object is invalid"); + return NULL; + } + return PyFloat_FromDouble(self->data->getVolume()); +} + +int PyMusic::set_volume(PyMusicObject* self, PyObject* value, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Music object is invalid"); + return -1; + } + float vol = PyFloat_AsDouble(value); + if (PyErr_Occurred()) { + return -1; + } + self->data->setVolume(vol); + return 0; +} + +PyObject* PyMusic::get_loop(PyMusicObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Music object is invalid"); + return NULL; + } + return PyBool_FromLong(self->data->getLoop()); +} + +int PyMusic::set_loop(PyMusicObject* self, PyObject* value, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Music object is invalid"); + return -1; + } + self->data->setLoop(PyObject_IsTrue(value)); + return 0; +} + +PyObject* PyMusic::get_playing(PyMusicObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Music object is invalid"); + return NULL; + } + return PyBool_FromLong(self->data->isPlaying()); +} + +PyObject* PyMusic::get_duration(PyMusicObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Music object is invalid"); + return NULL; + } + return PyFloat_FromDouble(self->data->getDuration()); +} + +PyObject* PyMusic::get_position(PyMusicObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Music object is invalid"); + return NULL; + } + return PyFloat_FromDouble(self->data->getPosition()); +} + +int PyMusic::set_position(PyMusicObject* self, PyObject* value, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Music object is invalid"); + return -1; + } + float pos = PyFloat_AsDouble(value); + if (PyErr_Occurred()) { + return -1; + } + self->data->setPosition(pos); + return 0; +} + +PyObject* PyMusic::get_source(PyMusicObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Music object is invalid"); + return NULL; + } + return PyUnicode_FromString(self->data->source.c_str()); +} + +PyMethodDef PyMusic::methods[] = { + {"play", (PyCFunction)PyMusic::py_play, METH_NOARGS, + MCRF_METHOD(Music, play, + MCRF_SIG("()", "None"), + MCRF_DESC("Start or resume playing the music.") + )}, + {"pause", (PyCFunction)PyMusic::py_pause, METH_NOARGS, + MCRF_METHOD(Music, pause, + MCRF_SIG("()", "None"), + MCRF_DESC("Pause the music. Use play() to resume from the paused position.") + )}, + {"stop", (PyCFunction)PyMusic::py_stop, METH_NOARGS, + MCRF_METHOD(Music, stop, + MCRF_SIG("()", "None"), + MCRF_DESC("Stop playing and reset to the beginning.") + )}, + {NULL} +}; + +PyGetSetDef PyMusic::getsetters[] = { + {"volume", (getter)PyMusic::get_volume, (setter)PyMusic::set_volume, + MCRF_PROPERTY(volume, "Volume level from 0 (silent) to 100 (full volume)."), NULL}, + {"loop", (getter)PyMusic::get_loop, (setter)PyMusic::set_loop, + MCRF_PROPERTY(loop, "Whether the music loops when it reaches the end."), NULL}, + {"playing", (getter)PyMusic::get_playing, NULL, + MCRF_PROPERTY(playing, "True if the music is currently playing (read-only)."), NULL}, + {"duration", (getter)PyMusic::get_duration, NULL, + MCRF_PROPERTY(duration, "Total duration of the music in seconds (read-only)."), NULL}, + {"position", (getter)PyMusic::get_position, (setter)PyMusic::set_position, + MCRF_PROPERTY(position, "Current playback position in seconds. Can be set to seek."), NULL}, + {"source", (getter)PyMusic::get_source, NULL, + MCRF_PROPERTY(source, "Filename path used to load this music (read-only)."), NULL}, + {NULL} +}; diff --git a/src/PyMusic.h b/src/PyMusic.h new file mode 100644 index 0000000..65b3873 --- /dev/null +++ b/src/PyMusic.h @@ -0,0 +1,79 @@ +#pragma once +#include "Common.h" +#include "Python.h" + +class PyMusic; + +typedef struct { + PyObject_HEAD + std::shared_ptr data; +} PyMusicObject; + +class PyMusic : public std::enable_shared_from_this +{ +private: + sf::Music music; + std::string source; + bool loaded; + +public: + PyMusic(const std::string& filename); + + // Playback control + void play(); + void pause(); + void stop(); + + // Properties + float getVolume() const; + void setVolume(float vol); + bool getLoop() const; + void setLoop(bool loop); + bool isPlaying() const; + float getDuration() const; + float getPosition() const; + void setPosition(float pos); + + // Python interface + PyObject* pyObject(); + static PyObject* repr(PyObject* obj); + static Py_hash_t hash(PyObject* obj); + static int init(PyMusicObject* self, PyObject* args, PyObject* kwds); + static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); + + // Python methods + static PyObject* py_play(PyMusicObject* self, PyObject* args); + static PyObject* py_pause(PyMusicObject* self, PyObject* args); + static PyObject* py_stop(PyMusicObject* self, PyObject* args); + + // Python getters/setters + static PyObject* get_volume(PyMusicObject* self, void* closure); + static int set_volume(PyMusicObject* self, PyObject* value, void* closure); + static PyObject* get_loop(PyMusicObject* self, void* closure); + static int set_loop(PyMusicObject* self, PyObject* value, void* closure); + static PyObject* get_playing(PyMusicObject* self, void* closure); + static PyObject* get_duration(PyMusicObject* self, void* closure); + static PyObject* get_position(PyMusicObject* self, void* closure); + static int set_position(PyMusicObject* self, PyObject* value, void* closure); + static PyObject* get_source(PyMusicObject* self, void* closure); + + static PyMethodDef methods[]; + static PyGetSetDef getsetters[]; +}; + +namespace mcrfpydef { + static PyTypeObject PyMusicType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.Music", + .tp_basicsize = sizeof(PyMusicObject), + .tp_itemsize = 0, + .tp_repr = PyMusic::repr, + .tp_hash = PyMusic::hash, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR("Streaming music object for longer audio tracks"), + .tp_methods = PyMusic::methods, + .tp_getset = PyMusic::getsetters, + .tp_init = (initproc)PyMusic::init, + .tp_new = PyType_GenericNew, + }; +} diff --git a/src/PySound.cpp b/src/PySound.cpp new file mode 100644 index 0000000..536fa52 --- /dev/null +++ b/src/PySound.cpp @@ -0,0 +1,259 @@ +#include "PySound.h" +#include "McRFPy_API.h" +#include "McRFPy_Doc.h" +#include + +PySound::PySound(const std::string& filename) + : source(filename), loaded(false) +{ + if (buffer.loadFromFile(filename)) { + sound.setBuffer(buffer); + loaded = true; + } +} + +void PySound::play() +{ + if (loaded) { + sound.play(); + } +} + +void PySound::pause() +{ + if (loaded) { + sound.pause(); + } +} + +void PySound::stop() +{ + if (loaded) { + sound.stop(); + } +} + +float PySound::getVolume() const +{ + return sound.getVolume(); +} + +void PySound::setVolume(float vol) +{ + sound.setVolume(std::max(0.0f, std::min(100.0f, vol))); +} + +bool PySound::getLoop() const +{ + return sound.getLoop(); +} + +void PySound::setLoop(bool loop) +{ + sound.setLoop(loop); +} + +bool PySound::isPlaying() const +{ + return sound.getStatus() == sf::Sound::Playing; +} + +float PySound::getDuration() const +{ + if (!loaded) return 0.0f; + return buffer.getDuration().asSeconds(); +} + +PyObject* PySound::pyObject() +{ + auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sound"); + PyObject* obj = PySound::pynew(type, Py_None, Py_None); + Py_DECREF(type); + try { + ((PySoundObject*)obj)->data = shared_from_this(); + } + catch (std::bad_weak_ptr& e) { + std::cerr << "PySound::pyObject() - shared_from_this() failed" << std::endl; + } + return obj; +} + +PyObject* PySound::repr(PyObject* obj) +{ + PySoundObject* self = (PySoundObject*)obj; + std::ostringstream ss; + if (!self->data) { + ss << ""; + } else if (!self->data->loaded) { + ss << "data->source << "]>"; + } else { + ss << ""; + } + std::string repr_str = ss.str(); + return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace"); +} + +Py_hash_t PySound::hash(PyObject* obj) +{ + auto self = (PySoundObject*)obj; + return reinterpret_cast(self->data.get()); +} + +int PySound::init(PySoundObject* self, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"filename", nullptr}; + const char* filename = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast(keywords), &filename)) { + return -1; + } + + self->data = std::make_shared(filename); + + if (!self->data->loaded) { + PyErr_Format(PyExc_RuntimeError, "Failed to load sound file: %s", filename); + return -1; + } + + return 0; +} + +PyObject* PySound::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) +{ + return (PyObject*)type->tp_alloc(type, 0); +} + +// Python methods +PyObject* PySound::py_play(PySoundObject* self, PyObject* args) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid"); + return NULL; + } + self->data->play(); + Py_RETURN_NONE; +} + +PyObject* PySound::py_pause(PySoundObject* self, PyObject* args) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid"); + return NULL; + } + self->data->pause(); + Py_RETURN_NONE; +} + +PyObject* PySound::py_stop(PySoundObject* self, PyObject* args) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid"); + return NULL; + } + self->data->stop(); + Py_RETURN_NONE; +} + +// Property getters/setters +PyObject* PySound::get_volume(PySoundObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid"); + return NULL; + } + return PyFloat_FromDouble(self->data->getVolume()); +} + +int PySound::set_volume(PySoundObject* self, PyObject* value, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid"); + return -1; + } + float vol = PyFloat_AsDouble(value); + if (PyErr_Occurred()) { + return -1; + } + self->data->setVolume(vol); + return 0; +} + +PyObject* PySound::get_loop(PySoundObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid"); + return NULL; + } + return PyBool_FromLong(self->data->getLoop()); +} + +int PySound::set_loop(PySoundObject* self, PyObject* value, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid"); + return -1; + } + self->data->setLoop(PyObject_IsTrue(value)); + return 0; +} + +PyObject* PySound::get_playing(PySoundObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid"); + return NULL; + } + return PyBool_FromLong(self->data->isPlaying()); +} + +PyObject* PySound::get_duration(PySoundObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid"); + return NULL; + } + return PyFloat_FromDouble(self->data->getDuration()); +} + +PyObject* PySound::get_source(PySoundObject* self, void* closure) +{ + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Sound object is invalid"); + return NULL; + } + return PyUnicode_FromString(self->data->source.c_str()); +} + +PyMethodDef PySound::methods[] = { + {"play", (PyCFunction)PySound::py_play, METH_NOARGS, + MCRF_METHOD(Sound, play, + MCRF_SIG("()", "None"), + MCRF_DESC("Start or resume playing the sound.") + )}, + {"pause", (PyCFunction)PySound::py_pause, METH_NOARGS, + MCRF_METHOD(Sound, pause, + MCRF_SIG("()", "None"), + MCRF_DESC("Pause the sound. Use play() to resume from the paused position.") + )}, + {"stop", (PyCFunction)PySound::py_stop, METH_NOARGS, + MCRF_METHOD(Sound, stop, + MCRF_SIG("()", "None"), + MCRF_DESC("Stop playing and reset to the beginning.") + )}, + {NULL} +}; + +PyGetSetDef PySound::getsetters[] = { + {"volume", (getter)PySound::get_volume, (setter)PySound::set_volume, + MCRF_PROPERTY(volume, "Volume level from 0 (silent) to 100 (full volume)."), NULL}, + {"loop", (getter)PySound::get_loop, (setter)PySound::set_loop, + MCRF_PROPERTY(loop, "Whether the sound loops when it reaches the end."), NULL}, + {"playing", (getter)PySound::get_playing, NULL, + MCRF_PROPERTY(playing, "True if the sound is currently playing (read-only)."), NULL}, + {"duration", (getter)PySound::get_duration, NULL, + MCRF_PROPERTY(duration, "Total duration of the sound in seconds (read-only)."), NULL}, + {"source", (getter)PySound::get_source, NULL, + MCRF_PROPERTY(source, "Filename path used to load this sound (read-only)."), NULL}, + {NULL} +}; diff --git a/src/PySound.h b/src/PySound.h new file mode 100644 index 0000000..12ba78a --- /dev/null +++ b/src/PySound.h @@ -0,0 +1,76 @@ +#pragma once +#include "Common.h" +#include "Python.h" + +class PySound; + +typedef struct { + PyObject_HEAD + std::shared_ptr data; +} PySoundObject; + +class PySound : public std::enable_shared_from_this +{ +private: + sf::SoundBuffer buffer; + sf::Sound sound; + std::string source; + bool loaded; + +public: + PySound(const std::string& filename); + + // Playback control + void play(); + void pause(); + void stop(); + + // Properties + float getVolume() const; + void setVolume(float vol); + bool getLoop() const; + void setLoop(bool loop); + bool isPlaying() const; + float getDuration() const; + + // Python interface + PyObject* pyObject(); + static PyObject* repr(PyObject* obj); + static Py_hash_t hash(PyObject* obj); + static int init(PySoundObject* self, PyObject* args, PyObject* kwds); + static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); + + // Python methods + static PyObject* py_play(PySoundObject* self, PyObject* args); + static PyObject* py_pause(PySoundObject* self, PyObject* args); + static PyObject* py_stop(PySoundObject* self, PyObject* args); + + // Python getters/setters + static PyObject* get_volume(PySoundObject* self, void* closure); + static int set_volume(PySoundObject* self, PyObject* value, void* closure); + static PyObject* get_loop(PySoundObject* self, void* closure); + static int set_loop(PySoundObject* self, PyObject* value, void* closure); + static PyObject* get_playing(PySoundObject* self, void* closure); + static PyObject* get_duration(PySoundObject* self, void* closure); + static PyObject* get_source(PySoundObject* self, void* closure); + + static PyMethodDef methods[]; + static PyGetSetDef getsetters[]; +}; + +namespace mcrfpydef { + static PyTypeObject PySoundType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.Sound", + .tp_basicsize = sizeof(PySoundObject), + .tp_itemsize = 0, + .tp_repr = PySound::repr, + .tp_hash = PySound::hash, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR("Sound effect object for short audio clips"), + .tp_methods = PySound::methods, + .tp_getset = PySound::getsetters, + .tp_init = (initproc)PySound::init, + .tp_new = PyType_GenericNew, + }; +} diff --git a/stubs/mcrfpy.pyi b/stubs/mcrfpy.pyi index bc0c00f..55c9886 100644 --- a/stubs/mcrfpy.pyi +++ b/stubs/mcrfpy.pyi @@ -67,12 +67,125 @@ class Texture: class Font: """SFML Font Object for text rendering.""" - + def __init__(self, filename: str) -> None: ... - + filename: str family: str +class Sound: + """Sound effect object for short audio clips. + + Sounds are loaded entirely into memory, making them suitable for + short sound effects that need to be played with minimal latency. + Multiple Sound instances can play simultaneously. + """ + + def __init__(self, filename: str) -> None: + """Load a sound effect from a file. + + Args: + filename: Path to the sound file (WAV, OGG, FLAC supported) + + Raises: + RuntimeError: If the file cannot be loaded + """ + ... + + volume: float + """Volume level from 0 (silent) to 100 (full volume).""" + + loop: bool + """Whether the sound loops when it reaches the end.""" + + playing: bool + """True if the sound is currently playing (read-only).""" + + duration: float + """Total duration of the sound in seconds (read-only).""" + + source: str + """Filename path used to load this sound (read-only).""" + + def play(self) -> None: + """Start or resume playing the sound.""" + ... + + def pause(self) -> None: + """Pause the sound. Use play() to resume.""" + ... + + def stop(self) -> None: + """Stop playing and reset to the beginning.""" + ... + +class Music: + """Streaming music object for longer audio tracks. + + Music is streamed from disk rather than loaded entirely into memory, + making it suitable for longer audio tracks like background music. + """ + + def __init__(self, filename: str) -> None: + """Load a music track from a file. + + Args: + filename: Path to the music file (WAV, OGG, FLAC supported) + + Raises: + RuntimeError: If the file cannot be loaded + """ + ... + + volume: float + """Volume level from 0 (silent) to 100 (full volume).""" + + loop: bool + """Whether the music loops when it reaches the end.""" + + playing: bool + """True if the music is currently playing (read-only).""" + + duration: float + """Total duration of the music in seconds (read-only).""" + + position: float + """Current playback position in seconds. Can be set to seek.""" + + source: str + """Filename path used to load this music (read-only).""" + + def play(self) -> None: + """Start or resume playing the music.""" + ... + + def pause(self) -> None: + """Pause the music. Use play() to resume.""" + ... + + def stop(self) -> None: + """Stop playing and reset to the beginning.""" + ... + +class Keyboard: + """Keyboard state singleton for checking modifier keys. + + Access via mcrfpy.keyboard (singleton instance). + Queries real-time keyboard state from SFML. + """ + + shift: bool + """True if either Shift key is currently pressed (read-only).""" + + ctrl: bool + """True if either Control key is currently pressed (read-only).""" + + alt: bool + """True if either Alt key is currently pressed (read-only).""" + + system: bool + """True if either System key (Win/Cmd) is currently pressed (read-only).""" + class Drawable: """Base class for all drawable UI elements.""" @@ -446,36 +559,16 @@ class Animation: """Get the current interpolated value.""" ... +# Module-level attributes + +__version__: str +"""McRogueFace version string (e.g., '1.0.0').""" + +keyboard: Keyboard +"""Keyboard state singleton for checking modifier keys.""" + # Module functions -def createSoundBuffer(filename: str) -> int: - """Load a sound effect from a file and return its buffer ID.""" - ... - -def loadMusic(filename: str) -> None: - """Load and immediately play background music from a file.""" - ... - -def setMusicVolume(volume: int) -> None: - """Set the global music volume (0-100).""" - ... - -def setSoundVolume(volume: int) -> None: - """Set the global sound effects volume (0-100).""" - ... - -def playSound(buffer_id: int) -> None: - """Play a sound effect using a previously loaded buffer.""" - ... - -def getMusicVolume() -> int: - """Get the current music volume level (0-100).""" - ... - -def getSoundVolume() -> int: - """Get the current sound effects volume level (0-100).""" - ... - def sceneUI(scene: Optional[str] = None) -> UICollection: """Get all UI elements for a scene.""" ... diff --git a/tests/unit/audio_and_keyboard_test.py b/tests/unit/audio_and_keyboard_test.py new file mode 100644 index 0000000..02d62c3 --- /dev/null +++ b/tests/unit/audio_and_keyboard_test.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Test for Sound, Music, Keyboard classes and __version__ (#66, #160, #164).""" + +import mcrfpy +import sys + +def test_version(): + """Test that __version__ exists and is a valid semver string.""" + assert hasattr(mcrfpy, '__version__'), "mcrfpy.__version__ not found" + version = mcrfpy.__version__ + assert isinstance(version, str), f"__version__ should be str, got {type(version)}" + parts = version.split('.') + assert len(parts) == 3, f"Version should be MAJOR.MINOR.PATCH, got {version}" + print(f" Version: {version}") + +def test_keyboard(): + """Test Keyboard singleton exists and has expected properties.""" + assert hasattr(mcrfpy, 'keyboard'), "mcrfpy.keyboard not found" + kb = mcrfpy.keyboard + + # Check all modifier properties exist and are bool + for prop in ['shift', 'ctrl', 'alt', 'system']: + assert hasattr(kb, prop), f"keyboard.{prop} not found" + val = getattr(kb, prop) + assert isinstance(val, bool), f"keyboard.{prop} should be bool, got {type(val)}" + + print(f" Keyboard state: {kb}") + +def test_sound_class(): + """Test Sound class creation and properties.""" + # Test with a known good file + sound = mcrfpy.Sound("assets/sfx/splat1.ogg") + + # Check repr works + repr_str = repr(sound) + assert 'Sound' in repr_str + assert 'splat1.ogg' in repr_str + print(f" Sound: {repr_str}") + + # Check default values + assert sound.volume == 100.0, f"Default volume should be 100, got {sound.volume}" + assert sound.loop == False, f"Default loop should be False, got {sound.loop}" + assert sound.playing == False, f"Should not be playing initially" + assert sound.duration > 0, f"Duration should be positive, got {sound.duration}" + assert sound.source == "assets/sfx/splat1.ogg" + + # Test setting properties (use tolerance for floating point) + sound.volume = 50.0 + assert abs(sound.volume - 50.0) < 0.01, f"Volume should be ~50, got {sound.volume}" + + sound.loop = True + assert sound.loop == True + + # Test methods exist (don't actually play in headless) + assert callable(sound.play) + assert callable(sound.pause) + assert callable(sound.stop) + + print(f" Duration: {sound.duration:.3f}s") + +def test_sound_error_handling(): + """Test Sound raises on invalid file.""" + try: + sound = mcrfpy.Sound("nonexistent_file.ogg") + print(" ERROR: Should have raised RuntimeError") + return False + except RuntimeError as e: + print(f" Correctly raised: {e}") + return True + +def test_music_class(): + """Test Music class creation and properties.""" + music = mcrfpy.Music("assets/sfx/splat1.ogg") + + # Check repr works + repr_str = repr(music) + assert 'Music' in repr_str + print(f" Music: {repr_str}") + + # Check default values + assert music.volume == 100.0, f"Default volume should be 100, got {music.volume}" + assert music.loop == False, f"Default loop should be False, got {music.loop}" + assert music.playing == False, f"Should not be playing initially, got {music.playing}" + assert music.duration > 0, f"Duration should be positive, got {music.duration}" + # Position comparison needs tolerance for floating point + assert abs(music.position) < 0.001, f"Position should be ~0, got {music.position}" + assert music.source == "assets/sfx/splat1.ogg", f"Source mismatch: {music.source}" + + # Test setting properties (use tolerance for floating point) + music.volume = 30.0 + assert abs(music.volume - 30.0) < 0.01, f"Volume should be ~30, got {music.volume}" + + music.loop = True + assert music.loop == True, f"Loop should be True, got {music.loop}" + + # Test position can be set (seek) + # Can't really test this without playing, but check it's writable + music.position = 0.1 + + print(f" Duration: {music.duration:.3f}s") + +def test_music_error_handling(): + """Test Music raises on invalid file.""" + try: + music = mcrfpy.Music("nonexistent_file.ogg") + print(" ERROR: Should have raised RuntimeError") + return False + except RuntimeError as e: + print(f" Correctly raised: {e}") + return True + +def main(): + print("Testing mcrfpy audio and keyboard features (#66, #160, #164)") + print() + + tests = [ + ("__version__", test_version), + ("keyboard singleton", test_keyboard), + ("Sound class", test_sound_class), + ("Sound error handling", test_sound_error_handling), + ("Music class", test_music_class), + ("Music error handling", test_music_error_handling), + ] + + passed = 0 + failed = 0 + + for name, test_fn in tests: + print(f"Testing {name}...") + try: + result = test_fn() + if result is False: + failed += 1 + print(f" FAILED") + else: + passed += 1 + print(f" PASSED") + except Exception as e: + failed += 1 + print(f" FAILED: {e}") + + print() + print(f"Results: {passed} passed, {failed} failed") + + if failed == 0: + print("PASS") + sys.exit(0) + else: + print("FAIL") + sys.exit(1) + +if __name__ == "__main__": + main()