455 lines
14 KiB
Python
455 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Utility functions and Game class for Storm Games.
|
|
|
|
Provides:
|
|
- Game class for centralized management
|
|
- Miscellaneous helper functions
|
|
- Version checking utilities
|
|
"""
|
|
|
|
import pygame
|
|
import random
|
|
import math
|
|
import numpy as np
|
|
import time
|
|
import re
|
|
import requests
|
|
import os
|
|
from .input import check_for_exit
|
|
from setproctitle import setproctitle
|
|
|
|
from .services import PathService, ConfigService, VolumeService
|
|
from .sound import Sound
|
|
from .speech import Speech
|
|
from .scoreboard import Scoreboard
|
|
|
|
class Game:
|
|
"""Central class to manage all game systems."""
|
|
|
|
def __init__(self, title):
|
|
"""Initialize a new game.
|
|
|
|
Args:
|
|
title (str): Title of the game
|
|
"""
|
|
self.title = title
|
|
|
|
# Initialize services
|
|
self.pathService = PathService.get_instance().initialize(title)
|
|
self.configService = ConfigService.get_instance()
|
|
self.configService.set_game_info(title, self.pathService)
|
|
self.volumeService = VolumeService.get_instance()
|
|
|
|
# Initialize game components (lazy loaded)
|
|
self._speech = None
|
|
self._sound = None
|
|
self._scoreboard = None
|
|
|
|
# Display text instructions flag
|
|
self.displayTextUsageInstructions = False
|
|
|
|
@property
|
|
def speech(self):
|
|
"""Get the speech system (lazy loaded).
|
|
|
|
Returns:
|
|
Speech: Speech system instance
|
|
"""
|
|
if not self._speech:
|
|
self._speech = Speech.get_instance()
|
|
return self._speech
|
|
|
|
@property
|
|
def sound(self):
|
|
"""Get the sound system (lazy loaded).
|
|
|
|
Returns:
|
|
Sound: Sound system instance
|
|
"""
|
|
if not self._sound:
|
|
self._sound = Sound("sounds/", self.volumeService)
|
|
return self._sound
|
|
|
|
@property
|
|
def scoreboard(self):
|
|
"""Get the scoreboard (lazy loaded).
|
|
|
|
Returns:
|
|
Scoreboard: Scoreboard instance
|
|
"""
|
|
if not self._scoreboard:
|
|
self._scoreboard = Scoreboard(self.configService)
|
|
return self._scoreboard
|
|
|
|
def initialize(self):
|
|
"""Initialize the game GUI and sound system.
|
|
|
|
Returns:
|
|
Game: Self for method chaining
|
|
"""
|
|
# Set process title
|
|
setproctitle(str.lower(str.replace(self.title, " ", "")))
|
|
|
|
# Seed the random generator
|
|
random.seed()
|
|
|
|
# Initialize pygame
|
|
pygame.init()
|
|
pygame.display.set_mode((800, 600))
|
|
pygame.display.set_caption(self.title)
|
|
|
|
# Set up audio system
|
|
pygame.mixer.pre_init(44100, -16, 2, 1024)
|
|
pygame.mixer.init()
|
|
pygame.mixer.set_num_channels(32)
|
|
pygame.mixer.set_reserved(0) # Reserve channel for cut scenes
|
|
|
|
# Enable key repeat for volume controls
|
|
pygame.key.set_repeat(500, 100)
|
|
|
|
# Load sound effects
|
|
self.sound
|
|
|
|
# Play intro sound if available
|
|
if 'game-intro' in self.sound.sounds:
|
|
self.sound.cut_scene('game-intro')
|
|
|
|
return self
|
|
|
|
def speak(self, text, interrupt=True):
|
|
"""Speak text using the speech system.
|
|
|
|
Args:
|
|
text (str): Text to speak
|
|
interrupt (bool): Whether to interrupt current speech
|
|
|
|
Returns:
|
|
Game: Self for method chaining
|
|
"""
|
|
self.speech.speak(text, interrupt)
|
|
return self
|
|
|
|
def play_bgm(self, musicFile):
|
|
"""Play background music.
|
|
|
|
Args:
|
|
musicFile (str): Path to music file
|
|
|
|
Returns:
|
|
Game: Self for method chaining
|
|
"""
|
|
self.sound.play_bgm(musicFile)
|
|
return self
|
|
|
|
def display_text(self, textLines):
|
|
"""Display text with navigation controls.
|
|
|
|
Args:
|
|
textLines (list): List of text lines
|
|
|
|
Returns:
|
|
Game: Self for method chaining
|
|
"""
|
|
# Store original text with blank lines for copying
|
|
originalText = textLines.copy()
|
|
|
|
# Create navigation text by filtering out blank lines
|
|
navText = [line for line in textLines if line.strip()]
|
|
|
|
# Add instructions at the start on the first display
|
|
if not self.displayTextUsageInstructions:
|
|
instructions = ("Press space to read the whole text. Use up and down arrows to navigate "
|
|
"the text line by line. Press c to copy the current line to the clipboard "
|
|
"or t to copy the entire text. Press enter or escape when you are done reading.")
|
|
navText.insert(0, instructions)
|
|
self.displayTextUsageInstructions = True
|
|
|
|
# Add end marker
|
|
navText.append("End of text.")
|
|
|
|
currentIndex = 0
|
|
self.speech.speak(navText[currentIndex])
|
|
|
|
while True:
|
|
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:
|
|
self.volumeService.adjust_master_volume(0.1, pygame.mixer)
|
|
elif event.key == pygame.K_PAGEDOWN:
|
|
self.volumeService.adjust_master_volume(-0.1, pygame.mixer)
|
|
elif event.key == pygame.K_HOME:
|
|
self.volumeService.adjust_bgm_volume(0.1, pygame.mixer)
|
|
elif event.key == pygame.K_END:
|
|
self.volumeService.adjust_bgm_volume(-0.1, pygame.mixer)
|
|
elif event.key == pygame.K_INSERT:
|
|
self.volumeService.adjust_sfx_volume(0.1, pygame.mixer)
|
|
elif event.key == pygame.K_DELETE:
|
|
self.volumeService.adjust_sfx_volume(-0.1, pygame.mixer)
|
|
else:
|
|
if event.key in (pygame.K_ESCAPE, pygame.K_RETURN):
|
|
return self
|
|
|
|
if event.key in [pygame.K_DOWN, pygame.K_s] and currentIndex < len(navText) - 1:
|
|
currentIndex += 1
|
|
self.speech.speak(navText[currentIndex])
|
|
|
|
if event.key in [pygame.K_UP, pygame.K_w] and currentIndex > 0:
|
|
currentIndex -= 1
|
|
self.speech.speak(navText[currentIndex])
|
|
|
|
if event.key == pygame.K_SPACE:
|
|
# Join with newlines to preserve spacing in speech
|
|
self.speech.speak('\n'.join(originalText[1:-1]))
|
|
|
|
if event.key == pygame.K_c:
|
|
try:
|
|
import pyperclip
|
|
pyperclip.copy(navText[currentIndex])
|
|
self.speech.speak("Copied " + navText[currentIndex] + " to the clipboard.")
|
|
except:
|
|
self.speech.speak("Failed to copy the text to the clipboard.")
|
|
|
|
if event.key == pygame.K_t:
|
|
try:
|
|
import pyperclip
|
|
# Join with newlines to preserve blank lines in full text
|
|
pyperclip.copy(''.join(originalText[2:-1]))
|
|
self.speech.speak("Copied entire message to the clipboard.")
|
|
except:
|
|
self.speech.speak("Failed to copy the text to the clipboard.")
|
|
|
|
pygame.event.clear()
|
|
time.sleep(0.001)
|
|
|
|
def exit(self):
|
|
"""Clean up and exit the game."""
|
|
if self._speech and self.speech.providerName == "speechd":
|
|
self.speech.close()
|
|
pygame.mixer.music.stop()
|
|
pygame.quit()
|
|
import sys
|
|
sys.exit()
|
|
|
|
# Utility functions
|
|
|
|
def check_for_updates(currentVersion, gameName, url):
|
|
"""Check for game updates.
|
|
|
|
Args:
|
|
currentVersion (str): Current version string (e.g. "1.0.0")
|
|
gameName (str): Name of the game
|
|
url (str): URL to check for updates
|
|
|
|
Returns:
|
|
dict: Update information or None if no update available
|
|
"""
|
|
try:
|
|
response = requests.get(url, timeout=5)
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
if 'version' in data and data['version'] > currentVersion:
|
|
return {
|
|
'version': data['version'],
|
|
'url': data.get('url', ''),
|
|
'notes': data.get('notes', '')
|
|
}
|
|
except Exception as e:
|
|
print(f"Error checking for updates: {e}")
|
|
return None
|
|
|
|
def get_version_tuple(versionStr):
|
|
"""Convert version string to comparable tuple.
|
|
|
|
Args:
|
|
versionStr (str): Version string (e.g. "1.0.0")
|
|
|
|
Returns:
|
|
tuple: Version as tuple of integers
|
|
"""
|
|
return tuple(map(int, versionStr.split('.')))
|
|
|
|
def check_compatibility(requiredVersion, currentVersion):
|
|
"""Check if current version meets minimum required version.
|
|
|
|
Args:
|
|
requiredVersion (str): Minimum required version string
|
|
currentVersion (str): Current version string
|
|
|
|
Returns:
|
|
bool: True if compatible, False otherwise
|
|
"""
|
|
req = get_version_tuple(requiredVersion)
|
|
cur = get_version_tuple(currentVersion)
|
|
return cur >= req
|
|
|
|
def sanitize_filename(filename):
|
|
"""Sanitize a filename to be safe for all operating systems.
|
|
|
|
Args:
|
|
filename (str): Original filename
|
|
|
|
Returns:
|
|
str: Sanitized filename
|
|
"""
|
|
# Remove invalid characters
|
|
filename = re.sub(r'[\\/*?:"<>|]', "", filename)
|
|
# Replace spaces with underscores
|
|
filename = filename.replace(" ", "_")
|
|
# Limit length
|
|
if len(filename) > 255:
|
|
filename = filename[:255]
|
|
return filename
|
|
|
|
def lerp(start, end, factor):
|
|
"""Linear interpolation between two values.
|
|
|
|
Args:
|
|
start (float): Start value
|
|
end (float): End value
|
|
factor (float): Interpolation factor (0.0-1.0)
|
|
|
|
Returns:
|
|
float: Interpolated value
|
|
"""
|
|
return start + (end - start) * factor
|
|
|
|
def smooth_step(edge0, edge1, x):
|
|
"""Hermite interpolation between two values.
|
|
|
|
Args:
|
|
edge0 (float): Start edge
|
|
edge1 (float): End edge
|
|
x (float): Value to interpolate
|
|
|
|
Returns:
|
|
float: Interpolated value with smooth step
|
|
"""
|
|
# Scale, bias and saturate x to 0..1 range
|
|
x = max(0.0, min(1.0, (x - edge0) / (edge1 - edge0)))
|
|
# Evaluate polynomial
|
|
return x * x * (3 - 2 * x)
|
|
|
|
def distance_2d(x1, y1, x2, y2):
|
|
"""Calculate Euclidean distance between two 2D points.
|
|
|
|
Args:
|
|
x1 (float): X coordinate of first point
|
|
y1 (float): Y coordinate of first point
|
|
x2 (float): X coordinate of second point
|
|
y2 (float): Y coordinate of second point
|
|
|
|
Returns:
|
|
float: Distance between points
|
|
"""
|
|
return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
|
|
|
|
def generate_tone(frequency, duration=0.1, sampleRate=44100, volume=0.2):
|
|
"""Generate a tone at the specified frequency.
|
|
|
|
Args:
|
|
frequency (float): Frequency in Hz
|
|
duration (float): Duration in seconds (default: 0.1)
|
|
sampleRate (int): Sample rate in Hz (default: 44100)
|
|
volume (float): Volume from 0.0 to 1.0 (default: 0.2)
|
|
|
|
Returns:
|
|
pygame.mixer.Sound: Sound object with the generated tone
|
|
"""
|
|
|
|
t = np.linspace(0, duration, int(sampleRate * duration), False)
|
|
tone = np.sin(2 * np.pi * frequency * t)
|
|
stereoTone = np.vstack((tone, tone)).T # Create a 2D array for stereo
|
|
stereoTone = (stereoTone * 32767 * volume).astype(np.int16) # Apply volume
|
|
stereoTone = np.ascontiguousarray(stereoTone) # Ensure C-contiguous array
|
|
return pygame.sndarray.make_sound(stereoTone)
|
|
|
|
def x_powerbar():
|
|
"""Sound based horizontal power bar
|
|
|
|
Returns:
|
|
int: Selected position between -50 and 50
|
|
"""
|
|
|
|
clock = pygame.time.Clock()
|
|
screen = pygame.display.get_surface()
|
|
position = -50 # Start from the leftmost position
|
|
direction = 1 # Move right initially
|
|
barHeight = 20
|
|
|
|
while True:
|
|
frequency = 440 # A4 note
|
|
leftVolume = (50 - position) / 100
|
|
rightVolume = (position + 50) / 100
|
|
tone = generate_tone(frequency)
|
|
channel = tone.play()
|
|
channel.set_volume(leftVolume, rightVolume)
|
|
|
|
# Visual representation
|
|
screen.fill((0, 0, 0))
|
|
barWidth = screen.get_width() - 40 # Leave 20px margin on each side
|
|
pygame.draw.rect(screen, (100, 100, 100), (20, screen.get_height() // 2 - barHeight // 2, barWidth, barHeight))
|
|
markerPos = int(20 + (position + 50) / 100 * barWidth)
|
|
pygame.draw.rect(screen, (255, 0, 0), (markerPos - 5, screen.get_height() // 2 - barHeight, 10, barHeight * 2))
|
|
pygame.display.flip()
|
|
|
|
for event in pygame.event.get():
|
|
check_for_exit()
|
|
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
|
|
channel.stop()
|
|
return position # This will return a value between -50 and 50
|
|
|
|
position += direction
|
|
if position > 50:
|
|
position = 50
|
|
direction = -1
|
|
elif position < -50:
|
|
position = -50
|
|
direction = 1
|
|
|
|
clock.tick(40) # Speed of bar
|
|
|
|
def y_powerbar():
|
|
"""Sound based vertical power bar
|
|
|
|
Returns:
|
|
int: Selected power level between 0 and 100
|
|
"""
|
|
|
|
clock = pygame.time.Clock()
|
|
screen = pygame.display.get_surface()
|
|
power = 0
|
|
direction = 1 # 1 for increasing, -1 for decreasing
|
|
barWidth = 20
|
|
|
|
while True:
|
|
frequency = 220 + (power * 5) # Adjust these values to change the pitch range
|
|
tone = generate_tone(frequency)
|
|
channel = tone.play()
|
|
|
|
# Visual representation
|
|
screen.fill((0, 0, 0))
|
|
barHeight = screen.get_height() - 40 # Leave 20px margin on top and bottom
|
|
pygame.draw.rect(screen, (100, 100, 100), (screen.get_width() // 2 - barWidth // 2, 20, barWidth, barHeight))
|
|
markerPos = int(20 + (100 - power) / 100 * barHeight)
|
|
pygame.draw.rect(screen, (255, 0, 0), (screen.get_width() // 2 - barWidth, markerPos - 5, barWidth * 2, 10))
|
|
pygame.display.flip()
|
|
|
|
for event in pygame.event.get():
|
|
check_for_exit()
|
|
if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
|
|
channel.stop()
|
|
return power
|
|
|
|
power += direction
|
|
if power >= 100 or power <= 0:
|
|
direction *= -1 # Reverse direction at limits
|
|
|
|
clock.tick(40)
|