Huge refactor of the libstormgames library. It is hopefully mostly backwards compatible. Still lots of testing to do, and probably some fixes needed, but this is a good start.
This commit is contained in:
350
utils.py
Normal file
350
utils.py
Normal file
@ -0,0 +1,350 @@
|
||||
#!/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 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.path_service = PathService.get_instance().initialize(title)
|
||||
self.config_service = ConfigService.get_instance()
|
||||
self.config_service.set_game_info(title, self.path_service)
|
||||
self.volume_service = VolumeService.get_instance()
|
||||
|
||||
# Initialize game components (lazy loaded)
|
||||
self._speech = None
|
||||
self._sound = None
|
||||
self._scoreboard = None
|
||||
|
||||
# Display text instructions flag
|
||||
self.display_text_usage_instructions = 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.volume_service)
|
||||
return self._sound
|
||||
|
||||
@property
|
||||
def scoreboard(self):
|
||||
"""Get the scoreboard (lazy loaded).
|
||||
|
||||
Returns:
|
||||
Scoreboard: Scoreboard instance
|
||||
"""
|
||||
if not self._scoreboard:
|
||||
self._scoreboard = Scoreboard(self.config_service)
|
||||
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, music_file):
|
||||
"""Play background music.
|
||||
|
||||
Args:
|
||||
music_file (str): Path to music file
|
||||
|
||||
Returns:
|
||||
Game: Self for method chaining
|
||||
"""
|
||||
self.sound.play_bgm(music_file)
|
||||
return self
|
||||
|
||||
def display_text(self, text_lines):
|
||||
"""Display text with navigation controls.
|
||||
|
||||
Args:
|
||||
text_lines (list): List of text lines
|
||||
|
||||
Returns:
|
||||
Game: Self for method chaining
|
||||
"""
|
||||
# Store original text with blank lines for copying
|
||||
original_text = text_lines.copy()
|
||||
|
||||
# Create navigation text by filtering out blank lines
|
||||
nav_text = [line for line in text_lines if line.strip()]
|
||||
|
||||
# Add instructions at the start on the first display
|
||||
if not self.display_text_usage_instructions:
|
||||
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.")
|
||||
nav_text.insert(0, instructions)
|
||||
self.display_text_usage_instructions = True
|
||||
|
||||
# Add end marker
|
||||
nav_text.append("End of text.")
|
||||
|
||||
current_index = 0
|
||||
self.speech.speak(nav_text[current_index])
|
||||
|
||||
while True:
|
||||
event = pygame.event.wait()
|
||||
if event.type == pygame.KEYDOWN:
|
||||
# Check for Alt modifier
|
||||
mods = pygame.key.get_mods()
|
||||
alt_pressed = mods & pygame.KMOD_ALT
|
||||
|
||||
# Volume controls (require Alt)
|
||||
if alt_pressed:
|
||||
if event.key == pygame.K_PAGEUP:
|
||||
self.volume_service.adjust_master_volume(0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_PAGEDOWN:
|
||||
self.volume_service.adjust_master_volume(-0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_HOME:
|
||||
self.volume_service.adjust_bgm_volume(0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_END:
|
||||
self.volume_service.adjust_bgm_volume(-0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_INSERT:
|
||||
self.volume_service.adjust_sfx_volume(0.1, pygame.mixer)
|
||||
elif event.key == pygame.K_DELETE:
|
||||
self.volume_service.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 current_index < len(nav_text) - 1:
|
||||
current_index += 1
|
||||
self.speech.speak(nav_text[current_index])
|
||||
|
||||
if event.key in [pygame.K_UP, pygame.K_w] and current_index > 0:
|
||||
current_index -= 1
|
||||
self.speech.speak(nav_text[current_index])
|
||||
|
||||
if event.key == pygame.K_SPACE:
|
||||
# Join with newlines to preserve spacing in speech
|
||||
self.speech.speak('\n'.join(original_text[1:-1]))
|
||||
|
||||
if event.key == pygame.K_c:
|
||||
try:
|
||||
import pyperclip
|
||||
pyperclip.copy(nav_text[current_index])
|
||||
self.speech.speak("Copied " + nav_text[current_index] + " 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(original_text[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.provider_name == "speechd":
|
||||
self.speech.close()
|
||||
pygame.mixer.music.stop()
|
||||
pygame.quit()
|
||||
import sys
|
||||
sys.exit()
|
||||
|
||||
# Utility functions
|
||||
|
||||
def check_for_updates(current_version, game_name, url):
|
||||
"""Check for game updates.
|
||||
|
||||
Args:
|
||||
current_version (str): Current version string (e.g. "1.0.0")
|
||||
game_name (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'] > current_version:
|
||||
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(version_str):
|
||||
"""Convert version string to comparable tuple.
|
||||
|
||||
Args:
|
||||
version_str (str): Version string (e.g. "1.0.0")
|
||||
|
||||
Returns:
|
||||
tuple: Version as tuple of integers
|
||||
"""
|
||||
return tuple(map(int, version_str.split('.')))
|
||||
|
||||
def check_compatibility(required_version, current_version):
|
||||
"""Check if current version meets minimum required version.
|
||||
|
||||
Args:
|
||||
required_version (str): Minimum required version string
|
||||
current_version (str): Current version string
|
||||
|
||||
Returns:
|
||||
bool: True if compatible, False otherwise
|
||||
"""
|
||||
req = get_version_tuple(required_version)
|
||||
cur = get_version_tuple(current_version)
|
||||
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)
|
Reference in New Issue
Block a user