Browse Source

Refine start menu difficulty and preview workflow

master
Matteo Benedetto 1 month ago
parent
commit
d32d2cd79c
  1. 40
      AGENTS.md
  2. BIN
      assets/music/Goblin_Garden_Sprint.mp3
  3. BIN
      assets/music/Noon_at_the_Gate.mp3
  4. 4
      conf/keybindings.json
  5. 4
      conf/keybindings_gamepad.yaml
  6. 4
      conf/keybindings_pc.yaml
  7. 4
      conf/keybindings_r36s.yaml
  8. 4
      conf/keybindings_rg40xx.yaml
  9. 48
      engine/sdl2.py
  10. 177
      rats.py
  11. 128
      tools/render_menu_preview.py
  12. 2
      units/rat.py

40
AGENTS.md

@ -0,0 +1,40 @@
# Project Guidelines
## UI Preview Tool
When editing the start menu, pause menu, or level intro UI, generate a real preview image before judging layout changes.
Use [tools/render_menu_preview.py](tools/render_menu_preview.py) instead of relying on mental layout or ad-hoc screenshots. The tool renders the actual SDL scene and saves a PNG from the real renderer.
Typical command:
```bash
/home/enne2/dev/mice/.venv/bin/python tools/render_menu_preview.py \
--output /tmp/mice_start_preview.png \
--screen start \
--difficulty normal \
--resolution 1280x720
```
Supported screens:
- `start`
- `pause`
- `level_intro`
Useful flags:
- `--difficulty easy|normal|hard`
- `--resolution WIDTHxHEIGHT`
- `--output /path/to/file.png`
- `--seed N` for deterministic previews
- `--animation-ms N` to choose the GIF frame timestamp for the start menu
Workflow when touching menu layout:
1. Edit the menu code.
2. Run the preview tool for the relevant screen.
3. Inspect the generated PNG.
4. Iterate until spacing and readability are correct.
The preview tool is intended for fast visual feedback and should be preferred before launching a full interactive game session for menu-only changes.

BIN
assets/music/Goblin_Garden_Sprint.mp3

Binary file not shown.

BIN
assets/music/Noon_at_the_Gate.mp3

Binary file not shown.

4
conf/keybindings.json

@ -20,6 +20,10 @@
"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
conf/keybindings_gamepad.yaml

@ -17,6 +17,10 @@ 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
conf/keybindings_pc.yaml

@ -18,6 +18,10 @@ 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
conf/keybindings_r36s.yaml

@ -15,6 +15,10 @@ keybinding_game:
keybinding_start_menu:
joybuttondown_13: reset_game
joybuttondown_8: menu_up
joybuttondown_9: menu_down
joybuttondown_10: menu_left
joybuttondown_11: menu_right
joybuttondown_16: toggle_pause
joybuttondown_12: quit_game

4
conf/keybindings_rg40xx.yaml

@ -13,6 +13,10 @@ keybinding_game:
keybinding_start_menu:
joybuttondown_9: reset_game
joyhatmotion_0_1: menu_up
joyhatmotion_0_4: menu_down
joyhatmotion_0_8: menu_left
joyhatmotion_0_2: menu_right
joybuttondown_10: toggle_pause
joybuttondown_11: quit_game

48
engine/sdl2.py

@ -2,6 +2,7 @@ import os
import random
import ctypes
from ctypes import *
from pathlib import Path
import sdl2
import sdl2.ext
@ -72,8 +73,15 @@ class GameWindow:
print(f"Screen size: {self.width}x{self.height}")
self.joystick_enabled = os.environ.get("MICE_DISABLE_JOYSTICK", "").strip().lower() not in {
"1",
"true",
"yes",
"on",
}
# SDL2 initialization
sdl2.ext.init(joystick=True)
sdl2.ext.init(joystick=self.joystick_enabled)
sdl2.SDL_Init(sdl2.SDL_INIT_AUDIO)
# Window and renderer setup
@ -119,7 +127,8 @@ class GameWindow:
self._init_audio_system()
self.audio = True
# Input devices
self.load_joystick()
if self.joystick_enabled:
self.load_joystick()
def _init_audio_system(self):
"""Initialize audio devices for different audio channels"""
@ -579,6 +588,8 @@ class GameWindow:
def load_joystick(self):
"""Initialize joystick and game controller support."""
if not getattr(self, "joystick_enabled", True):
return
sdl2.SDL_Init(sdl2.SDL_INIT_JOYSTICK | sdl2.SDL_INIT_GAMECONTROLLER)
sdl2.SDL_JoystickEventState(sdl2.SDL_ENABLE)
if hasattr(sdl2, "SDL_GameControllerEventState"):
@ -844,6 +855,39 @@ class GameWindow:
"""Placeholder for cycle management (not implemented)"""
pass
def capture_frame(self):
"""Capture the current renderer output as a PIL image."""
width, height = self.target_size
pitch = width * 4
pixel_buffer = (ctypes.c_ubyte * (pitch * height))()
result = sdl2.SDL_RenderReadPixels(
self.renderer.sdlrenderer,
None,
sdl2.SDL_PIXELFORMAT_RGBA32,
pixel_buffer,
pitch,
)
if result != 0:
raise RuntimeError(f"Failed to capture frame: {sdl2.SDL_GetError()}")
return Image.frombuffer(
"RGBA",
(width, height),
bytes(pixel_buffer),
"raw",
"RGBA",
0,
1,
).copy()
def save_frame(self, path):
"""Save the current renderer output to an image file."""
output_path = Path(path).expanduser()
output_path.parent.mkdir(parents=True, exist_ok=True)
image = self.capture_frame()
image.save(output_path)
return output_path
def full_screen(self, flag):
"""Toggle fullscreen mode"""
sdl2.SDL_SetWindowFullscreen(self.window.window, flag)

