Files
Archipelago/worlds/apquest/client/sounds.py
NewSoupVi e0cbf77dae APQuest: Implement New Game (#5393)
* APQuest

* Add confetti cannon

* ID change on enemy drop

* nevermind

* Write the apworld

* Actually implement hard mode

* split everything into multiple files

* Push out webworld into a file

* Comment

* Enemy health graphics

* more ruff rules

* graphics :)

* heal player when receiving health upgrade

* the dumbest client of all time

* Fix typo

* You can kinda play it now! Now we just need to render the game... :)))

* fix kvui imports again

* It's playable. Kind of

* oops

* Sounds and stuff

* exceptions for audio

* player sprite stuff

* Not attack without sword

* Make sure it plays correctly

* Collect behavior

* ruff

* don't need to clear checked_locations, but do need to still clear finished_game

* Connect calls disconnect, so this is not necessary

* more seemless reconnection

* Ok now I think it's correct

* Bgm

* Bgm

* minor adjustment

* More refactoring of graphics and sound

* add graphics

* Item column

* Fix enemies not regaining their health

* oops

* oops

* oops

* 6 health final boss on hard mode

* boss_6.png

* Display APQuest items correctly

* auto switch tabs

* some mypy stuff

* Intro song

* Confetti Cannon

* a bit more confetti work

* launcher component

* Graphics change

* graphics and cleanup

* fix apworld

* comment out horse and cat for now

* add docs

* copypasta

* ruff made my comment look unhinged

* Move that comment

* Fix typing and don't import kvui in nogui

* lmao that already exists I don't need to do it myself

* Must've just copied this from somewhere

* order change

* Add unit tests

* Notes about the client

* oops

* another intro song case

* Write WebWorld and setup guides

* Yes description provided

* thing

* how to play

* Music and Volume

* Add cat and horse player sprites

* updates

* Add hammer and breakable wall

* TODO

* replace wav with ogg

* Codeowners and readme

* finish unit tests

* lint

* Todid

* Update worlds/apquest/client/ap_quest_client.py

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>

* Update worlds/apquest/client/custom_views.py

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>

* Filler pattern

* __future__ annotations

* twebhost

* Allow wasd and arrow keys

* correct wording

* oops

* just say the website

* append instead of +=

* qwint is onto my favoritism

* kitty alias

* Add a comment about preplaced items for assertAccessDependency

* Use classvar_matrix instead of MultiworldTestBase

* actually remove multiworld stuff from those tests

* missed one more

* Refactor a bit more

* Fix getting of the user path

* Actually explain components

* Meh

* Be a bit clearer about what's what

* oops

* More comments in the regions.py file

* Nevermind

* clarify regions further

* I use too many brackets

* Ok I'm done fr

* simplify wording

* missing .

* Add precollected example

* add note about precollected advancements

* missing s

* APQuest sound rework

* Volume slider

* I forgot I made this

* a

* fix volume of jingles

* Add math trap to game (only works in play_in_console mode so far)

* Math trap in apworld and client side

* Fix background during math trap

* fix leading 0

* Sound and further ui improvements for Math Trap

* fix music bug

* rename apquest subfolder to game

* Move comment to where it belongs

* Clear up language around components (hopefully)

* Clear up what CommonClient is

* Reword some more

* Mention Archipelago (the program) explicitly

* Update worlds/apquest/docs/en_APQuest.md

Co-authored-by: Ixrec <ericrhitchcock@gmail.com>

* Explain a bit more why you would use classvar matrix

* reword the assert raises stuff

* the volume slider thing is no longer true

* german game page

* Be more clear about why we're overriding Item and Location

* default item classification

* logically considered -> relevant to logic ()

* Update worlds/apquest/items.py

Co-authored-by: Ixrec <ericrhitchcock@gmail.com>

* a word on the ambiguity of the word 'filler'

* more rewording

* amount -> number

* stress the necessity of appending to the multiworld itempool

* Update worlds/apquest/locations.py

Co-authored-by: Ixrec <ericrhitchcock@gmail.com>

* get_location_names_with_ids

* slight rewording of the new helper method

* add some words about creating known location+item pairs

* Add some more words to worlds/apqeust/options.py

* more words in options.py

