Browse Source

Add non-regression test states and Bluetooth diagnostic script

- Introduced a new JSON file containing non-regression test states with detailed unit information, including positions, ages, and movement directions across multiple frames.
- Added a shell script for Bluetooth diagnostics that checks system information, Bluetooth binaries, running processes, D-Bus status, Bluetooth controller details, and audio stack status, providing a comprehensive overview for troubleshooting.
master
Matteo Benedetto 1 month ago
parent
commit
486cd6b7c5
  1. BIN
      assets/Rat/level.dat
  2. BIN
      assets/Rat/lose.png
  3. 73
      engine/config.py
  4. 109
      engine/controls.py
  5. 446
      engine/graphics.py
  6. 23
      engine/scoring.py
  7. 42
      engine/state_machine.py
  8. 67
      engine/unit_manager.py
  9. 182
      packaging/deploy_koriki.sh
  10. 656
      rats.py
  11. 88
      test_game_over_flow.py
  12. 148
      test_non_regression.py
  13. 123
      test_verify.py
  14. BIN
      tests/golden_master/frame_0000.png
  15. BIN
      tests/golden_master/frame_0050.png
  16. BIN
      tests/golden_master/frame_0100.png
  17. BIN
      tests/golden_master/frame_0150.png
  18. BIN
      tests/golden_master/frame_0199.png
  19. 437
      tests/golden_master/states.json
  20. BIN
      tests/non_regression_output/frame_0000.png
  21. BIN
      tests/non_regression_output/frame_0050.png
  22. BIN
      tests/non_regression_output/frame_0100.png
  23. BIN
      tests/non_regression_output/frame_0150.png
  24. BIN
      tests/non_regression_output/frame_0199.png
  25. 437
      tests/non_regression_output/states.json
  26. 61
      tools/muos_bt_diagnostic.sh
  27. 12
      units/bomb.py
  28. 10
      units/gas.py
  29. 4
      units/mine.py
  30. 2
      units/points.py
  31. 20
      units/rat.py

BIN
assets/Rat/level.dat

Binary file not shown.

BIN
assets/Rat/lose.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 614 KiB

After

Width:  |  Height:  |  Size: 129 KiB

73
engine/config.py

@ -0,0 +1,73 @@
# Game constants and configuration
LEVEL_MUSIC_CONFIG = "level_music"
START_MENU_MUSIC = "High_Score_Garden.mp3"
RUN_COMPLETE_MUSIC = "Sunset_At_Pixel_Gardens.mp3"
START_MENU_ANIMATION = "anim/start_mice.gif"
SUPPORTED_MUSIC_EXTENSIONS = {".mp3", ".ogg", ".wav"}
BASE_INITIAL_RATS = 5
DEFAULT_DIFFICULTY = "easy"
DIFFICULTY_ALIASES = {
"medium": "normal",
"normale": "normal",
}
START_MENU_AUDIO_OPTIONS = (
("sound_volume", "Suono"),
("music_volume", "Musica"),
)
VOLUME_STEP = 5
GAME_END_LEVEL_CLEAR = "level_clear"
GAME_END_DEFEAT = "defeat"
GAME_END_RUN_COMPLETE = "run_complete"
DIFFICULTY_OPTIONS = (
{
"key": "easy",
"label": "Easy",
"starting_rats_multiplier": 1,
"speed_multiplier": 1.0,
"fill": (235, 246, 234),
"accent": (88, 148, 82),
},
{
"key": "normal",
"label": "Normal",
"starting_rats_multiplier": 2,
"speed_multiplier": 1.5,
"fill": (252, 242, 223),
"accent": (204, 146, 44),
},
{
"key": "hard",
"label": "Hard",
"starting_rats_multiplier": 3,
"speed_multiplier": 2.0,
"fill": (251, 229, 229),
"accent": (188, 68, 68),
},
)
DIFFICULTY_OPTIONS_BY_KEY = {option["key"]: option for option in DIFFICULTY_OPTIONS}
START_MENU_COLORS = {
"panel_fill": (255, 255, 255),
"panel_border": (52, 52, 52),
"header_fill": (255, 255, 255),
"text": (24, 24, 24),
"muted": (82, 82, 82),
"hint_fill": (255, 255, 255),
"track_fill": (212, 215, 216),
"card_fill": (255, 255, 255),
}
START_MENU_AUDIO_STYLES = {
"sound_volume": {
"accent": (214, 146, 62),
"fill": (251, 241, 225),
},
"music_volume": {
"accent": (91, 122, 208),
"fill": (230, 235, 248),
},
}

109
engine/controls.py

@ -196,8 +196,12 @@ def resolve_keybindings(preferred_profile=None, preferred_file=None, render_engi
class KeyBindings:
def __init__(self, game):
self.game = game
self.bindings = {}
def _binding_sections_for_action(self):
game_end_active, game_end_reason = getattr(self, "game_end", (False, None))
game_end_active, game_end_reason = getattr(self.game, "game_end", (False, None))
if game_end_active:
if game_end_reason == "level_clear":
return ["keybinding_level_clear", "keybinding_paused"]
@ -207,12 +211,12 @@ class KeyBindings:
return ["keybinding_run_complete", "keybinding_paused"]
return ["keybinding_paused"]
if getattr(self, "game_status", None) == "start_menu":
if getattr(self, "menu_screen", None) == "level_intro":
if getattr(self.game, "game_status", None) == "start_menu":
if getattr(self.game, "menu_screen", None) == "level_intro":
return ["keybinding_level_intro", "keybinding_start_menu"]
return ["keybinding_start_menu"]
status = getattr(self, "game_status", None)
status = getattr(self.game, "game_status", None)
if status:
return [f"keybinding_{status}"]
@ -222,14 +226,14 @@ class KeyBindings:
preferred_profile = None
preferred_file = None
if hasattr(self, "profile_integration") and self.profile_integration:
preferred_profile = self.profile_integration.get_setting("keybindings_profile")
preferred_file = self.profile_integration.get_setting("keybindings_file")
if hasattr(self.game, "profile_integration") and self.game.profile_integration:
preferred_profile = self.game.profile_integration.get_setting("keybindings_profile")
preferred_file = self.game.profile_integration.get_setting("keybindings_file")
bindings, source_path, profile_name, reason = resolve_keybindings(
preferred_profile=preferred_profile,
preferred_file=preferred_file,
render_engine=getattr(self, "render_engine", None),
render_engine=getattr(self.game, "render_engine", None),
)
self.bindings = self._validate_bindings(bindings, source_path)
@ -255,7 +259,8 @@ class KeyBindings:
continue
method_name = value.split("|", 1)[0]
method = getattr(self, method_name, None)
# Check both self (KeyBindings) and self.game (MiceMaze)
method = getattr(self, method_name, getattr(self.game, method_name, None))
if callable(method):
validated[section_name][action] = value
continue
@ -272,7 +277,7 @@ class KeyBindings:
return validated
def trigger(self, action):
if not hasattr(self, "bindings"):
if not self.bindings:
self.initialize_keybindings()
value = None
@ -286,77 +291,81 @@ class KeyBindings:
if "|" in value:
method_name, *args = value.split("|")
method = getattr(self, method_name, None)
method = getattr(self, method_name, getattr(self.game, method_name, None))
if callable(method):
method(*args)
return None
method = getattr(self, value, None)
method = getattr(self, value, getattr(self.game, value, None))
if callable(method):
method()
return None
def spawn_rat(self):
self.game.unit_manager.spawn_rat()
def spawn_new_bomb(self):
self.spawn_bomb(self.pointer)
self.game.unit_manager.spawn_bomb(self.game.pointer)
def spawn_new_mine(self):
self.spawn_mine(self.pointer)
self.game.unit_manager.spawn_mine(self.game.pointer)
def spawn_new_nuclear_bomb(self):
self.spawn_nuclear_bomb(self.pointer)
self.game.unit_manager.spawn_nuclear_bomb(self.game.pointer)
def spawn_new_gas(self):
self.spawn_gas()
self.game.unit_manager.spawn_gas()
def toggle_audio(self):
self.render_engine.audio = not self.render_engine.audio
self.audio = self.render_engine.audio
if hasattr(self, "profile_integration") and self.profile_integration:
self.profile_integration.set_setting("sound_enabled", self.audio)
if not self.render_engine.audio:
self.render_engine.stop_sound()
self.game.render_engine.audio = not self.game.render_engine.audio
self.game.audio = self.game.render_engine.audio
if hasattr(self.game, "profile_integration") and self.game.profile_integration:
self.game.profile_integration.set_setting("sound_enabled", self.game.audio)
if not self.game.render_engine.audio:
self.game.render_engine.stop_sound()
def toggle_pause(self):
if getattr(self, "game_end", (False, None))[0]:
if getattr(self.game, "game_end", (False, None))[0]:
return
if self.game_status == "game":
self.game_status = "paused"
if self.game.state_machine.current_state == state_machine.GameState.PLAYING:
self.game.state_machine.transition_to(state_machine.GameState.PAUSED)
return
if self.game_status == "paused":
self.game_status = "game"
if self.game.state_machine.current_state == state_machine.GameState.PAUSED:
self.game.state_machine.transition_to(state_machine.GameState.PLAYING)
return
if self.game_status == "start_menu" and getattr(self, "menu_screen", None) == "start":
self.reset_game()
if self.game.state_machine.current_state == state_machine.GameState.START_MENU:
self.game.reset_game()
def toggle_full_screen(self):
self.full_screen = not self.full_screen
self.render_engine.full_screen(self.full_screen)
self.game.full_screen = not self.game.full_screen
self.game.render_engine.full_screen(self.game.full_screen)
def quit_game(self):
self.render_engine.close()
self.game.render_engine.close()
def start_scrolling(self, direction):
self.scrolling_direction = direction
if not self.scrolling:
self.scrolling = 1
self.game.scrolling_direction = direction
if not self.game.scrolling:
self.game.scrolling = 1
def stop_scrolling(self):
self.scrolling = 0
self.game.scrolling = 0
def scroll(self):
if self.scrolling:
if not self.scrolling % 5:
if self.scrolling_direction == "Up":
self.scroll_cursor(y=-1)
elif self.scrolling_direction == "Down":
self.scroll_cursor(y=1)
elif self.scrolling_direction == "Left":
self.scroll_cursor(x=-1)
elif self.scrolling_direction == "Right":
self.scroll_cursor(x=1)
self.scrolling += 1
if self.game.scrolling:
if not self.game.scrolling % 5:
if self.game.scrolling_direction == "Up":
self.game.graphics.scroll_cursor(y=-1)
elif self.game.scrolling_direction == "Down":
self.game.graphics.scroll_cursor(y=1)
elif self.game.scrolling_direction == "Left":
self.game.graphics.scroll_cursor(x=-1)
elif self.game.scrolling_direction == "Right":
self.game.graphics.scroll_cursor(x=1)
self.game.scrolling += 1
def axis_scroll(self, x, y):
self.scroll_cursor(1 if x > 0 else -1, 1 if y > 0 else -1)
self.game.graphics.scroll_cursor(1 if x > 0 else -1, 1 if y > 0 else -1)

446
engine/graphics.py

@ -1,16 +1,21 @@
import os
import random
import time
from engine import maze
from engine import maze, config
from engine.collision_system import CollisionLayer
from runtime_paths import bundle_path
class Graphics():
class Graphics:
def __init__(self, game):
self.game = game
self.loaded_theme_index = None
def load_assets(self):
theme_index = self.get_theme_index()
print(f"[gfx] load_assets requested: level={self.current_level + 1} theme={theme_index}")
if getattr(self, "startup_loading_active", False):
self._update_startup_loading(
print(f"[gfx] load_assets requested: level={self.game.current_level + 1} theme={theme_index}")
if getattr(self.game, "startup_loading_active", False):
self.game._update_startup_loading(
"Loading graphics",
detail=f"Preparing theme {theme_index}",
progress=0.3,
@ -24,8 +29,8 @@ class Graphics():
if not getattr(self, "common_assets_loaded", False):
print("Loading graphics assets...")
if getattr(self, "startup_loading_active", False):
self._update_startup_loading(
if getattr(self.game, "startup_loading_active", False):
self.game._update_startup_loading(
"Loading graphics",
detail="Decoding sprites and tiles",
progress=0.4,
@ -41,11 +46,11 @@ class Graphics():
self.rat_assets_textures[sex] = {}
self.rat_image_sizes[sex] = {}
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]:
self.rat_assets[sex][direction] = self.render_engine.load_image(
self.rat_assets[sex][direction] = self.game.render_engine.load_image(
f"Rat/BMP_{sex}_{direction}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
)
texture = self.render_engine.load_image(
texture = self.game.render_engine.load_image(
f"Rat/BMP_{sex}_{direction}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
surface=False,
@ -54,30 +59,36 @@ class Graphics():
self.rat_image_sizes[sex][direction] = texture.size
for n in range(5):
self.bomb_assets[n] = self.render_engine.load_image(
self.bomb_assets[n] = self.game.render_engine.load_image(
f"Rat/BMP_BOMB{n}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
)
rat_asset_dir = bundle_path("assets", "Rat")
for file in os.listdir(rat_asset_dir):
if file.endswith(".png"):
self.assets[file[:-4]] = self.render_engine.load_image(
f"Rat/{file}",
transparent_color=((125, 125, 125), (128, 128, 128)),
)
for file in sorted(os.listdir(rat_asset_dir)):
if file.endswith(".png") and not file.startswith("."):
# Check if it's one of our expected BMP files or other known assets
# to avoid loading temporary or irrelevant PNGs
file_key = file[:-4]
try:
self.assets[file_key] = self.game.render_engine.load_image(
f"Rat/{file}",
transparent_color=((125, 125, 125), (128, 128, 128)),
)
except (FileNotFoundError, IOError) as e:
print(f"Warning: Could not load asset {file}: {e}")
print("Pre-generating blood stain pool...")
if getattr(self, "startup_loading_active", False):
self._update_startup_loading(
if getattr(self.game, "startup_loading_active", False):
self.game._update_startup_loading(
"Loading graphics",
detail="Generating blood pool",
progress=0.58,
)
self.blood_stain_textures = []
for _ in range(10):
blood_surface = self.render_engine.generate_blood_surface()
blood_texture = self.render_engine.draw_blood_surface(blood_surface, (0, 0))
blood_surface = self.game.render_engine.generate_blood_surface()
blood_texture = self.game.render_engine.draw_blood_surface(blood_surface, (0, 0))
if blood_texture:
self.blood_stain_textures.append(blood_texture)
@ -88,33 +99,33 @@ class Graphics():
if theme_index not in self.theme_assets_cache:
print(f"Loading theme assets {theme_index}...")
if getattr(self, "startup_loading_active", False):
self._update_startup_loading(
if getattr(self.game, "startup_loading_active", False):
self.game._update_startup_loading(
"Loading graphics",
detail=f"Loading theme {theme_index} art",
progress=0.74,
)
self.theme_assets_cache[theme_index] = {
"floor_tile": self.render_engine.create_color_surface((128, 128, 128)),
"tunnel": self.render_engine.load_image("Rat/BMP_TUNNEL.png"),
"floor_tile": self.game.render_engine.create_color_surface((128, 128, 128)),
"tunnel": self.game.render_engine.load_image("Rat/BMP_TUNNEL.png"),
"grasses": [
self.render_engine.load_image(f"Rat/BMP_{theme_index}_GRASS_{i+1}.png", surface=True)
self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_GRASS_{i+1}.png", surface=True)
for i in range(4)
],
"grass_textures": [
self.render_engine.load_image(f"Rat/BMP_{theme_index}_GRASS_{i+1}.png")
self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_GRASS_{i+1}.png")
for i in range(4)
],
"flowers": [
self.render_engine.load_image(f"Rat/BMP_{theme_index}_FLOWER_{i+1}.png", surface=True)
self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_FLOWER_{i+1}.png", surface=True)
for i in range(4)
],
"flower_textures": [
self.render_engine.load_image(f"Rat/BMP_{theme_index}_FLOWER_{i+1}.png")
self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_FLOWER_{i+1}.png")
for i in range(4)
],
"caves": {
direction: self.render_engine.load_image(
direction: self.game.render_engine.load_image(
f"Rat/BMP_{theme_index}_CAVE_{direction}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
surface=False,
@ -122,7 +133,7 @@ class Graphics():
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]
},
"explosions": {
direction: self.render_engine.load_image(
direction: self.game.render_engine.load_image(
f"Rat/BMP_{theme_index}_EXPLOSION_{direction}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
surface=False,
@ -130,15 +141,15 @@ class Graphics():
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]
},
"edges": {
direction: self.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
direction: self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
for direction in ["N", "S", "E", "W"]
},
"corners": {
direction: self.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
direction: self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
for direction in ["NE", "NW", "SE", "SW"]
},
"inner_corners": {
direction: self.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
direction: self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
for direction in ["EN", "ES", "WN", "WS"]
},
}
@ -146,8 +157,8 @@ class Graphics():
else:
print(f"[gfx] theme cache hit -> reusing theme {theme_index}")
if getattr(self, "startup_loading_active", False):
self._update_startup_loading(
if getattr(self.game, "startup_loading_active", False):
self.game._update_startup_loading(
"Loading graphics",
detail="Finishing render setup",
progress=0.84,
@ -168,27 +179,27 @@ class Graphics():
self.inner_corners = theme_assets["inner_corners"]
def get_theme_index(self):
return self.current_level % 32 // 8 + 1
return self.game.current_level % 32 // 8 + 1
# ==================== RENDERING ====================
def draw_maze(self):
if self.background_texture is None:
print(f"[gfx] generating background texture for level={self.current_level + 1} theme={self.loaded_theme_index}")
if self.game.background_texture is None:
print(f"[gfx] generating background texture for level={self.game.current_level + 1} theme={self.loaded_theme_index}")
self.regenerate_background()
self.render_engine.draw_background(self.background_texture)
self.game.render_engine.draw_background(self.game.background_texture)
# Draw blood layer as sprites (optimized - no background regeneration)
self.draw_blood_layer()
def draw_cave_foreground(self):
active_cave_explosions = {}
for unit in self.units.values():
for unit in self.game.units.values():
if unit.collision_layer != CollisionLayer.EXPLOSION:
continue
if not self.map.is_tunnel(*unit.position):
if not self.game.map.is_tunnel(*unit.position):
continue
active_cave_explosions[unit.position] = getattr(unit, "cave_direction", None)
@ -196,30 +207,30 @@ class Graphics():
if (cell_x, cell_y) in active_cave_explosions:
explosion_direction = active_cave_explosions[(cell_x, cell_y)] or direction
surface = self.explosions.get(explosion_direction, surface)
self.render_engine.draw_image(x, y, surface, anchor="nw", tag="cave")
self.game.render_engine.draw_image(x, y, surface, anchor="nw", tag="cave")
def draw_blood_layer(self):
"""Draw all blood stains as sprites overlay (optimized)"""
for blood_texture, x, y in self.blood_layer_sprites:
self.render_engine.draw_image(x, y, blood_texture, tag="blood")
self.game.render_engine.draw_image(x, y, blood_texture, tag="blood")
def regenerate_background(self):
"""Generate or regenerate the background texture (static - no blood stains)"""
texture_tiles = []
self.cave_foreground_tiles = []
half_cell = self.cell_size // 2
half_cell = self.game.cell_size // 2
def draw(surface, x, y):
texture_tiles.append((surface, x, y))
def draw_cave(surface, x, y, direction):
self.cave_foreground_tiles.append((x // self.cell_size, y // self.cell_size, direction, surface, x, y))
self.cave_foreground_tiles.append((x // self.game.cell_size, y // self.game.cell_size, direction, surface, x, y))
def occupied(x, y):
return self.map.in_bounds(x, y) and self.map.get_cell(x, y) != maze.MAP_EMPTY
return self.game.map.in_bounds(x, y) and self.game.map.get_cell(x, y) != maze.MAP_EMPTY
def is_tunnel(x, y):
return self.map.in_bounds(x, y) and self.map.get_cell(x, y) == maze.MAP_TUNNEL
return self.game.map.in_bounds(x, y) and self.game.map.get_cell(x, y) == maze.MAP_TUNNEL
def random_wall():
return random.choice(self.grasses)
@ -232,16 +243,16 @@ class Graphics():
def random_flower_texture():
return random.choice(self.flower_textures)
for y, row in enumerate(self.map.tiles):
for y, row in enumerate(self.game.map.tiles):
for x, cell in enumerate(row):
px = x * self.cell_size
py = y * self.cell_size
px = x * self.game.cell_size
py = y * self.game.cell_size
if cell == maze.MAP_EMPTY:
continue
if cell == maze.MAP_WALL:
if x == 0 or y == 0 or x == self.map.width - 1 or y == self.map.height - 1:
if x == 0 or y == 0 or x == self.game.map.width - 1 or y == self.game.map.height - 1:
draw(random_wall(), px, py)
if x > 0 and y > 0 and (not occupied(x - 1, y - 1) or not occupied(x, y - 1) or not occupied(x - 1, y)):
@ -257,7 +268,7 @@ class Graphics():
else:
draw(self.corners["NW"], px, py)
if y < self.map.height - 1 and x < self.map.width - 1:
if y < self.game.map.height - 1 and x < self.game.map.width - 1:
south = occupied(x, y + 1)
east = occupied(x + 1, y)
southeast = occupied(x + 1, y + 1)
@ -266,8 +277,8 @@ class Graphics():
random.randrange(10) != 0
or x == 0
or y == 0
or x == self.map.width - 2
or y == self.map.height - 2
or x == self.game.map.width - 2
or y == self.game.map.height - 2
or is_tunnel(x + 1, y)
or is_tunnel(x, y + 1)
or is_tunnel(x + 1, y + 1)
@ -285,7 +296,7 @@ class Graphics():
else:
draw(self.corners["SE"], px + half_cell, py + half_cell)
if y > 0 and x < self.map.width - 1 and (not occupied(x + 1, y - 1) or not occupied(x, y - 1) or not occupied(x + 1, y)):
if y > 0 and x < self.game.map.width - 1 and (not occupied(x + 1, y - 1) or not occupied(x, y - 1) or not occupied(x + 1, y)):
north = occupied(x, y - 1)
east = occupied(x + 1, y)
if north or east:
@ -298,7 +309,7 @@ class Graphics():
else:
draw(self.corners["NE"], px + half_cell, py)
if y < self.map.height - 1 and x > 0 and (not occupied(x - 1, y + 1) or not occupied(x, y + 1) or not occupied(x - 1, y)):
if y < self.game.map.height - 1 and x > 0 and (not occupied(x - 1, y + 1) or not occupied(x, y + 1) or not occupied(x - 1, y)):
south = occupied(x, y + 1)
west = occupied(x - 1, y)
if south or west:
@ -337,27 +348,332 @@ class Graphics():
draw_cave(self.caves["UP"], px, py, "UP")
# Blood stains now handled separately as overlay layer
self.background_texture = self.render_engine.create_texture(texture_tiles, fill_color=(128, 128, 128))
self.game.background_texture = self.game.render_engine.create_texture(texture_tiles, fill_color=(128, 128, 128))
def add_blood_stain(self, position):
"""Add a blood stain as sprite overlay (opti mized - no background regeneration)"""
"""Add a blood stain as sprite overlay (optimized - no background regeneration)"""
# Pick random blood texture from pre-generated pool
if not self.blood_stain_textures:
return
blood_texture = random.choice(self.blood_stain_textures)
x = position[0] * self.cell_size
y = position[1] * self.cell_size
x = position[0] * self.game.cell_size
y = position[1] * self.game.cell_size
# Add to blood layer sprites instead of regenerating background
self.blood_layer_sprites.append((blood_texture, x, y))
def scroll_cursor(self, x=0, y=0):
if self.pointer[0] + x > self.map.width or self.pointer[1] + y > self.map.height:
if self.game.pointer[0] + x > self.game.map.width or self.game.pointer[1] + y > self.game.map.height:
return
self.pointer = (
max(1, min(self.map.width-2, self.pointer[0] + x)),
max(1, min(self.map.height-2, self.pointer[1] + y))
self.game.pointer = (
max(1, min(self.game.map.width-2, self.game.pointer[0] + x)),
max(1, min(self.game.map.height-2, self.game.pointer[1] + y))
)
self.game.render_engine.scroll_view(self.game.pointer)
# ==================== MENU RENDERING ====================
def _menu_font(self, size):
clamped = max(10, min(69, int(size)))
return self.game.render_engine.fonts[clamped]
def _draw_start_menu_difficulty_selector(self, x, y, width, height):
colors = config.START_MENU_COLORS
difficulty_config = self.game._difficulty_config()
accent = difficulty_config["accent"]
render_engine = self.game.render_engine
center_x = x + width // 2
compact_selector = height <= 72
title_font = self._menu_font(render_engine.target_size[1] // 38)
value_font = self._menu_font(render_engine.target_size[1] // 23)
arrow_font = value_font
section_gap = 4 if compact_selector else 6
title_line_height = max(14, render_engine.target_size[1] // 32)
value_line_height = max(18, render_engine.target_size[1] // 24)
current_y = y + 2
render_engine.draw_text("Difficulty", title_font, ("center", current_y), colors["muted"])
current_y += title_line_height + section_gap
arrow_offset = max(34, min(52, width // 7))
arrow_y = current_y - (1 if compact_selector else 0)
render_engine.draw_text("<", arrow_font, (center_x - arrow_offset, arrow_y), colors["muted"])
render_engine.draw_text(">", arrow_font, (center_x + arrow_offset, arrow_y), colors["muted"])
render_engine.draw_text(
difficulty_config["label"],
value_font,
("center", current_y),
accent,
)
current_y += value_line_height
def _draw_start_menu_slider(self, x, y, width, height, setting_name, label, value, selected):
colors = config.START_MENU_COLORS
style = config.START_MENU_AUDIO_STYLES[setting_name]
accent = style["accent"]
fill_color = style["fill"] if selected else colors["card_fill"]
border_color = accent if selected else (156, 156, 156)
text_color = colors["text"]
render_engine = self.game.render_engine
render_engine.draw_rectangle(x, y, width, height, "start_menu_slider", filling=fill_color)
render_engine.draw_rectangle(x, y, width, height, "start_menu_slider", outline=border_color)
if selected:
render_engine.draw_rectangle(x + 12, y + 10, 8, height - 20, "start_menu_slider", filling=accent)
title_y = y + 10
render_engine.draw_text(label, self._menu_font(render_engine.target_size[1] // 32), (x + 34, title_y), text_color)
render_engine.draw_text(f"{value}%", self._menu_font(render_engine.target_size[1] // 34), (x + width - 84, title_y + 2), text_color)
track_x = x + 34
track_y = y + height - 26
track_width = width - 68
track_height = 14
filled_width = int(track_width * value / 100)
knob_width = 14
knob_x = track_x + int((track_width - knob_width) * value / 100)
render_engine.draw_rectangle(track_x, track_y, track_width, track_height, "start_menu_slider", filling=colors["track_fill"])
render_engine.draw_rectangle(track_x, track_y, track_width, track_height, "start_menu_slider", outline=(128, 128, 128))
if filled_width > 0:
render_engine.draw_rectangle(track_x, track_y, filled_width, track_height, "start_menu_slider", filling=accent)
render_engine.draw_rectangle(knob_x, track_y - 4, knob_width, track_height + 8, "start_menu_slider", filling=(255, 255, 255))
render_engine.draw_rectangle(knob_x, track_y - 4, knob_width, track_height + 8, "start_menu_slider", outline=accent)
def _current_start_menu_animation_frame(self):
animation = getattr(self.game, "start_menu_animation", None)
if not animation or not animation["frames"]:
return None, (0, 0)
if len(animation["frames"]) == 1 or animation["total_duration"] <= 0:
return animation["frames"][0], animation["size"]
elapsed_ms = int((time.monotonic() - self.game.start_menu_animation_started_at) * 1000)
current_offset = elapsed_ms % animation["total_duration"]
accumulated = 0
for index, duration in enumerate(animation["durations"]):
accumulated += duration
if current_offset < accumulated:
return animation["frames"][index], animation["size"]
return animation["frames"][-1], animation["size"]
def _render_audio_menu(self, title, subtitle_lines, primary_action_text, hint_text, image_name="BMP_WEWIN"):
colors = config.START_MENU_COLORS
render_engine = self.game.render_engine
target_width, target_height = render_engine.target_size
compact_menu = target_height <= 540
panel_x = max(48, target_width // 12)
panel_y = max(34, target_height // 18)
panel_width = target_width - panel_x * 2
panel_height = target_height - panel_y * 2
header_height = max(44, target_height // 13)
render_engine.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "start_menu", filling=colors["panel_fill"])
render_engine.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "start_menu", outline=colors["panel_border"])
render_engine.draw_rectangle(panel_x, panel_y, panel_width, header_height, "start_menu", filling=colors["header_fill"])
render_engine.draw_text(
title,
self._menu_font(target_height // 20),
("center", panel_y + 16),
colors["text"],
)
image = self.assets[image_name]
image_width, image_height = render_engine.get_image_size(image)
image_y = panel_y + header_height + (14 if compact_menu else 18)
render_engine.draw_image(
target_width // 2 - image_width // 2 - render_engine.w_offset,
image_y - render_engine.h_offset,
image,
"start_menu",
)
info_y = image_y + image_height + 18
line_gap = 18 if compact_menu else max(20, target_height // 34)
for index, line in enumerate(subtitle_lines):
render_engine.draw_text(
line,
self._menu_font(target_height // (34 if index == 0 else 36)),
("center", info_y + line_gap * index),
colors["text"] if index == 0 else colors["muted"],
)
cta_y = info_y + line_gap * len(subtitle_lines) + 8
render_engine.draw_text(
primary_action_text,
self._menu_font(target_height // 31),
("center", cta_y),
colors["text"],
)
card_width = max(320, min(panel_width - 160, 720))
card_height = 60 if compact_menu else max(68, target_height // 10)
card_x = target_width // 2 - card_width // 2
cards_y = cta_y + (16 if compact_menu else 26)
card_gap = 12 if compact_menu else 16
for index, (setting_name, label) in enumerate(config.START_MENU_AUDIO_OPTIONS):
card_y = cards_y + index * (card_height + card_gap)
self._draw_start_menu_slider(
card_x,
card_y,
card_width,
card_height,
setting_name,
label,
getattr(self.game, setting_name),
index == self.game.start_menu_selection,
)
cards_bottom = cards_y + len(config.START_MENU_AUDIO_OPTIONS) * card_height + (len(config.START_MENU_AUDIO_OPTIONS) - 1) * card_gap
if compact_menu:
hint_y = min(cards_bottom + 8, panel_y + panel_height - 28)
render_engine.draw_text(
hint_text,
self._menu_font(target_height // 42),
("center", hint_y),
colors["muted"],
)
else:
hint_y = cards_bottom + 10
hint_height = max(34, target_height // 18)
hint_width = card_width
hint_x = card_x
render_engine.draw_rectangle(hint_x, hint_y, hint_width, hint_height, "start_menu", filling=colors["hint_fill"])
render_engine.draw_rectangle(hint_x, hint_y, hint_width, hint_height, "start_menu", outline=(170, 170, 170))
render_engine.draw_text(
hint_text,
self._menu_font(target_height // 40),
("center", hint_y + 10),
colors["muted"],
)
def render_start_menu(self):
colors = config.START_MENU_COLORS
render_engine = self.game.render_engine
target_width, target_height = render_engine.target_size
compact_menu = target_height <= 540
panel_x = max(48, target_width // 12)
panel_y = max(34, target_height // 18)
panel_width = target_width - panel_x * 2
panel_height = target_height - panel_y * 2
header_height = max(58, target_height // 10)
title_font = self._menu_font(target_height // 15)
body_font = self._menu_font(target_height // 28)
meta_font = self._menu_font(target_height // 30)
cta_font = self._menu_font(target_height // 22)
hint_font = self._menu_font(target_height // 32)
line_gap = 22 if compact_menu else max(26, target_height // 26)
render_engine.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "start_menu", filling=colors["panel_fill"])
render_engine.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "start_menu", outline=colors["panel_border"])
render_engine.draw_rectangle(panel_x, panel_y, panel_width, header_height, "start_menu", filling=colors["header_fill"])
render_engine.draw_text(
f"Welcome to Mice, {self.game.profile_integration.get_profile_name()}!",
title_font,
("center", panel_y + max(12, header_height // 5)),
colors["text"],
)
animation_frame, animation_size = self._current_start_menu_animation_frame()
animation_bottom = panel_y + header_height + 18
if animation_frame is not None:
max_animation_width = panel_width - (70 if compact_menu else 110)
display_width = min(animation_size[0], max_animation_width)
scale_factor = display_width / animation_size[0]
display_height = max(1, int(animation_size[1] * scale_factor))
animation_x = target_width // 2 - display_width // 2
animation_y = panel_y + header_height + (18 if compact_menu else 26)
render_engine.draw_image(
animation_x - render_engine.w_offset,
animation_y - render_engine.h_offset,
animation_frame,
"start_menu",
source_rect=(0, 0, animation_size[0], animation_size[1]),
dest_size=(display_width, display_height),
)
animation_bottom = animation_y + display_height
player_profile = self.game.profile_integration.current_profile
device_id = self.game.profile_integration.get_device_id()
subtitle_lines = ["A game by Matteo, because he was bored."]
if player_profile:
if compact_menu:
subtitle_lines.append(
f"Best: {player_profile['best_score']} | Games: {player_profile['games_played']}"
)
else:
subtitle_lines.append(f"Device: {device_id}")
subtitle_lines.append(
f"Best Score: {player_profile['best_score']} | Games: {player_profile['games_played']}"
)
elif compact_menu:
subtitle_lines.append(f"Guest profile | {device_id}")
else:
subtitle_lines.append(f"Device: {device_id}")
subtitle_lines.append("No profile loaded - playing as guest")
info_y = animation_bottom + (18 if compact_menu else 22)
for index, line in enumerate(subtitle_lines):
render_engine.draw_text(
line,
body_font if index == 0 else meta_font,
("center", info_y + line_gap * index),
colors["text"] if index == 0 else colors["muted"],
)
difficulty_width = max(360, min(panel_width - 160, 760))
difficulty_height = 54 if compact_menu else max(72, target_height // 9)
difficulty_x = target_width // 2 - difficulty_width // 2
difficulty_y = info_y + line_gap * len(subtitle_lines) + (12 if compact_menu else 18)
self._draw_start_menu_difficulty_selector(
difficulty_x,
difficulty_y,
difficulty_width,
difficulty_height,
)
cta_y = difficulty_y + difficulty_height + (12 if compact_menu else 24)
render_engine.draw_text(
"Press Return to start",
cta_font,
("center", cta_y),
colors["text"],
)
if compact_menu:
render_engine.draw_text(
"Arrows change difficulty",
hint_font,
("center", cta_y + line_gap),
colors["muted"],
)
render_engine.draw_text(
"Esc quits M toggles audio",
hint_font,
("center", cta_y + line_gap + 18),
colors["muted"],
)
else:
render_engine.draw_text(
"Arrows change difficulty Esc quits M toggles audio",
hint_font,
("center", cta_y + line_gap + 8),
colors["muted"],
)
def render_pause_menu(self):
subtitle_lines = [
f"Level {self.game.current_level + 1} | Points: {self.game.points}",
f"Rats in maze: {self.game.unit_manager.count_rats()}",
]
self._render_audio_menu(
title="Pause",
subtitle_lines=subtitle_lines,
primary_action_text="Press Return to resume",
hint_text="Up/Down select Left/Right adjust Esc quits",
image_name="BMP_PAUSE" if "BMP_PAUSE" in self.assets else "BMP_WEWIN",
)
self.render_engine.scroll_view(self.pointer)

23
engine/scoring.py

@ -8,19 +8,22 @@ SCORES_FILE = persistent_data_path("scores.txt", default_text="")
class Scoring:
def __init__(self, game):
self.game = game
# ==================== SCORING ====================
def save_score(self):
# Save to traditional scores.txt file
with SCORES_FILE.open("a", encoding="utf-8") as f:
player_name = getattr(self, 'profile_integration', None)
if player_name and hasattr(player_name, 'get_profile_name'):
name = player_name.get_profile_name()
device_id = player_name.get_device_id()
f.write(f"{datetime.datetime.now()} - {self.points} - {name} - {device_id}\n")
profile_integration = getattr(self.game, 'profile_integration', None)
if profile_integration and hasattr(profile_integration, 'get_profile_name'):
name = profile_integration.get_profile_name()
device_id = profile_integration.get_device_id()
f.write(f"{datetime.datetime.now()} - {self.game.points} - {name} - {device_id}\n")
else:
f.write(f"{datetime.datetime.now()} - {self.points} - Guest\n")
f.write(f"{datetime.datetime.now()} - {self.game.points} - Guest\n")
def read_score(self):
table = []
try:
@ -40,6 +43,6 @@ class Scoring:
except FileNotFoundError:
pass
return table[:5] # Return top 5 scores instead of 3
def add_point(self, value):
self.points += value
self.game.points += value

42
engine/state_machine.py

@ -0,0 +1,42 @@
from enum import Enum, auto
class GameState(Enum):
START_MENU = auto()
PLAYING = auto()
PAUSED = auto()
GAME_OVER = auto()
VICTORY = auto()
class StateMachine:
def __init__(self, game):
self.game = game
self.current_state = GameState.START_MENU
def transition_to(self, new_state):
print(f"[state] Transitioning from {self.current_state} to {new_state}")
self.current_state = new_state
# Sincronizzazione per compatibilità con KeyBindings e logica esistente
if new_state == GameState.PLAYING:
self.game.game_status = "game"
self.game.menu_screen = None
elif new_state == GameState.PAUSED:
self.game.game_status = "paused"
self.game.menu_screen = None
elif new_state == GameState.START_MENU:
self.game.game_status = "start_menu"
self.game.menu_screen = "start"
elif new_state in [GameState.GAME_OVER, GameState.VICTORY]:
self.game.game_status = "paused" # Legacy status used for key handling in end screens
self.game.menu_screen = None
def update(self):
# Dispatch alla logica originale ripristinata in MiceMaze/Graphics
if self.current_state == GameState.START_MENU:
self.game.graphics.render_start_menu()
elif self.current_state == GameState.PAUSED:
self.game.graphics.render_pause_menu()
elif self.current_state in [GameState.GAME_OVER, GameState.VICTORY]:
# Delega al metodo game_over originale che gestisce i dialoghi specifici
self.game.game_over()

67
engine/unit_manager.py

@ -5,23 +5,26 @@ from units import gas, rat, bomb, mine
class UnitManager:
def __init__(self, game):
self.game = game
def _spawnable_rat_positions(self):
positions = []
for y in range(1, self.map.height - 1):
for x in range(1, self.map.width - 1):
if not self.map.is_empty(x, y):
for y in range(1, self.game.map.height - 1):
for x in range(1, self.game.map.width - 1):
if not self.game.map.is_empty(x, y):
continue
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx = x + dx
ny = y + dy
if self.map.in_bounds(nx, ny) and self.map.is_empty(nx, ny):
if self.game.map.in_bounds(nx, ny) and self.game.map.is_empty(nx, ny):
positions.append((x, y))
break
return positions
def has_weapon_at(self, position):
"""Check if there's a weapon (bomb, gas, mine) at the given position"""
for unit in self.units.values():
for unit in self.game.units.values():
if unit.position == position:
# Check if it's a weapon type (not a rat or points)
if isinstance(unit, (bomb.Timer, bomb.NuclearBomb, gas.Gas, mine.Mine)):
@ -30,9 +33,9 @@ class UnitManager:
def can_place_weapon_at(self, position):
x, y = position
if not self.map.in_bounds(x, y):
if not self.game.map.in_bounds(x, y):
return False
if not self.map.is_empty(x, y):
if not self.game.map.is_empty(x, y):
return False
if self.has_weapon_at(position):
return False
@ -40,19 +43,19 @@ class UnitManager:
def count_rats(self):
count = 0
for unit in self.units.values():
for unit in self.game.units.values():
if isinstance(unit, rat.Rat):
count += 1
return count
def spawn_gas(self, parent_id=None):
if not self.can_place_weapon_at(self.pointer):
if not self.can_place_weapon_at(self.game.pointer):
return
if self.ammo["gas"]["count"] <= 0:
if self.game.ammo["gas"]["count"] <= 0:
return
self.ammo["gas"]["count"] -= 1
self.render_engine.play_sound("GAS.WAV")
self.spawn_unit(gas.Gas, self.pointer, parent_id=parent_id)
self.game.ammo["gas"]["count"] -= 1
self.game.render_engine.play_sound("GAS.WAV")
self.spawn_unit(gas.Gas, self.game.pointer, parent_id=parent_id)
def spawn_rat(self, position=None):
if position is None:
@ -68,9 +71,9 @@ class UnitManager:
# Try nearby positions
for dx, dy in [(0,1), (1,0), (0,-1), (-1,0), (1,1), (-1,-1), (1,-1), (-1,1)]:
alt_pos = (position[0] + dx, position[1] + dy)
if not self.map.in_bounds(alt_pos[0], alt_pos[1]):
if not self.game.map.in_bounds(alt_pos[0], alt_pos[1]):
continue
if self.map.is_empty(alt_pos[0], alt_pos[1]) and not self.has_weapon_at(alt_pos):
if self.game.map.is_empty(alt_pos[0], alt_pos[1]) and not self.has_weapon_at(alt_pos):
position = alt_pos
break
else:
@ -84,45 +87,45 @@ class UnitManager:
def spawn_bomb(self, position):
if not self.can_place_weapon_at(position):
return
if self.ammo["bomb"]["count"] <= 0:
if self.game.ammo["bomb"]["count"] <= 0:
return
self.render_engine.play_sound("PUTDOWN.WAV")
self.game.render_engine.play_sound("PUTDOWN.WAV")
self.spawn_unit(bomb.Timer, position)
self.ammo["bomb"]["count"] -= 1
self.game.ammo["bomb"]["count"] -= 1
def spawn_nuclear_bomb(self, position):
"""Spawn a nuclear bomb at the specified position"""
if self.ammo["nuclear"]["count"] <= 0:
if self.game.ammo["nuclear"]["count"] <= 0:
return
if not self.can_place_weapon_at(position):
return
self.render_engine.play_sound("NUCLEAR.WAV")
self.ammo["nuclear"]["count"] -= 1
self.game.render_engine.play_sound("NUCLEAR.WAV")
self.game.ammo["nuclear"]["count"] -= 1
self.spawn_unit(bomb.NuclearBomb, position)
def spawn_mine(self, position):
if self.ammo["mine"]["count"] <= 0:
if self.game.ammo["mine"]["count"] <= 0:
return
if not self.can_place_weapon_at(position):
return
self.render_engine.play_sound("PUTDOWN.WAV")
self.ammo["mine"]["count"] -= 1
self.game.render_engine.play_sound("PUTDOWN.WAV")
self.game.ammo["mine"]["count"] -= 1
self.spawn_unit(mine.Mine, position, on_bottom=True)
def spawn_unit(self, unit, position, on_bottom=False, **kwargs):
id = uuid.uuid4()
if on_bottom:
self.units = {id: unit(self, position, id, **kwargs), **self.units}
self.game.units = {id: unit(self.game, position, id, **kwargs), **self.game.units}
else:
self.units[id] = unit(self, position, id, **kwargs)
self.game.units[id] = unit(self.game, position, id, **kwargs)
def choose_start(self):
if not hasattr(self, '_valid_positions') or self._valid_positions is None:
self._valid_positions = self._spawnable_rat_positions()
print(f"[flow] choose_start computed {len(self._valid_positions)} spawnable cells", flush=True)
if not self._valid_positions:
if not hasattr(self.game, '_valid_positions') or self.game._valid_positions is None:
self.game._valid_positions = self._spawnable_rat_positions()
print(f"[flow] choose_start computed {len(self.game._valid_positions)} spawnable cells", flush=True)
if not self.game._valid_positions:
return None
return random.choice(self._valid_positions)
return random.choice(self.game._valid_positions)
def get_unit_by_id(self, id):
return self.units.get(id) or None
return self.game.units.get(id) or None

182
packaging/deploy_koriki.sh

@ -0,0 +1,182 @@
#!/usr/bin/env bash
# Deploy Mice! to a Koriki CFW device over SSH.
#
# Requirements on host:
# sshpass, tar, ssh
#
# Target: koriki@<IP> (default 10.0.0.199)
# - ARMv7l Linux, glibc 2.28
# - Python 3.11 archive already present at /mnt/SDCARD/python_armv7.tar.gz
# - SDL2 libs in /mnt/SDCARD/Koriki/lib/
# - Internet access via WiFi
#
# Usage:
# ./packaging/deploy_koriki.sh [TARGET_IP]
set -euo pipefail
TARGET_IP="${1:-10.0.0.199}"
TARGET_USER="${TARGET_USER:-koriki}"
TARGET_PASS="${TARGET_PASS:-koriki}"
SDCARD="/mnt/SDCARD"
PYTHON_ARCHIVE="${SDCARD}/python_armv7.tar.gz"
PYTHON_DIR="${SDCARD}/python"
GAME_DIR="${SDCARD}/Ports/mice"
KORIKI_LIB="${SDCARD}/Koriki/lib"
VENDOR_DIR="${GAME_DIR}/vendor"
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
ssh_cmd() {
sshpass -p "$TARGET_PASS" ssh \
-o StrictHostKeyChecking=no \
-o ConnectTimeout=10 \
-o PreferredAuthentications=password \
-o PubkeyAuthentication=no \
"${TARGET_USER}@${TARGET_IP}" "$@"
}
log() { printf '==> %s\n' "$*"; }
# ── 1. Estrai Python 3.11 ──────────────────────────────────────────────────────
log "Step 1: Extracting Python 3.11 on device"
ssh_cmd "
if [ ! -f '${PYTHON_DIR}/bin/python3.11' ]; then
echo 'Extracting Python archive...'
tar -xzf '${PYTHON_ARCHIVE}' -C '${SDCARD}'
echo 'Done'
else
echo 'Python 3.11 already extracted, skipping'
fi
"
# ── 1b. Fix FAT32: i symlink non possono essere creati su vfat; li sostituiamo
# con copie reali dei file critici
log "Step 1b: Fixing missing symlinks on FAT32 (copying critical binaries)"
ssh_cmd "
PYBIN='${PYTHON_DIR}/bin'
PYLIB='${PYTHON_DIR}/lib'
# Copie necessarie all'interprete
[ ! -f \"\${PYBIN}/python3\" ] && cp \"\${PYBIN}/python3.11\" \"\${PYBIN}/python3\"
[ ! -f \"\${PYBIN}/python\" ] && cp \"\${PYBIN}/python3.11\" \"\${PYBIN}/python\"
[ ! -f \"\${PYBIN}/pip3\" ] && [ -f \"\${PYBIN}/pip3.11\" ] && cp \"\${PYBIN}/pip3.11\" \"\${PYBIN}/pip3\"
# Libreria condivisa
[ ! -f \"\${PYLIB}/libpython3.11.so\" ] && cp \"\${PYLIB}/libpython3.11.so.1.0\" \"\${PYLIB}/libpython3.11.so\"
echo 'Symlink workaround done'
"
# ── 2. Installa dipendenze Python ──────────────────────────────────────────────
log "Step 2: Installing Python dependencies (wheel download + extract)"
ssh_cmd "
PYTHON='${PYTHON_DIR}/bin/python3.11'
export TMPDIR='${GAME_DIR}/tmp'
export LD_LIBRARY_PATH='${PYTHON_DIR}/lib:${KORIKI_LIB}'
# pip --target su FAT32 puo fallire (rename di file temporanei).
# Workaround: scarichiamo wheel in /tmp e li estraiamo manualmente in vendor.
mkdir -p '${VENDOR_DIR}' '${GAME_DIR}/tmp' '${GAME_DIR}/tmp/mice_wheels'
rm -f '${GAME_DIR}/tmp/mice_wheels'/*.whl
\"\$PYTHON\" -m pip download --no-cache-dir \
--extra-index-url https://www.piwheels.org/simple/ \
--dest '${GAME_DIR}/tmp/mice_wheels' \
pysdl2 \
'Pillow>=10.0' \
'numpy>=1.26' \
pyaml \
requests
\"\$PYTHON\" - << 'PY'
import glob
import os
import zipfile
vendor = '${VENDOR_DIR}'
wheels = sorted(glob.glob('${GAME_DIR}/tmp/mice_wheels/*.whl'))
if not wheels:
raise SystemExit('No wheels downloaded')
for whl in wheels:
print('Extracting', os.path.basename(whl))
with zipfile.ZipFile(whl) as zf:
zf.extractall(vendor)
print('Dependencies extracted to', vendor)
PY
echo 'Dependencies installed (wheel extraction)'
"
# ── 3. Crea directory di gioco ─────────────────────────────────────────────────
log "Step 3: Creating game directory ${GAME_DIR}"
ssh_cmd "mkdir -p '${GAME_DIR}'"
# ── 4. Trasferisci il gioco ────────────────────────────────────────────────────
# Pipe diretta tar → tar: evita di scrivere file intermedi in /tmp (tmpfs 49MB)
log "Step 4: Transferring game files via direct pipe (no tmp file)"
tar czf - \
-C "$ROOT_DIR" \
--exclude='.git' \
--exclude='.venv' \
--exclude='venv' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='dist' \
--exclude='logs' \
--exclude='packaging' \
--exclude='tools' \
--exclude='server' \
--exclude='*.tar.gz' \
. \
| sshpass -p "$TARGET_PASS" ssh \
-o StrictHostKeyChecking=no \
"${TARGET_USER}@${TARGET_IP}" \
"mkdir -p '${GAME_DIR}' && tar -xzf - -C '${GAME_DIR}' && echo 'Game extracted'"
# ── 5. Crea il launcher ────────────────────────────────────────────────────────
log "Step 5: Creating launcher script"
LAUNCHER_CONTENT="#!/bin/sh
# Mice! launcher for Koriki CFW
export PYTHON_DIR=\"${PYTHON_DIR}\"
export PATH=\"\${PYTHON_DIR}/bin:\$PATH\"
export PYTHONPATH=\"${VENDOR_DIR}:\${PYTHON_DIR}/lib/python3.11/site-packages\"
# Puntiamo PySDL2 alle librerie SDL2 di Koriki
export PYSDL2_DLL_PATH=\"${KORIKI_LIB}\"
export LD_LIBRARY_PATH=\"${KORIKI_LIB}:\${LD_LIBRARY_PATH:-}\"
# Root del progetto e dati persistenti
export MICE_PROJECT_ROOT=\"${GAME_DIR}\"
export MICE_DATA_DIR=\"\${SDCARD}/.mice_data\"
mkdir -p \"\${MICE_DATA_DIR}\"
cd \"\${MICE_PROJECT_ROOT}\"
exec \"\${PYTHON_DIR}/bin/python3\" rats.py \"\$@\"
"
ssh_cmd "cat > '${SDCARD}/Ports/mice.sh'" <<< "$LAUNCHER_CONTENT"
ssh_cmd "chmod +x '${SDCARD}/Ports/mice.sh'"
log "Launcher written to ${SDCARD}/Ports/mice.sh"
# ── 6. Test rapido ─────────────────────────────────────────────────────────────
log "Step 6: Quick smoke test"
ssh_cmd "
export PATH='${PYTHON_DIR}/bin:\$PATH'
export PYSDL2_DLL_PATH='${KORIKI_LIB}'
export LD_LIBRARY_PATH='${PYTHON_DIR}/lib:${KORIKI_LIB}'
export PYTHONPATH='${VENDOR_DIR}:${PYTHON_DIR}/lib/python3.11/site-packages'
python3 -c \"
import sys
print('Python', sys.version)
import sdl2; print('PySDL2 OK:', sdl2.__version__)
import numpy; print('NumPy OK:', numpy.__version__)
import PIL; print('Pillow OK:', PIL.__version__)
import yaml; print('pyaml OK')
import requests; print('requests OK')
print('All dependencies OK')
\"
"
log "Deployment complete!"
log "Run the game from Ports > mice on Koriki, or manually:"
log " ssh ${TARGET_USER}@${TARGET_IP} '${SDCARD}/Ports/mice.sh'"

656
rats.py

@ -6,87 +6,15 @@ import os
import json
import time
from engine import maze, sdl2 as engine, controls, graphics, unit_manager, scoring
from engine import maze, sdl2 as engine, controls, graphics, unit_manager, scoring, state_machine, config
from engine.state_machine import GameState
from engine.collision_system import CollisionSystem
from units import points
from engine.user_profile_integration import UserProfileIntegration
from runtime_paths import bundle_path
LEVEL_MUSIC_CONFIG = "level_music"
START_MENU_MUSIC = "High_Score_Garden.mp3"
RUN_COMPLETE_MUSIC = "Sunset_At_Pixel_Gardens.mp3"
START_MENU_ANIMATION = "anim/start_mice.gif"
SUPPORTED_MUSIC_EXTENSIONS = {".mp3", ".ogg", ".wav"}
BASE_INITIAL_RATS = 5
DEFAULT_DIFFICULTY = "easy"
DIFFICULTY_ALIASES = {
"medium": "normal",
"normale": "normal",
}
START_MENU_AUDIO_OPTIONS = (
("sound_volume", "Suono"),
("music_volume", "Musica"),
)
VOLUME_STEP = 5
GAME_END_LEVEL_CLEAR = "level_clear"
GAME_END_DEFEAT = "defeat"
GAME_END_RUN_COMPLETE = "run_complete"
DIFFICULTY_OPTIONS = (
{
"key": "easy",
"label": "Easy",
"starting_rats_multiplier": 1,
"speed_multiplier": 1.0,
"fill": (235, 246, 234),
"accent": (88, 148, 82),
},
{
"key": "normal",
"label": "Normal",
"starting_rats_multiplier": 2,
"speed_multiplier": 1.5,
"fill": (252, 242, 223),
"accent": (204, 146, 44),
},
{
"key": "hard",
"label": "Hard",
"starting_rats_multiplier": 3,
"speed_multiplier": 2.0,
"fill": (251, 229, 229),
"accent": (188, 68, 68),
},
)
DIFFICULTY_OPTIONS_BY_KEY = {option["key"]: option for option in DIFFICULTY_OPTIONS}
START_MENU_COLORS = {
"panel_fill": (255, 255, 255),
"panel_border": (52, 52, 52),
"header_fill": (255, 255, 255),
"text": (24, 24, 24),
"muted": (82, 82, 82),
"hint_fill": (255, 255, 255),
"track_fill": (212, 215, 216),
"card_fill": (255, 255, 255),
}
START_MENU_AUDIO_STYLES = {
"sound_volume": {
"accent": (214, 146, 62),
"fill": (251, 241, 225),
},
"music_volume": {
"accent": (91, 122, 208),
"fill": (230, 235, 248),
},
}
class MiceMaze(
controls.KeyBindings,
unit_manager.UnitManager,
graphics.Graphics,
scoring.Scoring
):
class MiceMaze:
# ==================== INITIALIZATION ====================
@ -95,6 +23,13 @@ class MiceMaze(
self.render_engine.show_loading_screen(message, detail=detail, progress=progress)
def __init__(self, maze_file, level_index=0):
self._setup_initial_state(maze_file, level_index)
self._setup_engine()
self._setup_components()
self._setup_game_assets()
self._setup_initial_units()
def _setup_initial_state(self, maze_file, level_index):
# Initialize user profile integration
self.profile_integration = UserProfileIntegration()
self.map_source = maze_file
@ -106,35 +41,55 @@ class MiceMaze(
self.audio = self.profile_integration.get_setting('sound_enabled', True)
self.sound_volume = self.profile_integration.get_setting('sound_volume', 50)
self.music_volume = self.profile_integration.get_setting('music_volume', self.sound_volume)
self.difficulty = DEFAULT_DIFFICULTY
self.initial_rat_count = BASE_INITIAL_RATS
self.difficulty = config.DEFAULT_DIFFICULTY
self.initial_rat_count = config.BASE_INITIAL_RATS
self.rat_speed_multiplier = 1.0
self._apply_difficulty(self.profile_integration.get_setting('difficulty', DEFAULT_DIFFICULTY), persist=False)
self._apply_difficulty(self.profile_integration.get_setting('difficulty', config.DEFAULT_DIFFICULTY), persist=False)
self.cell_size = 40
self.full_screen = False
self.loaded_theme_index = None
self.start_menu_selection = 0
# Initialize render engine with profile-aware title
self.points = 0
self.units = {}
self.unit_positions = {}
self.unit_positions_before = {}
self.scrolling_direction = None
self.game_status = "start_menu"
self.menu_screen = "start"
self.game_end = (False, None)
self.run_recorded = False
self.scrolling = False
self.sounds = {}
self.background_texture = None
self.combined_scores = None
def _setup_engine(self):
player_name = self.profile_integration.get_profile_name()
window_title = f"Mice! - {player_name}"
self.render_engine = engine.GameWindow(self.map.width, self.map.height,
self.cell_size, window_title,
key_callback=self.trigger)
key_callback=lambda action: self.controls.trigger(action))
self.render_engine.audio = self.audio
self.startup_loading_active = True
self._update_startup_loading("Loading Mice!", detail="Applying player settings", progress=0.08)
# Apply profile settings
# Apply profile volumes
if hasattr(self.render_engine, 'set_sound_volume'):
self.render_engine.set_sound_volume(self.sound_volume)
if hasattr(self.render_engine, 'set_music_volume'):
self.render_engine.set_music_volume(self.music_volume)
elif hasattr(self.render_engine, 'set_volume'):
self.render_engine.set_volume(self.music_volume)
self.initialize_keybindings()
def _setup_components(self):
self.scoring = scoring.Scoring(self)
self.unit_manager = unit_manager.UnitManager(self)
self.graphics = graphics.Graphics(self)
self.controls = controls.KeyBindings(self)
self.state_machine = state_machine.StateMachine(self)
self.collision_system = CollisionSystem(self.cell_size, self.map.width, self.map.height)
def _setup_game_assets(self):
self._update_startup_loading("Loading Mice!", detail="Applying player settings", progress=0.08)
self.controls.initialize_keybindings()
self._update_startup_loading("Loading configuration", detail="Reading bundled settings", progress=0.14)
self.configs = self.get_config()
@ -144,46 +99,26 @@ class MiceMaze(
self.current_level_music = None
self._update_startup_loading("Loading graphics", detail="Preparing common assets", progress=0.26)
self.load_assets()
self.graphics.load_assets()
self._update_startup_loading("Loading start menu", detail="Preparing menu animation", progress=0.9)
self.start_menu_animation = self.render_engine.load_animation(START_MENU_ANIMATION)
self.start_menu_animation = self.render_engine.load_animation(config.START_MENU_ANIMATION)
self.start_menu_animation_started_at = time.monotonic()
def _setup_initial_units(self):
self._update_startup_loading("Starting", detail="Opening intro screen", progress=0.98)
self.render_engine.show_intro(bundle_path("assets", "Rat", "intro.png"))
self.startup_loading_active = False
self.pointer = (random.randint(1, self.map.width-2), random.randint(1, self.map.height-2))
self.scroll_cursor()
self.points = 0
self.units = {}
# Initialize optimized collision system with NumPy
self.collision_system = CollisionSystem(
self.cell_size,
self.map.width,
self.map.height
)
# Keep old dictionaries for backward compatibility (can be removed later)
self.unit_positions = {}
self.unit_positions_before = {}
self.pointer = (random.randint(1, self.map.width-2), random.randint(1, self.map.height-2))
self.graphics.scroll_cursor()
self.scrolling_direction = None
self.game_status = "start_menu"
self.menu_screen = "start"
self.game_end = (False, None)
self.run_recorded = False
self.scrolling = False
self.sounds = {}
self.start_game()
self.background_texture = None
self.combined_scores = None
def get_config(self):
configs = {}
conf_dir = bundle_path("conf")
for file in os.listdir(conf_dir):
for file in sorted(os.listdir(conf_dir)):
if file.endswith(".json"):
with open(os.path.join(conf_dir, file), encoding="utf-8") as f:
configs[file[:-5]] = json.load(f)
@ -194,7 +129,7 @@ class MiceMaze(
tracks = []
for file_name in sorted(os.listdir(music_dir)):
_, extension = os.path.splitext(file_name)
if extension.lower() not in SUPPORTED_MUSIC_EXTENSIONS:
if extension.lower() not in config.SUPPORTED_MUSIC_EXTENSIONS:
continue
if os.path.isfile(os.path.join(music_dir, file_name)):
tracks.append(file_name)
@ -202,7 +137,7 @@ class MiceMaze(
def _resolve_level_music(self, level_index):
level_number = level_index + 1
level_music_config = self.configs.get(LEVEL_MUSIC_CONFIG, {})
level_music_config = self.configs.get(config.LEVEL_MUSIC_CONFIG, {})
configured_tracks = level_music_config.get("levels", {})
configured_track = configured_tracks.get(str(level_number))
@ -222,21 +157,21 @@ class MiceMaze(
return fallback_track
def _normalize_difficulty(self, difficulty_key):
difficulty_key = DIFFICULTY_ALIASES.get(difficulty_key, difficulty_key)
if difficulty_key in DIFFICULTY_OPTIONS_BY_KEY:
difficulty_key = config.DIFFICULTY_ALIASES.get(difficulty_key, difficulty_key)
if difficulty_key in config.DIFFICULTY_OPTIONS_BY_KEY:
return difficulty_key
return DEFAULT_DIFFICULTY
return config.DEFAULT_DIFFICULTY
def _difficulty_config(self):
return DIFFICULTY_OPTIONS_BY_KEY[self.difficulty]
return config.DIFFICULTY_OPTIONS_BY_KEY[self.difficulty]
def _apply_difficulty(self, difficulty_key, persist=True):
normalized_difficulty = self._normalize_difficulty(difficulty_key)
difficulty_config = DIFFICULTY_OPTIONS_BY_KEY[normalized_difficulty]
difficulty_config = config.DIFFICULTY_OPTIONS_BY_KEY[normalized_difficulty]
self.difficulty = normalized_difficulty
self.initial_rat_count = max(
1,
int(round(BASE_INITIAL_RATS * difficulty_config["starting_rats_multiplier"])),
int(round(config.BASE_INITIAL_RATS * difficulty_config["starting_rats_multiplier"])),
)
self.rat_speed_multiplier = difficulty_config["speed_multiplier"]
if persist:
@ -245,20 +180,20 @@ class MiceMaze(
def _can_adjust_start_difficulty(self):
if self.game_end[0]:
return False
return self.game_status == "start_menu" and self.menu_screen == "start"
return self.state_machine.current_state == GameState.START_MENU
def _cycle_difficulty(self, delta):
if not self._can_adjust_start_difficulty():
return
current_index = 0
for index, option in enumerate(DIFFICULTY_OPTIONS):
for index, option in enumerate(config.DIFFICULTY_OPTIONS):
if option["key"] == self.difficulty:
current_index = index
break
next_index = (current_index + delta) % len(DIFFICULTY_OPTIONS)
self._apply_difficulty(DIFFICULTY_OPTIONS[next_index]["key"])
next_index = (current_index + delta) % len(config.DIFFICULTY_OPTIONS)
self._apply_difficulty(config.DIFFICULTY_OPTIONS[next_index]["key"])
def _is_dat_campaign(self):
return self.map.source_path.suffix.lower() == ".dat"
@ -273,7 +208,7 @@ class MiceMaze(
def _record_run_result(self, completed):
if not self.run_recorded:
self.save_score()
self.scoring.save_score()
self.profile_integration.update_game_stats(self.points, completed=completed)
self.run_recorded = True
self.combined_scores = self.profile_integration.get_device_leaderboard(5)
@ -282,9 +217,8 @@ class MiceMaze(
if score is not None:
self.points = max(0, int(score))
self.current_level = max(0, self.total_levels - 1)
self.game_status = "paused"
self.menu_screen = None
self.game_end = (True, GAME_END_RUN_COMPLETE)
self.state_machine.transition_to(GameState.PAUSED)
self.game_end = (True, config.GAME_END_RUN_COMPLETE)
self.run_recorded = True
self.combined_scores = self.profile_integration.get_device_leaderboard(5)
print(
@ -325,8 +259,8 @@ class MiceMaze(
self.background_texture = None
# Clear blood layer on game start/restart
self.blood_layer_sprites.clear()
self.cave_foreground_tiles.clear()
self.graphics.blood_layer_sprites.clear()
self.graphics.cave_foreground_tiles.clear()
self.game_end = (False, None)
self.game_status = "start_menu"
self.menu_screen = "start"
@ -335,10 +269,10 @@ class MiceMaze(
self.unit_positions_before.clear()
self.points = 0
self.pointer = (random.randint(1, self.map.width-2), random.randint(1, self.map.height-2))
self.scroll_cursor()
self.graphics.scroll_cursor()
for _ in range(self.initial_rat_count):
self.spawn_rat()
self.unit_manager.spawn_rat()
def load_level(self, level_index, preserve_points=True, show_menu=False, menu_screen=None):
target_level_index = self._normalize_target_level(level_index)
@ -359,20 +293,20 @@ class MiceMaze(
self.map.height
)
if getattr(self, "loaded_theme_index", None) != next_theme_index:
if self.graphics.loaded_theme_index != next_theme_index:
print(
f"[flow] theme switch needed: loaded_theme={getattr(self, 'loaded_theme_index', None)} "
f"[flow] theme switch needed: loaded_theme={self.graphics.loaded_theme_index} "
f"-> next_theme={next_theme_index}"
)
self.load_assets()
self.graphics.load_assets()
else:
print(f"[flow] theme unchanged: reusing theme {next_theme_index}")
self.units.clear()
self.unit_positions.clear()
self.unit_positions_before.clear()
self.blood_stains = {}
self.blood_layer_sprites.clear()
self.cave_foreground_tiles.clear()
self.graphics.blood_layer_sprites.clear()
self.graphics.cave_foreground_tiles.clear()
self.background_texture = None
self.ammo = {
"bomb": {"count": 2, "max": 8},
@ -383,7 +317,7 @@ class MiceMaze(
self.combined_scores = None
self.game_end = (False, None)
self.pointer = (random.randint(1, self.map.width-2), random.randint(1, self.map.height-2))
self.scroll_cursor()
self.graphics.scroll_cursor()
if not preserve_points:
self.points = 0
self.run_recorded = False
@ -395,16 +329,18 @@ class MiceMaze(
flush=True,
)
for _ in range(self.initial_rat_count):
self.spawn_rat()
self.unit_manager.spawn_rat()
if show_menu:
self.game_status = "start_menu"
self.menu_screen = menu_screen or "level_intro"
print(f"[flow] level loaded into menu state: menu_screen={self.menu_screen} points={self.points}")
if menu_screen == "start":
self.state_machine.transition_to(GameState.START_MENU)
else:
self.state_machine.transition_to(GameState.PLAYING)
print(f"[flow] level loaded into state: {self.state_machine.current_state} points={self.points}")
else:
self.game_status = "game"
self.menu_screen = None
print(f"[flow] level loaded directly into gameplay: level={self.current_level + 1} points={self.points}")
self.state_machine.transition_to(GameState.PLAYING)
print(f"[flow] level loaded directly into PLAYING: points={self.points}")
def advance_level(self):
print(f"[flow] advance_level called from level={self.current_level + 1} points={self.points}")
@ -414,8 +350,8 @@ class MiceMaze(
if self._is_last_dat_level():
print("[flow] advance_level -> reached end of DAT campaign")
self.game_end = (True, GAME_END_RUN_COMPLETE)
self.game_status = "paused"
self.game_end = (True, config.GAME_END_RUN_COMPLETE)
self.state_machine.transition_to(GameState.VICTORY)
self._record_run_result(completed=True)
return
@ -425,14 +361,14 @@ class MiceMaze(
def reset_game(self):
print(
f"[flow] reset_game called: game_end={self.game_end} game_status={self.game_status} "
f"[flow] reset_game called: game_end={self.game_end} state={self.state_machine.current_state} "
f"menu_screen={self.menu_screen} points={self.points} level={self.current_level + 1}"
)
if self.game_end[0]:
if self.game_end[1] == GAME_END_LEVEL_CLEAR:
if self.game_end[1] == config.GAME_END_LEVEL_CLEAR:
print("[flow] reset_game -> post-victory path")
self.advance_level()
elif self.game_end[1] == GAME_END_RUN_COMPLETE:
elif self.game_end[1] == config.GAME_END_RUN_COMPLETE:
print("[flow] reset_game -> DAT run complete, returning to start menu")
self.start_menu_animation_started_at = time.monotonic()
self.load_level(0, preserve_points=False, show_menu=True, menu_screen="start")
@ -442,18 +378,14 @@ class MiceMaze(
self.load_level(0, preserve_points=False, show_menu=True, menu_screen="start")
return
if self.game_status == "paused":
if self.state_machine.current_state == GameState.PAUSED:
print("[flow] reset_game -> unpausing current level")
self.game_status = "game"
self.state_machine.transition_to(GameState.PLAYING)
return
if self.game_status == "start_menu":
print(f"[flow] reset_game -> leaving menu_screen={self.menu_screen} and entering gameplay")
if self.menu_screen == "start":
self.load_level(0, preserve_points=False, show_menu=False)
return
self.game_status = "game"
self.menu_screen = None
if self.state_machine.current_state == GameState.START_MENU:
print(f"[flow] reset_game -> entering gameplay")
self.state_machine.transition_to(GameState.PLAYING)
return
print("[flow] reset_game -> hard reload current level")
@ -477,310 +409,7 @@ class MiceMaze(
def _can_adjust_audio_menu(self):
if self.game_end[0]:
return False
return self.game_status == "paused"
def _draw_start_menu_difficulty_selector(self, x, y, width, height):
colors = START_MENU_COLORS
difficulty_config = self._difficulty_config()
accent = difficulty_config["accent"]
render_engine = self.render_engine
center_x = x + width // 2
compact_selector = height <= 72
title_font = self._menu_font(render_engine.target_size[1] // 38)
value_font = self._menu_font(render_engine.target_size[1] // 23)
arrow_font = value_font
section_gap = 4 if compact_selector else 6
title_line_height = max(14, render_engine.target_size[1] // 32)
value_line_height = max(18, render_engine.target_size[1] // 24)
current_y = y + 2
render_engine.draw_text("Difficulty", title_font, ("center", current_y), colors["muted"])
current_y += title_line_height + section_gap
arrow_offset = max(34, min(52, width // 7))
arrow_y = current_y - (1 if compact_selector else 0)
render_engine.draw_text("<", arrow_font, (center_x - arrow_offset, arrow_y), colors["muted"])
render_engine.draw_text(">", arrow_font, (center_x + arrow_offset, arrow_y), colors["muted"])
render_engine.draw_text(
difficulty_config["label"],
value_font,
("center", current_y),
accent,
)
current_y += value_line_height
def _menu_font(self, size):
clamped = max(10, min(69, int(size)))
return self.render_engine.fonts[clamped]
def _draw_start_menu_slider(self, x, y, width, height, setting_name, label, value, selected):
colors = START_MENU_COLORS
style = START_MENU_AUDIO_STYLES[setting_name]
accent = style["accent"]
fill_color = style["fill"] if selected else colors["card_fill"]
border_color = accent if selected else (156, 156, 156)
text_color = colors["text"]
render_engine = self.render_engine
render_engine.draw_rectangle(x, y, width, height, "start_menu_slider", filling=fill_color)
render_engine.draw_rectangle(x, y, width, height, "start_menu_slider", outline=border_color)
if selected:
render_engine.draw_rectangle(x + 12, y + 10, 8, height - 20, "start_menu_slider", filling=accent)
title_y = y + 10
render_engine.draw_text(label, self._menu_font(render_engine.target_size[1] // 32), (x + 34, title_y), text_color)
render_engine.draw_text(f"{value}%", self._menu_font(render_engine.target_size[1] // 34), (x + width - 84, title_y + 2), text_color)
track_x = x + 34
track_y = y + height - 26
track_width = width - 68
track_height = 14
filled_width = int(track_width * value / 100)
knob_width = 14
knob_x = track_x + int((track_width - knob_width) * value / 100)
render_engine.draw_rectangle(track_x, track_y, track_width, track_height, "start_menu_slider", filling=colors["track_fill"])
render_engine.draw_rectangle(track_x, track_y, track_width, track_height, "start_menu_slider", outline=(128, 128, 128))
if filled_width > 0:
render_engine.draw_rectangle(track_x, track_y, filled_width, track_height, "start_menu_slider", filling=accent)
render_engine.draw_rectangle(knob_x, track_y - 4, knob_width, track_height + 8, "start_menu_slider", filling=(255, 255, 255))
render_engine.draw_rectangle(knob_x, track_y - 4, knob_width, track_height + 8, "start_menu_slider", outline=accent)
def _current_start_menu_animation_frame(self):
animation = getattr(self, "start_menu_animation", None)
if not animation or not animation["frames"]:
return None, (0, 0)
if len(animation["frames"]) == 1 or animation["total_duration"] <= 0:
return animation["frames"][0], animation["size"]
elapsed_ms = int((time.monotonic() - self.start_menu_animation_started_at) * 1000)
current_offset = elapsed_ms % animation["total_duration"]
accumulated = 0
for index, duration in enumerate(animation["durations"]):
accumulated += duration
if current_offset < accumulated:
return animation["frames"][index], animation["size"]
return animation["frames"][-1], animation["size"]
def _render_audio_menu(self, title, subtitle_lines, primary_action_text, hint_text, image_name="BMP_WEWIN"):
colors = START_MENU_COLORS
render_engine = self.render_engine
target_width, target_height = render_engine.target_size
compact_menu = target_height <= 540
panel_x = max(48, target_width // 12)
panel_y = max(34, target_height // 18)
panel_width = target_width - panel_x * 2
panel_height = target_height - panel_y * 2
header_height = max(44, target_height // 13)
render_engine.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "start_menu", filling=colors["panel_fill"])
render_engine.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "start_menu", outline=colors["panel_border"])
render_engine.draw_rectangle(panel_x, panel_y, panel_width, header_height, "start_menu", filling=colors["header_fill"])
render_engine.draw_text(
title,
self._menu_font(target_height // 20),
("center", panel_y + 16),
colors["text"],
)
image = self.assets[image_name]
image_width, image_height = render_engine.get_image_size(image)
image_y = panel_y + header_height + (14 if compact_menu else 18)
render_engine.draw_image(
target_width // 2 - image_width // 2 - render_engine.w_offset,
image_y - render_engine.h_offset,
image,
"start_menu",
)
info_y = image_y + image_height + 18
line_gap = 18 if compact_menu else max(20, target_height // 34)
for index, line in enumerate(subtitle_lines):
render_engine.draw_text(
line,
self._menu_font(target_height // (34 if index == 0 else 36)),
("center", info_y + line_gap * index),
colors["text"] if index == 0 else colors["muted"],
)
cta_y = info_y + line_gap * len(subtitle_lines) + 8
render_engine.draw_text(
primary_action_text,
self._menu_font(target_height // 31),
("center", cta_y),
colors["text"],
)
card_width = max(320, min(panel_width - 160, 720))
card_height = 60 if compact_menu else max(68, target_height // 10)
card_x = target_width // 2 - card_width // 2
cards_y = cta_y + (16 if compact_menu else 26)
card_gap = 12 if compact_menu else 16
for index, (setting_name, label) in enumerate(START_MENU_AUDIO_OPTIONS):
card_y = cards_y + index * (card_height + card_gap)
self._draw_start_menu_slider(
card_x,
card_y,
card_width,
card_height,
setting_name,
label,
getattr(self, setting_name),
index == self.start_menu_selection,
)
cards_bottom = cards_y + len(START_MENU_AUDIO_OPTIONS) * card_height + (len(START_MENU_AUDIO_OPTIONS) - 1) * card_gap
if compact_menu:
hint_y = min(cards_bottom + 8, panel_y + panel_height - 28)
render_engine.draw_text(
hint_text,
self._menu_font(target_height // 42),
("center", hint_y),
colors["muted"],
)
else:
hint_y = cards_bottom + 10
hint_height = max(34, target_height // 18)
hint_width = card_width
hint_x = card_x
render_engine.draw_rectangle(hint_x, hint_y, hint_width, hint_height, "start_menu", filling=colors["hint_fill"])
render_engine.draw_rectangle(hint_x, hint_y, hint_width, hint_height, "start_menu", outline=(170, 170, 170))
render_engine.draw_text(
hint_text,
self._menu_font(target_height // 40),
("center", hint_y + 10),
colors["muted"],
)
def render_start_menu(self):
colors = START_MENU_COLORS
render_engine = self.render_engine
target_width, target_height = render_engine.target_size
compact_menu = target_height <= 540
panel_x = max(48, target_width // 12)
panel_y = max(34, target_height // 18)
panel_width = target_width - panel_x * 2
panel_height = target_height - panel_y * 2
header_height = max(58, target_height // 10)
title_font = self._menu_font(target_height // 15)
body_font = self._menu_font(target_height // 28)
meta_font = self._menu_font(target_height // 30)
cta_font = self._menu_font(target_height // 22)
hint_font = self._menu_font(target_height // 32)
line_gap = 22 if compact_menu else max(26, target_height // 26)
render_engine.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "start_menu", filling=colors["panel_fill"])
render_engine.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "start_menu", outline=colors["panel_border"])
render_engine.draw_rectangle(panel_x, panel_y, panel_width, header_height, "start_menu", filling=colors["header_fill"])
render_engine.draw_text(
f"Welcome to Mice, {self.profile_integration.get_profile_name()}!",
title_font,
("center", panel_y + max(12, header_height // 5)),
colors["text"],
)
animation_frame, animation_size = self._current_start_menu_animation_frame()
animation_bottom = panel_y + header_height + 18
if animation_frame is not None:
max_animation_width = panel_width - (70 if compact_menu else 110)
display_width = min(animation_size[0], max_animation_width)
scale_factor = display_width / animation_size[0]
display_height = max(1, int(animation_size[1] * scale_factor))
animation_x = target_width // 2 - display_width // 2
animation_y = panel_y + header_height + (18 if compact_menu else 26)
render_engine.draw_image(
animation_x - render_engine.w_offset,
animation_y - render_engine.h_offset,
animation_frame,
"start_menu",
source_rect=(0, 0, animation_size[0], animation_size[1]),
dest_size=(display_width, display_height),
)
animation_bottom = animation_y + display_height
player_profile = self.profile_integration.current_profile
device_id = self.profile_integration.get_device_id()
subtitle_lines = ["A game by Matteo, because he was bored."]
if player_profile:
if compact_menu:
subtitle_lines.append(
f"Best: {player_profile['best_score']} | Games: {player_profile['games_played']}"
)
else:
subtitle_lines.append(f"Device: {device_id}")
subtitle_lines.append(
f"Best Score: {player_profile['best_score']} | Games: {player_profile['games_played']}"
)
elif compact_menu:
subtitle_lines.append(f"Guest profile | {device_id}")
else:
subtitle_lines.append(f"Device: {device_id}")
subtitle_lines.append("No profile loaded - playing as guest")
info_y = animation_bottom + (18 if compact_menu else 22)
for index, line in enumerate(subtitle_lines):
render_engine.draw_text(
line,
body_font if index == 0 else meta_font,
("center", info_y + line_gap * index),
colors["text"] if index == 0 else colors["muted"],
)
difficulty_width = max(360, min(panel_width - 160, 760))
difficulty_height = 54 if compact_menu else max(72, target_height // 9)
difficulty_x = target_width // 2 - difficulty_width // 2
difficulty_y = info_y + line_gap * len(subtitle_lines) + (12 if compact_menu else 18)
self._draw_start_menu_difficulty_selector(
difficulty_x,
difficulty_y,
difficulty_width,
difficulty_height,
)
cta_y = difficulty_y + difficulty_height + (12 if compact_menu else 24)
render_engine.draw_text(
"Press Return to start",
cta_font,
("center", cta_y),
colors["text"],
)
if compact_menu:
render_engine.draw_text(
"Arrows change difficulty",
hint_font,
("center", cta_y + line_gap),
colors["muted"],
)
render_engine.draw_text(
"Esc quits M toggles audio",
hint_font,
("center", cta_y + line_gap + 18),
colors["muted"],
)
else:
render_engine.draw_text(
"Arrows change difficulty Esc quits M toggles audio",
hint_font,
("center", cta_y + line_gap + 8),
colors["muted"],
)
def render_pause_menu(self):
subtitle_lines = [
f"Level {self.current_level + 1} | Points: {self.points}",
f"Rats in maze: {self.count_rats()}",
]
self._render_audio_menu(
title="Pause",
subtitle_lines=subtitle_lines,
primary_action_text="Press Return to resume",
hint_text="Up/Down select Left/Right adjust Esc quits",
image_name="BMP_PAUSE" if "BMP_PAUSE" in self.assets else "BMP_WEWIN",
)
return self.state_machine.current_state == GameState.PAUSED
def _apply_volume_setting(self, setting_name, value):
clamped = max(0, min(100, int(value)))
@ -799,7 +428,7 @@ class MiceMaze(
return
if not self._can_adjust_audio_menu():
return
self.start_menu_selection = (self.start_menu_selection - 1) % len(START_MENU_AUDIO_OPTIONS)
self.start_menu_selection = (self.start_menu_selection - 1) % len(config.START_MENU_AUDIO_OPTIONS)
def menu_down(self):
if self._can_adjust_start_difficulty():
@ -807,12 +436,12 @@ class MiceMaze(
return
if not self._can_adjust_audio_menu():
return
self.start_menu_selection = (self.start_menu_selection + 1) % len(START_MENU_AUDIO_OPTIONS)
self.start_menu_selection = (self.start_menu_selection + 1) % len(config.START_MENU_AUDIO_OPTIONS)
def _adjust_selected_volume(self, delta):
if not self._can_adjust_audio_menu():
return
setting_name, _ = START_MENU_AUDIO_OPTIONS[self.start_menu_selection]
setting_name, _ = config.START_MENU_AUDIO_OPTIONS[self.start_menu_selection]
current_value = getattr(self, setting_name)
self._apply_volume_setting(setting_name, current_value + delta)
@ -820,48 +449,46 @@ class MiceMaze(
if self._can_adjust_start_difficulty():
self._cycle_difficulty(-1)
return
self._adjust_selected_volume(-VOLUME_STEP)
self._adjust_selected_volume(-config.VOLUME_STEP)
def menu_right(self):
if self._can_adjust_start_difficulty():
self._cycle_difficulty(1)
return
self._adjust_selected_volume(VOLUME_STEP)
self._adjust_selected_volume(config.VOLUME_STEP)
def update_background_music(self):
if self.game_end[0] and self.game_end[1] == GAME_END_RUN_COMPLETE:
self.render_engine.play_music(RUN_COMPLETE_MUSIC, loop=True)
if self.game_end[0] and self.game_end[1] == config.GAME_END_RUN_COMPLETE:
self.render_engine.play_music(config.RUN_COMPLETE_MUSIC, loop=True)
return
if self.game_status == "game" and not self.game_end[0]:
if self.state_machine.current_state == GameState.PLAYING and not self.game_end[0]:
if self.current_level_music:
self.render_engine.play_music(self.current_level_music, loop=True)
return
if self.game_status == "start_menu" and not self.game_end[0]:
if self.menu_screen == "start":
self.render_engine.play_music(START_MENU_MUSIC, loop=True)
return
if self.menu_screen == "level_intro" and self.current_level_music:
self.render_engine.play_music(self.current_level_music, loop=True)
return
if self.state_machine.current_state == GameState.START_MENU and not self.game_end[0]:
self.render_engine.play_music(config.START_MENU_MUSIC, loop=True)
return
self.render_engine.pause_music()
def update_maze(self):
self.update_background_music()
if self.game_over():
return
if self.game_status == "paused":
self.render_pause_menu()
# Handle non-playing states via state machine
if self.state_machine.current_state != GameState.PLAYING:
self.state_machine.update()
return
if self.game_status == "start_menu":
if self.menu_screen == "level_intro":
self.render_engine.dialog(
f"Level {self.current_level + 1}",
subtitle=f"Points: {self.points}\nPress Return to begin",
image=self.assets["BMP_WEWIN"],
)
# Actual active gameplay logic
if self.game_end[0]:
if self.game_end[1] == config.GAME_END_DEFEAT:
self.state_machine.transition_to(GameState.GAME_OVER)
else:
self.render_start_menu()
self.state_machine.transition_to(GameState.VICTORY)
return
if self.game_over():
return
self.render_engine.delete_tag("unit")
self.render_engine.delete_tag("effect")
self.render_engine.delete_tag("cave")
@ -873,7 +500,7 @@ class MiceMaze(
# First pass: Register all units in collision system BEFORE move
# This allows bombs/gas to find victims during their move()
for unit in self.units.values():
for unit in sorted(self.units.values(), key=lambda u: int(u.id)):
# Calculate bbox if not yet set (first frame)
if not hasattr(unit, 'bbox') or unit.bbox == (0, 0, 0, 0):
# Temporary bbox based on position
@ -895,7 +522,7 @@ class MiceMaze(
self.unit_positions_before.setdefault(unit.position_before, []).append(unit)
# Second pass: move all units (can now access collision system)
for unit in self.units.copy().values():
for unit in sorted(self.units.values(), key=lambda u: int(u.id)):
unit.move()
# Third pass: Update collision system with new positions after move
@ -903,7 +530,7 @@ class MiceMaze(
self.unit_positions.clear()
self.unit_positions_before.clear()
for unit in self.units.values():
for unit in sorted(self.units.values(), key=lambda u: int(u.id)):
# Register with updated positions/bbox from move()
self.collision_system.register_unit(
unit.id,
@ -916,23 +543,23 @@ class MiceMaze(
self.unit_positions.setdefault(unit.position, []).append(unit)
self.unit_positions_before.setdefault(unit.position_before, []).append(unit)
# Fourth pass: check collisions and draw
for unit in self.units.copy().values():
for unit in sorted(self.units.values(), key=lambda u: int(u.id)):
unit.collisions()
unit.draw()
self.draw_cave_foreground()
self.graphics.draw_cave_foreground()
self.render_engine.draw_pointer(self.pointer[0] * self.cell_size, self.pointer[1] * self.cell_size)
self.render_engine.update_status(f"Mice: {self.count_rats()} - Points: {self.points}")
self.render_engine.update_status(f"Mice: {self.unit_manager.count_rats()} - Points: {self.points}")
self.refill_ammo()
self.render_engine.update_ammo(self.ammo, self.assets)
self.scroll()
self.render_engine.update_ammo(self.ammo, self.graphics.assets)
self.controls.scroll()
self.render_engine.new_cycle(50, self.update_maze)
def run(self):
self.render_engine.mainloop(update=self.update_maze, bg_update=self.draw_maze)
self.render_engine.mainloop(update=self.update_maze, bg_update=self.graphics.draw_maze)
# ==================== GAME OVER LOGIC ====================
@ -941,37 +568,36 @@ class MiceMaze(
if self.combined_scores is None:
self.combined_scores = self.profile_integration.get_device_leaderboard(5)
if self.game_end[1] == GAME_END_DEFEAT:
if self.game_end[1] == config.GAME_END_DEFEAT:
self.render_engine.dialog(
"Game Over: Mice are too many!",
image=self.assets.get("lose", self.assets["BMP_WEWIN"]),
image_scale=0.48,
image=self.graphics.assets.get("lose", self.graphics.assets["BMP_WEWIN"]),
subtitle=f"Reached level: {self.current_level + 1}\nPress Return to go back to the start menu",
scores=self.combined_scores,
)
elif self.game_end[1] == GAME_END_RUN_COMPLETE:
elif self.game_end[1] == config.GAME_END_RUN_COMPLETE:
self.render_engine.dialog(
"THE END",
image=self.assets.get("end", self.assets["BMP_WEWIN"]),
image=self.graphics.assets.get("end", self.graphics.assets["BMP_WEWIN"]),
current_score=self.points,
style="run_complete",
)
else:
self.render_engine.dialog(
f"Level {self.current_level + 1} Clear! Points: {self.points}",
image=self.assets.get("clear", self.assets["BMP_WEWIN"]),
image=self.graphics.assets.get("clear", self.graphics.assets["BMP_WEWIN"]),
subtitle="Press Return for the next level",
scores=self.combined_scores
)
return True
count_rats = self.count_rats()
count_rats = self.unit_manager.count_rats()
if count_rats > 200:
self.render_engine.stop_sound()
self.render_engine.play_sound("WEWIN.WAV")
self.game_end = (True, GAME_END_DEFEAT)
self.game_status = "paused"
self.game_end = (True, config.GAME_END_DEFEAT)
self.state_machine.transition_to(GameState.GAME_OVER)
print(f"[flow] defeat reached: rats={count_rats} points={self.points} level={self.current_level + 1}")
self._record_run_result(completed=False)
@ -980,23 +606,22 @@ class MiceMaze(
if not count_rats and not any(isinstance(unit, points.Point) for unit in self.units.values()):
self.render_engine.stop_sound()
self.render_engine.play_sound("VICTORY.WAV")
self.game_status = "paused"
if self._is_last_dat_level():
self.render_engine.play_sound("WELLDONE.WAV", tag="effects")
self.game_end = (True, GAME_END_RUN_COMPLETE)
self.game_end = (True, config.GAME_END_RUN_COMPLETE)
self.state_machine.transition_to(GameState.VICTORY)
self._record_run_result(completed=True)
print(f"[flow] final DAT victory reached: points={self.points} level={self.current_level + 1}")
else:
self.game_end = (True, GAME_END_LEVEL_CLEAR)
self.game_end = (True, config.GAME_END_LEVEL_CLEAR)
self.state_machine.transition_to(GameState.VICTORY)
self.combined_scores = self.profile_integration.get_device_leaderboard(5)
print(f"[flow] victory reached: points={self.points} level={self.current_level + 1}")
return True
def parse_args():
def parse_debug_score(value):
try:
@ -1033,4 +658,3 @@ if __name__ == "__main__":
if args.debug_run_complete_dialog:
solver.activate_debug_run_complete_dialog(score=args.debug_final_score)
solver.run()

88
test_game_over_flow.py

@ -0,0 +1,88 @@
import os
import sys
import unittest
import random
from pathlib import Path
# Add current directory to path
sys.path.append(os.getcwd())
# Set SDL to use dummy video driver for headless environments
os.environ["SDL_VIDEODRIVER"] = "dummy"
os.environ["SDL_AUDIODRIVER"] = "dummy"
os.environ["MICE_DISABLE_JOYSTICK"] = "1"
from rats import MiceMaze
from engine.state_machine import GameState
from engine.sdl2 import GameWindow
def mock_init_audio(self):
self.music_enabled = False
self.audio_devs = {"base": 0, "effects": 0, "music": 0}
self.sound_volume = 0
self.music_volume = 0
class TestGameOverFlow(unittest.TestCase):
@classmethod
def setUpClass(cls):
GameWindow.show_intro = lambda *args, **kwargs: None
GameWindow.show_loading_screen = lambda *args, **kwargs: None
GameWindow._init_audio_system = mock_init_audio
GameWindow.play_sound = lambda *args, **kwargs: None
GameWindow.stop_sound = lambda *args, **kwargs: None
def setUp(self):
random.seed(42)
# Load a standard level
self.game = MiceMaze("assets/Rat/level.dat", level_index=0)
self.game.game_status = "game"
self.game.state_machine.transition_to(GameState.PLAYING)
def test_defeat_triggers_game_over_state(self):
"""Verify that having > 200 rats triggers GAME_OVER state, not PAUSED."""
print("\nTesting defeat condition (> 200 rats)...")
# Manually inject > 200 rats to trigger defeat
from units.rat import Male
for i in range(210):
self.game.unit_manager.spawn_unit(Male, (1, 1))
# Run one update cycle
self.game.update_maze()
# Check end condition
self.assertTrue(self.game.game_end[0], "Game should be marked as ended")
self.assertEqual(self.game.game_end[1], "defeat", "End reason should be defeat")
# CRITICAL CHECK: State must be GAME_OVER, not PAUSED
current_state = self.game.state_machine.current_state
print(f"Current State: {current_state}")
print(f"Legacy game_status: {self.game.game_status}")
self.assertEqual(current_state, GameState.GAME_OVER,
f"Game should be in GAME_OVER state, but was in {current_state}")
def test_victory_triggers_victory_state(self):
"""Verify that clearing all rats triggers VICTORY state."""
print("\nTesting victory condition (0 rats)...")
# Clear all units
self.game.units.clear()
# Run one update cycle
self.game.update_maze()
# Check end condition
self.assertTrue(self.game.game_end[0], "Game should be marked as ended")
self.assertEqual(self.game.game_end[1], "level_clear", "End reason should be level_clear")
# State must be VICTORY
current_state = self.game.state_machine.current_state
print(f"Current State: {current_state}")
self.assertEqual(current_state, GameState.VICTORY,
f"Game should be in VICTORY state, but was in {current_state}")
if __name__ == "__main__":
unittest.main()

148
test_non_regression.py

@ -0,0 +1,148 @@
import os
import sys
import random
import unittest
import json
import time
from pathlib import Path
from PIL import Image
# Add current directory to path
sys.path.append(os.getcwd())
# Set SDL to use dummy video driver for headless environments
os.environ["SDL_VIDEODRIVER"] = "dummy"
os.environ["SDL_AUDIODRIVER"] = "dummy"
os.environ["MICE_DISABLE_JOYSTICK"] = "1"
from rats import MiceMaze
from engine.sdl2 import GameWindow
def mock_init_audio(self):
self.music_enabled = False
self.audio_devs = {"base": 0, "effects": 0, "music": 0}
self.sound_volume = 0
self.music_volume = 0
import uuid
# Global counter for deterministic UUIDs
_uuid_counter = 0
def mock_uuid4():
global _uuid_counter
val = _uuid_counter
_uuid_counter += 1
return val
class NonRegressionTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Deterministic UUIDs
uuid.uuid4 = mock_uuid4
# Monkeypatch SDL2 methods that block or show windows
GameWindow.show_intro = lambda *args, **kwargs: None
GameWindow.show_loading_screen = lambda *args, **kwargs: None
# Disable audio and its effects
GameWindow._init_audio_system = mock_init_audio
GameWindow.play_sound = lambda *args, **kwargs: None
GameWindow.stop_sound = lambda *args, **kwargs: None
def setUp(self):
global _uuid_counter
_uuid_counter = 0
# Initial seed for constructor
random.seed(42)
# Initialize game
self.game = MiceMaze("assets/Rat/level.dat", level_index=0)
# Re-seed again to clear entropy consumed by asset loading (blood stains etc)
# This ensures the game logic starts from a consistent random state
random.seed(42)
_uuid_counter = 0 # Reset UUIDs too for initial spawns
self.game.start_game()
# Trigger background generation once to consume those random calls
# before the simulation starts, ensuring stability.
self.game.graphics.draw_maze()
# Override dynamic attributes
self.game.start_menu_animation_started_at = 0
# Skip menu and start gameplay
self.game.game_status = "game"
self.game.menu_screen = None
def test_simulation_run(self):
steps = 200
states = []
# Output directory
output_dir = Path("tests/non_regression_output")
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Starting simulation for {steps} steps...")
for i in range(steps):
# Advance game state
self.game.update_maze()
# Every 50 steps, record state and screenshot
if i % 50 == 0 or i == steps - 1:
state = self.dump_game_state()
state["frame"] = i
states.append(state)
# Visual snapshot
# Note: In dummy driver, RenderReadPixels might return empty/black
# but we'll try anyway. If it fails, we rely on the state JSON.
try:
self.game.graphics.draw_maze()
# Manually draw units because we are not in mainloop
for unit in list(self.game.units.values()):
unit.draw()
img = self.game.render_engine.capture_frame()
img_path = output_dir / f"frame_{i:04d}.png"
img.save(img_path)
except Exception as e:
print(f"Warning: Could not capture frame at step {i}: {e}")
# Save states to JSON
states_path = output_dir / "states.json"
with open(states_path, "w") as f:
json.dump(states, f, indent=2)
print(f"Simulation complete. Outputs saved to {output_dir}")
def dump_game_state(self):
state = {
"points": self.game.points,
"unit_count": len(self.game.units),
"units": []
}
# Sort units by ID to be deterministic
sorted_units = sorted(self.game.units.items(), key=lambda x: int(x[0]))
for uid, unit in sorted_units:
unit_state = {
"id": str(uid),
"type": unit.__class__.__name__,
"pos": list(unit.position),
"pos_before": list(unit.position_before),
"partial_move": float(unit.partial_move),
"age": int(unit.age),
}
if hasattr(unit, "sex"):
unit_state["sex"] = unit.sex
if hasattr(unit, "direction"):
unit_state["direction"] = unit.direction
state["units"].append(unit_state)
return state
if __name__ == "__main__":
unittest.main()

123
test_verify.py

@ -0,0 +1,123 @@
import os
import sys
import random
import unittest
import json
from pathlib import Path
from PIL import Image
import numpy as np
# Add current directory to path
sys.path.append(os.getcwd())
# Set SDL to use dummy video driver for headless environments
os.environ["SDL_VIDEODRIVER"] = "dummy"
os.environ["SDL_AUDIODRIVER"] = "dummy"
os.environ["MICE_DISABLE_JOYSTICK"] = "1"
from rats import MiceMaze
from engine.sdl2 import GameWindow
def mock_init_audio(self):
self.music_enabled = False
self.audio_devs = {"base": 0, "effects": 0, "music": 0}
self.sound_volume = 0
self.music_volume = 0
import uuid
# Global counter for deterministic UUIDs
_uuid_counter = 0
def mock_uuid4():
global _uuid_counter
val = _uuid_counter
_uuid_counter += 1
return val
class NonRegressionVerification(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Deterministic UUIDs
uuid.uuid4 = mock_uuid4
GameWindow.show_intro = lambda *args, **kwargs: None
GameWindow.show_loading_screen = lambda *args, **kwargs: None
GameWindow._init_audio_system = mock_init_audio
GameWindow.play_sound = lambda *args, **kwargs: None
GameWindow.stop_sound = lambda *args, **kwargs: None
def setUp(self):
global _uuid_counter
_uuid_counter = 0
random.seed(42)
self.game = MiceMaze("assets/Rat/level.dat", level_index=0)
# Re-seed again to clear entropy consumed by asset loading
random.seed(42)
_uuid_counter = 0
self.game.start_game()
# Trigger background generation once to consume those random calls
# before the simulation starts, ensuring stability.
self.game.graphics.draw_maze()
self.game.game_status = "game"
self.game.menu_screen = None
def test_verify_against_golden_master(self):
golden_master_dir = Path("tests/golden_master")
if not golden_master_dir.exists():
self.skipTest("Golden master not found. Run recording first.")
with open(golden_master_dir / "states.json", "r") as f:
golden_states = json.load(f)
steps = 200
golden_idx = 0
print(f"Verifying against golden master for {steps} steps...")
for i in range(steps):
self.game.update_maze()
if i % 50 == 0 or i == steps - 1:
current_state = self.dump_game_state()
golden_state = golden_states[golden_idx]
# Compare unit count
self.assertEqual(current_state["unit_count"], golden_state["unit_count"],
f"Unit count mismatch at step {i}")
# Compare units
for u_idx, (curr_u, gold_u) in enumerate(zip(current_state["units"], golden_state["units"])):
self.assertEqual(curr_u["id"], gold_u["id"], f"Unit ID mismatch at step {i}, index {u_idx}")
self.assertEqual(curr_u["type"], gold_u["type"], f"Unit type mismatch at step {i}, unit {curr_u['id']}")
self.assertEqual(curr_u["pos"], gold_u["pos"], f"Unit pos mismatch at step {i}, unit {curr_u['id']}")
self.assertAlmostEqual(curr_u["partial_move"], gold_u["partial_move"], places=5,
msg=f"Unit partial_move mismatch at step {i}, unit {curr_u['id']}")
golden_idx += 1
print("Verification successful! No regressions detected.")
def dump_game_state(self):
state = {
"points": self.game.points,
"unit_count": len(self.game.units),
"units": []
}
# Sort units by ID to be deterministic
sorted_units = sorted(self.game.units.items(), key=lambda x: int(x[0]))
for uid, unit in sorted_units:
unit_state = {
"id": str(uid),
"type": unit.__class__.__name__,
"pos": list(unit.position),
"partial_move": float(unit.partial_move),
}
state["units"].append(unit_state)
return state
if __name__ == "__main__":
unittest.main()

BIN
tests/golden_master/frame_0000.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
tests/golden_master/frame_0050.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
tests/golden_master/frame_0100.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
tests/golden_master/frame_0150.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
tests/golden_master/frame_0199.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

437
tests/golden_master/states.json

@ -0,0 +1,437 @@
[
{
"points": 0,
"unit_count": 5,
"units": [
{
"id": "0",
"type": "Female",
"pos": [
12,
1
],
"pos_before": [
13,
1
],
"partial_move": 0.1,
"age": 1,
"sex": "FEMALE",
"direction": "LEFT"
},
{
"id": "2",
"type": "Male",
"pos": [
4,
11
],
"pos_before": [
5,
11
],
"partial_move": 0.1,
"age": 1,
"sex": "MALE",
"direction": "LEFT"
},
{
"id": "3",
"type": "Male",
"pos": [
8,
30
],
"pos_before": [
7,
30
],
"partial_move": 0.1,
"age": 1,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "4",
"type": "Male",
"pos": [
19,
1
],
"pos_before": [
20,
1
],
"partial_move": 0.1,
"age": 1,
"sex": "MALE",
"direction": "LEFT"
},
{
"id": "5",
"type": "Female",
"pos": [
9,
11
],
"pos_before": [
10,
11
],
"partial_move": 0.1,
"age": 1,
"sex": "FEMALE",
"direction": "LEFT"
}
],
"frame": 0
},
{
"points": 0,
"unit_count": 5,
"units": [
{
"id": "0",
"type": "Female",
"pos": [
7,
1
],
"pos_before": [
8,
1
],
"partial_move": 0.1,
"age": 51,
"sex": "FEMALE",
"direction": "LEFT"
},
{
"id": "2",
"type": "Male",
"pos": [
6,
8
],
"pos_before": [
5,
8
],
"partial_move": 0.1,
"age": 51,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "3",
"type": "Male",
"pos": [
13,
30
],
"pos_before": [
12,
30
],
"partial_move": 0.1,
"age": 51,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "4",
"type": "Male",
"pos": [
18,
5
],
"pos_before": [
18,
4
],
"partial_move": 0.1,
"age": 51,
"sex": "MALE",
"direction": "DOWN"
},
{
"id": "5",
"type": "Female",
"pos": [
4,
11
],
"pos_before": [
5,
11
],
"partial_move": 0.1,
"age": 51,
"sex": "FEMALE",
"direction": "LEFT"
}
],
"frame": 50
},
{
"points": 0,
"unit_count": 5,
"units": [
{
"id": "0",
"type": "Female",
"pos": [
6,
5
],
"pos_before": [
6,
4
],
"partial_move": 0.1,
"age": 101,
"sex": "FEMALE",
"direction": "DOWN"
},
{
"id": "2",
"type": "Male",
"pos": [
4,
5
],
"pos_before": [
5,
5
],
"partial_move": 0.1,
"age": 101,
"sex": "MALE",
"direction": "LEFT"
},
{
"id": "3",
"type": "Male",
"pos": [
18,
30
],
"pos_before": [
17,
30
],
"partial_move": 0.1,
"age": 101,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "4",
"type": "Male",
"pos": [
18,
8
],
"pos_before": [
19,
8
],
"partial_move": 0.1,
"age": 101,
"sex": "MALE",
"direction": "LEFT"
},
{
"id": "5",
"type": "Female",
"pos": [
3,
15
],
"pos_before": [
4,
15
],
"partial_move": 0.1,
"age": 101,
"sex": "FEMALE",
"direction": "LEFT"
}
],
"frame": 100
},
{
"points": 0,
"unit_count": 5,
"units": [
{
"id": "0",
"type": "Female",
"pos": [
10,
6
],
"pos_before": [
10,
5
],
"partial_move": 0.1,
"age": 151,
"sex": "FEMALE",
"direction": "DOWN"
},
{
"id": "2",
"type": "Male",
"pos": [
1,
3
],
"pos_before": [
1,
4
],
"partial_move": 0.1,
"age": 151,
"sex": "MALE",
"direction": "UP"
},
{
"id": "3",
"type": "Male",
"pos": [
22,
29
],
"pos_before": [
22,
30
],
"partial_move": 0.1,
"age": 151,
"sex": "MALE",
"direction": "UP"
},
{
"id": "4",
"type": "Male",
"pos": [
15,
8
],
"pos_before": [
16,
8
],
"partial_move": 0.1,
"age": 151,
"sex": "MALE",
"direction": "LEFT"
},
{
"id": "5",
"type": "Female",
"pos": [
4,
15
],
"pos_before": [
3,
15
],
"partial_move": 0.1,
"age": 151,
"sex": "FEMALE",
"direction": "RIGHT"
}
],
"frame": 150
},
{
"points": 0,
"unit_count": 5,
"units": [
{
"id": "0",
"type": "Female",
"pos": [
11,
7
],
"pos_before": [
11,
8
],
"partial_move": 0.95,
"age": 200,
"sex": "FEMALE",
"direction": "UP"
},
{
"id": "2",
"type": "Male",
"pos": [
3,
1
],
"pos_before": [
2,
1
],
"partial_move": 0.95,
"age": 200,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "3",
"type": "Male",
"pos": [
23,
26
],
"pos_before": [
22,
26
],
"partial_move": 0.95,
"age": 200,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "4",
"type": "Male",
"pos": [
13,
8
],
"pos_before": [
13,
7
],
"partial_move": 0.95,
"age": 200,
"sex": "MALE",
"direction": "DOWN"
},
{
"id": "5",
"type": "Female",
"pos": [
4,
11
],
"pos_before": [
4,
12
],
"partial_move": 0.95,
"age": 200,
"sex": "FEMALE",
"direction": "UP"
}
],
"frame": 199
}
]

BIN
tests/non_regression_output/frame_0000.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
tests/non_regression_output/frame_0050.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
tests/non_regression_output/frame_0100.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
tests/non_regression_output/frame_0150.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
tests/non_regression_output/frame_0199.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

437
tests/non_regression_output/states.json

@ -0,0 +1,437 @@
[
{
"points": 0,
"unit_count": 5,
"units": [
{
"id": "0",
"type": "Female",
"pos": [
12,
1
],
"pos_before": [
13,
1
],
"partial_move": 0.1,
"age": 1,
"sex": "FEMALE",
"direction": "LEFT"
},
{
"id": "2",
"type": "Male",
"pos": [
4,
11
],
"pos_before": [
5,
11
],
"partial_move": 0.1,
"age": 1,
"sex": "MALE",
"direction": "LEFT"
},
{
"id": "3",
"type": "Male",
"pos": [
8,
30
],
"pos_before": [
7,
30
],
"partial_move": 0.1,
"age": 1,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "4",
"type": "Male",
"pos": [
19,
1
],
"pos_before": [
20,
1
],
"partial_move": 0.1,
"age": 1,
"sex": "MALE",
"direction": "LEFT"
},
{
"id": "5",
"type": "Female",
"pos": [
9,
11
],
"pos_before": [
10,
11
],
"partial_move": 0.1,
"age": 1,
"sex": "FEMALE",
"direction": "LEFT"
}
],
"frame": 0
},
{
"points": 0,
"unit_count": 5,
"units": [
{
"id": "0",
"type": "Female",
"pos": [
7,
1
],
"pos_before": [
8,
1
],
"partial_move": 0.1,
"age": 51,
"sex": "FEMALE",
"direction": "LEFT"
},
{
"id": "2",
"type": "Male",
"pos": [
6,
8
],
"pos_before": [
5,
8
],
"partial_move": 0.1,
"age": 51,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "3",
"type": "Male",
"pos": [
13,
30
],
"pos_before": [
12,
30
],
"partial_move": 0.1,
"age": 51,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "4",
"type": "Male",
"pos": [
18,
5
],
"pos_before": [
18,
4
],
"partial_move": 0.1,
"age": 51,
"sex": "MALE",
"direction": "DOWN"
},
{
"id": "5",
"type": "Female",
"pos": [
4,
11
],
"pos_before": [
5,
11
],
"partial_move": 0.1,
"age": 51,
"sex": "FEMALE",
"direction": "LEFT"
}
],
"frame": 50
},
{
"points": 0,
"unit_count": 5,
"units": [
{
"id": "0",
"type": "Female",
"pos": [
6,
5
],
"pos_before": [
6,
4
],
"partial_move": 0.1,
"age": 101,
"sex": "FEMALE",
"direction": "DOWN"
},
{
"id": "2",
"type": "Male",
"pos": [
4,
5
],
"pos_before": [
5,
5
],
"partial_move": 0.1,
"age": 101,
"sex": "MALE",
"direction": "LEFT"
},
{
"id": "3",
"type": "Male",
"pos": [
18,
30
],
"pos_before": [
17,
30
],
"partial_move": 0.1,
"age": 101,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "4",
"type": "Male",
"pos": [
18,
8
],
"pos_before": [
19,
8
],
"partial_move": 0.1,
"age": 101,
"sex": "MALE",
"direction": "LEFT"
},
{
"id": "5",
"type": "Female",
"pos": [
3,
15
],
"pos_before": [
4,
15
],
"partial_move": 0.1,
"age": 101,
"sex": "FEMALE",
"direction": "LEFT"
}
],
"frame": 100
},
{
"points": 0,
"unit_count": 5,
"units": [
{
"id": "0",
"type": "Female",
"pos": [
10,
6
],
"pos_before": [
10,
5
],
"partial_move": 0.1,
"age": 151,
"sex": "FEMALE",
"direction": "DOWN"
},
{
"id": "2",
"type": "Male",
"pos": [
1,
3
],
"pos_before": [
1,
4
],
"partial_move": 0.1,
"age": 151,
"sex": "MALE",
"direction": "UP"
},
{
"id": "3",
"type": "Male",
"pos": [
22,
29
],
"pos_before": [
22,
30
],
"partial_move": 0.1,
"age": 151,
"sex": "MALE",
"direction": "UP"
},
{
"id": "4",
"type": "Male",
"pos": [
15,
8
],
"pos_before": [
16,
8
],
"partial_move": 0.1,
"age": 151,
"sex": "MALE",
"direction": "LEFT"
},
{
"id": "5",
"type": "Female",
"pos": [
4,
15
],
"pos_before": [
3,
15
],
"partial_move": 0.1,
"age": 151,
"sex": "FEMALE",
"direction": "RIGHT"
}
],
"frame": 150
},
{
"points": 0,
"unit_count": 5,
"units": [
{
"id": "0",
"type": "Female",
"pos": [
11,
7
],
"pos_before": [
11,
8
],
"partial_move": 0.95,
"age": 200,
"sex": "FEMALE",
"direction": "UP"
},
{
"id": "2",
"type": "Male",
"pos": [
3,
1
],
"pos_before": [
2,
1
],
"partial_move": 0.95,
"age": 200,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "3",
"type": "Male",
"pos": [
23,
26
],
"pos_before": [
22,
26
],
"partial_move": 0.95,
"age": 200,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "4",
"type": "Male",
"pos": [
13,
8
],
"pos_before": [
13,
7
],
"partial_move": 0.95,
"age": 200,
"sex": "MALE",
"direction": "DOWN"
},
{
"id": "5",
"type": "Female",
"pos": [
4,
11
],
"pos_before": [
4,
12
],
"partial_move": 0.95,
"age": 200,
"sex": "FEMALE",
"direction": "UP"
}
],
"frame": 199
}
]

61
tools/muos_bt_diagnostic.sh

@ -0,0 +1,61 @@
#!/bin/sh
set -u
section() {
printf '\n===== %s =====\n' "$1"
}
run_cmd() {
printf '\n$ %s\n' "$*"
"$@" 2>&1 || printf '[exit=%s]\n' "$?"
}
run_shell() {
printf '\n$ %s\n' "$1"
sh -c "$1" 2>&1 || printf '[exit=%s]\n' "$?"
}
section "system"
run_cmd date
run_cmd uname -a
run_shell 'cat /etc/os-release 2>/dev/null || true'
section "bluetooth binaries"
run_shell 'for path in /bin/bluetoothctl /usr/bin/bluetoothctl /usr/sbin/bluetoothd /usr/libexec/bluetooth/bluetoothd /usr/bin/dbus-daemon /bin/dbus-daemon /usr/bin/dbus-send /bin/dbus-send /usr/bin/btmgmt /bin/btmgmt /usr/bin/hciconfig /bin/hciconfig /usr/sbin/rfkill /bin/rfkill; do if [ -e "$path" ]; then ls -l "$path"; fi; done'
section "processes"
run_shell 'pidof bluetoothd || true'
run_shell 'pidof dbus-daemon || true'
run_shell 'ps w | grep -E "bluetoothd|dbus-daemon|pipewire|wireplumber|bluealsa" | grep -v grep || true'
section "dbus"
run_shell 'ls -l /run/dbus/system_bus_socket /var/run/dbus/system_bus_socket 2>/dev/null || true'
run_shell 'dbus-send --system --dest=org.freedesktop.DBus --type=method_call --print-reply / org.freedesktop.DBus.ListNames 2>/dev/null | head -n 40'
section "bluetoothctl"
run_shell 'timeout 8 bluetoothctl --version 2>/dev/null || bluetoothctl --version 2>/dev/null || true'
run_shell 'timeout 8 bluetoothctl show'
section "kernel"
run_shell 'lsmod | grep -i -E "bluetooth|btusb|btmtk|hci_uart|rtl" || true'
run_shell 'dmesg | grep -i -E "bluetooth|bluez|hci|rtl_bt|btusb|btmtk" | tail -n 120 || true'
section "controllers"
run_shell 'hciconfig -a'
run_shell 'btmgmt info'
run_shell 'rfkill list'
section "sysfs"
run_shell 'find /sys/class/bluetooth -maxdepth 2 -type f 2>/dev/null | head -n 50 || true'
run_shell 'find /sys/class/rfkill -maxdepth 2 -type f 2>/dev/null | head -n 50 || true'
section "firmware"
run_shell 'find /lib/firmware -maxdepth 3 -type f 2>/dev/null | grep -i -E "rtlbt|bluetooth|mt76|mt79|mediatek" | head -n 120 || true'
section "audio stack"
run_shell 'ps w | grep -E "pipewire|wireplumber|pulseaudio|bluealsa" | grep -v grep || true'
run_shell 'command -v wpctl >/dev/null 2>&1 && wpctl status || true'
run_shell 'command -v pactl >/dev/null 2>&1 && pactl info || true'
section "done"
printf '\nDiagnostic complete.\n'

12
units/bomb.py

@ -36,7 +36,7 @@ class Bomb(Unit):
n = 1
if n < 0:
n = 0
image = self.game.bomb_assets[n]
image = self.game.graphics.bomb_assets[n]
image_size = self.game.render_engine.get_image_size(image)
self.rat_image = image
partial_x, partial_y = 0, 0
@ -70,7 +70,7 @@ class Timer(Bomb):
print(f"Unit {target_unit.id} already dead")
# Bomb-specific behavior: create explosion
self.game.spawn_unit(Explosion, target_unit.position)
self.game.unit_manager.spawn_unit(Explosion, target_unit.position)
# Collect all explosion positions using vectorized approach
explosion_positions = []
@ -94,7 +94,7 @@ class Timer(Bomb):
# Create all explosions at once
for pos in explosion_positions:
self.game.spawn_unit(Explosion, pos)
self.game.unit_manager.spawn_unit(Explosion, pos)
# Use optimized collision system to get all rats in explosion area
# This replaces the nested loop with a single vectorized operation
@ -105,7 +105,7 @@ class Timer(Bomb):
# Kill all victims with score multiplier
for victim_id in victim_ids:
victim = self.game.get_unit_by_id(victim_id)
victim = self.game.unit_manager.get_unit_by_id(victim_id)
if victim and victim.id in self.game.units:
# Determine position based on partial_move
victim_pos = victim.position if victim.partial_move >= 0.5 else victim.position_before
@ -128,7 +128,7 @@ class Explosion(Bomb):
self.die()
def draw(self):
image = self.game.assets["BMP_EXPLOSION"]
image = self.game.graphics.assets["BMP_EXPLOSION"]
image_size = self.game.render_engine.get_image_size(image)
partial_x, partial_y = 0, 0
@ -186,7 +186,7 @@ class NuclearBomb(Unit):
def draw(self):
"""Draw nuclear bomb on position"""
image = self.game.assets["BMP_NUCLEAR"]
image = self.game.graphics.assets["BMP_NUCLEAR"]
image_size = self.game.render_engine.get_image_size(image)
x_pos = self.position[0] * self.game.cell_size + (self.game.cell_size - image_size[0]) // 2

10
units/gas.py

@ -32,7 +32,7 @@ class Gas(Unit):
)
for victim_id in victim_ids:
victim = self.game.get_unit_by_id(victim_id)
victim = self.game.unit_manager.get_unit_by_id(victim_id)
if victim and isinstance(victim, Rat):
if victim.partial_move > 0.5:
victim.gassed += 1
@ -43,14 +43,14 @@ class Gas(Unit):
)
for victim_id in victim_ids_before:
victim = self.game.get_unit_by_id(victim_id)
victim = self.game.unit_manager.get_unit_by_id(victim_id)
if victim and isinstance(victim, Rat):
if victim.partial_move < 0.5:
victim.gassed += 1
if self.age % self.speed:
return
parent = self.game.get_unit_by_id(self.parent_id)
parent = self.game.unit_manager.get_unit_by_id(self.parent_id)
if (parent) or self.parent_id is None:
print(f"Gas at {self.position} is spreading")
# Spread gas to adjacent cells
@ -61,7 +61,7 @@ class Gas(Unit):
if not self.game.map.is_wall(new_x, new_y):
if not any(isinstance(unit, Gas) for unit in self.game.units.values() if unit.position == (new_x, new_y)):
print(f"Spreading gas from {self.position} to ({new_x}, {new_y})")
self.game.spawn_unit(Gas, (new_x, new_y), parent_id=self.parent_id if self.parent_id else self.id)
self.game.unit_manager.spawn_unit(Gas, (new_x, new_y), parent_id=self.parent_id if self.parent_id else self.id)
def collisions(self):
pass
@ -72,7 +72,7 @@ class Gas(Unit):
def draw(self):
image = self.game.assets["BMP_GAS"]
image = self.game.graphics.assets["BMP_GAS"]
image_size = self.game.render_engine.get_image_size(image)
self.rat_image = image
partial_x, partial_y = 0, 0

4
units/mine.py

@ -23,7 +23,7 @@ class Mine(Unit):
)
for victim_id in victim_ids:
rat_unit = self.game.get_unit_by_id(victim_id)
rat_unit = self.game.unit_manager.get_unit_by_id(victim_id)
if rat_unit and hasattr(rat_unit, 'sex'): # Check if it's a rat
# Mine explodes and kills the rat
self.explode(rat_unit)
@ -49,7 +49,7 @@ class Mine(Unit):
return
# Use mine asset
image = self.game.assets["BMP_POISON"]
image = self.game.graphics.assets["BMP_POISON"]
image_size = self.game.render_engine.get_image_size(image)
# Center the mine in the cell

2
units/points.py

@ -37,7 +37,7 @@ class Point(Unit):
def draw(self):
image = self.game.assets[f"BMP_BONUS_{self.value}"]
image = self.game.graphics.assets[f"BMP_BONUS_{self.value}"]
image_size = self.game.render_engine.get_image_size(image)
self.rat_image = image
partial_x, partial_y = 0, 0

20
units/rat.py

@ -93,7 +93,7 @@ class Rat(Unit):
sex = self.sex if self.age > AGE_THRESHOLD else "BABY"
# Get cached image size instead of calling get_image_size()
image_size = self.game.rat_image_sizes[sex][self.direction]
image_size = self.game.graphics.rat_image_sizes[sex][self.direction]
# Calculate partial movement offset
if self.direction in ["UP", "DOWN"]:
@ -130,7 +130,7 @@ class Rat(Unit):
# Process each collision
for _, other_id in collisions:
other_unit = self.game.get_unit_by_id(other_id)
other_unit = self.game.unit_manager.get_unit_by_id(other_id)
# Skip if not another Rat
if not isinstance(other_unit, Rat):
@ -164,17 +164,17 @@ class Rat(Unit):
# Rat-specific behavior: spawn points
if score not in [None, 0]:
self.game.add_point(score)
self.game.spawn_unit(Point, death_position, value=score)
self.game.scoring.add_point(score)
self.game.unit_manager.spawn_unit(Point, death_position, value=score)
# Add blood stain directly to background
self.game.add_blood_stain(death_position)
self.game.graphics.add_blood_stain(death_position)
def draw(self):
"""Optimized draw using pre-calculated positions from move()"""
sex = self.sex if self.age > AGE_THRESHOLD else "BABY"
image = self.game.rat_assets_textures[sex][self.direction]
image_size = self.game.rat_image_sizes[sex][self.direction]
image = self.game.graphics.rat_assets_textures[sex][self.direction]
image_size = self.game.graphics.rat_image_sizes[sex][self.direction]
# Calculate render position if not yet set (first frame)
if not hasattr(self, 'render_x'):
@ -275,7 +275,7 @@ class Rat(Unit):
"""Calculate render position and bbox (used when render_x not yet set)"""
sex = self.sex if self.age > AGE_THRESHOLD else "BABY"
image_size = self.game.render_engine.get_image_size(
self.game.rat_assets_textures[sex][self.direction]
self.game.graphics.rat_assets_textures[sex][self.direction]
)
partial_x, partial_y = 0, 0
@ -314,7 +314,7 @@ class Female(Rat):
self.babies -= 1
self.stop = 20
if self.partial_move > 0.2:
self.game.spawn_rat(self.position)
self.game.unit_manager.spawn_rat(self.position)
else:
self.game.spawn_rat(self.position_before)
self.game.unit_manager.spawn_rat(self.position_before)
self.game.render_engine.play_sound("BIRTH.WAV")
Loading…
Cancel
Save