From 689b21bf65d9f8430f9010d7383847538d21b5ce Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Wed, 13 Aug 2025 20:36:51 +0200 Subject: [PATCH] Add unit tests for UnitFactory functionality and initialize units package --- engine/controls.py | 10 +-- engine/graphics.py | 43 ++++++++++ engine/scoring.py | 22 +++++ engine/tkinter.py | 56 ------------- engine/unit_manager.py | 33 ++++++++ rats.py | 177 ++++++++++++----------------------------- units/__init__.py | 8 ++ units/bomb.py | 15 ++-- units/points.py | 4 +- units/rat.py | 16 ++-- 10 files changed, 181 insertions(+), 203 deletions(-) create mode 100644 engine/graphics.py create mode 100644 engine/scoring.py delete mode 100644 engine/tkinter.py create mode 100644 engine/unit_manager.py create mode 100644 units/__init__.py diff --git a/engine/controls.py b/engine/controls.py index c8c6604..dcfa30d 100644 --- a/engine/controls.py +++ b/engine/controls.py @@ -7,9 +7,9 @@ class KeyBindings: def key_pressed(self, key, coords=None): keybindings = self.configs[f"keybinding_{self.game_status}"] if key in keybindings.get("quit", []): - self.engine.close() + self.render_engine.close() elif key in keybindings.get("new_rat", []): - self.new_rat() + self.spawn_rat() elif key in keybindings.get("kill_rat", []): if self.units: self.units[random.choice(list(self.units.keys()))].die(score=5) @@ -17,7 +17,7 @@ class KeyBindings: self.audio = not self.audio elif key in keybindings.get("toggle_full_screen", []): self.full_screen = not self.full_screen - self.engine.full_screen(self.full_screen) + self.render_engine.full_screen(self.full_screen) elif key in keybindings.get("scroll_up", []): self.start_scrolling("Up") elif key in keybindings.get("scroll_down", []): @@ -27,7 +27,7 @@ class KeyBindings: elif key in keybindings.get("scroll_right", []): self.start_scrolling("Right") elif key in keybindings.get("spawn_bomb", []): - self.play_sound("PUTDOWN.WAV") + self.render_engine.play_sound("PUTDOWN.WAV") self.spawn_bomb(self.pointer) elif key in keybindings.get("pause", []): self.game_status = "paused" if self.game_status == "game" else "game" @@ -44,7 +44,7 @@ class KeyBindings: self.start_game() def quit_game(self): - self.engine.close() + self.render_engine.close() def key_released(self, key): if key in ["Up", "Down", "Left", "Right", 8, 9, 10, 11]: self.stop_scrolling() diff --git a/engine/graphics.py b/engine/graphics.py new file mode 100644 index 0000000..c508c09 --- /dev/null +++ b/engine/graphics.py @@ -0,0 +1,43 @@ +import os + +class Graphics(): + def load_assets(self): + self.tunnel = self.render_engine.load_image("Rat/BMP_TUNNEL.png", surface=True) + self.grasses = [self.render_engine.load_image(f"Rat/BMP_1_GRASS_{i+1}.png", surface=True) for i in range(4)] + self.rat_assets = {} + self.bomb_assets = {} + for sex in ["MALE", "FEMALE", "BABY"]: + self.rat_assets[sex] = {} + for direction in ["UP", "DOWN", "LEFT", "RIGHT"]: + self.rat_assets[sex][direction] = self.render_engine.load_image(f"Rat/BMP_{sex}_{direction}.png", transparent_color=(128, 128, 128)) + for n in range(5): + self.bomb_assets[n] = self.render_engine.load_image(f"Rat/BMP_BOMB{n}.png", transparent_color=(128, 128, 128)) + self.assets = {} + for file in os.listdir("assets/Rat"): + if file.endswith(".png"): + self.assets[file[:-4]] = self.render_engine.load_image(f"Rat/{file}") + + + # ==================== RENDERING ==================== + + def draw_maze(self): + if self.background_texture is None: + + texture_tiles = [] + for y, row in enumerate(self.map.matrix): + for x, cell in enumerate(row): + variant = x*y % 4 + tile = self.grasses[variant] if cell else self.tunnel + texture_tiles.append((tile, x*self.cell_size, y*self.cell_size)) + self.background_texture = self.render_engine.create_texture(texture_tiles) + self.render_engine.draw_background(self.background_texture) + + def scroll_cursor(self, x=0, y=0): + if self.pointer[0] + x > self.map.width or self.pointer[1] + y > self.map.height: + return + + self.pointer = ( + max(1, min(self.map.width-2, self.pointer[0] + x)), + max(1, min(self.map.height-2, self.pointer[1] + y)) + ) + self.render_engine.scroll_view(self.pointer) \ No newline at end of file diff --git a/engine/scoring.py b/engine/scoring.py new file mode 100644 index 0000000..a86ed16 --- /dev/null +++ b/engine/scoring.py @@ -0,0 +1,22 @@ + +import datetime + + +class Scoring: + # ==================== SCORING ==================== + + def save_score(self): + with open("scores.txt", "a") as f: + f.write(f"{datetime.datetime.now()} - {self.points}\n") + + def read_score(self): + table = [] + with open("scores.txt") as f: + rows = f.read().splitlines() + for row in rows: + table.append(row.split(" - ")) + table.sort(key=lambda x: int(x[1]), reverse=True) + return table + + def add_point(self, value): + self.points += value \ No newline at end of file diff --git a/engine/tkinter.py b/engine/tkinter.py deleted file mode 100644 index 49643be..0000000 --- a/engine/tkinter.py +++ /dev/null @@ -1,56 +0,0 @@ -import tkinter as tk -import os - -class GameWindow: - """Classe che gestisce la finestra di gioco e il rendering grafico.""" - def __init__(self, width, height, cell_size, title, key_callback=None): - self.cell_size = cell_size - self.window = tk.Tk() - self.window.title(title) - self.canvas = tk.Canvas(self.window, width=width*cell_size, height=height*cell_size) - self.canvas.pack() - self.menu = tk.Menu(self.window) - self.menu.add_command(label="Quit", command=self.window.destroy) - self.status_bar = tk.Label(self.window, text=title, bd=1, relief=tk.SUNKEN, anchor=tk.W) - self.status_bar.pack(side=tk.BOTTOM, fill=tk.X) - self.window.config(menu=self.menu) - if key_callback: - self.window.bind("", key_callback) - - def load_image(self, path, transparent_color=None): - image = tk.PhotoImage(file=os.path.join(os.path.dirname(__file__), "..", "assets", path)) - if transparent_color: - gray_pixels = [] - for y in range(image.height()): - for x in range(image.width()): - r, g, b = image.get(x, y) - if r == transparent_color[0] and g == transparent_color[1] and b == transparent_color[2]: - gray_pixels.append((x, y)) - for x, y in gray_pixels: - image.transparency_set(x, y, 1) - return image.zoom(self.cell_size // 20) - - def bind(self, event, callback): - self.window.bind(event, callback) - - def draw_image(self, x, y, image, tag, anchor="nw"): - self.canvas.create_image(x, y, image=image, anchor=anchor, tag=tag) - - def draw_rectangle(self, x, y, width, height, tag, outline="red"): - self.canvas.create_rectangle(x, y, x+width, y+height, outline=outline, tag=tag) - - def delete_tag(self, tag): - self.canvas.delete(tag) - - def update_status(self, text): - self.status_bar.config(text=text) - - def new_cycle(self, delay, callback): - self.window.after(delay, callback) - - def mainloop(self, **kwargs): - kwargs["update"]() - self.window.mainloop() - - def get_image_size(self, image): - return image.width(), image.height() \ No newline at end of file diff --git a/engine/unit_manager.py b/engine/unit_manager.py new file mode 100644 index 0000000..4b39fe5 --- /dev/null +++ b/engine/unit_manager.py @@ -0,0 +1,33 @@ +import random +import uuid +from units import rat, bomb + +class UnitManager: + def count_rats(self): + count = 0 + for unit in self.units.values(): + if isinstance(unit, rat.Rat): + count += 1 + return count + + def spawn_rat(self, position=None): + if position is None: + position = self.choose_start() + rat_class = rat.Male if random.random() < 0.5 else rat.Female + self.spawn_unit(rat_class, position) + + def spawn_bomb(self, position): + self.spawn_unit(bomb.Timer, position) + + def spawn_unit(self, unit, position, **kwargs): + id = uuid.uuid4() + self.units[id] = unit(self, position, id, **kwargs) + + def choose_start(self): + if not hasattr(self, '_valid_positions'): + self._valid_positions = [ + (x, y) for y in range(1, self.map.height-1) + for x in range(1, self.map.width-1) + if self.map.matrix[y][x] + ] + return random.choice(self._valid_positions) diff --git a/rats.py b/rats.py index 6801915..36376c2 100644 --- a/rats.py +++ b/rats.py @@ -1,23 +1,31 @@ #!/usr/bin/python3 import random -from units import rat, bomb + import uuid -from engine import maze, sdl2 as engine, controls +from engine import maze, sdl2 as engine, controls, graphics, unit_manager, scoring import os import datetime import json -class MiceMaze(controls.KeyBindings): +class MiceMaze( + controls.KeyBindings, + unit_manager.UnitManager, + graphics.Graphics, + scoring.Scoring +): + + # ==================== INITIALIZATION ==================== + def __init__(self, maze_file): self.map = maze.Map(maze_file) self.audio = True self.cell_size = 40 self.full_screen = False - self.engine = engine.GameWindow(self.map.width, self.map.height, + self.render_engine = engine.GameWindow(self.map.width, self.map.height, self.cell_size, "Mice!", key_callback=(self.key_pressed, self.key_released, self.axis_scroll)) - self.graphics_load() + self.load_assets() self.pointer = (random.randint(1, self.map.width-2), random.randint(1, self.map.height-2)) self.scroll_cursor() self.points = 0 @@ -43,150 +51,67 @@ class MiceMaze(controls.KeyBindings): def start_game(self): for _ in range(5): - self.new_rat() - - def count_rats(self): - count = 0 - for unit in self.units.values(): - if isinstance(unit, rat.Rat): - count += 1 - return count - - def new_rat(self, position=None): - if position is None: - position = self.choose_start() - rat_class = rat.Male if random.random() < 0.5 else rat.Female - self.spawn_unit(rat_class, position) - - def spawn_bomb(self, position): - self.spawn_unit(bomb.Timer, position) + self.spawn_rat() - def spawn_unit(self, unit, position, **kwargs): - id = uuid.uuid4() - self.units[id] = unit(self, position, id, **kwargs) - - def choose_start(self): - if not hasattr(self, '_valid_positions'): - self._valid_positions = [ - (x, y) for y in range(1, self.map.height-1) - for x in range(1, self.map.width-1) - if self.map.matrix[y][x] - ] - return random.choice(self._valid_positions) + # ==================== GAME LOGIC ==================== - def draw_maze(self): - if self.background_texture is None: - - texture_tiles = [] - for y, row in enumerate(self.map.matrix): - for x, cell in enumerate(row): - variant = x*y % 4 - tile = self.grasses[variant] if cell else self.tunnel - texture_tiles.append((tile, x*self.cell_size, y*self.cell_size)) - self.background_texture = self.engine.create_texture(texture_tiles) - self.engine.draw_background(self.background_texture) - - - def game_over(self): - if self.game_end[0]: - if not self.game_end[1]: - self.engine.dialog("Game Over: Mice are too many!", image=self.assets["BMP_WEWIN"]) - else: - self.engine.dialog(f"You Win! Points: {self.points}", image=self.assets["BMP_WEWIN"], scores=self.read_score()) - - - return True - - if self.count_rats() > 200: - self.stop_sound() - self.play_sound("WEWIN.WAV") - self.game_end = (True, False) - self.game_status = "paused" - return True - if not len(self.units): - self.stop_sound() - self.play_sound("VICTORY.WAV") - self.play_sound("WELLDONE.WAV", tag="effects") - self.game_end = (True, True) - self.game_status = "paused" - self.save_score() - return True - - def save_score(self): - with open("scores.txt", "a") as f: - f.write(f"{datetime.datetime.now()} - {self.points}\n") - - def read_score(self): - table = [] - with open("scores.txt") as f: - rows = f.read().splitlines() - for row in rows: - table.append(row.split(" - ")) - table.sort(key=lambda x: int(x[1]), reverse=True) - return table - def update_maze(self): if self.game_over(): return if self.game_status == "paused": - self.engine.dialog("Pause") + self.render_engine.dialog("Pause") return if self.game_status == "start_menu": - self.engine.dialog("Welcome to the Mice!", subtitle="A game by Matteo because he was bored",image=self.assets["BMP_WEWIN"]) + self.render_engine.dialog("Welcome to the Mice!", subtitle="A game by Matteo because he was bored",image=self.assets["BMP_WEWIN"]) return - self.engine.delete_tag("unit") - self.engine.delete_tag("effect") - self.engine.draw_pointer(self.pointer[0] * self.cell_size, self.pointer[1] * self.cell_size) + self.render_engine.delete_tag("unit") + self.render_engine.delete_tag("effect") + self.render_engine.draw_pointer(self.pointer[0] * self.cell_size, self.pointer[1] * self.cell_size) self.unit_positions.clear() self.unit_positions_before.clear() for unit in self.units.values(): self.unit_positions.setdefault(unit.position, []).append(unit) + self.unit_positions_before.setdefault(unit.position_before, []).append(unit) for unit in self.units.copy().values(): unit.move() unit.collisions() unit.draw() - self.engine.update_status(f"Mice: {self.count_rats()} - Points: {self.points}") + self.render_engine.update_status(f"Mice: {self.count_rats()} - Points: {self.points}") self.scroll() - self.engine.new_cycle(50, self.update_maze) - + self.render_engine.new_cycle(50, self.update_maze) def run(self): - self.engine.mainloop(update=self.update_maze, bg_update=self.draw_maze) - - def scroll_cursor(self, x=0, y=0): - if self.pointer[0] + x > self.map.width or self.pointer[1] + y > self.map.height: - return - - self.pointer = ( - max(1, min(self.map.width-2, self.pointer[0] + x)), - max(1, min(self.map.height-2, self.pointer[1] + y)) - ) - self.engine.scroll_view(self.pointer) + self.render_engine.mainloop(update=self.update_maze, bg_update=self.draw_maze) - def play_sound(self, sound_file,tag="base"): - self.engine.play_sound(sound_file, tag=tag) + # ==================== GAME OVER LOGIC ==================== - def stop_sound(self, tag=None): - self.engine.stop_sound() - - def graphics_load(self): - self.tunnel = self.engine.load_image("Rat/BMP_TUNNEL.png", surface=True) - self.grasses = [self.engine.load_image(f"Rat/BMP_1_GRASS_{i+1}.png", surface=True) for i in range(4)] - self.rat_assets = {} - self.bomb_assets = {} - for sex in ["MALE", "FEMALE", "BABY"]: - self.rat_assets[sex] = {} - for direction in ["UP", "DOWN", "LEFT", "RIGHT"]: - self.rat_assets[sex][direction] = self.engine.load_image(f"Rat/BMP_{sex}_{direction}.png", transparent_color=(128, 128, 128)) - for n in range(5): - self.bomb_assets[n] = self.engine.load_image(f"Rat/BMP_BOMB{n}.png", transparent_color=(128, 128, 128)) - self.assets = {} - for file in os.listdir("assets/Rat"): - if file.endswith(".png"): - self.assets[file[:-4]] = self.engine.load_image(f"Rat/{file}") + def game_over(self): + if self.game_end[0]: + if not self.game_end[1]: + self.render_engine.dialog("Game Over: Mice are too many!", image=self.assets["BMP_WEWIN"]) + else: + self.render_engine.dialog(f"You Win! Points: {self.points}", image=self.assets["BMP_WEWIN"], scores=self.read_score()) - def add_point(self, value): - self.points += value + + return True + + if self.count_rats() > 200: + self.render_engine.stop_sound() + self.render_engine.play_sound("WEWIN.WAV") + self.game_end = (True, False) + self.game_status = "paused" + return True + if not len(self.units): + self.render_engine.stop_sound() + self.render_engine.play_sound("VICTORY.WAV") + self.render_engine.play_sound("WELLDONE.WAV", tag="effects") + self.game_end = (True, True) + self.game_status = "paused" + self.save_score() + return True + + + if __name__ == "__main__": diff --git a/units/__init__.py b/units/__init__.py new file mode 100644 index 0000000..6d3e24a --- /dev/null +++ b/units/__init__.py @@ -0,0 +1,8 @@ +""" +Units package - Game unit classes and factory. +""" + +from .unit import Unit +from .rat import Rat, Male, Female +from .bomb import Bomb, Timer, Explosion +from .points import Point diff --git a/units/bomb.py b/units/bomb.py index fe3a7c8..6a95cf8 100644 --- a/units/bomb.py +++ b/units/bomb.py @@ -31,14 +31,14 @@ class Bomb(Unit): if n < 0: n = 0 image = self.game.bomb_assets[n] - image_size = self.game.engine.get_image_size(image) + image_size = self.game.render_engine.get_image_size(image) self.rat_image = image partial_x, partial_y = 0, 0 x_pos = self.position_before[0] * self.game.cell_size + (self.game.cell_size - image_size[0]) // 2 + partial_x y_pos = self.position_before[1] * self.game.cell_size + (self.game.cell_size - image_size[1]) // 2 + partial_y - self.game.engine.draw_image(x_pos, y_pos, image, anchor="nw", tag="unit") + self.game.render_engine.draw_image(x_pos, y_pos, image, anchor="nw", tag="unit") class Timer(Bomb): def move(self): @@ -51,7 +51,7 @@ class Timer(Bomb): score = 10 print("BOOM") target_unit = unit if unit else self - self.game.play_sound("BOMB.WAV") + self.game.render_engine.play_sound("BOMB.WAV") # Use base class cleanup with error handling try: @@ -62,20 +62,23 @@ class Timer(Bomb): # Bomb-specific behavior: create explosion self.game.spawn_unit(Explosion, target_unit.position) + # Check for chain reactions in all four directions for direction in ["N", "S", "E", "W"]: - x, y = unit.position + x, y = target_unit.position while True: if not self.game.map.is_wall(x, y): self.game.spawn_unit(Explosion, (x, y)) for victim in self.game.unit_positions.get((x, y), []): if victim.id in self.game.units: if victim.partial_move >= 0.5: + print(f"Victim {victim.id} at {x}, {y} dies") victim.die(score=score) if score < 160: score *= 2 for victim in self.game.unit_positions_before.get((x, y), []): if victim.id in self.game.units: if victim.partial_move < 0.5: + print(f"Victim {victim.id} at {x}, {y} dies") victim.die(score=score) if score < 160: score *= 2 @@ -99,10 +102,10 @@ class Explosion(Bomb): def draw(self): image = self.game.assets["BMP_EXPLOSION"] - image_size = self.game.engine.get_image_size(image) + image_size = self.game.render_engine.get_image_size(image) partial_x, partial_y = 0, 0 x_pos = self.position_before[0] * self.game.cell_size + (self.game.cell_size - image_size[0]) // 2 + partial_x y_pos = self.position_before[1] * self.game.cell_size + (self.game.cell_size - image_size[1]) // 2 + partial_y - self.game.engine.draw_image(x_pos, y_pos, image, anchor="nw", tag="unit") \ No newline at end of file + self.game.render_engine.draw_image(x_pos, y_pos, image, anchor="nw", tag="unit") \ No newline at end of file diff --git a/units/points.py b/units/points.py index 42a784f..6abbe6c 100644 --- a/units/points.py +++ b/units/points.py @@ -32,11 +32,11 @@ class Point(Unit): def draw(self): image = self.game.assets[f"BMP_BONUS_{self.value}"] - image_size = self.game.engine.get_image_size(image) + image_size = self.game.render_engine.get_image_size(image) self.rat_image = image partial_x, partial_y = 0, 0 x_pos = self.position_before[0] * self.game.cell_size + (self.game.cell_size - image_size[0]) // 2 + partial_x y_pos = self.position_before[1] * self.game.cell_size + (self.game.cell_size - image_size[1]) // 2 + partial_y - self.game.engine.draw_image(x_pos, y_pos, image, anchor="nw", tag="unit") + self.game.render_engine.draw_image(x_pos, y_pos, image, anchor="nw", tag="unit") diff --git a/units/rat.py b/units/rat.py index 07333a9..8421d27 100644 --- a/units/rat.py +++ b/units/rat.py @@ -98,12 +98,12 @@ class Rat(Unit): self.game.spawn_unit(Point, target_unit.position_before, value=score) def draw(self): - start_perf = self.game.engine.get_perf_counter() + start_perf = self.game.render_engine.get_perf_counter() direction = self.calculate_rat_direction() sex = self.sex if self.age > AGE_THRESHOLD else "BABY" image = self.game.rat_assets[sex][direction] - image_size = self.game.engine.get_image_size(image) + image_size = self.game.render_engine.get_image_size(image) self.rat_image = image partial_x, partial_y = 0, 0 @@ -114,9 +114,9 @@ class Rat(Unit): x_pos = self.position_before[0] * self.game.cell_size + (self.game.cell_size - image_size[0]) // 2 + partial_x y_pos = self.position_before[1] * self.game.cell_size + (self.game.cell_size - image_size[1]) // 2 + partial_y - self.game.engine.draw_image(x_pos, y_pos, image, anchor="nw", tag="unit") + self.game.render_engine.draw_image(x_pos, y_pos, image, anchor="nw", tag="unit") self.bbox = (x_pos, y_pos, x_pos + image_size[0], y_pos + image_size[1]) - #self.game.engine.draw_rectangle(self.bbox[0], self.bbox[1], self.bbox[2] - self.bbox[0], self.bbox[3] - self.bbox[1], "unit") + #self.game.render_engine.draw_rectangle(self.bbox[0], self.bbox[1], self.bbox[2] - self.bbox[0], self.bbox[3] - self.bbox[1], "unit") class Male(Rat): def __init__(self, game, position=(0,0), id=None): @@ -125,7 +125,7 @@ class Male(Rat): def fuck(self, unit): if not unit.pregnant: - self.game.play_sound("SEX.WAV") + self.game.render_engine.play_sound("SEX.WAV") self.stop = 100 unit.stop = 200 unit.pregnant = PREGNANCY_DURATION @@ -144,7 +144,7 @@ class Female(Rat): self.babies -= 1 self.stop = 20 if self.partial_move > 0.2: - self.game.new_rat(self.position) + self.game.spawn_rat(self.position) else: - self.game.new_rat(self.position_before) - self.game.play_sound("BIRTH.WAV") \ No newline at end of file + self.game.spawn_rat(self.position_before) + self.game.render_engine.play_sound("BIRTH.WAV") \ No newline at end of file