Modernize Crypt of Sokoban demo game and fix timer segfault

Game script updates (src/scripts/):
- Migrate Sound/Music API: createSoundBuffer() -> Sound() objects
- Migrate Scene API: sceneUI("name") -> scene.children
- Migrate Timer API: setTimer/delTimer -> Timer objects with stop()
- Fix callback signatures: (x,y,btn,event) -> (pos,btn,action) with Vector
- Fix grid_size unpacking: now returns Vector, use .x/.y with int()

Segfault fix (src/PyTimer.cpp):
- Remove direct map erase in PyTimer::stop() that caused iterator
  invalidation when timer.stop() was called from within a callback
- Now just marks timer as stopped; testTimers() handles safe removal

The game now starts and runs without crashes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-01-21 23:47:46 -05:00
commit c23da11d7d
4 changed files with 61 additions and 48 deletions

View file

@ -156,14 +156,10 @@ PyObject* PyTimer::stop(PyTimerObject* self, PyObject* Py_UNUSED(ignored)) {
return nullptr; return nullptr;
} }
// Remove from game engine map (but preserve the Timer data!) // Just mark as stopped - do NOT erase from map here!
if (Resources::game && !self->name.empty()) { // Removing from the map during iteration (e.g., from a timer callback)
auto it = Resources::game->timers.find(self->name); // would invalidate iterators in testTimers(). The stopped flag tells
if (it != Resources::game->timers.end() && it->second == self->data) { // testTimers() to safely remove this timer on its next pass.
Resources::game->timers.erase(it);
}
}
self->data->stop(); self->data->stop();
// NOTE: We do NOT reset self->data here - the timer can be restarted // NOTE: We do NOT reset self->data here - the timer can be restarted
Py_RETURN_NONE; Py_RETURN_NONE;

View file

@ -82,7 +82,7 @@ class COSEntity(): #mcrfpy.Entity): # Fake mcrfpy.Entity integration; engine bu
pass pass
def try_move(self, dx, dy, test=False): def try_move(self, dx, dy, test=False):
x_max, y_max = self.grid.grid_size x_max, y_max = int(self.grid.grid_size.x), int(self.grid.grid_size.y)
tx, ty = int(self.draw_pos.x + dx), int(self.draw_pos.y + dy) tx, ty = int(self.draw_pos.x + dx), int(self.draw_pos.y + dy)
#for e in iterable_entities(self.grid): #for e in iterable_entities(self.grid):

View file

