diff --git a/assets/music/High_Score_Garden.mp3 b/assets/music/High_Score_Garden.mp3 new file mode 100644 index 0000000..2273f24 Binary files /dev/null and b/assets/music/High_Score_Garden.mp3 differ diff --git a/conf/keybindings.json b/conf/keybindings.json index 31c1a35..57c3027 100644 --- a/conf/keybindings.json +++ b/conf/keybindings.json @@ -20,12 +20,20 @@ "keybinding_start_menu": { "keydown_Return": "reset_game", "keydown_Escape": "quit_game", + "keydown_Up": "menu_up", + "keydown_Down": "menu_down", + "keydown_Left": "menu_left", + "keydown_Right": "menu_right", "keydown_M": "toggle_audio", "keydown_F": "toggle_full_screen" }, "keybinding_paused": { "keydown_Return": "reset_game", "keydown_Escape": "quit_game", + "keydown_Up": "menu_up", + "keydown_Down": "menu_down", + "keydown_Left": "menu_left", + "keydown_Right": "menu_right", "keydown_M": "toggle_audio", "keydown_F": "toggle_full_screen" } diff --git a/conf/keybindings_gamepad.yaml b/conf/keybindings_gamepad.yaml index a009eca..ebf5ba1 100644 --- a/conf/keybindings_gamepad.yaml +++ b/conf/keybindings_gamepad.yaml @@ -17,11 +17,19 @@ keybinding_game: keybinding_start_menu: controllerbuttondown_a: reset_game controllerbuttondown_start: reset_game + controllerbuttondown_dpad_up: menu_up + controllerbuttondown_dpad_down: menu_down + controllerbuttondown_dpad_left: menu_left + controllerbuttondown_dpad_right: menu_right controllerbuttondown_b: quit_game controllerbuttondown_back: quit_game keybinding_paused: controllerbuttondown_a: reset_game controllerbuttondown_start: toggle_pause + controllerbuttondown_dpad_up: menu_up + controllerbuttondown_dpad_down: menu_down + controllerbuttondown_dpad_left: menu_left + controllerbuttondown_dpad_right: menu_right controllerbuttondown_b: quit_game controllerbuttondown_back: quit_game \ No newline at end of file diff --git a/conf/keybindings_pc.yaml b/conf/keybindings_pc.yaml index b7778c4..f5caf53 100644 --- a/conf/keybindings_pc.yaml +++ b/conf/keybindings_pc.yaml @@ -18,11 +18,19 @@ keybinding_game: keybinding_start_menu: keydown_Return: reset_game keydown_Escape: quit_game + keydown_Up: menu_up + keydown_Down: menu_down + keydown_Left: menu_left + keydown_Right: menu_right keydown_M: toggle_audio keydown_F: toggle_full_screen keybinding_paused: keydown_Return: reset_game keydown_Escape: quit_game + keydown_Up: menu_up + keydown_Down: menu_down + keydown_Left: menu_left + keydown_Right: menu_right keydown_M: toggle_audio keydown_F: toggle_full_screen \ No newline at end of file diff --git a/conf/keybindings_r36s.yaml b/conf/keybindings_r36s.yaml index 5558f37..b175139 100644 --- a/conf/keybindings_r36s.yaml +++ b/conf/keybindings_r36s.yaml @@ -17,8 +17,16 @@ keybinding_start_menu: joybuttondown_13: reset_game joybuttondown_16: toggle_pause joybuttondown_12: quit_game + joybuttondown_8: menu_up + joybuttondown_9: menu_down + joybuttondown_10: menu_left + joybuttondown_11: menu_right keybinding_paused: joybuttondown_13: reset_game joybuttondown_16: toggle_pause joybuttondown_12: quit_game + joybuttondown_8: menu_up + joybuttondown_9: menu_down + joybuttondown_10: menu_left + joybuttondown_11: menu_right diff --git a/conf/keybindings_rg40xx.yaml b/conf/keybindings_rg40xx.yaml index 717d063..64408fe 100644 --- a/conf/keybindings_rg40xx.yaml +++ b/conf/keybindings_rg40xx.yaml @@ -15,8 +15,16 @@ keybinding_start_menu: joybuttondown_9: reset_game joybuttondown_10: toggle_pause joybuttondown_11: quit_game + joyhatmotion_0_1: menu_up + joyhatmotion_0_4: menu_down + joyhatmotion_0_8: menu_left + joyhatmotion_0_2: menu_right keybinding_paused: joybuttondown_9: reset_game joybuttondown_10: toggle_pause joybuttondown_11: quit_game + joyhatmotion_0_1: menu_up + joyhatmotion_0_4: menu_down + joyhatmotion_0_8: menu_left + joyhatmotion_0_2: menu_right diff --git a/engine/controls.py b/engine/controls.py index cbc1bd1..9d73e75 100644 --- a/engine/controls.py +++ b/engine/controls.py @@ -279,6 +279,8 @@ class KeyBindings: 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() diff --git a/engine/sdl2.py b/engine/sdl2.py index c40d1f9..35db84a 100644 --- a/engine/sdl2.py +++ b/engine/sdl2.py @@ -128,6 +128,7 @@ class GameWindow: self.audio_devs["base"] = sdl2.SDL_OpenAudioDevice(None, 0, audio_spec, None, 0) self.audio_devs["effects"] = sdl2.SDL_OpenAudioDevice(None, 0, audio_spec, None, 0) self.audio_devs["music"] = sdl2.SDL_OpenAudioDevice(None, 0, audio_spec, None, 0) + self.sound_volume = sdl2.SDL_MIX_MAXVOLUME self.music_enabled = False self.music_volume = 128 self.music_track = None @@ -149,12 +150,20 @@ class GameWindow: self.music_enabled = True sdlmixer.Mix_VolumeMusic(self.music_volume) - def set_volume(self, volume_percent): + def set_sound_volume(self, volume_percent): + clamped = max(0, min(int(volume_percent), 100)) + self.sound_volume = int(round(clamped * sdl2.SDL_MIX_MAXVOLUME / 100)) + + def set_music_volume(self, volume_percent): clamped = max(0, min(int(volume_percent), 100)) self.music_volume = int(round(clamped * 128 / 100)) if self.music_enabled and sdlmixer is not None: sdlmixer.Mix_VolumeMusic(self.music_volume) + def set_volume(self, volume_percent): + self.set_sound_volume(volume_percent) + self.set_music_volume(volume_percent) + def play_music(self, music_file, loop=True): if not self.audio or not self.music_enabled or sdlmixer is None: return False @@ -498,12 +507,31 @@ class GameWindow: spec = SDL_AudioSpec(freq=22050, aformat=sdl2.AUDIO_U8, channels=1, samples=2048) if sdl2.SDL_LoadWAV_RW(rw, 1, byref(spec), byref(_buf), byref(_length)) == None: raise RuntimeError("Failed to load WAV") + + if self.sound_volume <= 0: + sdl2.SDL_FreeWAV(_buf) + return devid = self.audio_devs[tag] # Clear any queued audio sdl2.SDL_ClearQueuedAudio(devid) + buffer_length = int(_length.value) + + if self.sound_volume < sdl2.SDL_MIX_MAXVOLUME: + scaled_buffer = create_string_buffer(buffer_length) + sdl2.SDL_MixAudioFormat( + cast(scaled_buffer, POINTER(sdl2.Uint8)), + _buf, + spec.format, + buffer_length, + self.sound_volume, + ) + sdl2.SDL_QueueAudio(devid, cast(scaled_buffer, POINTER(sdl2.Uint8)), buffer_length) + else: + sdl2.SDL_QueueAudio(devid, _buf, buffer_length) + + sdl2.SDL_FreeWAV(_buf) # Start playing audio - sdl2.SDL_QueueAudio(devid, _buf, _length) sdl2.SDL_PauseAudioDevice(devid, 0) def stop_sound(self): diff --git a/engine/user_profile_integration.py b/engine/user_profile_integration.py index fd52f4c..53f6cd2 100644 --- a/engine/user_profile_integration.py +++ b/engine/user_profile_integration.py @@ -72,6 +72,32 @@ class UserProfileIntegration: if self.current_profile and 'settings' in self.current_profile: return self.current_profile['settings'].get(setting_name, default_value) return default_value + + def set_setting(self, setting_name, value): + """Persist a setting on the active local profile.""" + if not self.current_profile: + return False + + try: + with self.profiles_file.open('r', encoding='utf-8') as f: + data = json.load(f) + + profile_name = self.current_profile['name'] + if profile_name not in data.get('profiles', {}): + return False + + profile = data['profiles'][profile_name] + profile.setdefault('settings', {}) + profile['settings'][setting_name] = value + self.current_profile = profile + + with self.profiles_file.open('w', encoding='utf-8') as f: + json.dump(data, f, indent=2) + + return True + except Exception as e: + print(f"Error updating setting {setting_name}: {e}") + return False def register_new_user(self, user_id): """Registration is handled locally via the profile manager.""" diff --git a/rats.py b/rats.py index c5dac97..e23a7db 100644 --- a/rats.py +++ b/rats.py @@ -13,6 +13,32 @@ from runtime_paths import bundle_path GAMEPLAY_MUSIC = "Clockwork_Thicket.mp3" +START_MENU_MUSIC = "High_Score_Garden.mp3" +START_MENU_AUDIO_OPTIONS = ( + ("sound_volume", "Suono"), + ("music_volume", "Musica"), +) +VOLUME_STEP = 5 +START_MENU_COLORS = { + "panel_fill": (248, 248, 244), + "panel_border": (52, 52, 52), + "header_fill": (230, 236, 231), + "text": (24, 24, 24), + "muted": (82, 82, 82), + "hint_fill": (236, 238, 232), + "track_fill": (212, 215, 216), + "card_fill": (242, 242, 239), +} +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( @@ -34,11 +60,13 @@ class MiceMaze( # Load profile-specific settings self.audio = self.profile_integration.get_setting('sound_enabled', True) - sound_volume = self.profile_integration.get_setting('sound_volume', 50) + 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.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() @@ -50,8 +78,12 @@ class MiceMaze( self.render_engine.audio = self.audio # Apply profile settings - if hasattr(self.render_engine, 'set_volume'): - self.render_engine.set_volume(sound_volume) + 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.load_assets() @@ -249,10 +281,223 @@ class MiceMaze( 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" + or (self.game_status == "start_menu" and self.menu_screen == "start") + ) + + 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 _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): + 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 self.render_engine.target_size[1] <= 540: + subtitle_lines.append( + f"Best: {player_profile['best_score']} | Games: {player_profile['games_played']} | {device_id}" + ) + else: + subtitle_lines.append(f"Device: {device_id}") + subtitle_lines.append( + f"Best Score: {player_profile['best_score']} | Games: {player_profile['games_played']}" + ) + elif self.render_engine.target_size[1] <= 540: + 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") + + self._render_audio_menu( + title=f"Welcome to Mice, {self.profile_integration.get_profile_name()}!", + subtitle_lines=subtitle_lines, + primary_action_text="Press Return to start", + hint_text="Up/Down select Left/Right adjust M toggle audio", + ) + + 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 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 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): + self._adjust_selected_volume(-VOLUME_STEP) + + def menu_right(self): + self._adjust_selected_volume(VOLUME_STEP) + def update_background_music(self): if self.game_status == "game" and not self.game_end[0]: self.render_engine.play_music(GAMEPLAY_MUSIC, loop=True) return + if self.game_status == "start_menu" and self.menu_screen == "start" and not self.game_end[0]: + self.render_engine.play_music(START_MENU_MUSIC, loop=True) + return self.render_engine.pause_music() def update_maze(self): @@ -260,7 +505,7 @@ class MiceMaze( if self.game_over(): return if self.game_status == "paused": - self.render_engine.dialog("Pause") + self.render_pause_menu() return if self.game_status == "start_menu": if self.menu_screen == "level_intro": @@ -270,25 +515,7 @@ class MiceMaze( image=self.assets["BMP_WEWIN"], ) else: - player_name = self.profile_integration.get_profile_name() - device_id = self.profile_integration.get_device_id() - - greeting_title = f"Welcome to Mice, {player_name}!" - subtitle = "A game by Matteo, because he was bored." - device_line = f"Device: {device_id}" - - if self.profile_integration.current_profile: - profile = self.profile_integration.current_profile - stats_line = f"Best Score: {profile['best_score']} | Games: {profile['games_played']}" - full_subtitle = f"{subtitle}\n{device_line}\n{stats_line}\nPress Return to start" - else: - full_subtitle = f"{device_line}\nNo profile loaded - playing as guest\nPress Return to start" - - self.render_engine.dialog( - greeting_title, - subtitle=full_subtitle, - image=self.assets["BMP_WEWIN"], - ) + self.render_start_menu() return self.render_engine.delete_tag("unit") self.render_engine.delete_tag("effect")