2025-03-22 17:34:35 -04:00

497 lines
18 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Menu systems for Storm Games.
Provides functionality for:
- Game menu navigation
- Instructions display
- Credits display
- Sound learning interface
- Game exit handling
"""
import pygame
import time
import webbrowser
import os
from sys import exit
from os.path import isfile
from os import listdir
from os.path import join
from inspect import isfunction
from .speech import messagebox, Speech
from .sound import adjust_master_volume, adjust_bgm_volume, adjust_sfx_volume, play_bgm
from .display import display_text
from .scoreboard import Scoreboard
from .services import PathService, ConfigService
def game_menu(sounds, playCallback=None, *customOptions):
"""Display and handle the main game menu with standard and custom options.
Standard menu structure:
1. Play (always first)
2. High Scores
3. Custom options (if provided)
4. Learn Sounds
5. Instructions (if available)
6. Credits (if available)
7. Donate
8. Exit
Handles navigation with:
- Up/Down arrows for selection
- Home/End for first/last option
- Enter to select
- Escape to exit
- Volume controls (with Alt modifier)
Args:
sounds (dict): Dictionary of sound objects
playCallback (function, optional): Callback function for the "play" option.
If None, "play" is returned as a string like other options.
*customOptions: Additional custom options to include after play but before standard ones
Returns:
str: Selected menu option or "exit" if user pressed escape
"""
# Get speech instance
speech = Speech.get_instance()
# Start with Play option
allOptions = ["play"]
# Add high scores option if scores exist
if Scoreboard.has_high_scores():
allOptions.append("high_scores")
# Add custom options (other menu items, etc.)
allOptions.extend(customOptions)
# Add standard options in preferred order
allOptions.append("learn_sounds")
# Check for instructions file
if os.path.isfile('files/instructions.txt'):
allOptions.append("instructions")
# Check for credits file
if os.path.isfile('files/credits.txt'):
allOptions.append("credits")
# Final options
allOptions.extend(["donate", "exit_game"])
# Track if music was previously playing
musicWasPlaying = pygame.mixer.music.get_busy()
# Only start menu music if no music is currently playing
if not musicWasPlaying:
try:
from .sound import play_bgm
play_bgm("sounds/music_menu.ogg")
except:
pass
loop = True
pygame.mixer.stop()
currentIndex = 0
lastSpoken = -1 # Track last spoken index
while loop:
if currentIndex != lastSpoken:
speech.speak(allOptions[currentIndex])
lastSpoken = currentIndex
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
# Check for Alt modifier
mods = pygame.key.get_mods()
altPressed = mods & pygame.KMOD_ALT
# Volume controls (require Alt)
if altPressed:
if event.key == pygame.K_PAGEUP:
adjust_master_volume(0.1)
elif event.key == pygame.K_PAGEDOWN:
adjust_master_volume(-0.1)
elif event.key == pygame.K_HOME:
adjust_bgm_volume(0.1)
elif event.key == pygame.K_END:
adjust_bgm_volume(-0.1)
elif event.key == pygame.K_INSERT:
adjust_sfx_volume(0.1)
elif event.key == pygame.K_DELETE:
adjust_sfx_volume(-0.1)
# Regular menu navigation (no Alt required)
else:
if event.key == pygame.K_ESCAPE:
# Exit with fade if music is playing
exit_game(500 if pygame.mixer.music.get_busy() else 0)
elif event.key == pygame.K_HOME:
if currentIndex != 0:
currentIndex = 0
try:
sounds['menu-move'].play()
except:
pass
if allOptions[currentIndex] != "donate":
pygame.mixer.music.unpause()
elif event.key == pygame.K_END:
if currentIndex != len(allOptions) - 1:
currentIndex = len(allOptions) - 1
try:
sounds['menu-move'].play()
except:
pass
if allOptions[currentIndex] != "donate":
pygame.mixer.music.unpause()
elif event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(allOptions) - 1:
currentIndex += 1
try:
sounds['menu-move'].play()
except:
pass
if allOptions[currentIndex] != "donate":
pygame.mixer.music.unpause()
elif event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0:
currentIndex -= 1
try:
sounds['menu-move'].play()
except:
pass
if allOptions[currentIndex] != "donate":
pygame.mixer.music.unpause()
elif event.key == pygame.K_RETURN:
try:
lastSpoken = -1
try:
sounds['menu-select'].play()
time.sleep(sounds['menu-select'].get_length())
except:
pass
selectedOption = allOptions[currentIndex]
# Special case for exit_game with fade
if selectedOption == "exit_game":
exit_game(500 if pygame.mixer.music.get_busy() else 0)
# Special case for play option
elif selectedOption == "play":
if playCallback:
# If a play callback is provided, call it directly
try:
pygame.mixer.music.fadeout(500)
time.sleep(0.5)
except Exception as e:
print(f"Could not fade music: {e}")
pass
playCallback()
else:
# Otherwise return "play" to the caller
try:
pygame.mixer.music.fadeout(500)
time.sleep(0.5)
except Exception as e:
print(f"Could not fade music: {e}")
pass
return "play"
# Handle standard options directly
elif selectedOption in ["instructions", "credits", "learn_sounds", "high_scores", "donate"]:
# Pause music before calling the selected function
try:
pygame.mixer.music.pause()
except:
pass
# Handle standard options
if selectedOption == "instructions":
instructions()
elif selectedOption == "credits":
credits()
elif selectedOption == "learn_sounds":
learn_sounds(sounds)
elif selectedOption == "high_scores":
Scoreboard.display_high_scores()
elif selectedOption == "donate":
donate()
# Unpause music after function returns
try:
# Check if music is actually paused before trying to unpause
if not pygame.mixer.music.get_busy():
pygame.mixer.music.unpause()
# If music is already playing, don't try to restart it
except:
# Only start fresh music if no music is playing at all
if not pygame.mixer.music.get_busy():
try:
from .sound import play_bgm
play_bgm("sounds/music_menu.ogg")
except:
pass
# Return custom options to the calling function
else:
lastSpoken = -1
try:
pygame.mixer.music.fadeout(500)
time.sleep(0.5)
except Exception as e:
print(f"Could not fade music: {e}")
pass
return selectedOption
except Exception as e:
print(f"Error handling menu selection: {e}")
lastSpoken = -1
try:
pygame.mixer.music.fadeout(500)
time.sleep(0.5)
except:
pass
return allOptions[currentIndex]
event = pygame.event.clear()
time.sleep(0.001)
def learn_sounds(sounds):
"""Interactive menu for learning game sounds.
Allows users to:
- Navigate through available sounds with up/down arrows
- Navigate between sound categories (folders) using Page Up/Page Down or Left/Right arrows
- Play selected sounds with Enter
- Return to menu with Escape
Excluded sounds:
- Files in folders named 'ambience' (at any level)
- Files in any directory starting with '.'
- Files starting with 'game-intro', 'music_menu', or '_'
Args:
sounds (dict): Dictionary of available sound objects
Returns:
str: "menu" if user exits with escape
"""
# Get speech instance
speech = Speech.get_instance()
# Define exclusion criteria
excludedPrefixes = ["game-intro", "music_menu", "_"]
excludedDirs = ["ambience", "."]
# Organize sounds by directory
soundsByDir = {}
# Process each sound key in the dictionary
for soundKey in sounds.keys():
# Skip if key has any excluded prefix
if any(soundKey.lower().startswith(prefix.lower()) for prefix in excludedPrefixes):
continue
# Split key into path parts
parts = soundKey.split('/')
# Skip if any part of the path is an excluded directory
if any(part.lower() == dirName.lower() or part.startswith('.') for part in parts for dirName in excludedDirs):
continue
# Determine the directory
if '/' in soundKey:
directory = soundKey.split('/')[0]
else:
directory = 'root' # Root directory sounds
# Add to sounds by directory
if directory not in soundsByDir:
soundsByDir[directory] = []
soundsByDir[directory].append(soundKey)
# Sort each directory's sounds
for directory in soundsByDir:
soundsByDir[directory].sort()
# If no sounds found, inform the user and return
if not soundsByDir:
speech.speak("No sounds available to learn.")
return "menu"
# Get list of directories in sorted order
directories = sorted(soundsByDir.keys())
# Start with first directory
currentDirIndex = 0
currentDir = directories[currentDirIndex]
currentSoundKeys = soundsByDir[currentDir]
currentSoundIndex = 0
# Display appropriate message based on number of directories
if len(directories) > 1:
messagebox(f"Starting with {currentDir if currentDir != 'root' else 'root directory'} sounds. Use left and right arrows or page up and page down to navigate categories.")
# Track last spoken to avoid repetition
lastSpoken = -1
directoryChanged = True # Flag to track if directory just changed
# Flag to track when to exit the loop
returnToMenu = False
while not returnToMenu:
# Announce current sound
if currentSoundIndex != lastSpoken:
totalSounds = len(currentSoundKeys)
soundName = currentSoundKeys[currentSoundIndex]
# Remove directory prefix if present
if '/' in soundName:
displayName = '/'.join(soundName.split('/')[1:])
else:
displayName = soundName
# If directory just changed, include directory name in announcement
if directoryChanged:
dirDescription = "Root directory" if currentDir == 'root' else currentDir
announcement = f"{dirDescription}: {displayName}, {currentSoundIndex + 1} of {totalSounds}"
directoryChanged = False # Reset flag after announcement
else:
announcement = f"{displayName}, {currentSoundIndex + 1} of {totalSounds}"
speech.speak(announcement)
lastSpoken = currentSoundIndex
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
returnToMenu = True
# Sound navigation
elif event.key in [pygame.K_DOWN, pygame.K_s] and currentSoundIndex < len(currentSoundKeys) - 1:
pygame.mixer.stop()
currentSoundIndex += 1
elif event.key in [pygame.K_UP, pygame.K_w] and currentSoundIndex > 0:
pygame.mixer.stop()
currentSoundIndex -= 1
# Directory navigation
elif event.key in [pygame.K_PAGEDOWN, pygame.K_RIGHT] and currentDirIndex < len(directories) - 1:
pygame.mixer.stop()
currentDirIndex += 1
currentDir = directories[currentDirIndex]
currentSoundKeys = soundsByDir[currentDir]
currentSoundIndex = 0
directoryChanged = True # Set flag on directory change
lastSpoken = -1 # Force announcement
elif event.key in [pygame.K_PAGEUP, pygame.K_LEFT] and currentDirIndex > 0:
pygame.mixer.stop()
currentDirIndex -= 1
currentDir = directories[currentDirIndex]
currentSoundKeys = soundsByDir[currentDir]
currentSoundIndex = 0
directoryChanged = True # Set flag on directory change
lastSpoken = -1 # Force announcement
# Play sound
elif event.key == pygame.K_RETURN:
try:
soundName = currentSoundKeys[currentSoundIndex]
pygame.mixer.stop()
sounds[soundName].play()
except Exception as e:
print(f"Error playing sound: {e}")
speech.speak("Could not play sound.")
event = pygame.event.clear()
pygame.event.pump() # Process pygame's internal events
time.sleep(0.001)
return "menu"
def instructions():
"""Display game instructions from file.
Reads and displays instructions from 'files/instructions.txt'.
If file is missing, displays an error message.
"""
try:
with open('files/instructions.txt', 'r') as f:
info = f.readlines()
except:
info = ["Instructions file is missing."]
display_text(info)
def credits():
"""Display game credits from file.
Reads and displays credits from 'files/credits.txt'.
Adds game name header before displaying.
If file is missing, displays an error message.
"""
try:
with open('files/credits.txt', 'r') as f:
info = f.readlines()
pathService = PathService.get_instance()
info.insert(0, pathService.gameName + "\n")
except Exception as e:
print(f"Error in credits: {e}")
info = ["Credits file is missing."]
display_text(info)
def donate():
"""Open the donation webpage.
Opens the Ko-fi donation page.
"""
webbrowser.open('https://ko-fi.com/stormux')
messagebox("The donation page has been opened in your browser.")
def exit_game(fade=0):
"""Clean up and exit the game properly.
Args:
fade (int): Milliseconds to fade out music before exiting.
0 means stop immediately (default)
"""
# Force clear any pending events to prevent hanging
pygame.event.clear()
# Stop all mixer channels first
try:
pygame.mixer.stop()
except Exception as e:
print(f"Warning: Could not stop mixer channels: {e}")
# Get speech instance and handle all providers
try:
speech = Speech.get_instance()
# Try to close speech regardless of provider type
try:
speech.close()
except Exception as e:
print(f"Warning: Could not close speech: {e}")
except Exception as e:
print(f"Warning: Could not get speech instance: {e}")
# Handle music based on fade parameter
try:
if fade > 0 and pygame.mixer.music.get_busy():
pygame.mixer.music.fadeout(fade)
# Wait for fade to start but don't wait for full completion
pygame.time.wait(min(250, fade))
else:
pygame.mixer.music.stop()
except Exception as e:
print(f"Warning: Could not handle music during exit: {e}")
# Clean up pygame
try:
pygame.quit()
except Exception as e:
print(f"Warning: Error during pygame.quit(): {e}")
# Use os._exit for immediate termination
import os
os._exit(0)