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 MainMenu:
"""
Manages the Main Menu, World Selection, and World Creation screens.
Handles smooth state transitions, dynamic world list loading from the
SQLite saves directory, and interaction events.
Args:
app (Any): The main application context.
"""
@global_profiler.profile_func('MainMenu_Init')
def __init__(self, app: Any) -> None:
"""
Initialize the `MainMenu`, including layouts and interactive buttons.
Prepares the main menu, create-world and select-world layouts and resources.
"""
# 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_TITLE, bold=True)
# Process logic block
self.title_mesh: Any = UITextMesh(app)
# Process logic block
try:
img: pg.Surface = pg.image.load(get_path('assets/pyrite-logo-upscaled.png')).convert_alpha()
self.title_tex: Any = self.app.ctx.texture(img.get_size(), 4, pg.image.tobytes(img, 'RGBA', True))
self.title_tex.build_mipmaps()
self.title_tex.filter = (mgl.LINEAR_MIPMAP_LINEAR, mgl.LINEAR)
except Exception as e:
print(f'[SYSTEM] Failed to load logo texture: {e}')
self.title_tex = self.title_renderer.get_texture('Pyrite')
try:
bg_img: pg.Surface = pg.image.load(get_path('assets/textures/uis/background.jpg')).convert_alpha()
self.bg_tex: Any = self.app.ctx.texture(bg_img.get_size(), 4, pg.image.tobytes(bg_img, 'RGBA', True))
self.bg_tex.filter = (mgl.LINEAR, mgl.LINEAR)
self.bg_tex_mesh: Any = UITextMesh(app)
except Exception as e:
print(f'Failed to load background: {e}')
self.bg_tex = None
# Process logic block
self.state: str = 'MAIN'
self.transition_state: str = 'IN'
self.transition_progress: float = 0.0
self.pending_action: Optional[Callable[[], None]] = None
self.animation_direction: int = 1
# Process logic block
self.world_buttons: List[Any] = []
self.delete_buttons: List[Any] = []
self.scroll_offset: float = 0.0
self.target_scroll_offset: float = 0.0
self.layout_main: Any = VBox(position=(0, 0.15), spacing=0.1)
# Execute expression statement
self.layout_main.add_child(
Button(
app,
'Play',
(0, 0),
(0.2, 0.07),
lambda: self.trigger_action(lambda: self.set_state('SELECT_WORLD'), -1),
)
)
self.layout_main.add_child(
Button(app, 'Options', (0, 0), (0.2, 0.07), lambda: self.trigger_action(self.open_options, 1))
)
self.layout_main.add_child(
Button(app, 'Quit', (0, 0), (0.2, 0.07), lambda: self.trigger_action(self.app.quit_game, 1))
)
self.layout_main.update_layout()
# Process logic block
self.create_game_mode: int = 1
self.layout_create: Any = VBox(position=(0, 0.25), spacing=0.2)
config_group: Any = self.layout_create.add_child(VBox(spacing=0.1))
self.input_name: Any = config_group.add_child(TextInput(app, (0, 0), (0.3, 0.05), 'World Name'))
self.input_seed: Any = config_group.add_child(
TextInput(app, (0, 0), (0.3, 0.05), 'Seed (Leave blank for random)')
)
# Process logic block
self.btn_game_mode: Any = config_group.add_child(
Button(app, 'Game Mode: Survival', (0, 0), (0.3, 0.05), self.toggle_game_mode)
)
action_group: Any = self.layout_create.add_child(VBox(spacing=0.1))
self.btn_create: Any = action_group.add_child(
Button(app, 'Create New World', (0, 0), (0.3, 0.07), lambda: self.trigger_action(self.create_world, 1))
)
self.btn_back_create: Any = action_group.add_child(
Button(
app, 'Back', (0, 0), (0.2, 0.07), lambda: self.trigger_action(lambda: self.set_state('SELECT_WORLD'), 1)
)
)
# Execute expression statement
self.layout_create.update_layout()
# Process logic block
self.btn_new_world: Any = Button(
app,
'Create New World',
(0, 0.22),
(0.3, 0.07),
lambda: self.trigger_action(lambda: self.set_state('CREATE_WORLD'), -1),
)
self.btn_back_select: Any = Button(
app, 'Back', (0, -0.65), (0.2, 0.07), lambda: self.trigger_action(lambda: self.set_state('MAIN'), 1)
)
[docs]
@global_profiler.profile_func('MainMenu_TriggerAction')
def trigger_action(self, action: Callable[[], None], animation_direction: int = 1) -> None:
"""
Initiate an animated transition and schedule an action to run after it.
Args:
action: A callable to execute once the 'OUT' transition completes.
animation_direction: Animation direction multiplier; used for transition easing.
"""
# 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('MainMenu_OpenOptions')
def open_options(self) -> None:
"""
Transition from the current menu to the Options Menu.
This sets the application's menu state and initializes the options menu
transition parameters.
"""
# Initialize and update variables
self.app.options_menu.previous_state = 'MAIN_MENU'
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('MainMenu_ToggleGameMode')
def toggle_game_mode(self) -> None:
"""
Toggle the game-mode selection used when creating a new world.
Updates the visible button text to reflect the current selection.
"""
# Initialize and update variables
self.create_game_mode = 0 if self.create_game_mode == 1 else 1
# Process logic block
mode_text: str = 'Survival' if self.create_game_mode == 1 else 'Creative'
# Initialize and update variables
self.btn_game_mode.text = f'Game Mode: {mode_text}'
self.btn_game_mode.text_tex = self.btn_game_mode.text_renderer.get_texture(self.btn_game_mode.text)
[docs]
@global_profiler.profile_func('MainMenu_SetState')
def set_state(self, new_state: str) -> None:
"""
Switch the active menu state for the main menu and reset dynamic UI.
Args:
new_state: One of 'MAIN', 'SELECT_WORLD', 'CREATE_WORLD' representing
which menu view should be active.
"""
# Initialize and update variables
self.state = new_state
# Handle conditional branching
if new_state == 'SELECT_WORLD':
self.load_world_list()
elif new_state == 'CREATE_WORLD':
self.input_name.text = ''
self.input_seed.text = ''
self.input_name.is_active = True
self.create_game_mode = 1
self.btn_game_mode.text = 'Game Mode: Survival'
self.btn_game_mode.text_tex = self.btn_game_mode.text_renderer.get_texture(self.btn_game_mode.text)
# Initialize and update variables
self.transition_state = 'IN'
self.transition_progress = 0.0
[docs]
@global_profiler.profile_func('MainMenu_LoadWorldList')
def load_world_list(self) -> None:
"""
Scan the local `saves` directory and populate the world selection UI.
This creates `WorldButton` instances for each found save database and
corresponding delete buttons.
"""
# Execute loop iteration
for btn in self.world_buttons:
if hasattr(btn, 'thumb_tex'):
btn.thumb_tex.release()
btn.tex_title.release()
btn.tex_details.release()
btn.tex_dates.release()
# Initialize and update variables
self.world_buttons = []
self.delete_buttons = []
self.scroll_offset = 0.0
self.target_scroll_offset = 0.0
# Execute expression statement
os.makedirs('saves', exist_ok=True)
# Process logic block
saves: List[str] = sorted(
[f for f in os.listdir('saves') if f.endswith('.db')],
key=lambda x: os.path.getmtime(os.path.join('saves', x)),
reverse=True,
)
y_offset: float = -0.05
# Execute loop iteration
for save_file in saves:
save_name: str = save_file[:-3]
display_name: str = save_name
seed: int = 0
game_mode: int = 1
creation_date: str = 'Unknown'
last_played: str = 'Unknown'
connection: Optional[sqlite3.Connection] = None
cursor: Optional[sqlite3.Cursor] = None
try:
connection = sqlite3.connect(f'saves/{save_file}')
cursor = connection.cursor()
cursor.execute(
'SELECT world_name, seed, game_mode, creation_date, last_played FROM world_meta WHERE id=1'
)
row: Any = cursor.fetchone()
if row:
display_name = row[0]
seed = row[1]
game_mode = row[2]
creation_date = row[3][:16].replace('T', ' ') if row[3] else 'Unknown'
last_played = row[4][:16].replace('T', ' ') if row[4] else 'Unknown'
except Exception as e:
print(f'[SYSTEM] Error reading world meta: {e}')
finally:
if cursor:
try:
cursor.close()
except Exception as e:
print(f'[SYSTEM] Error closing cursor: {e}')
if connection:
try:
connection.close()
except Exception as e:
print(f'[SYSTEM] Error closing connection: {e}')
def load_and_reset(sn: str = save_name) -> None:
# Execute expression statement
self.app.init_game_session(sn)
self.set_state('MAIN')
new_button: Any = WorldButton(
self.app,
save_name,
display_name,
seed,
game_mode,
creation_date,
last_played,
(0, y_offset),
(0.65, 0.12),
lambda sn=save_name, lr=load_and_reset: self.trigger_action(lambda: lr(sn), -1),
)
self.world_buttons.append(new_button)
delete_button: Any = Button(
self.app, 'X', (0.55, y_offset), (0.05, 0.08), lambda sn=save_name: self.delete_world(sn)
)
delete_button.base_color = (0.4, 0.1, 0.1, 0.7)
delete_button.hover_color = (0.8, 0.1, 0.1, 0.9)
self.delete_buttons.append(delete_button)
y_offset -= 0.26
[docs]
@global_profiler.profile_func('MainMenu_DeleteWorld')
def delete_world(self, save_name: str) -> None:
"""
Remove the saved world database file and its thumbnail (if present).
Args:
save_name: Base filename of the save (without extension).
"""
# Process logic block
try:
for ext in ('.db', '.db-shm', '.db-wal', '_thumb.png'):
path: str = f'saves/{save_name}{ext}'
if os.path.exists(path):
os.remove(path)
except Exception as e:
print(f'[SYSTEM] Failed to delete world: {e}')
# Execute expression statement
self.load_world_list()
[docs]
@global_profiler.profile_func('MainMenu_CreateWorld')
def create_world(self) -> None:
"""
Create a new world using form inputs from the 'Create World' UI.
Reads name and seed fields, sanitizes the name, and ensures the save
filename is unique before initializing the game session.
"""
# Process logic block
name: str = self.input_name.text.strip()
# Handle conditional branching
if not name:
name = 'New_World'
# Process logic block
base_save_name: str = name.replace(' ', '_')
save_name: str = base_save_name
counter: int = 1
# Execute loop iteration
while os.path.exists(f'saves/{save_name}.db'):
save_name = f'{base_save_name}_{counter}'
counter += 1
# Process logic block
seed_str: str = self.input_seed.text.strip()
seed: int
# Handle conditional branching
if not seed_str:
seed = random.randint(100000, 999999999)
else:
try:
seed = int(seed_str)
except ValueError:
seed = int(hashlib.md5(seed_str.encode()).hexdigest(), 16) % 10**9
# Execute expression statement
self.app.init_game_session(save_name, seed, self.create_game_mode)
self.set_state('MAIN')
[docs]
@global_profiler.profile_func('MainMenu_Update')
def update(self) -> None:
"""
Advance menu animations, update scrolling state and dispatch updates
to active UI components depending on the current menu state.
"""
# 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()
if self.state == 'MAIN':
self.layout_main.update(mouse_pos)
elif self.state == 'SELECT_WORLD':
if abs(self.target_scroll_offset - self.scroll_offset) > 0.001:
self.scroll_offset += (self.target_scroll_offset - self.scroll_offset) * min(
1.0, 15.0 * self.app.delta_time * 0.001
)
else:
self.scroll_offset = self.target_scroll_offset
self.btn_new_world.check_hover(mouse_pos)
self.btn_back_select.check_hover(mouse_pos)
base_y: float = -0.05
for i, (button, delete_button) in enumerate(zip(self.world_buttons, self.delete_buttons, strict=False)):
new_y: float = base_y - i * 0.26 + self.scroll_offset
button.local_pos = [0, new_y]
delete_button.local_pos = [0.55, new_y]
if -0.45 < new_y < 0.02:
button.check_hover(mouse_pos)
delete_button.check_hover(mouse_pos)
if delete_button.is_hovered:
button.is_hovered = False
button.is_pressed = False
else:
button.is_hovered = False
delete_button.is_hovered = False
elif self.state == 'CREATE_WORLD':
self.layout_create.update(mouse_pos)
[docs]
@global_profiler.profile_func('MainMenu_HandleEvent')
def handle_event(self, event: Any) -> None:
"""
Dispatch incoming Pygame events to the active menu UI elements.
Args:
event: Pygame event to process.
"""
# Handle conditional branching
if self.transition_state != 'IDLE':
return
if self.state == 'CREATE_WORLD':
self.layout_create.handle_event(event)
elif self.state == 'MAIN':
self.layout_main.handle_event(event)
elif self.state == 'SELECT_WORLD':
self.btn_new_world.handle_event(event)
self.btn_back_select.handle_event(event)
for button, delete_button in zip(self.world_buttons, self.delete_buttons, strict=False):
if -0.45 < button.local_pos[1] < 0.02:
button.handle_event(event)
delete_button.handle_event(event)
if event.type == pg.MOUSEBUTTONDOWN:
if event.button == 4:
self.target_scroll_offset = max(0.0, self.target_scroll_offset - 0.26)
elif event.button == 5:
max_scroll: float = max(0.0, (len(self.world_buttons) - 2) * 0.26)
self.target_scroll_offset = min(max_scroll, self.target_scroll_offset + 0.26)
[docs]
@global_profiler.profile_func('MainMenu_RenderBg')
def render_bg(self) -> None:
"""
Render the menu background image, scaled to maintain aspect ratio.
This will skip rendering if no background texture is available.
"""
# Handle conditional branching
if hasattr(self, 'bg_tex') and self.bg_tex:
self.bg_tex.use(location=4)
image_aspect: float = self.bg_tex.width / self.bg_tex.height
if image_aspect > ASPECT_RATIO:
scale_x, scale_y = (image_aspect / ASPECT_RATIO, 1.0)
else:
scale_x, scale_y = (1.0, ASPECT_RATIO / image_aspect)
self.bg_tex_mesh.program['u_scale'] = (scale_x, scale_y)
self.bg_tex_mesh.program['u_offset'] = (0.0, 0.0)
if 'u_alpha' in self.bg_tex_mesh.program:
self.bg_tex_mesh.program['u_alpha'] = 1.0
self.bg_tex_mesh.render()
[docs]
@global_profiler.profile_func('MainMenu_Render')
def render(self) -> None:
"""
Render the active menu layout including title, background, and child
UI nodes, applying transition offsets and alpha blending.
"""
# 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)
# Execute expression statement
self.render_bg()
self.title_tex.use(location=4)
# Process logic block
tex_w: int = self.title_tex.size[0]
tex_h: int = self.title_tex.size[1]
scale_y: float = 0.15
scale_x: float = scale_y * (tex_w / tex_h) / 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.5 + 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
if self.state == 'MAIN':
self.layout_main.render(offset, alpha)
elif self.state == 'SELECT_WORLD':
self.btn_new_world.render(offset, alpha)
if 'u_clip' in self.app.shader_program.ui_color:
self.app.shader_program.ui_color['u_clip'] = (-2.0, -0.55, 2.0, 0.1)
if 'u_clip' in self.app.shader_program.ui_text:
self.app.shader_program.ui_text['u_clip'] = (-2.0, -0.55, 2.0, 0.1)
for btn, del_btn in zip(self.world_buttons, self.delete_buttons, strict=False):
btn.render(offset, alpha)
del_btn.render(offset, alpha)
if 'u_clip' in self.app.shader_program.ui_color:
self.app.shader_program.ui_color['u_clip'] = (-2.0, -2.0, 2.0, 2.0)
if 'u_clip' in self.app.shader_program.ui_text:
self.app.shader_program.ui_text['u_clip'] = (-2.0, -2.0, 2.0, 2.0)
self.btn_back_select.render(offset, alpha)
elif self.state == 'CREATE_WORLD':
self.layout_create.render(offset, alpha)
[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_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)