vh-bombsquad-modded-server-.../dist/ba_data/python/ba/_music.py
2024-06-06 19:50:58 +05:30

527 lines
18 KiB
Python

# Released under the MIT License. See LICENSE for details.
#
"""Music related functionality."""
from __future__ import annotations
import copy
from typing import TYPE_CHECKING
from dataclasses import dataclass
from enum import Enum
import _ba
if TYPE_CHECKING:
from typing import Callable, Any
import ba
class MusicType(Enum):
"""Types of music available to play in-game.
Category: **Enums**
These do not correspond to specific pieces of music, but rather to
'situations'. The actual music played for each type can be overridden
by the game or by the user.
"""
MENU = 'Menu'
VICTORY = 'Victory'
CHAR_SELECT = 'CharSelect'
RUN_AWAY = 'RunAway'
ONSLAUGHT = 'Onslaught'
KEEP_AWAY = 'Keep Away'
RACE = 'Race'
EPIC_RACE = 'Epic Race'
SCORES = 'Scores'
GRAND_ROMP = 'GrandRomp'
TO_THE_DEATH = 'ToTheDeath'
CHOSEN_ONE = 'Chosen One'
FORWARD_MARCH = 'ForwardMarch'
FLAG_CATCHER = 'FlagCatcher'
SURVIVAL = 'Survival'
EPIC = 'Epic'
SPORTS = 'Sports'
HOCKEY = 'Hockey'
FOOTBALL = 'Football'
FLYING = 'Flying'
SCARY = 'Scary'
MARCHING = 'Marching'
class MusicPlayMode(Enum):
"""Influences behavior when playing music.
Category: **Enums**
"""
REGULAR = 'regular'
TEST = 'test'
@dataclass
class AssetSoundtrackEntry:
"""A music entry using an internal asset.
Category: **App Classes**
"""
assetname: str
volume: float = 1.0
loop: bool = True
# What gets played by default for our different music types:
ASSET_SOUNDTRACK_ENTRIES: dict[MusicType, AssetSoundtrackEntry] = {
MusicType.MENU: AssetSoundtrackEntry('menuMusic'),
MusicType.VICTORY: AssetSoundtrackEntry(
'victoryMusic', volume=1.2, loop=False
),
MusicType.CHAR_SELECT: AssetSoundtrackEntry('charSelectMusic', volume=0.4),
MusicType.RUN_AWAY: AssetSoundtrackEntry('runAwayMusic', volume=1.2),
MusicType.ONSLAUGHT: AssetSoundtrackEntry('runAwayMusic', volume=1.2),
MusicType.KEEP_AWAY: AssetSoundtrackEntry('runAwayMusic', volume=1.2),
MusicType.RACE: AssetSoundtrackEntry('runAwayMusic', volume=1.2),
MusicType.EPIC_RACE: AssetSoundtrackEntry('slowEpicMusic', volume=1.2),
MusicType.SCORES: AssetSoundtrackEntry(
'scoresEpicMusic', volume=0.6, loop=False
),
MusicType.GRAND_ROMP: AssetSoundtrackEntry('grandRompMusic', volume=1.2),
MusicType.TO_THE_DEATH: AssetSoundtrackEntry('toTheDeathMusic', volume=1.2),
MusicType.CHOSEN_ONE: AssetSoundtrackEntry('survivalMusic', volume=0.8),
MusicType.FORWARD_MARCH: AssetSoundtrackEntry(
'forwardMarchMusic', volume=0.8
),
MusicType.FLAG_CATCHER: AssetSoundtrackEntry(
'flagCatcherMusic', volume=1.2
),
MusicType.SURVIVAL: AssetSoundtrackEntry('survivalMusic', volume=0.8),
MusicType.EPIC: AssetSoundtrackEntry('slowEpicMusic', volume=1.2),
MusicType.SPORTS: AssetSoundtrackEntry('sportsMusic', volume=0.8),
MusicType.HOCKEY: AssetSoundtrackEntry('sportsMusic', volume=0.8),
MusicType.FOOTBALL: AssetSoundtrackEntry('sportsMusic', volume=0.8),
MusicType.FLYING: AssetSoundtrackEntry('flyingMusic', volume=0.8),
MusicType.SCARY: AssetSoundtrackEntry('scaryMusic', volume=0.8),
MusicType.MARCHING: AssetSoundtrackEntry(
'whenJohnnyComesMarchingHomeMusic', volume=0.8
),
}
class MusicSubsystem:
"""Subsystem for music playback in the app.
Category: **App Classes**
Access the single shared instance of this class at 'ba.app.music'.
"""
def __init__(self) -> None:
# pylint: disable=cyclic-import
self._music_node: _ba.Node | None = None
self._music_mode: MusicPlayMode = MusicPlayMode.REGULAR
self._music_player: MusicPlayer | None = None
self._music_player_type: type[MusicPlayer] | None = None
self.music_types: dict[MusicPlayMode, MusicType | None] = {
MusicPlayMode.REGULAR: None,
MusicPlayMode.TEST: None,
}
# Set up custom music players for platforms that support them.
# FIXME: should generalize this to support arbitrary players per
# platform (which can be discovered via ba_meta).
# Our standard asset playback should probably just be one of them
# instead of a special case.
if self.supports_soundtrack_entry_type('musicFile'):
from ba.osmusic import OSMusicPlayer
self._music_player_type = OSMusicPlayer
elif self.supports_soundtrack_entry_type('iTunesPlaylist'):
from ba.macmusicapp import MacMusicAppMusicPlayer
self._music_player_type = MacMusicAppMusicPlayer
def on_app_launch(self) -> None:
"""Should be called by app on_app_launch()."""
# If we're using a non-default playlist, lets go ahead and get our
# music-player going since it may hitch (better while we're faded
# out than later).
try:
cfg = _ba.app.config
if 'Soundtrack' in cfg and cfg['Soundtrack'] not in [
'__default__',
'Default Soundtrack',
]:
self.get_music_player()
except Exception:
from ba import _error
_error.print_exception('error prepping music-player')
def on_app_shutdown(self) -> None:
"""Should be called when the app is shutting down."""
if self._music_player is not None:
self._music_player.shutdown()
def have_music_player(self) -> bool:
"""Returns whether a music player is present."""
return self._music_player_type is not None
def get_music_player(self) -> MusicPlayer:
"""Returns the system music player, instantiating if necessary."""
if self._music_player is None:
if self._music_player_type is None:
raise TypeError('no music player type set')
self._music_player = self._music_player_type()
return self._music_player
def music_volume_changed(self, val: float) -> None:
"""Should be called when changing the music volume."""
if self._music_player is not None:
self._music_player.set_volume(val)
def set_music_play_mode(
self, mode: MusicPlayMode, force_restart: bool = False
) -> None:
"""Sets music play mode; used for soundtrack testing/etc."""
old_mode = self._music_mode
self._music_mode = mode
if old_mode != self._music_mode or force_restart:
# If we're switching into test mode we don't
# actually play anything until its requested.
# If we're switching *out* of test mode though
# we want to go back to whatever the normal song was.
if mode is MusicPlayMode.REGULAR:
mtype = self.music_types[MusicPlayMode.REGULAR]
self.do_play_music(None if mtype is None else mtype.value)
def supports_soundtrack_entry_type(self, entry_type: str) -> bool:
"""Return whether provided soundtrack entry type is supported here."""
uas = _ba.env()['user_agent_string']
assert isinstance(uas, str)
# FIXME: Generalize this.
if entry_type == 'iTunesPlaylist':
return 'Mac' in uas
if entry_type in ('musicFile', 'musicFolder'):
return (
'android' in uas
and _ba.android_get_external_files_dir() is not None
)
if entry_type == 'default':
return True
return False
def get_soundtrack_entry_type(self, entry: Any) -> str:
"""Given a soundtrack entry, returns its type, taking into
account what is supported locally."""
try:
if entry is None:
entry_type = 'default'
# Simple string denotes iTunesPlaylist (legacy format).
elif isinstance(entry, str):
entry_type = 'iTunesPlaylist'
# For other entries we expect type and name strings in a dict.
elif (
isinstance(entry, dict)
and 'type' in entry
and isinstance(entry['type'], str)
and 'name' in entry
and isinstance(entry['name'], str)
):
entry_type = entry['type']
else:
raise TypeError(
'invalid soundtrack entry: '
+ str(entry)
+ ' (type '
+ str(type(entry))
+ ')'
)
if self.supports_soundtrack_entry_type(entry_type):
return entry_type
raise ValueError('invalid soundtrack entry:' + str(entry))
except Exception:
from ba import _error
_error.print_exception()
return 'default'
def get_soundtrack_entry_name(self, entry: Any) -> str:
"""Given a soundtrack entry, returns its name."""
try:
if entry is None:
raise TypeError('entry is None')
# Simple string denotes an iTunesPlaylist name (legacy entry).
if isinstance(entry, str):
return entry
# For other entries we expect type and name strings in a dict.
if (
isinstance(entry, dict)
and 'type' in entry
and isinstance(entry['type'], str)
and 'name' in entry
and isinstance(entry['name'], str)
):
return entry['name']
raise ValueError('invalid soundtrack entry:' + str(entry))
except Exception:
from ba import _error
_error.print_exception()
return 'default'
def on_app_resume(self) -> None:
"""Should be run when the app resumes from a suspended state."""
if _ba.is_os_playing_music():
self.do_play_music(None)
def do_play_music(
self,
musictype: MusicType | str | None,
continuous: bool = False,
mode: MusicPlayMode = MusicPlayMode.REGULAR,
testsoundtrack: dict[str, Any] | None = None,
) -> None:
"""Plays the requested music type/mode.
For most cases, setmusic() is the proper call to use, which itself
calls this. Certain cases, however, such as soundtrack testing, may
require calling this directly.
"""
# We can be passed a MusicType or the string value corresponding
# to one.
if musictype is not None:
try:
musictype = MusicType(musictype)
except ValueError:
print(f"Invalid music type: '{musictype}'")
musictype = None
with _ba.Context('ui'):
# If they don't want to restart music and we're already
# playing what's requested, we're done.
if continuous and self.music_types[mode] is musictype:
return
self.music_types[mode] = musictype
# If the OS tells us there's currently music playing,
# all our operations default to playing nothing.
if _ba.is_os_playing_music():
musictype = None
# If we're not in the mode this music is being set for,
# don't actually change what's playing.
if mode != self._music_mode:
return
# Some platforms have a special music-player for things like iTunes
# soundtracks, mp3s, etc. if this is the case, attempt to grab an
# entry for this music-type, and if we have one, have the
# music-player play it. If not, we'll play game music ourself.
if musictype is not None and self._music_player_type is not None:
if testsoundtrack is not None:
soundtrack = testsoundtrack
else:
soundtrack = self._get_user_soundtrack()
entry = soundtrack.get(musictype.value)
else:
entry = None
# Go through music-player.
if entry is not None:
self._play_music_player_music(entry)
# Handle via internal music.
else:
self._play_internal_music(musictype)
def _get_user_soundtrack(self) -> dict[str, Any]:
"""Return current user soundtrack or empty dict otherwise."""
cfg = _ba.app.config
soundtrack: dict[str, Any] = {}
soundtrackname = cfg.get('Soundtrack')
if soundtrackname is not None and soundtrackname != '__default__':
try:
soundtrack = cfg.get('Soundtracks', {})[soundtrackname]
except Exception as exc:
print(f'Error looking up user soundtrack: {exc}')
soundtrack = {}
return soundtrack
def _play_music_player_music(self, entry: Any) -> None:
# Stop any existing internal music.
if self._music_node is not None:
self._music_node.delete()
self._music_node = None
# Do the thing.
self.get_music_player().play(entry)
def _play_internal_music(self, musictype: MusicType | None) -> None:
# Stop any existing music-player playback.
if self._music_player is not None:
self._music_player.stop()
# Stop any existing internal music.
if self._music_node:
self._music_node.delete()
self._music_node = None
# Start up new internal music.
if musictype is not None:
entry = ASSET_SOUNDTRACK_ENTRIES.get(musictype)
if entry is None:
print(f"Unknown music: '{musictype}'")
entry = ASSET_SOUNDTRACK_ENTRIES[MusicType.FLAG_CATCHER]
self._music_node = _ba.newnode(
type='sound',
attrs={
'sound': _ba.getsound(entry.assetname),
'positional': False,
'music': True,
'volume': entry.volume * 5.0,
'loop': entry.loop,
},
)
class MusicPlayer:
"""Wrangles soundtrack music playback.
Category: **App Classes**
Music can be played either through the game itself
or via a platform-specific external player.
"""
def __init__(self) -> None:
self._have_set_initial_volume = False
self._entry_to_play: Any = None
self._volume = 1.0
self._actually_playing = False
def select_entry(
self,
callback: Callable[[Any], None],
current_entry: Any,
selection_target_name: str,
) -> Any:
"""Summons a UI to select a new soundtrack entry."""
return self.on_select_entry(
callback, current_entry, selection_target_name
)
def set_volume(self, volume: float) -> None:
"""Set player volume (value should be between 0 and 1)."""
self._volume = volume
self.on_set_volume(volume)
self._update_play_state()
def play(self, entry: Any) -> None:
"""Play provided entry."""
if not self._have_set_initial_volume:
self._volume = _ba.app.config.resolve('Music Volume')
self.on_set_volume(self._volume)
self._have_set_initial_volume = True
self._entry_to_play = copy.deepcopy(entry)
# If we're currently *actually* playing something,
# switch to the new thing.
# Otherwise update state which will start us playing *only*
# if proper (volume > 0, etc).
if self._actually_playing:
self.on_play(self._entry_to_play)
else:
self._update_play_state()
def stop(self) -> None:
"""Stop any playback that is occurring."""
self._entry_to_play = None
self._update_play_state()
def shutdown(self) -> None:
"""Shutdown music playback completely."""
self.on_app_shutdown()
def on_select_entry(
self,
callback: Callable[[Any], None],
current_entry: Any,
selection_target_name: str,
) -> Any:
"""Present a GUI to select an entry.
The callback should be called with a valid entry or None to
signify that the default soundtrack should be used.."""
# Subclasses should override the following:
def on_set_volume(self, volume: float) -> None:
"""Called when the volume should be changed."""
def on_play(self, entry: Any) -> None:
"""Called when a new song/playlist/etc should be played."""
def on_stop(self) -> None:
"""Called when the music should stop."""
def on_app_shutdown(self) -> None:
"""Called on final app shutdown."""
def _update_play_state(self) -> None:
# If we aren't playing, should be, and have positive volume, do so.
if not self._actually_playing:
if self._entry_to_play is not None and self._volume > 0.0:
self.on_play(self._entry_to_play)
self._actually_playing = True
else:
if self._actually_playing and (
self._entry_to_play is None or self._volume <= 0.0
):
self.on_stop()
self._actually_playing = False
def setmusic(musictype: ba.MusicType | None, continuous: bool = False) -> None:
"""Set the app to play (or stop playing) a certain type of music.
category: **Gameplay Functions**
This function will handle loading and playing sound assets as necessary,
and also supports custom user soundtracks on specific platforms so the
user can override particular game music with their own.
Pass None to stop music.
if 'continuous' is True and musictype is the same as what is already
playing, the playing track will not be restarted.
"""
# All we do here now is set a few music attrs on the current globals
# node. The foreground globals' current playing music then gets fed to
# the do_play_music call in our music controller. This way we can
# seamlessly support custom soundtracks in replays/etc since we're being
# driven purely by node data.
gnode = _ba.getactivity().globalsnode
gnode.music_continuous = continuous
gnode.music = '' if musictype is None else musictype.value
gnode.music_count += 1
def do_play_music(*args: Any, **keywds: Any) -> None:
"""A passthrough used by the C++ layer."""
_ba.app.music.do_play_music(*args, **keywds)