Source code for ui.menus

"""
UI Menu systems: Main Menu, Pause Menu, and Options Menu.

This module manages the interactive overlays and state machines for the
game's user interfaces. It handles dynamic world saving/loading screens,
configuration binding for settings, and smooth animated transitions between states.
"""

import hashlib
import os
import random
import sqlite3
from typing import Any, Callable, List, Optional, Tuple

import moderngl as mgl
import pygame as pg
from pyglm import glm

from profiler import global_profiler
from settings import ASPECT_RATIO, FONT_SIZE_PAUSED, FONT_SIZE_TITLE, get_path

from .components import Button, Slider, TextInput, Toggle, UINode, VBox, WorldButton
from .meshes import UIColorMesh, UITextMesh
from .text import TextRenderer






[docs] class PauseMenu: """ Provides the in-game pause screen overlay. Allows the player to resume the game, open options, or quit back to the Main Menu. Args: app (Any): The main application context. """ @global_profiler.profile_func('PauseMenu_Init') def __init__(self, app: Any) -> None: """ Initialize the `PauseMenu` overlay and associated layout items. Sets up resume/options/quit buttons and default transition state. """ # Process logic block self.app: Any = app self.title_renderer: Any = TextRenderer(app) # Initialize and update variables self.title_renderer.font = pg.font.SysFont('arial', FONT_SIZE_PAUSED, bold=True) # Process logic block self.title_mesh: Any = UITextMesh(app) self.title_tex: Any = self.title_renderer.get_texture('Game Paused') self.bg_mesh: Any = UIColorMesh(app) self.transition_state: str = 'IN' self.transition_progress: float = 0.0 # Process logic block self.pending_action: Optional[Callable[[], None]] = None self.animation_direction: int = 1 self.layout: Any = VBox(position=(0, 0.15), spacing=0.1) # Execute expression statement self.layout.add_child( Button(app, 'Resume', (0, 0), (0.3, 0.07), lambda: self.trigger_action(self.resume_game, -1)) ) self.layout.add_child( Button(app, 'Options', (0, 0), (0.3, 0.07), lambda: self.trigger_action(self.open_options, 1)) ) self.layout.add_child( Button(app, 'Quit to Menu', (0, 0), (0.3, 0.07), lambda: self.trigger_action(self.quit_to_menu, 1)) ) self.layout.update_layout()
[docs] @global_profiler.profile_func('PauseMenu_TriggerAction') def trigger_action(self, action: Callable[[], None], animation_direction: int = 1) -> None: """Triggers an out-transition before calling the specified action.""" # Handle conditional branching if self.transition_state in ('IDLE', 'IN'): self.pending_action = action self.transition_state = 'OUT' self.transition_progress = 0.0 self.animation_direction = animation_direction
[docs] @global_profiler.profile_func('PauseMenu_OpenOptions') def open_options(self) -> None: """Transitions to the Options Menu.""" # Initialize and update variables self.app.options_menu.previous_state = 'PAUSED' self.app.game_state = 'OPTIONS' self.app.options_menu.transition_state = 'IN' self.app.options_menu.transition_progress = 0.0
[docs] @global_profiler.profile_func('PauseMenu_ResumeGame') def resume_game(self) -> None: """Hides the pause menu and re-captures the mouse for gameplay.""" # Initialize and update variables self.app.game_state = 'IN_GAME' # Execute expression statement pg.event.set_grab(True) pg.mouse.set_visible(False)
[docs] @global_profiler.profile_func('PauseMenu_QuitToMenu') def quit_to_menu(self) -> None: """Unloads the game world and returns to the Main Menu.""" # Initialize and update variables self.app.game_state = 'MAIN_MENU' self.app.menu.state = 'MAIN' # Handle conditional branching if self.app.scene: self.app.scene.world.save() self.app.scene = None
[docs] @global_profiler.profile_func('PauseMenu_Update') def update(self) -> None: """Processes animations and propagates update events to children.""" # Handle conditional branching if self.transition_state != 'IDLE': self.transition_progress += self.app.delta_time * 0.005 if self.transition_progress >= 1.0: self.transition_progress = 1.0 if self.transition_state == 'OUT': if self.pending_action: action: Callable[[], None] = self.pending_action self.pending_action = None action() else: self.transition_state = 'IDLE' elif self.transition_state == 'IN': self.transition_state = 'IDLE' if self.transition_state != 'IDLE': mouse_pos: Tuple[int, int] = (-999, -999) else: mouse_pos = pg.mouse.get_pos() # Execute expression statement self.layout.update(mouse_pos)
[docs] @global_profiler.profile_func('PauseMenu_HandleEvent') def handle_event(self, event: Any) -> None: """ Handle incoming Pygame events while the pause menu is active. Args: event: Pygame event to process. """ # Handle conditional branching if self.transition_state != 'IDLE': return # Execute expression statement self.layout.handle_event(event)
[docs] @global_profiler.profile_func('PauseMenu_Render') def render(self) -> None: """Renders the dimming background and the menu elements with animation easing.""" # Process logic block t: float = self.transition_progress ease: float = 1.0 - (1.0 - t) ** 3 offset_y: float = 0.0 alpha: float = 1.0 # Handle conditional branching if self.transition_state == 'IN': offset_y = (1.0 - ease) * -0.5 * self.animation_direction alpha = ease elif self.transition_state == 'OUT': offset_y = ease * 0.5 * self.animation_direction alpha = 1.0 - ease # Process logic block offset: Tuple[float, float] = (0.0, offset_y) background_alpha: float = alpha if self.transition_state != 'IDLE' else 1.0 # Initialize and update variables self.bg_mesh.program['u_scale'] = (1.0, 1.0) self.bg_mesh.program['u_offset'] = (0.0, 0.0) self.bg_mesh.program['u_color'] = (0.0, 0.0, 0.0, 0.6 * background_alpha) # Dispatch render call to GPU self.bg_mesh.render() # Process logic block texture: Any = self.title_tex # Execute expression statement texture.use(location=4) # Process logic block texture_width: int = texture.size[0] texture_height: int = texture.size[1] scale_y: float = 0.08 scale_x: float = scale_y * (texture_width / texture_height) / ASPECT_RATIO # Initialize and update variables self.title_mesh.program['u_scale'] = (scale_x, scale_y) self.title_mesh.program['u_offset'] = (0.0, 0.4 + offset_y * 0.5) # Handle conditional branching if 'u_alpha' in self.title_mesh.program: self.title_mesh.program['u_alpha'] = alpha # Dispatch render call to GPU self.title_mesh.render() # Handle conditional branching if 'u_alpha' in self.title_mesh.program: self.title_mesh.program['u_alpha'] = 1.0 # Dispatch render call to GPU self.layout.render(offset, alpha)
[docs] class OptionsMenu: """ Manages the game settings screen. Provides sliders and toggles for FOV, Mouse Sensitivity, Volume, Render Distance, and Visual Tints. Handles serializing these settings to config.json. Args: app (Any): The main application context. """ @global_profiler.profile_func('OptionsMenu_Init') def __init__(self, app: Any) -> None: """ Initialize the `OptionsMenu` with sliders and toggles bound to config keys. Ensures backward-compatible config keys and prepares layout nodes. """ # Process logic block self.app: Any = app self.title_renderer: Any = TextRenderer(app) # Initialize and update variables self.title_renderer.font = pg.font.SysFont('arial', FONT_SIZE_PAUSED, bold=True) # Process logic block self.title_mesh: Any = UITextMesh(app) self.title_tex: Any = self.title_renderer.get_texture('Options') self.bg_mesh: Any = UIColorMesh(app) self.transition_state: str = 'IN' self.transition_progress: float = 0.0 # Process logic block self.pending_action: Optional[Callable[[], None]] = None self.animation_direction: int = 1 # Handle conditional branching if 'music_volume' not in app.config: app.config['music_volume'] = app.config.get('volume', 50) if 'sfx_volume' not in app.config: app.config['sfx_volume'] = 20 # Process logic block self.layout: Any = VBox(position=(0, 0.3), spacing=0.09) # Execute expression statement self.layout.add_child(Slider(app, 'FOV', (0, 0), (0.3, 0.05), 30, 110, 'fov', self.update_fov, is_int=True)) self.layout.add_child(Slider(app, 'Sensitivity', (0, 0), (0.3, 0.05), 0.0005, 0.005, 'sensitivity')) self.layout.add_child( Slider( app, 'Music Volume', (0, 0), (0.3, 0.05), 0, 100, 'music_volume', self.update_music_volume, is_int=True ) ) self.layout.add_child( Slider(app, 'SFX Volume', (0, 0), (0.3, 0.05), 0, 100, 'sfx_volume', self.update_sfx_volume, is_int=True) ) self.layout.add_child( Slider(app, 'Render Distance', (0, 0), (0.3, 0.05), 2, 14, 'render_distance', is_int=True) ) # Execute expression statement self.layout.add_child(Toggle(app, 'Underwater Tint', (0.12, 0), (0.04, 0.035), 'underwater_tint')) self.layout.add_child(UINode(size=(0, 0.05))) self.layout.add_child(Button(app, 'Back', (0, 0), (0.2, 0.06), lambda: self.trigger_action(self.go_back, 1))) self.layout.update_layout() # Process logic block self.previous_state: str = 'MAIN_MENU'
[docs] @global_profiler.profile_func('OptionsMenu_TriggerAction') def trigger_action(self, action: Callable[[], None], animation_direction: int = 1) -> None: """Initiates an animated transition out before running the requested action.""" # Handle conditional branching if self.transition_state in ('IDLE', 'IN'): self.pending_action = action self.transition_state = 'OUT' self.transition_progress = 0.0 self.animation_direction = animation_direction
[docs] @global_profiler.profile_func('OptionsMenu_UpdateFov') def update_fov(self, val: float) -> None: """ Applies the field-of-view setting instantly to the active player. """ # Handle conditional branching if self.app.scene: self.app.player.fov = glm.radians(val)
[docs] @global_profiler.profile_func('OptionsMenu_UpdateMusicVolume') def update_music_volume(self, val: float) -> None: """ Adjusts the global Pygame mixer music volume. """ # Execute expression statement pg.mixer.music.set_volume(val / 100.0)
[docs] @global_profiler.profile_func('OptionsMenu_UpdateSfxVolume') def update_sfx_volume(self, val: float) -> None: """ Delegates sound effect volume changes to the central Sounds manager. """ # Execute expression statement self.app.sounds.set_sfx_volume(val)
[docs] @global_profiler.profile_func('OptionsMenu_GoBack') def go_back(self) -> None: """Returns to the menu that originally opened this options screen.""" # Execute expression statement self.app.save_config() # Initialize and update variables self.app.game_state = self.previous_state # Handle conditional branching if self.previous_state == 'MAIN_MENU': self.app.menu.transition_state = 'IN' self.app.menu.transition_progress = 0.0 elif self.previous_state == 'PAUSED': self.app.pause_menu.transition_state = 'IN' self.app.pause_menu.transition_progress = 0.0
[docs] @global_profiler.profile_func('OptionsMenu_Update') def update(self) -> None: """Updates animations and cascades logic down to layout components.""" # Handle conditional branching if self.transition_state != 'IDLE': self.transition_progress += self.app.delta_time * 0.005 if self.transition_progress >= 1.0: self.transition_progress = 1.0 if self.transition_state == 'OUT': if self.pending_action: action: Callable[[], None] = self.pending_action self.pending_action = None action() else: self.transition_state = 'IDLE' elif self.transition_state == 'IN': self.transition_state = 'IDLE' if self.transition_state != 'IDLE': mouse_pos: Tuple[int, int] = (-999, -999) else: mouse_pos = pg.mouse.get_pos() # Execute expression statement self.layout.update(mouse_pos)
[docs] @global_profiler.profile_func('OptionsMenu_HandleEvent') def handle_event(self, event: Any) -> None: """ Handle incoming Pygame events while the options menu is active. Args: event: Pygame event to process. """ # Handle conditional branching if self.transition_state != 'IDLE': return # Execute expression statement self.layout.handle_event(event)
[docs] @global_profiler.profile_func('OptionsMenu_Render') def render(self) -> None: """Draws the options layout UI nodes alongside their animated transitions.""" # Process logic block t: float = self.transition_progress ease: float = 1.0 - (1.0 - t) ** 3 offset_y: float = 0.0 alpha: float = 1.0 # Handle conditional branching if self.transition_state == 'IN': offset_y = (1.0 - ease) * -0.5 * self.animation_direction alpha = ease elif self.transition_state == 'OUT': offset_y = ease * 0.5 * self.animation_direction alpha = 1.0 - ease # Process logic block offset: Tuple[float, float] = (0.0, offset_y) background_alpha: float = alpha if self.transition_state != 'IDLE' else 1.0 # Handle conditional branching if self.previous_state == 'PAUSED': self.bg_mesh.program['u_scale'] = (1.0, 1.0) self.bg_mesh.program['u_offset'] = (0.0, 0.0) self.bg_mesh.program['u_color'] = (0.0, 0.0, 0.0, 0.8 * background_alpha) self.bg_mesh.render() # Process logic block texture: Any = self.title_tex # Execute expression statement texture.use(location=4) # Process logic block texture_width: int = texture.size[0] texture_height: int = texture.size[1] scale_y: float = 0.08 scale_x: float = scale_y * (texture_width / texture_height) / ASPECT_RATIO # Initialize and update variables self.title_mesh.program['u_scale'] = (scale_x, scale_y) self.title_mesh.program['u_offset'] = (0.0, 0.55 + offset_y * 0.5) # Handle conditional branching if 'u_alpha' in self.title_mesh.program: self.title_mesh.program['u_alpha'] = alpha # Dispatch render call to GPU self.title_mesh.render() # Handle conditional branching if 'u_alpha' in self.title_mesh.program: self.title_mesh.program['u_alpha'] = 1.0 # Dispatch render call to GPU self.layout.render(offset, alpha)