Files
mice/engine/controls.py
T
enne2 c7ed24483d Add comprehensive test suite for game mechanics and level handling
- Introduced `test_final_level_flow.py` to validate final level transitions and game end scenarios.
- Created `test_game_over_flow.py` to ensure game over conditions trigger correctly based on rat counts.
- Implemented `test_keybindings.py` to verify keybinding configurations and their context-specific actions.
- Developed `test_level_editor.py` to assess level editor functionalities and layout computations.
- Added `test_level_io.py` for testing level data serialization and deserialization.
- Established `test_loop_logic_parity.py` to ensure consistent game state across multiple simulation runs.
- Created `test_non_regression.py` to simulate game behavior and capture states for future verification.
- Implemented `test_verify.py` to compare current game states against a golden master for regression detection.
2026-05-19 22:18:43 +02:00

389 lines
13 KiB
Python

# This file contains the Controls class, which is responsible for handling user input.
import configparser
import json
import os
from pathlib import Path
import yaml
from runtime_paths import resolve_bundle_path
DEFAULT_KEYBINDINGS_PROFILE = "pc"
KEYBINDINGS_FILE_ENV = "MICE_KEYBINDINGS_FILE"
KEYBINDINGS_PROFILE_ENV = "MICE_KEYBINDINGS_PROFILE"
def _normalize_profile_name(profile_name):
if not profile_name:
return None
return profile_name.strip().lower().replace("-", "_")
def _read_text_hint(path_like):
path = Path(path_like)
if not path.exists():
return ""
try:
value = path.read_bytes().replace(b"\x00", b" ").decode("utf-8", errors="ignore")
except OSError:
return ""
return " ".join(value.split())
def _load_bindings_from_file(path):
with path.open("r", encoding="utf-8") as handle:
if path.suffix.lower() == ".json":
data = json.load(handle)
elif path.suffix.lower() in {".yaml", ".yml"}:
data = yaml.safe_load(handle) or {}
else:
raise ValueError(f"Unsupported keybindings format: {path.suffix}")
if not isinstance(data, dict):
raise ValueError(f"Keybindings file must contain a mapping: {path}")
return data
def _profile_candidates(profile_name):
profile = _normalize_profile_name(profile_name)
if not profile:
return []
conf_dir = resolve_bundle_path("conf")
candidates = [
conf_dir / f"keybindings_{profile}.json",
conf_dir / f"keybindings_{profile}.yaml",
conf_dir / f"keybindings_{profile}.yml",
]
if profile == DEFAULT_KEYBINDINGS_PROFILE:
candidates.extend([
conf_dir / "keybindings.json",
conf_dir / "keybindings.yaml",
conf_dir / "keybindings.yml",
])
unique_candidates = []
seen = set()
for candidate in candidates:
candidate_str = str(candidate)
if candidate_str in seen:
continue
seen.add(candidate_str)
unique_candidates.append(candidate)
return unique_candidates
def _detect_linux_hardware_profile():
muos_config_path = Path("/opt/muos/device/current/config.ini")
if muos_config_path.exists():
parser = configparser.ConfigParser()
try:
parser.read(muos_config_path, encoding="utf-8")
board_name = parser.get("board", "name", fallback="").strip().casefold()
except (configparser.Error, OSError):
board_name = ""
if "rg40xx" in board_name:
return "rg40xx", f"muos:{board_name}"
if "r36s" in board_name:
return "r36s", f"muos:{board_name}"
hints = []
for path_like in [
"/proc/device-tree/model",
"/sys/firmware/devicetree/base/model",
"/sys/devices/virtual/dmi/id/sys_vendor",
"/sys/devices/virtual/dmi/id/product_name",
"/sys/devices/virtual/dmi/id/product_version",
"/tmp/sysinfo/model",
]:
hint = _read_text_hint(path_like)
if hint:
hints.append(hint)
combined = " ".join(hints).casefold()
if not combined:
return None, None
hardware_profiles = {
"r36s": ("r36s",),
"rg40xx": ("rg40xx", "anbernic rg40xx", "rg40xx h"),
}
for profile, tokens in hardware_profiles.items():
if any(token in combined for token in tokens):
return profile, "; ".join(hints)
return None, None
def _detect_runtime_profile(render_engine):
if render_engine is None:
return None, None
device_kind = getattr(render_engine, "input_device_kind", None)
device_name = (getattr(render_engine, "input_device_name", "") or "").casefold()
if device_kind == "gamecontroller":
return "gamepad", device_name or "SDL GameController"
if device_kind == "joystick":
if "muos-keys" in device_name:
return "rg40xx", device_name
if "r36s" in device_name:
return "r36s", device_name
if "rg40xx" in device_name:
return "rg40xx", device_name
return None, None
def resolve_keybindings(preferred_profile=None, preferred_file=None, render_engine=None):
explicit_file = preferred_file or os.environ.get(KEYBINDINGS_FILE_ENV)
if explicit_file:
path = resolve_bundle_path(explicit_file)
if path.exists():
explicit_profile = path.stem
if explicit_profile.startswith("keybindings_"):
explicit_profile = explicit_profile[len("keybindings_"):]
return _load_bindings_from_file(path), path, explicit_profile, KEYBINDINGS_FILE_ENV
print(f"[input] requested keybindings file not found: {path}")
profiles_to_try = []
seen_profiles = set()
def add_profile(profile_name, reason):
profile = _normalize_profile_name(profile_name)
if not profile or profile in seen_profiles:
return
seen_profiles.add(profile)
profiles_to_try.append((profile, reason))
add_profile(os.environ.get(KEYBINDINGS_PROFILE_ENV), KEYBINDINGS_PROFILE_ENV)
add_profile(preferred_profile, "profile_setting")
hardware_profile, hardware_reason = _detect_linux_hardware_profile()
if hardware_profile:
add_profile(hardware_profile, f"hardware:{hardware_reason}")
runtime_profile, runtime_reason = _detect_runtime_profile(render_engine)
if runtime_profile:
add_profile(runtime_profile, f"runtime:{runtime_reason}")
add_profile(DEFAULT_KEYBINDINGS_PROFILE, "default")
errors = []
for profile, reason in profiles_to_try:
for candidate in _profile_candidates(profile):
if not candidate.exists():
continue
try:
bindings = _load_bindings_from_file(candidate)
return bindings, candidate, profile, reason
except (OSError, ValueError, json.JSONDecodeError, yaml.YAMLError) as exc:
errors.append(f"{candidate}: {exc}")
if errors:
raise RuntimeError("Failed to load keybindings:\n" + "\n".join(errors))
raise FileNotFoundError("No keybindings configuration could be resolved")
class KeyBindings:
def __init__(self, game):
self.game = game
self.bindings = {}
# Explicit action mapping for static-friendly dispatch (Nim-ready)
self.action_dispatcher = {
"spawn_rat": self.spawn_rat,
"spawn_new_bomb": self.spawn_new_bomb,
"spawn_new_mine": self.spawn_new_mine,
"spawn_new_nuclear_bomb": self.spawn_new_nuclear_bomb,
"spawn_new_gas": self.spawn_new_gas,
"toggle_audio": self.toggle_audio,
"toggle_pause": self.toggle_pause,
"toggle_full_screen": self.toggle_full_screen,
"quit_game": self.quit_game,
"menu_up": self.game.menu_up,
"menu_down": self.game.menu_down,
"menu_left": self.game.menu_left,
"menu_right": self.game.menu_right,
"reset_game": self.game.reset_game,
"start_scrolling": self.start_scrolling,
"stop_scrolling": self.stop_scrolling,
}
def _binding_sections_for_action(self):
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"]
if game_end_reason == "defeat":
return ["keybinding_defeat", "keybinding_paused"]
if game_end_reason == "run_complete":
return ["keybinding_run_complete", "keybinding_paused"]
return ["keybinding_paused"]
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, "game_status", None)
if status:
return [f"keybinding_{status}"]
return []
def initialize_keybindings(self):
preferred_profile = None
preferred_file = None
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.game, "render_engine", None),
)
self.bindings = self._validate_bindings(bindings, source_path)
self.keybindings_profile = profile_name
self.keybindings_source = str(source_path)
self.keybindings_reason = reason
print(
f"[input] keybindings profile={profile_name} source={source_path.name} reason={reason}"
)
def _validate_bindings(self, bindings, source_path):
validated = {}
invalid_bindings = 0
for section_name, action_map in bindings.items():
if not isinstance(action_map, dict):
print(f"[input] ignoring invalid section {section_name!r} in {source_path}")
continue
validated[section_name] = {}
for action, value in action_map.items():
if not value:
continue
method_name = value.split("|", 1)[0]
if method_name in self.action_dispatcher:
validated[section_name][action] = value
continue
invalid_bindings += 1
print(
f"[input] ignoring binding {section_name}.{action} -> {value}: "
f"missing method {method_name} in dispatcher"
)
if invalid_bindings:
print(f"[input] discarded {invalid_bindings} invalid binding(s) from {source_path.name}")
return validated
def trigger(self, action):
if not self.bindings:
self.initialize_keybindings()
value = None
for section_name in self._binding_sections_for_action():
value = self.bindings.get(section_name, {}).get(action)
if value:
break
if not value:
return None
if "|" in value:
method_name, *args = value.split("|")
method = self.action_dispatcher.get(method_name)
if method:
method(*args)
return None
method = self.action_dispatcher.get(value)
if method:
method()
return None
def spawn_rat(self):
self.game.unit_manager.spawn_rat()
def spawn_new_bomb(self):
self.game.unit_manager.spawn_bomb(self.game.pointer)
def spawn_new_mine(self):
self.game.unit_manager.spawn_mine(self.game.pointer)
def spawn_new_nuclear_bomb(self):
self.game.unit_manager.spawn_nuclear_bomb(self.game.pointer)
def spawn_new_gas(self):
self.game.unit_manager.spawn_gas()
def toggle_audio(self):
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, "game_end", (False, None))[0]:
return
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.state_machine.current_state == state_machine.GameState.PAUSED:
self.game.state_machine.transition_to(state_machine.GameState.PLAYING)
return
if self.game.state_machine.current_state == state_machine.GameState.START_MENU:
self.game.reset_game()
def toggle_full_screen(self):
self.game.full_screen = not self.game.full_screen
self.game.render_engine.full_screen(self.game.full_screen)
def quit_game(self):
self.game.render_engine.close()
def start_scrolling(self, direction):
self.game.scrolling_direction = direction
if not self.game.scrolling:
self.game.scrolling = 1
def stop_scrolling(self):
self.game.scrolling = 0
def scroll(self):
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.game.graphics.scroll_cursor(1 if x > 0 else -1, 1 if y > 0 else -1)