@ -22,7 +22,7 @@ class TileInfo:
@staticmethod @staticmethod
def from_grid(grid, xy:tuple): def from_grid(grid, xy:tuple):
values = {} values = {}
x_max, y_max = grid.grid_size x_max, y_max = int(grid.grid_size.x), int(grid.grid_size.y)
for d in deltas: for d in deltas:
tx, ty = d[0] + xy[0], d[1] + xy[1] tx, ty = d[0] + xy[0], d[1] + xy[1]
if tx < 0 or tx >= x_max or ty < 0 or ty >= y_max: if tx < 0 or tx >= x_max or ty < 0 or ty >= y_max:
@ -71,7 +71,7 @@ def special_rule_verify(rule, grid, xy, unverified_tiles, pass_unverified=False)
tx, ty = xy[0] + dxy[0], xy[1] + dxy[1] tx, ty = xy[0] + dxy[0], xy[1] + dxy[1]
#print(f"Special rule: {cardinal} {allowed_tile} {type(allowed_tile)} -> ({tx}, {ty}) [{grid.at((tx, ty)).tilesprite}]{'*' if (tx, ty) in unverified_tiles else ''}") #print(f"Special rule: {cardinal} {allowed_tile} {type(allowed_tile)} -> ({tx}, {ty}) [{grid.at((tx, ty)).tilesprite}]{'*' if (tx, ty) in unverified_tiles else ''}")
if (tx, ty) in unverified_tiles and cardinal in "nsew": return pass_unverified if (tx, ty) in unverified_tiles and cardinal in "nsew": return pass_unverified
x_max, y_max = grid.grid_size x_max, y_max = int(grid.grid_size.x), int(grid.grid_size.y)
if tx < 0 or tx >= x_max or ty < 0 or ty >= y_max: if tx < 0 or tx >= x_max or ty < 0 or ty >= y_max:
return False return False
return grid.at((tx, ty)).tilesprite == allowed_tile return grid.at((tx, ty)).tilesprite == allowed_tile
@ -107,7 +107,7 @@ def find_possible_tiles(grid, x, y, unverified_tiles=None, pass_unverified=False
return list(set(list(possible))) return list(set(list(possible)))
def wfc_first_pass(grid): def wfc_first_pass(grid):
w, h = grid.grid_size w, h = int(grid.grid_size.x), int(grid.grid_size.y)
possibilities = {} possibilities = {}
for x in range(0, w): for x in range(0, w):
for y in range(0, h): for y in range(0, h):
@ -122,7 +122,7 @@ def wfc_first_pass(grid):
return possibilities return possibilities
def wfc_pass(grid, possibilities=None): def wfc_pass(grid, possibilities=None):
w, h = grid.grid_size w, h = int(grid.grid_size.x), int(grid.grid_size.y)
if possibilities is None: if possibilities is None:
#print("first pass results:") #print("first pass results:")
possibilities = wfc_first_pass(grid) possibilities = wfc_first_pass(grid)

View file

@ -22,28 +22,47 @@ class Resources:
self.sfx_volume = 100 self.sfx_volume = 100
self.master_volume = 100 self.master_volume = 100
# load the music/sfx files here # Load the music/sfx files using new Sound API
self.splats = [] self.splats = []
for i in range(1, 10): for i in range(1, 10):
mcrfpy.createSoundBuffer(f"assets/sfx/splat{i}.ogg") try:
sound = mcrfpy.Sound(f"assets/sfx/splat{i}.ogg")
self.splats.append(sound)
except RuntimeError:
pass # Sound file not found, skip
self.music = None # Will hold Music object when loaded
def play_sfx(self, sfx_id): def play_sfx(self, sfx_id):
if self.sfx_enabled and self.sfx_volume and self.master_volume: if self.sfx_enabled and self.sfx_volume and self.master_volume:
mcrfpy.setSoundVolume(self.master_volume/100 * self.sfx_volume) if sfx_id < len(self.splats):
mcrfpy.playSound(sfx_id) sound = self.splats[sfx_id]
sound.volume = self.master_volume / 100 * self.sfx_volume
sound.play()
def play_music(self, track_id): def play_music(self, track_path):
if self.music_enabled and self.music_volume and self.master_volume: if self.music_enabled and self.music_volume and self.master_volume:
mcrfpy.setMusicVolume(self.master_volume/100 * self.music_volume) try:
mcrfpy.playMusic(...) self.music = mcrfpy.Music(track_path)
self.music.volume = self.master_volume / 100 * self.music_volume
self.music.play()
except RuntimeError:
pass # Music file not found
def set_music_volume(self, volume):
self.music_volume = volume
if self.music:
self.music.volume = self.master_volume / 100 * self.music_volume
def set_sfx_volume(self, volume):
self.sfx_volume = volume
resources = Resources() resources = Resources()
class Crypt: class Crypt:
def __init__(self): def __init__(self):
play = mcrfpy.Scene("play") self.scene = mcrfpy.Scene("play")
self.ui = mcrfpy.sceneUI("play") self.ui = self.scene.children
entity_frame = mcrfpy.Frame(pos=(815, 10), size=(194, 595), fill_color=frame_color) entity_frame = mcrfpy.Frame(pos=(815, 10), size=(194, 595), fill_color=frame_color)
inventory_frame = mcrfpy.Frame(pos=(10, 610), size=(800, 143), fill_color=frame_color) inventory_frame = mcrfpy.Frame(pos=(10, 610), size=(800, 143), fill_color=frame_color)
@ -244,8 +263,8 @@ class Crypt:
def start(self): def start(self):
resources.play_sfx(1) resources.play_sfx(1)
play.activate() self.scene.activate()
play.on_key = self.cos_keys self.scene.on_key = self.cos_keys
def add_entity(self, e:ce.COSEntity): def add_entity(self, e:ce.COSEntity):
self.entities.append(e) self.entities.append(e)
@ -402,7 +421,7 @@ class Crypt:
self.grid = self.level.grid self.grid = self.level.grid
self.grid.zoom = 2.0 self.grid.zoom = 2.0
# Center the camera on the middle of the grid (pixel coordinates: cells * tile_size / 2) # Center the camera on the middle of the grid (pixel coordinates: cells * tile_size / 2)
gw, gh = self.grid.grid_size gw, gh = int(self.grid.grid_size.x), int(self.grid.grid_size.y)
self.grid.center = (gw * 16 / 2, gh * 16 / 2) self.grid.center = (gw * 16 / 2, gh * 16 / 2)
# TODO, make an entity mover function # TODO, make an entity mover function
#self.add_entity(self.player) #self.add_entity(self.player)
@ -463,12 +482,12 @@ class SweetButton:
"""Helper func for when graphics changes or glitches make the button stuck down""" """Helper func for when graphics changes or glitches make the button stuck down"""
self.main_button.x, self.main_button.y = (self.shadow_offset, self.shadow_offset) self.main_button.x, self.main_button.y = (self.shadow_offset, self.shadow_offset)
def do_click(self, x, y, mousebtn, event): def do_click(self, pos, button, action):
if event == "start": if action == "start":
self.main_button.x, self.main_button.y = (0, 0) self.main_button.x, self.main_button.y = (0, 0)
elif event == "end": elif action == "end":
self.main_button.x, self.main_button.y = (self.shadow_offset, self.shadow_offset) self.main_button.x, self.main_button.y = (self.shadow_offset, self.shadow_offset)
result = self.click(self, (x, y, mousebtn, event)) result = self.click(self, (pos.x, pos.y, button, action))
if result: # return True from event function to instantly un-pop if result: # return True from event function to instantly un-pop
self.main_button.x, self.main_button.y = (self.shadow_offset, self.shadow_offset) self.main_button.x, self.main_button.y = (self.shadow_offset, self.shadow_offset)
@ -490,9 +509,9 @@ class SweetButton:
class MainMenu: class MainMenu:
def __init__(self): def __init__(self):
menu = mcrfpy.Scene("menu") self.scene = mcrfpy.Scene("menu")
self.ui = mcrfpy.sceneUI("menu") self.ui = self.scene.children
menu.activate() self.scene.activate()
self.crypt = None self.crypt = None
components = [] components = []
@ -501,7 +520,7 @@ class MainMenu:
self.grid = self.demo.grid self.grid = self.demo.grid
self.grid.zoom = 1.75 self.grid.zoom = 1.75
# Center the camera on the middle of the grid (pixel coordinates: cells * tile_size / 2) # Center the camera on the middle of the grid (pixel coordinates: cells * tile_size / 2)
gw, gh = self.grid.grid_size gw, gh = int(self.grid.grid_size.x), int(self.grid.grid_size.y)
self.grid.center = (gw * 16 / 2, gh * 16 / 2) self.grid.center = (gw * 16 / 2, gh * 16 / 2)
coords = self.demo.generate( coords = self.demo.generate(
[("boulder", "boulder", "rat", "cyclops", "boulder"), ("spawn"), ("rat", "big rat"), ("button", "boulder", "exit")] [("boulder", "boulder", "rat", "cyclops", "boulder"), ("spawn"), ("rat", "big rat"), ("button", "boulder", "exit")]
@ -538,14 +557,14 @@ class MainMenu:
#self.create_level(self.depth) #self.create_level(self.depth)
for e in self.entities: for e in self.entities:
self.grid.entities.append(e._entity) self.grid.entities.append(e._entity)
def just_wiggle(*args): def just_wiggle(timer, runtime):
try: try:
self.player.try_move(*random.choice(((1, 0),(-1, 0),(0, 1),(0, -1)))) self.player.try_move(*random.choice(((1, 0),(-1, 0),(0, 1),(0, -1))))
for e in self.entities: for e in self.entities:
e.act() e.act()
except: except:
pass pass
mcrfpy.setTimer("demo_motion", just_wiggle, 100) self.demo_timer = mcrfpy.Timer("demo_motion", just_wiggle, 100)
components.append( components.append(
self.demo.grid self.demo.grid
) )
@ -605,22 +624,22 @@ class MainMenu:
def toast_say(self, txt, delay=10): def toast_say(self, txt, delay=10):
"kick off a toast event" "kick off a toast event"
if self.toast_event is not None: if self.toast_event is not None and hasattr(self, 'toast_timer'):
mcrfpy.delTimer("toast_timer") self.toast_timer.stop()
self.toast.text = txt self.toast.text = txt
self.toast_event = 350 self.toast_event = 350
self.toast.fill_color = (255, 255, 255, 255) self.toast.fill_color = (255, 255, 255, 255)
self.toast.outline = 2 self.toast.outline = 2
self.toast.outline_color = (0, 0, 0, 255) self.toast.outline_color = (0, 0, 0, 255)
mcrfpy.setTimer("toast_timer", self.toast_callback, 100) self.toast_timer = mcrfpy.Timer("toast_timer", self.toast_callback, 100)
def toast_callback(self, *args): def toast_callback(self, timer, runtime):
"fade out the toast text" "fade out the toast text"
self.toast_event -= 5 self.toast_event -= 5
if self.toast_event < 0: if self.toast_event < 0:
self.toast_event = None self.toast_event = None
mcrfpy.delTimer("toast_timer") self.toast_timer.stop()
mcrfpy.text = "" self.toast.text = ""
return return
a = min(self.toast_event, 255) a = min(self.toast_event, 255)
self.toast.fill_color = (255, 255, 255, a) self.toast.fill_color = (255, 255, 255, a)
@ -632,9 +651,8 @@ class MainMenu:
def play(self, sweet_btn, args): def play(self, sweet_btn, args):
#if args[3] == "start": return # DRAMATIC on release action! #if args[3] == "start": return # DRAMATIC on release action!
if args[3] == "end": return if args[3] == "end": return
mcrfpy.delTimer("demo_motion") # Clean up the demo timer self.demo_timer.stop() # Clean up the demo timer
self.crypt = Crypt() self.crypt = Crypt()
#mcrfpy.setScene("play")
self.crypt.start() self.crypt.start()
def scale(self, sweet_btn, args, window_scale=None): def scale(self, sweet_btn, args, window_scale=None):
@ -658,26 +676,25 @@ class MainMenu:
resources.music_enabled = not resources.music_enabled resources.music_enabled = not resources.music_enabled
print(f"music: {resources.music_enabled}") print(f"music: {resources.music_enabled}")
if resources.music_enabled: if resources.music_enabled:
mcrfpy.setMusicVolume(self.music_volume) resources.set_music_volume(resources.music_volume)
sweet_btn.text = "Music is ON" sweet_btn.text = "Music is ON"
sweet_btn.sprite_number = 12 sweet_btn.sprite_number = 12
else: else:
self.toast_say("Use your volume keys or\nlook in Settings for a volume meter.") self.toast_say("Use your volume keys or\nlook in Settings for a volume meter.")
mcrfpy.setMusicVolume(0) resources.set_music_volume(0)
sweet_btn.text = "Music is OFF" sweet_btn.text = "Music is OFF"
sweet_btn.sprite_number = 17 sweet_btn.sprite_number = 17
def sfx_toggle(self, sweet_btn, args): def sfx_toggle(self, sweet_btn, args):
if args[3] == "end": return if args[3] == "end": return
resources.sfx_enabled = not resources.sfx_enabled resources.sfx_enabled = not resources.sfx_enabled
#print(f"sfx: {resources.sfx_enabled}")
if resources.sfx_enabled: if resources.sfx_enabled:
mcrfpy.setSoundVolume(self.sfx_volume) resources.set_sfx_volume(resources.sfx_volume)
sweet_btn.text = "SFX are ON" sweet_btn.text = "SFX are ON"
sweet_btn.sprite_number = 0 sweet_btn.sprite_number = 0
else: else:
self.toast_say("Use your volume keys or\nlook in Settings for a volume meter.") self.toast_say("Use your volume keys or\nlook in Settings for a volume meter.")
mcrfpy.setSoundVolume(0) resources.set_sfx_volume(0)
sweet_btn.text = "SFX are OFF" sweet_btn.text = "SFX are OFF"
sweet_btn.sprite_number = 17 sweet_btn.sprite_number = 17