import os import random import time from engine import maze, config from engine.collision_system import CollisionLayer from runtime_paths import bundle_path class Graphics: def __init__(self, game): self.game = game self.loaded_theme_index = None def load_assets(self): theme_index = self.get_theme_index() print(f"[gfx] load_assets requested: level={self.game.current_level + 1} theme={theme_index}") if getattr(self.game, "startup_loading_active", False): self.game._update_startup_loading( "Loading graphics", detail=f"Preparing theme {theme_index}", progress=0.3, ) if not hasattr(self, "theme_assets_cache"): self.theme_assets_cache = {} if not hasattr(self, "blood_layer_sprites"): self.blood_layer_sprites = [] if not hasattr(self, "cave_foreground_tiles"): self.cave_foreground_tiles = [] if not getattr(self, "common_assets_loaded", False): print("Loading graphics assets...") if getattr(self.game, "startup_loading_active", False): self.game._update_startup_loading( "Loading graphics", detail="Decoding sprites and tiles", progress=0.4, ) self.rat_assets = {} self.rat_assets_textures = {} self.rat_image_sizes = {} self.bomb_assets = {} self.assets = {} for sex in ["MALE", "FEMALE", "BABY"]: self.rat_assets[sex] = {} self.rat_assets_textures[sex] = {} self.rat_image_sizes[sex] = {} for direction in ["UP", "DOWN", "LEFT", "RIGHT"]: self.rat_assets[sex][direction] = self.game.render_engine.load_image( f"Rat/BMP_{sex}_{direction}.png", transparent_color=((125, 125, 125), (128, 128, 128)), ) texture = self.game.render_engine.load_image( f"Rat/BMP_{sex}_{direction}.png", transparent_color=((125, 125, 125), (128, 128, 128)), surface=False, ) self.rat_assets_textures[sex][direction] = texture self.rat_image_sizes[sex][direction] = texture.size for n in range(5): self.bomb_assets[n] = self.game.render_engine.load_image( f"Rat/BMP_BOMB{n}.png", transparent_color=((125, 125, 125), (128, 128, 128)), ) rat_asset_dir = bundle_path("assets", "Rat") for file in sorted(os.listdir(rat_asset_dir)): if file.endswith(".png") and not file.startswith("."): # Check if it's one of our expected BMP files or other known assets # to avoid loading temporary or irrelevant PNGs file_key = file[:-4] try: self.assets[file_key] = self.game.render_engine.load_image( f"Rat/{file}", transparent_color=((125, 125, 125), (128, 128, 128)), ) except (FileNotFoundError, IOError) as e: print(f"Warning: Could not load asset {file}: {e}") print("Pre-generating blood stain pool...") if getattr(self.game, "startup_loading_active", False): self.game._update_startup_loading( "Loading graphics", detail="Generating blood pool", progress=0.58, ) self.blood_stain_textures = [] for _ in range(10): blood_surface = self.game.render_engine.generate_blood_surface() blood_texture = self.game.render_engine.draw_blood_surface(blood_surface, (0, 0)) if blood_texture: self.blood_stain_textures.append(blood_texture) self.common_assets_loaded = True print("[gfx] common assets loaded") else: print("[gfx] common assets cache hit") if theme_index not in self.theme_assets_cache: print(f"Loading theme assets {theme_index}...") if getattr(self.game, "startup_loading_active", False): self.game._update_startup_loading( "Loading graphics", detail=f"Loading theme {theme_index} art", progress=0.74, ) self.theme_assets_cache[theme_index] = { "floor_tile": self.game.render_engine.create_color_surface((128, 128, 128)), "tunnel": self.game.render_engine.load_image("Rat/BMP_TUNNEL.png"), "grasses": [ self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_GRASS_{i+1}.png", surface=True) for i in range(4) ], "grass_textures": [ self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_GRASS_{i+1}.png") for i in range(4) ], "flowers": [ self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_FLOWER_{i+1}.png", surface=True) for i in range(4) ], "flower_textures": [ self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_FLOWER_{i+1}.png") for i in range(4) ], "caves": { direction: self.game.render_engine.load_image( f"Rat/BMP_{theme_index}_CAVE_{direction}.png", transparent_color=((125, 125, 125), (128, 128, 128)), surface=False, ) for direction in ["UP", "DOWN", "LEFT", "RIGHT"] }, "explosions": { direction: self.game.render_engine.load_image( f"Rat/BMP_{theme_index}_EXPLOSION_{direction}.png", transparent_color=((125, 125, 125), (128, 128, 128)), surface=False, ) for direction in ["UP", "DOWN", "LEFT", "RIGHT"] }, "edges": { direction: self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True) for direction in ["N", "S", "E", "W"] }, "corners": { direction: self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True) for direction in ["NE", "NW", "SE", "SW"] }, "inner_corners": { direction: self.game.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True) for direction in ["EN", "ES", "WN", "WS"] }, } print(f"[gfx] theme cache miss -> loaded theme {theme_index}") else: print(f"[gfx] theme cache hit -> reusing theme {theme_index}") if getattr(self.game, "startup_loading_active", False): self.game._update_startup_loading( "Loading graphics", detail="Finishing render setup", progress=0.84, ) self.loaded_theme_index = theme_index theme_assets = self.theme_assets_cache[theme_index] self.floor_tile = theme_assets["floor_tile"] self.tunnel = theme_assets["tunnel"] self.grasses = theme_assets["grasses"] self.grass_textures = theme_assets["grass_textures"] self.flowers = theme_assets["flowers"] self.flower_textures = theme_assets["flower_textures"] self.caves = theme_assets["caves"] self.explosions = theme_assets["explosions"] self.edges = theme_assets["edges"] self.corners = theme_assets["corners"] self.inner_corners = theme_assets["inner_corners"] def get_theme_index(self): return self.game.current_level % 32 // 8 + 1 # ==================== RENDERING ==================== def draw_maze(self): if self.game.background_texture is None: print(f"[gfx] generating background texture for level={self.game.current_level + 1} theme={self.loaded_theme_index}") self.regenerate_background() self.game.render_engine.draw_background(self.game.background_texture) # Draw blood layer as sprites (optimized - no background regeneration) self.draw_blood_layer() def draw_cave_foreground(self): active_cave_explosions = {} for unit in self.game.units.values(): if unit.collision_layer != CollisionLayer.EXPLOSION: continue if not self.game.map.is_tunnel(*unit.position): continue active_cave_explosions[unit.position] = getattr(unit, "cave_direction", None) for cell_x, cell_y, direction, surface, x, y in self.cave_foreground_tiles: if (cell_x, cell_y) in active_cave_explosions: explosion_direction = active_cave_explosions[(cell_x, cell_y)] or direction surface = self.explosions.get(explosion_direction, surface) self.game.render_engine.draw_image(x, y, surface, anchor="nw", tag="cave") def draw_blood_layer(self): """Draw all blood stains as sprites overlay (optimized)""" for blood_texture, x, y in self.blood_layer_sprites: self.game.render_engine.draw_image(x, y, blood_texture, tag="blood") def regenerate_background(self): """Generate or regenerate the background texture (static - no blood stains)""" texture_tiles = [] self.cave_foreground_tiles = [] half_cell = self.game.cell_size // 2 def draw(surface, x, y): texture_tiles.append((surface, x, y)) return True # allow callers to count successful draws def draw_cave(surface, x, y, direction): self.cave_foreground_tiles.append((x // self.game.cell_size, y // self.game.cell_size, direction, surface, x, y)) def occupied(x, y): return self.game.map.in_bounds(x, y) and self.game.map.get_cell(x, y) != maze.MAP_EMPTY def is_wall(x, y): return self.game.map.in_bounds(x, y) and self.game.map.get_cell(x, y) == maze.MAP_WALL def is_tunnel(x, y): return self.game.map.in_bounds(x, y) and self.game.map.get_cell(x, y) == maze.MAP_TUNNEL def random_wall(): return random.choice(self.grasses) def random_wall_texture(): return random.choice(self.grass_textures) def random_flower(): return random.choice(self.flowers) def random_flower_texture(): return random.choice(self.flower_textures) for y, row in enumerate(self.game.map.tiles): for x, cell in enumerate(row): px = x * self.game.cell_size py = y * self.game.cell_size if cell == maze.MAP_EMPTY: continue if cell == maze.MAP_WALL: wall_tiles_drawn = 0 def draw_wall(surface, x, y): nonlocal wall_tiles_drawn draw(surface, x, y) wall_tiles_drawn += 1 if x == 0 or y == 0 or x == self.game.map.width - 1 or y == self.game.map.height - 1: draw_wall(random_wall(), px, py) if x > 0 and y > 0 and (not is_wall(x - 1, y - 1) or not is_wall(x, y - 1) or not is_wall(x - 1, y)): north = is_wall(x, y - 1) west = is_wall(x - 1, y) if north or west: if north and west: draw_wall(self.inner_corners["WN"], px, py) elif north and not west: draw_wall(self.edges["W"], px, py) else: draw_wall(self.edges["N"], px, py) else: draw_wall(self.corners["NW"], px, py) if y < self.game.map.height - 1 and x < self.game.map.width - 1: south = is_wall(x, y + 1) east = is_wall(x + 1, y) southeast = is_wall(x + 1, y + 1) if southeast and south and east: if ( random.randrange(10) != 0 or x == 0 or y == 0 or x == self.game.map.width - 2 or y == self.game.map.height - 2 or is_tunnel(x + 1, y) or is_tunnel(x, y + 1) or is_tunnel(x + 1, y + 1) ): draw_wall(random_wall(), px + half_cell, py + half_cell) else: draw_wall(random_flower(), px + half_cell, py + half_cell) elif south or east: if south and east: draw_wall(self.inner_corners["ES"], px + half_cell, py + half_cell) elif south and not east: draw_wall(self.edges["E"], px + half_cell, py + half_cell) else: draw_wall(self.edges["S"], px + half_cell, py + half_cell) else: draw_wall(self.corners["SE"], px + half_cell, py + half_cell) if y > 0 and x < self.game.map.width - 1 and (not is_wall(x + 1, y - 1) or not is_wall(x, y - 1) or not is_wall(x + 1, y)): north = is_wall(x, y - 1) east = is_wall(x + 1, y) if north or east: if north and east: draw_wall(self.inner_corners["EN"], px + half_cell, py) elif north and not east: draw_wall(self.edges["E"], px + half_cell, py) else: draw_wall(self.edges["N"], px + half_cell, py) else: draw_wall(self.corners["NE"], px + half_cell, py) if y < self.game.map.height - 1 and x > 0 and (not is_wall(x - 1, y + 1) or not is_wall(x, y + 1) or not is_wall(x - 1, y)): south = is_wall(x, y + 1) west = is_wall(x - 1, y) if south or west: if south and west: draw_wall(self.inner_corners["WS"], px, py + half_cell) elif south and not west: draw_wall(self.edges["W"], px, py + half_cell) else: draw_wall(self.edges["S"], px, py + half_cell) else: draw_wall(self.corners["SW"], px, py + half_cell) # Fallback: isolated/surrounded wall cells must not show background color if wall_tiles_drawn == 0: draw(random_wall(), px, py) elif cell == maze.MAP_TUNNEL: above = occupied(x, y - 1) below = occupied(x, y + 1) left = occupied(x - 1, y) right = occupied(x + 1, y) if above: if below: if left: if right: # Internal tunnel passage: leave it empty so it uses # the background fill color and stays visually open. pass else: draw_cave(self.caves["RIGHT"], px, py, "RIGHT") else: draw_cave(self.caves["LEFT"], px, py, "LEFT") else: draw_cave(self.caves["DOWN"], px, py, "DOWN") else: draw_cave(self.caves["UP"], px, py, "UP") # Blood stains now handled separately as overlay layer self.game.background_texture = self.game.render_engine.create_texture(texture_tiles, fill_color=(128, 128, 128)) def add_blood_stain(self, position): """Add a blood stain as sprite overlay (optimized - no background regeneration)""" # Pick random blood texture from pre-generated pool if not self.blood_stain_textures: return blood_texture = random.choice(self.blood_stain_textures) x = position[0] * self.game.cell_size y = position[1] * self.game.cell_size # Add to blood layer sprites instead of regenerating background self.blood_layer_sprites.append((blood_texture, x, y)) def scroll_cursor(self, x=0, y=0): if self.game.pointer[0] + x > self.game.map.width or self.game.pointer[1] + y > self.game.map.height: return self.game.pointer = ( max(1, min(self.game.map.width-2, self.game.pointer[0] + x)), max(1, min(self.game.map.height-2, self.game.pointer[1] + y)) ) self.game.render_engine.scroll_view(self.game.pointer) # ==================== MENU RENDERING ==================== def _menu_font(self, size): clamped = max(10, min(69, int(size))) return self.game.render_engine.fonts[clamped] def _draw_start_menu_difficulty_selector(self, x, y, width, height): colors = config.START_MENU_COLORS difficulty_config = self.game._difficulty_config() accent = difficulty_config["accent"] render_engine = self.game.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 _draw_start_menu_slider(self, x, y, width, height, setting_name, label, value, selected): colors = config.START_MENU_COLORS style = config.START_MENU_AUDIO_STYLES[setting_name] accent = style["accent"] fill_color = style["fill"] if selected else colors["card_fill"] border_color = accent if selected else (156, 156, 156) text_color = colors["text"] render_engine = self.game.render_engine render_engine.draw_rectangle(x, y, width, height, "start_menu_slider", filling=fill_color) render_engine.draw_rectangle(x, y, width, height, "start_menu_slider", outline=border_color) if selected: render_engine.draw_rectangle(x + 12, y + 10, 8, height - 20, "start_menu_slider", filling=accent) title_y = y + 10 render_engine.draw_text(label, self._menu_font(render_engine.target_size[1] // 32), (x + 34, title_y), text_color) render_engine.draw_text(f"{value}%", self._menu_font(render_engine.target_size[1] // 34), (x + width - 84, title_y + 2), text_color) track_x = x + 34 track_y = y + height - 26 track_width = width - 68 track_height = 14 filled_width = int(track_width * value / 100) knob_width = 14 knob_x = track_x + int((track_width - knob_width) * value / 100) render_engine.draw_rectangle(track_x, track_y, track_width, track_height, "start_menu_slider", filling=colors["track_fill"]) render_engine.draw_rectangle(track_x, track_y, track_width, track_height, "start_menu_slider", outline=(128, 128, 128)) if filled_width > 0: render_engine.draw_rectangle(track_x, track_y, filled_width, track_height, "start_menu_slider", filling=accent) 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.game, "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.game.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 = config.START_MENU_COLORS render_engine = self.game.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(44, target_height // 13) 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( title, self._menu_font(target_height // 20), ("center", panel_y + 16), colors["text"], ) image = self.assets[image_name] image_width, image_height = render_engine.get_image_size(image) image_y = panel_y + header_height + (14 if compact_menu else 18) render_engine.draw_image( target_width // 2 - image_width // 2 - render_engine.w_offset, image_y - render_engine.h_offset, image, "start_menu", ) info_y = image_y + image_height + 18 line_gap = 18 if compact_menu else max(20, target_height // 34) for index, line in enumerate(subtitle_lines): render_engine.draw_text( line, self._menu_font(target_height // (34 if index == 0 else 36)), ("center", info_y + line_gap * index), colors["text"] if index == 0 else colors["muted"], ) cta_y = info_y + line_gap * len(subtitle_lines) + 8 render_engine.draw_text( primary_action_text, self._menu_font(target_height // 31), ("center", cta_y), colors["text"], ) card_width = max(320, min(panel_width - 160, 720)) card_height = 60 if compact_menu else max(68, target_height // 10) card_x = target_width // 2 - card_width // 2 cards_y = cta_y + (16 if compact_menu else 26) card_gap = 12 if compact_menu else 16 for index, (setting_name, label) in enumerate(config.START_MENU_AUDIO_OPTIONS): card_y = cards_y + index * (card_height + card_gap) self._draw_start_menu_slider( card_x, card_y, card_width, card_height, setting_name, label, getattr(self.game, setting_name), index == self.game.start_menu_selection, ) cards_bottom = cards_y + len(config.START_MENU_AUDIO_OPTIONS) * card_height + (len(config.START_MENU_AUDIO_OPTIONS) - 1) * card_gap if compact_menu: hint_y = min(cards_bottom + 8, panel_y + panel_height - 28) render_engine.draw_text( hint_text, self._menu_font(target_height // 42), ("center", hint_y), colors["muted"], ) else: hint_y = cards_bottom + 10 hint_height = max(34, target_height // 18) hint_width = card_width hint_x = card_x render_engine.draw_rectangle(hint_x, hint_y, hint_width, hint_height, "start_menu", filling=colors["hint_fill"]) render_engine.draw_rectangle(hint_x, hint_y, hint_width, hint_height, "start_menu", outline=(170, 170, 170)) render_engine.draw_text( hint_text, self._menu_font(target_height // 40), ("center", hint_y + 10), colors["muted"], ) def render_start_menu(self): colors = config.START_MENU_COLORS render_engine = self.game.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.game.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.game.profile_integration.current_profile device_id = self.game.profile_integration.get_device_id() subtitle_lines = ["A game by Matteo, because he was bored."] if player_profile: if compact_menu: subtitle_lines.append( 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 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") 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"], ) 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"], ) 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 = [ f"Level {self.game.current_level + 1} | Points: {self.game.points}", f"Rats in maze: {self.game.unit_manager.count_rats()}", ] self._render_audio_menu( title="Pause", subtitle_lines=subtitle_lines, primary_action_text="Press Return to resume", hint_text="Up/Down select Left/Right adjust Esc quits", image_name="BMP_PAUSE" if "BMP_PAUSE" in self.assets else "BMP_WEWIN", )