Files
mice/engine/graphics.py
T
enne2 2eccf504f5 Add semi-transparent tunnel cover overlay for internal passages
- Add sdl2.create_overlay_texture() and draw_overlay_texture(alpha) for
  transparent full-map overlays built from sub-tile surface blits.
- Add Graphics.regenerate_tunnel_cover() which builds an overlay of
  4 random grass sub-tiles (20x20) for every internal tunnel cell,
  defined as a tunnel cell surrounded by occupied cells (wall or
  tunnel) on all four sides. Cells with at least one open side are
  handled by cave_foreground and skipped here.
- Draw the tunnel cover in the game loop after top-layer units/effects
  but before cave_foreground and points, at 95% opacity (alpha=242)
  so the unit and effect passing underneath is just barely visible.
2026-06-16 23:06:17 +02:00

734 lines
33 KiB
Python

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 hasattr(self, "tunnel_cover_texture"):
self.tunnel_cover_texture = None
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 regenerate_tunnel_cover(self):
"""Generate an overlay texture that covers internal tunnel passages with grass sub-tiles.
A tunnel cell is considered an internal passage only when it is surrounded
by occupied cells (walls or tunnels) on all four sides. In that case there is
no cave entrance drawn by cave_foreground, so we cover it here.
"""
tunnel_tiles = []
half_cell = self.game.cell_size // 2
def occupied(x, y):
return self.game.map.in_bounds(x, y) and self.game.map.get_cell(x, y) != maze.MAP_EMPTY
for y, row in enumerate(self.game.map.tiles):
for x, cell in enumerate(row):
if cell != maze.MAP_TUNNEL:
continue
above = occupied(x, y - 1)
below = occupied(x, y + 1)
left = occupied(x - 1, y)
right = occupied(x + 1, y)
if not (above and below and left and right):
continue
px = x * self.game.cell_size
py = y * self.game.cell_size
for qx, qy, sx, sy in [
(0, 0, 0, 0),
(half_cell, 0, half_cell, 0),
(0, half_cell, 0, half_cell),
(half_cell, half_cell, half_cell, half_cell),
]:
grass = random.choice(self.grasses)
tunnel_tiles.append((grass, sx, sy, half_cell, half_cell, px + qx, py + qy))
self.tunnel_cover_texture = self.game.render_engine.create_overlay_texture(tunnel_tiles)
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 = []
self.regenerate_tunnel_cover()
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",
)