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:
John McCardle 2026-02-19 20:53:50 -05:00
commit 9718153709
15 changed files with 740 additions and 231 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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."""
@ -95,40 +176,40 @@ class Easing:
class Color: class Color:
"""SFML Color Object for RGBA colors.""" """SFML Color Object for RGBA colors."""
r: int r: int
g: int g: int
b: int b: int
a: int a: int
@overload @overload
def __init__(self) -> None: ... def __init__(self) -> None: ...
@overload @overload
def __init__(self, r: int, g: int, b: int, a: int = 255) -> None: ... def __init__(self, r: int, g: int, b: int, a: int = 255) -> None: ...
def from_hex(self, hex_string: str) -> 'Color': def from_hex(self, hex_string: str) -> 'Color':
"""Create color from hex string (e.g., '#FF0000' or 'FF0000').""" """Create color from hex string (e.g., '#FF0000' or 'FF0000')."""
... ...
def to_hex(self) -> str: def to_hex(self) -> str:
"""Convert color to hex string format.""" """Convert color to hex string format."""
... ...
def lerp(self, other: 'Color', t: float) -> 'Color': def lerp(self, other: 'Color', t: float) -> 'Color':
"""Linear interpolation between two colors.""" """Linear interpolation between two colors."""
... ...
class Vector: class Vector:
"""SFML Vector Object for 2D coordinates.""" """SFML Vector Object for 2D coordinates."""
x: float x: float
y: float y: float
@overload @overload
def __init__(self) -> None: ... def __init__(self) -> None: ...
@overload @overload
def __init__(self, x: float, y: float) -> None: ... def __init__(self, x: float, y: float) -> None: ...
def add(self, other: 'Vector') -> 'Vector': ... def add(self, other: 'Vector') -> 'Vector': ...
def subtract(self, other: 'Vector') -> 'Vector': ... def subtract(self, other: 'Vector') -> 'Vector': ...
def multiply(self, scalar: float) -> 'Vector': ... def multiply(self, scalar: float) -> 'Vector': ...
@ -138,23 +219,89 @@ 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."""
def __init__(self, filename: str) -> None: ... def __init__(self, filename: str) -> None: ...
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."""
... ...
@ -672,37 +851,38 @@ class BSP:
class Entity(Drawable): class Entity(Drawable):
"""Entity(grid_x=0, grid_y=0, texture=None, sprite_index=0, name='') """Entity(grid_x=0, grid_y=0, texture=None, sprite_index=0, name='')
Game entity that lives within a Grid. Game entity that lives within a Grid.
""" """
@overload @overload
def __init__(self) -> None: ... def __init__(self) -> None: ...
@overload @overload
def __init__(self, grid_x: float = 0, grid_y: float = 0, texture: Optional[Texture] = None, def __init__(self, grid_x: float = 0, grid_y: float = 0, texture: Optional[Texture] = None,
sprite_index: int = 0, name: str = '') -> None: ... sprite_index: int = 0, name: str = '') -> None: ...
grid_x: float grid_x: float
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:
"""Move entity to grid position.""" """Move entity to grid position."""
... ...
def die(self) -> None: def die(self) -> None:
"""Remove entity from its grid.""" """Remove entity from its grid."""
... ...
def index(self) -> int: def index(self) -> int:
"""Get index in parent grid's entity collection.""" """Get index in parent grid's entity collection."""
... ...
class UICollection: class UICollection:
"""Collection of UI drawable elements (Frame, Caption, Sprite, Grid, Line, Circle, Arc).""" """Collection of UI drawable elements (Frame, Caption, Sprite, Grid, Line, Circle, Arc)."""
def __len__(self) -> int: ... def __len__(self) -> int: ...
def __getitem__(self, index: int) -> UIElement: ... def __getitem__(self, index: int) -> UIElement: ...
def __setitem__(self, index: int, value: UIElement) -> None: ... def __setitem__(self, index: int, value: UIElement) -> None: ...
@ -711,16 +891,17 @@ class UICollection:
def __iter__(self) -> Any: ... def __iter__(self) -> Any: ...
def __add__(self, other: 'UICollection') -> 'UICollection': ... def __add__(self, other: 'UICollection') -> 'UICollection': ...
def __iadd__(self, other: 'UICollection') -> 'UICollection': ... def __iadd__(self, other: 'UICollection') -> '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: ...
class EntityCollection: class EntityCollection:
"""Collection of Entity objects.""" """Collection of Entity objects."""
def __len__(self) -> int: ... def __len__(self) -> int: ...
def __getitem__(self, index: int) -> Entity: ... def __getitem__(self, index: int) -> Entity: ...
def __setitem__(self, index: int, value: Entity) -> None: ... def __setitem__(self, index: int, value: Entity) -> None: ...
@ -729,9 +910,10 @@ class EntityCollection:
def __iter__(self) -> Any: ... def __iter__(self) -> Any: ...
def __add__(self, other: 'EntityCollection') -> 'EntityCollection': ... def __add__(self, other: 'EntityCollection') -> 'EntityCollection': ...
def __iadd__(self, other: 'EntityCollection') -> 'EntityCollection': ... def __iadd__(self, other: 'EntityCollection') -> '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,29 +965,46 @@ 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
def __init__(self, name: str, callback: Callable[[float], None], interval: int) -> None: ... stopped: bool
remaining: int
once: bool
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."""
... ...
def resume(self) -> None: def resume(self) -> None:
"""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."""
... ...
class Window: class Window:
"""Window singleton for managing the game window.""" """Window singleton for managing the game window."""
resolution: Tuple[int, int] resolution: Tuple[int, int]
fullscreen: bool fullscreen: bool
vsync: bool vsync: bool
@ -813,7 +1012,7 @@ class Window:
fps_limit: int fps_limit: int
game_resolution: Tuple[int, int] game_resolution: Tuple[int, int]
scaling_mode: str scaling_mode: str
@staticmethod @staticmethod
def get() -> 'Window': def get() -> 'Window':
"""Get the window singleton instance.""" """Get the window singleton instance."""
@ -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,81 +1139,97 @@ 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."""
@staticmethod @staticmethod
def screenshot(filename: str) -> bool: def screenshot(filename: str) -> bool:
"""Save a screenshot to the specified file.""" """Save a screenshot to the specified file."""
... ...
@staticmethod @staticmethod
def position() -> Tuple[int, int]: def position() -> Tuple[int, int]:
"""Get current mouse position as (x, y) tuple.""" """Get current mouse position as (x, y) tuple."""
... ...
@staticmethod @staticmethod
def size() -> Tuple[int, int]: def size() -> Tuple[int, int]:
"""Get screen size as (width, height) tuple.""" """Get screen size as (width, height) tuple."""
... ...
@staticmethod @staticmethod
def onScreen(x: int, y: int) -> bool: def onScreen(x: int, y: int) -> bool:
"""Check if coordinates are within screen bounds.""" """Check if coordinates are within screen bounds."""
... ...
@staticmethod @staticmethod
def moveTo(x: int, y: int, duration: float = 0.0) -> None: def moveTo(x: int, y: int, duration: float = 0.0) -> None:
"""Move mouse to absolute position.""" """Move mouse to absolute position."""
... ...
@staticmethod @staticmethod
def moveRel(xOffset: int, yOffset: int, duration: float = 0.0) -> None: def moveRel(xOffset: int, yOffset: int, duration: float = 0.0) -> None:
"""Move mouse relative to current position.""" """Move mouse relative to current position."""
... ...
@staticmethod @staticmethod
def dragTo(x: int, y: int, duration: float = 0.0, button: str = 'left') -> None: def dragTo(x: int, y: int, duration: float = 0.0, button: str = 'left') -> None:
"""Drag mouse to position.""" """Drag mouse to position."""
... ...
@staticmethod @staticmethod
def dragRel(xOffset: int, yOffset: int, duration: float = 0.0, button: str = 'left') -> None: def dragRel(xOffset: int, yOffset: int, duration: float = 0.0, button: str = 'left') -> None:
"""Drag mouse relative to current position.""" """Drag mouse relative to current position."""
... ...
@staticmethod @staticmethod
def click(x: Optional[int] = None, y: Optional[int] = None, clicks: int = 1, def click(x: Optional[int] = None, y: Optional[int] = None, clicks: int = 1,
interval: float = 0.0, button: str = 'left') -> None: interval: float = 0.0, button: str = 'left') -> None:
"""Click mouse at position.""" """Click mouse at position."""
... ...
@staticmethod @staticmethod
def mouseDown(x: Optional[int] = None, y: Optional[int] = None, button: str = 'left') -> None: def mouseDown(x: Optional[int] = None, y: Optional[int] = None, button: str = 'left') -> None:
"""Press mouse button down.""" """Press mouse button down."""
... ...
@staticmethod @staticmethod
def mouseUp(x: Optional[int] = None, y: Optional[int] = None, button: str = 'left') -> None: def mouseUp(x: Optional[int] = None, y: Optional[int] = None, button: str = 'left') -> None:
"""Release mouse button.""" """Release mouse button."""
... ...
@staticmethod @staticmethod
def keyDown(key: str) -> None: def keyDown(key: str) -> None:
"""Press key down.""" """Press key down."""
... ...
@staticmethod @staticmethod
def keyUp(key: str) -> None: def keyUp(key: str) -> None:
"""Release key.""" """Release key."""
... ...
@staticmethod @staticmethod
def press(key: str) -> None: def press(key: str) -> None:
"""Press and release a key.""" """Press and release a key."""
... ...
@staticmethod @staticmethod
def typewrite(text: str, interval: float = 0.0) -> None: def typewrite(text: str, interval: float = 0.0) -> None:
"""Type text with optional interval between characters.""" """Type text with optional interval between characters."""

View file

@ -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 = {}

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

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