* 120 chars (thanks Ixrec >:((( LOL)

* Less confusing wording about rules, hopefully?

* victory -> completion

* remove the immediate creation of the hammer rule on the option region entrance

* access rule performance

* Make all imports module-level in world.py

* formatting

* get rid of noqa RUF012 (and also disable the rule in my local ruff.toml

* move comment for docstring closer to docstring in another place

* advancement????

* Missing function type annotations

* pass mypy again (I don't love this one but all the alternatives are equally bad)

* subclass instead of override

* I forgor to remove these

* Get rid of classvar_matrix and instead talk about some other stuff

* protect people a bit from the assertAccessDependency nonsense

* reword a bit more

* word

* More accessdependency text

* More accessdependency text

* More accessdependency text

* More accessdependency text

* oops

* this is supposed to be absolute

* Add some links to docs

* that's called game now

* Add an archipelago.json and explain what it means

* new line who dis

* reorganize a bit

* ignore instead of skip

* Update archipelago.json

* She new on my line till I

* Update archipelago.json

* add controls tab

* new ruff rule? idk

* WHOOPS

* Pack graphics into fewer files

* annoying ruff format thing

* Cleanup + mypy

* relative import

* Update worlds/apquest/client/custom_views.py

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>

* Update generate_math_problem.py

* Update worlds/apquest/game/player.py

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
Co-authored-by: Ixrec <ericrhitchcock@gmail.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2025-11-25 00:38:06 +01:00

250 lines
8.3 KiB
Python

import asyncio
import pkgutil
from asyncio import Task
from collections.abc import Buffer
from pathlib import Path
from typing import cast
from kivy import Config
from kivy.core.audio import Sound, SoundLoader
from CommonClient import logger
from .. import game
from .item_quality import ItemQuality
from .utils import make_data_directory
ITEM_JINGLES = {
ItemQuality.PROGUSEFUL: "8bit ProgUseful.ogg",
ItemQuality.PROGRESSION: "8bit Progression.ogg",
ItemQuality.USEFUL: "8bit Useful.ogg",
ItemQuality.TRAP: "8bit Trap.ogg",
ItemQuality.FILLER: "8bit Filler.ogg",
}
CONFETTI_CANNON = "APQuest Confetti Cannon.ogg"
MATH_PROBLEM_STARTED_JINGLE = "APQuest Math Problem Starter Jingle.ogg"
MATH_PROBLEM_SOLVED_JINGLE = "APQuest Math Problem Solved Jingle.ogg"
VICTORY_JINGLE = "8bit Victory.ogg"
ALL_JINGLES = [
MATH_PROBLEM_SOLVED_JINGLE,
MATH_PROBLEM_STARTED_JINGLE,
CONFETTI_CANNON,
VICTORY_JINGLE,
*ITEM_JINGLES.values(),
]
BACKGROUND_MUSIC_INTRO = "APQuest Intro.ogg"
BACKGROUND_MUSIC = "APQuest BGM.ogg"
MATH_TIME_BACKGROUND_MUSIC = "APQuest Math BGM.ogg"
ALL_BGM = [
BACKGROUND_MUSIC_INTRO,
BACKGROUND_MUSIC,
MATH_TIME_BACKGROUND_MUSIC,
]
ALL_SOUNDS = [
*ALL_JINGLES,
*ALL_BGM,
]
class SoundManager:
sound_paths: dict[str, Path]
jingles: dict[str, Sound]
bgm_songs: dict[str, Sound]
active_bgm_song: str = BACKGROUND_MUSIC_INTRO
current_background_music_volume: float = 1.0
background_music_target_volume: float = 0.0
background_music_task: Task[None] | None = None
background_music_last_position: int = 0
volume_percentage: int = 0
game_started: bool
math_trap_active: bool
allow_intro_to_play: bool
def __init__(self) -> None:
self.extract_sounds()
self.populate_sounds()
self.game_started = False
self.allow_intro_to_play = False
self.math_trap_active = False
self.ensure_config()
self.background_music_task = asyncio.create_task(self.sound_manager_loop())
def ensure_config(self) -> None:
Config.adddefaultsection("APQuest")
Config.setdefault("APQuest", "volume", 50)
self.set_volume_percentage(Config.getint("APQuest", "volume"))
async def sound_manager_loop(self) -> None:
while True:
self.update_background_music()
self.do_fade()
await asyncio.sleep(0.02)
def extract_sounds(self) -> None:
# Kivy appears to have no good way of loading audio from bytes.
# So, we have to extract it out of the .apworld first
sound_paths = {}
sound_directory = make_data_directory("sounds")
for sound in ALL_SOUNDS:
sound_file_location = sound_directory / sound
sound_paths[sound] = sound_file_location
if sound_file_location.exists():
continue
with open(sound_file_location, "wb") as sound_file:
data = pkgutil.get_data(game.__name__, f"audio/{sound}")
if data is None:
logger.exception(f"Unable to extract sound {sound} to Archipelago/data")
continue
sound_file.write(cast(Buffer, data))
self.sound_paths = sound_paths
def load_audio(self, sound_filename: str) -> Sound:
audio_path = self.sound_paths[sound_filename]
sound_object = SoundLoader.load(str(audio_path.absolute()))
sound_object.seek(0)
return sound_object
def populate_sounds(self) -> None:
try:
self.jingles = {sound_filename: self.load_audio(sound_filename) for sound_filename in ALL_JINGLES}
except Exception as e:
logger.exception(e)
try:
self.bgm_songs = {sound_filename: self.load_audio(sound_filename) for sound_filename in ALL_BGM}
for bgm_song in self.bgm_songs.values():
bgm_song.loop = True
bgm_song.seek(0)
except Exception as e:
logger.exception(e)
def play_jingle(self, audio_filename: str) -> None:
higher_priority_sound_is_playing = False
for sound_name, sound in self.jingles.items():
if higher_priority_sound_is_playing: # jingles are ordered by priority, lower priority gets eaten
sound.stop()
continue
if sound_name == audio_filename:
sound.play()
self.update_background_music()
higher_priority_sound_is_playing = True
elif sound.state == "play":
higher_priority_sound_is_playing = True
def update_background_music(self) -> None:
self.update_active_song()
if any(sound.state == "play" for sound in self.jingles.values()):
self.play_background_music(False)
else:
if self.math_trap_active:
# Don't fade math trap song, it ends up feeling better
self.play_background_music(True)
else:
self.fade_background_music(True)
def play_background_music(self, play: bool = True) -> None:
if play:
self.background_music_target_volume = 1
self.set_background_music_volume(1)
else:
self.background_music_target_volume = 0
self.set_background_music_volume(0)
def set_background_music_volume(self, volume: float) -> None:
self.current_background_music_volume = volume
for song_filename, song in self.bgm_songs.items():
if song_filename != self.active_bgm_song:
song.volume = 0
continue
song.volume = volume * self.volume_percentage / 100
def fade_background_music(self, fade_in: bool = True) -> None:
if fade_in:
self.background_music_target_volume = 1
else:
self.background_music_target_volume = 0
def set_volume_percentage(self, volume_percentage: float) -> None:
volume_percentage_int = int(volume_percentage)
if self.volume_percentage != volume_percentage:
self.volume_percentage = volume_percentage_int
Config.set("APQuest", "volume", volume_percentage_int)
Config.write()
self.set_background_music_volume(self.current_background_music_volume)
for jingle in self.jingles.values():
jingle.volume = self.volume_percentage / 100
def do_fade(self) -> None:
if self.current_background_music_volume > self.background_music_target_volume:
self.set_background_music_volume(max(0.0, self.current_background_music_volume - 0.02))
if self.current_background_music_volume < self.background_music_target_volume:
self.set_background_music_volume(min(1.0, self.current_background_music_volume + 0.02))
for song_filename, song in self.bgm_songs.items():
if song_filename != self.active_bgm_song:
if song_filename == BACKGROUND_MUSIC:
# It ends up feeling better if this just always continues playing quietly after being started.
# Even "fading in at a random spot" is better than restarting the song after a jingle / math trap.
if self.game_started and song.state == "stop":
song.play()
song.seek(0)
continue
song.stop()
song.seek(0)
continue
if self.active_bgm_song == BACKGROUND_MUSIC_INTRO and not self.allow_intro_to_play:
song.stop()
song.seek(0)
continue
if self.current_background_music_volume != 0:
if song.state == "stop":
song.play()
song.seek(0)
def update_active_song(self) -> None:
new_active_song = self.determine_correct_song()
if new_active_song == self.active_bgm_song:
return
self.active_bgm_song = new_active_song
# reevaluate song volumes
self.set_background_music_volume(self.current_background_music_volume)
def determine_correct_song(self) -> str:
if not self.game_started:
return BACKGROUND_MUSIC_INTRO
if self.math_trap_active:
return MATH_TIME_BACKGROUND_MUSIC
return BACKGROUND_MUSIC