497 lines
18 KiB
Python
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)
|