import os import random import ctypes from ctypes import * from pathlib import Path import sdl2 import sdl2.ext try: import sdl2.sdlmixer as sdlmixer except ImportError: sdlmixer = None from sdl2.ext.compat import byteify from sdl2 import SDL_AudioSpec from PIL import Image, ImageSequence from runtime_paths import resolve_bundle_path CONTROLLER_BUTTON_NAMES = { sdl2.SDL_CONTROLLER_BUTTON_A: "a", sdl2.SDL_CONTROLLER_BUTTON_B: "b", sdl2.SDL_CONTROLLER_BUTTON_X: "x", sdl2.SDL_CONTROLLER_BUTTON_Y: "y", sdl2.SDL_CONTROLLER_BUTTON_BACK: "back", sdl2.SDL_CONTROLLER_BUTTON_GUIDE: "guide", sdl2.SDL_CONTROLLER_BUTTON_START: "start", sdl2.SDL_CONTROLLER_BUTTON_LEFTSTICK: "leftstick", sdl2.SDL_CONTROLLER_BUTTON_RIGHTSTICK: "rightstick", sdl2.SDL_CONTROLLER_BUTTON_LEFTSHOULDER: "leftshoulder", sdl2.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: "rightshoulder", sdl2.SDL_CONTROLLER_BUTTON_DPAD_UP: "dpad_up", sdl2.SDL_CONTROLLER_BUTTON_DPAD_DOWN: "dpad_down", sdl2.SDL_CONTROLLER_BUTTON_DPAD_LEFT: "dpad_left", sdl2.SDL_CONTROLLER_BUTTON_DPAD_RIGHT: "dpad_right", } def _decode_sdl_string(value): if not value: return None if isinstance(value, (bytes, bytearray)): return bytes(value).decode("utf-8", errors="ignore") try: return value.decode("utf-8", errors="ignore") except AttributeError: return str(value) class 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 # Cached viewport bounds for fast visibility checks self._update_viewport_bounds() print(f"Screen size: {self.width}x{self.height}") self.joystick_enabled = os.environ.get("MICE_DISABLE_JOYSTICK", "").strip().lower() not in { "1", "true", "yes", "on", } # SDL2 initialization sdl2.ext.init(joystick=self.joystick_enabled) sdl2.SDL_Init(sdl2.SDL_INIT_AUDIO) # Window and renderer setup self.window = sdl2.ext.Window(title=title, size=self.target_size) # self.window.show() self.renderer = sdl2.ext.Renderer(self.window, flags=sdl2.SDL_RENDERER_ACCELERATED) self.factory = sdl2.ext.SpriteFactory(renderer=self.renderer) # Font system self.fonts = self.generate_fonts("assets/decterm.ttf") # Initial loading dialog # self.dialog("Loading assets...") # self.renderer.present() # 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 = "" # 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 = {} self.joystick = None self.game_controller = None self.input_device_kind = "keyboard" self.input_device_name = None # Audio system initialization self._init_audio_system() self.audio = True # Input devices if self.joystick_enabled: self.load_joystick() def _init_audio_system(self): """Initialize audio devices for different audio channels""" audio_spec = SDL_AudioSpec(freq=22050, aformat=sdl2.AUDIO_U8, channels=1, samples=2048) self.audio_devs = {} self.audio_devs["base"] = sdl2.SDL_OpenAudioDevice(None, 0, audio_spec, None, 0) self.audio_devs["effects"] = sdl2.SDL_OpenAudioDevice(None, 0, audio_spec, None, 0) self.audio_devs["music"] = sdl2.SDL_OpenAudioDevice(None, 0, audio_spec, None, 0) self.sound_volume = sdl2.SDL_MIX_MAXVOLUME self.music_enabled = False self.music_volume = 128 self.music_track = None self.music_path = None if sdlmixer is None: return mixer_flags = getattr(sdlmixer, "MIX_INIT_MP3", 0) init_result = sdlmixer.Mix_Init(mixer_flags) if mixer_flags and init_result & mixer_flags != mixer_flags: print("[audio] SDL_mixer MP3 support not available") return if sdlmixer.Mix_OpenAudio(44100, sdl2.AUDIO_S16SYS, sdlmixer.MIX_DEFAULT_CHANNELS, 2048) != 0: print(f"[audio] failed to open music mixer: {sdl2.SDL_GetError()}") return self.music_enabled = True sdlmixer.Mix_VolumeMusic(self.music_volume) def set_sound_volume(self, volume_percent): clamped = max(0, min(int(volume_percent), 100)) self.sound_volume = int(round(clamped * sdl2.SDL_MIX_MAXVOLUME / 100)) def set_music_volume(self, volume_percent): clamped = max(0, min(int(volume_percent), 100)) self.music_volume = int(round(clamped * 128 / 100)) if self.music_enabled and sdlmixer is not None: sdlmixer.Mix_VolumeMusic(self.music_volume) def set_volume(self, volume_percent): self.set_sound_volume(volume_percent) self.set_music_volume(volume_percent) def play_music(self, music_file, loop=True): if not self.audio or not self.music_enabled or sdlmixer is None: return False music_path = str(resolve_bundle_path(os.path.join("assets", "music", music_file))) if self.music_path != music_path: self._free_music_track() self.music_track = sdlmixer.Mix_LoadMUS(byteify(music_path, "utf-8")) if not self.music_track: print(f"[audio] failed to load music: {music_path}") return False self.music_path = music_path if sdlmixer.Mix_PausedMusic(): sdlmixer.Mix_ResumeMusic() return True if sdlmixer.Mix_PlayingMusic(): return True loops = -1 if loop else 0 if sdlmixer.Mix_PlayMusic(self.music_track, loops) != 0: print(f"[audio] failed to play music: {music_path}") return False return True def pause_music(self): if self.music_enabled and sdlmixer is not None and sdlmixer.Mix_PlayingMusic() and not sdlmixer.Mix_PausedMusic(): sdlmixer.Mix_PauseMusic() def stop_music(self): if self.music_enabled and sdlmixer is not None and (sdlmixer.Mix_PlayingMusic() or sdlmixer.Mix_PausedMusic()): sdlmixer.Mix_HaltMusic() def _free_music_track(self): self.stop_music() if self.music_enabled and sdlmixer is not None and self.music_track is not None: sdlmixer.Mix_FreeMusic(self.music_track) self.music_track = None self.music_path = None # ====================== # TEXTURE & IMAGE METHODS # ====================== def create_texture(self, tiles: list, fill_color=None): """Create a texture from a list of tiles""" bg_surface = sdl2.SDL_CreateRGBSurface(0, self.width, self.height, 32, 0, 0, 0, 0) if fill_color is not None: mapped_color = sdl2.SDL_MapRGB(bg_surface.contents.format, *fill_color) sdl2.SDL_FillRect(bg_surface, None, mapped_color) for tile in tiles: dstrect = sdl2.SDL_Rect(tile[1], tile[2], self.cell_size, self.cell_size) sdl2.SDL_BlitSurface(tile[0], None, bg_surface, dstrect) bg_texture = self.factory.from_surface(bg_surface) sdl2.SDL_FreeSurface(bg_surface) return bg_texture def create_color_surface(self, color, width=None, height=None): """Create a solid color surface matching the current cell size by default.""" width = width or self.cell_size height = height or self.cell_size image = Image.new("RGBA", (width, height), (*color, 255)) return sdl2.ext.pillow_to_surface(image) def load_image(self, path, transparent_color=None, surface=False): """Load and process an image with optional transparency and scaling""" image_path = resolve_bundle_path(os.path.join("assets", path)) image = Image.open(image_path) # Handle transparency if transparent_color: image = image.convert("RGBA") # Support single color tuple or sequence of color tuples if isinstance(transparent_color[0], int): color_set = {transparent_color} else: color_set = set(transparent_color) datas = image.getdata() new_data = [ (255, 255, 255, 0) if item[:3] in color_set else item for item in datas ] image.putdata(new_data) # Scale image: tiles are now 64px (was 20px), multiply by 5/8 to reach cell_size (40px) image = image.resize((image.width * 5 // 8, image.height * 5 // 8), Image.NEAREST) if surface: return sdl2.ext.pillow_to_surface(image) temp_surface = sdl2.ext.pillow_to_surface(image) texture = self.factory.from_surface(temp_surface) sdl2.SDL_FreeSurface(temp_surface) return texture def load_animation(self, path): """Load a GIF animation as SDL textures with frame durations.""" image_path = resolve_bundle_path(os.path.join("assets", path)) image = Image.open(image_path) frames = [] durations = [] for frame in ImageSequence.Iterator(image): rgba_frame = frame.convert("RGBA") duration = int(frame.info.get("duration", 83)) duration = max(20, duration) temp_surface = sdl2.ext.pillow_to_surface(rgba_frame) texture = self.factory.from_surface(temp_surface) sdl2.SDL_FreeSurface(temp_surface) frames.append(texture) durations.append(duration) if not frames: rgba_frame = image.convert("RGBA") temp_surface = sdl2.ext.pillow_to_surface(rgba_frame) texture = self.factory.from_surface(temp_surface) sdl2.SDL_FreeSurface(temp_surface) frames.append(texture) durations.append(83) return { "frames": frames, "durations": durations, "total_duration": sum(durations), "size": frames[0].size, } def get_image_size(self, image): """Get the size of an image sprite""" return image.size # ====================== # FONT MANAGEMENT # ====================== def generate_fonts(self, font_file): """Generate font managers for different sizes""" fonts = {} font_path = str(resolve_bundle_path(font_file)) for i in range(10, 70, 1): fonts.update({i: sdl2.ext.FontManager(font_path=font_path, size=i)}) return fonts # ====================== # DRAWING METHODS # ====================== def draw_text(self, text, font, position, color): """Draw text at specified position with given font and color""" sprite = self.factory.from_text(text, color=color, fontmanager=font) # Handle center positioning if position == "center": position = ("center", "center") if position[0] == "center": position = (self.target_size[0] // 2 - sprite.size[0] // 2, position[1]) if position[1] == "center": position = (position[0], self.target_size[1] // 2 - sprite.size[1] // 2) sprite.position = position self.renderer.copy(sprite, dstrect=sprite.position) def draw_background(self, bg_texture): """Draw background texture with current view offset""" self.renderer.copy(bg_texture, dstrect=sdl2.SDL_Rect(self.w_offset, self.h_offset, self.width, self.height)) def draw_image(self, x, y, sprite, tag=None, anchor="nw", source_rect=None, dest_size=None): """Draw an image sprite at specified coordinates""" if not self.is_in_visible_area(x, y): return if source_rect is not None: src_x, src_y, src_w, src_h = (int(value) for value in source_rect) dst_w, dst_h = dest_size or (src_w, src_h) dstrect = sdl2.SDL_Rect( int(x + self.w_offset), int(y + self.h_offset), int(dst_w), int(dst_h), ) srcrect = sdl2.SDL_Rect(src_x, src_y, src_w, src_h) self.renderer.copy(sprite, srcrect=srcrect, dstrect=dstrect) return if dest_size is not None: dst_w, dst_h = (int(value) for value in dest_size) dstrect = sdl2.SDL_Rect( int(x + self.w_offset), int(y + self.h_offset), dst_w, dst_h, ) self.renderer.copy(sprite, dstrect=dstrect) return sprite.position = (x + self.w_offset, y + self.h_offset) self.renderer.copy(sprite, dstrect=sprite.position) def draw_rectangle(self, x, y, width, height, tag, outline="red", filling=None): """Draw a rectangle with optional fill and outline""" if filling: self.renderer.fill((x, y, width, height), sdl2.ext.Color(*filling)) else: self.renderer.draw_rect((x, y, width, height), sdl2.ext.Color(*outline)) 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): self.renderer.draw_rect((x + i, y + i, self.cell_size - 2*i, self.cell_size - 2*i), color=sdl2.ext.Color(255, 0, 0)) def delete_tag(self, tag): """Placeholder for tag deletion (not implemented)""" pass # ====================== # UI METHODS # ====================== def dialog(self, text, **kwargs): """Display a dialog box with text and optional extras""" if kwargs.get("style") == "run_complete": self._draw_run_complete_dialog(text, **kwargs) return panel_x = 50 panel_y = 50 panel_width = self.target_size[0] - 100 panel_height = self.target_size[1] - 100 panel_bottom = panel_y + panel_height panel_padding_x = max(24, panel_width // 18) panel_padding_y = max(20, panel_height // 18) inner_left = panel_x + panel_padding_x inner_right = panel_x + panel_width - panel_padding_x inner_width = inner_right - inner_left title_font_size = max(18, min(42, self.target_size[1] // 20)) subtitle_font_size = max(12, min(22, self.target_size[1] // 35)) scores_title_font_size = max(16, min(30, self.target_size[1] // 25)) scores_font_size = max(12, min(20, self.target_size[1] // 40)) def make_text_sprite(message, font_size, color=(0, 0, 0)): return self.factory.from_text( message, color=sdl2.ext.Color(*color), fontmanager=self.fonts[font_size], ) def score_row(score, rank): if isinstance(score, dict): return f"{rank}. {score.get('user_id', 'Guest')}", f"{score.get('best_score', 0)} pts" if len(score) >= 4: return f"{rank}. {score[2]}", f"{score[1]} pts" if len(score) >= 3: return f"{rank}. {score[2]}", f"{score[1]} pts" return f"{rank}. Guest", f"{score[1]} pts" self.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "win", filling=(255, 255, 255)) title_sprite = make_text_sprite(text, title_font_size) content_y = panel_y + panel_padding_y title_x = self.target_size[0] // 2 - title_sprite.size[0] // 2 self.renderer.copy(title_sprite, dstrect=sdl2.SDL_Rect(title_x, content_y, *title_sprite.size)) content_y += title_sprite.size[1] + max(14, panel_height // 36) if image := kwargs.get("image"): image_width, image_height = self.get_image_size(image) image_x = self.target_size[0] // 2 - image_width // 2 self.draw_image(image_x - self.w_offset, content_y - self.h_offset, image, "win") content_y += image_height + max(14, panel_height // 30) if subtitle := kwargs.get("subtitle"): subtitle_sprites = [ make_text_sprite(line.strip(), subtitle_font_size) for line in subtitle.split('\n') if line.strip() ] subtitle_gap = max(6, panel_height // 70) for sprite in subtitle_sprites: subtitle_x = self.target_size[0] // 2 - sprite.size[0] // 2 self.renderer.copy(sprite, dstrect=sdl2.SDL_Rect(subtitle_x, content_y, *sprite.size)) content_y += sprite.size[1] + subtitle_gap content_y += max(8, panel_height // 40) if scores := kwargs.get("scores"): score_rows = [score_row(score, index) for index, score in enumerate(scores[:5], start=1)] if score_rows: scores_title_sprite = make_text_sprite("High Scores:", scores_title_font_size) scores_title_x = self.target_size[0] // 2 - scores_title_sprite.size[0] // 2 self.renderer.copy( scores_title_sprite, dstrect=sdl2.SDL_Rect(scores_title_x, content_y, *scores_title_sprite.size), ) content_y += scores_title_sprite.size[1] + max(10, panel_height // 42) name_sprites = [] value_sprites = [] for name_text, value_text in score_rows: name_sprites.append(make_text_sprite(name_text, scores_font_size)) value_sprites.append(make_text_sprite(value_text, scores_font_size)) max_name_width = max(sprite.size[0] for sprite in name_sprites) max_value_width = max(sprite.size[0] for sprite in value_sprites) row_height = max( max(sprite.size[1] for sprite in name_sprites), max(sprite.size[1] for sprite in value_sprites), ) row_gap = max(4, panel_height // 80) table_width = min(inner_width, max_name_width + max_value_width + max(24, inner_width // 18)) table_x = self.target_size[0] // 2 - table_width // 2 value_column_x = table_x + table_width max_rows_fit = max(1, (panel_bottom - panel_padding_y - content_y + row_gap) // (row_height + row_gap)) for index, (name_sprite, value_sprite) in enumerate(zip(name_sprites[:max_rows_fit], value_sprites[:max_rows_fit])): row_y = content_y + index * (row_height + row_gap) self.renderer.copy( name_sprite, dstrect=sdl2.SDL_Rect(table_x, row_y, *name_sprite.size), ) self.renderer.copy( value_sprite, dstrect=sdl2.SDL_Rect(value_column_x - value_sprite.size[0], row_y, *value_sprite.size), ) def _draw_run_complete_dialog(self, text, **kwargs): target_width, target_height = self.target_size panel_x = max(18, target_width // 30) panel_y = max(18, target_height // 30) panel_width = target_width - panel_x * 2 panel_height = target_height - panel_y * 2 panel_center_x = panel_x + panel_width // 2 panel_bottom = panel_y + panel_height inner_padding_x = max(20, panel_width // 28) inner_padding_y = max(18, panel_height // 28) inner_left = panel_x + inner_padding_x inner_right = panel_x + panel_width - inner_padding_x palette = { "backdrop": (5, 5, 6), "panel": (14, 15, 16), "panel_inner": (28, 29, 31), "border": (188, 151, 77), "title": (244, 233, 204), "score": (241, 196, 83), "image_frame": (50, 42, 30), } title_font_size = max(28, min(54, target_height // 12)) score_font_size = max(20, min(34, target_height // 20)) def make_text_sprite(message, font_size, color): return self.factory.from_text( message, color=sdl2.ext.Color(*color), fontmanager=self.fonts[font_size], ) def blit_centered(sprite, y): x = panel_center_x - sprite.size[0] // 2 self.renderer.copy(sprite, dstrect=sdl2.SDL_Rect(x, y, *sprite.size)) return y + sprite.size[1] def draw_card(x, y, width, height, fill, border): self.draw_rectangle(x, y, width, height, "run_complete", filling=fill) self.draw_rectangle(x, y, width, height, "run_complete", outline=border) self.draw_rectangle(0, 0, target_width, target_height, "run_complete", filling=palette["backdrop"]) draw_card(panel_x, panel_y, panel_width, panel_height, palette["panel"], palette["border"]) draw_card(panel_x + 10, panel_y + 10, panel_width - 20, panel_height - 20, palette["panel_inner"], palette["border"]) title_y = panel_y + inner_padding_y title_sprite = make_text_sprite(text, title_font_size, palette["title"]) title_bottom = blit_centered(title_sprite, title_y) score_value = kwargs.get("current_score") score_text = None if score_value is None else f"SCORE {score_value}" score_sprite = None if score_text is None else make_text_sprite(score_text, score_font_size, palette["score"]) score_y = panel_bottom - inner_padding_y - (0 if score_sprite is None else score_sprite.size[1]) image_top = title_bottom + max(14, panel_height // 28) image_bottom = score_y - max(14, panel_height // 26) image_area_height = max(120, image_bottom - image_top) image_area_width = inner_right - inner_left image = kwargs.get("image") if image is not None: texture_width, texture_height = self.get_image_size(image) image_scale = min(image_area_width / texture_width, image_area_height / texture_height) image_width = max(1, int(round(texture_width * image_scale))) image_height = max(1, int(round(texture_height * image_scale))) image_x = panel_center_x - image_width // 2 image_y = image_top + max(0, (image_area_height - image_height) // 4) image_frame = max(6, panel_width // 80) draw_card( image_x - image_frame, image_y - image_frame, image_width + image_frame * 2, image_height + image_frame * 2, palette["image_frame"], palette["border"], ) self.draw_image( image_x - self.w_offset, image_y - self.h_offset, image, "run_complete", dest_size=(image_width, image_height), ) if score_sprite is not None: blit_centered(score_sprite, score_y) 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""" # TODO: Fix outline parameter usage color = (0, 0, 255) if self.button_cursor == list(coords) else (0, 0, 0) self.draw_rectangle(x, y, width, height, "button", outline=color) #self.draw_text(text, self.fonts[20], (x + 10, y + 10), (0, 0, 0)) def update_status(self, text): """Update and display the status bar with FPS information""" fps = int(1000 / self.performance) if self.performance != 0 else 0 # at 10% of probability print fps if len(self.fpss) > 20: self.mean_fps = round(sum(self.fpss) / len(self.fpss)) if self.fpss else fps #print(f"FPS: {self.mean_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 = self.factory.from_text(status_text, color=sdl2.ext.Color(0, 0, 0), fontmanager=font) if self.text_width != self.stats_sprite.size[0] or self.text_height != self.stats_sprite.size[1]: self.text_width, self.text_height = self.stats_sprite.size # create a background for the status text using texture self.stats_background = self.factory.from_color(sdl2.ext.Color(255, 255, 255), (self.text_width + 10, self.text_height + 4)) # self.renderer.fill((3, 3, self.text_width + 10, self.text_height + 4), sdl2.ext.Color(255, 255, 255)) self.renderer.copy(self.stats_background, dstrect=sdl2.SDL_Rect(3, 3, self.text_width + 10, self.text_height + 4)) self.renderer.copy(self.stats_sprite, dstrect=sdl2.SDL_Rect(8, 5, self.text_width, self.text_height)) 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 = self.factory.from_text(ammo_text, color=sdl2.ext.Color(0, 0, 0), fontmanager=font) text_width, text_height = self.ammo_sprite.size self.ammo_background = self.factory.from_color(sdl2.ext.Color(255, 255, 255), (text_width + 10, text_height + 4)) text_width, text_height = self.ammo_sprite.size position = (self.target_size[0] - text_width - 10, self.target_size[1] - text_height - 5) #self.renderer.fill((position[0] - 5, position[1] - 2, text_width + 10, text_height + 4), sdl2.ext.Color(255, 255, 255)) self.renderer.copy(self.ammo_background, dstrect=sdl2.SDL_Rect(position[0] - 5, position[1] - 2, text_width + 10, text_height + 4)) self.renderer.copy(self.ammo_sprite, dstrect=sdl2.SDL_Rect(position[0], position[1], text_width, text_height)) self.renderer.copy(assets["BMP_BOMB0"], dstrect=sdl2.SDL_Rect(position[0]+25, position[1], 20, 20)) self.renderer.copy(assets["BMP_POISON"], dstrect=sdl2.SDL_Rect(position[0]+85, position[1], 20, 20)) self.renderer.copy(assets["BMP_GAS"], dstrect=sdl2.SDL_Rect(position[0]+140, position[1], 20, 20)) # ====================== # VIEW & NAVIGATION # ====================== def _update_viewport_bounds(self): """Update cached viewport bounds for fast visibility checks""" self.visible_x_min = -self.w_offset - self.cell_size self.visible_x_max = self.width - self.w_offset self.visible_y_min = -self.h_offset - self.cell_size self.visible_y_max = self.height - self.h_offset def scroll_view(self, pointer): """Adjust the view offset based on pointer coordinates""" cell_x, cell_y = pointer pointer_x = cell_x * self.cell_size pointer_y = cell_y * self.cell_size desired_w_offset = (self.target_size[0] - self.cell_size) // 2 - pointer_x desired_h_offset = (self.target_size[1] - self.cell_size) // 2 - pointer_y self.w_offset = max(self.max_w_offset, min(0, desired_w_offset)) self.h_offset = max(self.max_h_offset, min(0, desired_h_offset)) # Update cached bounds when viewport changes self._update_viewport_bounds() def is_in_visible_area(self, x, y): """Check if coordinates are within the visible area (optimized with cached bounds)""" return (self.visible_x_min <= x <= self.visible_x_max and self.visible_y_min <= y <= self.visible_y_max) 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 sound_path = str(resolve_bundle_path(os.path.join("sound", sound_file))) rw = sdl2.SDL_RWFromFile(byteify(sound_path, "utf-8"), b"rb") if not rw: raise RuntimeError("Failed to open sound file") _buf = POINTER(sdl2.Uint8)() _length = sdl2.Uint32() spec = SDL_AudioSpec(freq=22050, aformat=sdl2.AUDIO_U8, channels=1, samples=2048) if sdl2.SDL_LoadWAV_RW(rw, 1, byref(spec), byref(_buf), byref(_length)) == None: raise RuntimeError("Failed to load WAV") if self.sound_volume <= 0: sdl2.SDL_FreeWAV(_buf) return devid = self.audio_devs[tag] # Clear any queued audio sdl2.SDL_ClearQueuedAudio(devid) buffer_length = int(_length.value) if self.sound_volume < sdl2.SDL_MIX_MAXVOLUME: scaled_buffer = create_string_buffer(buffer_length) sdl2.SDL_MixAudioFormat( cast(scaled_buffer, POINTER(sdl2.Uint8)), _buf, spec.format, buffer_length, self.sound_volume, ) sdl2.SDL_QueueAudio(devid, cast(scaled_buffer, POINTER(sdl2.Uint8)), buffer_length) else: sdl2.SDL_QueueAudio(devid, _buf, buffer_length) sdl2.SDL_FreeWAV(_buf) # Start playing audio sdl2.SDL_PauseAudioDevice(devid, 0) def stop_sound(self): """Stop all audio playback""" self.stop_music() for dev in self.audio_devs.values(): sdl2.SDL_PauseAudioDevice(dev, 1) sdl2.SDL_ClearQueuedAudio(dev) # ====================== # INPUT METHODS # ====================== def load_joystick(self): """Initialize joystick and game controller support.""" if not getattr(self, "joystick_enabled", True): return sdl2.SDL_Init(sdl2.SDL_INIT_JOYSTICK | sdl2.SDL_INIT_GAMECONTROLLER) sdl2.SDL_JoystickEventState(sdl2.SDL_ENABLE) if hasattr(sdl2, "SDL_GameControllerEventState"): sdl2.SDL_GameControllerEventState(sdl2.SDL_ENABLE) num_joysticks = sdl2.SDL_NumJoysticks() if num_joysticks < 1: print("[input] no joystick detected") return for device_index in range(num_joysticks): if hasattr(sdl2, "SDL_IsGameController") and sdl2.SDL_IsGameController(device_index): controller = sdl2.SDL_GameControllerOpen(device_index) if controller: self.game_controller = controller self.input_device_kind = "gamecontroller" self.input_device_name = _decode_sdl_string( sdl2.SDL_GameControllerName(controller) ) print(f"[input] game controller detected: {self.input_device_name}") return self.joystick = sdl2.SDL_JoystickOpen(0) if self.joystick: self.input_device_kind = "joystick" self.input_device_name = _decode_sdl_string(sdl2.SDL_JoystickName(self.joystick)) print(f"[input] raw joystick detected: {self.input_device_name}") def get_input_device_summary(self): return { "kind": self.input_device_kind, "name": self.input_device_name, } def _controller_button_name(self, button): return CONTROLLER_BUTTON_NAMES.get(button, str(button)) # ====================== # MAIN GAME LOOP # ====================== def mainloop(self, **kwargs): """Main game loop handling events and rendering""" while self.running: performance_start = sdl2.SDL_GetPerformanceCounter() self.renderer.clear() # 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 SDL events events = sdl2.ext.get_events() for event in events: if event.type == sdl2.SDL_QUIT: self.running = False elif event.type == sdl2.SDL_KEYDOWN: # print in file keycode keycode = event.key.keysym.sym key = sdl2.SDL_GetKeyName(event.key.keysym.sym).decode('utf-8') key = key.replace(" ", "_") # Check for Right Ctrl key to trigger white flash self.trigger(f"keydown_{key}") elif event.type == sdl2.SDL_KEYUP: key = sdl2.SDL_GetKeyName(event.key.keysym.sym).decode('utf-8') key = key.replace(" ", "_") self.trigger(f"keyup_{key}") elif event.type == sdl2.SDL_MOUSEMOTION: self.trigger(f"mousemove_{event.motion.x}, {event.motion.y}") elif event.type == sdl2.SDL_JOYBUTTONDOWN: if self.game_controller is None: key = event.jbutton.button self.trigger(f"joybuttondown_{key}") elif event.type == sdl2.SDL_JOYBUTTONUP: if self.game_controller is None: key = event.jbutton.button self.trigger(f"joybuttonup_{key}") elif event.type == sdl2.SDL_JOYHATMOTION: if self.game_controller is None: hat = event.jhat.hat value = event.jhat.value self.trigger(f"joyhatmotion_{hat}_{value}") elif hasattr(sdl2, "SDL_CONTROLLERBUTTONDOWN") and event.type == sdl2.SDL_CONTROLLERBUTTONDOWN: button_name = self._controller_button_name(event.cbutton.button) self.trigger(f"controllerbuttondown_{button_name}") elif hasattr(sdl2, "SDL_CONTROLLERBUTTONUP") and event.type == sdl2.SDL_CONTROLLERBUTTONUP: button_name = self._controller_button_name(event.cbutton.button) self.trigger(f"controllerbuttonup_{button_name}") # Present the rendered frame self.renderer.present() # Calculate performance and delay self.performance = ((sdl2.SDL_GetPerformanceCounter() - performance_start) / sdl2.SDL_GetPerformanceFrequency() * 1000) delay = max(0, self.delay - round(self.performance)) sdl2.SDL_Delay(delay) # ====================== # INTRO SCREEN # ====================== def show_intro(self, path, duration_ms=2000, fade_ms=500, crop_center_y=520): """Show a fullscreen intro image with fade-in/out from black. Skip on keypress. SDL scales the image to target width (proportional height), then crops vertically around crop_center_y. No PIL resize — scaling is done by SDL via srcrect/dstrect. """ img = Image.open(resolve_bundle_path(path)).convert("RGBA") tw, th = self.target_size iw, ih = img.size # Compute scaled height at target width (aspect-correct), keep in source space scaled_h = int(ih * tw / iw) # Crop window in scaled coords, mapped back to source coords for srcrect crop_y_scaled = max(0, min(crop_center_y - th // 2, scaled_h - th)) src_y = int(crop_y_scaled * ih / scaled_h) src_h = max(1, int(th * ih / scaled_h)) surface = sdl2.ext.pillow_to_surface(img) texture = self.factory.from_surface(surface) sdl2.SDL_FreeSurface(surface) srcrect = sdl2.SDL_Rect(0, src_y, iw, src_h) dstrect = sdl2.SDL_Rect(0, 0, tw, th) start = sdl2.SDL_GetTicks() skipped = False while True: elapsed = sdl2.SDL_GetTicks() - start if elapsed >= duration_ms: break # Compute black overlay alpha for fade-in / fade-out if elapsed < fade_ms: overlay_alpha = int(255 * (1.0 - elapsed / fade_ms)) elif elapsed > duration_ms - fade_ms: overlay_alpha = int(255 * (elapsed - (duration_ms - fade_ms)) / fade_ms) else: overlay_alpha = 0 self.renderer.clear() self.renderer.copy(texture, srcrect=srcrect, dstrect=dstrect) if overlay_alpha > 0: sdl2.SDL_SetRenderDrawBlendMode( self.renderer.sdlrenderer, sdl2.SDL_BLENDMODE_BLEND) sdl2.SDL_SetRenderDrawColor( self.renderer.sdlrenderer, 0, 0, 0, overlay_alpha) sdl2.SDL_RenderFillRect(self.renderer.sdlrenderer, None) self.renderer.present() sdl2.SDL_Delay(16) for event in sdl2.ext.get_events(): if event.type == sdl2.SDL_QUIT: self.running = False return elif event.type in (sdl2.SDL_KEYDOWN, sdl2.SDL_JOYBUTTONDOWN, sdl2.SDL_CONTROLLERBUTTONDOWN): skipped = True if skipped: break # Fade to black before returning (fast if skipped, already there if natural end) fade_out_ms = fade_ms // 2 if skipped else 0 if fade_out_ms > 0: fade_start = sdl2.SDL_GetTicks() while True: elapsed = sdl2.SDL_GetTicks() - fade_start if elapsed >= fade_out_ms: break alpha = int(255 * elapsed / fade_out_ms) self.renderer.clear() self.renderer.copy(texture, srcrect=srcrect, dstrect=dstrect) sdl2.SDL_SetRenderDrawBlendMode( self.renderer.sdlrenderer, sdl2.SDL_BLENDMODE_BLEND) sdl2.SDL_SetRenderDrawColor(self.renderer.sdlrenderer, 0, 0, 0, alpha) sdl2.SDL_RenderFillRect(self.renderer.sdlrenderer, None) self.renderer.present() sdl2.SDL_Delay(16) # Final black frame self.renderer.clear() self.renderer.present() # ====================== # SPECIAL EFFECTS # ====================== def trigger_white_flash(self): """Trigger the white flash effect""" self.white_flash_active = True self.white_flash_start_time = sdl2.SDL_GetTicks() 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 = sdl2.SDL_GetTicks() 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 2 seconds: fade out # Calculate fade based on remaining time (1000ms fade duration) fade_progress = (elapsed_time - 500) / 1000.0 # 0.0 to 1.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: # Create a white surface with the current opacity white_surface = sdl2.SDL_CreateRGBSurface( 0, self.target_size[0], self.target_size[1], 32, 0x000000FF, # R mask 0x0000FF00, # G mask 0x00FF0000, # B mask 0xFF000000 # A mask ) if white_surface: # Fill surface with white sdl2.SDL_FillRect(white_surface, None, sdl2.SDL_MapRGBA(white_surface.contents.format, 255, 255, 255, self.white_flash_opacity)) # Convert to texture and draw white_texture = self.factory.from_surface(white_surface) white_texture.position = (0, 0) # Enable alpha blending for the texture sdl2.SDL_SetTextureBlendMode(white_texture.texture, sdl2.SDL_BLENDMODE_BLEND) # Draw the white overlay self.renderer.copy(white_texture, dstrect=sdl2.SDL_Rect(0, 0, self.target_size[0], self.target_size[1])) # Clean up sdl2.SDL_FreeSurface(white_surface) # ====================== # UTILITY METHODS # ====================== def new_cycle(self, delay, callback): """Placeholder for cycle management (not implemented)""" pass def capture_frame(self): """Capture the current renderer output as a PIL image.""" width, height = self.target_size pitch = width * 4 pixel_buffer = (ctypes.c_ubyte * (pitch * height))() result = sdl2.SDL_RenderReadPixels( self.renderer.sdlrenderer, None, sdl2.SDL_PIXELFORMAT_RGBA32, pixel_buffer, pitch, ) if result != 0: raise RuntimeError(f"Failed to capture frame: {sdl2.SDL_GetError()}") return Image.frombuffer( "RGBA", (width, height), bytes(pixel_buffer), "raw", "RGBA", 0, 1, ).copy() def save_frame(self, path): """Save the current renderer output to an image file.""" output_path = Path(path).expanduser() output_path.parent.mkdir(parents=True, exist_ok=True) image = self.capture_frame() image.save(output_path) return output_path def full_screen(self, flag): """Toggle fullscreen mode""" sdl2.SDL_SetWindowFullscreen(self.window.window, flag) def get_perf_counter(self): """Get performance counter for timing""" return sdl2.SDL_GetPerformanceCounter() def close(self): """Close the game window and cleanup""" self._free_music_track() if self.music_enabled and sdlmixer is not None: sdlmixer.Mix_CloseAudio() sdlmixer.Mix_Quit() self.music_enabled = False if self.game_controller: sdl2.SDL_GameControllerClose(self.game_controller) self.game_controller = None if self.joystick: sdl2.SDL_JoystickClose(self.joystick) self.joystick = None self.running = False sdl2.ext.quit() # ====================== # MAIN GAME LOOP # ====================== # ====================== # SPECIAL EFFECTS # ====================== def generate_blood_surface(self): """Generate a dynamic blood splatter surface using SDL2 with transparency""" size = self.cell_size # Create RGBA surface for blood splatter with proper alpha channel blood_surface = sdl2.SDL_CreateRGBSurface( 0, size, size, 32, 0x000000FF, # R mask 0x0000FF00, # G mask 0x00FF0000, # B mask 0xFF000000 # A mask ) if not blood_surface: return None # Enable alpha blending for the surface sdl2.SDL_SetSurfaceBlendMode(blood_surface, sdl2.SDL_BLENDMODE_BLEND) # Fill with transparent color first sdl2.SDL_FillRect(blood_surface, None, sdl2.SDL_MapRGBA(blood_surface.contents.format, 0, 0, 0, 0)) # Lock surface for pixel manipulation sdl2.SDL_LockSurface(blood_surface) # Get pixel data pixels = cast(blood_surface.contents.pixels, POINTER(c_uint32)) pitch = blood_surface.contents.pitch // 4 # Convert pitch to pixels (32-bit) # Blood color variations (RGBA format for proper alpha) blood_colors = [ (139, 0, 0), # Dark red (178, 34, 34), # Firebrick (160, 0, 0), # Dark red (200, 0, 0), # Red (128, 0, 0), # Maroon ] # 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: # Closer to center = higher probability probability = max(0, 1 - (distance / max_radius)) # Add noise for irregular shape noise = random.random() * 0.7 if random.random() < probability * noise: # Choose random blood color r, g, b = random.choice(blood_colors) # Add alpha variation for transparency alpha = int(255 * probability * random.uniform(0.6, 1.0)) # Pack RGBA into uint32 (ABGR format for SDL) pixel_color = (alpha << 24) | (b << 16) | (g << 8) | r pixels[y * pitch + x] = pixel_color else: # Transparent pixel pixels[y * pitch + x] = 0x00000000 else: # Outside radius, transparent pixels[y * pitch + x] = 0x00000000 # 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: r, g, b = random.choice(blood_colors[:3]) # Darker colors for drops alpha = random.randint(100, 200) # Pack RGBA into uint32 (ABGR format for SDL) pixel_color = (alpha << 24) | (b << 16) | (g << 8) | r pixels[ny * pitch + nx] = pixel_color # Unlock surface sdl2.SDL_UnlockSurface(blood_surface) return blood_surface def draw_blood_surface(self, blood_surface, position): """Convert blood surface to texture with proper alpha blending""" # Create texture directly from renderer texture_ptr = sdl2.SDL_CreateTextureFromSurface(self.renderer.renderer, blood_surface) if texture_ptr: # Enable alpha blending sdl2.SDL_SetTextureBlendMode(texture_ptr, sdl2.SDL_BLENDMODE_BLEND) # Wrap in sprite for compatibility sprite = sdl2.ext.TextureSprite(texture_ptr) # Free the surface sdl2.SDL_FreeSurface(blood_surface) return sprite sdl2.SDL_FreeSurface(blood_surface) return None def combine_blood_surfaces(self, existing_surface, new_surface): """Combine two blood surfaces by blending them together""" # Create combined surface combined_surface = sdl2.SDL_CreateRGBSurface( 0, self.cell_size, self.cell_size, 32, 0x000000FF, # R mask 0x0000FF00, # G mask 0x00FF0000, # B mask 0xFF000000 # A mask ) if combined_surface is None: return existing_surface # Lock surfaces for pixel manipulation sdl2.SDL_LockSurface(existing_surface) sdl2.SDL_LockSurface(new_surface) sdl2.SDL_LockSurface(combined_surface) # Get pixel data existing_pixels = cast(existing_surface.contents.pixels, POINTER(c_uint32)) new_pixels = cast(new_surface.contents.pixels, POINTER(c_uint32)) combined_pixels = cast(combined_surface.contents.pixels, POINTER(c_uint32)) pitch = combined_surface.contents.pitch // 4 # Convert pitch to pixels (32-bit) # Combine pixels with additive blending for y in range(self.cell_size): for x in range(self.cell_size): idx = y * pitch + x existing_pixel = existing_pixels[idx] new_pixel = new_pixels[idx] # Extract RGBA components existing_a = (existing_pixel >> 24) & 0xFF existing_r = (existing_pixel >> 16) & 0xFF existing_g = (existing_pixel >> 8) & 0xFF existing_b = existing_pixel & 0xFF new_a = (new_pixel >> 24) & 0xFF new_r = (new_pixel >> 16) & 0xFF new_g = (new_pixel >> 8) & 0xFF new_b = new_pixel & 0xFF # Blend colors (additive blending for blood accumulation) if new_a > 0: # If new pixel has color if existing_a > 0: # If existing pixel has color # Combine both colors, making it darker/more opaque final_r = min(255, existing_r + (new_r // 2)) final_g = min(255, existing_g + (new_g // 2)) final_b = min(255, existing_b + (new_b // 2)) final_a = min(255, existing_a + (new_a // 2)) else: # Use new pixel color final_r = new_r final_g = new_g final_b = new_b final_a = new_a else: # Use existing pixel color final_r = existing_r final_g = existing_g final_b = existing_b final_a = existing_a # Pack the final pixel combined_pixels[idx] = (final_a << 24) | (final_r << 16) | (final_g << 8) | final_b # Unlock surfaces sdl2.SDL_UnlockSurface(existing_surface) sdl2.SDL_UnlockSurface(new_surface) sdl2.SDL_UnlockSurface(combined_surface) return combined_surface def free_surface(self, surface): """Safely free an SDL surface""" if surface is not None: sdl2.SDL_FreeSurface(surface)