Add animated start menu and level music config

This commit is contained in:
2026-05-07 23:18:39 +02:00
parent 2367f4fb1c
commit f1770b218c
13 changed files with 215 additions and 43 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-4
View File
@@ -20,10 +20,6 @@
"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"
},
-4
View File
@@ -17,10 +17,6 @@ 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
-4
View File
@@ -18,10 +18,6 @@ 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
-4
View File
@@ -17,10 +17,6 @@ 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
-4
View File
@@ -15,10 +15,6 @@ 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
+36
View File
@@ -0,0 +1,36 @@
{
"levels": {
"1": "Clockwork_Thicket.mp3",
"2": "Rat_Trap_Run.mp3",
"3": "Scuttle_Through_the_Walls.mp3",
"4": "The_Long_Way_Down.mp3",
"5": "Clockwork_Thicket.mp3",
"6": "Rat_Trap_Run.mp3",
"7": "Scuttle_Through_the_Walls.mp3",
"8": "The_Long_Way_Down.mp3",
"9": "Clockwork_Thicket.mp3",
"10": "Rat_Trap_Run.mp3",
"11": "Scuttle_Through_the_Walls.mp3",
"12": "The_Long_Way_Down.mp3",
"13": "Clockwork_Thicket.mp3",
"14": "Rat_Trap_Run.mp3",
"15": "Scuttle_Through_the_Walls.mp3",
"16": "The_Long_Way_Down.mp3",
"17": "Clockwork_Thicket.mp3",
"18": "Rat_Trap_Run.mp3",
"19": "Scuttle_Through_the_Walls.mp3",
"20": "The_Long_Way_Down.mp3",
"21": "Clockwork_Thicket.mp3",
"22": "Rat_Trap_Run.mp3",
"23": "Scuttle_Through_the_Walls.mp3",
"24": "The_Long_Way_Down.mp3",
"25": "Clockwork_Thicket.mp3",
"26": "Rat_Trap_Run.mp3",
"27": "Scuttle_Through_the_Walls.mp3",
"28": "The_Long_Way_Down.mp3",
"29": "Clockwork_Thicket.mp3",
"30": "Rat_Trap_Run.mp3",
"31": "Scuttle_Through_the_Walls.mp3",
"32": "The_Long_Way_Down.mp3"
}
}
+33 -1
View File
@@ -11,7 +11,7 @@ except ImportError:
sdlmixer = None
from sdl2.ext.compat import byteify
from sdl2 import SDL_AudioSpec
from PIL import Image
from PIL import Image, ImageSequence
from runtime_paths import resolve_bundle_path
@@ -259,6 +259,38 @@ class GameWindow:
sdl2.SDL_FreeSurface(temp_surface)
return texture
def load_animation(self, path):
"""Load a GIF animation as SDL textures with frame durations."""
image_path = resolve_bundle_path(os.path.join("assets", path))
image = Image.open(image_path)
frames = []
durations = []
for frame in ImageSequence.Iterator(image):
rgba_frame = frame.convert("RGBA")
duration = int(frame.info.get("duration", 83))
duration = max(20, duration)
temp_surface = sdl2.ext.pillow_to_surface(rgba_frame)
texture = self.factory.from_surface(temp_surface)
sdl2.SDL_FreeSurface(temp_surface)
frames.append(texture)
durations.append(duration)
if not frames:
rgba_frame = image.convert("RGBA")
temp_surface = sdl2.ext.pillow_to_surface(rgba_frame)
texture = self.factory.from_surface(temp_surface)
sdl2.SDL_FreeSurface(temp_surface)
frames.append(texture)
durations.append(83)
return {
"frames": frames,
"durations": durations,
"total_duration": sum(durations),
"size": frames[0].size,
}
def get_image_size(self, image):
"""Get the size of an image sprite"""
return image.size
+146 -22
View File
@@ -4,6 +4,7 @@ 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
@@ -12,22 +13,24 @@ from engine.user_profile_integration import UserProfileIntegration
from runtime_paths import bundle_path
GAMEPLAY_MUSIC = "Clockwork_Thicket.mp3"
LEVEL_MUSIC_CONFIG = "level_music"
START_MENU_MUSIC = "High_Score_Garden.mp3"
START_MENU_ANIMATION = "anim/start_mice.gif"
SUPPORTED_MUSIC_EXTENSIONS = {".mp3", ".ogg", ".wav"}
START_MENU_AUDIO_OPTIONS = (
("sound_volume", "Suono"),
("music_volume", "Musica"),
)
VOLUME_STEP = 5
START_MENU_COLORS = {
"panel_fill": (248, 248, 244),
"panel_fill": (255, 255, 255),
"panel_border": (52, 52, 52),
"header_fill": (230, 236, 231),
"header_fill": (255, 255, 255),
"text": (24, 24, 24),
"muted": (82, 82, 82),
"hint_fill": (236, 238, 232),
"hint_fill": (255, 255, 255),
"track_fill": (212, 215, 216),
"card_fill": (242, 242, 239),
"card_fill": (255, 255, 255),
}
START_MENU_AUDIO_STYLES = {
"sound_volume": {
@@ -85,8 +88,14 @@ class MiceMaze(
elif hasattr(self.render_engine, 'set_volume'):
self.render_engine.set_volume(self.music_volume)
self.initialize_keybindings()
self.configs = self.get_config()
self.available_music_tracks = self._load_available_music_tracks()
self.current_level_music = None
self.load_assets()
self.start_menu_animation = self.render_engine.load_animation(START_MENU_ANIMATION)
self.start_menu_animation_started_at = time.monotonic()
self.render_engine.window.show()
self.render_engine.show_intro(bundle_path("assets", "Rat", "intro.png"))
self.pointer = (random.randint(1, self.map.width-2), random.randint(1, self.map.height-2))
@@ -114,7 +123,6 @@ class MiceMaze(
self.sounds = {}
self.start_game()
self.background_texture = None
self.configs = self.get_config()
self.combined_scores = None
@@ -126,9 +134,43 @@ class MiceMaze(
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 start_game(self):
print(f"[flow] start_game: level={self.current_level + 1}")
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
@@ -177,6 +219,7 @@ class MiceMaze(
f"current_points={self.points} next_theme={next_theme_index}"
)
self.current_level = level_index
self.current_level_music = self._resolve_level_music(level_index)
self.map = maze.Map(self.map_source, level_index=level_index)
self._valid_positions = None
self.collision_system = CollisionSystem(
@@ -284,10 +327,7 @@ class MiceMaze(
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")
)
return self.game_status == "paused"
def _menu_font(self, size):
clamped = max(10, min(69, int(size)))
@@ -327,6 +367,24 @@ class MiceMaze(
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
@@ -418,30 +476,91 @@ class MiceMaze(
)
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 self.render_engine.target_size[1] <= 540:
if compact_menu:
subtitle_lines.append(
f"Best: {player_profile['best_score']} | Games: {player_profile['games_played']} | {device_id}"
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 self.render_engine.target_size[1] <= 540:
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")
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",
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"],
)
cta_y = info_y + line_gap * len(subtitle_lines) + 18
render_engine.draw_text(
"Press Return to start",
cta_font,
("center", cta_y),
colors["text"],
)
render_engine.draw_text(
"Esc quits M toggles audio",
hint_font,
("center", cta_y + line_gap + 8),
colors["muted"],
)
def render_pause_menu(self):
@@ -493,11 +612,16 @@ class MiceMaze(
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)
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):