177
rats.py

@ -17,11 +17,44 @@ 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"}
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
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),
@ -65,6 +98,10 @@ 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.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
@ -166,9 +203,52 @@ class MiceMaze(
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 start_game(self):
print(f"[flow] start_game: level={self.current_level + 1}")
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
@ -208,7 +288,7 @@ class MiceMaze(
self.pointer = (random.randint(1, self.map.width-2), random.randint(1, self.map.height-2))
self.scroll_cursor()
for _ in range(5):
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):
@ -258,8 +338,12 @@ class MiceMaze(
self.run_recorded = False
print("[flow] points reset for new run")
for spawn_index in range(5):
print(f"[flow] spawning rat {spawn_index + 1}/5 for level={self.current_level + 1}", flush=True)
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:
@ -302,6 +386,9 @@ class MiceMaze(
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
@ -329,6 +416,37 @@ class MiceMaze(
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]
@ -549,19 +667,44 @@ class MiceMaze(
colors["text"] if index == 0 else colors["muted"],
)
cta_y = info_y + line_gap * len(subtitle_lines) + 18
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"],
)
render_engine.draw_text(
"Esc quits M toggles audio",
hint_font,
("center", cta_y + line_gap + 8),
colors["muted"],
)
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 = [
@ -588,11 +731,17 @@ class MiceMaze(
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)
@ -605,9 +754,15 @@ class MiceMaze(
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):

128
tools/render_menu_preview.py

@ -0,0 +1,128 @@
#!/usr/bin/env python3
import argparse
import os
import random
import sys
import time
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
def parse_args():
parser = argparse.ArgumentParser(description="Render a real Mice menu screen to a PNG preview")
parser.add_argument("--output", default="/tmp/mice_menu_preview.png", help="Output PNG path")
parser.add_argument("--screen", choices=("start", "pause", "level_intro"), default="start")
parser.add_argument("--difficulty", choices=("easy", "normal", "hard", "medium"), default="hard")
parser.add_argument("--level", type=int, default=0, help="0-based level index")
parser.add_argument("--resolution", default="1280x720", help="Preview resolution, for example 1280x720")
parser.add_argument("--seed", type=int, default=1337, help="Random seed for deterministic previews")
parser.add_argument("--animation-ms", type=int, default=1200, help="Start-menu GIF timestamp in milliseconds")
return parser.parse_args()
def configure_environment(args):
os.environ.setdefault("MICE_PROJECT_ROOT", str(PROJECT_ROOT))
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
os.environ.setdefault("SDL_RENDER_DRIVER", "software")
os.environ.setdefault("MICE_DISABLE_JOYSTICK", "1")
os.environ["RESOLUTION"] = args.resolution
def build_game(args):
from engine import maze, sdl2 as engine
from rats import MiceMaze
original_show_intro = engine.GameWindow.show_intro
original_load_joystick = engine.GameWindow.load_joystick
original_start_game = MiceMaze.start_game
def preview_start_game(self):
self.start_menu_animation_started_at = time.monotonic()
self.current_level_music = self._resolve_level_music(self.current_level)
self._valid_positions = 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
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 = (self.map.width // 2, self.map.height // 2)
self.scroll_cursor()
engine.GameWindow.show_intro = lambda self, *a, **k: None
engine.GameWindow.load_joystick = lambda self: None
MiceMaze.start_game = preview_start_game
try:
game = MiceMaze(maze.get_default_map_source(), level_index=args.level)
finally:
engine.GameWindow.show_intro = original_show_intro
engine.GameWindow.load_joystick = original_load_joystick
MiceMaze.start_game = original_start_game
game.render_engine.audio = False
game._apply_difficulty(args.difficulty, persist=False)
game.start_menu_animation_started_at = time.monotonic() - args.animation_ms / 1000
return game
def render_screen(game, screen_name):
renderer = game.render_engine.renderer
renderer.clear()
game.draw_maze()
if screen_name == "start":
game.game_status = "start_menu"
game.menu_screen = "start"
game.render_start_menu()
elif screen_name == "pause":
game.game_status = "paused"
game.menu_screen = None
game.render_pause_menu()
else:
game.game_status = "start_menu"
game.menu_screen = "level_intro"
game.render_engine.dialog(
f"Level {game.current_level + 1}",
subtitle=f"Points: {game.points}\nPress Return to begin",
image=game.assets["BMP_WEWIN"],
)
renderer.present()
def main():
args = parse_args()
configure_environment(args)
random.seed(args.seed)
game = build_game(args)
output_path = Path(args.output).expanduser()
try:
render_screen(game, args.screen)
saved_path = game.render_engine.save_frame(output_path)
print(saved_path)
finally:
game.render_engine.close()
if __name__ == "__main__":
sys.exit(main())

2
units/rat.py

@ -16,7 +16,7 @@ class Rat(Unit):
def __init__(self, game, position=(0,0), id=None):
super().__init__(game, position, id, collision_layer=CollisionLayer.RAT)
# Specific attributes for rats
self.speed = 0.10 # Rats are slower
self.speed = 0.10 * getattr(self.game, "rat_speed_multiplier", 1.0)
self.fight = False
self.gassed = 0
self.direction = "DOWN" # Default direction

Loading…
Cancel
Save