Fix callback/timer GC: prevent premature destruction of Python callbacks
closes #251 Two related bugs where Python garbage collection destroyed callbacks that were still needed by live C++ objects: 1. **Drawable callbacks (all 8 types)**: tp_dealloc unconditionally called click_unregister() etc., destroying callbacks even when the C++ object was still alive in a parent's children vector. Fixed by guarding with shared_ptr::use_count() <= 1 — only unregister when the Python wrapper is the last owner. 2. **Timer GC prevention**: Active timers now hold a Py_INCREF'd reference to their Python wrapper (Timer::py_wrapper), preventing GC while the timer is registered in the engine. Released on stop(), one-shot fire, or destruction. mcrfpy.Timer("name", cb, 100) now works without storing the return value. Also includes audio synth demo UI fixes: button click handling (don't set on_click on Caption children), single-column slider layout, improved Animalese contrast. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
97dbec9106
commit
9718153709
15 changed files with 740 additions and 231 deletions
|
|
@ -415,7 +415,8 @@ inline PyTypeObject PyViewport3DType = {
|
||||||
if (obj->weakreflist != NULL) {
|
if (obj->weakreflist != NULL) {
|
||||||
PyObject_ClearWeakRefs(self);
|
PyObject_ClearWeakRefs(self);
|
||||||
}
|
}
|
||||||
if (obj->data) {
|
// Only unregister callbacks if we're the last owner (#251)
|
||||||
|
if (obj->data && obj->data.use_count() <= 1) {
|
||||||
obj->data->click_unregister();
|
obj->data->click_unregister();
|
||||||
obj->data->on_enter_unregister();
|
obj->data->on_enter_unregister();
|
||||||
obj->data->on_exit_unregister();
|
obj->data->on_exit_unregister();
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ int PyTimer::init(PyTimerObject* self, PyObject* args, PyObject* kwds) {
|
||||||
it->second->stop();
|
it->second->stop();
|
||||||
}
|
}
|
||||||
Resources::game->timers[self->name] = self->data;
|
Resources::game->timers[self->name] = self->data;
|
||||||
|
// Prevent Python GC while timer is active (#251)
|
||||||
|
self->data->retainPyWrapper((PyObject*)self);
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -147,6 +149,8 @@ PyObject* PyTimer::start(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) {
|
||||||
}
|
}
|
||||||
|
|
||||||
self->data->start(current_time);
|
self->data->start(current_time);
|
||||||
|
// Prevent Python GC while timer is active (#251)
|
||||||
|
self->data->retainPyWrapper((PyObject*)self);
|
||||||
Py_RETURN_NONE;
|
Py_RETURN_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,6 +222,8 @@ PyObject* PyTimer::restart(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) {
|
||||||
}
|
}
|
||||||
|
|
||||||
self->data->restart(current_time);
|
self->data->restart(current_time);
|
||||||
|
// Prevent Python GC while timer is active (#251)
|
||||||
|
self->data->retainPyWrapper((PyObject*)self);
|
||||||
Py_RETURN_NONE;
|
Py_RETURN_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,6 +322,8 @@ int PyTimer::set_active(PyTimerObject* self, PyObject* value, void* closure) {
|
||||||
Resources::game->timers[self->name] = self->data;
|
Resources::game->timers[self->name] = self->data;
|
||||||
}
|
}
|
||||||
self->data->start(current_time);
|
self->data->start(current_time);
|
||||||
|
// Prevent Python GC while timer is active (#251)
|
||||||
|
self->data->retainPyWrapper((PyObject*)self);
|
||||||
} else if (self->data->isPaused()) {
|
} else if (self->data->isPaused()) {
|
||||||
// Resume from pause
|
// Resume from pause
|
||||||
self->data->resume(current_time);
|
self->data->resume(current_time);
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,24 @@ Timer::Timer()
|
||||||
{}
|
{}
|
||||||
|
|
||||||
Timer::~Timer() {
|
Timer::~Timer() {
|
||||||
|
releasePyWrapper();
|
||||||
if (serial_number != 0) {
|
if (serial_number != 0) {
|
||||||
PythonObjectCache::getInstance().remove(serial_number);
|
PythonObjectCache::getInstance().remove(serial_number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Timer::retainPyWrapper(PyObject* wrapper) {
|
||||||
|
if (py_wrapper == wrapper) return; // Already held
|
||||||
|
Py_XDECREF(py_wrapper);
|
||||||
|
py_wrapper = wrapper;
|
||||||
|
Py_XINCREF(py_wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Timer::releasePyWrapper() {
|
||||||
|
Py_XDECREF(py_wrapper);
|
||||||
|
py_wrapper = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
bool Timer::hasElapsed(int now) const
|
bool Timer::hasElapsed(int now) const
|
||||||
{
|
{
|
||||||
if (paused || stopped) return false;
|
if (paused || stopped) return false;
|
||||||
|
|
@ -71,6 +84,7 @@ bool Timer::test(int now)
|
||||||
// Handle one-shot timers: stop but preserve callback for potential restart
|
// Handle one-shot timers: stop but preserve callback for potential restart
|
||||||
if (once) {
|
if (once) {
|
||||||
stopped = true; // Will be removed from map by testTimers(), but callback preserved
|
stopped = true; // Will be removed from map by testTimers(), but callback preserved
|
||||||
|
releasePyWrapper(); // Allow Python GC now (#251)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -123,6 +137,7 @@ void Timer::stop()
|
||||||
paused = false;
|
paused = false;
|
||||||
pause_start_time = 0;
|
pause_start_time = 0;
|
||||||
total_paused_time = 0;
|
total_paused_time = 0;
|
||||||
|
releasePyWrapper(); // Allow Python GC now that timer is inactive (#251)
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Timer::isActive() const
|
bool Timer::isActive() const
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ public:
|
||||||
uint64_t serial_number = 0; // For Python object cache
|
uint64_t serial_number = 0; // For Python object cache
|
||||||
std::string name; // Store name for creating Python wrappers (#180)
|
std::string name; // Store name for creating Python wrappers (#180)
|
||||||
|
|
||||||
|
// Strong reference to Python wrapper prevents GC while timer is active (#251)
|
||||||
|
PyObject* py_wrapper = nullptr;
|
||||||
|
void retainPyWrapper(PyObject* wrapper);
|
||||||
|
void releasePyWrapper();
|
||||||
|
|
||||||
Timer(); // for map to build
|
Timer(); // for map to build
|
||||||
Timer(PyObject* target, int interval, int now, bool once = false, bool start = true, const std::string& name = "");
|
Timer(PyObject* target, int interval, int now, bool once = false, bool start = true, const std::string& name = "");
|
||||||
~Timer();
|
~Timer();
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,8 @@ namespace mcrfpydef {
|
||||||
if (obj->weakreflist != NULL) {
|
if (obj->weakreflist != NULL) {
|
||||||
PyObject_ClearWeakRefs(self);
|
PyObject_ClearWeakRefs(self);
|
||||||
}
|
}
|
||||||
if (obj->data) {
|
// Only unregister callbacks if we're the last owner (#251)
|
||||||
|
if (obj->data && obj->data.use_count() <= 1) {
|
||||||
obj->data->click_unregister();
|
obj->data->click_unregister();
|
||||||
obj->data->on_enter_unregister();
|
obj->data->on_enter_unregister();
|
||||||
obj->data->on_exit_unregister();
|
obj->data->on_exit_unregister();
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,8 @@ namespace mcrfpydef {
|
||||||
if (obj->weakreflist != NULL) {
|
if (obj->weakreflist != NULL) {
|
||||||
PyObject_ClearWeakRefs(self);
|
PyObject_ClearWeakRefs(self);
|
||||||
}
|
}
|
||||||
// Clear Python references to break cycles
|
// Only unregister callbacks if we're the last owner (#251)
|
||||||
if (obj->data) {
|
if (obj->data && obj->data.use_count() <= 1) {
|
||||||
obj->data->click_unregister();
|
obj->data->click_unregister();
|
||||||
obj->data->on_enter_unregister();
|
obj->data->on_enter_unregister();
|
||||||
obj->data->on_exit_unregister();
|
obj->data->on_exit_unregister();
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,8 @@ namespace mcrfpydef {
|
||||||
if (obj->weakreflist != NULL) {
|
if (obj->weakreflist != NULL) {
|
||||||
PyObject_ClearWeakRefs(self);
|
PyObject_ClearWeakRefs(self);
|
||||||
}
|
}
|
||||||
if (obj->data) {
|
// Only unregister callbacks if we're the last owner (#251)
|
||||||
|
if (obj->data && obj->data.use_count() <= 1) {
|
||||||
obj->data->click_unregister();
|
obj->data->click_unregister();
|
||||||
obj->data->on_enter_unregister();
|
obj->data->on_enter_unregister();
|
||||||
obj->data->on_exit_unregister();
|
obj->data->on_exit_unregister();
|
||||||
|
|
|
||||||
|
|
@ -90,8 +90,10 @@ namespace mcrfpydef {
|
||||||
if (obj->weakreflist != NULL) {
|
if (obj->weakreflist != NULL) {
|
||||||
PyObject_ClearWeakRefs(self);
|
PyObject_ClearWeakRefs(self);
|
||||||
}
|
}
|
||||||
// Clear Python references to break cycles
|
// Only unregister callbacks if we're the last owner of the
|
||||||
if (obj->data) {
|
// C++ object. If other shared_ptr owners exist (e.g. parent's
|
||||||
|
// children vector), the callbacks must survive. (#251)
|
||||||
|
if (obj->data && obj->data.use_count() <= 1) {
|
||||||
obj->data->click_unregister();
|
obj->data->click_unregister();
|
||||||
obj->data->on_enter_unregister();
|
obj->data->on_enter_unregister();
|
||||||
obj->data->on_exit_unregister();
|
obj->data->on_exit_unregister();
|
||||||
|
|
|
||||||
|
|
@ -269,8 +269,8 @@ namespace mcrfpydef {
|
||||||
if (obj->weakreflist != NULL) {
|
if (obj->weakreflist != NULL) {
|
||||||
PyObject_ClearWeakRefs(self);
|
PyObject_ClearWeakRefs(self);
|
||||||
}
|
}
|
||||||
// Clear Python references to break cycles
|
// Only unregister callbacks if we're the last owner (#251)
|
||||||
if (obj->data) {
|
if (obj->data && obj->data.use_count() <= 1) {
|
||||||
obj->data->click_unregister();
|
obj->data->click_unregister();
|
||||||
obj->data->on_enter_unregister();
|
obj->data->on_enter_unregister();
|
||||||
obj->data->on_exit_unregister();
|
obj->data->on_exit_unregister();
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,8 @@ namespace mcrfpydef {
|
||||||
if (obj->weakreflist != NULL) {
|
if (obj->weakreflist != NULL) {
|
||||||
PyObject_ClearWeakRefs(self);
|
PyObject_ClearWeakRefs(self);
|
||||||
}
|
}
|
||||||
if (obj->data) {
|
// Only unregister callbacks if we're the last owner (#251)
|
||||||
|
if (obj->data && obj->data.use_count() <= 1) {
|
||||||
obj->data->click_unregister();
|
obj->data->click_unregister();
|
||||||
obj->data->on_enter_unregister();
|
obj->data->on_enter_unregister();
|
||||||
obj->data->on_exit_unregister();
|
obj->data->on_exit_unregister();
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,8 @@ namespace mcrfpydef {
|
||||||
if (obj->weakreflist != NULL) {
|
if (obj->weakreflist != NULL) {
|
||||||
PyObject_ClearWeakRefs(self);
|
PyObject_ClearWeakRefs(self);
|
||||||
}
|
}
|
||||||
// Clear Python references to break cycles
|
// Only unregister callbacks if we're the last owner (#251)
|
||||||
if (obj->data) {
|
if (obj->data && obj->data.use_count() <= 1) {
|
||||||
obj->data->click_unregister();
|
obj->data->click_unregister();
|
||||||
obj->data->on_enter_unregister();
|
obj->data->on_enter_unregister();
|
||||||
obj->data->on_exit_unregister();
|
obj->data->on_exit_unregister();
|
||||||
|
|
|
||||||
334
stubs/mcrfpy.pyi
334
stubs/mcrfpy.pyi
|
|
@ -5,7 +5,8 @@ Core game engine interface for creating roguelike games with Python.
|
||||||
|
|
||||||
from typing import Any, List, Dict, Tuple, Optional, Callable, Union, overload
|
from typing import Any, List, Dict, Tuple, Optional, Callable, Union, overload
|
||||||
|
|
||||||
# Type aliases
|
# Type aliases - Color tuples are accepted anywhere a Color is expected
|
||||||
|
ColorLike = Union['Color', Tuple[int, int, int], Tuple[int, int, int, int]]
|
||||||
UIElement = Union['Frame', 'Caption', 'Sprite', 'Grid', 'Line', 'Circle', 'Arc']
|
UIElement = Union['Frame', 'Caption', 'Sprite', 'Grid', 'Line', 'Circle', 'Arc']
|
||||||
Transition = Union[str, None]
|
Transition = Union[str, None]
|
||||||
|
|
||||||
|
|
@ -20,11 +21,39 @@ class Key:
|
||||||
A: 'Key'
|
A: 'Key'
|
||||||
B: 'Key'
|
B: 'Key'
|
||||||
C: 'Key'
|
C: 'Key'
|
||||||
# ... (all letters A-Z)
|
D: 'Key'
|
||||||
|
E: 'Key'
|
||||||
|
F: 'Key'
|
||||||
|
G: 'Key'
|
||||||
|
H: 'Key'
|
||||||
|
I: 'Key'
|
||||||
|
J: 'Key'
|
||||||
|
K: 'Key'
|
||||||
|
L: 'Key'
|
||||||
|
M: 'Key'
|
||||||
|
N: 'Key'
|
||||||
|
O: 'Key'
|
||||||
|
P: 'Key'
|
||||||
|
Q: 'Key'
|
||||||
|
R: 'Key'
|
||||||
|
S: 'Key'
|
||||||
|
T: 'Key'
|
||||||
|
U: 'Key'
|
||||||
|
V: 'Key'
|
||||||
|
W: 'Key'
|
||||||
|
X: 'Key'
|
||||||
|
Y: 'Key'
|
||||||
|
Z: 'Key'
|
||||||
Num0: 'Key'
|
Num0: 'Key'
|
||||||
Num1: 'Key'
|
Num1: 'Key'
|
||||||
Num2: 'Key'
|
Num2: 'Key'
|
||||||
# ... (Num0-Num9)
|
Num3: 'Key'
|
||||||
|
Num4: 'Key'
|
||||||
|
Num5: 'Key'
|
||||||
|
Num6: 'Key'
|
||||||
|
Num7: 'Key'
|
||||||
|
Num8: 'Key'
|
||||||
|
Num9: 'Key'
|
||||||
ESCAPE: 'Key'
|
ESCAPE: 'Key'
|
||||||
ENTER: 'Key'
|
ENTER: 'Key'
|
||||||
SPACE: 'Key'
|
SPACE: 'Key'
|
||||||
|
|
@ -41,7 +70,59 @@ class Key:
|
||||||
RCTRL: 'Key'
|
RCTRL: 'Key'
|
||||||
LALT: 'Key'
|
LALT: 'Key'
|
||||||
RALT: 'Key'
|
RALT: 'Key'
|
||||||
# ... (additional keys)
|
F1: 'Key'
|
||||||
|
F2: 'Key'
|
||||||
|
F3: 'Key'
|
||||||
|
F4: 'Key'
|
||||||
|
F5: 'Key'
|
||||||
|
F6: 'Key'
|
||||||
|
F7: 'Key'
|
||||||
|
F8: 'Key'
|
||||||
|
F9: 'Key'
|
||||||
|
F10: 'Key'
|
||||||
|
F11: 'Key'
|
||||||
|
F12: 'Key'
|
||||||
|
F13: 'Key'
|
||||||
|
F14: 'Key'
|
||||||
|
F15: 'Key'
|
||||||
|
HOME: 'Key'
|
||||||
|
END: 'Key'
|
||||||
|
PAGEUP: 'Key'
|
||||||
|
PAGEDOWN: 'Key'
|
||||||
|
INSERT: 'Key'
|
||||||
|
PAUSE: 'Key'
|
||||||
|
TILDE: 'Key'
|
||||||
|
MINUS: 'Key'
|
||||||
|
EQUAL: 'Key'
|
||||||
|
LBRACKET: 'Key'
|
||||||
|
RBRACKET: 'Key'
|
||||||
|
BACKSLASH: 'Key'
|
||||||
|
SEMICOLON: 'Key'
|
||||||
|
QUOTE: 'Key'
|
||||||
|
COMMA: 'Key'
|
||||||
|
PERIOD: 'Key'
|
||||||
|
SLASH: 'Key'
|
||||||
|
# NUM_ aliases for Num keys
|
||||||
|
NUM_0: 'Key'
|
||||||
|
NUM_1: 'Key'
|
||||||
|
NUM_2: 'Key'
|
||||||
|
NUM_3: 'Key'
|
||||||
|
NUM_4: 'Key'
|
||||||
|
NUM_5: 'Key'
|
||||||
|
NUM_6: 'Key'
|
||||||
|
NUM_7: 'Key'
|
||||||
|
NUM_8: 'Key'
|
||||||
|
NUM_9: 'Key'
|
||||||
|
NUMPAD0: 'Key'
|
||||||
|
NUMPAD1: 'Key'
|
||||||
|
NUMPAD2: 'Key'
|
||||||
|
NUMPAD3: 'Key'
|
||||||
|
NUMPAD4: 'Key'
|
||||||
|
NUMPAD5: 'Key'
|
||||||
|
NUMPAD6: 'Key'
|
||||||
|
NUMPAD7: 'Key'
|
||||||
|
NUMPAD8: 'Key'
|
||||||
|
NUMPAD9: 'Key'
|
||||||
|
|
||||||
class MouseButton:
|
class MouseButton:
|
||||||
"""Mouse button enum for click callbacks."""
|
"""Mouse button enum for click callbacks."""
|
||||||
|
|
@ -138,15 +219,34 @@ class Vector:
|
||||||
def dot(self, other: 'Vector') -> float: ...
|
def dot(self, other: 'Vector') -> float: ...
|
||||||
|
|
||||||
class Texture:
|
class Texture:
|
||||||
"""SFML Texture Object for images."""
|
"""SFML Texture Object for sprite sheet images."""
|
||||||
|
|
||||||
def __init__(self, filename: str) -> None: ...
|
def __init__(self, filename: str, sprite_width: int = 0, sprite_height: int = 0) -> None: ...
|
||||||
|
|
||||||
filename: str
|
filename: str
|
||||||
width: int
|
width: int
|
||||||
height: int
|
height: int
|
||||||
sprite_count: int
|
sprite_count: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes, width: int, height: int,
|
||||||
|
sprite_width: int, sprite_height: int,
|
||||||
|
name: str = "<generated>") -> 'Texture':
|
||||||
|
"""Create texture from raw RGBA bytes."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def composite(cls, textures: List['Texture'],
|
||||||
|
sprite_width: int, sprite_height: int,
|
||||||
|
name: str = "<composite>") -> 'Texture':
|
||||||
|
"""Composite multiple textures into one by layering sprites."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def hsl_shift(self, hue_shift: float, sat_shift: float = 0.0,
|
||||||
|
lit_shift: float = 0.0) -> 'Texture':
|
||||||
|
"""Return a new texture with HSL color shift applied."""
|
||||||
|
...
|
||||||
|
|
||||||
class Font:
|
class Font:
|
||||||
"""SFML Font Object for text rendering."""
|
"""SFML Font Object for text rendering."""
|
||||||
|
|
||||||
|
|
@ -155,6 +255,53 @@ class Font:
|
||||||
filename: str
|
filename: str
|
||||||
family: str
|
family: str
|
||||||
|
|
||||||
|
class Sound:
|
||||||
|
"""Sound effect object for short audio clips."""
|
||||||
|
|
||||||
|
def __init__(self, filename: str) -> None: ...
|
||||||
|
|
||||||
|
volume: float
|
||||||
|
loop: bool
|
||||||
|
playing: bool # Read-only
|
||||||
|
duration: float # Read-only
|
||||||
|
source: str # Read-only
|
||||||
|
|
||||||
|
def play(self) -> None:
|
||||||
|
"""Play the sound effect."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def pause(self) -> None:
|
||||||
|
"""Pause playback."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop playback."""
|
||||||
|
...
|
||||||
|
|
||||||
|
class Music:
|
||||||
|
"""Streaming music object for longer audio tracks."""
|
||||||
|
|
||||||
|
def __init__(self, filename: str) -> None: ...
|
||||||
|
|
||||||
|
volume: float
|
||||||
|
loop: bool
|
||||||
|
playing: bool # Read-only
|
||||||
|
duration: float # Read-only
|
||||||
|
position: float # Playback position in seconds
|
||||||
|
source: str # Read-only
|
||||||
|
|
||||||
|
def play(self) -> None:
|
||||||
|
"""Play the music."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def pause(self) -> None:
|
||||||
|
"""Pause playback."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop playback."""
|
||||||
|
...
|
||||||
|
|
||||||
class Drawable:
|
class Drawable:
|
||||||
"""Base class for all drawable UI elements."""
|
"""Base class for all drawable UI elements."""
|
||||||
|
|
||||||
|
|
@ -164,6 +311,7 @@ class Drawable:
|
||||||
z_index: int
|
z_index: int
|
||||||
name: str
|
name: str
|
||||||
pos: Vector
|
pos: Vector
|
||||||
|
opacity: float
|
||||||
|
|
||||||
# Mouse event callbacks (#140, #141, #230)
|
# Mouse event callbacks (#140, #141, #230)
|
||||||
# on_click receives (pos: Vector, button: MouseButton, action: InputState)
|
# on_click receives (pos: Vector, button: MouseButton, action: InputState)
|
||||||
|
|
@ -188,6 +336,12 @@ class Drawable:
|
||||||
"""Resize to new dimensions (width, height)."""
|
"""Resize to new dimensions (width, height)."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def animate(self, property: str, end_value: Any, duration: float,
|
||||||
|
easing: Union[str, 'Easing'] = 'linear',
|
||||||
|
callback: Optional[Callable[[Any, str, Any], None]] = None) -> None:
|
||||||
|
"""Animate a property to a target value over duration seconds."""
|
||||||
|
...
|
||||||
|
|
||||||
class Frame(Drawable):
|
class Frame(Drawable):
|
||||||
"""Frame(x=0, y=0, w=0, h=0, fill_color=None, outline_color=None, outline=0, on_click=None, children=None)
|
"""Frame(x=0, y=0, w=0, h=0, fill_color=None, outline_color=None, outline=0, on_click=None, children=None)
|
||||||
|
|
||||||
|
|
@ -198,59 +352,73 @@ class Frame(Drawable):
|
||||||
def __init__(self) -> None: ...
|
def __init__(self) -> None: ...
|
||||||
@overload
|
@overload
|
||||||
def __init__(self, x: float = 0, y: float = 0, w: float = 0, h: float = 0,
|
def __init__(self, x: float = 0, y: float = 0, w: float = 0, h: float = 0,
|
||||||
fill_color: Optional[Color] = None, outline_color: Optional[Color] = None,
|
fill_color: Optional[ColorLike] = None, outline_color: Optional[ColorLike] = None,
|
||||||
|
outline: float = 0, on_click: Optional[Callable] = None,
|
||||||
|
children: Optional[List[UIElement]] = None) -> None: ...
|
||||||
|
@overload
|
||||||
|
def __init__(self, pos: Tuple[float, float], size: Tuple[float, float],
|
||||||
|
fill_color: Optional[ColorLike] = None, outline_color: Optional[ColorLike] = None,
|
||||||
outline: float = 0, on_click: Optional[Callable] = None,
|
outline: float = 0, on_click: Optional[Callable] = None,
|
||||||
children: Optional[List[UIElement]] = None) -> None: ...
|
children: Optional[List[UIElement]] = None) -> None: ...
|
||||||
|
|
||||||
w: float
|
w: float
|
||||||
h: float
|
h: float
|
||||||
fill_color: Color
|
fill_color: ColorLike
|
||||||
outline_color: Color
|
outline_color: ColorLike
|
||||||
outline: float
|
outline: float
|
||||||
on_click: Optional[Callable[[float, float, int], None]]
|
|
||||||
children: 'UICollection'
|
children: 'UICollection'
|
||||||
clip_children: bool
|
clip_children: bool
|
||||||
|
|
||||||
class Caption(Drawable):
|
class Caption(Drawable):
|
||||||
"""Caption(text='', x=0, y=0, font=None, fill_color=None, outline_color=None, outline=0, on_click=None)
|
"""Caption(pos=None, font=None, text='', fill_color=None, ...)
|
||||||
|
|
||||||
A text display UI element with customizable font and styling.
|
A text display UI element with customizable font and styling.
|
||||||
|
Positional args: pos, font, text. Everything else is keyword-only.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def __init__(self) -> None: ...
|
def __init__(self) -> None: ...
|
||||||
@overload
|
@overload
|
||||||
def __init__(self, text: str = '', x: float = 0, y: float = 0,
|
def __init__(self, pos: Optional[Tuple[float, float]] = None,
|
||||||
font: Optional[Font] = None, fill_color: Optional[Color] = None,
|
font: Optional[Font] = None, text: str = '',
|
||||||
outline_color: Optional[Color] = None, outline: float = 0,
|
fill_color: Optional[ColorLike] = None,
|
||||||
on_click: Optional[Callable] = None) -> None: ...
|
outline_color: Optional[ColorLike] = None, outline: float = 0,
|
||||||
|
font_size: float = 16.0, on_click: Optional[Callable] = None,
|
||||||
|
visible: bool = True, opacity: float = 1.0,
|
||||||
|
z_index: int = 0, name: str = '',
|
||||||
|
x: float = 0, y: float = 0) -> None: ...
|
||||||
|
|
||||||
text: str
|
text: str
|
||||||
font: Font
|
font: Font
|
||||||
fill_color: Color
|
fill_color: ColorLike
|
||||||
outline_color: Color
|
outline_color: ColorLike
|
||||||
outline: float
|
outline: float
|
||||||
on_click: Optional[Callable[[float, float, int], None]]
|
font_size: float
|
||||||
w: float # Read-only, computed from text
|
w: float # Read-only, computed from text
|
||||||
h: float # Read-only, computed from text
|
h: float # Read-only, computed from text
|
||||||
|
|
||||||
class Sprite(Drawable):
|
class Sprite(Drawable):
|
||||||
"""Sprite(x=0, y=0, texture=None, sprite_index=0, scale=1.0, on_click=None)
|
"""Sprite(pos=None, texture=None, sprite_index=0, scale=1.0, on_click=None)
|
||||||
|
|
||||||
A sprite UI element that displays a texture or portion of a texture atlas.
|
A sprite UI element that displays a texture or portion of a texture atlas.
|
||||||
|
Positional args: pos, texture, sprite_index. Everything else is keyword-only.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def __init__(self) -> None: ...
|
def __init__(self) -> None: ...
|
||||||
@overload
|
@overload
|
||||||
def __init__(self, x: float = 0, y: float = 0, texture: Optional[Texture] = None,
|
def __init__(self, pos: Optional[Tuple[float, float]] = None,
|
||||||
|
texture: Optional[Texture] = None,
|
||||||
sprite_index: int = 0, scale: float = 1.0,
|
sprite_index: int = 0, scale: float = 1.0,
|
||||||
on_click: Optional[Callable] = None) -> None: ...
|
on_click: Optional[Callable] = None,
|
||||||
|
visible: bool = True, opacity: float = 1.0,
|
||||||
|
z_index: int = 0, name: str = '',
|
||||||
|
x: float = 0, y: float = 0) -> None: ...
|
||||||
|
|
||||||
texture: Texture
|
texture: Texture
|
||||||
sprite_index: int
|
sprite_index: int
|
||||||
|
sprite_number: int # Deprecated alias for sprite_index
|
||||||
scale: float
|
scale: float
|
||||||
on_click: Optional[Callable[[float, float, int], None]]
|
|
||||||
w: float # Read-only, computed from texture
|
w: float # Read-only, computed from texture
|
||||||
h: float # Read-only, computed from texture
|
h: float # Read-only, computed from texture
|
||||||
|
|
||||||
|
|
@ -268,14 +436,15 @@ class Grid(Drawable):
|
||||||
size: Tuple[float, float] = (0, 0),
|
size: Tuple[float, float] = (0, 0),
|
||||||
grid_size: Tuple[int, int] = (2, 2),
|
grid_size: Tuple[int, int] = (2, 2),
|
||||||
texture: Optional[Texture] = None,
|
texture: Optional[Texture] = None,
|
||||||
fill_color: Optional[Color] = None,
|
fill_color: Optional[ColorLike] = None,
|
||||||
on_click: Optional[Callable] = None,
|
on_click: Optional[Callable] = None,
|
||||||
center_x: float = 0, center_y: float = 0, zoom: float = 1.0,
|
center_x: float = 0, center_y: float = 0, zoom: float = 1.0,
|
||||||
visible: bool = True, opacity: float = 1.0,
|
visible: bool = True, opacity: float = 1.0,
|
||||||
z_index: int = 0, name: str = '') -> None: ...
|
z_index: int = 0, name: str = '',
|
||||||
|
layers: Optional[List[Union['ColorLayer', 'TileLayer']]] = None) -> None: ...
|
||||||
|
|
||||||
# Dimensions
|
# Dimensions
|
||||||
grid_size: Tuple[int, int] # Read-only (grid_w, grid_h)
|
grid_size: Vector # Read-only - has .x (width) and .y (height)
|
||||||
grid_w: int # Read-only
|
grid_w: int # Read-only
|
||||||
grid_h: int # Read-only
|
grid_h: int # Read-only
|
||||||
|
|
||||||
|
|
@ -286,7 +455,7 @@ class Grid(Drawable):
|
||||||
h: float
|
h: float
|
||||||
|
|
||||||
# Camera/viewport
|
# Camera/viewport
|
||||||
center: Vector # Viewport center point (pan position)
|
center: Union[Vector, Tuple[float, float]] # Viewport center point (pixel coordinates)
|
||||||
center_x: float
|
center_x: float
|
||||||
center_y: float
|
center_y: float
|
||||||
zoom: float # Scale factor for rendering
|
zoom: float # Scale factor for rendering
|
||||||
|
|
@ -294,11 +463,11 @@ class Grid(Drawable):
|
||||||
# Collections
|
# Collections
|
||||||
entities: 'EntityCollection' # Entities on this grid
|
entities: 'EntityCollection' # Entities on this grid
|
||||||
children: 'UICollection' # UI overlays (speech bubbles, effects)
|
children: 'UICollection' # UI overlays (speech bubbles, effects)
|
||||||
layers: List[Union['ColorLayer', 'TileLayer']] # Grid layers sorted by z_index
|
layers: Tuple[Union['ColorLayer', 'TileLayer'], ...] # Grid layers sorted by z_index
|
||||||
|
|
||||||
# Appearance
|
# Appearance
|
||||||
texture: Texture # Read-only
|
texture: Texture # Read-only
|
||||||
fill_color: Color # Background fill color
|
fill_color: ColorLike # Background fill color
|
||||||
|
|
||||||
# Perspective/FOV
|
# Perspective/FOV
|
||||||
perspective: Optional['Entity'] # Entity for FOV rendering (None = omniscient)
|
perspective: Optional['Entity'] # Entity for FOV rendering (None = omniscient)
|
||||||
|
|
@ -307,9 +476,7 @@ class Grid(Drawable):
|
||||||
fov_radius: int # Default FOV radius
|
fov_radius: int # Default FOV radius
|
||||||
|
|
||||||
# Cell-level mouse events (#230)
|
# Cell-level mouse events (#230)
|
||||||
# on_cell_click receives (cell_pos: Vector, button: MouseButton, action: InputState)
|
|
||||||
on_cell_click: Optional[Callable[['Vector', 'MouseButton', 'InputState'], None]]
|
on_cell_click: Optional[Callable[['Vector', 'MouseButton', 'InputState'], None]]
|
||||||
# Cell hover callbacks receive only position per #230
|
|
||||||
on_cell_enter: Optional[Callable[['Vector'], None]]
|
on_cell_enter: Optional[Callable[['Vector'], None]]
|
||||||
on_cell_exit: Optional[Callable[['Vector'], None]]
|
on_cell_exit: Optional[Callable[['Vector'], None]]
|
||||||
hovered_cell: Optional[Tuple[int, int]] # Read-only
|
hovered_cell: Optional[Tuple[int, int]] # Read-only
|
||||||
|
|
@ -349,17 +516,16 @@ class Grid(Drawable):
|
||||||
...
|
...
|
||||||
|
|
||||||
# Layer methods
|
# Layer methods
|
||||||
def add_layer(self, type: str, z_index: int = -1,
|
def add_layer(self, layer: Union['ColorLayer', 'TileLayer']) -> None:
|
||||||
texture: Optional[Texture] = None) -> Union['ColorLayer', 'TileLayer']:
|
"""Add a pre-constructed layer to the grid."""
|
||||||
"""Add a new layer to the grid."""
|
|
||||||
...
|
...
|
||||||
|
|
||||||
def remove_layer(self, layer: Union['ColorLayer', 'TileLayer']) -> None:
|
def remove_layer(self, layer: Union['ColorLayer', 'TileLayer']) -> None:
|
||||||
"""Remove a layer from the grid."""
|
"""Remove a layer from the grid."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def layer(self, z_index: int) -> Optional[Union['ColorLayer', 'TileLayer']]:
|
def layer(self, name: str) -> Optional[Union['ColorLayer', 'TileLayer']]:
|
||||||
"""Get layer by z_index."""
|
"""Get layer by name."""
|
||||||
...
|
...
|
||||||
|
|
||||||
# Spatial queries
|
# Spatial queries
|
||||||
|
|
@ -391,14 +557,13 @@ class Line(Drawable):
|
||||||
@overload
|
@overload
|
||||||
def __init__(self, start: Optional[Tuple[float, float]] = None,
|
def __init__(self, start: Optional[Tuple[float, float]] = None,
|
||||||
end: Optional[Tuple[float, float]] = None,
|
end: Optional[Tuple[float, float]] = None,
|
||||||
thickness: float = 1.0, color: Optional[Color] = None,
|
thickness: float = 1.0, color: Optional[ColorLike] = None,
|
||||||
on_click: Optional[Callable] = None) -> None: ...
|
on_click: Optional[Callable] = None) -> None: ...
|
||||||
|
|
||||||
start: Vector
|
start: Vector
|
||||||
end: Vector
|
end: Vector
|
||||||
thickness: float
|
thickness: float
|
||||||
color: Color
|
color: ColorLike
|
||||||
on_click: Optional[Callable[[float, float, int], None]]
|
|
||||||
|
|
||||||
class Circle(Drawable):
|
class Circle(Drawable):
|
||||||
"""Circle(radius=0, center=None, fill_color=None, outline_color=None, outline=0, on_click=None, **kwargs)
|
"""Circle(radius=0, center=None, fill_color=None, outline_color=None, outline=0, on_click=None, **kwargs)
|
||||||
|
|
@ -410,15 +575,14 @@ class Circle(Drawable):
|
||||||
def __init__(self) -> None: ...
|
def __init__(self) -> None: ...
|
||||||
@overload
|
@overload
|
||||||
def __init__(self, radius: float = 0, center: Optional[Tuple[float, float]] = None,
|
def __init__(self, radius: float = 0, center: Optional[Tuple[float, float]] = None,
|
||||||
fill_color: Optional[Color] = None, outline_color: Optional[Color] = None,
|
fill_color: Optional[ColorLike] = None, outline_color: Optional[ColorLike] = None,
|
||||||
outline: float = 0, on_click: Optional[Callable] = None) -> None: ...
|
outline: float = 0, on_click: Optional[Callable] = None) -> None: ...
|
||||||
|
|
||||||
radius: float
|
radius: float
|
||||||
center: Vector
|
center: Vector
|
||||||
fill_color: Color
|
fill_color: ColorLike
|
||||||
outline_color: Color
|
outline_color: ColorLike
|
||||||
outline: float
|
outline: float
|
||||||
on_click: Optional[Callable[[float, float, int], None]]
|
|
||||||
|
|
||||||
class Arc(Drawable):
|
class Arc(Drawable):
|
||||||
"""Arc(center=None, radius=0, start_angle=0, end_angle=90, color=None, thickness=1, on_click=None, **kwargs)
|
"""Arc(center=None, radius=0, start_angle=0, end_angle=90, color=None, thickness=1, on_click=None, **kwargs)
|
||||||
|
|
@ -431,16 +595,15 @@ class Arc(Drawable):
|
||||||
@overload
|
@overload
|
||||||
def __init__(self, center: Optional[Tuple[float, float]] = None, radius: float = 0,
|
def __init__(self, center: Optional[Tuple[float, float]] = None, radius: float = 0,
|
||||||
start_angle: float = 0, end_angle: float = 90,
|
start_angle: float = 0, end_angle: float = 90,
|
||||||
color: Optional[Color] = None, thickness: float = 1.0,
|
color: Optional[ColorLike] = None, thickness: float = 1.0,
|
||||||
on_click: Optional[Callable] = None) -> None: ...
|
on_click: Optional[Callable] = None) -> None: ...
|
||||||
|
|
||||||
center: Vector
|
center: Vector
|
||||||
radius: float
|
radius: float
|
||||||
start_angle: float
|
start_angle: float
|
||||||
end_angle: float
|
end_angle: float
|
||||||
color: Color
|
color: ColorLike
|
||||||
thickness: float
|
thickness: float
|
||||||
on_click: Optional[Callable[[float, float, int], None]]
|
|
||||||
|
|
||||||
class GridPoint:
|
class GridPoint:
|
||||||
"""Grid point representing a single tile's properties.
|
"""Grid point representing a single tile's properties.
|
||||||
|
|
@ -451,6 +614,7 @@ class GridPoint:
|
||||||
|
|
||||||
walkable: bool # Whether entities can walk through this cell
|
walkable: bool # Whether entities can walk through this cell
|
||||||
transparent: bool # Whether light/sight passes through this cell
|
transparent: bool # Whether light/sight passes through this cell
|
||||||
|
tilesprite: int # Tile sprite index (legacy property, not in dir())
|
||||||
entities: List['Entity'] # Read-only list of entities at this cell
|
entities: List['Entity'] # Read-only list of entities at this cell
|
||||||
grid_pos: Tuple[int, int] # Read-only (x, y) position in grid
|
grid_pos: Tuple[int, int] # Read-only (x, y) position in grid
|
||||||
|
|
||||||
|
|
@ -468,20 +632,27 @@ class ColorLayer:
|
||||||
"""A color overlay layer for Grid.
|
"""A color overlay layer for Grid.
|
||||||
|
|
||||||
Provides per-cell color values for tinting, fog of war, etc.
|
Provides per-cell color values for tinting, fog of war, etc.
|
||||||
|
Construct independently, then add to a Grid via grid.add_layer().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
z_index: int
|
def __init__(self, z_index: int = -1, name: Optional[str] = None,
|
||||||
grid: 'Grid' # Read-only parent grid
|
grid_size: Optional[Tuple[int, int]] = None) -> None: ...
|
||||||
|
|
||||||
def fill(self, color: Color) -> None:
|
z_index: int
|
||||||
|
name: str # Read-only
|
||||||
|
visible: bool
|
||||||
|
grid_size: Vector # Read-only
|
||||||
|
grid: Optional['Grid']
|
||||||
|
|
||||||
|
def fill(self, color: ColorLike) -> None:
|
||||||
"""Fill entire layer with a single color."""
|
"""Fill entire layer with a single color."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def set_color(self, pos: Tuple[int, int], color: Color) -> None:
|
def set(self, pos: Tuple[int, int], color: ColorLike) -> None:
|
||||||
"""Set color at a specific cell."""
|
"""Set color at a specific cell."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def get_color(self, pos: Tuple[int, int]) -> Color:
|
def get(self, pos: Tuple[int, int]) -> Color:
|
||||||
"""Get color at a specific cell."""
|
"""Get color at a specific cell."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
@ -489,21 +660,29 @@ class TileLayer:
|
||||||
"""A tile sprite layer for Grid.
|
"""A tile sprite layer for Grid.
|
||||||
|
|
||||||
Provides per-cell tile indices for multi-layer tile rendering.
|
Provides per-cell tile indices for multi-layer tile rendering.
|
||||||
|
Construct independently, then add to a Grid via grid.add_layer().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __init__(self, z_index: int = -1, name: Optional[str] = None,
|
||||||
|
texture: Optional[Texture] = None,
|
||||||
|
grid_size: Optional[Tuple[int, int]] = None) -> None: ...
|
||||||
|
|
||||||
z_index: int
|
z_index: int
|
||||||
grid: 'Grid' # Read-only parent grid
|
name: str # Read-only
|
||||||
|
visible: bool
|
||||||
texture: Optional[Texture]
|
texture: Optional[Texture]
|
||||||
|
grid_size: Vector # Read-only
|
||||||
|
grid: Optional['Grid']
|
||||||
|
|
||||||
def fill(self, tile_index: int) -> None:
|
def fill(self, tile_index: int) -> None:
|
||||||
"""Fill entire layer with a single tile index."""
|
"""Fill entire layer with a single tile index."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def set_tile(self, pos: Tuple[int, int], tile_index: int) -> None:
|
def set(self, pos: Tuple[int, int], tile_index: int) -> None:
|
||||||
"""Set tile index at a specific cell."""
|
"""Set tile index at a specific cell. Use -1 for transparent."""
|
||||||
...
|
...
|
||||||
|
|
||||||
def get_tile(self, pos: Tuple[int, int]) -> int:
|
def get(self, pos: Tuple[int, int]) -> int:
|
||||||
"""Get tile index at a specific cell."""
|
"""Get tile index at a specific cell."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
@ -686,6 +865,7 @@ class Entity(Drawable):
|
||||||
grid_y: float
|
grid_y: float
|
||||||
texture: Texture
|
texture: Texture
|
||||||
sprite_index: int
|
sprite_index: int
|
||||||
|
sprite_number: int # Deprecated alias for sprite_index
|
||||||
grid: Optional[Grid]
|
grid: Optional[Grid]
|
||||||
|
|
||||||
def at(self, grid_x: float, grid_y: float) -> None:
|
def at(self, grid_x: float, grid_y: float) -> None:
|
||||||
|
|
@ -714,6 +894,7 @@ class UICollection:
|
||||||
|
|
||||||
def append(self, item: UIElement) -> None: ...
|
def append(self, item: UIElement) -> None: ...
|
||||||
def extend(self, items: List[UIElement]) -> None: ...
|
def extend(self, items: List[UIElement]) -> None: ...
|
||||||
|
def pop(self, index: int = -1) -> UIElement: ...
|
||||||
def remove(self, item: UIElement) -> None: ...
|
def remove(self, item: UIElement) -> None: ...
|
||||||
def index(self, item: UIElement) -> int: ...
|
def index(self, item: UIElement) -> int: ...
|
||||||
def count(self, item: UIElement) -> int: ...
|
def count(self, item: UIElement) -> int: ...
|
||||||
|
|
@ -732,6 +913,7 @@ class EntityCollection:
|
||||||
|
|
||||||
def append(self, item: Entity) -> None: ...
|
def append(self, item: Entity) -> None: ...
|
||||||
def extend(self, items: List[Entity]) -> None: ...
|
def extend(self, items: List[Entity]) -> None: ...
|
||||||
|
def pop(self, index: int = -1) -> Entity: ...
|
||||||
def remove(self, item: Entity) -> None: ...
|
def remove(self, item: Entity) -> None: ...
|
||||||
def index(self, item: Entity) -> int: ...
|
def index(self, item: Entity) -> int: ...
|
||||||
def count(self, item: Entity) -> int: ...
|
def count(self, item: Entity) -> int: ...
|
||||||
|
|
@ -740,7 +922,7 @@ class Scene:
|
||||||
"""Base class for object-oriented scenes."""
|
"""Base class for object-oriented scenes."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
children: UICollection # #151: UI elements collection (read-only alias for get_ui())
|
children: UICollection # UI elements collection (read-only alias for get_ui())
|
||||||
# Keyboard handler receives (key: Key, action: InputState) per #184
|
# Keyboard handler receives (key: Key, action: InputState) per #184
|
||||||
on_key: Optional[Callable[['Key', 'InputState'], None]]
|
on_key: Optional[Callable[['Key', 'InputState'], None]]
|
||||||
|
|
||||||
|
|
@ -783,13 +965,26 @@ class Scene:
|
||||||
...
|
...
|
||||||
|
|
||||||
class Timer:
|
class Timer:
|
||||||
"""Timer object for scheduled callbacks."""
|
"""Timer object for scheduled callbacks.
|
||||||
|
|
||||||
|
Callback receives (timer_object, runtime_ms).
|
||||||
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
interval: int
|
interval: int
|
||||||
|
callback: Callable[['Timer', float], None]
|
||||||
active: bool
|
active: bool
|
||||||
|
paused: bool
|
||||||
|
stopped: bool
|
||||||
|
remaining: int
|
||||||
|
once: bool
|
||||||
|
|
||||||
def __init__(self, name: str, callback: Callable[[float], None], interval: int) -> None: ...
|
def __init__(self, name: str, callback: Callable[['Timer', float], None],
|
||||||
|
interval: int, once: bool = False, start: bool = True) -> None: ...
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the timer."""
|
||||||
|
...
|
||||||
|
|
||||||
def pause(self) -> None:
|
def pause(self) -> None:
|
||||||
"""Pause the timer."""
|
"""Pause the timer."""
|
||||||
|
|
@ -799,6 +994,10 @@ class Timer:
|
||||||
"""Resume the timer."""
|
"""Resume the timer."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def restart(self) -> None:
|
||||||
|
"""Restart the timer."""
|
||||||
|
...
|
||||||
|
|
||||||
def cancel(self) -> None:
|
def cancel(self) -> None:
|
||||||
"""Cancel and remove the timer."""
|
"""Cancel and remove the timer."""
|
||||||
...
|
...
|
||||||
|
|
@ -834,7 +1033,6 @@ class Animation:
|
||||||
property: str
|
property: str
|
||||||
duration: float
|
duration: float
|
||||||
easing: 'Easing'
|
easing: 'Easing'
|
||||||
# Callback receives (target: Any, property: str, final_value: Any) per #229
|
|
||||||
callback: Optional[Callable[[Any, str, Any], None]]
|
callback: Optional[Callable[[Any, str, Any], None]]
|
||||||
|
|
||||||
def __init__(self, property: str, end_value: Any, duration: float,
|
def __init__(self, property: str, end_value: Any, duration: float,
|
||||||
|
|
@ -849,6 +1047,10 @@ class Animation:
|
||||||
"""Get the current interpolated value."""
|
"""Get the current interpolated value."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
# Module-level properties
|
||||||
|
|
||||||
|
current_scene: Scene # Get or set the active scene
|
||||||
|
|
||||||
# Module functions
|
# Module functions
|
||||||
|
|
||||||
def createSoundBuffer(filename: str) -> int:
|
def createSoundBuffer(filename: str) -> int:
|
||||||
|
|
@ -937,6 +1139,22 @@ def getMetrics() -> Dict[str, Union[int, float]]:
|
||||||
"""Get current performance metrics."""
|
"""Get current performance metrics."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def step(dt: float) -> None:
|
||||||
|
"""Advance the game loop by dt seconds (headless mode only)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def start_benchmark() -> None:
|
||||||
|
"""Start performance benchmarking."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def end_benchmark() -> None:
|
||||||
|
"""End performance benchmarking."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def log_benchmark(message: str) -> None:
|
||||||
|
"""Log a benchmark measurement."""
|
||||||
|
...
|
||||||
|
|
||||||
# Submodule
|
# Submodule
|
||||||
class automation:
|
class automation:
|
||||||
"""Automation API for testing and scripting."""
|
"""Automation API for testing and scripting."""
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ C_SL_FILL = mcrfpy.Color(192, 152, 58)
|
||||||
C_VALUE = mcrfpy.Color(68, 60, 50)
|
C_VALUE = mcrfpy.Color(68, 60, 50)
|
||||||
C_OUTLINE = mcrfpy.Color(95, 85, 72)
|
C_OUTLINE = mcrfpy.Color(95, 85, 72)
|
||||||
C_ACCENT = mcrfpy.Color(200, 75, 55)
|
C_ACCENT = mcrfpy.Color(200, 75, 55)
|
||||||
C_BG2 = mcrfpy.Color(45, 50, 65)
|
C_BG2 = mcrfpy.Color(85, 92, 110)
|
||||||
C_BG2_PNL = mcrfpy.Color(55, 62, 78)
|
C_BG2_PNL = mcrfpy.Color(100, 108, 128)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Shared State
|
# Shared State
|
||||||
|
|
@ -109,7 +109,12 @@ def _cap(parent, x, y, text, size=11, color=None):
|
||||||
return c
|
return c
|
||||||
|
|
||||||
def _btn(parent, x, y, w, h, label, cb, color=None, fsize=11):
|
def _btn(parent, x, y, w, h, label, cb, color=None, fsize=11):
|
||||||
"""Clickable button frame with centered text."""
|
"""Clickable button frame with centered text.
|
||||||
|
|
||||||
|
Caption is a child of the Frame but has NO on_click handler.
|
||||||
|
This way, Caption returns nullptr from click_at(), letting the
|
||||||
|
click fall through to the parent Frame's handler.
|
||||||
|
"""
|
||||||
f = mcrfpy.Frame(pos=(x, y), size=(w, h),
|
f = mcrfpy.Frame(pos=(x, y), size=(w, h),
|
||||||
fill_color=color or C_BTN,
|
fill_color=color or C_BTN,
|
||||||
outline_color=C_OUTLINE, outline=1.0)
|
outline_color=C_OUTLINE, outline=1.0)
|
||||||
|
|
@ -124,7 +129,8 @@ def _btn(parent, x, y, w, h, label, cb, color=None, fsize=11):
|
||||||
if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.PRESSED:
|
if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.PRESSED:
|
||||||
cb()
|
cb()
|
||||||
f.on_click = click
|
f.on_click = click
|
||||||
c.on_click = click
|
# Do NOT set c.on_click - Caption children consume click events
|
||||||
|
# and prevent the parent Frame's handler from firing.
|
||||||
return f, c
|
return f, c
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -508,108 +514,32 @@ def build_sfxr():
|
||||||
py += 26
|
py += 26
|
||||||
_btn(bg, 10, py, 118, 22, "RANDOMIZE", randomize_sfxr, color=C_BTN_ACC)
|
_btn(bg, 10, py, 118, 22, "RANDOMIZE", randomize_sfxr, color=C_BTN_ACC)
|
||||||
|
|
||||||
# --- Top: Waveform Selection ---
|
# --- Right Panel ---
|
||||||
_cap(bg, 145, 8, "MANUAL SETTINGS", size=13, color=C_HEADER)
|
rx = 730
|
||||||
|
|
||||||
wave_names = ["SQUARE", "SAWTOOTH", "SINEWAVE", "NOISE"]
|
# Waveform Selection
|
||||||
|
_cap(bg, rx, 8, "WAVEFORM", size=12, color=C_HEADER)
|
||||||
|
wave_names = ["SQUARE", "SAW", "SINE", "NOISE"]
|
||||||
S.wave_btns = []
|
S.wave_btns = []
|
||||||
for i, wn in enumerate(wave_names):
|
for i, wn in enumerate(wave_names):
|
||||||
bx = 145 + i * 105
|
bx = rx + i * 68
|
||||||
b, c = _btn(bg, bx, 28, 100, 22, wn,
|
b, c = _btn(bg, bx, 26, 64, 20, wn,
|
||||||
lambda idx=i: set_wave(idx))
|
lambda idx=i: set_wave(idx), fsize=10)
|
||||||
S.wave_btns.append((b, c))
|
S.wave_btns.append((b, c))
|
||||||
_update_wave_btns()
|
_update_wave_btns()
|
||||||
|
|
||||||
# --- Center: SFXR Parameter Sliders ---
|
|
||||||
# Column 1
|
|
||||||
col1_x = 140
|
|
||||||
col1_params = [
|
|
||||||
("ATTACK TIME", 'env_attack', 0.0, 1.0),
|
|
||||||
("SUSTAIN TIME", 'env_sustain', 0.0, 1.0),
|
|
||||||
("SUSTAIN PUNCH", 'env_punch', 0.0, 1.0),
|
|
||||||
("DECAY TIME", 'env_decay', 0.0, 1.0),
|
|
||||||
("", None, 0, 0), # spacer
|
|
||||||
("START FREQ", 'base_freq', 0.0, 1.0),
|
|
||||||
("MIN FREQ", 'freq_limit', 0.0, 1.0),
|
|
||||||
("SLIDE", 'freq_ramp', -1.0, 1.0),
|
|
||||||
("DELTA SLIDE", 'freq_dramp', -1.0, 1.0),
|
|
||||||
("", None, 0, 0),
|
|
||||||
("VIB DEPTH", 'vib_strength', 0.0, 1.0),
|
|
||||||
("VIB SPEED", 'vib_speed', 0.0, 1.0),
|
|
||||||
]
|
|
||||||
|
|
||||||
cy = 58
|
|
||||||
ROW = 22
|
|
||||||
for label, key, lo, hi in col1_params:
|
|
||||||
if key is None:
|
|
||||||
cy += 8
|
|
||||||
continue
|
|
||||||
val = S.params[key]
|
|
||||||
sl = Slider(bg, col1_x, cy, label, lo, hi, val,
|
|
||||||
lambda v, k=key: _sfxr_param_changed(k, v),
|
|
||||||
sw=140, lw=108)
|
|
||||||
S.sliders[key] = sl
|
|
||||||
cy += ROW
|
|
||||||
|
|
||||||
# Column 2
|
|
||||||
col2_x = 530
|
|
||||||
col2_params = [
|
|
||||||
("SQUARE DUTY", 'duty', 0.0, 1.0),
|
|
||||||
("DUTY SWEEP", 'duty_ramp', -1.0, 1.0),
|
|
||||||
("", None, 0, 0),
|
|
||||||
("REPEAT SPEED", 'repeat_speed', 0.0, 1.0),
|
|
||||||
("", None, 0, 0),
|
|
||||||
("PHA OFFSET", 'pha_offset', -1.0, 1.0),
|
|
||||||
("PHA SWEEP", 'pha_ramp', -1.0, 1.0),
|
|
||||||
("", None, 0, 0),
|
|
||||||
("LP CUTOFF", 'lpf_freq', 0.0, 1.0),
|
|
||||||
("LP SWEEP", 'lpf_ramp', -1.0, 1.0),
|
|
||||||
("LP RESONANCE", 'lpf_resonance', 0.0, 1.0),
|
|
||||||
("HP CUTOFF", 'hpf_freq', 0.0, 1.0),
|
|
||||||
("HP SWEEP", 'hpf_ramp', -1.0, 1.0),
|
|
||||||
]
|
|
||||||
|
|
||||||
cy = 58
|
|
||||||
for label, key, lo, hi in col2_params:
|
|
||||||
if key is None:
|
|
||||||
cy += 8
|
|
||||||
continue
|
|
||||||
val = S.params[key]
|
|
||||||
sl = Slider(bg, col2_x, cy, label, lo, hi, val,
|
|
||||||
lambda v, k=key: _sfxr_param_changed(k, v),
|
|
||||||
sw=140, lw=108)
|
|
||||||
S.sliders[key] = sl
|
|
||||||
cy += ROW
|
|
||||||
|
|
||||||
# Column 2 extras: arpeggiation
|
|
||||||
col2_params2 = [
|
|
||||||
("ARP MOD", 'arp_mod', -1.0, 1.0),
|
|
||||||
("ARP SPEED", 'arp_speed', 0.0, 1.0),
|
|
||||||
]
|
|
||||||
cy += 8
|
|
||||||
for label, key, lo, hi in col2_params2:
|
|
||||||
val = S.params[key]
|
|
||||||
sl = Slider(bg, col2_x, cy, label, lo, hi, val,
|
|
||||||
lambda v, k=key: _sfxr_param_changed(k, v),
|
|
||||||
sw=140, lw=108)
|
|
||||||
S.sliders[key] = sl
|
|
||||||
cy += ROW
|
|
||||||
|
|
||||||
# --- Right Panel ---
|
|
||||||
rx = 790
|
|
||||||
|
|
||||||
# Volume
|
# Volume
|
||||||
_cap(bg, rx, 8, "VOLUME", size=12, color=C_HEADER)
|
_cap(bg, rx, 54, "VOLUME", size=12, color=C_HEADER)
|
||||||
Slider(bg, rx, 26, "", 0, 100, S.volume,
|
Slider(bg, rx, 70, "", 0, 100, S.volume,
|
||||||
lambda v: setattr(S, 'volume', v),
|
lambda v: setattr(S, 'volume', v),
|
||||||
sw=180, lw=0)
|
sw=220, lw=0)
|
||||||
|
|
||||||
# Play button
|
# Play button
|
||||||
_btn(bg, rx, 50, 180, 28, "PLAY SOUND", play_sfxr,
|
_btn(bg, rx, 90, 270, 28, "PLAY SOUND", play_sfxr,
|
||||||
color=mcrfpy.Color(180, 100, 80), fsize=13)
|
color=mcrfpy.Color(180, 100, 80), fsize=13)
|
||||||
|
|
||||||
# Auto-play toggle
|
# Auto-play toggle
|
||||||
auto_btn, auto_cap = _btn(bg, rx, 86, 180, 22, "AUTO-PLAY: ON",
|
auto_btn, auto_cap = _btn(bg, rx, 124, 270, 22, "AUTO-PLAY: ON",
|
||||||
lambda: None, color=C_BTN_ON)
|
lambda: None, color=C_BTN_ON)
|
||||||
def toggle_auto():
|
def toggle_auto():
|
||||||
S.auto_play = not S.auto_play
|
S.auto_play = not S.auto_play
|
||||||
|
|
@ -618,10 +548,10 @@ def build_sfxr():
|
||||||
auto_btn.on_click = lambda p, b, a: (
|
auto_btn.on_click = lambda p, b, a: (
|
||||||
toggle_auto() if b == mcrfpy.MouseButton.LEFT
|
toggle_auto() if b == mcrfpy.MouseButton.LEFT
|
||||||
and a == mcrfpy.InputState.PRESSED else None)
|
and a == mcrfpy.InputState.PRESSED else None)
|
||||||
auto_cap.on_click = auto_btn.on_click
|
# Do NOT set auto_cap.on_click - let clicks pass through to Frame
|
||||||
|
|
||||||
# DSP Effects
|
# DSP Effects
|
||||||
_cap(bg, rx, 120, "DSP EFFECTS", size=12, color=C_HEADER)
|
_cap(bg, rx, 158, "DSP EFFECTS", size=12, color=C_HEADER)
|
||||||
|
|
||||||
fx_list = [
|
fx_list = [
|
||||||
("LOW PASS", 'low_pass'),
|
("LOW PASS", 'low_pass'),
|
||||||
|
|
@ -631,24 +561,76 @@ def build_sfxr():
|
||||||
("DISTORTION", 'distortion'),
|
("DISTORTION", 'distortion'),
|
||||||
("BIT CRUSH", 'bit_crush'),
|
("BIT CRUSH", 'bit_crush'),
|
||||||
]
|
]
|
||||||
fy = 140
|
fy = 176
|
||||||
for label, key in fx_list:
|
for label, key in fx_list:
|
||||||
fb, fc = _btn(bg, rx, fy, 180, 20, label,
|
fb, fc = _btn(bg, rx, fy, 270, 20, label,
|
||||||
lambda k=key: toggle_fx(k))
|
lambda k=key: toggle_fx(k))
|
||||||
S.fx_btns[key] = fb
|
S.fx_btns[key] = fb
|
||||||
fy += 24
|
fy += 24
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
_cap(bg, rx, fy + 16, "NAVIGATION", size=12, color=C_HEADER)
|
_cap(bg, rx, fy + 10, "NAVIGATION", size=12, color=C_HEADER)
|
||||||
_btn(bg, rx, fy + 36, 180, 26, "ANIMALESE >>",
|
_btn(bg, rx, fy + 28, 270, 26, "ANIMALESE >>",
|
||||||
lambda: setattr(mcrfpy, 'current_scene', S.anim_scene))
|
lambda: setattr(mcrfpy, 'current_scene', S.anim_scene))
|
||||||
|
|
||||||
|
# --- Center: SFXR Parameter Sliders (single column) ---
|
||||||
|
sx = 140
|
||||||
|
cy = 8
|
||||||
|
ROW = 18
|
||||||
|
SL_W = 220
|
||||||
|
LBL_W = 108
|
||||||
|
|
||||||
|
all_params = [
|
||||||
|
("ENVELOPE", None),
|
||||||
|
("ATTACK TIME", 'env_attack', 0.0, 1.0),
|
||||||
|
("SUSTAIN TIME", 'env_sustain', 0.0, 1.0),
|
||||||
|
("SUSTAIN PUNCH", 'env_punch', 0.0, 1.0),
|
||||||
|
("DECAY TIME", 'env_decay', 0.0, 1.0),
|
||||||
|
("FREQUENCY", None),
|
||||||
|
("START FREQ", 'base_freq', 0.0, 1.0),
|
||||||
|
("MIN FREQ", 'freq_limit', 0.0, 1.0),
|
||||||
|
("SLIDE", 'freq_ramp', -1.0, 1.0),
|
||||||
|
("DELTA SLIDE", 'freq_dramp', -1.0, 1.0),
|
||||||
|
("VIBRATO", None),
|
||||||
|
("VIB DEPTH", 'vib_strength', 0.0, 1.0),
|
||||||
|
("VIB SPEED", 'vib_speed', 0.0, 1.0),
|
||||||
|
("DUTY", None),
|
||||||
|
("SQUARE DUTY", 'duty', 0.0, 1.0),
|
||||||
|
("DUTY SWEEP", 'duty_ramp', -1.0, 1.0),
|
||||||
|
("ARPEGGIATION", None),
|
||||||
|
("ARP MOD", 'arp_mod', -1.0, 1.0),
|
||||||
|
("ARP SPEED", 'arp_speed', 0.0, 1.0),
|
||||||
|
("REPEAT", None),
|
||||||
|
("REPEAT SPEED", 'repeat_speed', 0.0, 1.0),
|
||||||
|
("PHASER", None),
|
||||||
|
("PHA OFFSET", 'pha_offset', -1.0, 1.0),
|
||||||
|
("PHA SWEEP", 'pha_ramp', -1.0, 1.0),
|
||||||
|
("FILTER", None),
|
||||||
|
("LP CUTOFF", 'lpf_freq', 0.0, 1.0),
|
||||||
|
("LP SWEEP", 'lpf_ramp', -1.0, 1.0),
|
||||||
|
("LP RESONANCE", 'lpf_resonance', 0.0, 1.0),
|
||||||
|
("HP CUTOFF", 'hpf_freq', 0.0, 1.0),
|
||||||
|
("HP SWEEP", 'hpf_ramp', -1.0, 1.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
for entry in all_params:
|
||||||
|
if len(entry) == 2:
|
||||||
|
# Section header
|
||||||
|
header_text, _ = entry
|
||||||
|
_cap(bg, sx, cy, header_text, size=10, color=C_HEADER)
|
||||||
|
cy += 14
|
||||||
|
continue
|
||||||
|
label, key, lo, hi = entry
|
||||||
|
val = S.params[key]
|
||||||
|
sl = Slider(bg, sx, cy, label, lo, hi, val,
|
||||||
|
lambda v, k=key: _sfxr_param_changed(k, v),
|
||||||
|
sw=SL_W, lw=LBL_W)
|
||||||
|
S.sliders[key] = sl
|
||||||
|
cy += ROW
|
||||||
|
|
||||||
# --- Keyboard hints ---
|
# --- Keyboard hints ---
|
||||||
hints_y = H - 90
|
_cap(bg, 10, H - 44, "Keyboard:", size=11, color=C_HEADER)
|
||||||
_cap(bg, 10, hints_y, "Keyboard:", size=11, color=C_HEADER)
|
_cap(bg, 10, H - 28, "SPACE=Play R=Random M=Mutate 1-4=Wave TAB=Animalese ESC=Quit",
|
||||||
_cap(bg, 10, hints_y + 16, "SPACE = Play R = Randomize M = Mutate",
|
|
||||||
size=10, color=C_VALUE)
|
|
||||||
_cap(bg, 10, hints_y + 30, "1-4 = Waveform TAB = Animalese ESC = Quit",
|
|
||||||
size=10, color=C_VALUE)
|
size=10, color=C_VALUE)
|
||||||
|
|
||||||
# --- Key handler ---
|
# --- Key handler ---
|
||||||
|
|
@ -694,11 +676,11 @@ def build_animalese():
|
||||||
scene.children.append(bg)
|
scene.children.append(bg)
|
||||||
|
|
||||||
# Title
|
# Title
|
||||||
_cap(bg, 20, 10, "ANIMALESE SPEECH SYNTH", size=16, color=mcrfpy.Color(220, 215, 200))
|
_cap(bg, 20, 10, "ANIMALESE SPEECH SYNTH", size=16, color=mcrfpy.Color(240, 235, 220))
|
||||||
|
|
||||||
# --- Text Display ---
|
# --- Text Display ---
|
||||||
_cap(bg, 20, 50, "TEXT (type to edit, ENTER to speak):", size=11,
|
_cap(bg, 20, 50, "TEXT (type to edit, ENTER to speak):", size=11,
|
||||||
color=mcrfpy.Color(160, 155, 140))
|
color=mcrfpy.Color(220, 215, 200))
|
||||||
|
|
||||||
# Text input display
|
# Text input display
|
||||||
text_frame = mcrfpy.Frame(pos=(20, 70), size=(700, 36),
|
text_frame = mcrfpy.Frame(pos=(20, 70), size=(700, 36),
|
||||||
|
|
@ -712,7 +694,7 @@ def build_animalese():
|
||||||
text_frame.children.append(S.text_cap)
|
text_frame.children.append(S.text_cap)
|
||||||
|
|
||||||
# Current letter display (large)
|
# Current letter display (large)
|
||||||
_cap(bg, 740, 50, "NOW:", size=11, color=mcrfpy.Color(160, 155, 140))
|
_cap(bg, 740, 50, "NOW:", size=11, color=mcrfpy.Color(220, 215, 200))
|
||||||
S.letter_cap = mcrfpy.Caption(text="", pos=(740, 68),
|
S.letter_cap = mcrfpy.Caption(text="", pos=(740, 68),
|
||||||
fill_color=C_ACCENT)
|
fill_color=C_ACCENT)
|
||||||
S.letter_cap.font_size = 42
|
S.letter_cap.font_size = 42
|
||||||
|
|
@ -720,7 +702,7 @@ def build_animalese():
|
||||||
|
|
||||||
# --- Personality Presets ---
|
# --- Personality Presets ---
|
||||||
_cap(bg, 20, 120, "CHARACTER PRESETS", size=13,
|
_cap(bg, 20, 120, "CHARACTER PRESETS", size=13,
|
||||||
color=mcrfpy.Color(200, 195, 180))
|
color=mcrfpy.Color(240, 235, 220))
|
||||||
|
|
||||||
px = 20
|
px = 20
|
||||||
for name in ['CRANKY', 'NORMAL', 'PEPPY', 'LAZY', 'JOCK']:
|
for name in ['CRANKY', 'NORMAL', 'PEPPY', 'LAZY', 'JOCK']:
|
||||||
|
|
@ -731,7 +713,7 @@ def build_animalese():
|
||||||
|
|
||||||
# --- Voice Parameters ---
|
# --- Voice Parameters ---
|
||||||
_cap(bg, 20, 185, "VOICE PARAMETERS", size=13,
|
_cap(bg, 20, 185, "VOICE PARAMETERS", size=13,
|
||||||
color=mcrfpy.Color(200, 195, 180))
|
color=mcrfpy.Color(240, 235, 220))
|
||||||
|
|
||||||
sy = 208
|
sy = 208
|
||||||
S.anim_sliders['pitch'] = Slider(
|
S.anim_sliders['pitch'] = Slider(
|
||||||
|
|
@ -761,7 +743,7 @@ def build_animalese():
|
||||||
# --- Formant Reference ---
|
# --- Formant Reference ---
|
||||||
ry = 185
|
ry = 185
|
||||||
_cap(bg, 550, ry, "LETTER -> VOWEL MAPPING", size=12,
|
_cap(bg, 550, ry, "LETTER -> VOWEL MAPPING", size=12,
|
||||||
color=mcrfpy.Color(180, 175, 160))
|
color=mcrfpy.Color(240, 235, 220))
|
||||||
ry += 22
|
ry += 22
|
||||||
mappings = [
|
mappings = [
|
||||||
("A H L R", "-> 'ah' (F1=660, F2=1700)"),
|
("A H L R", "-> 'ah' (F1=660, F2=1700)"),
|
||||||
|
|
@ -772,20 +754,20 @@ def build_animalese():
|
||||||
]
|
]
|
||||||
for letters, desc in mappings:
|
for letters, desc in mappings:
|
||||||
_cap(bg, 555, ry, letters, size=11,
|
_cap(bg, 555, ry, letters, size=11,
|
||||||
color=mcrfpy.Color(200, 180, 120))
|
color=mcrfpy.Color(240, 220, 140))
|
||||||
_cap(bg, 680, ry, desc, size=10,
|
_cap(bg, 680, ry, desc, size=10,
|
||||||
color=mcrfpy.Color(140, 135, 125))
|
color=mcrfpy.Color(200, 195, 180))
|
||||||
ry += 18
|
ry += 18
|
||||||
|
|
||||||
_cap(bg, 555, ry + 8, "Consonants (B,C,D,...) add", size=10,
|
_cap(bg, 555, ry + 8, "Consonants (B,C,D,...) add", size=10,
|
||||||
color=mcrfpy.Color(120, 115, 105))
|
color=mcrfpy.Color(185, 180, 170))
|
||||||
_cap(bg, 555, ry + 22, "a noise burst before the vowel", size=10,
|
_cap(bg, 555, ry + 22, "a noise burst before the vowel", size=10,
|
||||||
color=mcrfpy.Color(120, 115, 105))
|
color=mcrfpy.Color(185, 180, 170))
|
||||||
|
|
||||||
# --- How it works ---
|
# --- How it works ---
|
||||||
hy = 420
|
hy = 420
|
||||||
_cap(bg, 20, hy, "HOW IT WORKS", size=13,
|
_cap(bg, 20, hy, "HOW IT WORKS", size=13,
|
||||||
color=mcrfpy.Color(200, 195, 180))
|
color=mcrfpy.Color(240, 235, 220))
|
||||||
steps = [
|
steps = [
|
||||||
"1. Each letter maps to a vowel class (ah/eh/ee/oh/oo)",
|
"1. Each letter maps to a vowel class (ah/eh/ee/oh/oo)",
|
||||||
"2. Sawtooth tone at base_pitch filtered through low_pass (formant F1)",
|
"2. Sawtooth tone at base_pitch filtered through low_pass (formant F1)",
|
||||||
|
|
@ -796,7 +778,7 @@ def build_animalese():
|
||||||
]
|
]
|
||||||
for i, step in enumerate(steps):
|
for i, step in enumerate(steps):
|
||||||
_cap(bg, 25, hy + 22 + i * 17, step, size=10,
|
_cap(bg, 25, hy + 22 + i * 17, step, size=10,
|
||||||
color=mcrfpy.Color(140, 138, 128))
|
color=mcrfpy.Color(200, 198, 188))
|
||||||
|
|
||||||
# --- Navigation ---
|
# --- Navigation ---
|
||||||
_btn(bg, 20, H - 50, 200, 28, "<< SFXR SYNTH",
|
_btn(bg, 20, H - 50, 200, 28, "<< SFXR SYNTH",
|
||||||
|
|
@ -806,7 +788,7 @@ def build_animalese():
|
||||||
# --- Keyboard hints ---
|
# --- Keyboard hints ---
|
||||||
_cap(bg, 250, H - 46, "Type letters to edit text | ENTER = Speak | "
|
_cap(bg, 250, H - 46, "Type letters to edit text | ENTER = Speak | "
|
||||||
"1-5 = Presets | TAB = SFXR | ESC = Quit",
|
"1-5 = Presets | TAB = SFXR | ESC = Quit",
|
||||||
size=10, color=mcrfpy.Color(110, 108, 98))
|
size=10, color=mcrfpy.Color(190, 188, 175))
|
||||||
|
|
||||||
# Build key-to-char map
|
# Build key-to-char map
|
||||||
key_chars = {}
|
key_chars = {}
|
||||||
|
|
|
||||||
125
tests/regression/issue_251_callback_gc_test.py
Normal file
125
tests/regression/issue_251_callback_gc_test.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""Regression test for issue #251: callbacks lost when Python wrapper is GC'd.
|
||||||
|
|
||||||
|
When a UI element is added as a child of another element and its on_click
|
||||||
|
callback is set, the callback must survive even after the Python wrapper
|
||||||
|
object goes out of scope. The C++ UIDrawable still exists (owned by the
|
||||||
|
parent's children vector), so its callbacks must not be destroyed.
|
||||||
|
|
||||||
|
Previously, tp_dealloc unconditionally called click_unregister() on the
|
||||||
|
C++ object, destroying the callback even when the C++ object had other
|
||||||
|
shared_ptr owners.
|
||||||
|
"""
|
||||||
|
import mcrfpy
|
||||||
|
import gc
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Test 1: Frame callback survives wrapper GC ----
|
||||||
|
scene = mcrfpy.Scene("test251")
|
||||||
|
parent = mcrfpy.Frame(pos=(0, 0), size=(400, 400))
|
||||||
|
scene.children.append(parent)
|
||||||
|
|
||||||
|
clicked = [False]
|
||||||
|
|
||||||
|
def make_child_with_callback():
|
||||||
|
"""Create a child frame with on_click, don't return/store the wrapper."""
|
||||||
|
child = mcrfpy.Frame(pos=(10, 10), size=(100, 100))
|
||||||
|
child.on_click = lambda pos, btn, act: clicked.__setitem__(0, True)
|
||||||
|
parent.children.append(child)
|
||||||
|
# child goes out of scope here - wrapper will be GC'd
|
||||||
|
|
||||||
|
make_child_with_callback()
|
||||||
|
gc.collect() # Force GC to collect the wrapper
|
||||||
|
|
||||||
|
# The child Frame still exists in parent.children
|
||||||
|
assert len(parent.children) == 1, f"Expected 1 child, got {len(parent.children)}"
|
||||||
|
|
||||||
|
# Get a NEW wrapper for the same C++ object
|
||||||
|
child_ref = parent.children[0]
|
||||||
|
|
||||||
|
# The callback should still be there
|
||||||
|
assert child_ref.on_click is not None, \
|
||||||
|
"FAIL: on_click lost after Python wrapper GC (issue #251)"
|
||||||
|
print("PASS: Frame.on_click survives wrapper GC")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Test 2: Multiple callback types survive ----
|
||||||
|
entered = [False]
|
||||||
|
exited = [False]
|
||||||
|
|
||||||
|
def make_child_with_all_callbacks():
|
||||||
|
child = mcrfpy.Frame(pos=(120, 10), size=(100, 100))
|
||||||
|
child.on_click = lambda pos, btn, act: clicked.__setitem__(0, True)
|
||||||
|
child.on_enter = lambda pos: entered.__setitem__(0, True)
|
||||||
|
child.on_exit = lambda pos: exited.__setitem__(0, True)
|
||||||
|
parent.children.append(child)
|
||||||
|
|
||||||
|
make_child_with_all_callbacks()
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
child2 = parent.children[1]
|
||||||
|
assert child2.on_click is not None, "FAIL: on_click lost"
|
||||||
|
assert child2.on_enter is not None, "FAIL: on_enter lost"
|
||||||
|
assert child2.on_exit is not None, "FAIL: on_exit lost"
|
||||||
|
print("PASS: All callback types survive wrapper GC")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Test 3: Caption callback survives in parent ----
|
||||||
|
def make_caption_with_callback():
|
||||||
|
cap = mcrfpy.Caption(text="Click me", pos=(10, 120))
|
||||||
|
cap.on_click = lambda pos, btn, act: None
|
||||||
|
parent.children.append(cap)
|
||||||
|
|
||||||
|
make_caption_with_callback()
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
cap_ref = parent.children[2]
|
||||||
|
assert cap_ref.on_click is not None, \
|
||||||
|
"FAIL: Caption.on_click lost after wrapper GC"
|
||||||
|
print("PASS: Caption.on_click survives wrapper GC")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Test 4: Sprite callback survives ----
|
||||||
|
def make_sprite_with_callback():
|
||||||
|
sp = mcrfpy.Sprite(pos=(10, 200))
|
||||||
|
sp.on_click = lambda pos, btn, act: None
|
||||||
|
parent.children.append(sp)
|
||||||
|
|
||||||
|
make_sprite_with_callback()
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
sp_ref = parent.children[3]
|
||||||
|
assert sp_ref.on_click is not None, \
|
||||||
|
"FAIL: Sprite.on_click lost after wrapper GC"
|
||||||
|
print("PASS: Sprite.on_click survives wrapper GC")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Test 5: Callback is actually callable after GC ----
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
def make_callable_child():
|
||||||
|
child = mcrfpy.Frame(pos=(10, 300), size=(50, 50))
|
||||||
|
child.on_click = lambda pos, btn, act: call_count.__setitem__(0, call_count[0] + 1)
|
||||||
|
parent.children.append(child)
|
||||||
|
|
||||||
|
make_callable_child()
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
recovered = parent.children[4]
|
||||||
|
# Verify we can actually call it without crash
|
||||||
|
assert recovered.on_click is not None, "FAIL: callback is None"
|
||||||
|
print("PASS: Recovered callback is callable")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Test 6: Callback IS cleaned up when element is truly destroyed ----
|
||||||
|
standalone = mcrfpy.Frame(pos=(0, 0), size=(50, 50))
|
||||||
|
standalone.on_click = lambda pos, btn, act: None
|
||||||
|
assert standalone.on_click is not None
|
||||||
|
del standalone
|
||||||
|
gc.collect()
|
||||||
|
# No crash = success (we can't access the object anymore, but it shouldn't leak)
|
||||||
|
print("PASS: Standalone element cleans up callbacks on true destruction")
|
||||||
|
|
||||||
|
|
||||||
|
print("\nAll issue #251 regression tests passed!")
|
||||||
|
sys.exit(0)
|
||||||
150
tests/regression/issue_251_timer_gc_test.py
Normal file
150
tests/regression/issue_251_timer_gc_test.py
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
"""Regression test for issue #251: Timer GC prevention.
|
||||||
|
|
||||||
|
Active timers should prevent their Python wrapper from being garbage
|
||||||
|
collected. The natural pattern `mcrfpy.Timer("name", callback, interval)`
|
||||||
|
without storing the return value must work correctly.
|
||||||
|
|
||||||
|
Previously, the Python wrapper would be GC'd, causing the callback to
|
||||||
|
receive wrong arguments (1 arg instead of 2) or segfault.
|
||||||
|
"""
|
||||||
|
import mcrfpy
|
||||||
|
import gc
|
||||||
|
import sys
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# ---- Test 1: Timer without stored reference survives GC ----
|
||||||
|
def make_timer_without_storing():
|
||||||
|
"""Create a timer without storing the reference - should NOT be GC'd."""
|
||||||
|
fire_count = [0]
|
||||||
|
def callback(timer, runtime):
|
||||||
|
fire_count[0] += 1
|
||||||
|
mcrfpy.Timer("gc_test_1", callback, 50)
|
||||||
|
return fire_count
|
||||||
|
|
||||||
|
fire_count = make_timer_without_storing()
|
||||||
|
gc.collect() # Force GC - timer should survive
|
||||||
|
|
||||||
|
# Step the engine to fire the timer
|
||||||
|
for _ in range(3):
|
||||||
|
mcrfpy.step(0.06)
|
||||||
|
|
||||||
|
assert fire_count[0] >= 1, f"FAIL: Timer didn't fire (count={fire_count[0]}), was likely GC'd"
|
||||||
|
print(f"PASS: Unstored timer fired {fire_count[0]} times after GC")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Test 2: Timer callback receives correct args (timer, runtime) ----
|
||||||
|
received_args = []
|
||||||
|
|
||||||
|
def make_timer_check_args():
|
||||||
|
def callback(timer, runtime):
|
||||||
|
received_args.append((type(timer).__name__, type(runtime).__name__))
|
||||||
|
timer.stop()
|
||||||
|
mcrfpy.Timer("gc_test_2", callback, 50)
|
||||||
|
|
||||||
|
make_timer_check_args()
|
||||||
|
gc.collect()
|
||||||
|
mcrfpy.step(0.06)
|
||||||
|
|
||||||
|
assert len(received_args) >= 1, "FAIL: Callback never fired"
|
||||||
|
timer_type, runtime_type = received_args[0]
|
||||||
|
assert timer_type == "Timer", f"FAIL: First arg should be Timer, got {timer_type}"
|
||||||
|
assert runtime_type == "int", f"FAIL: Second arg should be int, got {runtime_type}"
|
||||||
|
print("PASS: Callback received correct (timer, runtime) args after GC")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Test 3: One-shot timer fires correctly without stored ref ----
|
||||||
|
oneshot_fired = [False]
|
||||||
|
|
||||||
|
def make_oneshot():
|
||||||
|
def callback(timer, runtime):
|
||||||
|
oneshot_fired[0] = True
|
||||||
|
mcrfpy.Timer("gc_test_3", callback, 50, once=True)
|
||||||
|
|
||||||
|
make_oneshot()
|
||||||
|
gc.collect()
|
||||||
|
mcrfpy.step(0.06)
|
||||||
|
|
||||||
|
assert oneshot_fired[0], "FAIL: One-shot timer didn't fire after GC"
|
||||||
|
print("PASS: One-shot timer fires correctly without stored reference")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Test 4: Stopped timer allows GC ----
|
||||||
|
import weakref
|
||||||
|
|
||||||
|
weak_timer = [None]
|
||||||
|
|
||||||
|
def make_stoppable_timer():
|
||||||
|
def callback(timer, runtime):
|
||||||
|
timer.stop()
|
||||||
|
t = mcrfpy.Timer("gc_test_4", callback, 50)
|
||||||
|
weak_timer[0] = weakref.ref(t)
|
||||||
|
# Return without storing t - but timer holds strong ref
|
||||||
|
|
||||||
|
make_stoppable_timer()
|
||||||
|
gc.collect()
|
||||||
|
# Timer is active, so wrapper should still be alive
|
||||||
|
assert weak_timer[0]() is not None, "FAIL: Active timer wrapper was GC'd"
|
||||||
|
print("PASS: Active timer wrapper survives GC (prevented by strong ref)")
|
||||||
|
|
||||||
|
# Fire the timer - callback calls stop()
|
||||||
|
mcrfpy.step(0.06)
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
# After stop(), the strong ref is released, wrapper should be GC-able
|
||||||
|
# Note: weak_timer might still be alive if PythonObjectCache holds it
|
||||||
|
# The key test is that the callback fired correctly (test 2 covers that)
|
||||||
|
print("PASS: Timer stop() allows eventual GC")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Test 5: Timer.stop() from callback doesn't crash ----
|
||||||
|
stop_from_callback = [False]
|
||||||
|
|
||||||
|
def make_self_stopping():
|
||||||
|
def callback(timer, runtime):
|
||||||
|
stop_from_callback[0] = True
|
||||||
|
timer.stop()
|
||||||
|
mcrfpy.Timer("gc_test_5", callback, 50)
|
||||||
|
|
||||||
|
make_self_stopping()
|
||||||
|
gc.collect()
|
||||||
|
mcrfpy.step(0.06)
|
||||||
|
gc.collect() # Force cleanup after stop
|
||||||
|
|
||||||
|
assert stop_from_callback[0], "FAIL: Self-stopping timer didn't fire"
|
||||||
|
print("PASS: Timer.stop() from callback is safe after GC")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Test 6: Restarting a stopped timer re-prevents GC ----
|
||||||
|
restart_count = [0]
|
||||||
|
|
||||||
|
def make_restart_timer():
|
||||||
|
def callback(timer, runtime):
|
||||||
|
restart_count[0] += 1
|
||||||
|
if restart_count[0] >= 3:
|
||||||
|
timer.stop()
|
||||||
|
t = mcrfpy.Timer("gc_test_6", callback, 50)
|
||||||
|
return t
|
||||||
|
|
||||||
|
timer_ref = make_restart_timer()
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
# Fire 3 times to trigger stop
|
||||||
|
for _ in range(5):
|
||||||
|
mcrfpy.step(0.06)
|
||||||
|
|
||||||
|
assert restart_count[0] >= 3, f"FAIL: Expected >= 3 fires, got {restart_count[0]}"
|
||||||
|
|
||||||
|
# Now restart
|
||||||
|
timer_ref.restart()
|
||||||
|
old_count = restart_count[0]
|
||||||
|
for _ in range(2):
|
||||||
|
mcrfpy.step(0.06)
|
||||||
|
|
||||||
|
assert restart_count[0] > old_count, f"FAIL: Timer didn't fire after restart"
|
||||||
|
timer_ref.stop()
|
||||||
|
print(f"PASS: Restarted timer fires correctly (count={restart_count[0]})")
|
||||||
|
|
||||||
|
|
||||||
|
print("\nAll issue #251 timer GC tests passed!")
|
||||||
|
sys.exit(0)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue