#!/usr/bin/python3 import argparse import random import os import json import time from engine import maze, sdl2 as engine, controls, graphics, unit_manager, scoring 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 ): # ==================== INITIALIZATION ==================== def _update_startup_loading(self, message, detail=None, progress=None): if getattr(self, "startup_loading_active", False): self.render_engine.show_loading_screen(message, detail=detail, progress=progress) def __init__(self, maze_file, level_index=0): # Initialize user profile integration self.profile_integration = UserProfileIntegration() self.map_source = maze_file self.map = maze.Map(maze_file, level_index=level_index) self.current_level = self.map.level_index self.total_levels = self.map.level_count # Load profile-specific settings 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.rat_speed_multiplier = 1.0 self._apply_difficulty(self.profile_integration.get_setting('difficulty', 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 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) 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 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() self._update_startup_loading("Loading configuration", detail="Reading bundled settings", progress=0.14) self.configs = self.get_config() self._update_startup_loading("Scanning music", detail="Building soundtrack list", progress=0.2) self.available_music_tracks = self._load_available_music_tracks() self.current_level_music = None self._update_startup_loading("Loading graphics", detail="Preparing common assets", progress=0.26) self.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_started_at = time.monotonic() 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.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): if file.endswith(".json"): with open(os.path.join(conf_dir, file), encoding="utf-8") as f: configs[file[:-5]] = json.load(f) return configs def _load_available_music_tracks(self): music_dir = bundle_path("assets", "music") tracks = [] for file_name in sorted(os.listdir(music_dir)): _, extension = os.path.splitext(file_name) if extension.lower() not in SUPPORTED_MUSIC_EXTENSIONS: continue if os.path.isfile(os.path.join(music_dir, file_name)): tracks.append(file_name) return tracks def _resolve_level_music(self, level_index): level_number = level_index + 1 level_music_config = self.configs.get(LEVEL_MUSIC_CONFIG, {}) configured_tracks = level_music_config.get("levels", {}) configured_track = configured_tracks.get(str(level_number)) if configured_track in self.available_music_tracks: print(f"[audio] level {level_number} music={configured_track} source=config") return configured_track if configured_track: print(f"[audio] level {level_number} configured music missing: {configured_track}") if not self.available_music_tracks: print(f"[audio] no music tracks available for level {level_number}") return None fallback_track = random.choice(self.available_music_tracks) print(f"[audio] level {level_number} music={fallback_track} source=random") 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: return difficulty_key return DEFAULT_DIFFICULTY def _difficulty_config(self): return 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] self.difficulty = normalized_difficulty self.initial_rat_count = max( 1, int(round(BASE_INITIAL_RATS * difficulty_config["starting_rats_multiplier"])), ) self.rat_speed_multiplier = difficulty_config["speed_multiplier"] if persist: self.profile_integration.set_setting("difficulty", normalized_difficulty) def _can_adjust_start_difficulty(self): if self.game_end[0]: return False return self.game_status == "start_menu" and self.menu_screen == "start" def _cycle_difficulty(self, delta): if not self._can_adjust_start_difficulty(): return current_index = 0 for index, option in enumerate(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"]) def _is_dat_campaign(self): return self.map.source_path.suffix.lower() == ".dat" def _is_last_dat_level(self): return self._is_dat_campaign() and self.current_level >= self.total_levels - 1 def _normalize_target_level(self, level_index): if self._is_dat_campaign(): return maze.normalize_level_index(level_index, self.total_levels) return 0 def _record_run_result(self, completed): if not self.run_recorded: self.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) def activate_debug_run_complete_dialog(self, score=None): 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.run_recorded = True self.combined_scores = self.profile_integration.get_device_leaderboard(5) print( f"[debug] showing run-complete dialog: level={self.current_level + 1}/{self.total_levels} " f"points={self.points}" ) def start_game(self): print( f"[flow] start_game: level={self.current_level + 1} " f"difficulty={self.difficulty} starting_rats={self.initial_rat_count} " f"rat_speed={int(self.rat_speed_multiplier * 100)}%" ) self.start_menu_animation_started_at = time.monotonic() self.current_level_music = self._resolve_level_music(self.current_level) self._valid_positions = None self.combined_scores = None self.run_recorded = False self.ammo = { "bomb": { "count": 2, "max": 8 }, "nuclear": { "count": 1, "max": 1 }, "mine": { "count": 2, "max": 4 }, "gas": { "count": 2, "max": 4 } } self.blood_stains = {} self.background_texture = None # Clear blood layer on game start/restart self.blood_layer_sprites.clear() self.cave_foreground_tiles.clear() self.game_end = (False, None) self.game_status = "start_menu" self.menu_screen = "start" self.units.clear() self.unit_positions.clear() 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() for _ in range(self.initial_rat_count): self.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) next_theme_index = target_level_index // 8 + 1 print( f"[flow] load_level requested: target_level={target_level_index + 1} " f"preserve_points={preserve_points} show_menu={show_menu} menu_screen={menu_screen} " f"current_points={self.points} next_theme={next_theme_index}" ) self.map = maze.Map(self.map_source, level_index=target_level_index) self.current_level = self.map.level_index self.total_levels = self.map.level_count self.current_level_music = self._resolve_level_music(self.current_level) self._valid_positions = None self.collision_system = CollisionSystem( self.cell_size, self.map.width, self.map.height ) if getattr(self, "loaded_theme_index", None) != next_theme_index: print( f"[flow] theme switch needed: loaded_theme={getattr(self, 'loaded_theme_index', None)} " f"-> next_theme={next_theme_index}" ) self.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.background_texture = None self.ammo = { "bomb": {"count": 2, "max": 8}, "nuclear": {"count": 1, "max": 1}, "mine": {"count": 2, "max": 4}, "gas": {"count": 2, "max": 4}, } 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() if not preserve_points: self.points = 0 self.run_recorded = False print("[flow] points reset for new run") print( f"[flow] spawning {self.initial_rat_count} rats for level={self.current_level + 1} " f"difficulty={self.difficulty}", flush=True, ) for _ in range(self.initial_rat_count): self.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}") 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}") def advance_level(self): print(f"[flow] advance_level called from level={self.current_level + 1} points={self.points}") if not self._is_dat_campaign(): self.load_level(self.current_level, preserve_points=True, show_menu=True, menu_screen="level_intro") return 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._record_run_result(completed=True) return next_level = self.current_level + 1 print(f"[flow] advancing to level={next_level + 1}") self.load_level(next_level, preserve_points=True, show_menu=True, menu_screen="level_intro") def reset_game(self): print( f"[flow] reset_game called: game_end={self.game_end} game_status={self.game_status} " 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: print("[flow] reset_game -> post-victory path") self.advance_level() elif self.game_end[1] == 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") else: print("[flow] reset_game -> restart from level 1 after defeat") self.load_level(0, preserve_points=False, show_menu=False) return if self.game_status == "paused": print("[flow] reset_game -> unpausing current level") self.game_status = "game" 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 return print("[flow] reset_game -> hard reload current level") self.load_level(self.current_level, preserve_points=False, show_menu=False) # ==================== GAME LOGIC ==================== def refill_ammo(self): for ammo_type, data in self.ammo.items(): if ammo_type == "bomb": if random.random() < 0.02: data["count"] = min(data["count"] + 1, data["max"]) elif ammo_type == "mine": if random.random() < 0.05: data["count"] = min(data["count"] + 1, data["max"]) elif ammo_type == "gas": if random.random() < 0.01: data["count"] = min(data["count"] + 1, data["max"]) 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", ) def _apply_volume_setting(self, setting_name, value): clamped = max(0, min(100, int(value))) setattr(self, setting_name, clamped) if setting_name == "sound_volume": self.render_engine.set_sound_volume(clamped) else: self.render_engine.set_music_volume(clamped) self.profile_integration.set_setting(setting_name, clamped) def menu_up(self): if self._can_adjust_start_difficulty(): self._cycle_difficulty(-1) return if not self._can_adjust_audio_menu(): return self.start_menu_selection = (self.start_menu_selection - 1) % len(START_MENU_AUDIO_OPTIONS) def menu_down(self): if self._can_adjust_start_difficulty(): self._cycle_difficulty(1) return if not self._can_adjust_audio_menu(): return self.start_menu_selection = (self.start_menu_selection + 1) % len(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] current_value = getattr(self, setting_name) self._apply_volume_setting(setting_name, current_value + delta) def menu_left(self): if self._can_adjust_start_difficulty(): self._cycle_difficulty(-1) return self._adjust_selected_volume(-VOLUME_STEP) def menu_right(self): if self._can_adjust_start_difficulty(): self._cycle_difficulty(1) return self._adjust_selected_volume(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) return if self.game_status == "game" 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 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() 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"], ) else: self.render_start_menu() return self.render_engine.delete_tag("unit") self.render_engine.delete_tag("effect") self.render_engine.delete_tag("cave") # Clear collision system for new frame self.collision_system.clear() self.unit_positions.clear() self.unit_positions_before.clear() # 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(): # 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 x_pos = unit.position[0] * self.cell_size y_pos = unit.position[1] * self.cell_size unit.bbox = (x_pos, y_pos, x_pos + self.cell_size, y_pos + self.cell_size) # Register unit in optimized collision system self.collision_system.register_unit( unit.id, unit.bbox, unit.position, unit.position_before, unit.collision_layer ) # Maintain backward compatibility dictionaries self.unit_positions.setdefault(unit.position, []).append(unit) 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(): unit.move() # Third pass: Update collision system with new positions after move self.collision_system.clear() self.unit_positions.clear() self.unit_positions_before.clear() for unit in self.units.values(): # Register with updated positions/bbox from move() self.collision_system.register_unit( unit.id, unit.bbox, unit.position, unit.position_before, unit.collision_layer ) 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(): unit.collisions() unit.draw() self.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.refill_ammo() self.render_engine.update_ammo(self.ammo, self.assets) self.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) # ==================== GAME OVER LOGIC ==================== def game_over(self): if self.game_end[0]: if self.combined_scores is None: self.combined_scores = self.profile_integration.get_device_leaderboard(5) if self.game_end[1] == GAME_END_DEFEAT: self.render_engine.dialog( "Game Over: Mice are too many!", image=self.assets["BMP_WEWIN"], subtitle=f"Reached level: {self.current_level + 1}\nPress Return to restart from level 1", scores=self.combined_scores ) elif self.game_end[1] == GAME_END_RUN_COMPLETE: self.render_engine.dialog( "THE END", image=self.assets.get("end", self.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["BMP_WEWIN"], subtitle="Press Return for the next level", scores=self.combined_scores ) return True count_rats = self.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" print(f"[flow] defeat reached: rats={count_rats} points={self.points} level={self.current_level + 1}") self._record_run_result(completed=False) return True 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._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.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: parsed = float(str(value).strip()) except ValueError as exc: raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") from exc if not parsed.is_integer(): raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") return int(parsed) parser = argparse.ArgumentParser(description="Run Mice! with DAT or JSON map loading") parser.add_argument("--level", type=int, default=0, help="Level index to load from level.dat (default: 0)") parser.add_argument("--map", dest="map_path", default=None, help="Optional map path override (.dat or .json)") parser.add_argument( "--debug-run-complete-dialog", action="store_true", help="Start directly on the final run-complete dialog without saving a fake high score", ) parser.add_argument( "--debug-final-score", type=parse_debug_score, default=1125, help="Score shown by --debug-run-complete-dialog (default: 1125)", ) return parser.parse_args() if __name__ == "__main__": args = parse_args() print("Game starting...") map_source = args.map_path or maze.get_default_map_source() print(f"Loading map from {map_source} (level {args.level})") solver = MiceMaze(map_source, level_index=args.level) if args.debug_run_complete_dialog: solver.activate_debug_run_complete_dialog(score=args.debug_final_score) solver.run()