Browse Source

Refactor code structure for improved readability and maintainability

master
Matteo Benedetto 1 month ago
parent
commit
c7ff5ae4cf
  1. BIN
      assets/Rat/clear.png
  2. BIN
      assets/Rat/lose.png
  3. 24
      conf/keybindings.json
  4. 24
      conf/keybindings_gamepad.yaml
  5. 24
      conf/keybindings_pc.yaml
  6. 16
      conf/keybindings_r36s.yaml
  7. 16
      conf/keybindings_rg40xx.yaml
  8. 29
      engine/controls.py
  9. 26
      engine/sdl2.py
  10. 18
      rats.py
  11. 76
      test_final_level_flow.py
  12. 55
      test_keybindings.py

BIN
assets/Rat/clear.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
assets/Rat/lose.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

24
conf/keybindings.json

@ -27,6 +27,30 @@
"keydown_M": "toggle_audio",
"keydown_F": "toggle_full_screen"
},
"keybinding_level_intro": {
"keydown_Return": "reset_game",
"keydown_Escape": "quit_game",
"keydown_M": "toggle_audio",
"keydown_F": "toggle_full_screen"
},
"keybinding_level_clear": {
"keydown_Return": "reset_game",
"keydown_Escape": "quit_game",
"keydown_M": "toggle_audio",
"keydown_F": "toggle_full_screen"
},
"keybinding_defeat": {
"keydown_Return": "reset_game",
"keydown_Escape": "quit_game",
"keydown_M": "toggle_audio",
"keydown_F": "toggle_full_screen"
},
"keybinding_run_complete": {
"keydown_Return": "reset_game",
"keydown_Escape": "quit_game",
"keydown_M": "toggle_audio",
"keydown_F": "toggle_full_screen"
},
"keybinding_paused": {
"keydown_Return": "reset_game",
"keydown_Escape": "quit_game",

24
conf/keybindings_gamepad.yaml

@ -25,6 +25,30 @@ keybinding_start_menu:
controllerbuttondown_misc1: quit_game
controllerbuttondown_guide: quit_game
keybinding_level_intro:
controllerbuttondown_a: reset_game
controllerbuttondown_start: reset_game
controllerbuttondown_misc1: quit_game
controllerbuttondown_guide: quit_game
keybinding_level_clear:
controllerbuttondown_a: reset_game
controllerbuttondown_start: reset_game
controllerbuttondown_misc1: quit_game
controllerbuttondown_guide: quit_game
keybinding_defeat:
controllerbuttondown_a: reset_game
controllerbuttondown_start: reset_game
controllerbuttondown_misc1: quit_game
controllerbuttondown_guide: quit_game
keybinding_run_complete:
controllerbuttondown_a: reset_game
controllerbuttondown_start: reset_game
controllerbuttondown_misc1: quit_game
controllerbuttondown_guide: quit_game
keybinding_paused:
controllerbuttondown_start: toggle_pause
controllerbuttondown_dpad_up: menu_up

24
conf/keybindings_pc.yaml

@ -26,6 +26,30 @@ keybinding_start_menu:
keydown_M: toggle_audio
keydown_F: toggle_full_screen
keybinding_level_intro:
keydown_Return: reset_game
keydown_Escape: quit_game
keydown_M: toggle_audio
keydown_F: toggle_full_screen
keybinding_level_clear:
keydown_Return: reset_game
keydown_Escape: quit_game
keydown_M: toggle_audio
keydown_F: toggle_full_screen
keybinding_defeat:
keydown_Return: reset_game
keydown_Escape: quit_game
keydown_M: toggle_audio
keydown_F: toggle_full_screen
keybinding_run_complete:
keydown_Return: reset_game
keydown_Escape: quit_game
keydown_M: toggle_audio
keydown_F: toggle_full_screen
keybinding_paused:
keydown_Return: reset_game
keydown_Escape: quit_game

16
conf/keybindings_r36s.yaml

@ -24,6 +24,22 @@ keybinding_start_menu:
joybuttondown_16: toggle_pause
joybuttondown_12: quit_game
keybinding_level_intro:
joybuttondown_13: reset_game
joybuttondown_12: quit_game
keybinding_level_clear:
joybuttondown_13: reset_game
joybuttondown_12: quit_game
keybinding_defeat:
joybuttondown_13: reset_game
joybuttondown_12: quit_game
keybinding_run_complete:
joybuttondown_13: reset_game
joybuttondown_12: quit_game
keybinding_paused:
joybuttondown_13: reset_game
joybuttondown_16: toggle_pause

16
conf/keybindings_rg40xx.yaml

@ -20,6 +20,22 @@ keybinding_start_menu:
joybuttondown_10: toggle_pause
joybuttondown_11: quit_game
keybinding_level_intro:
joybuttondown_9: reset_game
joybuttondown_11: quit_game
keybinding_level_clear:
joybuttondown_9: reset_game
joybuttondown_11: quit_game
keybinding_defeat:
joybuttondown_9: reset_game
joybuttondown_11: quit_game
keybinding_run_complete:
joybuttondown_9: reset_game
joybuttondown_11: quit_game
keybinding_paused:
joybuttondown_9: reset_game
joybuttondown_10: toggle_pause

29
engine/controls.py

@ -196,6 +196,28 @@ def resolve_keybindings(preferred_profile=None, preferred_file=None, render_engi
class KeyBindings:
def _binding_sections_for_action(self):
game_end_active, game_end_reason = getattr(self, "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_status", None) == "start_menu":
if getattr(self, "menu_screen", None) == "level_intro":
return ["keybinding_level_intro", "keybinding_start_menu"]
return ["keybinding_start_menu"]
status = getattr(self, "game_status", None)
if status:
return [f"keybinding_{status}"]
return []
def initialize_keybindings(self):
preferred_profile = None
preferred_file = None
@ -253,7 +275,12 @@ class KeyBindings:
if not hasattr(self, "bindings"):
self.initialize_keybindings()
value = self.bindings.get(f"keybinding_{self.game_status}", {}).get(action)
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

26
engine/sdl2.py

@ -494,9 +494,29 @@ class GameWindow:
if image := kwargs.get("image"):
image_width, image_height = self.get_image_size(image)
image_x = self.target_size[0] // 2 - image_width // 2
self.draw_image(image_x - self.w_offset, content_y - self.h_offset, image, "win")
content_y += image_height + max(14, panel_height // 30)
image_scale = kwargs.get("image_scale")
image_max_width = kwargs.get("image_max_width")
image_max_height = kwargs.get("image_max_height")
scale_limit = 1.0
if image_scale is not None:
scale_limit = min(scale_limit, max(0.01, float(image_scale)))
if image_max_width:
scale_limit = min(scale_limit, float(image_max_width) / image_width)
if image_max_height:
scale_limit = min(scale_limit, float(image_max_height) / image_height)
render_width = max(1, int(round(image_width * scale_limit)))
render_height = max(1, int(round(image_height * scale_limit)))
image_x = self.target_size[0] // 2 - render_width // 2
self.draw_image(
image_x - self.w_offset,
content_y - self.h_offset,
image,
"win",
dest_size=(render_width, render_height),
)
content_y += render_height + max(14, panel_height // 30)
if subtitle := kwargs.get("subtitle"):
subtitle_sprites = [

18
rats.py

@ -409,7 +409,7 @@ class MiceMaze(
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")
self.load_level(self.current_level, preserve_points=True, show_menu=False)
return
if self._is_last_dat_level():
@ -421,7 +421,7 @@ class MiceMaze(
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")
self.load_level(next_level, preserve_points=True, show_menu=False)
def reset_game(self):
print(
@ -437,8 +437,9 @@ class MiceMaze(
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)
print("[flow] reset_game -> defeat, 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")
return
if self.game_status == "paused":
@ -943,9 +944,10 @@ class MiceMaze(
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
image=self.assets.get("lose", self.assets["BMP_WEWIN"]),
image_scale=0.48,
subtitle=f"Reached level: {self.current_level + 1}\nPress Return to go back to the start menu",
scores=self.combined_scores,
)
elif self.game_end[1] == GAME_END_RUN_COMPLETE:
self.render_engine.dialog(
@ -957,7 +959,7 @@ class MiceMaze(
else:
self.render_engine.dialog(
f"Level {self.current_level + 1} Clear! Points: {self.points}",
image=self.assets["BMP_WEWIN"],
image=self.assets.get("clear", self.assets["BMP_WEWIN"]),
subtitle="Press Return for the next level",
scores=self.combined_scores
)

76
test_final_level_flow.py

@ -25,7 +25,7 @@ def build_dummy(level_index):
dummy.game_end = (False, None)
dummy.menu_screen = None
dummy.units = {}
dummy.assets = {"BMP_WEWIN": object(), "end": object()}
dummy.assets = {"BMP_WEWIN": object(), "end": object(), "clear": object(), "lose": object()}
dummy.explosions = {"LEFT": object(), "RIGHT": object(), "UP": object(), "DOWN": object()}
dummy.count_rats = lambda: 0
dummy.start_menu_animation_started_at = 0
@ -89,8 +89,29 @@ class FinalLevelFlowTests(unittest.TestCase):
self.assertEqual(saves, [1234])
self.assertEqual(load_calls, [])
def test_advance_level_starts_next_dat_level_immediately(self):
dummy, _, _, _, _, load_calls = build_dummy(4)
dummy.advance_level()
self.assertEqual(
load_calls,
[((5,), {"preserve_points": True, "show_menu": False})],
)
def test_advance_level_starts_non_dat_level_immediately(self):
dummy, _, _, _, _, load_calls = build_dummy(0)
dummy.map = SimpleNamespace(source_path=Path("/tmp/level.json"))
dummy.advance_level()
self.assertEqual(
load_calls,
[((0,), {"preserve_points": True, "show_menu": False})],
)
def test_regular_level_clear_keeps_run_open(self):
dummy, sounds, _, stats, saves, _ = build_dummy(4)
dummy, sounds, dialogs, stats, saves, _ = build_dummy(4)
result = dummy.game_over()
@ -108,6 +129,41 @@ class FinalLevelFlowTests(unittest.TestCase):
],
)
dummy.game_over()
self.assertEqual(dialogs[-1][0][0], "Level 5 Clear! Points: 1234")
self.assertIs(dialogs[-1][1]["image"], dummy.assets["clear"])
def test_defeat_dialog_uses_lose_art(self):
dummy, sounds, dialogs, stats, saves, _ = build_dummy(4)
dummy.count_rats = lambda: 201
result = dummy.game_over()
self.assertTrue(result)
self.assertEqual(dummy.game_end, (True, "defeat"))
self.assertEqual(stats, [(1234, False)])
self.assertEqual(saves, [1234])
self.assertEqual(
sounds,
[
("stop",),
(("WEWIN.WAV",), {}),
],
)
dummy.game_over()
self.assertEqual(dialogs[-1][0][0], "Game Over: Mice are too many!")
self.assertIs(dialogs[-1][1]["image"], dummy.assets["lose"])
self.assertEqual(dialogs[-1][1]["image_scale"], 0.48)
self.assertEqual(dialogs[-1][1]["scores"], dummy.combined_scores)
self.assertEqual(
dialogs[-1][1]["subtitle"],
"Reached level: 5\nPress Return to go back to the start menu",
)
def test_final_dat_level_records_score_and_shows_dedicated_dialog(self):
dummy, sounds, dialogs, stats, saves, _ = build_dummy(maze.DEFAULT_LEVELS_PER_DAT_FILE - 1)
@ -147,15 +203,17 @@ class FinalLevelFlowTests(unittest.TestCase):
[((0,), {"preserve_points": False, "show_menu": True, "menu_screen": "start"})],
)
def test_toggle_pause_ignores_transition_screen_between_levels(self):
dummy, _, _, _, _, _ = build_dummy(2)
dummy.game_status = "start_menu"
dummy.menu_screen = "level_intro"
def test_return_after_defeat_goes_back_to_start_menu(self):
dummy, _, _, _, _, load_calls = build_dummy(4)
dummy.game_end = (True, "defeat")
dummy.game_status = "paused"
dummy.toggle_pause()
dummy.reset_game()
self.assertEqual(dummy.game_status, "start_menu")
self.assertEqual(dummy.menu_screen, "level_intro")
self.assertEqual(
load_calls,
[((0,), {"preserve_points": False, "show_menu": True, "menu_screen": "start"})],
)
def test_toggle_pause_ignores_game_end_dialogs(self):
dummy, _, _, _, _, _ = build_dummy(2)

55
test_keybindings.py

@ -82,6 +82,61 @@ class KeybindingProfileTests(unittest.TestCase):
f"incomplete weapon bindings in {config_path.name}: {sorted(actions)}",
)
def test_shipped_profiles_define_level_dialog_bindings(self):
dummy = DummyBindings()
conf_dir = Path(__file__).resolve().parent / "conf"
required_sections = {
"keybinding_level_intro",
"keybinding_level_clear",
"keybinding_defeat",
"keybinding_run_complete",
}
for config_path in sorted(conf_dir.glob("keybindings*.json")) + sorted(conf_dir.glob("keybindings*.yaml")):
bindings = controls._load_bindings_from_file(config_path)
validated = dummy._validate_bindings(bindings, config_path)
for section_name in required_sections:
section = validated.get(section_name, {})
self.assertTrue(section, f"missing {section_name} in {config_path.name}")
values = set(section.values())
self.assertIn("reset_game", values, f"missing confirm binding in {section_name} for {config_path.name}")
self.assertIn("quit_game", values, f"missing quit binding in {section_name} for {config_path.name}")
def test_trigger_uses_level_intro_context(self):
dummy = DummyBindings()
calls = []
dummy.reset_game = lambda: calls.append("reset")
dummy.quit_game = lambda: calls.append("quit")
dummy.bindings = {
"keybinding_level_intro": {"keydown_Return": "reset_game"},
"keybinding_start_menu": {"keydown_Return": "quit_game"},
}
dummy.game_status = "start_menu"
dummy.menu_screen = "level_intro"
dummy.game_end = (False, None)
dummy.trigger("keydown_Return")
self.assertEqual(calls, ["reset"])
def test_trigger_uses_level_clear_context(self):
dummy = DummyBindings()
calls = []
dummy.reset_game = lambda: calls.append("reset")
dummy.quit_game = lambda: calls.append("quit")
dummy.bindings = {
"keybinding_level_clear": {"keydown_Return": "reset_game"},
"keybinding_paused": {"keydown_Return": "quit_game"},
}
dummy.game_status = "paused"
dummy.menu_screen = None
dummy.game_end = (True, "level_clear")
dummy.trigger("keydown_Return")
self.assertEqual(calls, ["reset"])
if __name__ == "__main__":
unittest.main()
Loading…
Cancel
Save