import os import random import pygame from pygame import mixer class GameWindow: """ Pygame-based game window implementation. Provides a complete interface equivalent to sdl2_layer.GameWindow """ def __init__(self, width, height, cell_size, title="Default", key_callback=None): # Display configuration self.cell_size = cell_size self.width = width * cell_size self.height = height * cell_size # Screen resolution handling actual_screen_size = os.environ.get("RESOLUTION", "640x480").split("x") actual_screen_size = tuple(map(int, actual_screen_size)) self.target_size = actual_screen_size if self.width > actual_screen_size[0] or self.height > actual_screen_size[1] else (self.width, self.height) # View offset calculations self.w_start_offset = (self.target_size[0] - self.width) // 2 self.h_start_offset = (self.target_size[1] - self.height) // 2 self.w_offset = self.w_start_offset self.h_offset = self.h_start_offset self.max_w_offset = self.target_size[0] - self.width self.max_h_offset = self.target_size[1] - self.height self.scale = self.target_size[1] // self.cell_size print(f"Screen size: {self.width}x{self.height}") # Pygame initialization pygame.init() mixer.init(frequency=22050, size=-16, channels=1, buffer=2048) # Window and screen setup self.window = pygame.display.set_mode(self.target_size) pygame.display.set_caption(title) self.screen = self.window # Font system self.fonts = self.generate_fonts("assets/decterm.ttf") # Game state self.running = True self.delay = 30 self.performance = 0 self.last_status_text = "" self.stats_sprite = None self.mean_fps = 0 self.fpss = [] self.text_width = 0 self.text_height = 0 self.ammo_text = "" self.stats_background = None self.ammo_background = None self.ammo_sprite = None # White flash effect state self.white_flash_active = False self.white_flash_start_time = 0 self.white_flash_opacity = 255 # Input handling self.trigger = key_callback self.button_cursor = [0, 0] self.buttons = {} # Audio system initialization self._init_audio_system() self.audio = True # Clock for frame rate control self.clock = pygame.time.Clock() # Input devices self.load_joystick() def show(self): """Show the window (for compatibility with SDL2 interface)""" pygame.display.set_mode(self.target_size) def _init_audio_system(self): """Initialize audio channels for different audio types""" mixer.set_num_channels(8) # Ensure enough channels self.audio_channels = { "base": mixer.Channel(0), "effects": mixer.Channel(1), "music": mixer.Channel(2) } self.current_sounds = {} # ====================== # TEXTURE & IMAGE METHODS # ====================== def create_texture(self, tiles: list): """Create a texture from a list of tiles""" bg_surface = pygame.Surface((self.width, self.height)) for tile in tiles: bg_surface.blit(tile[0], (tile[1], tile[2])) return bg_surface # Helpers to support incremental background generation def create_empty_background_surface(self): """Create and return an empty background surface to incrementally blit onto.""" return pygame.Surface((self.width, self.height)) def blit_tiles_batch(self, bg_surface, tiles_batch: list): """Blit a small batch of tiles onto the provided background surface. tiles_batch: list of (surface, x, y) Returns None. Designed to be called repeatedly with small batches to avoid long blocking operations. """ for tile, x, y in tiles_batch: try: bg_surface.blit(tile, (x, y)) except Exception: # If tile is a SpriteWrapper, extract surface try: bg_surface.blit(tile.surface, (x, y)) except Exception: pass def load_image(self, path, transparent_color=None, surface=False): """Load and process an image with optional transparency and scaling""" image_path = os.path.join("assets", path) # First try to use pygame's native loader which avoids a Pillow dependency. try: py_image = pygame.image.load(image_path) # Ensure alpha if needed try: py_image = py_image.convert_alpha() except Exception: try: py_image = py_image.convert() except Exception: pass # Handle transparent color via colorkey if provided if transparent_color: # pygame expects a tuple of ints try: py_image.set_colorkey(transparent_color) except Exception: pass # Scale image using pygame transforms scale = max(1, self.cell_size // 20) new_size = (py_image.get_width() * scale, py_image.get_height() * scale) try: py_image = pygame.transform.scale(py_image, new_size) except Exception: # If scaling fails, continue with original pass if not surface: return SpriteWrapper(py_image) return py_image except Exception: # Fallback to PIL-based loading if pygame can't handle the file or Pillow is present try: # Import Pillow lazily to avoid hard dependency at module import time try: from PIL import Image except Exception: Image = None if Image is None: raise image = Image.open(image_path) # Handle transparency if transparent_color: image = image.convert("RGBA") datas = image.getdata() new_data = [] for item in datas: if item[:3] == transparent_color: new_data.append((255, 255, 255, 0)) else: new_data.append(item) image.putdata(new_data) # Scale image scale = max(1, self.cell_size // 20) image = image.resize((image.width * scale, image.height * scale), Image.NEAREST) # Convert PIL image to pygame surface mode = image.mode size = image.size data = image.tobytes() if mode == "RGBA": py_image = pygame.image.fromstring(data, size, mode) elif mode == "RGB": py_image = pygame.image.fromstring(data, size, mode) else: image = image.convert("RGBA") data = image.tobytes() py_image = pygame.image.fromstring(data, size, "RGBA") if not surface: return SpriteWrapper(py_image) return py_image except Exception: # If both loaders fail, raise to notify caller raise def get_image_size(self, image): """Get the size of an image sprite""" if isinstance(image, SpriteWrapper): return image.size return image.get_size() # ====================== # FONT MANAGEMENT # ====================== def generate_fonts(self, font_file): """Generate font objects for different sizes""" fonts = {} for i in range(10, 70, 1): try: fonts[i] = pygame.font.Font(font_file, i) except: fonts[i] = pygame.font.Font(None, i) return fonts # ====================== # DRAWING METHODS # ====================== def draw_text(self, text, font, position, color): """Draw text at specified position with given font and color""" if isinstance(color, tuple): # Pygame color format pass else: # Convert from any other format to RGB tuple color = (color.r, color.g, color.b) if hasattr(color, 'r') else (0, 0, 0) text_surface = font.render(text, True, color) text_rect = text_surface.get_rect() # Handle center positioning if position == "center": position = ("center", "center") if isinstance(position, tuple): if position[0] == "center": text_rect.centerx = self.target_size[0] // 2 text_rect.y = position[1] elif position[1] == "center": text_rect.x = position[0] text_rect.centery = self.target_size[1] // 2 else: text_rect.topleft = position self.screen.blit(text_surface, text_rect) def draw_background(self, bg_texture): """Draw background texture with current view offset""" self.screen.blit(bg_texture, (self.w_offset, self.h_offset)) def draw_image(self, x, y, sprite, tag=None, anchor="nw"): """Draw an image sprite at specified coordinates""" if not self.is_in_visible_area(x, y): return if isinstance(sprite, SpriteWrapper): surface = sprite.surface else: surface = sprite self.screen.blit(surface, (x + self.w_offset, y + self.h_offset)) def draw_rectangle(self, x, y, width, height, tag, outline="red", filling=None): """Draw a rectangle with optional fill and outline""" rect = pygame.Rect(x, y, width, height) if filling: pygame.draw.rect(self.screen, filling, rect) else: # Handle outline color if isinstance(outline, str): color_map = { "red": (255, 0, 0), "blue": (0, 0, 255), "green": (0, 255, 0), "black": (0, 0, 0), "white": (255, 255, 255) } outline = color_map.get(outline, (255, 0, 0)) pygame.draw.rect(self.screen, outline, rect, 2) def draw_pointer(self, x, y): """Draw a red pointer rectangle at specified coordinates""" x = x + self.w_offset y = y + self.h_offset for i in range(3): rect = pygame.Rect(x + i, y + i, self.cell_size - 2*i, self.cell_size - 2*i) pygame.draw.rect(self.screen, (255, 0, 0), rect, 1) def delete_tag(self, tag): """Placeholder for tag deletion (not needed in pygame implementation)""" pass # ====================== # UI METHODS # ====================== def dialog(self, text, **kwargs): """Display a dialog box with text and optional extras""" # Draw dialog background dialog_rect = pygame.Rect(50, 50, self.target_size[0] - 100, self.target_size[1] - 100) pygame.draw.rect(self.screen, (255, 255, 255), dialog_rect) # Calculate layout positions to avoid overlaps title_y = self.target_size[1] // 4 # Title at 1/4 of screen height # Draw main text (title) self.draw_text(text, self.fonts[self.target_size[1]//20], ("center", title_y), (0, 0, 0)) # Draw image if provided - position it below title image_bottom_y = title_y + 60 # Default position if no image if image := kwargs.get("image"): image_size = self.get_image_size(image) image_y = title_y + 50 self.draw_image(self.target_size[0] // 2 - image_size[0] // 2 - self.w_offset, image_y - self.h_offset, image, "win") image_bottom_y = image_y + image_size[1] + 20 # Draw subtitle if provided - handle multi-line text, position below image if subtitle := kwargs.get("subtitle"): subtitle_lines = subtitle.split('\n') base_y = image_bottom_y + 20 line_height = 25 # Fixed line height for consistent spacing for i, line in enumerate(subtitle_lines): if line.strip(): # Only draw non-empty lines self.draw_text(line.strip(), self.fonts[self.target_size[1]//35], ("center", base_y + i * line_height), (0, 0, 0)) # Draw scores if provided - position at bottom if scores := kwargs.get("scores"): scores_start_y = self.target_size[1] * 3 // 4 # Bottom quarter of screen title_surface = self.fonts[self.target_size[1]//25].render("High Scores:", True, (0, 0, 0)) title_rect = title_surface.get_rect(center=(self.target_size[0] // 2, scores_start_y)) self.screen.blit(title_surface, title_rect) for i, score in enumerate(scores[:5]): if len(score) >= 4: # New format: date, score, name, device score_text = f"{score[2]}: {score[1]} pts ({score[3]})" elif len(score) >= 3: # Medium format: date, score, name score_text = f"{score[2]}: {score[1]} pts" else: # Old format: date, score score_text = f"Guest: {score[1]} pts" self.draw_text(score_text, self.fonts[self.target_size[1]//45], ("center", scores_start_y + 30 + 25 * (i + 1)), (0, 0, 0)) def start_dialog(self, **kwargs): """Display the welcome dialog""" self.dialog("Welcome to the Mice!", subtitle="A game by Matteo because was bored", **kwargs) def draw_button(self, x, y, text, width, height, coords): """Draw a button with text""" color = (0, 0, 255) if self.button_cursor == list(coords) else (0, 0, 0) self.draw_rectangle(x, y, width, height, "button", outline=color) def update_status(self, text): """Update and display the status bar with FPS information""" fps = int(self.clock.get_fps()) if self.clock.get_fps() > 0 else 0 if len(self.fpss) > 20: self.mean_fps = round(sum(self.fpss) / len(self.fpss)) if self.fpss else fps self.fpss.clear() else: self.fpss.append(fps) status_text = f"FPS: {self.mean_fps} - {text}" if status_text != self.last_status_text: self.last_status_text = status_text font = self.fonts[20] self.stats_sprite = font.render(status_text, True, (0, 0, 0)) if self.text_width != self.stats_sprite.get_width() or self.text_height != self.stats_sprite.get_height(): self.text_width, self.text_height = self.stats_sprite.get_size() self.stats_background = pygame.Surface((self.text_width + 10, self.text_height + 4)) self.stats_background.fill((255, 255, 255)) self.screen.blit(self.stats_background, (3, 3)) self.screen.blit(self.stats_sprite, (8, 5)) def update_ammo(self, ammo, assets): """Update and display the ammo count""" ammo_text = f"{ammo['bomb']['count']}/{ammo['bomb']['max']} {ammo['mine']['count']}/{ammo['mine']['max']} {ammo['gas']['count']}/{ammo['gas']['max']} " if self.ammo_text != ammo_text: self.ammo_text = ammo_text font = self.fonts[20] self.ammo_sprite = font.render(ammo_text, True, (0, 0, 0)) text_width, text_height = self.ammo_sprite.get_size() self.ammo_background = pygame.Surface((text_width + 10, text_height + 4)) self.ammo_background.fill((255, 255, 255)) text_width, text_height = self.ammo_sprite.get_size() position = (self.target_size[0] - text_width - 10, self.target_size[1] - text_height - 5) self.screen.blit(self.ammo_background, (position[0] - 5, position[1] - 2)) self.screen.blit(self.ammo_sprite, position) # Draw ammo icons bomb_sprite = assets["BMP_BOMB0"] poison_sprite = assets["BMP_POISON"] gas_sprite = assets["BMP_GAS"] if isinstance(bomb_sprite, SpriteWrapper): self.screen.blit(bomb_sprite.surface, (position[0]+25, position[1])) else: # Scale to 20x20 if needed bomb_scaled = pygame.transform.scale(bomb_sprite, (20, 20)) self.screen.blit(bomb_scaled, (position[0]+25, position[1])) if isinstance(poison_sprite, SpriteWrapper): self.screen.blit(poison_sprite.surface, (position[0]+85, position[1])) else: poison_scaled = pygame.transform.scale(poison_sprite, (20, 20)) self.screen.blit(poison_scaled, (position[0]+85, position[1])) if isinstance(gas_sprite, SpriteWrapper): self.screen.blit(gas_sprite.surface, (position[0]+140, position[1])) else: gas_scaled = pygame.transform.scale(gas_sprite, (20, 20)) self.screen.blit(gas_scaled, (position[0]+140, position[1])) # ====================== # VIEW & NAVIGATION # ====================== def scroll_view(self, pointer): """Adjust the view offset based on pointer coordinates""" x, y = pointer # Scale down and invert coordinates x = -(x // 2) * self.cell_size y = -(y // 2) * self.cell_size # Clamp horizontal offset to valid range if x <= self.max_w_offset + self.cell_size: x = self.max_w_offset # Clamp vertical offset to valid range if y < self.max_h_offset: y = self.max_h_offset self.w_offset = x self.h_offset = y def is_in_visible_area(self, x, y): """Check if coordinates are within the visible area""" return (-self.w_offset - self.cell_size <= x <= self.width - self.w_offset and -self.h_offset - self.cell_size <= y <= self.height - self.h_offset) def get_view_center(self): """Get the center coordinates of the current view""" return self.w_offset + self.width // 2, self.h_offset + self.height // 2 # ====================== # AUDIO METHODS # ====================== def play_sound(self, sound_file, tag="base"): """Play a sound file on the specified audio channel""" if not self.audio: return try: sound_path = os.path.join("sound", sound_file) sound = mixer.Sound(sound_path) # Get the appropriate channel channel = self.audio_channels.get(tag, self.audio_channels["base"]) # Stop any currently playing sound on this channel channel.stop() # Play the new sound channel.play(sound) # Store reference to prevent garbage collection self.current_sounds[tag] = sound except Exception as e: print(f"Error playing sound {sound_file}: {e}") def stop_sound(self): """Stop all audio playback""" for channel in self.audio_channels.values(): channel.stop() # ====================== # INPUT METHODS # ====================== def load_joystick(self): """Initialize joystick support""" pygame.joystick.init() joystick_count = pygame.joystick.get_count() if joystick_count > 0: self.joystick = pygame.joystick.Joystick(0) self.joystick.init() print(f"Joystick initialized: {self.joystick.get_name()}") else: self.joystick = None # ====================== # MAIN GAME LOOP # ====================== def _normalize_key_name(self, key): """Normalize pygame key names to match SDL2 key names""" # Pygame returns lowercase, SDL2 returns with proper case key_map = { "return": "Return", "escape": "Escape", "space": "Space", "tab": "Tab", "left shift": "Left_Shift", "right shift": "Right_Shift", "left ctrl": "Left_Ctrl", "right ctrl": "Right_Ctrl", "left alt": "Left_Alt", "right alt": "Right_Alt", "up": "Up", "down": "Down", "left": "Left", "right": "Right", "delete": "Delete", "backspace": "Backspace", "insert": "Insert", "home": "Home", "end": "End", "pageup": "Page_Up", "pagedown": "Page_Down", "f1": "F1", "f2": "F2", "f3": "F3", "f4": "F4", "f5": "F5", "f6": "F6", "f7": "F7", "f8": "F8", "f9": "F9", "f10": "F10", "f11": "F11", "f12": "F12", } # Return mapped value or capitalize first letter of original normalized = key_map.get(key.lower(), key) # Handle single letters (make uppercase) if len(normalized) == 1: normalized = normalized.upper() return normalized def mainloop(self, **kwargs): """Main game loop handling events and rendering""" while self.running: performance_start = pygame.time.get_ticks() # Clear screen self.screen.fill((0, 0, 0)) # Execute background update if provided if "bg_update" in kwargs: kwargs["bg_update"]() # Execute main update kwargs["update"]() # Update and draw white flash effect if self.update_white_flash(): self.draw_white_flash() # Handle Pygame events for event in pygame.event.get(): if event.type == pygame.QUIT: self.running = False elif event.type == pygame.KEYDOWN: key = pygame.key.name(event.key) key = self._normalize_key_name(key) key = key.replace(" ", "_") self.trigger(f"keydown_{key}") elif event.type == pygame.KEYUP: key = pygame.key.name(event.key) key = self._normalize_key_name(key) key = key.replace(" ", "_") self.trigger(f"keyup_{key}") elif event.type == pygame.MOUSEMOTION: self.trigger(f"mousemove_{event.pos[0]}, {event.pos[1]}") elif event.type == pygame.JOYBUTTONDOWN: self.trigger(f"joybuttondown_{event.button}") elif event.type == pygame.JOYBUTTONUP: self.trigger(f"joybuttonup_{event.button}") elif event.type == pygame.JOYHATMOTION: self.trigger(f"joyhatmotion_{event.hat}_{event.value}") # Update display pygame.display.flip() # Control frame rate self.clock.tick(60) # Target 60 FPS # Calculate performance self.performance = pygame.time.get_ticks() - performance_start def step(self, update=None, bg_update=None): """Execute a single frame iteration. This is non-blocking and useful when the caller (JS) schedules frames via requestAnimationFrame in the browser. """ performance_start = pygame.time.get_ticks() # Clear screen self.screen.fill((0, 0, 0)) # Background update if bg_update: try: bg_update() except Exception: pass # Main update if update: try: update() except Exception: pass # Update and draw white flash effect if self.update_white_flash(): self.draw_white_flash() # Handle Pygame events (single-frame processing) for event in pygame.event.get(): if event.type == pygame.QUIT: self.running = False elif event.type == pygame.KEYDOWN: key = pygame.key.name(event.key) key = self._normalize_key_name(key) key = key.replace(" ", "_") self.trigger(f"keydown_{key}") elif event.type == pygame.KEYUP: key = pygame.key.name(event.key) key = self._normalize_key_name(key) key = key.replace(" ", "_") self.trigger(f"keyup_{key}") elif event.type == pygame.MOUSEMOTION: self.trigger(f"mousemove_{event.pos[0]}, {event.pos[1]}") elif event.type == pygame.JOYBUTTONDOWN: self.trigger(f"joybuttondown_{event.button}") elif event.type == pygame.JOYBUTTONUP: self.trigger(f"joybuttonup_{event.button}") elif event.type == pygame.JOYHATMOTION: self.trigger(f"joyhatmotion_{event.hat}_{event.value}") # Update display once per frame pygame.display.flip() # Control frame rate self.clock.tick(60) # Calculate performance self.performance = pygame.time.get_ticks() - performance_start # ====================== # SPECIAL EFFECTS # ====================== def trigger_white_flash(self): """Trigger the white flash effect""" self.white_flash_active = True self.white_flash_start_time = pygame.time.get_ticks() self.white_flash_opacity = 255 def update_white_flash(self): """Update the white flash effect and return True if it should be drawn""" if not self.white_flash_active: return False current_time = pygame.time.get_ticks() elapsed_time = current_time - self.white_flash_start_time if elapsed_time < 500: # First 500ms: full white self.white_flash_opacity = 255 return True elif elapsed_time < 2000: # Next 1500ms: fade out fade_progress = (elapsed_time - 500) / 1500.0 self.white_flash_opacity = int(255 * (1.0 - fade_progress)) return True else: # Effect is complete self.white_flash_active = False self.white_flash_opacity = 0 return False def draw_white_flash(self): """Draw the white flash overlay""" if self.white_flash_opacity > 0: white_surface = pygame.Surface(self.target_size) white_surface.fill((255, 255, 255)) white_surface.set_alpha(self.white_flash_opacity) self.screen.blit(white_surface, (0, 0)) # ====================== # UTILITY METHODS # ====================== def new_cycle(self, delay, callback): """Placeholder for cycle management (not needed in pygame implementation)""" pass def full_screen(self, flag): """Toggle fullscreen mode""" if flag: self.window = pygame.display.set_mode(self.target_size, pygame.FULLSCREEN) else: self.window = pygame.display.set_mode(self.target_size) self.screen = self.window def get_perf_counter(self): """Get performance counter for timing""" return pygame.time.get_ticks() def close(self): """Close the game window and cleanup""" self.running = False pygame.quit() # ====================== # BLOOD EFFECT METHODS # ====================== def generate_blood_surface(self): """Generate a dynamic blood splatter surface using Pygame""" size = self.cell_size # Create RGBA surface for blood splatter blood_surface = pygame.Surface((size, size), pygame.SRCALPHA) # Blood color variations blood_colors = [ (139, 0, 0, 255), # Dark red (34, 34, 34, 255), # Very dark gray (20, 60, 60, 255), # Dark teal (255, 0, 0, 255), # Pure red (128, 0, 0, 255), # Reddish brown ] # Generate splatter with diffusion algorithm center_x, center_y = size // 2, size // 2 max_radius = size // 3 + random.randint(-3, 5) for y in range(size): for x in range(size): # Calculate distance from center distance = ((x - center_x) ** 2 + (y - center_y) ** 2) ** 0.5 # Calculate blood probability based on distance if distance <= max_radius: probability = max(0, 1 - (distance / max_radius)) noise = random.random() * 0.7 if random.random() < probability * noise: color = random.choice(blood_colors) alpha = int(255 * probability * random.uniform(0.6, 1.0)) blood_surface.set_at((x, y), (*color[:3], alpha)) # Add scattered droplets around main splatter for _ in range(random.randint(3, 8)): drop_x = center_x + random.randint(-max_radius - 5, max_radius + 5) drop_y = center_y + random.randint(-max_radius - 5, max_radius + 5) if 0 <= drop_x < size and 0 <= drop_y < size: drop_size = random.randint(1, 3) for dy in range(-drop_size, drop_size + 1): for dx in range(-drop_size, drop_size + 1): nx, ny = drop_x + dx, drop_y + dy if 0 <= nx < size and 0 <= ny < size: if random.random() < 0.6: color = random.choice(blood_colors[:3]) alpha = random.randint(100, 200) blood_surface.set_at((nx, ny), (*color[:3], alpha)) return blood_surface def draw_blood_surface(self, blood_surface, position): """Convert blood surface to texture and return it""" # In pygame, we can return the surface directly return blood_surface def combine_blood_surfaces(self, existing_surface, new_surface): """Combine two blood surfaces by blending them together""" combined_surface = pygame.Surface((self.cell_size, self.cell_size), pygame.SRCALPHA) # Blit existing blood first combined_surface.blit(existing_surface, (0, 0)) # Blit new blood on top with alpha blending combined_surface.blit(new_surface, (0, 0)) return combined_surface def free_surface(self, surface): """Safely free a pygame surface (not needed in pygame, handled by GC)""" pass class SpriteWrapper: """ Wrapper class to make pygame surfaces compatible with SDL2 sprite interface """ def __init__(self, surface): self.surface = surface self.size = surface.get_size() self.position = (0, 0) def get_size(self): return self.size