mirror of
https://github.com/hypervortex/VH-Bombsquad-Modded-Server-Files
synced 2025-10-16 12:02:51 +00:00
Added new files
This commit is contained in:
parent
5e004af549
commit
77ccb73089
1783 changed files with 566966 additions and 0 deletions
396
dist/ba_data/python/ba/__init__.py
vendored
Normal file
396
dist/ba_data/python/ba/__init__.py
vendored
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""The public face of Ballistica.
|
||||
|
||||
This top level module is a collection of most commonly used functionality.
|
||||
For many modding purposes, the bits exposed here are all you'll need.
|
||||
In some specific cases you may need to pull in individual submodules instead.
|
||||
"""
|
||||
# pylint: disable=redefined-builtin
|
||||
|
||||
from _ba import (
|
||||
CollideModel,
|
||||
Context,
|
||||
ContextCall,
|
||||
Data,
|
||||
InputDevice,
|
||||
Material,
|
||||
Model,
|
||||
Node,
|
||||
SessionPlayer,
|
||||
Sound,
|
||||
Texture,
|
||||
Timer,
|
||||
Vec3,
|
||||
Widget,
|
||||
buttonwidget,
|
||||
camerashake,
|
||||
checkboxwidget,
|
||||
columnwidget,
|
||||
containerwidget,
|
||||
do_once,
|
||||
emitfx,
|
||||
getactivity,
|
||||
getcollidemodel,
|
||||
getmodel,
|
||||
getnodes,
|
||||
getsession,
|
||||
getsound,
|
||||
gettexture,
|
||||
hscrollwidget,
|
||||
imagewidget,
|
||||
newactivity,
|
||||
newnode,
|
||||
playsound,
|
||||
printnodes,
|
||||
ls_objects,
|
||||
ls_input_devices,
|
||||
pushcall,
|
||||
quit,
|
||||
rowwidget,
|
||||
safecolor,
|
||||
screenmessage,
|
||||
scrollwidget,
|
||||
set_analytics_screen,
|
||||
charstr,
|
||||
textwidget,
|
||||
time,
|
||||
timer,
|
||||
open_url,
|
||||
widget,
|
||||
clipboard_is_supported,
|
||||
clipboard_has_text,
|
||||
clipboard_get_text,
|
||||
clipboard_set_text,
|
||||
getdata,
|
||||
in_logic_thread,
|
||||
)
|
||||
from ba._accountv2 import AccountV2Handle
|
||||
from ba._activity import Activity
|
||||
from ba._plugin import PotentialPlugin, Plugin, PluginSubsystem
|
||||
from ba._actor import Actor
|
||||
from ba._player import PlayerInfo, Player, EmptyPlayer, StandLocation
|
||||
from ba._nodeactor import NodeActor
|
||||
from ba._app import App
|
||||
from ba._cloud import CloudSubsystem
|
||||
from ba._coopgame import CoopGameActivity
|
||||
from ba._coopsession import CoopSession
|
||||
from ba._dependency import (
|
||||
Dependency,
|
||||
DependencyComponent,
|
||||
DependencySet,
|
||||
AssetPackage,
|
||||
)
|
||||
from ba._generated.enums import (
|
||||
TimeType,
|
||||
Permission,
|
||||
TimeFormat,
|
||||
SpecialChar,
|
||||
InputType,
|
||||
UIScale,
|
||||
)
|
||||
from ba._error import (
|
||||
print_exception,
|
||||
print_error,
|
||||
ContextError,
|
||||
NotFoundError,
|
||||
PlayerNotFoundError,
|
||||
SessionPlayerNotFoundError,
|
||||
NodeNotFoundError,
|
||||
ActorNotFoundError,
|
||||
InputDeviceNotFoundError,
|
||||
WidgetNotFoundError,
|
||||
ActivityNotFoundError,
|
||||
TeamNotFoundError,
|
||||
MapNotFoundError,
|
||||
SessionTeamNotFoundError,
|
||||
SessionNotFoundError,
|
||||
DelegateNotFoundError,
|
||||
DependencyError,
|
||||
)
|
||||
from ba._freeforallsession import FreeForAllSession
|
||||
from ba._gameactivity import GameActivity
|
||||
from ba._gameresults import GameResults
|
||||
from ba._settings import (
|
||||
Setting,
|
||||
IntSetting,
|
||||
FloatSetting,
|
||||
ChoiceSetting,
|
||||
BoolSetting,
|
||||
IntChoiceSetting,
|
||||
FloatChoiceSetting,
|
||||
)
|
||||
from ba._language import Lstr, LanguageSubsystem
|
||||
from ba._map import Map, getmaps
|
||||
from ba._session import Session
|
||||
from ba._ui import UISubsystem
|
||||
from ba._servermode import ServerController
|
||||
from ba._score import ScoreType, ScoreConfig
|
||||
from ba._stats import PlayerScoredMessage, PlayerRecord, Stats
|
||||
from ba._team import SessionTeam, Team, EmptyTeam
|
||||
from ba._teamgame import TeamGameActivity
|
||||
from ba._dualteamsession import DualTeamSession
|
||||
from ba._achievement import Achievement, AchievementSubsystem
|
||||
from ba._appconfig import AppConfig
|
||||
from ba._appdelegate import AppDelegate
|
||||
from ba._apputils import is_browser_likely_available, garbage_collect
|
||||
from ba._campaign import Campaign
|
||||
from ba._gameutils import (
|
||||
GameTip,
|
||||
animate,
|
||||
animate_array,
|
||||
show_damage_count,
|
||||
timestring,
|
||||
cameraflash,
|
||||
)
|
||||
from ba._general import (
|
||||
WeakCall,
|
||||
Call,
|
||||
existing,
|
||||
Existable,
|
||||
verify_object_death,
|
||||
storagename,
|
||||
getclass,
|
||||
)
|
||||
from ba._keyboard import Keyboard
|
||||
from ba._level import Level
|
||||
from ba._lobby import Lobby, Chooser
|
||||
from ba._math import normalized_color, is_point_in_box, vec3validate
|
||||
from ba._meta import MetadataSubsystem
|
||||
from ba._messages import (
|
||||
UNHANDLED,
|
||||
OutOfBoundsMessage,
|
||||
DeathType,
|
||||
DieMessage,
|
||||
PlayerDiedMessage,
|
||||
StandMessage,
|
||||
PickUpMessage,
|
||||
DropMessage,
|
||||
PickedUpMessage,
|
||||
DroppedMessage,
|
||||
ShouldShatterMessage,
|
||||
ImpactDamageMessage,
|
||||
FreezeMessage,
|
||||
ThawMessage,
|
||||
HitMessage,
|
||||
CelebrateMessage,
|
||||
)
|
||||
from ba._music import (
|
||||
setmusic,
|
||||
MusicPlayer,
|
||||
MusicType,
|
||||
MusicPlayMode,
|
||||
MusicSubsystem,
|
||||
)
|
||||
from ba._powerup import PowerupMessage, PowerupAcceptMessage
|
||||
from ba._multiteamsession import MultiTeamSession
|
||||
from ba.ui import Window, UIController, uicleanupcheck
|
||||
from ba._collision import Collision, getcollision
|
||||
|
||||
app: App
|
||||
|
||||
__all__ = [
|
||||
'AccountV2Handle',
|
||||
'Achievement',
|
||||
'AchievementSubsystem',
|
||||
'Activity',
|
||||
'ActivityNotFoundError',
|
||||
'Actor',
|
||||
'ActorNotFoundError',
|
||||
'animate',
|
||||
'animate_array',
|
||||
'app',
|
||||
'App',
|
||||
'AppConfig',
|
||||
'AppDelegate',
|
||||
'AssetPackage',
|
||||
'BoolSetting',
|
||||
'buttonwidget',
|
||||
'Call',
|
||||
'cameraflash',
|
||||
'camerashake',
|
||||
'Campaign',
|
||||
'CelebrateMessage',
|
||||
'charstr',
|
||||
'checkboxwidget',
|
||||
'ChoiceSetting',
|
||||
'Chooser',
|
||||
'clipboard_get_text',
|
||||
'clipboard_has_text',
|
||||
'clipboard_is_supported',
|
||||
'clipboard_set_text',
|
||||
'CollideModel',
|
||||
'Collision',
|
||||
'columnwidget',
|
||||
'containerwidget',
|
||||
'Context',
|
||||
'ContextCall',
|
||||
'ContextError',
|
||||
'CloudSubsystem',
|
||||
'CoopGameActivity',
|
||||
'CoopSession',
|
||||
'Data',
|
||||
'DeathType',
|
||||
'DelegateNotFoundError',
|
||||
'Dependency',
|
||||
'DependencyComponent',
|
||||
'DependencyError',
|
||||
'DependencySet',
|
||||
'DieMessage',
|
||||
'do_once',
|
||||
'DropMessage',
|
||||
'DroppedMessage',
|
||||
'DualTeamSession',
|
||||
'emitfx',
|
||||
'EmptyPlayer',
|
||||
'EmptyTeam',
|
||||
'Existable',
|
||||
'existing',
|
||||
'FloatChoiceSetting',
|
||||
'FloatSetting',
|
||||
'FreeForAllSession',
|
||||
'FreezeMessage',
|
||||
'GameActivity',
|
||||
'GameResults',
|
||||
'GameTip',
|
||||
'garbage_collect',
|
||||
'getactivity',
|
||||
'getclass',
|
||||
'getcollidemodel',
|
||||
'getcollision',
|
||||
'getdata',
|
||||
'getmaps',
|
||||
'getmodel',
|
||||
'getnodes',
|
||||
'getsession',
|
||||
'getsound',
|
||||
'gettexture',
|
||||
'HitMessage',
|
||||
'hscrollwidget',
|
||||
'imagewidget',
|
||||
'ImpactDamageMessage',
|
||||
'in_logic_thread',
|
||||
'InputDevice',
|
||||
'InputDeviceNotFoundError',
|
||||
'InputType',
|
||||
'IntChoiceSetting',
|
||||
'IntSetting',
|
||||
'is_browser_likely_available',
|
||||
'is_point_in_box',
|
||||
'Keyboard',
|
||||
'LanguageSubsystem',
|
||||
'Level',
|
||||
'Lobby',
|
||||
'Lstr',
|
||||
'Map',
|
||||
'MapNotFoundError',
|
||||
'Material',
|
||||
'MetadataSubsystem',
|
||||
'Model',
|
||||
'MultiTeamSession',
|
||||
'MusicPlayer',
|
||||
'MusicPlayMode',
|
||||
'MusicSubsystem',
|
||||
'MusicType',
|
||||
'newactivity',
|
||||
'newnode',
|
||||
'Node',
|
||||
'NodeActor',
|
||||
'NodeNotFoundError',
|
||||
'normalized_color',
|
||||
'NotFoundError',
|
||||
'open_url',
|
||||
'OutOfBoundsMessage',
|
||||
'Permission',
|
||||
'PickedUpMessage',
|
||||
'PickUpMessage',
|
||||
'Player',
|
||||
'PlayerDiedMessage',
|
||||
'PlayerInfo',
|
||||
'PlayerNotFoundError',
|
||||
'PlayerRecord',
|
||||
'PlayerScoredMessage',
|
||||
'playsound',
|
||||
'Plugin',
|
||||
'PluginSubsystem',
|
||||
'PotentialPlugin',
|
||||
'PowerupAcceptMessage',
|
||||
'PowerupMessage',
|
||||
'print_error',
|
||||
'print_exception',
|
||||
'printnodes',
|
||||
'ls_objects',
|
||||
'ls_input_devices',
|
||||
'pushcall',
|
||||
'quit',
|
||||
'rowwidget',
|
||||
'safecolor',
|
||||
'ScoreConfig',
|
||||
'ScoreType',
|
||||
'screenmessage',
|
||||
'scrollwidget',
|
||||
'ServerController',
|
||||
'Session',
|
||||
'SessionNotFoundError',
|
||||
'SessionPlayer',
|
||||
'SessionPlayerNotFoundError',
|
||||
'SessionTeam',
|
||||
'SessionTeamNotFoundError',
|
||||
'set_analytics_screen',
|
||||
'setmusic',
|
||||
'Setting',
|
||||
'ShouldShatterMessage',
|
||||
'show_damage_count',
|
||||
'Sound',
|
||||
'SpecialChar',
|
||||
'StandLocation',
|
||||
'StandMessage',
|
||||
'Stats',
|
||||
'storagename',
|
||||
'Team',
|
||||
'TeamGameActivity',
|
||||
'TeamNotFoundError',
|
||||
'Texture',
|
||||
'textwidget',
|
||||
'ThawMessage',
|
||||
'time',
|
||||
'TimeFormat',
|
||||
'Timer',
|
||||
'timer',
|
||||
'timestring',
|
||||
'TimeType',
|
||||
'uicleanupcheck',
|
||||
'UIController',
|
||||
'UIScale',
|
||||
'UISubsystem',
|
||||
'UNHANDLED',
|
||||
'Vec3',
|
||||
'vec3validate',
|
||||
'verify_object_death',
|
||||
'WeakCall',
|
||||
'Widget',
|
||||
'widget',
|
||||
'WidgetNotFoundError',
|
||||
'Window',
|
||||
]
|
||||
|
||||
|
||||
# Have these things present themselves cleanly as 'ba.Foo'
|
||||
# instead of 'ba._submodule.Foo'
|
||||
def _simplify_module_names() -> None:
|
||||
import os
|
||||
|
||||
# Though pdoc gets confused when we override __module__,
|
||||
# so let's make an exception for it.
|
||||
if os.environ.get('BA_DOCS_GENERATION', '0') != '1':
|
||||
from efro.util import set_canonical_module
|
||||
|
||||
globs = globals()
|
||||
set_canonical_module(
|
||||
module_globals=globs,
|
||||
names=[n for n in globs.keys() if not n.startswith('_')],
|
||||
)
|
||||
|
||||
|
||||
_simplify_module_names()
|
||||
del _simplify_module_names
|
||||
BIN
dist/ba_data/python/ba/__pycache__/__init__.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/__init__.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_accountv1.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_accountv1.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_accountv2.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_accountv2.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_achievement.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_achievement.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_activity.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_activity.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_activitytypes.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_activitytypes.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_actor.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_actor.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_ads.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_ads.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_analytics.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_analytics.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_app.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_app.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_appcomponent.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_appcomponent.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_appconfig.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_appconfig.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_appdelegate.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_appdelegate.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_apputils.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_apputils.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_asyncio.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_asyncio.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_benchmark.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_benchmark.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_bootstrap.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_bootstrap.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_campaign.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_campaign.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_cloud.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_cloud.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_collision.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_collision.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_coopgame.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_coopgame.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_coopsession.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_coopsession.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_dependency.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_dependency.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_dualteamsession.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_dualteamsession.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_enums.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_enums.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_error.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_error.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_freeforallsession.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_freeforallsession.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_gameactivity.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_gameactivity.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_gameresults.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_gameresults.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_gameutils.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_gameutils.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_general.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_general.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_hooks.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_hooks.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_input.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_input.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_internal.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_internal.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_keyboard.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_keyboard.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_language.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_language.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_level.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_level.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_lobby.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_lobby.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_login.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_login.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_map.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_map.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_math.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_math.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_messages.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_messages.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_meta.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_meta.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_multiteamsession.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_multiteamsession.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_music.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_music.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_net.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_net.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_nodeactor.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_nodeactor.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_player.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_player.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_playlist.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_playlist.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_plugin.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_plugin.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_powerup.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_powerup.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_profile.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_profile.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_score.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_score.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_servermode.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_servermode.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_session.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_session.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_settings.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_settings.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_stats.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_stats.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_store.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_store.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_team.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_team.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_teamgame.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_teamgame.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_tips.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_tips.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_tournament.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_tournament.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_ui.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_ui.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/_workspace.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/_workspace.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/__pycache__/internal.cpython-310.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/__pycache__/internal.cpython-310.opt-1.pyc
vendored
Normal file
Binary file not shown.
267
dist/ba_data/python/ba/_account.py
vendored
Normal file
267
dist/ba_data/python/ba/_account.py
vendored
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Account related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Optional
|
||||
import ba
|
||||
|
||||
|
||||
class AccountSubsystem:
|
||||
"""Subsystem for account handling in the app.
|
||||
|
||||
Category: App Classes
|
||||
|
||||
Access the single shared instance of this class at 'ba.app.plugins'.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.account_tournament_list: Optional[tuple[int, list[str]]] = None
|
||||
|
||||
# FIXME: should abstract/structure these.
|
||||
self.tournament_info: dict = {}
|
||||
self.league_rank_cache: dict = {}
|
||||
self.last_post_purchase_message_time: Optional[float] = None
|
||||
|
||||
# If we try to run promo-codes due to launch-args/etc we might
|
||||
# not be signed in yet; go ahead and queue them up in that case.
|
||||
self.pending_promo_codes: list[str] = []
|
||||
|
||||
def on_app_launch(self) -> None:
|
||||
"""Called when the app is done bootstrapping."""
|
||||
|
||||
# Auto-sign-in to a local account in a moment if we're set to.
|
||||
def do_auto_sign_in() -> None:
|
||||
if _ba.app.headless_mode or _ba.app.config.get(
|
||||
'Auto Account State') == 'Local':
|
||||
_ba.sign_in('Local')
|
||||
|
||||
_ba.pushcall(do_auto_sign_in)
|
||||
|
||||
def on_app_resume(self) -> None:
|
||||
"""Should be called when the app is resumed."""
|
||||
|
||||
# Mark our cached tourneys as invalid so anyone using them knows
|
||||
# they might be out of date.
|
||||
for entry in list(self.tournament_info.values()):
|
||||
entry['valid'] = False
|
||||
|
||||
def handle_account_gained_tickets(self, count: int) -> None:
|
||||
"""Called when the current account has been awarded tickets.
|
||||
|
||||
(internal)
|
||||
"""
|
||||
from ba._language import Lstr
|
||||
_ba.screenmessage(Lstr(resource='getTicketsWindow.receivedTicketsText',
|
||||
subs=[('${COUNT}', str(count))]),
|
||||
color=(0, 1, 0))
|
||||
_ba.playsound(_ba.getsound('cashRegister'))
|
||||
|
||||
def cache_league_rank_data(self, data: Any) -> None:
|
||||
"""(internal)"""
|
||||
self.league_rank_cache['info'] = copy.deepcopy(data)
|
||||
|
||||
def get_cached_league_rank_data(self) -> Any:
|
||||
"""(internal)"""
|
||||
return self.league_rank_cache.get('info', None)
|
||||
|
||||
def get_league_rank_points(self,
|
||||
data: Optional[dict[str, Any]],
|
||||
subset: str = None) -> int:
|
||||
"""(internal)"""
|
||||
if data is None:
|
||||
return 0
|
||||
|
||||
# If the data contains an achievement total, use that. otherwise calc
|
||||
# locally.
|
||||
if data['at'] is not None:
|
||||
total_ach_value = data['at']
|
||||
else:
|
||||
total_ach_value = 0
|
||||
for ach in _ba.app.ach.achievements:
|
||||
if ach.complete:
|
||||
total_ach_value += ach.power_ranking_value
|
||||
|
||||
trophies_total: int = (data['t0a'] * data['t0am'] +
|
||||
data['t0b'] * data['t0bm'] +
|
||||
data['t1'] * data['t1m'] +
|
||||
data['t2'] * data['t2m'] +
|
||||
data['t3'] * data['t3m'] +
|
||||
data['t4'] * data['t4m'])
|
||||
if subset == 'trophyCount':
|
||||
val: int = (data['t0a'] + data['t0b'] + data['t1'] + data['t2'] +
|
||||
data['t3'] + data['t4'])
|
||||
assert isinstance(val, int)
|
||||
return val
|
||||
if subset == 'trophies':
|
||||
assert isinstance(trophies_total, int)
|
||||
return trophies_total
|
||||
if subset is not None:
|
||||
raise ValueError('invalid subset value: ' + str(subset))
|
||||
|
||||
if data['p']:
|
||||
pro_mult = 1.0 + float(
|
||||
_ba.get_account_misc_read_val('proPowerRankingBoost',
|
||||
0.0)) * 0.01
|
||||
else:
|
||||
pro_mult = 1.0
|
||||
|
||||
# For final value, apply our pro mult and activeness-mult.
|
||||
return int(
|
||||
(total_ach_value + trophies_total) *
|
||||
(data['act'] if data['act'] is not None else 1.0) * pro_mult)
|
||||
|
||||
def cache_tournament_info(self, info: Any) -> None:
|
||||
"""(internal)"""
|
||||
from ba._generated.enums import TimeType, TimeFormat
|
||||
for entry in info:
|
||||
cache_entry = self.tournament_info[entry['tournamentID']] = (
|
||||
copy.deepcopy(entry))
|
||||
|
||||
# Also store the time we received this, so we can adjust
|
||||
# time-remaining values/etc.
|
||||
cache_entry['timeReceived'] = _ba.time(TimeType.REAL,
|
||||
TimeFormat.MILLISECONDS)
|
||||
cache_entry['valid'] = True
|
||||
|
||||
def get_purchased_icons(self) -> list[str]:
|
||||
"""(internal)"""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba import _store
|
||||
if _ba.get_account_state() != 'signed_in':
|
||||
return []
|
||||
icons = []
|
||||
store_items = _store.get_store_items()
|
||||
for item_name, item in list(store_items.items()):
|
||||
if item_name.startswith('icons.') and _ba.get_purchased(item_name):
|
||||
icons.append(item['icon'])
|
||||
return icons
|
||||
|
||||
def ensure_have_account_player_profile(self) -> None:
|
||||
"""
|
||||
Ensure the standard account-named player profile exists;
|
||||
creating if needed.
|
||||
|
||||
(internal)
|
||||
"""
|
||||
# This only applies when we're signed in.
|
||||
if _ba.get_account_state() != 'signed_in':
|
||||
return
|
||||
|
||||
# If the short version of our account name currently cant be
|
||||
# displayed by the game, cancel.
|
||||
if not _ba.have_chars(_ba.get_account_display_string(full=False)):
|
||||
return
|
||||
|
||||
config = _ba.app.config
|
||||
if ('Player Profiles' not in config
|
||||
or '__account__' not in config['Player Profiles']):
|
||||
|
||||
# Create a spaz with a nice default purply color.
|
||||
_ba.add_transaction({
|
||||
'type': 'ADD_PLAYER_PROFILE',
|
||||
'name': '__account__',
|
||||
'profile': {
|
||||
'character': 'Spaz',
|
||||
'color': [0.5, 0.25, 1.0],
|
||||
'highlight': [0.5, 0.25, 1.0]
|
||||
}
|
||||
})
|
||||
_ba.run_transactions()
|
||||
|
||||
def have_pro(self) -> bool:
|
||||
"""Return whether pro is currently unlocked."""
|
||||
|
||||
# Check our tickets-based pro upgrade and our two real-IAP based
|
||||
# upgrades. Also always unlock this stuff in ballistica-core builds.
|
||||
return bool(
|
||||
_ba.get_purchased('upgrades.pro')
|
||||
or _ba.get_purchased('static.pro')
|
||||
or _ba.get_purchased('static.pro_sale')
|
||||
or 'ballistica' + 'core' == _ba.appname())
|
||||
|
||||
def have_pro_options(self) -> bool:
|
||||
"""Return whether pro-options are present.
|
||||
|
||||
This is True for owners of Pro or for old installs
|
||||
before Pro was a requirement for these options.
|
||||
"""
|
||||
|
||||
# We expose pro options if the server tells us to
|
||||
# (which is generally just when we own pro),
|
||||
# or also if we've been grandfathered in or are using ballistica-core
|
||||
# builds.
|
||||
return self.have_pro() or bool(
|
||||
_ba.get_account_misc_read_val_2('proOptionsUnlocked', False)
|
||||
or _ba.app.config.get('lc14292', 0) > 1)
|
||||
|
||||
def show_post_purchase_message(self) -> None:
|
||||
"""(internal)"""
|
||||
from ba._language import Lstr
|
||||
from ba._generated.enums import TimeType
|
||||
cur_time = _ba.time(TimeType.REAL)
|
||||
if (self.last_post_purchase_message_time is None
|
||||
or cur_time - self.last_post_purchase_message_time > 3.0):
|
||||
self.last_post_purchase_message_time = cur_time
|
||||
with _ba.Context('ui'):
|
||||
_ba.screenmessage(Lstr(resource='updatingAccountText',
|
||||
fallback_resource='purchasingText'),
|
||||
color=(0, 1, 0))
|
||||
_ba.playsound(_ba.getsound('click01'))
|
||||
|
||||
def on_account_state_changed(self) -> None:
|
||||
"""(internal)"""
|
||||
from ba._language import Lstr
|
||||
|
||||
# Run any pending promo codes we had queued up while not signed in.
|
||||
if _ba.get_account_state() == 'signed_in' and self.pending_promo_codes:
|
||||
for code in self.pending_promo_codes:
|
||||
_ba.screenmessage(Lstr(resource='submittingPromoCodeText'),
|
||||
color=(0, 1, 0))
|
||||
_ba.add_transaction({
|
||||
'type': 'PROMO_CODE',
|
||||
'expire_time': time.time() + 5,
|
||||
'code': code
|
||||
})
|
||||
_ba.run_transactions()
|
||||
self.pending_promo_codes = []
|
||||
|
||||
def add_pending_promo_code(self, code: str) -> None:
|
||||
"""(internal)"""
|
||||
from ba._language import Lstr
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
# If we're not signed in, queue up the code to run the next time we
|
||||
# are and issue a warning if we haven't signed in within the next
|
||||
# few seconds.
|
||||
if _ba.get_account_state() != 'signed_in':
|
||||
|
||||
def check_pending_codes() -> None:
|
||||
"""(internal)"""
|
||||
|
||||
# If we're still not signed in and have pending codes,
|
||||
# inform the user that they need to sign in to use them.
|
||||
if self.pending_promo_codes:
|
||||
_ba.screenmessage(Lstr(resource='signInForPromoCodeText'),
|
||||
color=(1, 0, 0))
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
|
||||
self.pending_promo_codes.append(code)
|
||||
_ba.timer(6.0, check_pending_codes, timetype=TimeType.REAL)
|
||||
return
|
||||
_ba.screenmessage(Lstr(resource='submittingPromoCodeText'),
|
||||
color=(0, 1, 0))
|
||||
_ba.add_transaction({
|
||||
'type': 'PROMO_CODE',
|
||||
'expire_time': time.time() + 5,
|
||||
'code': code
|
||||
})
|
||||
_ba.run_transactions()
|
||||
322
dist/ba_data/python/ba/_accountv1.py
vendored
Normal file
322
dist/ba_data/python/ba/_accountv1.py
vendored
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Account related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba import _internal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AccountV1Subsystem:
|
||||
"""Subsystem for legacy account handling in the app.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
Access the single shared instance of this class at 'ba.app.accounts_v1'.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.account_tournament_list: tuple[int, list[str]] | None = None
|
||||
|
||||
# FIXME: should abstract/structure these.
|
||||
self.tournament_info: dict = {}
|
||||
self.league_rank_cache: dict = {}
|
||||
self.last_post_purchase_message_time: float | None = None
|
||||
|
||||
# If we try to run promo-codes due to launch-args/etc we might
|
||||
# not be signed in yet; go ahead and queue them up in that case.
|
||||
self.pending_promo_codes: list[str] = []
|
||||
|
||||
def on_app_launch(self) -> None:
|
||||
"""Called when the app is done bootstrapping."""
|
||||
|
||||
# Auto-sign-in to a local account in a moment if we're set to.
|
||||
def do_auto_sign_in() -> None:
|
||||
if (
|
||||
_ba.app.headless_mode
|
||||
or _ba.app.config.get('Auto Account State') == 'Local'
|
||||
):
|
||||
_internal.sign_in_v1('Local')
|
||||
|
||||
_ba.pushcall(do_auto_sign_in)
|
||||
|
||||
def on_app_pause(self) -> None:
|
||||
"""Should be called when app is pausing."""
|
||||
|
||||
def on_app_resume(self) -> None:
|
||||
"""Should be called when the app is resumed."""
|
||||
|
||||
# Mark our cached tourneys as invalid so anyone using them knows
|
||||
# they might be out of date.
|
||||
for entry in list(self.tournament_info.values()):
|
||||
entry['valid'] = False
|
||||
|
||||
def handle_account_gained_tickets(self, count: int) -> None:
|
||||
"""Called when the current account has been awarded tickets.
|
||||
|
||||
(internal)
|
||||
"""
|
||||
from ba._language import Lstr
|
||||
|
||||
_ba.screenmessage(
|
||||
Lstr(
|
||||
resource='getTicketsWindow.receivedTicketsText',
|
||||
subs=[('${COUNT}', str(count))],
|
||||
),
|
||||
color=(0, 1, 0),
|
||||
)
|
||||
_ba.playsound(_ba.getsound('cashRegister'))
|
||||
|
||||
def cache_league_rank_data(self, data: Any) -> None:
|
||||
"""(internal)"""
|
||||
self.league_rank_cache['info'] = copy.deepcopy(data)
|
||||
|
||||
def get_cached_league_rank_data(self) -> Any:
|
||||
"""(internal)"""
|
||||
return self.league_rank_cache.get('info', None)
|
||||
|
||||
def get_league_rank_points(
|
||||
self, data: dict[str, Any] | None, subset: str | None = None
|
||||
) -> int:
|
||||
"""(internal)"""
|
||||
if data is None:
|
||||
return 0
|
||||
|
||||
# If the data contains an achievement total, use that. otherwise calc
|
||||
# locally.
|
||||
if data['at'] is not None:
|
||||
total_ach_value = data['at']
|
||||
else:
|
||||
total_ach_value = 0
|
||||
for ach in _ba.app.ach.achievements:
|
||||
if ach.complete:
|
||||
total_ach_value += ach.power_ranking_value
|
||||
|
||||
trophies_total: int = (
|
||||
data['t0a'] * data['t0am']
|
||||
+ data['t0b'] * data['t0bm']
|
||||
+ data['t1'] * data['t1m']
|
||||
+ data['t2'] * data['t2m']
|
||||
+ data['t3'] * data['t3m']
|
||||
+ data['t4'] * data['t4m']
|
||||
)
|
||||
if subset == 'trophyCount':
|
||||
val: int = (
|
||||
data['t0a']
|
||||
+ data['t0b']
|
||||
+ data['t1']
|
||||
+ data['t2']
|
||||
+ data['t3']
|
||||
+ data['t4']
|
||||
)
|
||||
assert isinstance(val, int)
|
||||
return val
|
||||
if subset == 'trophies':
|
||||
assert isinstance(trophies_total, int)
|
||||
return trophies_total
|
||||
if subset is not None:
|
||||
raise ValueError('invalid subset value: ' + str(subset))
|
||||
|
||||
if data['p']:
|
||||
pro_mult = (
|
||||
1.0
|
||||
+ float(
|
||||
_internal.get_v1_account_misc_read_val(
|
||||
'proPowerRankingBoost', 0.0
|
||||
)
|
||||
)
|
||||
* 0.01
|
||||
)
|
||||
else:
|
||||
pro_mult = 1.0
|
||||
|
||||
# For final value, apply our pro mult and activeness-mult.
|
||||
return int(
|
||||
(total_ach_value + trophies_total)
|
||||
* (data['act'] if data['act'] is not None else 1.0)
|
||||
* pro_mult
|
||||
)
|
||||
|
||||
def cache_tournament_info(self, info: Any) -> None:
|
||||
"""(internal)"""
|
||||
from ba._generated.enums import TimeType, TimeFormat
|
||||
|
||||
for entry in info:
|
||||
cache_entry = self.tournament_info[
|
||||
entry['tournamentID']
|
||||
] = copy.deepcopy(entry)
|
||||
|
||||
# Also store the time we received this, so we can adjust
|
||||
# time-remaining values/etc.
|
||||
cache_entry['timeReceived'] = _ba.time(
|
||||
TimeType.REAL, TimeFormat.MILLISECONDS
|
||||
)
|
||||
cache_entry['valid'] = True
|
||||
|
||||
def get_purchased_icons(self) -> list[str]:
|
||||
"""(internal)"""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba import _store
|
||||
|
||||
if _internal.get_v1_account_state() != 'signed_in':
|
||||
return []
|
||||
icons = []
|
||||
store_items = _store.get_store_items()
|
||||
for item_name, item in list(store_items.items()):
|
||||
if item_name.startswith('icons.') and _internal.get_purchased(
|
||||
item_name
|
||||
):
|
||||
icons.append(item['icon'])
|
||||
return icons
|
||||
|
||||
def ensure_have_account_player_profile(self) -> None:
|
||||
"""
|
||||
Ensure the standard account-named player profile exists;
|
||||
creating if needed.
|
||||
|
||||
(internal)
|
||||
"""
|
||||
# This only applies when we're signed in.
|
||||
if _internal.get_v1_account_state() != 'signed_in':
|
||||
return
|
||||
|
||||
# If the short version of our account name currently cant be
|
||||
# displayed by the game, cancel.
|
||||
if not _ba.have_chars(
|
||||
_internal.get_v1_account_display_string(full=False)
|
||||
):
|
||||
return
|
||||
|
||||
config = _ba.app.config
|
||||
if (
|
||||
'Player Profiles' not in config
|
||||
or '__account__' not in config['Player Profiles']
|
||||
):
|
||||
|
||||
# Create a spaz with a nice default purply color.
|
||||
_internal.add_transaction(
|
||||
{
|
||||
'type': 'ADD_PLAYER_PROFILE',
|
||||
'name': '__account__',
|
||||
'profile': {
|
||||
'character': 'Spaz',
|
||||
'color': [0.5, 0.25, 1.0],
|
||||
'highlight': [0.5, 0.25, 1.0],
|
||||
},
|
||||
}
|
||||
)
|
||||
_internal.run_transactions()
|
||||
|
||||
def have_pro(self) -> bool:
|
||||
"""Return whether pro is currently unlocked."""
|
||||
|
||||
# Check our tickets-based pro upgrade and our two real-IAP based
|
||||
# upgrades. Also always unlock this stuff in ballistica-core builds.
|
||||
return bool(
|
||||
_internal.get_purchased('upgrades.pro')
|
||||
or _internal.get_purchased('static.pro')
|
||||
or _internal.get_purchased('static.pro_sale')
|
||||
or 'ballistica' + 'core' == _ba.appname()
|
||||
)
|
||||
|
||||
def have_pro_options(self) -> bool:
|
||||
"""Return whether pro-options are present.
|
||||
|
||||
This is True for owners of Pro or for old installs
|
||||
before Pro was a requirement for these options.
|
||||
"""
|
||||
|
||||
# We expose pro options if the server tells us to
|
||||
# (which is generally just when we own pro),
|
||||
# or also if we've been grandfathered in
|
||||
# or are using ballistica-core builds.
|
||||
return self.have_pro() or bool(
|
||||
_internal.get_v1_account_misc_read_val_2(
|
||||
'proOptionsUnlocked', False
|
||||
)
|
||||
or _ba.app.config.get('lc14292', 0) > 1
|
||||
)
|
||||
|
||||
def show_post_purchase_message(self) -> None:
|
||||
"""(internal)"""
|
||||
from ba._language import Lstr
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
cur_time = _ba.time(TimeType.REAL)
|
||||
if (
|
||||
self.last_post_purchase_message_time is None
|
||||
or cur_time - self.last_post_purchase_message_time > 3.0
|
||||
):
|
||||
self.last_post_purchase_message_time = cur_time
|
||||
with _ba.Context('ui'):
|
||||
_ba.screenmessage(
|
||||
Lstr(
|
||||
resource='updatingAccountText',
|
||||
fallback_resource='purchasingText',
|
||||
),
|
||||
color=(0, 1, 0),
|
||||
)
|
||||
_ba.playsound(_ba.getsound('click01'))
|
||||
|
||||
def on_account_state_changed(self) -> None:
|
||||
"""(internal)"""
|
||||
from ba._language import Lstr
|
||||
|
||||
# Run any pending promo codes we had queued up while not signed in.
|
||||
if (
|
||||
_internal.get_v1_account_state() == 'signed_in'
|
||||
and self.pending_promo_codes
|
||||
):
|
||||
for code in self.pending_promo_codes:
|
||||
_ba.screenmessage(
|
||||
Lstr(resource='submittingPromoCodeText'), color=(0, 1, 0)
|
||||
)
|
||||
_internal.add_transaction(
|
||||
{
|
||||
'type': 'PROMO_CODE',
|
||||
'expire_time': time.time() + 5,
|
||||
'code': code,
|
||||
}
|
||||
)
|
||||
_internal.run_transactions()
|
||||
self.pending_promo_codes = []
|
||||
|
||||
def add_pending_promo_code(self, code: str) -> None:
|
||||
"""(internal)"""
|
||||
from ba._language import Lstr
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
# If we're not signed in, queue up the code to run the next time we
|
||||
# are and issue a warning if we haven't signed in within the next
|
||||
# few seconds.
|
||||
if _internal.get_v1_account_state() != 'signed_in':
|
||||
|
||||
def check_pending_codes() -> None:
|
||||
"""(internal)"""
|
||||
|
||||
# If we're still not signed in and have pending codes,
|
||||
# inform the user that they need to sign in to use them.
|
||||
if self.pending_promo_codes:
|
||||
_ba.screenmessage(
|
||||
Lstr(resource='signInForPromoCodeText'), color=(1, 0, 0)
|
||||
)
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
|
||||
self.pending_promo_codes.append(code)
|
||||
_ba.timer(6.0, check_pending_codes, timetype=TimeType.REAL)
|
||||
return
|
||||
_ba.screenmessage(
|
||||
Lstr(resource='submittingPromoCodeText'), color=(0, 1, 0)
|
||||
)
|
||||
_internal.add_transaction(
|
||||
{'type': 'PROMO_CODE', 'expire_time': time.time() + 5, 'code': code}
|
||||
)
|
||||
_internal.run_transactions()
|
||||
432
dist/ba_data/python/ba/_accountv2.py
vendored
Normal file
432
dist/ba_data/python/ba/_accountv2.py
vendored
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Account related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from efro.call import tpartial
|
||||
from efro.error import CommunicationError
|
||||
from bacommon.login import LoginType
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
from ba._login import LoginAdapter
|
||||
|
||||
|
||||
DEBUG_LOG = False
|
||||
|
||||
|
||||
class AccountV2Subsystem:
|
||||
"""Subsystem for modern account handling in the app.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
Access the single shared instance of this class at 'ba.app.accounts_v2'.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
# Whether or not everything related to an initial login
|
||||
# (or lack thereof) has completed. This includes things like
|
||||
# workspace syncing. Completion of this is what flips the app
|
||||
# into 'running' state.
|
||||
self._initial_sign_in_completed = False
|
||||
|
||||
self._kicked_off_workspace_load = False
|
||||
|
||||
self.login_adapters: dict[LoginType, LoginAdapter] = {}
|
||||
|
||||
self._implicit_signed_in_adapter: LoginAdapter | None = None
|
||||
self._implicit_state_changed = False
|
||||
self._can_do_auto_sign_in = True
|
||||
|
||||
if _ba.app.platform == 'android' and _ba.app.subplatform == 'google':
|
||||
from ba._login import LoginAdapterGPGS
|
||||
|
||||
self.login_adapters[LoginType.GPGS] = LoginAdapterGPGS()
|
||||
|
||||
def on_app_launch(self) -> None:
|
||||
"""Should be called at standard on_app_launch time."""
|
||||
|
||||
for adapter in self.login_adapters.values():
|
||||
adapter.on_app_launch()
|
||||
|
||||
def set_primary_credentials(self, credentials: str | None) -> None:
|
||||
"""Set credentials for the primary app account."""
|
||||
raise RuntimeError('This should be overridden.')
|
||||
|
||||
def have_primary_credentials(self) -> bool:
|
||||
"""Are credentials currently set for the primary app account?
|
||||
|
||||
Note that this does not mean these credentials are currently valid;
|
||||
only that they exist. If/when credentials are validated, the 'primary'
|
||||
account handle will be set.
|
||||
"""
|
||||
raise RuntimeError('This should be overridden.')
|
||||
|
||||
@property
|
||||
def primary(self) -> AccountV2Handle | None:
|
||||
"""The primary account for the app, or None if not logged in."""
|
||||
return self.do_get_primary()
|
||||
|
||||
def do_get_primary(self) -> AccountV2Handle | None:
|
||||
"""Internal - should be overridden by subclass."""
|
||||
return None
|
||||
|
||||
def on_primary_account_changed(
|
||||
self, account: AccountV2Handle | None
|
||||
) -> None:
|
||||
"""Callback run after the primary account changes.
|
||||
|
||||
Will be called with None on log-outs and when new credentials
|
||||
are set but have not yet been verified.
|
||||
"""
|
||||
assert _ba.in_logic_thread()
|
||||
|
||||
# Currently don't do anything special on sign-outs.
|
||||
if account is None:
|
||||
return
|
||||
|
||||
# If this new account has a workspace, update it and ask to be
|
||||
# informed when that process completes.
|
||||
if account.workspaceid is not None:
|
||||
assert account.workspacename is not None
|
||||
if (
|
||||
not self._initial_sign_in_completed
|
||||
and not self._kicked_off_workspace_load
|
||||
):
|
||||
self._kicked_off_workspace_load = True
|
||||
_ba.app.workspaces.set_active_workspace(
|
||||
account=account,
|
||||
workspaceid=account.workspaceid,
|
||||
workspacename=account.workspacename,
|
||||
on_completed=self._on_set_active_workspace_completed,
|
||||
)
|
||||
else:
|
||||
# Don't activate workspaces if we've already told the game
|
||||
# that initial-log-in is done or if we've already kicked
|
||||
# off a workspace load.
|
||||
_ba.screenmessage(
|
||||
f'\'{account.workspacename}\''
|
||||
f' will be activated at next app launch.',
|
||||
color=(1, 1, 0),
|
||||
)
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
return
|
||||
|
||||
# Ok; no workspace to worry about; carry on.
|
||||
if not self._initial_sign_in_completed:
|
||||
self._initial_sign_in_completed = True
|
||||
_ba.app.on_initial_sign_in_completed()
|
||||
|
||||
def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None:
|
||||
"""Should be called when logins for the active account change."""
|
||||
|
||||
for adapter in self.login_adapters.values():
|
||||
adapter.set_active_logins(logins)
|
||||
|
||||
def on_implicit_sign_in(
|
||||
self, login_type: LoginType, login_id: str, display_name: str
|
||||
) -> None:
|
||||
"""An implicit sign-in happened (called by native layer)."""
|
||||
from ba._login import LoginAdapter
|
||||
|
||||
with _ba.Context('ui'):
|
||||
self.login_adapters[login_type].set_implicit_login_state(
|
||||
LoginAdapter.ImplicitLoginState(
|
||||
login_id=login_id, display_name=display_name
|
||||
)
|
||||
)
|
||||
|
||||
def on_implicit_sign_out(self, login_type: LoginType) -> None:
|
||||
"""An implicit sign-out happened (called by native layer)."""
|
||||
with _ba.Context('ui'):
|
||||
self.login_adapters[login_type].set_implicit_login_state(None)
|
||||
|
||||
def on_no_initial_primary_account(self) -> None:
|
||||
"""Callback run if the app has no primary account after launch.
|
||||
|
||||
Either this callback or on_primary_account_changed will be called
|
||||
within a few seconds of app launch; the app can move forward
|
||||
with the startup sequence at that point.
|
||||
"""
|
||||
if not self._initial_sign_in_completed:
|
||||
self._initial_sign_in_completed = True
|
||||
_ba.app.on_initial_sign_in_completed()
|
||||
|
||||
@staticmethod
|
||||
def _hashstr(val: str) -> str:
|
||||
md5 = hashlib.md5()
|
||||
md5.update(val.encode())
|
||||
return md5.hexdigest()
|
||||
|
||||
def on_implicit_login_state_changed(
|
||||
self,
|
||||
login_type: LoginType,
|
||||
state: LoginAdapter.ImplicitLoginState | None,
|
||||
) -> None:
|
||||
"""Called when implicit login state changes.
|
||||
|
||||
Login systems that tend to sign themselves in/out in the
|
||||
background are considered implicit. We may choose to honor or
|
||||
ignore their states, allowing the user to opt for other login
|
||||
types even if the default implicit one can't be explicitly
|
||||
logged out or otherwise controlled.
|
||||
"""
|
||||
from ba._language import Lstr
|
||||
|
||||
assert _ba.in_logic_thread()
|
||||
|
||||
cfg = _ba.app.config
|
||||
cfgkey = 'ImplicitLoginStates'
|
||||
cfgdict = _ba.app.config.setdefault(cfgkey, {})
|
||||
|
||||
# Store which (if any) adapter is currently implicitly signed in.
|
||||
# Making the assumption there will only ever be one implicit
|
||||
# adapter at a time; may need to update this if that changes.
|
||||
prev_state = cfgdict.get(login_type.value)
|
||||
if state is None:
|
||||
self._implicit_signed_in_adapter = None
|
||||
new_state = cfgdict[login_type.value] = None
|
||||
else:
|
||||
self._implicit_signed_in_adapter = self.login_adapters[login_type]
|
||||
new_state = cfgdict[login_type.value] = self._hashstr(
|
||||
state.login_id
|
||||
)
|
||||
|
||||
# Special case: if the user is already signed in but not with
|
||||
# this implicit login, we may want to let them know that the
|
||||
# 'Welcome back FOO' they likely just saw is not actually
|
||||
# accurate.
|
||||
if (
|
||||
self.primary is not None
|
||||
and not self.login_adapters[login_type].is_back_end_active()
|
||||
):
|
||||
if login_type is LoginType.GPGS:
|
||||
service_str = Lstr(resource='googlePlayText')
|
||||
else:
|
||||
service_str = None
|
||||
if service_str is not None:
|
||||
_ba.timer(
|
||||
2.0,
|
||||
tpartial(
|
||||
_ba.screenmessage,
|
||||
Lstr(
|
||||
resource='notUsingAccountText',
|
||||
subs=[
|
||||
('${ACCOUNT}', state.display_name),
|
||||
('${SERVICE}', service_str),
|
||||
],
|
||||
),
|
||||
(1, 0.5, 0),
|
||||
),
|
||||
)
|
||||
|
||||
cfg.commit()
|
||||
|
||||
# We want to respond any time the implicit state changes;
|
||||
# generally this means the user has explicitly signed in/out or
|
||||
# switched accounts within that back-end.
|
||||
if prev_state != new_state:
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'AccountV2: Implicit state changed (%s -> %s);'
|
||||
' will update app sign-in state accordingly.',
|
||||
prev_state,
|
||||
new_state,
|
||||
)
|
||||
self._implicit_state_changed = True
|
||||
|
||||
# We may want to auto-sign-in based on this new state.
|
||||
self._update_auto_sign_in()
|
||||
|
||||
def on_cloud_connectivity_changed(self, connected: bool) -> None:
|
||||
"""Should be called with cloud connectivity changes."""
|
||||
del connected # Unused.
|
||||
assert _ba.in_logic_thread()
|
||||
|
||||
# We may want to auto-sign-in based on this new state.
|
||||
self._update_auto_sign_in()
|
||||
|
||||
def _update_auto_sign_in(self) -> None:
|
||||
from ba._internal import get_v1_account_state
|
||||
|
||||
# If implicit state has changed, try to respond.
|
||||
if self._implicit_state_changed:
|
||||
if self._implicit_signed_in_adapter is None:
|
||||
# If implicit back-end is signed out, follow suit
|
||||
# immediately; no need to wait for network connectivity.
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'AccountV2: Signing out as result'
|
||||
' of implicit state change...',
|
||||
)
|
||||
_ba.app.accounts_v2.set_primary_credentials(None)
|
||||
self._implicit_state_changed = False
|
||||
|
||||
# Once we've made a move here we don't want to
|
||||
# do any more automatic stuff.
|
||||
self._can_do_auto_sign_in = False
|
||||
|
||||
else:
|
||||
# Ok; we've got a new implicit state. If we've got
|
||||
# connectivity, let's attempt to sign in with it.
|
||||
# Consider this an 'explicit' sign in because the
|
||||
# implicit-login state change presumably was triggered
|
||||
# by some user action (signing in, signing out, or
|
||||
# switching accounts via the back-end).
|
||||
# NOTE: should test case where we don't have
|
||||
# connectivity here.
|
||||
if _ba.app.cloud.is_connected():
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'AccountV2: Signing in as result'
|
||||
' of implicit state change...',
|
||||
)
|
||||
self._implicit_signed_in_adapter.sign_in(
|
||||
self._on_explicit_sign_in_completed,
|
||||
description='implicit state change',
|
||||
)
|
||||
self._implicit_state_changed = False
|
||||
|
||||
# Once we've made a move here we don't want to
|
||||
# do any more automatic stuff.
|
||||
self._can_do_auto_sign_in = False
|
||||
|
||||
if not self._can_do_auto_sign_in:
|
||||
return
|
||||
|
||||
# If we're not currently signed in, we have connectivity, and
|
||||
# we have an available implicit login, auto-sign-in with it once.
|
||||
# The implicit-state-change logic above should keep things
|
||||
# mostly in-sync, but that might not always be the case due to
|
||||
# connectivity or other issues. We prefer to keep people signed
|
||||
# in as a rule, even if there are corner cases where this might
|
||||
# not be what they want (A user signing out and then restarting
|
||||
# may be auto-signed back in).
|
||||
connected = _ba.app.cloud.is_connected()
|
||||
signed_in_v1 = get_v1_account_state() == 'signed_in'
|
||||
signed_in_v2 = _ba.app.accounts_v2.have_primary_credentials()
|
||||
if (
|
||||
connected
|
||||
and not signed_in_v1
|
||||
and not signed_in_v2
|
||||
and self._implicit_signed_in_adapter is not None
|
||||
):
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'AccountV2: Signing in due to on-launch-auto-sign-in...',
|
||||
)
|
||||
self._can_do_auto_sign_in = False # Only ATTEMPT once
|
||||
self._implicit_signed_in_adapter.sign_in(
|
||||
self._on_implicit_sign_in_completed, description='auto-sign-in'
|
||||
)
|
||||
|
||||
def _on_explicit_sign_in_completed(
|
||||
self,
|
||||
adapter: LoginAdapter,
|
||||
result: LoginAdapter.SignInResult | Exception,
|
||||
) -> None:
|
||||
"""A sign-in has completed that the user asked for explicitly."""
|
||||
from ba._language import Lstr
|
||||
|
||||
del adapter # Unused.
|
||||
|
||||
# Make some noise on errors since the user knows a
|
||||
# sign-in attempt is happening in this case (the 'explicit' part).
|
||||
if isinstance(result, Exception):
|
||||
# We expect the occasional communication errors;
|
||||
# Log a full exception for anything else though.
|
||||
if not isinstance(result, CommunicationError):
|
||||
logging.warning(
|
||||
'Error on explicit accountv2 sign in attempt.',
|
||||
exc_info=result,
|
||||
)
|
||||
|
||||
# For now just show 'error'. Should do better than this.
|
||||
with _ba.Context('ui'):
|
||||
_ba.screenmessage(
|
||||
Lstr(resource='internal.signInErrorText'),
|
||||
color=(1, 0, 0),
|
||||
)
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
|
||||
# Also I suppose we should sign them out in this case since
|
||||
# it could be misleading to be still signed in with the old
|
||||
# account.
|
||||
_ba.app.accounts_v2.set_primary_credentials(None)
|
||||
return
|
||||
|
||||
_ba.app.accounts_v2.set_primary_credentials(result.credentials)
|
||||
|
||||
def _on_implicit_sign_in_completed(
|
||||
self,
|
||||
adapter: LoginAdapter,
|
||||
result: LoginAdapter.SignInResult | Exception,
|
||||
) -> None:
|
||||
"""A sign-in has completed that the user didn't ask for explicitly."""
|
||||
from ba._internal import get_v1_account_state
|
||||
|
||||
del adapter # Unused.
|
||||
|
||||
# Log errors but don't inform the user; they're not aware of this
|
||||
# attempt and ignorance is bliss.
|
||||
if isinstance(result, Exception):
|
||||
# We expect the occasional communication errors;
|
||||
# Log a full exception for anything else though.
|
||||
if not isinstance(result, CommunicationError):
|
||||
logging.warning(
|
||||
'Error on implicit accountv2 sign in attempt.',
|
||||
exc_info=result,
|
||||
)
|
||||
return
|
||||
|
||||
# If we're still connected and still not signed in,
|
||||
# plug in the credentials we got. We want to be extra cautious
|
||||
# in case the user has since explicitly signed in since we
|
||||
# kicked off.
|
||||
connected = _ba.app.cloud.is_connected()
|
||||
signed_in_v1 = get_v1_account_state() == 'signed_in'
|
||||
signed_in_v2 = _ba.app.accounts_v2.have_primary_credentials()
|
||||
if connected and not signed_in_v1 and not signed_in_v2:
|
||||
_ba.app.accounts_v2.set_primary_credentials(result.credentials)
|
||||
|
||||
def _on_set_active_workspace_completed(self) -> None:
|
||||
if not self._initial_sign_in_completed:
|
||||
self._initial_sign_in_completed = True
|
||||
_ba.app.on_initial_sign_in_completed()
|
||||
|
||||
|
||||
class AccountV2Handle:
|
||||
"""Handle for interacting with a V2 account.
|
||||
|
||||
This class supports the 'with' statement, which is how it is
|
||||
used with some operations such as cloud messaging.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.tag = '?'
|
||||
|
||||
self.workspacename: str | None = None
|
||||
self.workspaceid: str | None = None
|
||||
|
||||
# Login types and their display-names associated with this account.
|
||||
self.logins: dict[LoginType, str] = {}
|
||||
|
||||
def __enter__(self) -> None:
|
||||
"""Support for "with" statement.
|
||||
|
||||
This allows cloud messages to be sent on our behalf.
|
||||
"""
|
||||
|
||||
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any:
|
||||
"""Support for "with" statement.
|
||||
|
||||
This allows cloud messages to be sent on our behalf.
|
||||
"""
|
||||
1527
dist/ba_data/python/ba/_achievement.py
vendored
Normal file
1527
dist/ba_data/python/ba/_achievement.py
vendored
Normal file
File diff suppressed because it is too large
Load diff
886
dist/ba_data/python/ba/_activity.py
vendored
Normal file
886
dist/ba_data/python/ba/_activity.py
vendored
Normal file
|
|
@ -0,0 +1,886 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Defines Activity class."""
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING, Generic, TypeVar
|
||||
|
||||
import _ba
|
||||
from ba._team import Team
|
||||
from ba._player import Player
|
||||
from ba._error import (
|
||||
print_exception,
|
||||
SessionTeamNotFoundError,
|
||||
SessionPlayerNotFoundError,
|
||||
NodeNotFoundError,
|
||||
)
|
||||
from ba._dependency import DependencyComponent
|
||||
from ba._general import Call, verify_object_death
|
||||
from ba._messages import UNHANDLED
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
import ba
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
PlayerType = TypeVar('PlayerType', bound=Player)
|
||||
TeamType = TypeVar('TeamType', bound=Team)
|
||||
# pylint: enable=invalid-name
|
||||
|
||||
|
||||
class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
|
||||
"""Units of execution wrangled by a ba.Session.
|
||||
|
||||
Category: Gameplay Classes
|
||||
|
||||
Examples of Activities include games, score-screens, cutscenes, etc.
|
||||
A ba.Session has one 'current' Activity at any time, though their existence
|
||||
can overlap during transitions.
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
settings_raw: dict[str, Any]
|
||||
"""The settings dict passed in when the activity was made.
|
||||
This attribute is deprecated and should be avoided when possible;
|
||||
activities should pull all values they need from the 'settings' arg
|
||||
passed to the Activity __init__ call."""
|
||||
|
||||
teams: list[TeamType]
|
||||
"""The list of ba.Team-s in the Activity. This gets populated just
|
||||
before on_begin() is called and is updated automatically as players
|
||||
join or leave the game. (at least in free-for-all mode where every
|
||||
player gets their own team; in teams mode there are always 2 teams
|
||||
regardless of the player count)."""
|
||||
|
||||
players: list[PlayerType]
|
||||
"""The list of ba.Player-s in the Activity. This gets populated just
|
||||
before on_begin() is called and is updated automatically as players
|
||||
join or leave the game."""
|
||||
|
||||
announce_player_deaths = False
|
||||
"""Whether to print every time a player dies. This can be pertinent
|
||||
in games such as Death-Match but can be annoying in games where it
|
||||
doesn't matter."""
|
||||
|
||||
is_joining_activity = False
|
||||
"""Joining activities are for waiting for initial player joins.
|
||||
They are treated slightly differently than regular activities,
|
||||
mainly in that all players are passed to the activity at once
|
||||
instead of as each joins."""
|
||||
|
||||
allow_pausing = False
|
||||
"""Whether game-time should still progress when in menus/etc."""
|
||||
|
||||
allow_kick_idle_players = True
|
||||
"""Whether idle players can potentially be kicked (should not happen in
|
||||
menus/etc)."""
|
||||
|
||||
use_fixed_vr_overlay = False
|
||||
"""In vr mode, this determines whether overlay nodes (text, images, etc)
|
||||
are created at a fixed position in space or one that moves based on
|
||||
the current map. Generally this should be on for games and off for
|
||||
transitions/score-screens/etc. that persist between maps."""
|
||||
|
||||
slow_motion = False
|
||||
"""If True, runs in slow motion and turns down sound pitch."""
|
||||
|
||||
inherits_slow_motion = False
|
||||
"""Set this to True to inherit slow motion setting from previous
|
||||
activity (useful for transitions to avoid hitches)."""
|
||||
|
||||
inherits_music = False
|
||||
"""Set this to True to keep playing the music from the previous activity
|
||||
(without even restarting it)."""
|
||||
|
||||
inherits_vr_camera_offset = False
|
||||
"""Set this to true to inherit VR camera offsets from the previous
|
||||
activity (useful for preventing sporadic camera movement
|
||||
during transitions)."""
|
||||
|
||||
inherits_vr_overlay_center = False
|
||||
"""Set this to true to inherit (non-fixed) VR overlay positioning from
|
||||
the previous activity (useful for prevent sporadic overlay jostling
|
||||
during transitions)."""
|
||||
|
||||
inherits_tint = False
|
||||
"""Set this to true to inherit screen tint/vignette colors from the
|
||||
previous activity (useful to prevent sudden color changes during
|
||||
transitions)."""
|
||||
|
||||
allow_mid_activity_joins: bool = True
|
||||
"""Whether players should be allowed to join in the middle of this
|
||||
activity. Note that Sessions may not allow mid-activity-joins even
|
||||
if the activity says its ok."""
|
||||
|
||||
transition_time = 0.0
|
||||
"""If the activity fades or transitions in, it should set the length of
|
||||
time here so that previous activities will be kept alive for that
|
||||
long (avoiding 'holes' in the screen)
|
||||
This value is given in real-time seconds."""
|
||||
|
||||
can_show_ad_on_death = False
|
||||
"""Is it ok to show an ad after this activity ends before showing
|
||||
the next activity?"""
|
||||
|
||||
def __init__(self, settings: dict):
|
||||
"""Creates an Activity in the current ba.Session.
|
||||
|
||||
The activity will not be actually run until ba.Session.setactivity
|
||||
is called. 'settings' should be a dict of key/value pairs specific
|
||||
to the activity.
|
||||
|
||||
Activities should preload as much of their media/etc as possible in
|
||||
their constructor, but none of it should actually be used until they
|
||||
are transitioned in.
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
# Create our internal engine data.
|
||||
self._activity_data = _ba.register_activity(self)
|
||||
|
||||
assert isinstance(settings, dict)
|
||||
assert _ba.getactivity() is self
|
||||
|
||||
self._globalsnode: ba.Node | None = None
|
||||
|
||||
# Player/Team types should have been specified as type args;
|
||||
# grab those.
|
||||
self._playertype: type[PlayerType]
|
||||
self._teamtype: type[TeamType]
|
||||
self._setup_player_and_team_types()
|
||||
|
||||
# FIXME: Relocate or remove the need for this stuff.
|
||||
self.paused_text: ba.Actor | None = None
|
||||
|
||||
self._session = weakref.ref(_ba.getsession())
|
||||
|
||||
# Preloaded data for actors, maps, etc; indexed by type.
|
||||
self.preloads: dict[type, Any] = {}
|
||||
|
||||
# Hopefully can eventually kill this; activities should
|
||||
# validate/store whatever settings they need at init time
|
||||
# (in a more type-safe way).
|
||||
self.settings_raw = settings
|
||||
|
||||
self._has_transitioned_in = False
|
||||
self._has_begun = False
|
||||
self._has_ended = False
|
||||
self._activity_death_check_timer: ba.Timer | None = None
|
||||
self._expired = False
|
||||
self._delay_delete_players: list[PlayerType] = []
|
||||
self._delay_delete_teams: list[TeamType] = []
|
||||
self._players_that_left: list[weakref.ref[PlayerType]] = []
|
||||
self._teams_that_left: list[weakref.ref[TeamType]] = []
|
||||
self._transitioning_out = False
|
||||
|
||||
# A handy place to put most actors; this list is pruned of dead
|
||||
# actors regularly and these actors are insta-killed as the activity
|
||||
# is dying.
|
||||
self._actor_refs: list[ba.Actor] = []
|
||||
self._actor_weak_refs: list[weakref.ref[ba.Actor]] = []
|
||||
self._last_prune_dead_actors_time = _ba.time()
|
||||
self._prune_dead_actors_timer: ba.Timer | None = None
|
||||
|
||||
self.teams = []
|
||||
self.players = []
|
||||
|
||||
self.lobby = None
|
||||
self._stats: ba.Stats | None = None
|
||||
self._customdata: dict | None = {}
|
||||
|
||||
def __del__(self) -> None:
|
||||
|
||||
# If the activity has been run then we should have already cleaned
|
||||
# it up, but we still need to run expire calls for un-run activities.
|
||||
if not self._expired:
|
||||
with _ba.Context('empty'):
|
||||
self._expire()
|
||||
|
||||
# Inform our owner that we officially kicked the bucket.
|
||||
if self._transitioning_out:
|
||||
session = self._session()
|
||||
if session is not None:
|
||||
_ba.pushcall(
|
||||
Call(
|
||||
session.transitioning_out_activity_was_freed,
|
||||
self.can_show_ad_on_death,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def globalsnode(self) -> ba.Node:
|
||||
"""The 'globals' ba.Node for the activity. This contains various
|
||||
global controls and values.
|
||||
"""
|
||||
node = self._globalsnode
|
||||
if not node:
|
||||
raise NodeNotFoundError()
|
||||
return node
|
||||
|
||||
@property
|
||||
def stats(self) -> ba.Stats:
|
||||
"""The stats instance accessible while the activity is running.
|
||||
|
||||
If access is attempted before or after, raises a ba.NotFoundError.
|
||||
"""
|
||||
if self._stats is None:
|
||||
from ba._error import NotFoundError
|
||||
|
||||
raise NotFoundError()
|
||||
return self._stats
|
||||
|
||||
def on_expire(self) -> None:
|
||||
"""Called when your activity is being expired.
|
||||
|
||||
If your activity has created anything explicitly that may be retaining
|
||||
a strong reference to the activity and preventing it from dying, you
|
||||
should clear that out here. From this point on your activity's sole
|
||||
purpose in life is to hit zero references and die so the next activity
|
||||
can begin.
|
||||
"""
|
||||
|
||||
@property
|
||||
def customdata(self) -> dict:
|
||||
"""Entities needing to store simple data with an activity can put it
|
||||
here. This dict will be deleted when the activity expires, so contained
|
||||
objects generally do not need to worry about handling expired
|
||||
activities.
|
||||
"""
|
||||
assert not self._expired
|
||||
assert isinstance(self._customdata, dict)
|
||||
return self._customdata
|
||||
|
||||
@property
|
||||
def expired(self) -> bool:
|
||||
"""Whether the activity is expired.
|
||||
|
||||
An activity is set as expired when shutting down.
|
||||
At this point no new nodes, timers, etc should be made,
|
||||
run, etc, and the activity should be considered a 'zombie'.
|
||||
"""
|
||||
return self._expired
|
||||
|
||||
@property
|
||||
def playertype(self) -> type[PlayerType]:
|
||||
"""The type of ba.Player this Activity is using."""
|
||||
return self._playertype
|
||||
|
||||
@property
|
||||
def teamtype(self) -> type[TeamType]:
|
||||
"""The type of ba.Team this Activity is using."""
|
||||
return self._teamtype
|
||||
|
||||
def set_has_ended(self, val: bool) -> None:
|
||||
"""(internal)"""
|
||||
self._has_ended = val
|
||||
|
||||
def expire(self) -> None:
|
||||
"""Begin the process of tearing down the activity.
|
||||
|
||||
(internal)
|
||||
"""
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
# Create a real-timer that watches a weak-ref of this activity
|
||||
# and reports any lingering references keeping it alive.
|
||||
# We store the timer on the activity so as soon as the activity dies
|
||||
# it gets cleaned up.
|
||||
with _ba.Context('ui'):
|
||||
ref = weakref.ref(self)
|
||||
self._activity_death_check_timer = _ba.Timer(
|
||||
5.0,
|
||||
Call(self._check_activity_death, ref, [0]),
|
||||
repeat=True,
|
||||
timetype=TimeType.REAL,
|
||||
)
|
||||
|
||||
# Run _expire in an empty context; nothing should be happening in
|
||||
# there except deleting things which requires no context.
|
||||
# (plus, _expire() runs in the destructor for un-run activities
|
||||
# and we can't properly provide context in that situation anyway; might
|
||||
# as well be consistent).
|
||||
if not self._expired:
|
||||
with _ba.Context('empty'):
|
||||
self._expire()
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f'destroy() called when' f' already expired for {self}'
|
||||
)
|
||||
|
||||
def retain_actor(self, actor: ba.Actor) -> None:
|
||||
"""Add a strong-reference to a ba.Actor to this Activity.
|
||||
|
||||
The reference will be lazily released once ba.Actor.exists()
|
||||
returns False for the Actor. The ba.Actor.autoretain() method
|
||||
is a convenient way to access this same functionality.
|
||||
"""
|
||||
if __debug__:
|
||||
from ba._actor import Actor
|
||||
|
||||
assert isinstance(actor, Actor)
|
||||
self._actor_refs.append(actor)
|
||||
|
||||
def add_actor_weak_ref(self, actor: ba.Actor) -> None:
|
||||
"""Add a weak-reference to a ba.Actor to the ba.Activity.
|
||||
|
||||
(called by the ba.Actor base class)
|
||||
"""
|
||||
if __debug__:
|
||||
from ba._actor import Actor
|
||||
|
||||
assert isinstance(actor, Actor)
|
||||
self._actor_weak_refs.append(weakref.ref(actor))
|
||||
|
||||
@property
|
||||
def session(self) -> ba.Session:
|
||||
"""The ba.Session this ba.Activity belongs go.
|
||||
|
||||
Raises a ba.SessionNotFoundError if the Session no longer exists.
|
||||
"""
|
||||
session = self._session()
|
||||
if session is None:
|
||||
from ba._error import SessionNotFoundError
|
||||
|
||||
raise SessionNotFoundError()
|
||||
return session
|
||||
|
||||
def on_player_join(self, player: PlayerType) -> None:
|
||||
"""Called when a new ba.Player has joined the Activity.
|
||||
|
||||
(including the initial set of Players)
|
||||
"""
|
||||
|
||||
def on_player_leave(self, player: PlayerType) -> None:
|
||||
"""Called when a ba.Player is leaving the Activity."""
|
||||
|
||||
def on_team_join(self, team: TeamType) -> None:
|
||||
"""Called when a new ba.Team joins the Activity.
|
||||
|
||||
(including the initial set of Teams)
|
||||
"""
|
||||
|
||||
def on_team_leave(self, team: TeamType) -> None:
|
||||
"""Called when a ba.Team leaves the Activity."""
|
||||
|
||||
def on_transition_in(self) -> None:
|
||||
"""Called when the Activity is first becoming visible.
|
||||
|
||||
Upon this call, the Activity should fade in backgrounds,
|
||||
start playing music, etc. It does not yet have access to players
|
||||
or teams, however. They remain owned by the previous Activity
|
||||
up until ba.Activity.on_begin() is called.
|
||||
"""
|
||||
|
||||
def on_transition_out(self) -> None:
|
||||
"""Called when your activity begins transitioning out.
|
||||
|
||||
Note that this may happen at any time even if ba.Activity.end() has
|
||||
not been called.
|
||||
"""
|
||||
|
||||
def on_begin(self) -> None:
|
||||
"""Called once the previous ba.Activity has finished transitioning out.
|
||||
|
||||
At this point the activity's initial players and teams are filled in
|
||||
and it should begin its actual game logic.
|
||||
"""
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
"""General message handling; can be passed any message object."""
|
||||
del msg # Unused arg.
|
||||
return UNHANDLED
|
||||
|
||||
def has_transitioned_in(self) -> bool:
|
||||
"""Return whether ba.Activity.on_transition_in()
|
||||
has been called."""
|
||||
return self._has_transitioned_in
|
||||
|
||||
def has_begun(self) -> bool:
|
||||
"""Return whether ba.Activity.on_begin() has been called."""
|
||||
return self._has_begun
|
||||
|
||||
def has_ended(self) -> bool:
|
||||
"""Return whether the activity has commenced ending."""
|
||||
return self._has_ended
|
||||
|
||||
def is_transitioning_out(self) -> bool:
|
||||
"""Return whether ba.Activity.on_transition_out() has been called."""
|
||||
return self._transitioning_out
|
||||
|
||||
def transition_in(self, prev_globals: ba.Node | None) -> None:
|
||||
"""Called by Session to kick off transition-in.
|
||||
|
||||
(internal)
|
||||
"""
|
||||
assert not self._has_transitioned_in
|
||||
self._has_transitioned_in = True
|
||||
|
||||
# Set up the globals node based on our settings.
|
||||
with _ba.Context(self):
|
||||
glb = self._globalsnode = _ba.newnode('globals')
|
||||
|
||||
# Now that it's going to be front and center,
|
||||
# set some global values based on what the activity wants.
|
||||
glb.use_fixed_vr_overlay = self.use_fixed_vr_overlay
|
||||
glb.allow_kick_idle_players = self.allow_kick_idle_players
|
||||
if self.inherits_slow_motion and prev_globals is not None:
|
||||
glb.slow_motion = prev_globals.slow_motion
|
||||
else:
|
||||
glb.slow_motion = self.slow_motion
|
||||
if self.inherits_music and prev_globals is not None:
|
||||
glb.music_continuous = True # Prevent restarting same music.
|
||||
glb.music = prev_globals.music
|
||||
glb.music_count += 1
|
||||
if self.inherits_vr_camera_offset and prev_globals is not None:
|
||||
glb.vr_camera_offset = prev_globals.vr_camera_offset
|
||||
if self.inherits_vr_overlay_center and prev_globals is not None:
|
||||
glb.vr_overlay_center = prev_globals.vr_overlay_center
|
||||
glb.vr_overlay_center_enabled = (
|
||||
prev_globals.vr_overlay_center_enabled
|
||||
)
|
||||
|
||||
# If they want to inherit tint from the previous self.
|
||||
if self.inherits_tint and prev_globals is not None:
|
||||
glb.tint = prev_globals.tint
|
||||
glb.vignette_outer = prev_globals.vignette_outer
|
||||
glb.vignette_inner = prev_globals.vignette_inner
|
||||
|
||||
# Start pruning our various things periodically.
|
||||
self._prune_dead_actors()
|
||||
self._prune_dead_actors_timer = _ba.Timer(
|
||||
5.17, self._prune_dead_actors, repeat=True
|
||||
)
|
||||
|
||||
_ba.timer(13.3, self._prune_delay_deletes, repeat=True)
|
||||
|
||||
# Also start our low-level scene running.
|
||||
self._activity_data.start()
|
||||
|
||||
try:
|
||||
self.on_transition_in()
|
||||
except Exception:
|
||||
print_exception(f'Error in on_transition_in for {self}.')
|
||||
|
||||
# Tell the C++ layer that this activity is the main one, so it uses
|
||||
# settings from our globals, directs various events to us, etc.
|
||||
self._activity_data.make_foreground()
|
||||
|
||||
def transition_out(self) -> None:
|
||||
"""Called by the Session to start us transitioning out."""
|
||||
assert not self._transitioning_out
|
||||
self._transitioning_out = True
|
||||
with _ba.Context(self):
|
||||
try:
|
||||
self.on_transition_out()
|
||||
except Exception:
|
||||
print_exception(f'Error in on_transition_out for {self}.')
|
||||
|
||||
def begin(self, session: ba.Session) -> None:
|
||||
"""Begin the activity.
|
||||
|
||||
(internal)
|
||||
"""
|
||||
|
||||
assert not self._has_begun
|
||||
|
||||
# Inherit stats from the session.
|
||||
self._stats = session.stats
|
||||
|
||||
# Add session's teams in.
|
||||
for team in session.sessionteams:
|
||||
self.add_team(team)
|
||||
|
||||
# Add session's players in.
|
||||
for player in session.sessionplayers:
|
||||
self.add_player(player)
|
||||
|
||||
self._has_begun = True
|
||||
|
||||
# Let the activity do its thing.
|
||||
with _ba.Context(self):
|
||||
# Note: do we want to catch errors here?
|
||||
# Currently I believe we wind up canceling the
|
||||
# activity launch; just wanna be sure that is intentional.
|
||||
self.on_begin()
|
||||
|
||||
def end(
|
||||
self, results: Any = None, delay: float = 0.0, force: bool = False
|
||||
) -> None:
|
||||
"""Commences Activity shutdown and delivers results to the ba.Session.
|
||||
|
||||
'delay' is the time delay before the Activity actually ends
|
||||
(in seconds). Further calls to end() will be ignored up until
|
||||
this time, unless 'force' is True, in which case the new results
|
||||
will replace the old.
|
||||
"""
|
||||
|
||||
# Ask the session to end us.
|
||||
self.session.end_activity(self, results, delay, force)
|
||||
|
||||
def create_player(self, sessionplayer: ba.SessionPlayer) -> PlayerType:
|
||||
"""Create the Player instance for this Activity.
|
||||
|
||||
Subclasses can override this if the activity's player class
|
||||
requires a custom constructor; otherwise it will be called with
|
||||
no args. Note that the player object should not be used at this
|
||||
point as it is not yet fully wired up; wait for
|
||||
ba.Activity.on_player_join() for that.
|
||||
"""
|
||||
del sessionplayer # Unused.
|
||||
player = self._playertype()
|
||||
return player
|
||||
|
||||
def create_team(self, sessionteam: ba.SessionTeam) -> TeamType:
|
||||
"""Create the Team instance for this Activity.
|
||||
|
||||
Subclasses can override this if the activity's team class
|
||||
requires a custom constructor; otherwise it will be called with
|
||||
no args. Note that the team object should not be used at this
|
||||
point as it is not yet fully wired up; wait for on_team_join()
|
||||
for that.
|
||||
"""
|
||||
del sessionteam # Unused.
|
||||
team = self._teamtype()
|
||||
return team
|
||||
|
||||
def add_player(self, sessionplayer: ba.SessionPlayer) -> None:
|
||||
"""(internal)"""
|
||||
assert sessionplayer.sessionteam is not None
|
||||
sessionplayer.resetinput()
|
||||
sessionteam = sessionplayer.sessionteam
|
||||
assert sessionplayer in sessionteam.players
|
||||
team = sessionteam.activityteam
|
||||
assert team is not None
|
||||
sessionplayer.setactivity(self)
|
||||
with _ba.Context(self):
|
||||
sessionplayer.activityplayer = player = self.create_player(
|
||||
sessionplayer
|
||||
)
|
||||
player.postinit(sessionplayer)
|
||||
|
||||
assert player not in team.players
|
||||
team.players.append(player)
|
||||
assert player in team.players
|
||||
|
||||
assert player not in self.players
|
||||
self.players.append(player)
|
||||
assert player in self.players
|
||||
|
||||
try:
|
||||
self.on_player_join(player)
|
||||
except Exception:
|
||||
print_exception(f'Error in on_player_join for {self}.')
|
||||
|
||||
def remove_player(self, sessionplayer: ba.SessionPlayer) -> None:
|
||||
"""Remove a player from the Activity while it is running.
|
||||
|
||||
(internal)
|
||||
"""
|
||||
assert not self.expired
|
||||
|
||||
player: Any = sessionplayer.activityplayer
|
||||
assert isinstance(player, self._playertype)
|
||||
team: Any = sessionplayer.sessionteam.activityteam
|
||||
assert isinstance(team, self._teamtype)
|
||||
|
||||
assert player in team.players
|
||||
team.players.remove(player)
|
||||
assert player not in team.players
|
||||
|
||||
assert player in self.players
|
||||
self.players.remove(player)
|
||||
assert player not in self.players
|
||||
|
||||
# This should allow our ba.Player instance to die.
|
||||
# Complain if that doesn't happen.
|
||||
# verify_object_death(player)
|
||||
|
||||
with _ba.Context(self):
|
||||
try:
|
||||
self.on_player_leave(player)
|
||||
except Exception:
|
||||
print_exception(f'Error in on_player_leave for {self}.')
|
||||
try:
|
||||
player.leave()
|
||||
except Exception:
|
||||
print_exception(f'Error on leave for {player} in {self}.')
|
||||
|
||||
self._reset_session_player_for_no_activity(sessionplayer)
|
||||
|
||||
# Add the player to a list to keep it around for a while. This is
|
||||
# to discourage logic from firing on player object death, which
|
||||
# may not happen until activity end if something is holding refs
|
||||
# to it.
|
||||
self._delay_delete_players.append(player)
|
||||
self._players_that_left.append(weakref.ref(player))
|
||||
|
||||
def add_team(self, sessionteam: ba.SessionTeam) -> None:
|
||||
"""Add a team to the Activity
|
||||
|
||||
(internal)
|
||||
"""
|
||||
assert not self.expired
|
||||
|
||||
with _ba.Context(self):
|
||||
sessionteam.activityteam = team = self.create_team(sessionteam)
|
||||
team.postinit(sessionteam)
|
||||
self.teams.append(team)
|
||||
try:
|
||||
self.on_team_join(team)
|
||||
except Exception:
|
||||
print_exception(f'Error in on_team_join for {self}.')
|
||||
|
||||
def remove_team(self, sessionteam: ba.SessionTeam) -> None:
|
||||
"""Remove a team from a Running Activity
|
||||
|
||||
(internal)
|
||||
"""
|
||||
assert not self.expired
|
||||
assert sessionteam.activityteam is not None
|
||||
|
||||
team: Any = sessionteam.activityteam
|
||||
assert isinstance(team, self._teamtype)
|
||||
|
||||
assert team in self.teams
|
||||
self.teams.remove(team)
|
||||
assert team not in self.teams
|
||||
|
||||
with _ba.Context(self):
|
||||
# Make a decent attempt to persevere if user code breaks.
|
||||
try:
|
||||
self.on_team_leave(team)
|
||||
except Exception:
|
||||
print_exception(f'Error in on_team_leave for {self}.')
|
||||
try:
|
||||
team.leave()
|
||||
except Exception:
|
||||
print_exception(f'Error on leave for {team} in {self}.')
|
||||
|
||||
sessionteam.activityteam = None
|
||||
|
||||
# Add the team to a list to keep it around for a while. This is
|
||||
# to discourage logic from firing on team object death, which
|
||||
# may not happen until activity end if something is holding refs
|
||||
# to it.
|
||||
self._delay_delete_teams.append(team)
|
||||
self._teams_that_left.append(weakref.ref(team))
|
||||
|
||||
def _reset_session_player_for_no_activity(
|
||||
self, sessionplayer: ba.SessionPlayer
|
||||
) -> None:
|
||||
|
||||
# Let's be extra-defensive here: killing a node/input-call/etc
|
||||
# could trigger user-code resulting in errors, but we would still
|
||||
# like to complete the reset if possible.
|
||||
try:
|
||||
sessionplayer.setnode(None)
|
||||
except Exception:
|
||||
print_exception(
|
||||
f'Error resetting SessionPlayer node on {sessionplayer}'
|
||||
f' for {self}.'
|
||||
)
|
||||
try:
|
||||
sessionplayer.resetinput()
|
||||
except Exception:
|
||||
print_exception(
|
||||
f'Error resetting SessionPlayer input on {sessionplayer}'
|
||||
f' for {self}.'
|
||||
)
|
||||
|
||||
# These should never fail I think...
|
||||
sessionplayer.setactivity(None)
|
||||
sessionplayer.activityplayer = None
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
def _setup_player_and_team_types(self) -> None:
|
||||
"""Pull player and team types from our typing.Generic params."""
|
||||
|
||||
# TODO: There are proper calls for pulling these in Python 3.8;
|
||||
# should update this code when we adopt that.
|
||||
# NOTE: If we get Any as PlayerType or TeamType (generally due
|
||||
# to no generic params being passed) we automatically use the
|
||||
# base class types, but also warn the user since this will mean
|
||||
# less type safety for that class. (its better to pass the base
|
||||
# player/team types explicitly vs. having them be Any)
|
||||
if not TYPE_CHECKING:
|
||||
self._playertype = type(self).__orig_bases__[-1].__args__[0]
|
||||
if not isinstance(self._playertype, type):
|
||||
self._playertype = Player
|
||||
print(
|
||||
f'ERROR: {type(self)} was not passed a Player'
|
||||
f' type argument; please explicitly pass ba.Player'
|
||||
f' if you do not want to override it.'
|
||||
)
|
||||
self._teamtype = type(self).__orig_bases__[-1].__args__[1]
|
||||
if not isinstance(self._teamtype, type):
|
||||
self._teamtype = Team
|
||||
print(
|
||||
f'ERROR: {type(self)} was not passed a Team'
|
||||
f' type argument; please explicitly pass ba.Team'
|
||||
f' if you do not want to override it.'
|
||||
)
|
||||
assert issubclass(self._playertype, Player)
|
||||
assert issubclass(self._teamtype, Team)
|
||||
|
||||
@classmethod
|
||||
def _check_activity_death(
|
||||
cls, activity_ref: weakref.ref[Activity], counter: list[int]
|
||||
) -> None:
|
||||
"""Sanity check to make sure an Activity was destroyed properly.
|
||||
|
||||
Receives a weakref to a ba.Activity which should have torn itself
|
||||
down due to no longer being referenced anywhere. Will complain
|
||||
and/or print debugging info if the Activity still exists.
|
||||
"""
|
||||
try:
|
||||
activity = activity_ref()
|
||||
print(
|
||||
'ERROR: Activity is not dying when expected:',
|
||||
activity,
|
||||
'(warning ' + str(counter[0] + 1) + ')',
|
||||
)
|
||||
print(
|
||||
'This means something is still strong-referencing it.\n'
|
||||
'Check out methods such as efro.debug.printrefs() to'
|
||||
' help debug this sort of thing.'
|
||||
)
|
||||
# Note: no longer calling gc.get_referrers() here because it's
|
||||
# usage can bork stuff. (see notes at top of efro.debug)
|
||||
counter[0] += 1
|
||||
if counter[0] == 4:
|
||||
print('Killing app due to stuck activity... :-(')
|
||||
_ba.quit()
|
||||
|
||||
except Exception:
|
||||
print_exception('Error on _check_activity_death/')
|
||||
|
||||
def _expire(self) -> None:
|
||||
"""Put the activity in a state where it can be garbage-collected.
|
||||
|
||||
This involves clearing anything that might be holding a reference
|
||||
to it, etc.
|
||||
"""
|
||||
assert not self._expired
|
||||
self._expired = True
|
||||
|
||||
try:
|
||||
self.on_expire()
|
||||
except Exception:
|
||||
print_exception(f'Error in Activity on_expire() for {self}.')
|
||||
|
||||
try:
|
||||
self._customdata = None
|
||||
except Exception:
|
||||
print_exception(f'Error clearing customdata for {self}.')
|
||||
|
||||
# Don't want to be holding any delay-delete refs at this point.
|
||||
self._prune_delay_deletes()
|
||||
|
||||
self._expire_actors()
|
||||
self._expire_players()
|
||||
self._expire_teams()
|
||||
|
||||
# This will kill all low level stuff: Timers, Nodes, etc., which
|
||||
# should clear up any remaining refs to our Activity and allow us
|
||||
# to die peacefully.
|
||||
try:
|
||||
self._activity_data.expire()
|
||||
except Exception:
|
||||
print_exception(f'Error expiring _activity_data for {self}.')
|
||||
|
||||
def _expire_actors(self) -> None:
|
||||
# Expire all Actors.
|
||||
for actor_ref in self._actor_weak_refs:
|
||||
actor = actor_ref()
|
||||
if actor is not None:
|
||||
verify_object_death(actor)
|
||||
try:
|
||||
actor.on_expire()
|
||||
except Exception:
|
||||
print_exception(
|
||||
f'Error in Actor.on_expire()' f' for {actor_ref()}.'
|
||||
)
|
||||
|
||||
def _expire_players(self) -> None:
|
||||
|
||||
# Issue warnings for any players that left the game but don't
|
||||
# get freed soon.
|
||||
for ex_player in (p() for p in self._players_that_left):
|
||||
if ex_player is not None:
|
||||
verify_object_death(ex_player)
|
||||
|
||||
for player in self.players:
|
||||
# This should allow our ba.Player instance to be freed.
|
||||
# Complain if that doesn't happen.
|
||||
verify_object_death(player)
|
||||
|
||||
try:
|
||||
player.expire()
|
||||
except Exception:
|
||||
print_exception(f'Error expiring {player}')
|
||||
|
||||
# Reset the SessionPlayer to a not-in-an-activity state.
|
||||
try:
|
||||
sessionplayer = player.sessionplayer
|
||||
self._reset_session_player_for_no_activity(sessionplayer)
|
||||
except SessionPlayerNotFoundError:
|
||||
# Conceivably, someone could have held on to a Player object
|
||||
# until now whos underlying SessionPlayer left long ago...
|
||||
pass
|
||||
except Exception:
|
||||
print_exception(f'Error expiring {player}.')
|
||||
|
||||
def _expire_teams(self) -> None:
|
||||
|
||||
# Issue warnings for any teams that left the game but don't
|
||||
# get freed soon.
|
||||
for ex_team in (p() for p in self._teams_that_left):
|
||||
if ex_team is not None:
|
||||
verify_object_death(ex_team)
|
||||
|
||||
for team in self.teams:
|
||||
# This should allow our ba.Team instance to die.
|
||||
# Complain if that doesn't happen.
|
||||
verify_object_death(team)
|
||||
|
||||
try:
|
||||
team.expire()
|
||||
except Exception:
|
||||
print_exception(f'Error expiring {team}')
|
||||
|
||||
try:
|
||||
sessionteam = team.sessionteam
|
||||
sessionteam.activityteam = None
|
||||
except SessionTeamNotFoundError:
|
||||
# It is expected that Team objects may last longer than
|
||||
# the SessionTeam they came from (game objects may hold
|
||||
# team references past the point at which the underlying
|
||||
# player/team has left the game)
|
||||
pass
|
||||
except Exception:
|
||||
print_exception(f'Error expiring Team {team}.')
|
||||
|
||||
def _prune_delay_deletes(self) -> None:
|
||||
self._delay_delete_players.clear()
|
||||
self._delay_delete_teams.clear()
|
||||
|
||||
# Clear out any dead weak-refs.
|
||||
self._teams_that_left = [
|
||||
t for t in self._teams_that_left if t() is not None
|
||||
]
|
||||
self._players_that_left = [
|
||||
p for p in self._players_that_left if p() is not None
|
||||
]
|
||||
|
||||
def _prune_dead_actors(self) -> None:
|
||||
self._last_prune_dead_actors_time = _ba.time()
|
||||
|
||||
# Prune our strong refs when the Actor's exists() call gives False
|
||||
self._actor_refs = [a for a in self._actor_refs if a.exists()]
|
||||
|
||||
# Prune our weak refs once the Actor object has been freed.
|
||||
self._actor_weak_refs = [
|
||||
a for a in self._actor_weak_refs if a() is not None
|
||||
]
|
||||
238
dist/ba_data/python/ba/_activitytypes.py
vendored
Normal file
238
dist/ba_data/python/ba/_activitytypes.py
vendored
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Some handy base class and special purpose Activity types."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._activity import Activity
|
||||
from ba._music import setmusic, MusicType
|
||||
from ba._generated.enums import InputType, UIScale
|
||||
|
||||
# False-positive from pylint due to our class-generics-filter.
|
||||
from ba._player import EmptyPlayer # pylint: disable=W0611
|
||||
from ba._team import EmptyTeam # pylint: disable=W0611
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ba
|
||||
from ba._lobby import JoinInfo
|
||||
|
||||
|
||||
class EndSessionActivity(Activity[EmptyPlayer, EmptyTeam]):
|
||||
"""Special ba.Activity to fade out and end the current ba.Session."""
|
||||
|
||||
def __init__(self, settings: dict):
|
||||
super().__init__(settings)
|
||||
|
||||
# Keeps prev activity alive while we fade out.
|
||||
self.transition_time = 0.25
|
||||
self.inherits_tint = True
|
||||
self.inherits_slow_motion = True
|
||||
self.inherits_vr_camera_offset = True
|
||||
self.inherits_vr_overlay_center = True
|
||||
|
||||
def on_transition_in(self) -> None:
|
||||
super().on_transition_in()
|
||||
_ba.fade_screen(False)
|
||||
_ba.lock_all_input()
|
||||
|
||||
def on_begin(self) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.mainmenu import MainMenuSession
|
||||
from ba._general import Call
|
||||
|
||||
super().on_begin()
|
||||
_ba.unlock_all_input()
|
||||
_ba.app.ads.call_after_ad(Call(_ba.new_host_session, MainMenuSession))
|
||||
|
||||
|
||||
class JoinActivity(Activity[EmptyPlayer, EmptyTeam]):
|
||||
"""Standard activity for waiting for players to join.
|
||||
|
||||
It shows tips and other info and waits for all players to check ready.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: dict):
|
||||
super().__init__(settings)
|
||||
|
||||
# This activity is a special 'joiner' activity.
|
||||
# It will get shut down as soon as all players have checked ready.
|
||||
self.is_joining_activity = True
|
||||
|
||||
# Players may be idle waiting for joiners; lets not kick them for it.
|
||||
self.allow_kick_idle_players = False
|
||||
|
||||
# In vr mode we don't want stuff moving around.
|
||||
self.use_fixed_vr_overlay = True
|
||||
|
||||
self._background: ba.Actor | None = None
|
||||
self._tips_text: ba.Actor | None = None
|
||||
self._join_info: JoinInfo | None = None
|
||||
|
||||
def on_transition_in(self) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.actor.tipstext import TipsText
|
||||
from bastd.actor.background import Background
|
||||
|
||||
super().on_transition_in()
|
||||
self._background = Background(
|
||||
fade_time=0.5, start_faded=True, show_logo=True
|
||||
)
|
||||
self._tips_text = TipsText()
|
||||
setmusic(MusicType.CHAR_SELECT)
|
||||
self._join_info = self.session.lobby.create_join_info()
|
||||
_ba.set_analytics_screen('Joining Screen')
|
||||
|
||||
|
||||
class TransitionActivity(Activity[EmptyPlayer, EmptyTeam]):
|
||||
"""A simple overlay to fade out/in.
|
||||
|
||||
Useful as a bare minimum transition between two level based activities.
|
||||
"""
|
||||
|
||||
# Keep prev activity alive while we fade in.
|
||||
transition_time = 0.5
|
||||
inherits_slow_motion = True # Don't change.
|
||||
inherits_tint = True # Don't change.
|
||||
inherits_vr_camera_offset = True # Don't change.
|
||||
inherits_vr_overlay_center = True
|
||||
use_fixed_vr_overlay = True
|
||||
|
||||
def __init__(self, settings: dict):
|
||||
super().__init__(settings)
|
||||
self._background: ba.Actor | None = None
|
||||
|
||||
def on_transition_in(self) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.actor import background # FIXME: Don't use bastd from ba.
|
||||
|
||||
super().on_transition_in()
|
||||
self._background = background.Background(
|
||||
fade_time=0.5, start_faded=False, show_logo=False
|
||||
)
|
||||
|
||||
def on_begin(self) -> None:
|
||||
super().on_begin()
|
||||
|
||||
# Die almost immediately.
|
||||
_ba.timer(0.1, self.end)
|
||||
|
||||
|
||||
class ScoreScreenActivity(Activity[EmptyPlayer, EmptyTeam]):
|
||||
"""A standard score screen that fades in and shows stuff for a while.
|
||||
|
||||
After a specified delay, player input is assigned to end the activity.
|
||||
"""
|
||||
|
||||
transition_time = 0.5
|
||||
inherits_tint = True
|
||||
inherits_vr_camera_offset = True
|
||||
use_fixed_vr_overlay = True
|
||||
|
||||
default_music: MusicType | None = MusicType.SCORES
|
||||
|
||||
def __init__(self, settings: dict):
|
||||
super().__init__(settings)
|
||||
self._birth_time = _ba.time()
|
||||
self._min_view_time = 5.0
|
||||
self._allow_server_transition = False
|
||||
self._background: ba.Actor | None = None
|
||||
self._tips_text: ba.Actor | None = None
|
||||
self._kicked_off_server_shutdown = False
|
||||
self._kicked_off_server_restart = False
|
||||
self._default_show_tips = True
|
||||
self._custom_continue_message: ba.Lstr | None = None
|
||||
self._server_transitioning: bool | None = None
|
||||
|
||||
def on_player_join(self, player: EmptyPlayer) -> None:
|
||||
from ba._general import WeakCall
|
||||
|
||||
super().on_player_join(player)
|
||||
time_till_assign = max(
|
||||
0, self._birth_time + self._min_view_time - _ba.time()
|
||||
)
|
||||
|
||||
# If we're still kicking at the end of our assign-delay, assign this
|
||||
# guy's input to trigger us.
|
||||
_ba.timer(time_till_assign, WeakCall(self._safe_assign, player))
|
||||
|
||||
def on_transition_in(self) -> None:
|
||||
from bastd.actor.tipstext import TipsText
|
||||
from bastd.actor.background import Background
|
||||
|
||||
super().on_transition_in()
|
||||
self._background = Background(
|
||||
fade_time=0.5, start_faded=False, show_logo=True
|
||||
)
|
||||
if self._default_show_tips:
|
||||
self._tips_text = TipsText()
|
||||
setmusic(self.default_music)
|
||||
|
||||
def on_begin(self) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.actor.text import Text
|
||||
from ba import _language
|
||||
|
||||
super().on_begin()
|
||||
import custom_hooks
|
||||
custom_hooks.score_screen_on_begin(self._stats)
|
||||
|
||||
# Pop up a 'press any button to continue' statement after our
|
||||
# min-view-time show a 'press any button to continue..'
|
||||
# thing after a bit.
|
||||
if _ba.app.ui.uiscale is UIScale.LARGE:
|
||||
# FIXME: Need a better way to determine whether we've probably
|
||||
# got a keyboard.
|
||||
sval = _language.Lstr(resource='pressAnyKeyButtonText')
|
||||
else:
|
||||
sval = _language.Lstr(resource='pressAnyButtonText')
|
||||
|
||||
Text(
|
||||
self._custom_continue_message
|
||||
if self._custom_continue_message is not None
|
||||
else sval,
|
||||
v_attach=Text.VAttach.BOTTOM,
|
||||
h_align=Text.HAlign.CENTER,
|
||||
flash=True,
|
||||
vr_depth=50,
|
||||
position=(0, 10),
|
||||
scale=0.8,
|
||||
color=(0.5, 0.7, 0.5, 0.5),
|
||||
transition=Text.Transition.IN_BOTTOM_SLOW,
|
||||
transition_delay=self._min_view_time,
|
||||
).autoretain()
|
||||
|
||||
def _player_press(self) -> None:
|
||||
|
||||
# If this activity is a good 'end point', ask server-mode just once if
|
||||
# it wants to do anything special like switch sessions or kill the app.
|
||||
if (
|
||||
self._allow_server_transition
|
||||
and _ba.app.server is not None
|
||||
and self._server_transitioning is None
|
||||
):
|
||||
self._server_transitioning = _ba.app.server.handle_transition()
|
||||
assert isinstance(self._server_transitioning, bool)
|
||||
|
||||
# If server-mode is handling this, don't do anything ourself.
|
||||
if self._server_transitioning is True:
|
||||
return
|
||||
|
||||
# Otherwise end the activity normally.
|
||||
self.end()
|
||||
|
||||
def _safe_assign(self, player: EmptyPlayer) -> None:
|
||||
|
||||
# Just to be extra careful, don't assign if we're transitioning out.
|
||||
# (though theoretically that should be ok).
|
||||
if not self.is_transitioning_out() and player:
|
||||
player.assigninput(
|
||||
(
|
||||
InputType.JUMP_PRESS,
|
||||
InputType.PUNCH_PRESS,
|
||||
InputType.BOMB_PRESS,
|
||||
InputType.PICK_UP_PRESS,
|
||||
),
|
||||
self._player_press,
|
||||
)
|
||||
202
dist/ba_data/python/ba/_actor.py
vendored
Normal file
202
dist/ba_data/python/ba/_actor.py
vendored
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Defines base Actor class."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from typing import TYPE_CHECKING, TypeVar, overload
|
||||
|
||||
import _ba
|
||||
from ba._messages import DieMessage, DeathType, OutOfBoundsMessage, UNHANDLED
|
||||
from ba._error import print_exception, ActivityNotFoundError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Literal
|
||||
import ba
|
||||
|
||||
ActorT = TypeVar('ActorT', bound='Actor')
|
||||
|
||||
|
||||
class Actor:
|
||||
"""High level logical entities in a ba.Activity.
|
||||
|
||||
Category: **Gameplay Classes**
|
||||
|
||||
Actors act as controllers, combining some number of ba.Nodes,
|
||||
ba.Textures, ba.Sounds, etc. into a high-level cohesive unit.
|
||||
|
||||
Some example actors include the Bomb, Flag, and Spaz classes that
|
||||
live in the bastd.actor.* modules.
|
||||
|
||||
One key feature of Actors is that they generally 'die'
|
||||
(killing off or transitioning out their nodes) when the last Python
|
||||
reference to them disappears, so you can use logic such as:
|
||||
|
||||
##### Example
|
||||
>>> # Create a flag Actor in our game activity:
|
||||
... from bastd.actor.flag import Flag
|
||||
... self.flag = Flag(position=(0, 10, 0))
|
||||
...
|
||||
... # Later, destroy the flag.
|
||||
... # (provided nothing else is holding a reference to it)
|
||||
... # We could also just assign a new flag to this value.
|
||||
... # Either way, the old flag disappears.
|
||||
... self.flag = None
|
||||
|
||||
This is in contrast to the behavior of the more low level ba.Nodes,
|
||||
which are always explicitly created and destroyed and don't care
|
||||
how many Python references to them exist.
|
||||
|
||||
Note, however, that you can use the ba.Actor.autoretain() method
|
||||
if you want an Actor to stick around until explicitly killed
|
||||
regardless of references.
|
||||
|
||||
Another key feature of ba.Actor is its ba.Actor.handlemessage() method,
|
||||
which takes a single arbitrary object as an argument. This provides a safe
|
||||
way to communicate between ba.Actor, ba.Activity, ba.Session, and any other
|
||||
class providing a handlemessage() method. The most universally handled
|
||||
message type for Actors is the ba.DieMessage.
|
||||
|
||||
Another way to kill the flag from the example above:
|
||||
We can safely call this on any type with a 'handlemessage' method
|
||||
(though its not guaranteed to always have a meaningful effect).
|
||||
In this case the Actor instance will still be around, but its
|
||||
ba.Actor.exists() and ba.Actor.is_alive() methods will both return False.
|
||||
>>> self.flag.handlemessage(ba.DieMessage())
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Instantiates an Actor in the current ba.Activity."""
|
||||
|
||||
if __debug__:
|
||||
self._root_actor_init_called = True
|
||||
activity = _ba.getactivity()
|
||||
self._activity = weakref.ref(activity)
|
||||
activity.add_actor_weak_ref(self)
|
||||
|
||||
def __del__(self) -> None:
|
||||
try:
|
||||
# Unexpired Actors send themselves a DieMessage when going down.
|
||||
# That way we can treat DieMessage handling as the single
|
||||
# point-of-action for death.
|
||||
if not self.expired:
|
||||
self.handlemessage(DieMessage())
|
||||
except Exception:
|
||||
print_exception('exception in ba.Actor.__del__() for', self)
|
||||
|
||||
def handlemessage(self, msg: Any) -> Any:
|
||||
"""General message handling; can be passed any message object."""
|
||||
assert not self.expired
|
||||
|
||||
# By default, actors going out-of-bounds simply kill themselves.
|
||||
if isinstance(msg, OutOfBoundsMessage):
|
||||
return self.handlemessage(DieMessage(how=DeathType.OUT_OF_BOUNDS))
|
||||
|
||||
return UNHANDLED
|
||||
|
||||
def autoretain(self: ActorT) -> ActorT:
|
||||
"""Keep this Actor alive without needing to hold a reference to it.
|
||||
|
||||
This keeps the ba.Actor in existence by storing a reference to it
|
||||
with the ba.Activity it was created in. The reference is lazily
|
||||
released once ba.Actor.exists() returns False for it or when the
|
||||
Activity is set as expired. This can be a convenient alternative
|
||||
to storing references explicitly just to keep a ba.Actor from dying.
|
||||
For convenience, this method returns the ba.Actor it is called with,
|
||||
enabling chained statements such as: myflag = ba.Flag().autoretain()
|
||||
"""
|
||||
activity = self._activity()
|
||||
if activity is None:
|
||||
raise ActivityNotFoundError()
|
||||
activity.retain_actor(self)
|
||||
return self
|
||||
|
||||
def on_expire(self) -> None:
|
||||
"""Called for remaining `ba.Actor`s when their ba.Activity shuts down.
|
||||
|
||||
Actors can use this opportunity to clear callbacks or other
|
||||
references which have the potential of keeping the ba.Activity
|
||||
alive inadvertently (Activities can not exit cleanly while
|
||||
any Python references to them remain.)
|
||||
|
||||
Once an actor is expired (see ba.Actor.is_expired()) it should no
|
||||
longer perform any game-affecting operations (creating, modifying,
|
||||
or deleting nodes, media, timers, etc.) Attempts to do so will
|
||||
likely result in errors.
|
||||
"""
|
||||
|
||||
@property
|
||||
def expired(self) -> bool:
|
||||
"""Whether the Actor is expired.
|
||||
|
||||
(see ba.Actor.on_expire())
|
||||
"""
|
||||
activity = self.getactivity(doraise=False)
|
||||
return True if activity is None else activity.expired
|
||||
|
||||
def exists(self) -> bool:
|
||||
"""Returns whether the Actor is still present in a meaningful way.
|
||||
|
||||
Note that a dying character should still return True here as long as
|
||||
their corpse is visible; this is about presence, not being 'alive'
|
||||
(see ba.Actor.is_alive() for that).
|
||||
|
||||
If this returns False, it is assumed the Actor can be completely
|
||||
deleted without affecting the game; this call is often used
|
||||
when pruning lists of Actors, such as with ba.Actor.autoretain()
|
||||
|
||||
The default implementation of this method always return True.
|
||||
|
||||
Note that the boolean operator for the Actor class calls this method,
|
||||
so a simple "if myactor" test will conveniently do the right thing
|
||||
even if myactor is set to None.
|
||||
"""
|
||||
return True
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
# Cleaner way to test existence; friendlier to None values.
|
||||
return self.exists()
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Returns whether the Actor is 'alive'.
|
||||
|
||||
What this means is up to the Actor.
|
||||
It is not a requirement for Actors to be able to die;
|
||||
just that they report whether they consider themselves
|
||||
to be alive or not. In cases where dead/alive is
|
||||
irrelevant, True should be returned.
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def activity(self) -> ba.Activity:
|
||||
"""The Activity this Actor was created in.
|
||||
|
||||
Raises a ba.ActivityNotFoundError if the Activity no longer exists.
|
||||
"""
|
||||
activity = self._activity()
|
||||
if activity is None:
|
||||
raise ActivityNotFoundError()
|
||||
return activity
|
||||
|
||||
# Overloads to convey our exact return type depending on 'doraise' value.
|
||||
|
||||
@overload
|
||||
def getactivity(self, doraise: Literal[True] = True) -> ba.Activity:
|
||||
...
|
||||
|
||||
@overload
|
||||
def getactivity(self, doraise: Literal[False]) -> ba.Activity | None:
|
||||
...
|
||||
|
||||
def getactivity(self, doraise: bool = True) -> ba.Activity | None:
|
||||
"""Return the ba.Activity this Actor is associated with.
|
||||
|
||||
If the Activity no longer exists, raises a ba.ActivityNotFoundError
|
||||
or returns None depending on whether 'doraise' is True.
|
||||
"""
|
||||
activity = self._activity()
|
||||
if activity is None and doraise:
|
||||
raise ActivityNotFoundError()
|
||||
return activity
|
||||
225
dist/ba_data/python/ba/_ads.py
vendored
Normal file
225
dist/ba_data/python/ba/_ads.py
vendored
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to ads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba import _internal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Callable, Any
|
||||
|
||||
|
||||
class AdsSubsystem:
|
||||
"""Subsystem for ads functionality in the app.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
Access the single shared instance of this class at 'ba.app.ads'.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.last_ad_network = 'unknown'
|
||||
self.last_ad_network_set_time = time.time()
|
||||
self.ad_amt: float | None = None
|
||||
self.last_ad_purpose = 'invalid'
|
||||
self.attempted_first_ad = False
|
||||
self.last_in_game_ad_remove_message_show_time: float | None = None
|
||||
self.last_ad_completion_time: float | None = None
|
||||
self.last_ad_was_short = False
|
||||
|
||||
def do_remove_in_game_ads_message(self) -> None:
|
||||
"""(internal)"""
|
||||
from ba._language import Lstr
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
# Print this message once every 10 minutes at most.
|
||||
tval = _ba.time(TimeType.REAL)
|
||||
if self.last_in_game_ad_remove_message_show_time is None or (
|
||||
tval - self.last_in_game_ad_remove_message_show_time > 60 * 10
|
||||
):
|
||||
self.last_in_game_ad_remove_message_show_time = tval
|
||||
with _ba.Context('ui'):
|
||||
_ba.timer(
|
||||
1.0,
|
||||
lambda: _ba.screenmessage(
|
||||
Lstr(
|
||||
resource='removeInGameAdsText',
|
||||
subs=[
|
||||
(
|
||||
'${PRO}',
|
||||
Lstr(resource='store.bombSquadProNameText'),
|
||||
),
|
||||
('${APP_NAME}', Lstr(resource='titleText')),
|
||||
],
|
||||
),
|
||||
color=(1, 1, 0),
|
||||
),
|
||||
timetype=TimeType.REAL,
|
||||
)
|
||||
|
||||
def show_ad(
|
||||
self, purpose: str, on_completion_call: Callable[[], Any] | None = None
|
||||
) -> None:
|
||||
"""(internal)"""
|
||||
self.last_ad_purpose = purpose
|
||||
_ba.show_ad(purpose, on_completion_call)
|
||||
|
||||
def show_ad_2(
|
||||
self,
|
||||
purpose: str,
|
||||
on_completion_call: Callable[[bool], Any] | None = None,
|
||||
) -> None:
|
||||
"""(internal)"""
|
||||
self.last_ad_purpose = purpose
|
||||
_ba.show_ad_2(purpose, on_completion_call)
|
||||
|
||||
def call_after_ad(self, call: Callable[[], Any]) -> None:
|
||||
"""Run a call after potentially showing an ad."""
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-locals
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
app = _ba.app
|
||||
show = True
|
||||
|
||||
# No ads without net-connections, etc.
|
||||
if not _ba.can_show_ad():
|
||||
show = False
|
||||
if app.accounts_v1.have_pro():
|
||||
show = False # Pro disables interstitials.
|
||||
try:
|
||||
session = _ba.get_foreground_host_session()
|
||||
assert session is not None
|
||||
is_tournament = session.tournament_id is not None
|
||||
except Exception:
|
||||
is_tournament = False
|
||||
if is_tournament:
|
||||
show = False # Never show ads during tournaments.
|
||||
|
||||
if show:
|
||||
interval: float | None
|
||||
launch_count = app.config.get('launchCount', 0)
|
||||
|
||||
# If we're seeing short ads we may want to space them differently.
|
||||
interval_mult = (
|
||||
_internal.get_v1_account_misc_read_val(
|
||||
'ads.shortIntervalMult', 1.0
|
||||
)
|
||||
if self.last_ad_was_short
|
||||
else 1.0
|
||||
)
|
||||
if self.ad_amt is None:
|
||||
if launch_count <= 1:
|
||||
self.ad_amt = _internal.get_v1_account_misc_read_val(
|
||||
'ads.startVal1', 0.99
|
||||
)
|
||||
else:
|
||||
self.ad_amt = _internal.get_v1_account_misc_read_val(
|
||||
'ads.startVal2', 1.0
|
||||
)
|
||||
interval = None
|
||||
else:
|
||||
# So far we're cleared to show; now calc our
|
||||
# ad-show-threshold and see if we should *actually* show
|
||||
# (we reach our threshold faster the longer we've been
|
||||
# playing).
|
||||
base = 'ads' if _ba.has_video_ads() else 'ads2'
|
||||
min_lc = _internal.get_v1_account_misc_read_val(
|
||||
base + '.minLC', 0.0
|
||||
)
|
||||
max_lc = _internal.get_v1_account_misc_read_val(
|
||||
base + '.maxLC', 5.0
|
||||
)
|
||||
min_lc_scale = _internal.get_v1_account_misc_read_val(
|
||||
base + '.minLCScale', 0.25
|
||||
)
|
||||
max_lc_scale = _internal.get_v1_account_misc_read_val(
|
||||
base + '.maxLCScale', 0.34
|
||||
)
|
||||
min_lc_interval = _internal.get_v1_account_misc_read_val(
|
||||
base + '.minLCInterval', 360
|
||||
)
|
||||
max_lc_interval = _internal.get_v1_account_misc_read_val(
|
||||
base + '.maxLCInterval', 300
|
||||
)
|
||||
if launch_count < min_lc:
|
||||
lc_amt = 0.0
|
||||
elif launch_count > max_lc:
|
||||
lc_amt = 1.0
|
||||
else:
|
||||
lc_amt = (float(launch_count) - min_lc) / (max_lc - min_lc)
|
||||
incr = (1.0 - lc_amt) * min_lc_scale + lc_amt * max_lc_scale
|
||||
interval = (
|
||||
1.0 - lc_amt
|
||||
) * min_lc_interval + lc_amt * max_lc_interval
|
||||
self.ad_amt += incr
|
||||
assert self.ad_amt is not None
|
||||
if self.ad_amt >= 1.0:
|
||||
self.ad_amt = self.ad_amt % 1.0
|
||||
self.attempted_first_ad = True
|
||||
|
||||
# After we've reached the traditional show-threshold once,
|
||||
# try again whenever its been INTERVAL since our last successful
|
||||
# show.
|
||||
elif self.attempted_first_ad and (
|
||||
self.last_ad_completion_time is None
|
||||
or (
|
||||
interval is not None
|
||||
and _ba.time(TimeType.REAL) - self.last_ad_completion_time
|
||||
> (interval * interval_mult)
|
||||
)
|
||||
):
|
||||
# Reset our other counter too in this case.
|
||||
self.ad_amt = 0.0
|
||||
else:
|
||||
show = False
|
||||
|
||||
# If we're *still* cleared to show, actually tell the system to show.
|
||||
if show:
|
||||
# As a safety-check, set up an object that will run
|
||||
# the completion callback if we've returned and sat for 10 seconds
|
||||
# (in case some random ad network doesn't properly deliver its
|
||||
# completion callback).
|
||||
class _Payload:
|
||||
def __init__(self, pcall: Callable[[], Any]):
|
||||
self._call = pcall
|
||||
self._ran = False
|
||||
|
||||
def run(self, fallback: bool = False) -> None:
|
||||
"""Run fallback call (and issue a warning about it)."""
|
||||
if not self._ran:
|
||||
if fallback:
|
||||
print(
|
||||
(
|
||||
'ERROR: relying on fallback ad-callback! '
|
||||
'last network: '
|
||||
+ app.ads.last_ad_network
|
||||
+ ' (set '
|
||||
+ str(
|
||||
int(
|
||||
time.time()
|
||||
- app.ads.last_ad_network_set_time
|
||||
)
|
||||
)
|
||||
+ 's ago); purpose='
|
||||
+ app.ads.last_ad_purpose
|
||||
)
|
||||
)
|
||||
_ba.pushcall(self._call)
|
||||
self._ran = True
|
||||
|
||||
payload = _Payload(call)
|
||||
with _ba.Context('ui'):
|
||||
_ba.timer(
|
||||
5.0,
|
||||
lambda: payload.run(fallback=True),
|
||||
timetype=TimeType.REAL,
|
||||
)
|
||||
self.show_ad('between_game', on_completion_call=payload.run)
|
||||
else:
|
||||
_ba.pushcall(call) # Just run the callback without the ad.
|
||||
83
dist/ba_data/python/ba/_analytics.py
vendored
Normal file
83
dist/ba_data/python/ba/_analytics.py
vendored
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to analytics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
def game_begin_analytics() -> None:
|
||||
"""Update analytics events for the start of a game."""
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._dualteamsession import DualTeamSession
|
||||
from ba._freeforallsession import FreeForAllSession
|
||||
from ba._coopsession import CoopSession
|
||||
from ba._gameactivity import GameActivity
|
||||
|
||||
activity = _ba.getactivity(False)
|
||||
session = _ba.getsession(False)
|
||||
|
||||
# Fail gracefully if we didn't cleanly get a session and game activity.
|
||||
if not activity or not session or not isinstance(activity, GameActivity):
|
||||
return
|
||||
|
||||
if isinstance(session, CoopSession):
|
||||
campaign = session.campaign
|
||||
assert campaign is not None
|
||||
_ba.set_analytics_screen(
|
||||
'Coop Game: '
|
||||
+ campaign.name
|
||||
+ ' '
|
||||
+ campaign.getlevel(_ba.app.coop_session_args['level']).name
|
||||
)
|
||||
_ba.increment_analytics_count('Co-op round start')
|
||||
if len(activity.players) == 1:
|
||||
_ba.increment_analytics_count('Co-op round start 1 human player')
|
||||
elif len(activity.players) == 2:
|
||||
_ba.increment_analytics_count('Co-op round start 2 human players')
|
||||
elif len(activity.players) == 3:
|
||||
_ba.increment_analytics_count('Co-op round start 3 human players')
|
||||
elif len(activity.players) >= 4:
|
||||
_ba.increment_analytics_count('Co-op round start 4+ human players')
|
||||
|
||||
elif isinstance(session, DualTeamSession):
|
||||
_ba.set_analytics_screen('Teams Game: ' + activity.getname())
|
||||
_ba.increment_analytics_count('Teams round start')
|
||||
if len(activity.players) == 1:
|
||||
_ba.increment_analytics_count('Teams round start 1 human player')
|
||||
elif 1 < len(activity.players) < 8:
|
||||
_ba.increment_analytics_count(
|
||||
'Teams round start '
|
||||
+ str(len(activity.players))
|
||||
+ ' human players'
|
||||
)
|
||||
elif len(activity.players) >= 8:
|
||||
_ba.increment_analytics_count('Teams round start 8+ human players')
|
||||
|
||||
elif isinstance(session, FreeForAllSession):
|
||||
_ba.set_analytics_screen('FreeForAll Game: ' + activity.getname())
|
||||
_ba.increment_analytics_count('Free-for-all round start')
|
||||
if len(activity.players) == 1:
|
||||
_ba.increment_analytics_count(
|
||||
'Free-for-all round start 1 human player'
|
||||
)
|
||||
elif 1 < len(activity.players) < 8:
|
||||
_ba.increment_analytics_count(
|
||||
'Free-for-all round start '
|
||||
+ str(len(activity.players))
|
||||
+ ' human players'
|
||||
)
|
||||
elif len(activity.players) >= 8:
|
||||
_ba.increment_analytics_count(
|
||||
'Free-for-all round start 8+ human players'
|
||||
)
|
||||
|
||||
# For some analytics tracking on the c layer.
|
||||
_ba.reset_game_activity_tracking()
|
||||
769
dist/ba_data/python/ba/_app.py
vendored
Normal file
769
dist/ba_data/python/ba/_app.py
vendored
Normal file
|
|
@ -0,0 +1,769 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to the high level state of the app."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import _ba
|
||||
from ba._music import MusicSubsystem
|
||||
from ba._language import LanguageSubsystem
|
||||
from ba._ui import UISubsystem
|
||||
from ba._achievement import AchievementSubsystem
|
||||
from ba._plugin import PluginSubsystem
|
||||
from ba._accountv1 import AccountV1Subsystem
|
||||
from ba._meta import MetadataSubsystem
|
||||
from ba._ads import AdsSubsystem
|
||||
from ba._net import NetworkSubsystem
|
||||
from ba._workspace import WorkspaceSubsystem
|
||||
from ba._appcomponent import AppComponentSubsystem
|
||||
from ba import _internal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncio
|
||||
from typing import Any, Callable
|
||||
|
||||
import efro.log
|
||||
import ba
|
||||
from ba._cloud import CloudSubsystem
|
||||
from bastd.actor import spazappearance
|
||||
from ba._accountv2 import AccountV2Subsystem
|
||||
from ba._level import Level
|
||||
from ba._apputils import AppHealthMonitor
|
||||
|
||||
|
||||
class App:
|
||||
"""A class for high level app functionality and state.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
Use ba.app to access the single shared instance of this class.
|
||||
|
||||
Note that properties not documented here should be considered internal
|
||||
and subject to change without warning.
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
# Implementations for these will be filled in by internal libs.
|
||||
accounts_v2: AccountV2Subsystem
|
||||
cloud: CloudSubsystem
|
||||
|
||||
log_handler: efro.log.LogHandler
|
||||
health_monitor: AppHealthMonitor
|
||||
|
||||
class State(Enum):
|
||||
"""High level state the app can be in."""
|
||||
|
||||
# The launch process has not yet begun.
|
||||
INITIAL = 0
|
||||
|
||||
# Our app subsystems are being inited but should not yet interact.
|
||||
LAUNCHING = 1
|
||||
|
||||
# App subsystems are inited and interacting, but the app has not
|
||||
# yet embarked on a high level course of action. It is doing initial
|
||||
# account logins, workspace & asset downloads, etc. in order to
|
||||
# prepare for this.
|
||||
LOADING = 2
|
||||
|
||||
# All pieces are in place and the app is now doing its thing.
|
||||
RUNNING = 3
|
||||
|
||||
# The app is backgrounded or otherwise suspended.
|
||||
PAUSED = 4
|
||||
|
||||
# The app is shutting down.
|
||||
SHUTTING_DOWN = 5
|
||||
|
||||
@property
|
||||
def aioloop(self) -> asyncio.AbstractEventLoop:
|
||||
"""The Logic Thread's Asyncio Event Loop.
|
||||
|
||||
This allow async tasks to be run in the logic thread.
|
||||
Note that, at this time, the asyncio loop is encapsulated
|
||||
and explicitly stepped by the engine's logic thread loop and
|
||||
thus things like asyncio.get_running_loop() will not return this
|
||||
loop from most places in the logic thread; only from within a
|
||||
task explicitly created in this loop.
|
||||
"""
|
||||
assert self._aioloop is not None
|
||||
return self._aioloop
|
||||
|
||||
@property
|
||||
def build_number(self) -> int:
|
||||
"""Integer build number.
|
||||
|
||||
This value increases by at least 1 with each release of the game.
|
||||
It is independent of the human readable ba.App.version string.
|
||||
"""
|
||||
assert isinstance(self._env['build_number'], int)
|
||||
return self._env['build_number']
|
||||
|
||||
@property
|
||||
def device_name(self) -> str:
|
||||
"""Name of the device running the game."""
|
||||
assert isinstance(self._env['device_name'], str)
|
||||
return self._env['device_name']
|
||||
|
||||
@property
|
||||
def config_file_path(self) -> str:
|
||||
"""Where the game's config file is stored on disk."""
|
||||
assert isinstance(self._env['config_file_path'], str)
|
||||
return self._env['config_file_path']
|
||||
|
||||
@property
|
||||
def user_agent_string(self) -> str:
|
||||
"""String containing various bits of info about OS/device/etc."""
|
||||
assert isinstance(self._env['user_agent_string'], str)
|
||||
return self._env['user_agent_string']
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""Human-readable version string; something like '1.3.24'.
|
||||
|
||||
This should not be interpreted as a number; it may contain
|
||||
string elements such as 'alpha', 'beta', 'test', etc.
|
||||
If a numeric version is needed, use 'ba.App.build_number'.
|
||||
"""
|
||||
assert isinstance(self._env['version'], str)
|
||||
return self._env['version']
|
||||
|
||||
@property
|
||||
def debug_build(self) -> bool:
|
||||
"""Whether the app was compiled in debug mode.
|
||||
|
||||
Debug builds generally run substantially slower than non-debug
|
||||
builds due to compiler optimizations being disabled and extra
|
||||
checks being run.
|
||||
"""
|
||||
assert isinstance(self._env['debug_build'], bool)
|
||||
return self._env['debug_build']
|
||||
|
||||
@property
|
||||
def test_build(self) -> bool:
|
||||
"""Whether the game was compiled in test mode.
|
||||
|
||||
Test mode enables extra checks and features that are useful for
|
||||
release testing but which do not slow the game down significantly.
|
||||
"""
|
||||
assert isinstance(self._env['test_build'], bool)
|
||||
return self._env['test_build']
|
||||
|
||||
@property
|
||||
def python_directory_user(self) -> str:
|
||||
"""Path where the app looks for custom user scripts."""
|
||||
assert isinstance(self._env['python_directory_user'], str)
|
||||
return self._env['python_directory_user']
|
||||
|
||||
@property
|
||||
def python_directory_app(self) -> str:
|
||||
"""Path where the app looks for its bundled scripts."""
|
||||
assert isinstance(self._env['python_directory_app'], str)
|
||||
return self._env['python_directory_app']
|
||||
|
||||
@property
|
||||
def python_directory_app_site(self) -> str:
|
||||
"""Path containing pip packages bundled with the app."""
|
||||
assert isinstance(self._env['python_directory_app_site'], str)
|
||||
return self._env['python_directory_app_site']
|
||||
|
||||
@property
|
||||
def config(self) -> ba.AppConfig:
|
||||
"""The ba.AppConfig instance representing the app's config state."""
|
||||
assert self._config is not None
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def platform(self) -> str:
|
||||
"""Name of the current platform.
|
||||
|
||||
Examples are: 'mac', 'windows', android'.
|
||||
"""
|
||||
assert isinstance(self._env['platform'], str)
|
||||
return self._env['platform']
|
||||
|
||||
@property
|
||||
def subplatform(self) -> str:
|
||||
"""String for subplatform.
|
||||
|
||||
Can be empty. For the 'android' platform, subplatform may
|
||||
be 'google', 'amazon', etc.
|
||||
"""
|
||||
assert isinstance(self._env['subplatform'], str)
|
||||
return self._env['subplatform']
|
||||
|
||||
@property
|
||||
def api_version(self) -> int:
|
||||
"""The game's api version.
|
||||
|
||||
Only Python modules and packages associated with the current API
|
||||
version number will be detected by the game (see the ba_meta tag).
|
||||
This value will change whenever backward-incompatible changes are
|
||||
introduced to game APIs. When that happens, scripts should be updated
|
||||
accordingly and set to target the new API version number.
|
||||
"""
|
||||
from ba._meta import CURRENT_API_VERSION
|
||||
|
||||
return CURRENT_API_VERSION
|
||||
|
||||
@property
|
||||
def on_tv(self) -> bool:
|
||||
"""Whether the game is currently running on a TV."""
|
||||
assert isinstance(self._env['on_tv'], bool)
|
||||
return self._env['on_tv']
|
||||
|
||||
@property
|
||||
def vr_mode(self) -> bool:
|
||||
"""Whether the game is currently running in VR."""
|
||||
assert isinstance(self._env['vr_mode'], bool)
|
||||
return self._env['vr_mode']
|
||||
|
||||
@property
|
||||
def ui_bounds(self) -> tuple[float, float, float, float]:
|
||||
"""Bounds of the 'safe' screen area in ui space.
|
||||
|
||||
This tuple contains: (x-min, x-max, y-min, y-max)
|
||||
"""
|
||||
return _ba.uibounds()
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""(internal)
|
||||
|
||||
Do not instantiate this class; use ba.app to access
|
||||
the single shared instance.
|
||||
"""
|
||||
# pylint: disable=too-many-statements
|
||||
|
||||
self.state = self.State.INITIAL
|
||||
|
||||
self._bootstrapping_completed = False
|
||||
self._called_on_app_launching = False
|
||||
self._launch_completed = False
|
||||
self._initial_sign_in_completed = False
|
||||
self._meta_scan_completed = False
|
||||
self._called_on_app_loading = False
|
||||
self._called_on_app_running = False
|
||||
self._app_paused = False
|
||||
|
||||
# Config.
|
||||
self.config_file_healthy = False
|
||||
|
||||
# This is incremented any time the app is backgrounded/foregrounded;
|
||||
# can be a simple way to determine if network data should be
|
||||
# refreshed/etc.
|
||||
self.fg_state = 0
|
||||
|
||||
self._aioloop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
self._env = _ba.env()
|
||||
self.protocol_version: int = self._env['protocol_version']
|
||||
assert isinstance(self.protocol_version, int)
|
||||
self.toolbar_test: bool = self._env['toolbar_test']
|
||||
assert isinstance(self.toolbar_test, bool)
|
||||
self.demo_mode: bool = self._env['demo_mode']
|
||||
assert isinstance(self.demo_mode, bool)
|
||||
self.arcade_mode: bool = self._env['arcade_mode']
|
||||
assert isinstance(self.arcade_mode, bool)
|
||||
self.headless_mode: bool = self._env['headless_mode']
|
||||
assert isinstance(self.headless_mode, bool)
|
||||
self.iircade_mode: bool = self._env['iircade_mode']
|
||||
assert isinstance(self.iircade_mode, bool)
|
||||
self.allow_ticket_purchases: bool = not self.iircade_mode
|
||||
|
||||
# Default executor which can be used for misc background processing.
|
||||
# It should also be passed to any asyncio loops we create so that
|
||||
# everything shares the same single set of threads.
|
||||
self.threadpool = ThreadPoolExecutor(thread_name_prefix='baworker')
|
||||
|
||||
# Misc.
|
||||
self.tips: list[str] = []
|
||||
self.stress_test_reset_timer: ba.Timer | None = None
|
||||
self.did_weak_call_warning = False
|
||||
|
||||
self.log_have_new = False
|
||||
self.log_upload_timer_started = False
|
||||
self._config: ba.AppConfig | None = None
|
||||
self.printed_live_object_warning = False
|
||||
|
||||
# We include this extra hash with shared input-mapping names so
|
||||
# that we don't share mappings between differently-configured
|
||||
# systems. For instance, different android devices may give different
|
||||
# key values for the same controller type so we keep their mappings
|
||||
# distinct.
|
||||
self.input_map_hash: str | None = None
|
||||
|
||||
# Co-op Campaigns.
|
||||
self.campaigns: dict[str, ba.Campaign] = {}
|
||||
self.custom_coop_practice_games: list[str] = []
|
||||
|
||||
# Server Mode.
|
||||
self.server: ba.ServerController | None = None
|
||||
|
||||
self.components = AppComponentSubsystem()
|
||||
self.meta = MetadataSubsystem()
|
||||
self.accounts_v1 = AccountV1Subsystem()
|
||||
self.plugins = PluginSubsystem()
|
||||
self.music = MusicSubsystem()
|
||||
self.lang = LanguageSubsystem()
|
||||
self.ach = AchievementSubsystem()
|
||||
self.ui = UISubsystem()
|
||||
self.ads = AdsSubsystem()
|
||||
self.net = NetworkSubsystem()
|
||||
self.workspaces = WorkspaceSubsystem()
|
||||
|
||||
# Lobby.
|
||||
self.lobby_random_profile_index: int = 1
|
||||
self.lobby_random_char_index_offset = random.randrange(1000)
|
||||
self.lobby_account_profile_device_id: int | None = None
|
||||
|
||||
# Main Menu.
|
||||
self.main_menu_did_initial_transition = False
|
||||
self.main_menu_last_news_fetch_time: float | None = None
|
||||
|
||||
# Spaz.
|
||||
self.spaz_appearances: dict[str, spazappearance.Appearance] = {}
|
||||
self.last_spaz_turbo_warn_time: float = -99999.0
|
||||
|
||||
# Maps.
|
||||
self.maps: dict[str, type[ba.Map]] = {}
|
||||
|
||||
# Gameplay.
|
||||
self.teams_series_length = 7
|
||||
self.ffa_series_length = 24
|
||||
self.coop_session_args: dict = {}
|
||||
|
||||
self.value_test_defaults: dict = {}
|
||||
self.first_main_menu = True # FIXME: Move to mainmenu class.
|
||||
self.did_menu_intro = False # FIXME: Move to mainmenu class.
|
||||
self.main_menu_window_refresh_check_count = 0 # FIXME: Mv to mainmenu.
|
||||
self.main_menu_resume_callbacks: list = [] # Can probably go away.
|
||||
self.special_offer: dict | None = None
|
||||
self.ping_thread_count = 0
|
||||
self.invite_confirm_windows: list[Any] = [] # FIXME: Don't use Any.
|
||||
self.store_layout: dict[str, list[dict[str, Any]]] | None = None
|
||||
self.store_items: dict[str, dict] | None = None
|
||||
self.pro_sale_start_time: int | None = None
|
||||
self.pro_sale_start_val: int | None = None
|
||||
|
||||
self.delegate: ba.AppDelegate | None = None
|
||||
self._asyncio_timer: ba.Timer | None = None
|
||||
|
||||
def on_app_launching(self) -> None:
|
||||
"""Called when the app is first entering the launching state."""
|
||||
# pylint: disable=cyclic-import
|
||||
# pylint: disable=too-many-locals
|
||||
from ba import _asyncio
|
||||
from ba import _appconfig
|
||||
from ba import _map
|
||||
from ba import _campaign
|
||||
from bastd import appdelegate
|
||||
from bastd import maps as stdmaps
|
||||
from bastd.actor import spazappearance
|
||||
from ba._generated.enums import TimeType
|
||||
from ba._apputils import (
|
||||
log_dumped_app_state,
|
||||
handle_leftover_v1_cloud_log_file,
|
||||
AppHealthMonitor,
|
||||
)
|
||||
|
||||
assert _ba.in_logic_thread()
|
||||
|
||||
self._aioloop = _asyncio.setup_asyncio()
|
||||
self.health_monitor = AppHealthMonitor()
|
||||
|
||||
cfg = self.config
|
||||
|
||||
self.delegate = appdelegate.AppDelegate()
|
||||
|
||||
self.ui.on_app_launch()
|
||||
|
||||
spazappearance.register_appearances()
|
||||
_campaign.init_campaigns()
|
||||
|
||||
# FIXME: This should not be hard-coded.
|
||||
for maptype in [
|
||||
stdmaps.HockeyStadium,
|
||||
stdmaps.FootballStadium,
|
||||
stdmaps.Bridgit,
|
||||
stdmaps.BigG,
|
||||
stdmaps.Roundabout,
|
||||
stdmaps.MonkeyFace,
|
||||
stdmaps.ZigZag,
|
||||
stdmaps.ThePad,
|
||||
stdmaps.DoomShroom,
|
||||
stdmaps.LakeFrigid,
|
||||
stdmaps.TipTop,
|
||||
stdmaps.CragCastle,
|
||||
stdmaps.TowerD,
|
||||
stdmaps.HappyThoughts,
|
||||
stdmaps.StepRightUp,
|
||||
stdmaps.Courtyard,
|
||||
stdmaps.Rampage,
|
||||
]:
|
||||
_map.register_map(maptype)
|
||||
|
||||
# Non-test, non-debug builds should generally be blessed; warn if not.
|
||||
# (so I don't accidentally release a build that can't play tourneys)
|
||||
if (
|
||||
not self.debug_build
|
||||
and not self.test_build
|
||||
and not _internal.is_blessed()
|
||||
):
|
||||
_ba.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0))
|
||||
|
||||
# If there's a leftover log file, attempt to upload it to the
|
||||
# master-server and/or get rid of it.
|
||||
handle_leftover_v1_cloud_log_file()
|
||||
|
||||
# Only do this stuff if our config file is healthy so we don't
|
||||
# overwrite a broken one or whatnot and wipe out data.
|
||||
if not self.config_file_healthy:
|
||||
if self.platform in ('mac', 'linux', 'windows'):
|
||||
from bastd.ui.configerror import ConfigErrorWindow
|
||||
|
||||
_ba.pushcall(ConfigErrorWindow)
|
||||
return
|
||||
|
||||
# For now on other systems we just overwrite the bum config.
|
||||
# At this point settings are already set; lets just commit them
|
||||
# to disk.
|
||||
_appconfig.commit_app_config(force=True)
|
||||
|
||||
self.music.on_app_launch()
|
||||
|
||||
launch_count = cfg.get('launchCount', 0)
|
||||
launch_count += 1
|
||||
|
||||
# So we know how many times we've run the game at various
|
||||
# version milestones.
|
||||
for key in ('lc14173', 'lc14292'):
|
||||
cfg.setdefault(key, launch_count)
|
||||
|
||||
cfg['launchCount'] = launch_count
|
||||
cfg.commit()
|
||||
|
||||
# Run a test in a few seconds to see if we should pop up an existing
|
||||
# pending special offer.
|
||||
def check_special_offer() -> None:
|
||||
from bastd.ui.specialoffer import show_offer
|
||||
|
||||
config = self.config
|
||||
if (
|
||||
'pendingSpecialOffer' in config
|
||||
and _internal.get_public_login_id()
|
||||
== config['pendingSpecialOffer']['a']
|
||||
):
|
||||
self.special_offer = config['pendingSpecialOffer']['o']
|
||||
show_offer()
|
||||
|
||||
if not self.headless_mode:
|
||||
_ba.timer(3.0, check_special_offer, timetype=TimeType.REAL)
|
||||
|
||||
# Get meta-system scanning built-in stuff in the bg.
|
||||
self.meta.start_scan(scan_complete_cb=self.on_meta_scan_complete)
|
||||
|
||||
self.accounts_v2.on_app_launch()
|
||||
self.accounts_v1.on_app_launch()
|
||||
|
||||
# See note below in on_app_pause.
|
||||
if self.state != self.State.LAUNCHING:
|
||||
logging.error(
|
||||
'on_app_launch found state %s; expected LAUNCHING.', self.state
|
||||
)
|
||||
|
||||
# If any traceback dumps happened last run, log and clear them.
|
||||
log_dumped_app_state()
|
||||
|
||||
self._launch_completed = True
|
||||
self._update_state()
|
||||
|
||||
def on_app_loading(self) -> None:
|
||||
"""Called when initially entering the loading state."""
|
||||
|
||||
def on_app_running(self) -> None:
|
||||
"""Called when initially entering the running state."""
|
||||
|
||||
self.plugins.on_app_running()
|
||||
|
||||
# from ba._dependency import test_depset
|
||||
# test_depset()
|
||||
|
||||
def on_bootstrapping_completed(self) -> None:
|
||||
"""Called by the C++ layer once its ready to rock."""
|
||||
assert _ba.in_logic_thread()
|
||||
assert not self._bootstrapping_completed
|
||||
self._bootstrapping_completed = True
|
||||
self._update_state()
|
||||
|
||||
def on_meta_scan_complete(self) -> None:
|
||||
"""Called by meta-scan when it is done doing its thing."""
|
||||
assert _ba.in_logic_thread()
|
||||
self.plugins.on_meta_scan_complete()
|
||||
|
||||
assert not self._meta_scan_completed
|
||||
self._meta_scan_completed = True
|
||||
self._update_state()
|
||||
|
||||
def _update_state(self) -> None:
|
||||
assert _ba.in_logic_thread()
|
||||
|
||||
if self._app_paused:
|
||||
# Entering paused state:
|
||||
if self.state is not self.State.PAUSED:
|
||||
self.state = self.State.PAUSED
|
||||
self.cloud.on_app_pause()
|
||||
self.accounts_v1.on_app_pause()
|
||||
self.plugins.on_app_pause()
|
||||
self.health_monitor.on_app_pause()
|
||||
else:
|
||||
# Leaving paused state:
|
||||
if self.state is self.State.PAUSED:
|
||||
self.fg_state += 1
|
||||
self.cloud.on_app_resume()
|
||||
self.accounts_v1.on_app_resume()
|
||||
self.music.on_app_resume()
|
||||
self.plugins.on_app_resume()
|
||||
self.health_monitor.on_app_resume()
|
||||
|
||||
# Handle initially entering or returning to other states.
|
||||
if self._initial_sign_in_completed and self._meta_scan_completed:
|
||||
self.state = self.State.RUNNING
|
||||
if not self._called_on_app_running:
|
||||
self._called_on_app_running = True
|
||||
self.on_app_running()
|
||||
elif self._launch_completed:
|
||||
self.state = self.State.LOADING
|
||||
if not self._called_on_app_loading:
|
||||
self._called_on_app_loading = True
|
||||
self.on_app_loading()
|
||||
else:
|
||||
# Only thing left is launching. We shouldn't be getting
|
||||
# called before at least that is complete.
|
||||
assert self._bootstrapping_completed
|
||||
self.state = self.State.LAUNCHING
|
||||
if not self._called_on_app_launching:
|
||||
self._called_on_app_launching = True
|
||||
self.on_app_launching()
|
||||
|
||||
def on_app_pause(self) -> None:
|
||||
"""Called when the app goes to a suspended state."""
|
||||
|
||||
assert not self._app_paused # Should avoid redundant calls.
|
||||
self._app_paused = True
|
||||
self._update_state()
|
||||
|
||||
def on_app_resume(self) -> None:
|
||||
"""Run when the app resumes from a suspended state."""
|
||||
|
||||
assert self._app_paused # Should avoid redundant calls.
|
||||
self._app_paused = False
|
||||
self._update_state()
|
||||
|
||||
def on_app_shutdown(self) -> None:
|
||||
"""(internal)"""
|
||||
self.state = self.State.SHUTTING_DOWN
|
||||
self.music.on_app_shutdown()
|
||||
self.plugins.on_app_shutdown()
|
||||
|
||||
def read_config(self) -> None:
|
||||
"""(internal)"""
|
||||
from ba._appconfig import read_config
|
||||
|
||||
self._config, self.config_file_healthy = read_config()
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pause the game due to a user request or menu popping up.
|
||||
|
||||
If there's a foreground host-activity that says it's pausable, tell it
|
||||
to pause ..we now no longer pause if there are connected clients.
|
||||
"""
|
||||
activity: ba.Activity | None = _ba.get_foreground_host_activity()
|
||||
if (
|
||||
activity is not None
|
||||
and activity.allow_pausing
|
||||
and not _ba.have_connected_clients()
|
||||
):
|
||||
from ba._language import Lstr
|
||||
from ba._nodeactor import NodeActor
|
||||
|
||||
# FIXME: Shouldn't be touching scene stuff here;
|
||||
# should just pass the request on to the host-session.
|
||||
with _ba.Context(activity):
|
||||
globs = activity.globalsnode
|
||||
if not globs.paused:
|
||||
_ba.playsound(_ba.getsound('refWhistle'))
|
||||
globs.paused = True
|
||||
|
||||
# FIXME: This should not be an attr on Actor.
|
||||
activity.paused_text = NodeActor(
|
||||
_ba.newnode(
|
||||
'text',
|
||||
attrs={
|
||||
'text': Lstr(resource='pausedByHostText'),
|
||||
'client_only': True,
|
||||
'flatness': 1.0,
|
||||
'h_align': 'center',
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume the game due to a user request or menu closing.
|
||||
|
||||
If there's a foreground host-activity that's currently paused, tell it
|
||||
to resume.
|
||||
"""
|
||||
|
||||
# FIXME: Shouldn't be touching scene stuff here;
|
||||
# should just pass the request on to the host-session.
|
||||
activity = _ba.get_foreground_host_activity()
|
||||
if activity is not None:
|
||||
with _ba.Context(activity):
|
||||
globs = activity.globalsnode
|
||||
if globs.paused:
|
||||
_ba.playsound(_ba.getsound('refWhistle'))
|
||||
globs.paused = False
|
||||
|
||||
# FIXME: This should not be an actor attr.
|
||||
activity.paused_text = None
|
||||
|
||||
def add_coop_practice_level(self, level: Level) -> None:
|
||||
"""Adds an individual level to the 'practice' section in Co-op."""
|
||||
|
||||
# Assign this level to our catch-all campaign.
|
||||
self.campaigns['Challenges'].addlevel(level)
|
||||
|
||||
# Make note to add it to our challenges UI.
|
||||
self.custom_coop_practice_games.append(f'Challenges:{level.name}')
|
||||
|
||||
def return_to_main_menu_session_gracefully(
|
||||
self, reset_ui: bool = True
|
||||
) -> None:
|
||||
"""Attempt to cleanly get back to the main menu."""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba import _benchmark
|
||||
from ba._general import Call
|
||||
from bastd.mainmenu import MainMenuSession
|
||||
|
||||
if reset_ui:
|
||||
_ba.app.ui.clear_main_menu_window()
|
||||
|
||||
if isinstance(_ba.get_foreground_host_session(), MainMenuSession):
|
||||
# It may be possible we're on the main menu but the screen is faded
|
||||
# so fade back in.
|
||||
_ba.fade_screen(True)
|
||||
return
|
||||
|
||||
_benchmark.stop_stress_test() # Stop stress-test if in progress.
|
||||
|
||||
# If we're in a host-session, tell them to end.
|
||||
# This lets them tear themselves down gracefully.
|
||||
host_session: ba.Session | None = _ba.get_foreground_host_session()
|
||||
if host_session is not None:
|
||||
|
||||
# Kick off a little transaction so we'll hopefully have all the
|
||||
# latest account state when we get back to the menu.
|
||||
_internal.add_transaction(
|
||||
{'type': 'END_SESSION', 'sType': str(type(host_session))}
|
||||
)
|
||||
_internal.run_transactions()
|
||||
|
||||
host_session.end()
|
||||
|
||||
# Otherwise just force the issue.
|
||||
else:
|
||||
_ba.pushcall(Call(_ba.new_host_session, MainMenuSession))
|
||||
|
||||
def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None:
|
||||
"""(internal)"""
|
||||
|
||||
# If there's no main menu up, just call immediately.
|
||||
if not self.ui.has_main_menu_window():
|
||||
with _ba.Context('ui'):
|
||||
call()
|
||||
else:
|
||||
self.main_menu_resume_callbacks.append(call)
|
||||
|
||||
def launch_coop_game(
|
||||
self, game: str, force: bool = False, args: dict | None = None
|
||||
) -> bool:
|
||||
"""High level way to launch a local co-op session."""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._campaign import getcampaign
|
||||
from bastd.ui.coop.level import CoopLevelLockedWindow
|
||||
|
||||
if args is None:
|
||||
args = {}
|
||||
if game == '':
|
||||
raise ValueError('empty game name')
|
||||
campaignname, levelname = game.split(':')
|
||||
campaign = getcampaign(campaignname)
|
||||
|
||||
# If this campaign is sequential, make sure we've completed the
|
||||
# one before this.
|
||||
if campaign.sequential and not force:
|
||||
for level in campaign.levels:
|
||||
if level.name == levelname:
|
||||
break
|
||||
if not level.complete:
|
||||
CoopLevelLockedWindow(
|
||||
campaign.getlevel(levelname).displayname,
|
||||
campaign.getlevel(level.name).displayname,
|
||||
)
|
||||
return False
|
||||
|
||||
# Ok, we're good to go.
|
||||
self.coop_session_args = {
|
||||
'campaign': campaignname,
|
||||
'level': levelname,
|
||||
}
|
||||
for arg_name, arg_val in list(args.items()):
|
||||
self.coop_session_args[arg_name] = arg_val
|
||||
|
||||
def _fade_end() -> None:
|
||||
from ba import _coopsession
|
||||
|
||||
try:
|
||||
_ba.new_host_session(_coopsession.CoopSession)
|
||||
except Exception:
|
||||
from ba import _error
|
||||
|
||||
_error.print_exception()
|
||||
from bastd.mainmenu import MainMenuSession
|
||||
|
||||
_ba.new_host_session(MainMenuSession)
|
||||
|
||||
_ba.fade_screen(False, endcall=_fade_end)
|
||||
return True
|
||||
|
||||
def handle_deep_link(self, url: str) -> None:
|
||||
"""Handle a deep link URL."""
|
||||
from ba._language import Lstr
|
||||
|
||||
appname = _ba.appname()
|
||||
if url.startswith(f'{appname}://code/'):
|
||||
code = url.replace(f'{appname}://code/', '')
|
||||
self.accounts_v1.add_pending_promo_code(code)
|
||||
else:
|
||||
_ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
|
||||
_ba.playsound(_ba.getsound('error'))
|
||||
|
||||
def on_initial_sign_in_completed(self) -> None:
|
||||
"""Callback to be run after initial sign-in (or lack thereof).
|
||||
|
||||
This period includes things such as syncing account workspaces
|
||||
or other data so it may take a substantial amount of time.
|
||||
This should also run after a short amount of time if no login
|
||||
has occurred.
|
||||
"""
|
||||
# Tell meta it can start scanning extra stuff that just showed up
|
||||
# (account workspaces).
|
||||
self.meta.start_extra_scan()
|
||||
|
||||
self._initial_sign_in_completed = True
|
||||
self._update_state()
|
||||
90
dist/ba_data/python/ba/_appcomponent.py
vendored
Normal file
90
dist/ba_data/python/ba/_appcomponent.py
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Provides the AppComponent class."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypeVar, cast
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Callable, Any
|
||||
|
||||
T = TypeVar('T', bound=type)
|
||||
|
||||
|
||||
class AppComponentSubsystem:
|
||||
"""Subsystem for wrangling AppComponents.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
This subsystem acts as a registry for classes providing particular
|
||||
functionality for the app, and allows plugins or other custom code to
|
||||
easily override said functionality.
|
||||
|
||||
Use ba.app.components to get the single shared instance of this class.
|
||||
|
||||
The general idea with this setup is that a base-class is defined to
|
||||
provide some functionality and then anyone wanting that functionality
|
||||
uses the getclass() method with that base class to return the current
|
||||
registered implementation. The user should not know or care whether
|
||||
they are getting the base class itself or some other implementation.
|
||||
|
||||
Change-callbacks can also be requested for base classes which will
|
||||
fire in a deferred manner when particular base-classes are overridden.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._implementations: dict[type, type] = {}
|
||||
self._prev_implementations: dict[type, type] = {}
|
||||
self._dirty_base_classes: set[type] = set()
|
||||
self._change_callbacks: dict[type, list[Callable[[Any], None]]] = {}
|
||||
|
||||
def setclass(self, baseclass: type, implementation: type) -> None:
|
||||
"""Set the class providing an implementation of some base-class.
|
||||
|
||||
The provided implementation class must be a subclass of baseclass.
|
||||
"""
|
||||
# Currently limiting this to logic-thread use; can revisit if needed
|
||||
# (would need to guard access to our implementations dict).
|
||||
assert _ba.in_logic_thread()
|
||||
|
||||
if not issubclass(implementation, baseclass):
|
||||
raise TypeError(
|
||||
f'Implementation {implementation}'
|
||||
f' is not a subclass of baseclass {baseclass}.'
|
||||
)
|
||||
|
||||
self._implementations[baseclass] = implementation
|
||||
|
||||
# If we're the first thing getting dirtied, set up a callback to
|
||||
# clean everything. And add ourself to the dirty list regardless.
|
||||
if not self._dirty_base_classes:
|
||||
_ba.pushcall(self._run_change_callbacks)
|
||||
self._dirty_base_classes.add(baseclass)
|
||||
|
||||
def getclass(self, baseclass: T) -> T:
|
||||
"""Given a base-class, return the currently set implementation class.
|
||||
|
||||
If no custom implementation has been set, the provided base-class
|
||||
is returned.
|
||||
"""
|
||||
assert _ba.in_logic_thread()
|
||||
|
||||
del baseclass # Unused.
|
||||
return cast(T, None)
|
||||
|
||||
def register_change_callback(
|
||||
self, baseclass: T, callback: Callable[[T], None]
|
||||
) -> None:
|
||||
"""Register a callback to fire when a class implementation changes.
|
||||
|
||||
The callback will be scheduled to run in the logic thread event
|
||||
loop. Note that any further setclass calls before the callback
|
||||
runs will not result in additional callbacks.
|
||||
"""
|
||||
assert _ba.in_logic_thread()
|
||||
self._change_callbacks.setdefault(baseclass, []).append(callback)
|
||||
|
||||
def _run_change_callbacks(self) -> None:
|
||||
pass
|
||||
177
dist/ba_data/python/ba/_appconfig.py
vendored
Normal file
177
dist/ba_data/python/ba/_appconfig.py
vendored
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Provides the AppConfig class."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AppConfig(dict):
|
||||
"""A special dict that holds the game's persistent configuration values.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
It also provides methods for fetching values with app-defined fallback
|
||||
defaults, applying contained values to the game, and committing the
|
||||
config to storage.
|
||||
|
||||
Call ba.appconfig() to get the single shared instance of this class.
|
||||
|
||||
AppConfig data is stored as json on disk on so make sure to only place
|
||||
json-friendly values in it (dict, list, str, float, int, bool).
|
||||
Be aware that tuples will be quietly converted to lists when stored.
|
||||
"""
|
||||
|
||||
def resolve(self, key: str) -> Any:
|
||||
"""Given a string key, return a config value (type varies).
|
||||
|
||||
This will substitute application defaults for values not present in
|
||||
the config dict, filter some invalid values, etc. Note that these
|
||||
values do not represent the state of the app; simply the state of its
|
||||
config. Use ba.App to access actual live state.
|
||||
|
||||
Raises an Exception for unrecognized key names. To get the list of keys
|
||||
supported by this method, use ba.AppConfig.builtin_keys(). Note that it
|
||||
is perfectly legal to store other data in the config; it just needs to
|
||||
be accessed through standard dict methods and missing values handled
|
||||
manually.
|
||||
"""
|
||||
return _ba.resolve_appconfig_value(key)
|
||||
|
||||
def default_value(self, key: str) -> Any:
|
||||
"""Given a string key, return its predefined default value.
|
||||
|
||||
This is the value that will be returned by ba.AppConfig.resolve() if
|
||||
the key is not present in the config dict or of an incompatible type.
|
||||
|
||||
Raises an Exception for unrecognized key names. To get the list of keys
|
||||
supported by this method, use ba.AppConfig.builtin_keys(). Note that it
|
||||
is perfectly legal to store other data in the config; it just needs to
|
||||
be accessed through standard dict methods and missing values handled
|
||||
manually.
|
||||
"""
|
||||
return _ba.get_appconfig_default_value(key)
|
||||
|
||||
def builtin_keys(self) -> list[str]:
|
||||
"""Return the list of valid key names recognized by ba.AppConfig.
|
||||
|
||||
This set of keys can be used with resolve(), default_value(), etc.
|
||||
It does not vary across platforms and may include keys that are
|
||||
obsolete or not relevant on the current running version. (for instance,
|
||||
VR related keys on non-VR platforms). This is to minimize the amount
|
||||
of platform checking necessary)
|
||||
|
||||
Note that it is perfectly legal to store arbitrary named data in the
|
||||
config, but in that case it is up to the user to test for the existence
|
||||
of the key in the config dict, fall back to consistent defaults, etc.
|
||||
"""
|
||||
return _ba.get_appconfig_builtin_keys()
|
||||
|
||||
def apply(self) -> None:
|
||||
"""Apply config values to the running app."""
|
||||
_ba.apply_config()
|
||||
|
||||
def commit(self) -> None:
|
||||
"""Commits the config to local storage.
|
||||
|
||||
Note that this call is asynchronous so the actual write to disk may not
|
||||
occur immediately.
|
||||
"""
|
||||
commit_app_config()
|
||||
|
||||
def apply_and_commit(self) -> None:
|
||||
"""Run apply() followed by commit(); for convenience.
|
||||
|
||||
(This way the commit() will not occur if apply() hits invalid data)
|
||||
"""
|
||||
self.apply()
|
||||
self.commit()
|
||||
|
||||
|
||||
def read_config() -> tuple[AppConfig, bool]:
|
||||
"""Read the game config."""
|
||||
import os
|
||||
import json
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
config_file_healthy = False
|
||||
|
||||
# NOTE: it is assumed that this only gets called once and the
|
||||
# config object will not change from here on out
|
||||
config_file_path = _ba.app.config_file_path
|
||||
config_contents = ''
|
||||
try:
|
||||
if os.path.exists(config_file_path):
|
||||
with open(config_file_path, encoding='utf-8') as infile:
|
||||
config_contents = infile.read()
|
||||
config = AppConfig(json.loads(config_contents))
|
||||
else:
|
||||
config = AppConfig()
|
||||
config_file_healthy = True
|
||||
|
||||
except Exception as exc:
|
||||
print(
|
||||
(
|
||||
'error reading config file at time '
|
||||
+ str(_ba.time(TimeType.REAL))
|
||||
+ ': \''
|
||||
+ config_file_path
|
||||
+ '\':\n'
|
||||
),
|
||||
exc,
|
||||
)
|
||||
|
||||
# Whenever this happens lets back up the broken one just in case it
|
||||
# gets overwritten accidentally.
|
||||
print(
|
||||
(
|
||||
'backing up current config file to \''
|
||||
+ config_file_path
|
||||
+ ".broken\'"
|
||||
)
|
||||
)
|
||||
try:
|
||||
import shutil
|
||||
|
||||
shutil.copyfile(config_file_path, config_file_path + '.broken')
|
||||
except Exception as exc2:
|
||||
print('EXC copying broken config:', exc2)
|
||||
config = AppConfig()
|
||||
|
||||
# Now attempt to read one of our 'prev' backup copies.
|
||||
prev_path = config_file_path + '.prev'
|
||||
try:
|
||||
if os.path.exists(prev_path):
|
||||
with open(prev_path, encoding='utf-8') as infile:
|
||||
config_contents = infile.read()
|
||||
config = AppConfig(json.loads(config_contents))
|
||||
else:
|
||||
config = AppConfig()
|
||||
config_file_healthy = True
|
||||
print('successfully read backup config.')
|
||||
except Exception as exc2:
|
||||
print('EXC reading prev backup config:', exc2)
|
||||
return config, config_file_healthy
|
||||
|
||||
|
||||
def commit_app_config(force: bool = False) -> None:
|
||||
"""Commit the config to persistent storage.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
(internal)
|
||||
"""
|
||||
from ba._internal import mark_config_dirty
|
||||
|
||||
if not _ba.app.config_file_healthy and not force:
|
||||
print(
|
||||
'Current config file is broken; '
|
||||
'skipping write to avoid losing settings.'
|
||||
)
|
||||
return
|
||||
mark_config_dirty()
|
||||
36
dist/ba_data/python/ba/_appdelegate.py
vendored
Normal file
36
dist/ba_data/python/ba/_appdelegate.py
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Defines AppDelegate class for handling high level app functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Callable
|
||||
import ba
|
||||
|
||||
|
||||
class AppDelegate:
|
||||
"""Defines handlers for high level app functionality.
|
||||
|
||||
Category: App Classes
|
||||
"""
|
||||
|
||||
def create_default_game_settings_ui(
|
||||
self,
|
||||
gameclass: type[ba.GameActivity],
|
||||
sessiontype: type[ba.Session],
|
||||
settings: dict | None,
|
||||
completion_call: Callable[[dict | None], None],
|
||||
) -> None:
|
||||
"""Launch a UI to configure the given game config.
|
||||
|
||||
It should manipulate the contents of config and call completion_call
|
||||
when done.
|
||||
"""
|
||||
del gameclass, sessiontype, settings, completion_call # Unused.
|
||||
from ba import _error
|
||||
|
||||
_error.print_error(
|
||||
"create_default_game_settings_ui needs to be overridden"
|
||||
)
|
||||
3
dist/ba_data/python/ba/_appmode.py
vendored
Normal file
3
dist/ba_data/python/ba/_appmode.py
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to the high level state of the app."""
|
||||
509
dist/ba_data/python/ba/_apputils.py
vendored
Normal file
509
dist/ba_data/python/ba/_apputils.py
vendored
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Utility functionality related to the overall operation of the app."""
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import os
|
||||
import logging
|
||||
from threading import Thread
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from efro.log import LogLevel
|
||||
from efro.dataclassio import ioprepped, dataclass_to_json, dataclass_from_json
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, TextIO
|
||||
import ba
|
||||
|
||||
|
||||
def is_browser_likely_available() -> bool:
|
||||
"""Return whether a browser likely exists on the current device.
|
||||
|
||||
category: General Utility Functions
|
||||
|
||||
If this returns False you may want to avoid calling ba.show_url()
|
||||
with any lengthy addresses. (ba.show_url() will display an address
|
||||
as a string in a window if unable to bring up a browser, but that
|
||||
is only useful for simple URLs.)
|
||||
"""
|
||||
app = _ba.app
|
||||
platform = app.platform
|
||||
touchscreen = _ba.getinputdevice('TouchScreen', '#1', doraise=False)
|
||||
|
||||
# If we're on a vr device or an android device with no touchscreen,
|
||||
# assume no browser.
|
||||
# FIXME: Might not be the case anymore; should make this definable
|
||||
# at the platform level.
|
||||
if app.vr_mode or (platform == 'android' and touchscreen is None):
|
||||
return False
|
||||
|
||||
# Anywhere else assume we've got one.
|
||||
return True
|
||||
|
||||
|
||||
def get_remote_app_name() -> ba.Lstr:
|
||||
"""(internal)"""
|
||||
from ba import _language
|
||||
|
||||
return _language.Lstr(resource='remote_app.app_name')
|
||||
|
||||
|
||||
def should_submit_debug_info() -> bool:
|
||||
"""(internal)"""
|
||||
return _ba.app.config.get('Submit Debug Info', True)
|
||||
|
||||
|
||||
def handle_v1_cloud_log() -> None:
|
||||
"""Called on debug log prints.
|
||||
|
||||
When this happens, we can upload our log to the server
|
||||
after a short bit if desired.
|
||||
"""
|
||||
from ba._net import master_server_post
|
||||
from ba._generated.enums import TimeType
|
||||
from ba._internal import get_news_show
|
||||
|
||||
app = _ba.app
|
||||
app.log_have_new = True
|
||||
if not app.log_upload_timer_started:
|
||||
|
||||
def _put_log() -> None:
|
||||
try:
|
||||
sessionname = str(_ba.get_foreground_host_session())
|
||||
except Exception:
|
||||
sessionname = 'unavailable'
|
||||
try:
|
||||
activityname = str(_ba.get_foreground_host_activity())
|
||||
except Exception:
|
||||
activityname = 'unavailable'
|
||||
|
||||
info = {
|
||||
'log': _ba.get_v1_cloud_log(),
|
||||
'version': app.version,
|
||||
'build': app.build_number,
|
||||
'userAgentString': app.user_agent_string,
|
||||
'session': sessionname,
|
||||
'activity': activityname,
|
||||
'fatal': 0,
|
||||
'userRanCommands': _ba.has_user_run_commands(),
|
||||
'time': _ba.time(TimeType.REAL),
|
||||
'userModded': _ba.workspaces_in_use(),
|
||||
'newsShow': get_news_show(),
|
||||
}
|
||||
|
||||
def response(data: Any) -> None:
|
||||
# A non-None response means success; lets
|
||||
# take note that we don't need to report further
|
||||
# log info this run
|
||||
if data is not None:
|
||||
app.log_have_new = False
|
||||
_ba.mark_log_sent()
|
||||
|
||||
master_server_post('bsLog', info, response)
|
||||
|
||||
app.log_upload_timer_started = True
|
||||
|
||||
# Delay our log upload slightly in case other
|
||||
# pertinent info gets printed between now and then.
|
||||
with _ba.Context('ui'):
|
||||
_ba.timer(3.0, _put_log, timetype=TimeType.REAL)
|
||||
|
||||
# After a while, allow another log-put.
|
||||
def _reset() -> None:
|
||||
app.log_upload_timer_started = False
|
||||
if app.log_have_new:
|
||||
handle_v1_cloud_log()
|
||||
|
||||
if not _ba.is_log_full():
|
||||
with _ba.Context('ui'):
|
||||
_ba.timer(
|
||||
600.0,
|
||||
_reset,
|
||||
timetype=TimeType.REAL,
|
||||
suppress_format_warning=True,
|
||||
)
|
||||
|
||||
|
||||
def handle_leftover_v1_cloud_log_file() -> None:
|
||||
"""Handle an un-uploaded v1-cloud-log from a previous run."""
|
||||
try:
|
||||
import json
|
||||
from ba._net import master_server_post
|
||||
|
||||
if os.path.exists(_ba.get_v1_cloud_log_file_path()):
|
||||
with open(
|
||||
_ba.get_v1_cloud_log_file_path(), encoding='utf-8'
|
||||
) as infile:
|
||||
info = json.loads(infile.read())
|
||||
infile.close()
|
||||
do_send = should_submit_debug_info()
|
||||
if do_send:
|
||||
|
||||
def response(data: Any) -> None:
|
||||
# Non-None response means we were successful;
|
||||
# lets kill it.
|
||||
if data is not None:
|
||||
try:
|
||||
os.remove(_ba.get_v1_cloud_log_file_path())
|
||||
except FileNotFoundError:
|
||||
# Saw this in the wild. The file just existed
|
||||
# a moment ago but I suppose something could have
|
||||
# killed it since. ¯\_(ツ)_/¯
|
||||
pass
|
||||
|
||||
master_server_post('bsLog', info, response)
|
||||
else:
|
||||
# If they don't want logs uploaded just kill it.
|
||||
os.remove(_ba.get_v1_cloud_log_file_path())
|
||||
except Exception:
|
||||
from ba import _error
|
||||
|
||||
_error.print_exception('Error handling leftover log file.')
|
||||
|
||||
|
||||
def garbage_collect_session_end() -> None:
|
||||
"""Run explicit garbage collection with extra checks for session end."""
|
||||
gc.collect()
|
||||
|
||||
# Can be handy to print this to check for leaks between games.
|
||||
if bool(False):
|
||||
print('PY OBJ COUNT', len(gc.get_objects()))
|
||||
if gc.garbage:
|
||||
print('PYTHON GC FOUND', len(gc.garbage), 'UNCOLLECTIBLE OBJECTS:')
|
||||
for i, obj in enumerate(gc.garbage):
|
||||
print(str(i) + ':', obj)
|
||||
|
||||
# NOTE: no longer running these checks. Perhaps we can allow
|
||||
# running them with an explicit flag passed, but we should never
|
||||
# run them by default because gc.get_objects() can mess up the app.
|
||||
# See notes at top of efro.debug.
|
||||
if bool(False):
|
||||
print_live_object_warnings('after session shutdown')
|
||||
|
||||
|
||||
def garbage_collect() -> None:
|
||||
"""Run an explicit pass of garbage collection.
|
||||
|
||||
category: General Utility Functions
|
||||
|
||||
May also print warnings/etc. if collection takes too long or if
|
||||
uncollectible objects are found (so use this instead of simply
|
||||
gc.collect().
|
||||
"""
|
||||
gc.collect()
|
||||
|
||||
|
||||
def print_live_object_warnings(
|
||||
when: Any,
|
||||
ignore_session: ba.Session | None = None,
|
||||
ignore_activity: ba.Activity | None = None,
|
||||
) -> None:
|
||||
"""Print warnings for remaining objects in the current context.
|
||||
|
||||
IMPORTANT - don't call this in production; usage of gc.get_objects()
|
||||
can bork Python. See notes at top of efro.debug module.
|
||||
"""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._session import Session
|
||||
from ba._actor import Actor
|
||||
from ba._activity import Activity
|
||||
|
||||
sessions: list[ba.Session] = []
|
||||
activities: list[ba.Activity] = []
|
||||
actors: list[ba.Actor] = []
|
||||
|
||||
# Once we come across leaked stuff, printing again is probably
|
||||
# redundant.
|
||||
if _ba.app.printed_live_object_warning:
|
||||
return
|
||||
for obj in gc.get_objects():
|
||||
if isinstance(obj, Actor):
|
||||
actors.append(obj)
|
||||
elif isinstance(obj, Session):
|
||||
sessions.append(obj)
|
||||
elif isinstance(obj, Activity):
|
||||
activities.append(obj)
|
||||
|
||||
# Complain about any remaining sessions.
|
||||
for session in sessions:
|
||||
if session is ignore_session:
|
||||
continue
|
||||
_ba.app.printed_live_object_warning = True
|
||||
print(f'ERROR: Session found {when}: {session}')
|
||||
|
||||
# Complain about any remaining activities.
|
||||
for activity in activities:
|
||||
if activity is ignore_activity:
|
||||
continue
|
||||
_ba.app.printed_live_object_warning = True
|
||||
print(f'ERROR: Activity found {when}: {activity}')
|
||||
|
||||
# Complain about any remaining actors.
|
||||
for actor in actors:
|
||||
_ba.app.printed_live_object_warning = True
|
||||
print(f'ERROR: Actor found {when}: {actor}')
|
||||
|
||||
|
||||
def print_corrupt_file_error() -> None:
|
||||
"""Print an error if a corrupt file is found."""
|
||||
from ba._general import Call
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
_ba.timer(
|
||||
2.0,
|
||||
lambda: _ba.screenmessage(
|
||||
_ba.app.lang.get_resource('internal.corruptFileText').replace(
|
||||
'${EMAIL}', 'support@froemling.net'
|
||||
),
|
||||
color=(1, 0, 0),
|
||||
),
|
||||
timetype=TimeType.REAL,
|
||||
)
|
||||
_ba.timer(
|
||||
2.0, Call(_ba.playsound, _ba.getsound('error')), timetype=TimeType.REAL
|
||||
)
|
||||
|
||||
|
||||
_tbfiles: list[TextIO] = []
|
||||
|
||||
|
||||
@ioprepped
|
||||
@dataclass
|
||||
class DumpedAppStateMetadata:
|
||||
"""High level info about a dumped app state."""
|
||||
|
||||
reason: str
|
||||
app_time: float
|
||||
log_level: LogLevel
|
||||
|
||||
|
||||
def dump_app_state(
|
||||
delay: float = 0.0,
|
||||
reason: str = 'Unspecified',
|
||||
log_level: LogLevel = LogLevel.WARNING,
|
||||
) -> None:
|
||||
"""Dump various app state for debugging purposes.
|
||||
|
||||
This includes stack traces for all Python threads (and potentially
|
||||
other info in the future).
|
||||
|
||||
This is intended for use debugging deadlock situations. It will dump
|
||||
to preset file location(s) in the app config dir, and will attempt to
|
||||
log and clear the results after dumping. If that should fail (due to
|
||||
a hung app, etc.), then the results will be logged and cleared on the
|
||||
next app run.
|
||||
|
||||
Do not use this call during regular smooth operation of the app; it
|
||||
is should only be used for debugging or in response to confirmed
|
||||
problems as it can leak file descriptors, cause hitches, etc.
|
||||
"""
|
||||
# pylint: disable=consider-using-with
|
||||
import faulthandler
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
# Dump our metadata immediately. If a delay is passed, it generally
|
||||
# means we expect things to hang momentarily, so we should not delay
|
||||
# writing our metadata or it will likely not happen. Though we
|
||||
# should remember that metadata doesn't line up perfectly in time with
|
||||
# the dump in that case.
|
||||
try:
|
||||
mdpath = os.path.join(
|
||||
os.path.dirname(_ba.app.config_file_path), '_appstate_dump_md'
|
||||
)
|
||||
with open(mdpath, 'w', encoding='utf-8') as outfile:
|
||||
outfile.write(
|
||||
dataclass_to_json(
|
||||
DumpedAppStateMetadata(
|
||||
reason=reason,
|
||||
app_time=_ba.time(TimeType.REAL),
|
||||
log_level=log_level,
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Abandon whole dump if we can't write metadata.
|
||||
logging.exception('Error writing app state dump metadata.')
|
||||
return
|
||||
|
||||
tbpath = os.path.join(
|
||||
os.path.dirname(_ba.app.config_file_path), '_appstate_dump_tb'
|
||||
)
|
||||
|
||||
# faulthandler needs the raw file descriptor to still be valid when
|
||||
# it fires, so stuff this into a global var to make sure it doesn't get
|
||||
# cleaned up.
|
||||
tbfile = open(tbpath, 'w', encoding='utf-8')
|
||||
_tbfiles.append(tbfile)
|
||||
|
||||
if delay > 0.0:
|
||||
faulthandler.dump_traceback_later(delay, file=tbfile)
|
||||
else:
|
||||
faulthandler.dump_traceback(file=tbfile)
|
||||
|
||||
# Attempt to log shortly after dumping.
|
||||
# Allow sufficient time since we don't know how long the dump takes.
|
||||
# We want this to work from any thread, so need to kick this part
|
||||
# over to the logic thread so timer works.
|
||||
_ba.pushcall(
|
||||
lambda: _ba.timer(
|
||||
delay + 1.0, log_dumped_app_state, timetype=TimeType.REAL
|
||||
),
|
||||
from_other_thread=True,
|
||||
suppress_other_thread_warning=True,
|
||||
)
|
||||
|
||||
|
||||
def log_dumped_app_state() -> None:
|
||||
"""If an app-state dump exists, log it and clear it. No-op otherwise."""
|
||||
|
||||
try:
|
||||
out = ''
|
||||
mdpath = os.path.join(
|
||||
os.path.dirname(_ba.app.config_file_path), '_appstate_dump_md'
|
||||
)
|
||||
if os.path.exists(mdpath):
|
||||
with open(mdpath, 'r', encoding='utf-8') as infile:
|
||||
metadata = dataclass_from_json(
|
||||
DumpedAppStateMetadata, infile.read()
|
||||
)
|
||||
os.unlink(mdpath)
|
||||
out += (
|
||||
f'App state dump:\nReason: {metadata.reason}\n'
|
||||
f'Time: {metadata.app_time:.2f}'
|
||||
)
|
||||
tbpath = os.path.join(
|
||||
os.path.dirname(_ba.app.config_file_path), '_appstate_dump_tb'
|
||||
)
|
||||
if os.path.exists(tbpath):
|
||||
with open(tbpath, 'r', encoding='utf-8') as infile:
|
||||
out += '\nPython tracebacks:\n' + infile.read()
|
||||
os.unlink(tbpath)
|
||||
logging.log(metadata.log_level.python_logging_level, out)
|
||||
except Exception:
|
||||
logging.exception('Error logging dumped app state.')
|
||||
|
||||
|
||||
class AppHealthMonitor:
|
||||
"""Logs things like app-not-responding issues."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
assert _ba.in_logic_thread()
|
||||
self._running = True
|
||||
self._thread = Thread(target=self._app_monitor_thread_main, daemon=True)
|
||||
self._thread.start()
|
||||
self._response = False
|
||||
self._first_check = True
|
||||
|
||||
def _app_monitor_thread_main(self) -> None:
|
||||
|
||||
try:
|
||||
self._monitor_app()
|
||||
except Exception:
|
||||
logging.exception('Error in AppHealthMonitor thread.')
|
||||
|
||||
def _set_response(self) -> None:
|
||||
assert _ba.in_logic_thread()
|
||||
self._response = True
|
||||
|
||||
def _check_running(self) -> bool:
|
||||
# Workaround for the fact that mypy assumes _running
|
||||
# doesn't change during the course of a function.
|
||||
return self._running
|
||||
|
||||
def _monitor_app(self) -> None:
|
||||
import time
|
||||
|
||||
while bool(True):
|
||||
|
||||
# Always sleep a bit between checks.
|
||||
time.sleep(1.234)
|
||||
|
||||
# Do nothing while backgrounded.
|
||||
while not self._running:
|
||||
time.sleep(2.3456)
|
||||
|
||||
# Wait for the logic thread to run something we send it.
|
||||
starttime = time.monotonic()
|
||||
self._response = False
|
||||
_ba.pushcall(self._set_response, raw=True)
|
||||
while not self._response:
|
||||
|
||||
# Abort this check if we went into the background.
|
||||
if not self._check_running():
|
||||
break
|
||||
|
||||
# Wait a bit longer the first time through since the app
|
||||
# could still be starting up; we generally don't want to
|
||||
# report that.
|
||||
threshold = 10 if self._first_check else 5
|
||||
|
||||
# If we've been waiting too long (and the app is running)
|
||||
# dump the app state and bail. Make an exception for the
|
||||
# first check though since the app could just be taking
|
||||
# a while to get going; we don't want to report that.
|
||||
duration = time.monotonic() - starttime
|
||||
if duration > threshold:
|
||||
dump_app_state(
|
||||
reason=f'Logic thread unresponsive'
|
||||
f' for {threshold} seconds.'
|
||||
)
|
||||
|
||||
# We just do one alert for now.
|
||||
return
|
||||
|
||||
time.sleep(1.042)
|
||||
|
||||
self._first_check = False
|
||||
|
||||
def on_app_pause(self) -> None:
|
||||
"""Should be called when the app pauses."""
|
||||
assert _ba.in_logic_thread()
|
||||
self._running = False
|
||||
|
||||
def on_app_resume(self) -> None:
|
||||
"""Should be called when the app resumes."""
|
||||
assert _ba.in_logic_thread()
|
||||
self._running = True
|
||||
|
||||
|
||||
def on_too_many_file_descriptors() -> None:
|
||||
"""Called when too many file descriptors are open; trying to debug."""
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
real_time = _ba.time(TimeType.REAL)
|
||||
|
||||
def _do_log() -> None:
|
||||
pid = os.getpid()
|
||||
try:
|
||||
fdcount: int | str = len(os.listdir(f'/proc/{pid}/fd'))
|
||||
except Exception as exc:
|
||||
fdcount = f'? ({exc})'
|
||||
logging.warning(
|
||||
'TOO MANY FDS at %.2f. We are pid %d. FDCount is %s.',
|
||||
real_time,
|
||||
pid,
|
||||
fdcount,
|
||||
)
|
||||
|
||||
Thread(target=_do_log, daemon=True).start()
|
||||
|
||||
# import io
|
||||
# from efro.debug import printtypes
|
||||
|
||||
# with io.StringIO() as fstr:
|
||||
# fstr.write('Too many FDs.\n')
|
||||
# printtypes(file=fstr)
|
||||
# fstr.seek(0)
|
||||
# logging.warning(fstr.read())
|
||||
# import socket
|
||||
|
||||
# objs: list[Any] = []
|
||||
# for obj in gc.get_objects():
|
||||
# if isinstance(obj, socket.socket):
|
||||
# objs.append(obj)
|
||||
# test = open('/Users/ericf/.zshrc', 'r', encoding='utf-8')
|
||||
# reveal_type(test)
|
||||
# print('FOUND', len(objs))
|
||||
241
dist/ba_data/python/ba/_assetmanager.py
vendored
Normal file
241
dist/ba_data/python/ba/_assetmanager.py
vendored
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to managing cloud based assets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import urllib.request
|
||||
import logging
|
||||
import weakref
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
|
||||
from efro.dataclassio import (
|
||||
ioprepped,
|
||||
IOAttrs,
|
||||
dataclass_from_json,
|
||||
dataclass_to_json,
|
||||
)
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bacommon.assets import AssetPackageFlavor
|
||||
|
||||
|
||||
@ioprepped
|
||||
@dataclass
|
||||
class FileValue:
|
||||
"""State for an individual file."""
|
||||
|
||||
|
||||
@ioprepped
|
||||
@dataclass
|
||||
class State:
|
||||
"""Holds all persistent state for the asset-manager."""
|
||||
|
||||
files: Annotated[dict[str, FileValue], IOAttrs('files')] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
|
||||
class AssetManager:
|
||||
"""Wrangles all assets."""
|
||||
|
||||
_state: State
|
||||
|
||||
def __init__(self, rootdir: Path) -> None:
|
||||
print('AssetManager()')
|
||||
assert isinstance(rootdir, Path)
|
||||
self.thread_ident = threading.get_ident()
|
||||
self._rootdir = rootdir
|
||||
self._started = False
|
||||
if not self._rootdir.is_dir():
|
||||
raise RuntimeError(f'Provided rootdir does not exist: "{rootdir}"')
|
||||
|
||||
self.load_state()
|
||||
|
||||
def __del__(self) -> None:
|
||||
print('~AssetManager()')
|
||||
if self._started:
|
||||
logging.warning('AssetManager dying in a started state.')
|
||||
|
||||
def launch_gather(
|
||||
self,
|
||||
packages: list[str],
|
||||
flavor: AssetPackageFlavor,
|
||||
account_token: str,
|
||||
) -> AssetGather:
|
||||
"""Spawn an asset-gather operation from this manager."""
|
||||
print(
|
||||
'would gather',
|
||||
packages,
|
||||
'and flavor',
|
||||
flavor,
|
||||
'with token',
|
||||
account_token,
|
||||
)
|
||||
return AssetGather(self)
|
||||
|
||||
def update(self) -> None:
|
||||
"""Can be called periodically to perform upkeep."""
|
||||
|
||||
def start(self) -> None:
|
||||
"""Tell the manager to start working.
|
||||
|
||||
This will initiate network activity and other processing.
|
||||
"""
|
||||
if self._started:
|
||||
logging.warning('AssetManager.start() called on running manager.')
|
||||
self._started = True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Tell the manager to stop working.
|
||||
|
||||
All network activity should be ceased before this function returns.
|
||||
"""
|
||||
if not self._started:
|
||||
logging.warning('AssetManager.stop() called on stopped manager.')
|
||||
self._started = False
|
||||
self.save_state()
|
||||
|
||||
@property
|
||||
def rootdir(self) -> Path:
|
||||
"""The root directory for this manager."""
|
||||
return self._rootdir
|
||||
|
||||
@property
|
||||
def state_path(self) -> Path:
|
||||
"""The path of the state file."""
|
||||
return Path(self._rootdir, 'state')
|
||||
|
||||
def load_state(self) -> None:
|
||||
"""Loads state from disk. Resets to default state if unable to."""
|
||||
print('ASSET-MANAGER LOADING STATE')
|
||||
try:
|
||||
state_path = self.state_path
|
||||
if state_path.exists():
|
||||
with open(self.state_path, encoding='utf-8') as infile:
|
||||
self._state = dataclass_from_json(State, infile.read())
|
||||
return
|
||||
except Exception:
|
||||
logging.exception('Error loading existing AssetManager state')
|
||||
self._state = State()
|
||||
|
||||
def save_state(self) -> None:
|
||||
"""Save state to disk (if possible)."""
|
||||
|
||||
print('ASSET-MANAGER SAVING STATE')
|
||||
try:
|
||||
with open(self.state_path, 'w', encoding='utf-8') as outfile:
|
||||
outfile.write(dataclass_to_json(self._state))
|
||||
except Exception:
|
||||
logging.exception('Error writing AssetManager state')
|
||||
|
||||
|
||||
class AssetGather:
|
||||
"""Wrangles a gathering of assets."""
|
||||
|
||||
def __init__(self, manager: AssetManager) -> None:
|
||||
assert threading.get_ident() == manager.thread_ident
|
||||
self._manager = weakref.ref(manager)
|
||||
# self._valid = True
|
||||
print('AssetGather()')
|
||||
# url = 'https://files.ballistica.net/bombsquad/promo/BSGamePlay.mov'
|
||||
# url = 'http://www.python.org/ftp/python/2.7.3/Python-2.7.3.tgz'
|
||||
# fetch_url(url,
|
||||
# filename=Path(manager.rootdir, 'testdl'),
|
||||
# asset_gather=self)
|
||||
# print('fetch success')
|
||||
thread = threading.Thread(target=self._run)
|
||||
thread.run()
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Run the gather in a background thread."""
|
||||
print('hello from gather bg')
|
||||
|
||||
# First, do some sort of.
|
||||
|
||||
# @property
|
||||
# def valid(self) -> bool:
|
||||
# """Whether this gather is still valid.
|
||||
|
||||
# A gather becomes in valid if its originating AssetManager dies.
|
||||
# """
|
||||
# return True
|
||||
|
||||
def __del__(self) -> None:
|
||||
print('~AssetGather()')
|
||||
|
||||
|
||||
def fetch_url(url: str, filename: Path, asset_gather: AssetGather) -> None:
|
||||
"""Fetch a given url to a given filename for a given AssetGather."""
|
||||
# pylint: disable=consider-using-with
|
||||
import socket
|
||||
|
||||
# We don't want to keep the provided AssetGather alive, but we want
|
||||
# to abort if it dies.
|
||||
assert isinstance(asset_gather, AssetGather)
|
||||
# weak_gather = weakref.ref(asset_gather)
|
||||
|
||||
# Pass a very short timeout to urllib so we have opportunities
|
||||
# to cancel even with network blockage.
|
||||
req = urllib.request.urlopen(url, context=_ba.app.net.sslcontext, timeout=1)
|
||||
file_size = int(req.headers['Content-Length'])
|
||||
print(f'\nDownloading: {filename} Bytes: {file_size:,}')
|
||||
|
||||
def doit() -> None:
|
||||
time.sleep(1)
|
||||
print('dir', type(req.fp), dir(req.fp))
|
||||
print('WOULD DO IT', flush=True)
|
||||
# req.close()
|
||||
# req.fp.close()
|
||||
|
||||
threading.Thread(target=doit).run()
|
||||
|
||||
with open(filename, 'wb') as outfile:
|
||||
file_size_dl = 0
|
||||
|
||||
block_sz = 1024 * 1024 * 1000
|
||||
time_outs = 0
|
||||
while True:
|
||||
try:
|
||||
data = req.read(block_sz)
|
||||
except ValueError:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
print('VALUEERROR', flush=True)
|
||||
break
|
||||
except socket.timeout:
|
||||
print('TIMEOUT', flush=True)
|
||||
# File has not had activity in max seconds.
|
||||
if time_outs > 3:
|
||||
print('\n\n\nsorry -- try back later')
|
||||
os.unlink(filename)
|
||||
raise
|
||||
print(
|
||||
'\nHmmm... little issue... '
|
||||
'I\'ll wait a couple of seconds'
|
||||
)
|
||||
time.sleep(3)
|
||||
time_outs += 1
|
||||
continue
|
||||
|
||||
# We reached the end of the download!
|
||||
if not data:
|
||||
sys.stdout.write('\rDone!\n\n')
|
||||
sys.stdout.flush()
|
||||
break
|
||||
|
||||
file_size_dl += len(data)
|
||||
outfile.write(data)
|
||||
percent = file_size_dl * 1.0 / file_size
|
||||
status = f'{file_size_dl:20,} Bytes [{percent:.2%}] received'
|
||||
sys.stdout.write('\r' + status)
|
||||
sys.stdout.flush()
|
||||
print('done with', req.fp)
|
||||
92
dist/ba_data/python/ba/_asyncio.py
vendored
Normal file
92
dist/ba_data/python/ba/_asyncio.py
vendored
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Asyncio related functionality.
|
||||
|
||||
Exploring the idea of allowing Python coroutines to run gracefully
|
||||
besides our internal event loop. They could prove useful for networking
|
||||
operations or possibly game logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ba
|
||||
|
||||
# Our timer and event loop for the ballistica logic thread.
|
||||
_asyncio_timer: ba.Timer | None = None
|
||||
_asyncio_event_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
DEBUG_TIMING = os.environ.get('BA_DEBUG_TIMING') == '1'
|
||||
|
||||
|
||||
def setup_asyncio() -> asyncio.AbstractEventLoop:
|
||||
"""Setup asyncio functionality for the logic thread."""
|
||||
# pylint: disable=global-statement
|
||||
|
||||
import _ba
|
||||
import ba
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
assert _ba.in_logic_thread()
|
||||
|
||||
# Create our event-loop. We don't expect there to be one
|
||||
# running on this thread before we do.
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
print('Found running asyncio loop; unexpected.')
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
global _asyncio_event_loop # pylint: disable=invalid-name
|
||||
_asyncio_event_loop = asyncio.new_event_loop()
|
||||
_asyncio_event_loop.set_default_executor(ba.app.threadpool)
|
||||
|
||||
# Ideally we should integrate asyncio into our C++ Thread class's
|
||||
# low level event loop so that asyncio timers/sockets/etc. could
|
||||
# be true first-class citizens. For now, though, we can explicitly
|
||||
# pump an asyncio loop periodically which gets us a decent
|
||||
# approximation of that, which should be good enough for
|
||||
# all but extremely time sensitive uses.
|
||||
# See https://stackoverflow.com/questions/29782377/
|
||||
# is-it-possible-to-run-only-a-single-step-of-the-asyncio-event-loop
|
||||
def run_cycle() -> None:
|
||||
assert _asyncio_event_loop is not None
|
||||
_asyncio_event_loop.call_soon(_asyncio_event_loop.stop)
|
||||
starttime = time.monotonic() if DEBUG_TIMING else 0
|
||||
_asyncio_event_loop.run_forever()
|
||||
endtime = time.monotonic() if DEBUG_TIMING else 0
|
||||
|
||||
# Let's aim to have nothing take longer than 1/120 of a second.
|
||||
if DEBUG_TIMING:
|
||||
warn_time = 1.0 / 120
|
||||
duration = endtime - starttime
|
||||
if duration > warn_time:
|
||||
logging.warning(
|
||||
'Asyncio loop step took %.4fs; ideal max is %.4f',
|
||||
duration,
|
||||
warn_time,
|
||||
)
|
||||
|
||||
global _asyncio_timer # pylint: disable=invalid-name
|
||||
_asyncio_timer = _ba.Timer(
|
||||
1.0 / 30.0, run_cycle, timetype=TimeType.REAL, repeat=True
|
||||
)
|
||||
|
||||
if bool(False):
|
||||
|
||||
async def aio_test() -> None:
|
||||
print('TEST AIO TASK STARTING')
|
||||
assert _asyncio_event_loop is not None
|
||||
assert asyncio.get_running_loop() is _asyncio_event_loop
|
||||
await asyncio.sleep(2.0)
|
||||
print('TEST AIO TASK ENDING')
|
||||
|
||||
_asyncio_event_loop.create_task(aio_test())
|
||||
|
||||
return _asyncio_event_loop
|
||||
198
dist/ba_data/python/ba/_benchmark.py
vendored
Normal file
198
dist/ba_data/python/ba/_benchmark.py
vendored
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Benchmark/Stress-Test related functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Sequence
|
||||
import ba
|
||||
|
||||
|
||||
def run_cpu_benchmark() -> None:
|
||||
"""Run a cpu benchmark."""
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd import tutorial
|
||||
from ba._session import Session
|
||||
|
||||
class BenchmarkSession(Session):
|
||||
"""Session type for cpu benchmark."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
# print('FIXME: BENCHMARK SESSION WOULD CALC DEPS.')
|
||||
depsets: Sequence[ba.DependencySet] = []
|
||||
|
||||
super().__init__(depsets)
|
||||
|
||||
# Store old graphics settings.
|
||||
self._old_quality = _ba.app.config.resolve('Graphics Quality')
|
||||
cfg = _ba.app.config
|
||||
cfg['Graphics Quality'] = 'Low'
|
||||
cfg.apply()
|
||||
self.benchmark_type = 'cpu'
|
||||
self.setactivity(_ba.newactivity(tutorial.TutorialActivity))
|
||||
|
||||
def __del__(self) -> None:
|
||||
|
||||
# When we're torn down, restore old graphics settings.
|
||||
cfg = _ba.app.config
|
||||
cfg['Graphics Quality'] = self._old_quality
|
||||
cfg.apply()
|
||||
|
||||
def on_player_request(self, player: ba.SessionPlayer) -> bool:
|
||||
return False
|
||||
|
||||
_ba.new_host_session(BenchmarkSession, benchmark_type='cpu')
|
||||
|
||||
|
||||
def run_stress_test(
|
||||
playlist_type: str = 'Random',
|
||||
playlist_name: str = '__default__',
|
||||
player_count: int = 8,
|
||||
round_duration: int = 30,
|
||||
) -> None:
|
||||
"""Run a stress test."""
|
||||
from ba import modutils
|
||||
from ba._general import Call
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
_ba.screenmessage(
|
||||
"Beginning stress test.. use 'End Test' to stop testing.",
|
||||
color=(1, 1, 0),
|
||||
)
|
||||
with _ba.Context('ui'):
|
||||
start_stress_test(
|
||||
{
|
||||
'playlist_type': playlist_type,
|
||||
'playlist_name': playlist_name,
|
||||
'player_count': player_count,
|
||||
'round_duration': round_duration,
|
||||
}
|
||||
)
|
||||
_ba.timer(
|
||||
7.0,
|
||||
Call(
|
||||
_ba.screenmessage,
|
||||
(
|
||||
'stats will be written to '
|
||||
+ modutils.get_human_readable_user_scripts_path()
|
||||
+ '/stress_test_stats.csv'
|
||||
),
|
||||
),
|
||||
timetype=TimeType.REAL,
|
||||
)
|
||||
|
||||
|
||||
def stop_stress_test() -> None:
|
||||
"""End a running stress test."""
|
||||
_ba.set_stress_testing(False, 0)
|
||||
try:
|
||||
if _ba.app.stress_test_reset_timer is not None:
|
||||
_ba.screenmessage('Ending stress test...', color=(1, 1, 0))
|
||||
except Exception:
|
||||
pass
|
||||
_ba.app.stress_test_reset_timer = None
|
||||
|
||||
|
||||
def start_stress_test(args: dict[str, Any]) -> None:
|
||||
"""(internal)"""
|
||||
from ba._general import Call
|
||||
from ba._dualteamsession import DualTeamSession
|
||||
from ba._freeforallsession import FreeForAllSession
|
||||
from ba._generated.enums import TimeType, TimeFormat
|
||||
|
||||
appconfig = _ba.app.config
|
||||
playlist_type = args['playlist_type']
|
||||
if playlist_type == 'Random':
|
||||
if random.random() < 0.5:
|
||||
playlist_type = 'Teams'
|
||||
else:
|
||||
playlist_type = 'Free-For-All'
|
||||
_ba.screenmessage(
|
||||
'Running Stress Test (listType="'
|
||||
+ playlist_type
|
||||
+ '", listName="'
|
||||
+ args['playlist_name']
|
||||
+ '")...'
|
||||
)
|
||||
if playlist_type == 'Teams':
|
||||
appconfig['Team Tournament Playlist Selection'] = args['playlist_name']
|
||||
appconfig['Team Tournament Playlist Randomize'] = 1
|
||||
_ba.timer(
|
||||
1.0,
|
||||
Call(_ba.pushcall, Call(_ba.new_host_session, DualTeamSession)),
|
||||
timetype=TimeType.REAL,
|
||||
)
|
||||
else:
|
||||
appconfig['Free-for-All Playlist Selection'] = args['playlist_name']
|
||||
appconfig['Free-for-All Playlist Randomize'] = 1
|
||||
_ba.timer(
|
||||
1.0,
|
||||
Call(_ba.pushcall, Call(_ba.new_host_session, FreeForAllSession)),
|
||||
timetype=TimeType.REAL,
|
||||
)
|
||||
_ba.set_stress_testing(True, args['player_count'])
|
||||
_ba.app.stress_test_reset_timer = _ba.Timer(
|
||||
args['round_duration'] * 1000,
|
||||
Call(_reset_stress_test, args),
|
||||
timetype=TimeType.REAL,
|
||||
timeformat=TimeFormat.MILLISECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _reset_stress_test(args: dict[str, Any]) -> None:
|
||||
from ba._general import Call
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
_ba.set_stress_testing(False, args['player_count'])
|
||||
_ba.screenmessage('Resetting stress test...')
|
||||
session = _ba.get_foreground_host_session()
|
||||
assert session is not None
|
||||
session.end()
|
||||
_ba.timer(1.0, Call(start_stress_test, args), timetype=TimeType.REAL)
|
||||
|
||||
|
||||
def run_gpu_benchmark() -> None:
|
||||
"""Kick off a benchmark to test gpu speeds."""
|
||||
# FIXME: Not wired up yet.
|
||||
_ba.screenmessage('Not wired up yet.', color=(1, 0, 0))
|
||||
|
||||
|
||||
def run_media_reload_benchmark() -> None:
|
||||
"""Kick off a benchmark to test media reloading speeds."""
|
||||
from ba._general import Call
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
_ba.reload_media()
|
||||
_ba.show_progress_bar()
|
||||
|
||||
def delay_add(start_time: float) -> None:
|
||||
def doit(start_time_2: float) -> None:
|
||||
_ba.screenmessage(
|
||||
_ba.app.lang.get_resource(
|
||||
'debugWindow.totalReloadTimeText'
|
||||
).replace(
|
||||
'${TIME}', str(_ba.time(TimeType.REAL) - start_time_2)
|
||||
)
|
||||
)
|
||||
_ba.print_load_info()
|
||||
if _ba.app.config.resolve('Texture Quality') != 'High':
|
||||
_ba.screenmessage(
|
||||
_ba.app.lang.get_resource(
|
||||
'debugWindow.reloadBenchmarkBestResultsText'
|
||||
),
|
||||
color=(1, 1, 0),
|
||||
)
|
||||
|
||||
_ba.add_clean_frame_callback(Call(doit, start_time))
|
||||
|
||||
# The reload starts (should add a completion callback to the
|
||||
# reload func to fix this).
|
||||
_ba.timer(
|
||||
0.05, Call(delay_add, _ba.time(TimeType.REAL)), timetype=TimeType.REAL
|
||||
)
|
||||
192
dist/ba_data/python/ba/_bootstrap.py
vendored
Normal file
192
dist/ba_data/python/ba/_bootstrap.py
vendored
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Bootstrapping."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from efro.log import setup_logging, LogLevel
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
from efro.log import LogEntry
|
||||
|
||||
_g_did_bootstrap = False # pylint: disable=invalid-name
|
||||
|
||||
|
||||
def bootstrap() -> None:
|
||||
"""Run bootstrapping logic.
|
||||
|
||||
This is the very first ballistica code that runs (aside from imports).
|
||||
It sets up low level environment bits and creates the app instance.
|
||||
"""
|
||||
|
||||
global _g_did_bootstrap # pylint: disable=global-statement, invalid-name
|
||||
if _g_did_bootstrap:
|
||||
raise RuntimeError('Bootstrap has already been called.')
|
||||
_g_did_bootstrap = True
|
||||
|
||||
# The first thing we do is set up our logging system and feed
|
||||
# Python's stdout/stderr into it. Then we can at least debug problems
|
||||
# on systems where native stdout/stderr is not easily accessible
|
||||
# such as Android.
|
||||
log_handler = setup_logging(
|
||||
log_path=None,
|
||||
level=LogLevel.DEBUG,
|
||||
suppress_non_root_debug=True,
|
||||
log_stdout_stderr=True,
|
||||
cache_size_limit=1024 * 1024,
|
||||
)
|
||||
|
||||
log_handler.add_callback(_on_log)
|
||||
|
||||
env = _ba.env()
|
||||
|
||||
# Give a soft warning if we're being used with a different binary
|
||||
# version than we expect.
|
||||
expected_build = 21005
|
||||
running_build: int = env['build_number']
|
||||
if running_build != expected_build:
|
||||
print(
|
||||
f'WARNING: These script files are meant to be used with'
|
||||
f' Ballistica build {expected_build}.\n'
|
||||
f' You are running build {running_build}.'
|
||||
f' This might cause the app to error or misbehave.',
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# In bootstrap_monolithic.py we told Python not to handle SIGINT itself
|
||||
# (because that must be done in the main thread). Now we finish the
|
||||
# job by adding our own handler to replace it.
|
||||
|
||||
# Note: I've found we need to set up our C signal handling AFTER
|
||||
# we've told Python to disable its own; otherwise (on Mac at least) it
|
||||
# wipes out our existing C handler.
|
||||
_ba.setup_sigint()
|
||||
|
||||
# Sanity check: we should always be run in UTF-8 mode.
|
||||
if sys.flags.utf8_mode != 1:
|
||||
print(
|
||||
'ERROR: Python\'s UTF-8 mode is not set.'
|
||||
' This will likely result in errors.',
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
debug_build = env['debug_build']
|
||||
|
||||
# We expect dev_mode on in debug builds and off otherwise.
|
||||
if debug_build != sys.flags.dev_mode:
|
||||
print(
|
||||
f'WARNING: Mismatch in debug_build {debug_build}'
|
||||
f' and sys.flags.dev_mode {sys.flags.dev_mode}',
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# In embedded situations (when we're providing our own Python) let's
|
||||
# also provide our own root certs so ssl works. We can consider overriding
|
||||
# this in particular embedded cases if we can verify that system certs
|
||||
# are working.
|
||||
# (We also allow forcing this via an env var if the user desires)
|
||||
if (
|
||||
_ba.contains_python_dist()
|
||||
or os.environ.get('BA_USE_BUNDLED_ROOT_CERTS') == '1'
|
||||
):
|
||||
import certifi
|
||||
|
||||
# Let both OpenSSL and requests (if present) know to use this.
|
||||
os.environ['SSL_CERT_FILE'] = os.environ[
|
||||
'REQUESTS_CA_BUNDLE'
|
||||
] = certifi.where()
|
||||
|
||||
# On Windows I'm seeing the following error creating asyncio loops in
|
||||
# background threads with the default proactor setup:
|
||||
# ValueError: set_wakeup_fd only works in main thread of the main
|
||||
# interpreter
|
||||
# So let's explicitly request selector loops.
|
||||
# Interestingly this error only started showing up once I moved
|
||||
# Python init to the main thread; previously the various asyncio
|
||||
# bg thread loops were working fine (maybe something caused them
|
||||
# to default to selector in that case?..
|
||||
if sys.platform == 'win32':
|
||||
import asyncio
|
||||
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
# pylint: disable=c-extension-no-member
|
||||
if not TYPE_CHECKING:
|
||||
import __main__
|
||||
|
||||
# Clear out the standard quit/exit messages since they don't
|
||||
# work in our embedded situation (should revisit this once we're
|
||||
# usable from a standard interpreter).
|
||||
del __main__.__builtins__.quit
|
||||
del __main__.__builtins__.exit
|
||||
|
||||
# Also replace standard interactive help with our simplified
|
||||
# one which is more friendly to cloud/in-game console situations.
|
||||
__main__.__builtins__.help = _CustomHelper()
|
||||
|
||||
# Now spin up our App instance and store it on both _ba and ba.
|
||||
from ba._app import App
|
||||
import ba
|
||||
|
||||
_ba.app = ba.app = App()
|
||||
_ba.app.log_handler = log_handler
|
||||
|
||||
|
||||
class _CustomHelper:
|
||||
"""Replacement 'help' that behaves better for our setup."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return 'Type help(object) for help about object.'
|
||||
|
||||
def __call__(self, *args: Any, **kwds: Any) -> Any:
|
||||
# We get an ugly error importing pydoc on our embedded
|
||||
# platforms due to _sysconfigdata_xxx.py not being present
|
||||
# (but then things mostly work). Let's get the ugly error out
|
||||
# of the way explicitly.
|
||||
import sysconfig
|
||||
|
||||
try:
|
||||
# This errors once but seems to run cleanly after, so let's
|
||||
# get the error out of the way.
|
||||
sysconfig.get_path('stdlib')
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
import pydoc
|
||||
|
||||
# Disable pager and interactive help since neither works well
|
||||
# with our funky multi-threaded setup or in-game/cloud consoles.
|
||||
# Let's just do simple text dumps.
|
||||
pydoc.pager = pydoc.plainpager
|
||||
if not args and not kwds:
|
||||
print(
|
||||
'Interactive help is not available in this environment.\n'
|
||||
'Type help(object) for help about object.'
|
||||
)
|
||||
return None
|
||||
return pydoc.help(*args, **kwds)
|
||||
|
||||
|
||||
def _on_log(entry: LogEntry) -> None:
|
||||
|
||||
# Just forward this along to the engine to display in the in-game console,
|
||||
# in the Android log, etc.
|
||||
_ba.display_log(
|
||||
name=entry.name,
|
||||
level=entry.level.name,
|
||||
message=entry.message,
|
||||
)
|
||||
|
||||
# We also want to feed some logs to the old V1-cloud-log system.
|
||||
# Let's go with anything warning or higher as well as the stdout/stderr
|
||||
# log messages that ba.app.log_handler creates for us.
|
||||
if entry.level.value >= LogLevel.WARNING.value or entry.name in (
|
||||
'stdout',
|
||||
'stderr',
|
||||
):
|
||||
_ba.v1_cloud_log(entry.message)
|
||||
409
dist/ba_data/python/ba/_campaign.py
vendored
Normal file
409
dist/ba_data/python/ba/_campaign.py
vendored
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to co-op campaigns."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
import ba
|
||||
|
||||
|
||||
def register_campaign(campaign: ba.Campaign) -> None:
|
||||
"""Register a new campaign."""
|
||||
_ba.app.campaigns[campaign.name] = campaign
|
||||
|
||||
|
||||
def getcampaign(name: str) -> ba.Campaign:
|
||||
"""Return a campaign by name."""
|
||||
return _ba.app.campaigns[name]
|
||||
|
||||
|
||||
class Campaign:
|
||||
"""Represents a unique set or series of ba.Level-s.
|
||||
|
||||
Category: **App Classes**
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
sequential: bool = True,
|
||||
levels: list[ba.Level] | None = None,
|
||||
):
|
||||
self._name = name
|
||||
self._sequential = sequential
|
||||
self._levels: list[ba.Level] = []
|
||||
if levels is not None:
|
||||
for level in levels:
|
||||
self.addlevel(level)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""The name of the Campaign."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def sequential(self) -> bool:
|
||||
"""Whether this Campaign's levels must be played in sequence."""
|
||||
return self._sequential
|
||||
|
||||
def addlevel(self, level: ba.Level, index: int | None = None) -> None:
|
||||
"""Adds a ba.Level to the Campaign."""
|
||||
if level.campaign is not None:
|
||||
raise RuntimeError('Level already belongs to a campaign.')
|
||||
level.set_campaign(self, len(self._levels))
|
||||
if index is None:
|
||||
self._levels.append(level)
|
||||
else:
|
||||
self._levels.insert(index, level)
|
||||
|
||||
@property
|
||||
def levels(self) -> list[ba.Level]:
|
||||
"""The list of ba.Level-s in the Campaign."""
|
||||
return self._levels
|
||||
|
||||
def getlevel(self, name: str) -> ba.Level:
|
||||
"""Return a contained ba.Level by name."""
|
||||
from ba import _error
|
||||
|
||||
for level in self._levels:
|
||||
if level.name == name:
|
||||
return level
|
||||
raise _error.NotFoundError(
|
||||
"Level '" + name + "' not found in campaign '" + self.name + "'"
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset state for the Campaign."""
|
||||
_ba.app.config.setdefault('Campaigns', {})[self._name] = {}
|
||||
|
||||
# FIXME should these give/take ba.Level instances instead of level names?..
|
||||
def set_selected_level(self, levelname: str) -> None:
|
||||
"""Set the Level currently selected in the UI (by name)."""
|
||||
self.configdict['Selection'] = levelname
|
||||
_ba.app.config.commit()
|
||||
|
||||
def get_selected_level(self) -> str:
|
||||
"""Return the name of the Level currently selected in the UI."""
|
||||
return self.configdict.get('Selection', self._levels[0].name)
|
||||
|
||||
@property
|
||||
def configdict(self) -> dict[str, Any]:
|
||||
"""Return the live config dict for this campaign."""
|
||||
val: dict[str, Any] = _ba.app.config.setdefault(
|
||||
'Campaigns', {}
|
||||
).setdefault(self._name, {})
|
||||
assert isinstance(val, dict)
|
||||
return val
|
||||
|
||||
|
||||
def init_campaigns() -> None:
|
||||
"""Fill out initial default Campaigns."""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._level import Level
|
||||
from bastd.game.onslaught import OnslaughtGame
|
||||
from bastd.game.football import FootballCoopGame
|
||||
from bastd.game.runaround import RunaroundGame
|
||||
from bastd.game.thelaststand import TheLastStandGame
|
||||
from bastd.game.race import RaceGame
|
||||
from bastd.game.targetpractice import TargetPracticeGame
|
||||
from bastd.game.meteorshower import MeteorShowerGame
|
||||
from bastd.game.easteregghunt import EasterEggHuntGame
|
||||
from bastd.game.ninjafight import NinjaFightGame
|
||||
|
||||
# TODO: Campaigns should be load-on-demand; not all imported at launch
|
||||
# like this.
|
||||
|
||||
# FIXME: Once translations catch up, we can convert these to use the
|
||||
# generic display-name '${GAME} Training' type stuff.
|
||||
register_campaign(
|
||||
Campaign(
|
||||
'Easy',
|
||||
levels=[
|
||||
Level(
|
||||
'Onslaught Training',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'training_easy'},
|
||||
preview_texture_name='doomShroomPreview',
|
||||
),
|
||||
Level(
|
||||
'Rookie Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'rookie_easy'},
|
||||
preview_texture_name='courtyardPreview',
|
||||
),
|
||||
Level(
|
||||
'Rookie Football',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'rookie_easy'},
|
||||
preview_texture_name='footballStadiumPreview',
|
||||
),
|
||||
Level(
|
||||
'Pro Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'pro_easy'},
|
||||
preview_texture_name='doomShroomPreview',
|
||||
),
|
||||
Level(
|
||||
'Pro Football',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'pro_easy'},
|
||||
preview_texture_name='footballStadiumPreview',
|
||||
),
|
||||
Level(
|
||||
'Pro Runaround',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'pro_easy'},
|
||||
preview_texture_name='towerDPreview',
|
||||
),
|
||||
Level(
|
||||
'Uber Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'uber_easy'},
|
||||
preview_texture_name='courtyardPreview',
|
||||
),
|
||||
Level(
|
||||
'Uber Football',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'uber_easy'},
|
||||
preview_texture_name='footballStadiumPreview',
|
||||
),
|
||||
Level(
|
||||
'Uber Runaround',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'uber_easy'},
|
||||
preview_texture_name='towerDPreview',
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# "hard" mode
|
||||
register_campaign(
|
||||
Campaign(
|
||||
'Default',
|
||||
levels=[
|
||||
Level(
|
||||
'Onslaught Training',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'training'},
|
||||
preview_texture_name='doomShroomPreview',
|
||||
),
|
||||
Level(
|
||||
'Rookie Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'rookie'},
|
||||
preview_texture_name='courtyardPreview',
|
||||
),
|
||||
Level(
|
||||
'Rookie Football',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'rookie'},
|
||||
preview_texture_name='footballStadiumPreview',
|
||||
),
|
||||
Level(
|
||||
'Pro Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'pro'},
|
||||
preview_texture_name='doomShroomPreview',
|
||||
),
|
||||
Level(
|
||||
'Pro Football',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'pro'},
|
||||
preview_texture_name='footballStadiumPreview',
|
||||
),
|
||||
Level(
|
||||
'Pro Runaround',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'pro'},
|
||||
preview_texture_name='towerDPreview',
|
||||
),
|
||||
Level(
|
||||
'Uber Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'uber'},
|
||||
preview_texture_name='courtyardPreview',
|
||||
),
|
||||
Level(
|
||||
'Uber Football',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'uber'},
|
||||
preview_texture_name='footballStadiumPreview',
|
||||
),
|
||||
Level(
|
||||
'Uber Runaround',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'uber'},
|
||||
preview_texture_name='towerDPreview',
|
||||
),
|
||||
Level(
|
||||
'The Last Stand',
|
||||
gametype=TheLastStandGame,
|
||||
settings={},
|
||||
preview_texture_name='rampagePreview',
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# challenges: our 'official' random extra co-op levels
|
||||
register_campaign(
|
||||
Campaign(
|
||||
'Challenges',
|
||||
sequential=False,
|
||||
levels=[
|
||||
Level(
|
||||
'Infinite Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'endless'},
|
||||
preview_texture_name='doomShroomPreview',
|
||||
),
|
||||
Level(
|
||||
'Infinite Runaround',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'endless'},
|
||||
preview_texture_name='towerDPreview',
|
||||
),
|
||||
Level(
|
||||
'Race',
|
||||
displayname='${GAME}',
|
||||
gametype=RaceGame,
|
||||
settings={'map': 'Big G', 'Laps': 3, 'Bomb Spawning': 0},
|
||||
preview_texture_name='bigGPreview',
|
||||
),
|
||||
Level(
|
||||
'Pro Race',
|
||||
displayname='Pro ${GAME}',
|
||||
gametype=RaceGame,
|
||||
settings={'map': 'Big G', 'Laps': 3, 'Bomb Spawning': 1000},
|
||||
preview_texture_name='bigGPreview',
|
||||
),
|
||||
Level(
|
||||
'Lake Frigid Race',
|
||||
displayname='${GAME}',
|
||||
gametype=RaceGame,
|
||||
settings={
|
||||
'map': 'Lake Frigid',
|
||||
'Laps': 6,
|
||||
'Mine Spawning': 2000,
|
||||
'Bomb Spawning': 0,
|
||||
},
|
||||
preview_texture_name='lakeFrigidPreview',
|
||||
),
|
||||
Level(
|
||||
'Football',
|
||||
displayname='${GAME}',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'tournament'},
|
||||
preview_texture_name='footballStadiumPreview',
|
||||
),
|
||||
Level(
|
||||
'Pro Football',
|
||||
displayname='Pro ${GAME}',
|
||||
gametype=FootballCoopGame,
|
||||
settings={'preset': 'tournament_pro'},
|
||||
preview_texture_name='footballStadiumPreview',
|
||||
),
|
||||
Level(
|
||||
'Runaround',
|
||||
displayname='${GAME}',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'tournament'},
|
||||
preview_texture_name='towerDPreview',
|
||||
),
|
||||
Level(
|
||||
'Uber Runaround',
|
||||
displayname='Uber ${GAME}',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'tournament_uber'},
|
||||
preview_texture_name='towerDPreview',
|
||||
),
|
||||
Level(
|
||||
'The Last Stand',
|
||||
displayname='${GAME}',
|
||||
gametype=TheLastStandGame,
|
||||
settings={'preset': 'tournament'},
|
||||
preview_texture_name='rampagePreview',
|
||||
),
|
||||
Level(
|
||||
'Tournament Infinite Onslaught',
|
||||
displayname='Infinite Onslaught',
|
||||
gametype=OnslaughtGame,
|
||||
settings={'preset': 'endless_tournament'},
|
||||
preview_texture_name='doomShroomPreview',
|
||||
),
|
||||
Level(
|
||||
'Tournament Infinite Runaround',
|
||||
displayname='Infinite Runaround',
|
||||
gametype=RunaroundGame,
|
||||
settings={'preset': 'endless_tournament'},
|
||||
preview_texture_name='towerDPreview',
|
||||
),
|
||||
Level(
|
||||
'Target Practice',
|
||||
displayname='Pro ${GAME}',
|
||||
gametype=TargetPracticeGame,
|
||||
settings={},
|
||||
preview_texture_name='doomShroomPreview',
|
||||
),
|
||||
Level(
|
||||
'Target Practice B',
|
||||
displayname='${GAME}',
|
||||
gametype=TargetPracticeGame,
|
||||
settings={
|
||||
'Target Count': 2,
|
||||
'Enable Impact Bombs': False,
|
||||
'Enable Triple Bombs': False,
|
||||
},
|
||||
preview_texture_name='doomShroomPreview',
|
||||
),
|
||||
Level(
|
||||
'Meteor Shower',
|
||||
displayname='${GAME}',
|
||||
gametype=MeteorShowerGame,
|
||||
settings={},
|
||||
preview_texture_name='rampagePreview',
|
||||
),
|
||||
Level(
|
||||
'Epic Meteor Shower',
|
||||
displayname='${GAME}',
|
||||
gametype=MeteorShowerGame,
|
||||
settings={'Epic Mode': True},
|
||||
preview_texture_name='rampagePreview',
|
||||
),
|
||||
Level(
|
||||
'Easter Egg Hunt',
|
||||
displayname='${GAME}',
|
||||
gametype=EasterEggHuntGame,
|
||||
settings={},
|
||||
preview_texture_name='towerDPreview',
|
||||
),
|
||||
Level(
|
||||
'Pro Easter Egg Hunt',
|
||||
displayname='Pro ${GAME}',
|
||||
gametype=EasterEggHuntGame,
|
||||
settings={'Pro Mode': True},
|
||||
preview_texture_name='towerDPreview',
|
||||
),
|
||||
Level(
|
||||
name='Ninja Fight', # (unique id not seen by player)
|
||||
displayname='${GAME}', # (readable name seen by player)
|
||||
gametype=NinjaFightGame,
|
||||
settings={'preset': 'regular'},
|
||||
preview_texture_name='courtyardPreview',
|
||||
),
|
||||
Level(
|
||||
name='Pro Ninja Fight',
|
||||
displayname='Pro ${GAME}',
|
||||
gametype=NinjaFightGame,
|
||||
settings={'preset': 'pro'},
|
||||
preview_texture_name='courtyardPreview',
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
196
dist/ba_data/python/ba/_cloud.py
vendored
Normal file
196
dist/ba_data/python/ba/_cloud.py
vendored
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to the cloud."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, overload
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Callable, Any
|
||||
|
||||
from efro.message import Message, Response
|
||||
import bacommon.cloud
|
||||
|
||||
DEBUG_LOG = False
|
||||
|
||||
# TODO: Should make it possible to define a protocol in bacommon.cloud and
|
||||
# autogenerate this. That would give us type safety between this and
|
||||
# internal protocols.
|
||||
|
||||
|
||||
class CloudSubsystem:
|
||||
"""Manages communication with cloud components."""
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Return whether a connection to the cloud is present.
|
||||
|
||||
This is a good indicator (though not for certain) that sending
|
||||
messages will succeed.
|
||||
"""
|
||||
return False # Needs to be overridden
|
||||
|
||||
def on_app_pause(self) -> None:
|
||||
"""Should be called when the app pauses."""
|
||||
|
||||
def on_app_resume(self) -> None:
|
||||
"""Should be called when the app resumes."""
|
||||
|
||||
def on_connectivity_changed(self, connected: bool) -> None:
|
||||
"""Called when cloud connectivity state changes."""
|
||||
if DEBUG_LOG:
|
||||
logging.debug('CloudSubsystem: Connectivity is now %s.', connected)
|
||||
|
||||
# Inform things that use this.
|
||||
# (TODO: should generalize this into some sort of registration system)
|
||||
_ba.app.accounts_v2.on_cloud_connectivity_changed(connected)
|
||||
|
||||
@overload
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: bacommon.cloud.LoginProxyRequestMessage,
|
||||
on_response: Callable[
|
||||
[bacommon.cloud.LoginProxyRequestResponse | Exception], None
|
||||
],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: bacommon.cloud.LoginProxyStateQueryMessage,
|
||||
on_response: Callable[
|
||||
[bacommon.cloud.LoginProxyStateQueryResponse | Exception], None
|
||||
],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: bacommon.cloud.LoginProxyCompleteMessage,
|
||||
on_response: Callable[[None | Exception], None],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: bacommon.cloud.PingMessage,
|
||||
on_response: Callable[[bacommon.cloud.PingResponse | Exception], None],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: bacommon.cloud.SignInMessage,
|
||||
on_response: Callable[
|
||||
[bacommon.cloud.SignInResponse | Exception], None
|
||||
],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: bacommon.cloud.ManageAccountMessage,
|
||||
on_response: Callable[
|
||||
[bacommon.cloud.ManageAccountResponse | Exception], None
|
||||
],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: Message,
|
||||
on_response: Callable[[Any], None],
|
||||
) -> None:
|
||||
"""Asynchronously send a message to the cloud from the logic thread.
|
||||
|
||||
The provided on_response call will be run in the logic thread
|
||||
and passed either the response or the error that occurred.
|
||||
"""
|
||||
from ba._general import Call
|
||||
|
||||
del msg # Unused.
|
||||
|
||||
_ba.pushcall(
|
||||
Call(
|
||||
on_response,
|
||||
RuntimeError('Cloud functionality is not available.'),
|
||||
)
|
||||
)
|
||||
|
||||
@overload
|
||||
def send_message(
|
||||
self, msg: bacommon.cloud.WorkspaceFetchMessage
|
||||
) -> bacommon.cloud.WorkspaceFetchResponse:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message(
|
||||
self, msg: bacommon.cloud.MerchAvailabilityMessage
|
||||
) -> bacommon.cloud.MerchAvailabilityResponse:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message(
|
||||
self, msg: bacommon.cloud.TestMessage
|
||||
) -> bacommon.cloud.TestResponse:
|
||||
...
|
||||
|
||||
def send_message(self, msg: Message) -> Response | None:
|
||||
"""Synchronously send a message to the cloud.
|
||||
|
||||
Must be called from a background thread.
|
||||
"""
|
||||
raise RuntimeError('Cloud functionality is not available.')
|
||||
|
||||
|
||||
def cloud_console_exec(code: str) -> None:
|
||||
"""Called by the cloud console to run code in the logic thread."""
|
||||
import sys
|
||||
import __main__
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
try:
|
||||
|
||||
# First try it as eval.
|
||||
try:
|
||||
evalcode = compile(code, '<console>', 'eval')
|
||||
except SyntaxError:
|
||||
evalcode = None
|
||||
except Exception:
|
||||
# hmm; when we can't compile it as eval will we always get
|
||||
# syntax error?
|
||||
logging.exception(
|
||||
'unexpected error compiling code for cloud-console eval.'
|
||||
)
|
||||
evalcode = None
|
||||
if evalcode is not None:
|
||||
# pylint: disable=eval-used
|
||||
value = eval(evalcode, vars(__main__), vars(__main__))
|
||||
# For eval-able statements, print the resulting value if
|
||||
# it is not None (just like standard Python interpreter).
|
||||
if value is not None:
|
||||
print(repr(value), file=sys.stderr)
|
||||
|
||||
# Fall back to exec if we couldn't compile it as eval.
|
||||
else:
|
||||
execcode = compile(code, '<console>', 'exec')
|
||||
# pylint: disable=exec-used
|
||||
exec(execcode, vars(__main__), vars(__main__))
|
||||
except Exception:
|
||||
import traceback
|
||||
|
||||
apptime = _ba.time(TimeType.REAL)
|
||||
print(f'Exec error at time {apptime:.2f}.', file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
|
||||
# This helps the logging system ship stderr back to the
|
||||
# cloud promptly.
|
||||
sys.stderr.flush()
|
||||
72
dist/ba_data/python/ba/_collision.py
vendored
Normal file
72
dist/ba_data/python/ba/_collision.py
vendored
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Collision related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._error import NodeNotFoundError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ba
|
||||
|
||||
|
||||
class Collision:
|
||||
"""A class providing info about occurring collisions.
|
||||
|
||||
Category: **Gameplay Classes**
|
||||
"""
|
||||
|
||||
@property
|
||||
def position(self) -> ba.Vec3:
|
||||
"""The position of the current collision."""
|
||||
return _ba.Vec3(_ba.get_collision_info('position'))
|
||||
|
||||
@property
|
||||
def sourcenode(self) -> ba.Node:
|
||||
"""The node containing the material triggering the current callback.
|
||||
|
||||
Throws a ba.NodeNotFoundError if the node does not exist, though
|
||||
the node should always exist (at least at the start of the collision
|
||||
callback).
|
||||
"""
|
||||
node = _ba.get_collision_info('sourcenode')
|
||||
assert isinstance(node, (_ba.Node, type(None)))
|
||||
if not node:
|
||||
raise NodeNotFoundError()
|
||||
return node
|
||||
|
||||
@property
|
||||
def opposingnode(self) -> ba.Node:
|
||||
"""The node the current callback material node is hitting.
|
||||
|
||||
Throws a ba.NodeNotFoundError if the node does not exist.
|
||||
This can be expected in some cases such as in 'disconnect'
|
||||
callbacks triggered by deleting a currently-colliding node.
|
||||
"""
|
||||
node = _ba.get_collision_info('opposingnode')
|
||||
assert isinstance(node, (_ba.Node, type(None)))
|
||||
if not node:
|
||||
raise NodeNotFoundError()
|
||||
return node
|
||||
|
||||
@property
|
||||
def opposingbody(self) -> int:
|
||||
"""The body index on the opposing node in the current collision."""
|
||||
body = _ba.get_collision_info('opposingbody')
|
||||
assert isinstance(body, int)
|
||||
return body
|
||||
|
||||
|
||||
# Simply recycle one instance...
|
||||
_collision = Collision()
|
||||
|
||||
|
||||
def getcollision() -> Collision:
|
||||
"""Return the in-progress collision.
|
||||
|
||||
Category: **Gameplay Functions**
|
||||
"""
|
||||
return _collision
|
||||
240
dist/ba_data/python/ba/_coopgame.py
vendored
Normal file
240
dist/ba_data/python/ba/_coopgame.py
vendored
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to co-op games."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
import _ba
|
||||
from ba import _internal
|
||||
from ba._gameactivity import GameActivity
|
||||
from ba._general import WeakCall
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Sequence
|
||||
from bastd.actor.playerspaz import PlayerSpaz
|
||||
import ba
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
PlayerType = TypeVar('PlayerType', bound='ba.Player')
|
||||
TeamType = TypeVar('TeamType', bound='ba.Team')
|
||||
# pylint: enable=invalid-name
|
||||
|
||||
|
||||
class CoopGameActivity(GameActivity[PlayerType, TeamType]):
|
||||
"""Base class for cooperative-mode games.
|
||||
|
||||
Category: **Gameplay Classes**
|
||||
"""
|
||||
|
||||
# We can assume our session is a CoopSession.
|
||||
session: ba.CoopSession
|
||||
|
||||
@classmethod
|
||||
def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
|
||||
from ba._coopsession import CoopSession
|
||||
|
||||
return issubclass(sessiontype, CoopSession)
|
||||
|
||||
def __init__(self, settings: dict):
|
||||
super().__init__(settings)
|
||||
|
||||
# Cache these for efficiency.
|
||||
self._achievements_awarded: set[str] = set()
|
||||
|
||||
self._life_warning_beep: ba.Actor | None = None
|
||||
self._life_warning_beep_timer: ba.Timer | None = None
|
||||
self._warn_beeps_sound = _ba.getsound('warnBeeps')
|
||||
|
||||
def on_begin(self) -> None:
|
||||
super().on_begin()
|
||||
|
||||
# Show achievements remaining.
|
||||
if not (_ba.app.demo_mode or _ba.app.arcade_mode):
|
||||
_ba.timer(3.8, WeakCall(self._show_remaining_achievements))
|
||||
|
||||
# Preload achievement images in case we get some.
|
||||
_ba.timer(2.0, WeakCall(self._preload_achievements))
|
||||
|
||||
# FIXME: this is now redundant with activityutils.getscoreconfig();
|
||||
# need to kill this.
|
||||
def get_score_type(self) -> str:
|
||||
"""
|
||||
Return the score unit this co-op game uses ('point', 'seconds', etc.)
|
||||
"""
|
||||
return 'points'
|
||||
|
||||
def _get_coop_level_name(self) -> str:
|
||||
assert self.session.campaign is not None
|
||||
return self.session.campaign.name + ':' + str(self.settings_raw['name'])
|
||||
|
||||
def celebrate(self, duration: float) -> None:
|
||||
"""Tells all existing player-controlled characters to celebrate.
|
||||
|
||||
Can be useful in co-op games when the good guys score or complete
|
||||
a wave.
|
||||
duration is given in seconds.
|
||||
"""
|
||||
from ba._messages import CelebrateMessage
|
||||
|
||||
for player in self.players:
|
||||
if player.actor:
|
||||
player.actor.handlemessage(CelebrateMessage(duration))
|
||||
|
||||
def _preload_achievements(self) -> None:
|
||||
achievements = _ba.app.ach.achievements_for_coop_level(
|
||||
self._get_coop_level_name()
|
||||
)
|
||||
for ach in achievements:
|
||||
ach.get_icon_texture(True)
|
||||
|
||||
def _show_remaining_achievements(self) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._language import Lstr
|
||||
from bastd.actor.text import Text
|
||||
|
||||
ts_h_offs = 30
|
||||
v_offs = -200
|
||||
achievements = [
|
||||
a
|
||||
for a in _ba.app.ach.achievements_for_coop_level(
|
||||
self._get_coop_level_name()
|
||||
)
|
||||
if not a.complete
|
||||
]
|
||||
vrmode = _ba.app.vr_mode
|
||||
if achievements:
|
||||
Text(
|
||||
Lstr(resource='achievementsRemainingText'),
|
||||
host_only=True,
|
||||
position=(ts_h_offs - 10 + 40, v_offs - 10),
|
||||
transition=Text.Transition.FADE_IN,
|
||||
scale=1.1,
|
||||
h_attach=Text.HAttach.LEFT,
|
||||
v_attach=Text.VAttach.TOP,
|
||||
color=(1, 1, 1.2, 1) if vrmode else (0.8, 0.8, 1.0, 1.0),
|
||||
flatness=1.0 if vrmode else 0.6,
|
||||
shadow=1.0 if vrmode else 0.5,
|
||||
transition_delay=0.0,
|
||||
transition_out_delay=1.3 if self.slow_motion else 4.0,
|
||||
).autoretain()
|
||||
hval = 70
|
||||
vval = -50
|
||||
tdelay = 0.0
|
||||
for ach in achievements:
|
||||
tdelay += 0.05
|
||||
ach.create_display(
|
||||
hval + 40,
|
||||
vval + v_offs,
|
||||
0 + tdelay,
|
||||
outdelay=1.3 if self.slow_motion else 4.0,
|
||||
style='in_game',
|
||||
)
|
||||
vval -= 55
|
||||
|
||||
def spawn_player_spaz(
|
||||
self,
|
||||
player: PlayerType,
|
||||
position: Sequence[float] = (0.0, 0.0, 0.0),
|
||||
angle: float | None = None,
|
||||
) -> PlayerSpaz:
|
||||
"""Spawn and wire up a standard player spaz."""
|
||||
spaz = super().spawn_player_spaz(player, position, angle)
|
||||
|
||||
# Deaths are noteworthy in co-op games.
|
||||
spaz.play_big_death_sound = True
|
||||
return spaz
|
||||
|
||||
def _award_achievement(
|
||||
self, achievement_name: str, sound: bool = True
|
||||
) -> None:
|
||||
"""Award an achievement.
|
||||
|
||||
Returns True if a banner will be shown;
|
||||
False otherwise
|
||||
"""
|
||||
|
||||
if achievement_name in self._achievements_awarded:
|
||||
return
|
||||
|
||||
ach = _ba.app.ach.get_achievement(achievement_name)
|
||||
|
||||
# If we're in the easy campaign and this achievement is hard-mode-only,
|
||||
# ignore it.
|
||||
try:
|
||||
campaign = self.session.campaign
|
||||
assert campaign is not None
|
||||
if ach.hard_mode_only and campaign.name == 'Easy':
|
||||
return
|
||||
except Exception:
|
||||
from ba._error import print_exception
|
||||
|
||||
print_exception()
|
||||
|
||||
# If we haven't awarded this one, check to see if we've got it.
|
||||
# If not, set it through the game service *and* add a transaction
|
||||
# for it.
|
||||
if not ach.complete:
|
||||
self._achievements_awarded.add(achievement_name)
|
||||
|
||||
# Report new achievements to the game-service.
|
||||
_internal.report_achievement(achievement_name)
|
||||
|
||||
# ...and to our account.
|
||||
_internal.add_transaction(
|
||||
{'type': 'ACHIEVEMENT', 'name': achievement_name}
|
||||
)
|
||||
|
||||
# Now bring up a celebration banner.
|
||||
ach.announce_completion(sound=sound)
|
||||
|
||||
def fade_to_red(self) -> None:
|
||||
"""Fade the screen to red; (such as when the good guys have lost)."""
|
||||
from ba import _gameutils
|
||||
|
||||
c_existing = self.globalsnode.tint
|
||||
cnode = _ba.newnode(
|
||||
'combine',
|
||||
attrs={
|
||||
'input0': c_existing[0],
|
||||
'input1': c_existing[1],
|
||||
'input2': c_existing[2],
|
||||
'size': 3,
|
||||
},
|
||||
)
|
||||
_gameutils.animate(cnode, 'input1', {0: c_existing[1], 2.0: 0})
|
||||
_gameutils.animate(cnode, 'input2', {0: c_existing[2], 2.0: 0})
|
||||
cnode.connectattr('output', self.globalsnode, 'tint')
|
||||
|
||||
def setup_low_life_warning_sound(self) -> None:
|
||||
"""Set up a beeping noise to play when any players are near death."""
|
||||
self._life_warning_beep = None
|
||||
self._life_warning_beep_timer = _ba.Timer(
|
||||
1.0, WeakCall(self._update_life_warning), repeat=True
|
||||
)
|
||||
|
||||
def _update_life_warning(self) -> None:
|
||||
# Beep continuously if anyone is close to death.
|
||||
should_beep = False
|
||||
for player in self.players:
|
||||
if player.is_alive():
|
||||
# FIXME: Should abstract this instead of
|
||||
# reading hitpoints directly.
|
||||
if getattr(player.actor, 'hitpoints', 999) < 200:
|
||||
should_beep = True
|
||||
break
|
||||
if should_beep and self._life_warning_beep is None:
|
||||
from ba._nodeactor import NodeActor
|
||||
|
||||
self._life_warning_beep = NodeActor(
|
||||
_ba.newnode(
|
||||
'sound',
|
||||
attrs={
|
||||
'sound': self._warn_beeps_sound,
|
||||
'positional': False,
|
||||
'loop': True,
|
||||
},
|
||||
)
|
||||
)
|
||||
if self._life_warning_beep is not None and not should_beep:
|
||||
self._life_warning_beep = None
|
||||
460
dist/ba_data/python/ba/_coopsession.py
vendored
Normal file
460
dist/ba_data/python/ba/_coopsession.py
vendored
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to coop-mode sessions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._session import Session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Callable, Sequence
|
||||
import ba
|
||||
|
||||
TEAM_COLORS = [(0.2, 0.4, 1.6)]
|
||||
TEAM_NAMES = ['Humans']
|
||||
|
||||
|
||||
class CoopSession(Session):
|
||||
"""A ba.Session which runs cooperative-mode games.
|
||||
|
||||
Category: Gameplay Classes
|
||||
|
||||
These generally consist of 1-4 players against
|
||||
the computer and include functionality such as
|
||||
high score lists.
|
||||
|
||||
Attributes:
|
||||
|
||||
campaign
|
||||
The ba.Campaign instance this Session represents, or None if
|
||||
there is no associated Campaign.
|
||||
"""
|
||||
use_teams = True
|
||||
use_team_colors = False
|
||||
allow_mid_activity_joins = True
|
||||
|
||||
# Note: even though these are instance vars, we annotate them at the
|
||||
# class level so that docs generation can access their types.
|
||||
|
||||
campaign: ba.Campaign | None
|
||||
"""The ba.Campaign instance this Session represents, or None if
|
||||
there is no associated Campaign."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Instantiate a co-op mode session."""
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._campaign import getcampaign
|
||||
from bastd.activity.coopjoin import CoopJoinActivity
|
||||
|
||||
_ba.increment_analytics_count('Co-op session start')
|
||||
app = _ba.app
|
||||
|
||||
# If they passed in explicit min/max, honor that.
|
||||
# Otherwise defer to user overrides or defaults.
|
||||
if 'min_players' in app.coop_session_args:
|
||||
min_players = app.coop_session_args['min_players']
|
||||
else:
|
||||
min_players = 1
|
||||
if 'max_players' in app.coop_session_args:
|
||||
max_players = app.coop_session_args['max_players']
|
||||
else:
|
||||
max_players = app.config.get('Coop Game Max Players', 4)
|
||||
|
||||
# print('FIXME: COOP SESSION WOULD CALC DEPS.')
|
||||
depsets: Sequence[ba.DependencySet] = []
|
||||
|
||||
super().__init__(
|
||||
depsets,
|
||||
team_names=TEAM_NAMES,
|
||||
team_colors=TEAM_COLORS,
|
||||
min_players=min_players,
|
||||
max_players=max_players,
|
||||
)
|
||||
|
||||
# Tournament-ID if we correspond to a co-op tournament (otherwise None)
|
||||
self.tournament_id: str | None = app.coop_session_args.get(
|
||||
'tournament_id'
|
||||
)
|
||||
|
||||
self.campaign = getcampaign(app.coop_session_args['campaign'])
|
||||
self.campaign_level_name: str = app.coop_session_args['level']
|
||||
|
||||
self._ran_tutorial_activity = False
|
||||
self._tutorial_activity: ba.Activity | None = None
|
||||
self._custom_menu_ui: list[dict[str, Any]] = []
|
||||
|
||||
# Start our joining screen.
|
||||
self.setactivity(_ba.newactivity(CoopJoinActivity))
|
||||
|
||||
self._next_game_instance: ba.GameActivity | None = None
|
||||
self._next_game_level_name: str | None = None
|
||||
self._update_on_deck_game_instances()
|
||||
|
||||
def get_current_game_instance(self) -> ba.GameActivity:
|
||||
"""Get the game instance currently being played."""
|
||||
return self._current_game_instance
|
||||
|
||||
def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
|
||||
# pylint: disable=cyclic-import
|
||||
# from ba._gameactivity import GameActivity
|
||||
|
||||
# Disallow any joins in the middle of the game.
|
||||
# if isinstance(activity, GameActivity):
|
||||
# return False
|
||||
|
||||
return True
|
||||
|
||||
def _update_on_deck_game_instances(self) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._gameactivity import GameActivity
|
||||
|
||||
# Instantiate levels we may be running soon to let them load in the bg.
|
||||
|
||||
# Build an instance for the current level.
|
||||
assert self.campaign is not None
|
||||
level = self.campaign.getlevel(self.campaign_level_name)
|
||||
gametype = level.gametype
|
||||
settings = level.get_settings()
|
||||
|
||||
# Make sure all settings the game expects are present.
|
||||
neededsettings = gametype.get_available_settings(type(self))
|
||||
for setting in neededsettings:
|
||||
if setting.name not in settings:
|
||||
settings[setting.name] = setting.default
|
||||
|
||||
newactivity = _ba.newactivity(gametype, settings)
|
||||
assert isinstance(newactivity, GameActivity)
|
||||
self._current_game_instance: GameActivity = newactivity
|
||||
|
||||
# Find the next level and build an instance for it too.
|
||||
levels = self.campaign.levels
|
||||
level = self.campaign.getlevel(self.campaign_level_name)
|
||||
|
||||
|
||||
nextlevel: ba.Level | None
|
||||
# if level.index < len(levels) - 1:
|
||||
# nextlevel = levels[level.index + 1]
|
||||
# else:
|
||||
# nextlevel = None
|
||||
nextlevel=levels[(level.index+1)%len(levels)]
|
||||
|
||||
if nextlevel:
|
||||
gametype = nextlevel.gametype
|
||||
settings = nextlevel.get_settings()
|
||||
|
||||
# Make sure all settings the game expects are present.
|
||||
neededsettings = gametype.get_available_settings(type(self))
|
||||
for setting in neededsettings:
|
||||
if setting.name not in settings:
|
||||
settings[setting.name] = setting.default
|
||||
|
||||
# We wanna be in the activity's context while taking it down.
|
||||
newactivity = _ba.newactivity(gametype, settings)
|
||||
assert isinstance(newactivity, GameActivity)
|
||||
self._next_game_instance = newactivity
|
||||
self._next_game_level_name = nextlevel.name
|
||||
else:
|
||||
self._next_game_instance = None
|
||||
self._next_game_level_name = None
|
||||
|
||||
# Special case:
|
||||
# If our current level is 'onslaught training', instantiate
|
||||
# our tutorial so its ready to go. (if we haven't run it yet).
|
||||
if (
|
||||
self.campaign_level_name == 'Onslaught Training'
|
||||
and self._tutorial_activity is None
|
||||
and not self._ran_tutorial_activity
|
||||
):
|
||||
from bastd.tutorial import TutorialActivity
|
||||
|
||||
self._tutorial_activity = _ba.newactivity(TutorialActivity)
|
||||
|
||||
def get_custom_menu_entries(self) -> list[dict[str, Any]]:
|
||||
return self._custom_menu_ui
|
||||
|
||||
def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None:
|
||||
from ba._general import WeakCall
|
||||
|
||||
super().on_player_leave(sessionplayer)
|
||||
|
||||
_ba.timer(2.0, WeakCall(self._handle_empty_activity))
|
||||
|
||||
def _handle_empty_activity(self) -> None:
|
||||
"""Handle cases where all players have left the current activity."""
|
||||
|
||||
from ba._gameactivity import GameActivity
|
||||
|
||||
activity = self.getactivity()
|
||||
if activity is None:
|
||||
return # Hmm what should we do in this case?
|
||||
|
||||
# If there are still players in the current activity, we're good.
|
||||
if activity.players:
|
||||
return
|
||||
|
||||
# If there are *not* players in the current activity but there
|
||||
# *are* in the session:
|
||||
if not activity.players and self.sessionplayers:
|
||||
|
||||
# If we're in a game, we should restart to pull in players
|
||||
# currently waiting in the session.
|
||||
if isinstance(activity, GameActivity):
|
||||
|
||||
# Never restart tourney games however; just end the session
|
||||
# if all players are gone.
|
||||
if self.tournament_id is not None:
|
||||
self.end()
|
||||
else:
|
||||
self.restart()
|
||||
|
||||
# Hmm; no players anywhere. Let's end the entire session if we're
|
||||
# running a GUI (or just the current game if we're running headless).
|
||||
else:
|
||||
if not _ba.app.headless_mode:
|
||||
self.end()
|
||||
else:
|
||||
if isinstance(activity, GameActivity):
|
||||
with _ba.Context(activity):
|
||||
activity.end_game()
|
||||
|
||||
def _on_tournament_restart_menu_press(
|
||||
self, resume_callback: Callable[[], Any]
|
||||
) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.ui.tournamententry import TournamentEntryWindow
|
||||
from ba._gameactivity import GameActivity
|
||||
|
||||
activity = self.getactivity()
|
||||
if activity is not None and not activity.expired:
|
||||
assert self.tournament_id is not None
|
||||
assert isinstance(activity, GameActivity)
|
||||
TournamentEntryWindow(
|
||||
tournament_id=self.tournament_id,
|
||||
tournament_activity=activity,
|
||||
on_close_call=resume_callback,
|
||||
)
|
||||
|
||||
def restart(self) -> None:
|
||||
"""Restart the current game activity."""
|
||||
|
||||
# Tell the current activity to end with a 'restart' outcome.
|
||||
# We use 'force' so that we apply even if end has already been called
|
||||
# (but is in its delay period).
|
||||
|
||||
# Make an exception if there's no players left. Otherwise this
|
||||
# can override the default session end that occurs in that case.
|
||||
if not self.sessionplayers:
|
||||
return
|
||||
|
||||
# This method may get called from the UI context so make sure we
|
||||
# explicitly run in the activity's context.
|
||||
activity = self.getactivity()
|
||||
if activity is not None and not activity.expired:
|
||||
activity.can_show_ad_on_death = True
|
||||
with _ba.Context(activity):
|
||||
activity.end(results={'outcome': 'restart'}, force=True)
|
||||
|
||||
def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
|
||||
"""Method override for co-op sessions.
|
||||
|
||||
Jumps between co-op games and score screens.
|
||||
"""
|
||||
# pylint: disable=too-many-branches
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=cyclic-import
|
||||
from ba._activitytypes import JoinActivity, TransitionActivity
|
||||
from ba._language import Lstr
|
||||
from ba._general import WeakCall
|
||||
from ba._coopgame import CoopGameActivity
|
||||
from ba._gameresults import GameResults
|
||||
from ba._score import ScoreType
|
||||
from ba._player import PlayerInfo
|
||||
from bastd.tutorial import TutorialActivity
|
||||
from bastd.activity.coopscore import CoopScoreScreen
|
||||
|
||||
app = _ba.app
|
||||
|
||||
# If we're running a TeamGameActivity we'll have a GameResults
|
||||
# as results. Otherwise its an old CoopGameActivity so its giving
|
||||
# us a dict of random stuff.
|
||||
if isinstance(results, GameResults):
|
||||
outcome = 'defeat' # This can't be 'beaten'.
|
||||
else:
|
||||
outcome = '' if results is None else results.get('outcome', '')
|
||||
|
||||
# If we're running with a gui and at any point we have no
|
||||
# in-game players, quit out of the session (this can happen if
|
||||
# someone leaves in the tutorial for instance).
|
||||
if not _ba.app.headless_mode:
|
||||
active_players = [p for p in self.sessionplayers if p.in_game]
|
||||
if not active_players:
|
||||
self.end()
|
||||
return
|
||||
|
||||
# If we're in a between-round activity or a restart-activity,
|
||||
# hop into a round.
|
||||
|
||||
|
||||
if outcome=="victory" or outcome=="restart" or outcome=="defeat":
|
||||
outcome = 'next_level'
|
||||
|
||||
|
||||
|
||||
if (isinstance(activity,
|
||||
(JoinActivity, CoopScoreScreen, TransitionActivity))) or True:
|
||||
|
||||
from features import team_balancer
|
||||
team_balancer.checkToExitCoop()
|
||||
|
||||
if outcome == 'next_level':
|
||||
if self._next_game_instance is None:
|
||||
raise RuntimeError()
|
||||
assert self._next_game_level_name is not None
|
||||
self.campaign_level_name = self._next_game_level_name
|
||||
next_game = self._next_game_instance
|
||||
else:
|
||||
next_game = self._current_game_instance
|
||||
|
||||
# Special case: if we're coming from a joining-activity
|
||||
# and will be going into onslaught-training, show the
|
||||
# tutorial first.
|
||||
if (
|
||||
isinstance(activity, JoinActivity)
|
||||
and self.campaign_level_name == 'Onslaught Training'
|
||||
and not (app.demo_mode or app.arcade_mode)
|
||||
):
|
||||
if self._tutorial_activity is None:
|
||||
raise RuntimeError('Tutorial not preloaded properly.')
|
||||
self.setactivity(self._tutorial_activity)
|
||||
self._tutorial_activity = None
|
||||
self._ran_tutorial_activity = True
|
||||
self._custom_menu_ui = []
|
||||
|
||||
# Normal case; launch the next round.
|
||||
else:
|
||||
|
||||
# Reset stats for the new activity.
|
||||
self.stats.reset()
|
||||
for player in self.sessionplayers:
|
||||
|
||||
# Skip players that are still choosing a team.
|
||||
if player.in_game:
|
||||
self.stats.register_sessionplayer(player)
|
||||
self.stats.setactivity(next_game)
|
||||
|
||||
# Now flip the current activity..
|
||||
self.setactivity(next_game)
|
||||
|
||||
if not (app.demo_mode or app.arcade_mode):
|
||||
if self.tournament_id is not None:
|
||||
self._custom_menu_ui = [
|
||||
{
|
||||
'label': Lstr(resource='restartText'),
|
||||
'resume_on_call': False,
|
||||
'call': WeakCall(
|
||||
self._on_tournament_restart_menu_press
|
||||
),
|
||||
}
|
||||
]
|
||||
else:
|
||||
self._custom_menu_ui = [
|
||||
{
|
||||
'label': Lstr(resource='restartText'),
|
||||
'call': WeakCall(self.restart),
|
||||
}
|
||||
]
|
||||
|
||||
# If we were in a tutorial, just pop a transition to get to the
|
||||
# actual round.
|
||||
elif isinstance(activity, TutorialActivity):
|
||||
self.setactivity(_ba.newactivity(TransitionActivity))
|
||||
|
||||
else:
|
||||
pass
|
||||
if False:
|
||||
|
||||
|
||||
playerinfos: list[ba.PlayerInfo]
|
||||
|
||||
# Generic team games.
|
||||
if isinstance(results, GameResults):
|
||||
playerinfos = results.playerinfos
|
||||
score = results.get_sessionteam_score(results.sessionteams[0])
|
||||
fail_message = None
|
||||
score_order = (
|
||||
'decreasing' if results.lower_is_better else 'increasing'
|
||||
)
|
||||
if results.scoretype in (
|
||||
ScoreType.SECONDS,
|
||||
ScoreType.MILLISECONDS,
|
||||
):
|
||||
scoretype = 'time'
|
||||
|
||||
# ScoreScreen wants hundredths of a second.
|
||||
if score is not None:
|
||||
if results.scoretype is ScoreType.SECONDS:
|
||||
score *= 100
|
||||
elif results.scoretype is ScoreType.MILLISECONDS:
|
||||
score //= 10
|
||||
else:
|
||||
raise RuntimeError('FIXME')
|
||||
else:
|
||||
if results.scoretype is not ScoreType.POINTS:
|
||||
print(f'Unknown ScoreType:' f' "{results.scoretype}"')
|
||||
scoretype = 'points'
|
||||
|
||||
# Old coop-game-specific results; should migrate away from these.
|
||||
else:
|
||||
playerinfos = results.get('playerinfos')
|
||||
score = results['score'] if 'score' in results else None
|
||||
fail_message = (
|
||||
results['fail_message']
|
||||
if 'fail_message' in results
|
||||
else None
|
||||
)
|
||||
score_order = (
|
||||
results['score_order']
|
||||
if 'score_order' in results
|
||||
else 'increasing'
|
||||
)
|
||||
activity_score_type = (
|
||||
activity.get_score_type()
|
||||
if isinstance(activity, CoopGameActivity)
|
||||
else None
|
||||
)
|
||||
assert activity_score_type is not None
|
||||
scoretype = activity_score_type
|
||||
|
||||
# Validate types.
|
||||
if playerinfos is not None:
|
||||
assert isinstance(playerinfos, list)
|
||||
assert (isinstance(i, PlayerInfo) for i in playerinfos)
|
||||
|
||||
# Looks like we were in a round - check the outcome and
|
||||
# go from there.
|
||||
if outcome == 'restart':
|
||||
|
||||
# This will pop up back in the same round.
|
||||
self.setactivity(_ba.newactivity(TransitionActivity))
|
||||
else:
|
||||
self.setactivity(
|
||||
_ba.newactivity(
|
||||
CoopScoreScreen,
|
||||
{
|
||||
'playerinfos': playerinfos,
|
||||
'score': score,
|
||||
'fail_message': fail_message,
|
||||
'score_order': score_order,
|
||||
'score_type': scoretype,
|
||||
'outcome': outcome,
|
||||
'campaign': self.campaign,
|
||||
'level': self.campaign_level_name,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# No matter what, get the next 2 levels ready to go.
|
||||
self._update_on_deck_game_instances()
|
||||
431
dist/ba_data/python/ba/_dependency.py
vendored
Normal file
431
dist/ba_data/python/ba/_dependency.py
vendored
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to object/asset dependencies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from typing import Generic, TypeVar, TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
import ba
|
||||
|
||||
T = TypeVar('T', bound='DependencyComponent')
|
||||
|
||||
|
||||
class Dependency(Generic[T]):
|
||||
"""A dependency on a DependencyComponent (with an optional config).
|
||||
|
||||
Category: **Dependency Classes**
|
||||
|
||||
This class is used to request and access functionality provided
|
||||
by other DependencyComponent classes from a DependencyComponent class.
|
||||
The class functions as a descriptor, allowing dependencies to
|
||||
be added at a class level much the same as properties or methods
|
||||
and then used with class instances to access those dependencies.
|
||||
For instance, if you do 'floofcls = ba.Dependency(FloofClass)' you
|
||||
would then be able to instantiate a FloofClass in your class's
|
||||
methods via self.floofcls().
|
||||
"""
|
||||
|
||||
def __init__(self, cls: type[T], config: Any = None):
|
||||
"""Instantiate a Dependency given a ba.DependencyComponent type.
|
||||
|
||||
Optionally, an arbitrary object can be passed as 'config' to
|
||||
influence dependency calculation for the target class.
|
||||
"""
|
||||
self.cls: type[T] = cls
|
||||
self.config = config
|
||||
self._hash: int | None = None
|
||||
|
||||
def get_hash(self) -> int:
|
||||
"""Return the dependency's hash, calculating it if necessary."""
|
||||
from efro.util import make_hash
|
||||
|
||||
if self._hash is None:
|
||||
self._hash = make_hash((self.cls, self.config))
|
||||
return self._hash
|
||||
|
||||
def __get__(self, obj: Any, cls: Any = None) -> T:
|
||||
if not isinstance(obj, DependencyComponent):
|
||||
if obj is None:
|
||||
raise TypeError(
|
||||
'Dependency must be accessed through an instance.'
|
||||
)
|
||||
raise TypeError(
|
||||
f'Dependency cannot be added to class of type {type(obj)}'
|
||||
' (class must inherit from ba.DependencyComponent).'
|
||||
)
|
||||
|
||||
# We expect to be instantiated from an already living
|
||||
# DependencyComponent with valid dep-data in place..
|
||||
assert cls is not None
|
||||
|
||||
# Get the DependencyEntry this instance is associated with and from
|
||||
# there get back to the DependencySet
|
||||
entry = getattr(obj, '_dep_entry')
|
||||
if entry is None:
|
||||
raise RuntimeError('Invalid dependency access.')
|
||||
entry = entry()
|
||||
assert isinstance(entry, DependencyEntry)
|
||||
depset = entry.depset()
|
||||
assert isinstance(depset, DependencySet)
|
||||
|
||||
if not depset.resolved:
|
||||
raise RuntimeError(
|
||||
"Can't access data on an unresolved DependencySet."
|
||||
)
|
||||
|
||||
# Look up the data in the set based on the hash for this Dependency.
|
||||
assert self._hash in depset.entries
|
||||
entry = depset.entries[self._hash]
|
||||
assert isinstance(entry, DependencyEntry)
|
||||
retval = entry.get_component()
|
||||
assert isinstance(retval, self.cls)
|
||||
return retval
|
||||
|
||||
|
||||
class DependencyComponent:
|
||||
"""Base class for all classes that can act as or use dependencies.
|
||||
|
||||
Category: **Dependency Classes**
|
||||
"""
|
||||
|
||||
_dep_entry: weakref.ref[DependencyEntry]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Instantiate a DependencyComponent."""
|
||||
|
||||
# For now lets issue a warning if these are instantiated without
|
||||
# a dep-entry; we'll make this an error once we're no longer
|
||||
# seeing warnings.
|
||||
# entry = getattr(self, '_dep_entry', None)
|
||||
# if entry is None:
|
||||
# print(f'FIXME: INSTANTIATING DEP CLASS {type(self)} DIRECTLY.')
|
||||
|
||||
@classmethod
|
||||
def dep_is_present(cls, config: Any = None) -> bool:
|
||||
"""Return whether this component/config is present on this device."""
|
||||
del config # Unused here.
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_dynamic_deps(cls, config: Any = None) -> list[Dependency]:
|
||||
"""Return any dynamically-calculated deps for this component/config.
|
||||
|
||||
Deps declared statically as part of the class do not need to be
|
||||
included here; this is only for additional deps that may vary based
|
||||
on the dep config value. (for instance a map required by a game type)
|
||||
"""
|
||||
del config # Unused here.
|
||||
return []
|
||||
|
||||
|
||||
class DependencyEntry:
|
||||
"""Data associated with a dependency/config pair in a ba.DependencySet."""
|
||||
|
||||
# def __del__(self) -> None:
|
||||
# print('~DepEntry()', self.cls)
|
||||
|
||||
def __init__(self, depset: DependencySet, dep: Dependency[T]):
|
||||
# print("DepEntry()", dep.cls)
|
||||
self.cls = dep.cls
|
||||
self.config = dep.config
|
||||
|
||||
# Arbitrary data for use by dependencies in the resolved set
|
||||
# (the static instance for static-deps, etc).
|
||||
self.component: DependencyComponent | None = None
|
||||
|
||||
# Weakref to the depset that includes us (to avoid ref loop).
|
||||
self.depset = weakref.ref(depset)
|
||||
|
||||
def get_component(self) -> DependencyComponent:
|
||||
"""Return the component instance, creating it if necessary."""
|
||||
if self.component is None:
|
||||
# We don't simply call our type to instantiate our instance;
|
||||
# instead we manually call __new__ and then __init__.
|
||||
# This allows us to inject its data properly before __init__().
|
||||
print('creating', self.cls)
|
||||
instance = self.cls.__new__(self.cls)
|
||||
# pylint: disable=protected-access, unnecessary-dunder-call
|
||||
instance._dep_entry = weakref.ref(self)
|
||||
instance.__init__() # type: ignore
|
||||
|
||||
assert self.depset
|
||||
depset = self.depset()
|
||||
assert depset is not None
|
||||
self.component = instance
|
||||
component = self.component
|
||||
assert isinstance(component, self.cls)
|
||||
if component is None:
|
||||
raise RuntimeError(
|
||||
f'Accessing DependencyComponent {self.cls} '
|
||||
'in an invalid state.'
|
||||
)
|
||||
return component
|
||||
|
||||
|
||||
class DependencySet(Generic[T]):
|
||||
"""Set of resolved dependencies and their associated data.
|
||||
|
||||
Category: **Dependency Classes**
|
||||
|
||||
To use DependencyComponents, a set must be created, resolved, and then
|
||||
loaded. The DependencyComponents are only valid while the set remains
|
||||
in existence.
|
||||
"""
|
||||
|
||||
def __init__(self, root_dependency: Dependency[T]):
|
||||
# print('DepSet()')
|
||||
self._root_dependency = root_dependency
|
||||
self._resolved = False
|
||||
self._loaded = False
|
||||
|
||||
# Dependency data indexed by hash.
|
||||
self.entries: dict[int, DependencyEntry] = {}
|
||||
|
||||
# def __del__(self) -> None:
|
||||
# print("~DepSet()")
|
||||
|
||||
def resolve(self) -> None:
|
||||
"""Resolve the complete set of required dependencies for this set.
|
||||
|
||||
Raises a ba.DependencyError if dependencies are missing (or other
|
||||
Exception types on other errors).
|
||||
"""
|
||||
|
||||
if self._resolved:
|
||||
raise Exception('DependencySet has already been resolved.')
|
||||
|
||||
# print('RESOLVING DEP SET')
|
||||
|
||||
# First, recursively expand out all dependencies.
|
||||
self._resolve(self._root_dependency, 0)
|
||||
|
||||
# Now, if any dependencies are not present, raise an Exception
|
||||
# telling exactly which ones (so hopefully they'll be able to be
|
||||
# downloaded/etc.
|
||||
missing = [
|
||||
Dependency(entry.cls, entry.config)
|
||||
for entry in self.entries.values()
|
||||
if not entry.cls.dep_is_present(entry.config)
|
||||
]
|
||||
if missing:
|
||||
from ba._error import DependencyError
|
||||
|
||||
raise DependencyError(missing)
|
||||
|
||||
self._resolved = True
|
||||
# print('RESOLVE SUCCESS!')
|
||||
|
||||
@property
|
||||
def resolved(self) -> bool:
|
||||
"""Whether this set has been successfully resolved."""
|
||||
return self._resolved
|
||||
|
||||
def get_asset_package_ids(self) -> set[str]:
|
||||
"""Return the set of asset-package-ids required by this dep-set.
|
||||
|
||||
Must be called on a resolved dep-set.
|
||||
"""
|
||||
ids: set[str] = set()
|
||||
if not self._resolved:
|
||||
raise Exception('Must be called on a resolved dep-set.')
|
||||
for entry in self.entries.values():
|
||||
if issubclass(entry.cls, AssetPackage):
|
||||
assert isinstance(entry.config, str)
|
||||
ids.add(entry.config)
|
||||
return ids
|
||||
|
||||
def load(self) -> None:
|
||||
"""Instantiate all DependencyComponents in the set.
|
||||
|
||||
Returns a wrapper which can be used to instantiate the root dep.
|
||||
"""
|
||||
# NOTE: stuff below here should probably go in a separate 'instantiate'
|
||||
# method or something.
|
||||
if not self._resolved:
|
||||
raise RuntimeError("Can't load an unresolved DependencySet")
|
||||
|
||||
for entry in self.entries.values():
|
||||
# Do a get on everything which will init all payloads
|
||||
# in the proper order recursively.
|
||||
entry.get_component()
|
||||
|
||||
self._loaded = True
|
||||
|
||||
@property
|
||||
def root(self) -> T:
|
||||
"""The instantiated root DependencyComponent instance for the set."""
|
||||
if not self._loaded:
|
||||
raise RuntimeError('DependencySet is not loaded.')
|
||||
|
||||
rootdata = self.entries[self._root_dependency.get_hash()].component
|
||||
assert isinstance(rootdata, self._root_dependency.cls)
|
||||
return rootdata
|
||||
|
||||
def _resolve(self, dep: Dependency[T], recursion: int) -> None:
|
||||
|
||||
# Watch for wacky infinite dep loops.
|
||||
if recursion > 10:
|
||||
raise RecursionError('Max recursion reached')
|
||||
|
||||
hashval = dep.get_hash()
|
||||
|
||||
if hashval in self.entries:
|
||||
# Found an already resolved one; we're done here.
|
||||
return
|
||||
|
||||
# Add our entry before we recurse so we don't repeat add it if
|
||||
# there's a dependency loop.
|
||||
self.entries[hashval] = DependencyEntry(self, dep)
|
||||
|
||||
# Grab all Dependency instances we find in the class.
|
||||
subdeps = [
|
||||
cls
|
||||
for cls in dep.cls.__dict__.values()
|
||||
if isinstance(cls, Dependency)
|
||||
]
|
||||
|
||||
# ..and add in any dynamic ones it provides.
|
||||
subdeps += dep.cls.get_dynamic_deps(dep.config)
|
||||
for subdep in subdeps:
|
||||
self._resolve(subdep, recursion + 1)
|
||||
|
||||
|
||||
class AssetPackage(DependencyComponent):
|
||||
"""ba.DependencyComponent representing a bundled package of game assets.
|
||||
|
||||
Category: **Asset Classes**
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
# This is used internally by the get_package_xxx calls.
|
||||
self.context = _ba.Context('current')
|
||||
|
||||
entry = self._dep_entry()
|
||||
assert entry is not None
|
||||
assert isinstance(entry.config, str)
|
||||
self.package_id = entry.config
|
||||
print(f'LOADING ASSET PACKAGE {self.package_id}')
|
||||
|
||||
@classmethod
|
||||
def dep_is_present(cls, config: Any = None) -> bool:
|
||||
assert isinstance(config, str)
|
||||
|
||||
# Temp: hard-coding for a single asset-package at the moment.
|
||||
if config == 'stdassets@1':
|
||||
return True
|
||||
return False
|
||||
|
||||
def gettexture(self, name: str) -> ba.Texture:
|
||||
"""Load a named ba.Texture from the AssetPackage.
|
||||
|
||||
Behavior is similar to ba.gettexture()
|
||||
"""
|
||||
return _ba.get_package_texture(self, name)
|
||||
|
||||
def getmodel(self, name: str) -> ba.Model:
|
||||
"""Load a named ba.Model from the AssetPackage.
|
||||
|
||||
Behavior is similar to ba.getmodel()
|
||||
"""
|
||||
return _ba.get_package_model(self, name)
|
||||
|
||||
def getcollidemodel(self, name: str) -> ba.CollideModel:
|
||||
"""Load a named ba.CollideModel from the AssetPackage.
|
||||
|
||||
Behavior is similar to ba.getcollideModel()
|
||||
"""
|
||||
return _ba.get_package_collide_model(self, name)
|
||||
|
||||
def getsound(self, name: str) -> ba.Sound:
|
||||
"""Load a named ba.Sound from the AssetPackage.
|
||||
|
||||
Behavior is similar to ba.getsound()
|
||||
"""
|
||||
return _ba.get_package_sound(self, name)
|
||||
|
||||
def getdata(self, name: str) -> ba.Data:
|
||||
"""Load a named ba.Data from the AssetPackage.
|
||||
|
||||
Behavior is similar to ba.getdata()
|
||||
"""
|
||||
return _ba.get_package_data(self, name)
|
||||
|
||||
|
||||
class TestClassFactory(DependencyComponent):
|
||||
"""Another test dep-obj."""
|
||||
|
||||
_assets = Dependency(AssetPackage, 'stdassets@1')
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
print('Instantiating TestClassFactory')
|
||||
self.tex = self._assets.gettexture('black')
|
||||
self.model = self._assets.getmodel('landMine')
|
||||
self.sound = self._assets.getsound('error')
|
||||
self.data = self._assets.getdata('langdata')
|
||||
|
||||
|
||||
class TestClassObj(DependencyComponent):
|
||||
"""Another test dep-obj."""
|
||||
|
||||
|
||||
class TestClass(DependencyComponent):
|
||||
"""A test dep-obj."""
|
||||
|
||||
_testclass = Dependency(TestClassObj)
|
||||
_factoryclass = Dependency(TestClassFactory, 123)
|
||||
_factoryclass2 = Dependency(TestClassFactory, 123)
|
||||
|
||||
def __del__(self) -> None:
|
||||
print('~TestClass()')
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
print('TestClass()')
|
||||
self._actor = self._testclass
|
||||
print('got actor', self._actor)
|
||||
print('have factory', self._factoryclass)
|
||||
print('have factory2', self._factoryclass2)
|
||||
|
||||
|
||||
def test_depset() -> None:
|
||||
"""Test call to try this stuff out..."""
|
||||
if bool(False):
|
||||
print('running test_depset()...')
|
||||
|
||||
def doit() -> None:
|
||||
from ba._error import DependencyError
|
||||
|
||||
depset = DependencySet(Dependency(TestClass))
|
||||
try:
|
||||
depset.resolve()
|
||||
except DependencyError as exc:
|
||||
for dep in exc.deps:
|
||||
if dep.cls is AssetPackage:
|
||||
print('MISSING ASSET PACKAGE', dep.config)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f'Unknown dependency error for {dep.cls}'
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
print('DependencySet resolve failed with exc type:', type(exc))
|
||||
if depset.resolved:
|
||||
depset.load()
|
||||
testobj = depset.root
|
||||
# instance = testclass(123)
|
||||
print('INSTANTIATED ROOT:', testobj)
|
||||
|
||||
doit()
|
||||
|
||||
# To test this, add prints on __del__ for stuff used above;
|
||||
# everything should be dead at this point if we have no cycles.
|
||||
print('everything should be cleaned up...')
|
||||
_ba.quit()
|
||||
62
dist/ba_data/python/ba/_dualteamsession.py
vendored
Normal file
62
dist/ba_data/python/ba/_dualteamsession.py
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to teams sessions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._multiteamsession import MultiTeamSession
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ba
|
||||
|
||||
|
||||
class DualTeamSession(MultiTeamSession):
|
||||
"""ba.Session type for teams mode games.
|
||||
|
||||
Category: **Gameplay Classes**
|
||||
"""
|
||||
|
||||
# Base class overrides:
|
||||
use_teams = True
|
||||
use_team_colors = True
|
||||
|
||||
_playlist_selection_var = 'Team Tournament Playlist Selection'
|
||||
_playlist_randomize_var = 'Team Tournament Playlist Randomize'
|
||||
_playlists_var = 'Team Tournament Playlists'
|
||||
|
||||
def __init__(self) -> None:
|
||||
_ba.increment_analytics_count('Teams session start')
|
||||
super().__init__()
|
||||
|
||||
def _switch_to_score_screen(self, results: ba.GameResults) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from bastd.activity.drawscore import DrawScoreScreenActivity
|
||||
from bastd.activity.dualteamscore import TeamVictoryScoreScreenActivity
|
||||
from bastd.activity.multiteamvictory import (
|
||||
TeamSeriesVictoryScoreScreenActivity,
|
||||
)
|
||||
|
||||
winnergroups = results.winnergroups
|
||||
|
||||
# If everyone has the same score, call it a draw.
|
||||
if len(winnergroups) < 2:
|
||||
self.setactivity(_ba.newactivity(DrawScoreScreenActivity))
|
||||
else:
|
||||
winner = winnergroups[0].teams[0]
|
||||
winner.customdata['score'] += 1
|
||||
|
||||
# If a team has won, show final victory screen.
|
||||
if winner.customdata['score'] >= (self._series_length - 1) / 2 + 1:
|
||||
self.setactivity(
|
||||
_ba.newactivity(
|
||||
TeamSeriesVictoryScoreScreenActivity, {'winner': winner}
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.setactivity(
|
||||
_ba.newactivity(
|
||||
TeamVictoryScoreScreenActivity, {'winner': winner}
|
||||
)
|
||||
)
|
||||
199
dist/ba_data/python/ba/_enums.py
vendored
Normal file
199
dist/ba_data/python/ba/_enums.py
vendored
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
"""Enum vals generated by batools.pythonenumsmodule; do not edit by hand."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class InputType(Enum):
|
||||
"""Types of input a controller can send to the game.
|
||||
|
||||
Category: Enums
|
||||
|
||||
"""
|
||||
UP_DOWN = 2
|
||||
LEFT_RIGHT = 3
|
||||
JUMP_PRESS = 4
|
||||
JUMP_RELEASE = 5
|
||||
PUNCH_PRESS = 6
|
||||
PUNCH_RELEASE = 7
|
||||
BOMB_PRESS = 8
|
||||
BOMB_RELEASE = 9
|
||||
PICK_UP_PRESS = 10
|
||||
PICK_UP_RELEASE = 11
|
||||
RUN = 12
|
||||
FLY_PRESS = 13
|
||||
FLY_RELEASE = 14
|
||||
START_PRESS = 15
|
||||
START_RELEASE = 16
|
||||
HOLD_POSITION_PRESS = 17
|
||||
HOLD_POSITION_RELEASE = 18
|
||||
LEFT_PRESS = 19
|
||||
LEFT_RELEASE = 20
|
||||
RIGHT_PRESS = 21
|
||||
RIGHT_RELEASE = 22
|
||||
UP_PRESS = 23
|
||||
UP_RELEASE = 24
|
||||
DOWN_PRESS = 25
|
||||
DOWN_RELEASE = 26
|
||||
|
||||
|
||||
class UIScale(Enum):
|
||||
"""The overall scale the UI is being rendered for. Note that this is
|
||||
independent of pixel resolution. For example, a phone and a desktop PC
|
||||
might render the game at similar pixel resolutions but the size they
|
||||
display content at will vary significantly.
|
||||
|
||||
Category: Enums
|
||||
|
||||
'large' is used for devices such as desktop PCs where fine details can
|
||||
be clearly seen. UI elements are generally smaller on the screen
|
||||
and more content can be seen at once.
|
||||
|
||||
'medium' is used for devices such as tablets, TVs, or VR headsets.
|
||||
This mode strikes a balance between clean readability and amount of
|
||||
content visible.
|
||||
|
||||
'small' is used primarily for phones or other small devices where
|
||||
content needs to be presented as large and clear in order to remain
|
||||
readable from an average distance.
|
||||
"""
|
||||
LARGE = 0
|
||||
MEDIUM = 1
|
||||
SMALL = 2
|
||||
|
||||
|
||||
class TimeType(Enum):
|
||||
"""Specifies the type of time for various operations to target/use.
|
||||
|
||||
Category: Enums
|
||||
|
||||
'sim' time is the local simulation time for an activity or session.
|
||||
It can proceed at different rates depending on game speed, stops
|
||||
for pauses, etc.
|
||||
|
||||
'base' is the baseline time for an activity or session. It proceeds
|
||||
consistently regardless of game speed or pausing, but may stop during
|
||||
occurrences such as network outages.
|
||||
|
||||
'real' time is mostly based on clock time, with a few exceptions. It may
|
||||
not advance while the app is backgrounded for instance. (the engine
|
||||
attempts to prevent single large time jumps from occurring)
|
||||
"""
|
||||
SIM = 0
|
||||
BASE = 1
|
||||
REAL = 2
|
||||
|
||||
|
||||
class TimeFormat(Enum):
|
||||
"""Specifies the format time values are provided in.
|
||||
|
||||
Category: Enums
|
||||
"""
|
||||
SECONDS = 0
|
||||
MILLISECONDS = 1
|
||||
|
||||
|
||||
class Permission(Enum):
|
||||
"""Permissions that can be requested from the OS.
|
||||
|
||||
Category: Enums
|
||||
"""
|
||||
STORAGE = 0
|
||||
|
||||
|
||||
class SpecialChar(Enum):
|
||||
"""Special characters the game can print.
|
||||
|
||||
Category: Enums
|
||||
"""
|
||||
DOWN_ARROW = 0
|
||||
UP_ARROW = 1
|
||||
LEFT_ARROW = 2
|
||||
RIGHT_ARROW = 3
|
||||
TOP_BUTTON = 4
|
||||
LEFT_BUTTON = 5
|
||||
RIGHT_BUTTON = 6
|
||||
BOTTOM_BUTTON = 7
|
||||
DELETE = 8
|
||||
SHIFT = 9
|
||||
BACK = 10
|
||||
LOGO_FLAT = 11
|
||||
REWIND_BUTTON = 12
|
||||
PLAY_PAUSE_BUTTON = 13
|
||||
FAST_FORWARD_BUTTON = 14
|
||||
DPAD_CENTER_BUTTON = 15
|
||||
OUYA_BUTTON_O = 16
|
||||
OUYA_BUTTON_U = 17
|
||||
OUYA_BUTTON_Y = 18
|
||||
OUYA_BUTTON_A = 19
|
||||
OUYA_LOGO = 20
|
||||
LOGO = 21
|
||||
TICKET = 22
|
||||
GOOGLE_PLAY_GAMES_LOGO = 23
|
||||
GAME_CENTER_LOGO = 24
|
||||
DICE_BUTTON1 = 25
|
||||
DICE_BUTTON2 = 26
|
||||
DICE_BUTTON3 = 27
|
||||
DICE_BUTTON4 = 28
|
||||
GAME_CIRCLE_LOGO = 29
|
||||
PARTY_ICON = 30
|
||||
TEST_ACCOUNT = 31
|
||||
TICKET_BACKING = 32
|
||||
TROPHY1 = 33
|
||||
TROPHY2 = 34
|
||||
TROPHY3 = 35
|
||||
TROPHY0A = 36
|
||||
TROPHY0B = 37
|
||||
TROPHY4 = 38
|
||||
LOCAL_ACCOUNT = 39
|
||||
ALIBABA_LOGO = 40
|
||||
FLAG_UNITED_STATES = 41
|
||||
FLAG_MEXICO = 42
|
||||
FLAG_GERMANY = 43
|
||||
FLAG_BRAZIL = 44
|
||||
FLAG_RUSSIA = 45
|
||||
FLAG_CHINA = 46
|
||||
FLAG_UNITED_KINGDOM = 47
|
||||
FLAG_CANADA = 48
|
||||
FLAG_INDIA = 49
|
||||
FLAG_JAPAN = 50
|
||||
FLAG_FRANCE = 51
|
||||
FLAG_INDONESIA = 52
|
||||
FLAG_ITALY = 53
|
||||
FLAG_SOUTH_KOREA = 54
|
||||
FLAG_NETHERLANDS = 55
|
||||
FEDORA = 56
|
||||
HAL = 57
|
||||
CROWN = 58
|
||||
YIN_YANG = 59
|
||||
EYE_BALL = 60
|
||||
SKULL = 61
|
||||
HEART = 62
|
||||
DRAGON = 63
|
||||
HELMET = 64
|
||||
MUSHROOM = 65
|
||||
NINJA_STAR = 66
|
||||
VIKING_HELMET = 67
|
||||
MOON = 68
|
||||
SPIDER = 69
|
||||
FIREBALL = 70
|
||||
FLAG_UNITED_ARAB_EMIRATES = 71
|
||||
FLAG_QATAR = 72
|
||||
FLAG_EGYPT = 73
|
||||
FLAG_KUWAIT = 74
|
||||
FLAG_ALGERIA = 75
|
||||
FLAG_SAUDI_ARABIA = 76
|
||||
FLAG_MALAYSIA = 77
|
||||
FLAG_CZECH_REPUBLIC = 78
|
||||
FLAG_AUSTRALIA = 79
|
||||
FLAG_SINGAPORE = 80
|
||||
OCULUS_LOGO = 81
|
||||
STEAM_LOGO = 82
|
||||
NVIDIA_LOGO = 83
|
||||
FLAG_IRAN = 84
|
||||
FLAG_POLAND = 85
|
||||
FLAG_ARGENTINA = 86
|
||||
FLAG_PHILIPPINES = 87
|
||||
FLAG_CHILE = 88
|
||||
MIKIROG = 89
|
||||
V2_LOGO = 90
|
||||
207
dist/ba_data/python/ba/_error.py
vendored
Normal file
207
dist/ba_data/python/ba/_error.py
vendored
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Error related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
import ba
|
||||
|
||||
|
||||
class DependencyError(Exception):
|
||||
"""Exception raised when one or more ba.Dependency items are missing.
|
||||
|
||||
Category: **Exception Classes**
|
||||
|
||||
(this will generally be missing assets).
|
||||
"""
|
||||
|
||||
def __init__(self, deps: list[ba.Dependency]):
|
||||
super().__init__()
|
||||
self._deps = deps
|
||||
|
||||
@property
|
||||
def deps(self) -> list[ba.Dependency]:
|
||||
"""The list of missing dependencies causing this error."""
|
||||
return self._deps
|
||||
|
||||
|
||||
class ContextError(Exception):
|
||||
"""Exception raised when a call is made in an invalid context.
|
||||
|
||||
Category: **Exception Classes**
|
||||
|
||||
Examples of this include calling UI functions within an Activity context
|
||||
or calling scene manipulation functions outside of a game context.
|
||||
"""
|
||||
|
||||
|
||||
class NotFoundError(Exception):
|
||||
"""Exception raised when a referenced object does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class PlayerNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Player does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class SessionPlayerNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.SessionPlayer does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class TeamNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Team does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class MapNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Map does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class DelegateNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected delegate object does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class SessionTeamNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.SessionTeam does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class NodeNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Node does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class ActorNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Actor does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class ActivityNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Activity does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class SessionNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Session does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class InputDeviceNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.InputDevice does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class WidgetNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected ba.Widget does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
# TODO: Should integrate some sort of context printing into our
|
||||
# log handling so we can just use logging.exception() and kill these
|
||||
# two functions.
|
||||
|
||||
|
||||
def print_exception(*args: Any, **keywds: Any) -> None:
|
||||
"""Print info about an exception along with pertinent context state.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
Prints all arguments provided along with various info about the
|
||||
current context and the outstanding exception.
|
||||
Pass the keyword 'once' as True if you want the call to only happen
|
||||
one time from an exact calling location.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
if keywds:
|
||||
allowed_keywds = ['once']
|
||||
if any(keywd not in allowed_keywds for keywd in keywds):
|
||||
raise TypeError('invalid keyword(s)')
|
||||
try:
|
||||
# If we're only printing once and already have, bail.
|
||||
if keywds.get('once', False):
|
||||
if not _ba.do_once():
|
||||
return
|
||||
|
||||
err_str = ' '.join([str(a) for a in args])
|
||||
print('ERROR:', err_str)
|
||||
_ba.print_context()
|
||||
print('PRINTED-FROM:')
|
||||
|
||||
# Basically the output of traceback.print_stack()
|
||||
stackstr = ''.join(traceback.format_stack())
|
||||
print(stackstr, end='')
|
||||
print('EXCEPTION:')
|
||||
|
||||
# Basically the output of traceback.print_exc()
|
||||
excstr = traceback.format_exc()
|
||||
print('\n'.join(' ' + l for l in excstr.splitlines()))
|
||||
except Exception:
|
||||
# I suppose using print_exception here would be a bad idea.
|
||||
print('ERROR: exception in ba.print_exception():')
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def print_error(err_str: str, once: bool = False) -> None:
|
||||
"""Print info about an error along with pertinent context state.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
Prints all positional arguments provided along with various info about the
|
||||
current context.
|
||||
Pass the keyword 'once' as True if you want the call to only happen
|
||||
one time from an exact calling location.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
# If we're only printing once and already have, bail.
|
||||
if once:
|
||||
if not _ba.do_once():
|
||||
return
|
||||
|
||||
print('ERROR:', err_str)
|
||||
_ba.print_context()
|
||||
|
||||
# Basically the output of traceback.print_stack()
|
||||
stackstr = ''.join(traceback.format_stack())
|
||||
print(stackstr, end='')
|
||||
except Exception:
|
||||
print('ERROR: exception in ba.print_error():')
|
||||
traceback.print_exc()
|
||||
109
dist/ba_data/python/ba/_freeforallsession.py
vendored
Normal file
109
dist/ba_data/python/ba/_freeforallsession.py
vendored
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to free-for-all sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._multiteamsession import MultiTeamSession
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ba
|
||||
|
||||
|
||||
class FreeForAllSession(MultiTeamSession):
|
||||
"""ba.Session type for free-for-all mode games.
|
||||
|
||||
Category: **Gameplay Classes**
|
||||
"""
|
||||
|
||||
use_teams = False
|
||||
use_team_colors = False
|
||||
_playlist_selection_var = 'Free-for-All Playlist Selection'
|
||||
_playlist_randomize_var = 'Free-for-All Playlist Randomize'
|
||||
_playlists_var = 'Free-for-All Playlists'
|
||||
|
||||
def get_ffa_point_awards(self) -> dict[int, int]:
|
||||
"""Return the number of points awarded for different rankings.
|
||||
|
||||
This is based on the current number of players.
|
||||
"""
|
||||
point_awards: dict[int, int]
|
||||
if len(self.sessionplayers) == 1:
|
||||
point_awards = {}
|
||||
elif len(self.sessionplayers) == 2:
|
||||
point_awards = {0: 6}
|
||||
elif len(self.sessionplayers) == 3:
|
||||
point_awards = {0: 6, 1: 3}
|
||||
elif len(self.sessionplayers) == 4:
|
||||
point_awards = {0: 8, 1: 4, 2: 2}
|
||||
elif len(self.sessionplayers) == 5:
|
||||
point_awards = {0: 8, 1: 4, 2: 2}
|
||||
elif len(self.sessionplayers) == 6:
|
||||
point_awards = {0: 8, 1: 4, 2: 2}
|
||||
else:
|
||||
point_awards = {0: 8, 1: 4, 2: 2, 3: 1}
|
||||
return point_awards
|
||||
|
||||
def __init__(self) -> None:
|
||||
_ba.increment_analytics_count('Free-for-all session start')
|
||||
super().__init__()
|
||||
|
||||
def _switch_to_score_screen(self, results: ba.GameResults) -> None:
|
||||
# pylint: disable=cyclic-import
|
||||
from efro.util import asserttype
|
||||
from bastd.activity.drawscore import DrawScoreScreenActivity
|
||||
from bastd.activity.multiteamvictory import (
|
||||
TeamSeriesVictoryScoreScreenActivity,
|
||||
)
|
||||
from bastd.activity.freeforallvictory import (
|
||||
FreeForAllVictoryScoreScreenActivity,
|
||||
)
|
||||
|
||||
winners = results.winnergroups
|
||||
|
||||
# If there's multiple players and everyone has the same score,
|
||||
# call it a draw.
|
||||
if len(self.sessionplayers) > 1 and len(winners) < 2:
|
||||
self.setactivity(
|
||||
_ba.newactivity(DrawScoreScreenActivity, {'results': results})
|
||||
)
|
||||
else:
|
||||
# Award different point amounts based on number of players.
|
||||
point_awards = self.get_ffa_point_awards()
|
||||
|
||||
for i, winner in enumerate(winners):
|
||||
for team in winner.teams:
|
||||
points = point_awards[i] if i in point_awards else 0
|
||||
team.customdata['previous_score'] = team.customdata['score']
|
||||
team.customdata['score'] += points
|
||||
|
||||
series_winners = [
|
||||
team
|
||||
for team in self.sessionteams
|
||||
if team.customdata['score'] >= self._ffa_series_length
|
||||
]
|
||||
series_winners.sort(
|
||||
reverse=True,
|
||||
key=lambda t: asserttype(t.customdata['score'], int),
|
||||
)
|
||||
if len(series_winners) == 1 or (
|
||||
len(series_winners) > 1
|
||||
and series_winners[0].customdata['score']
|
||||
!= series_winners[1].customdata['score']
|
||||
):
|
||||
self.setactivity(
|
||||
_ba.newactivity(
|
||||
TeamSeriesVictoryScoreScreenActivity,
|
||||
{'winner': series_winners[0]},
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.setactivity(
|
||||
_ba.newactivity(
|
||||
FreeForAllVictoryScoreScreenActivity,
|
||||
{'results': results},
|
||||
)
|
||||
)
|
||||
1289
dist/ba_data/python/ba/_gameactivity.py
vendored
Normal file
1289
dist/ba_data/python/ba/_gameactivity.py
vendored
Normal file
File diff suppressed because it is too large
Load diff
217
dist/ba_data/python/ba/_gameresults.py
vendored
Normal file
217
dist/ba_data/python/ba/_gameresults.py
vendored
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to game results."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from efro.util import asserttype
|
||||
from ba._team import Team, SessionTeam
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Sequence
|
||||
import ba
|
||||
|
||||
|
||||
@dataclass
|
||||
class WinnerGroup:
|
||||
"""Entry for a winning team or teams calculated by game-results."""
|
||||
|
||||
score: int | None
|
||||
teams: Sequence[ba.SessionTeam]
|
||||
|
||||
|
||||
class GameResults:
|
||||
"""
|
||||
Results for a completed game.
|
||||
|
||||
Category: **Gameplay Classes**
|
||||
|
||||
Upon completion, a game should fill one of these out and pass it to its
|
||||
ba.Activity.end call.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._game_set = False
|
||||
self._scores: dict[
|
||||
int, tuple[weakref.ref[ba.SessionTeam], int | None]
|
||||
] = {}
|
||||
self._sessionteams: list[weakref.ref[ba.SessionTeam]] | None = None
|
||||
self._playerinfos: list[ba.PlayerInfo] | None = None
|
||||
self._lower_is_better: bool | None = None
|
||||
self._score_label: str | None = None
|
||||
self._none_is_winner: bool | None = None
|
||||
self._scoretype: ba.ScoreType | None = None
|
||||
|
||||
def set_game(self, game: ba.GameActivity) -> None:
|
||||
"""Set the game instance these results are applying to."""
|
||||
if self._game_set:
|
||||
raise RuntimeError('Game set twice for GameResults.')
|
||||
self._game_set = True
|
||||
self._sessionteams = [
|
||||
weakref.ref(team.sessionteam) for team in game.teams
|
||||
]
|
||||
scoreconfig = game.getscoreconfig()
|
||||
self._playerinfos = copy.deepcopy(game.initialplayerinfos)
|
||||
self._lower_is_better = scoreconfig.lower_is_better
|
||||
self._score_label = scoreconfig.label
|
||||
self._none_is_winner = scoreconfig.none_is_winner
|
||||
self._scoretype = scoreconfig.scoretype
|
||||
|
||||
def set_team_score(self, team: ba.Team, score: int | None) -> None:
|
||||
"""Set the score for a given team.
|
||||
|
||||
This can be a number or None.
|
||||
(see the none_is_winner arg in the constructor)
|
||||
"""
|
||||
assert isinstance(team, Team)
|
||||
sessionteam = team.sessionteam
|
||||
self._scores[sessionteam.id] = (weakref.ref(sessionteam), score)
|
||||
|
||||
def get_sessionteam_score(self, sessionteam: ba.SessionTeam) -> int | None:
|
||||
"""Return the score for a given ba.SessionTeam."""
|
||||
assert isinstance(sessionteam, SessionTeam)
|
||||
for score in list(self._scores.values()):
|
||||
if score[0]() is sessionteam:
|
||||
return score[1]
|
||||
|
||||
# If we have no score value, assume None.
|
||||
return None
|
||||
|
||||
@property
|
||||
def sessionteams(self) -> list[ba.SessionTeam]:
|
||||
"""Return all ba.SessionTeams in the results."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get teams until game is set.")
|
||||
teams = []
|
||||
assert self._sessionteams is not None
|
||||
for team_ref in self._sessionteams:
|
||||
team = team_ref()
|
||||
if team is not None:
|
||||
teams.append(team)
|
||||
return teams
|
||||
|
||||
def has_score_for_sessionteam(self, sessionteam: ba.SessionTeam) -> bool:
|
||||
"""Return whether there is a score for a given session-team."""
|
||||
return any(s[0]() is sessionteam for s in self._scores.values())
|
||||
|
||||
def get_sessionteam_score_str(self, sessionteam: ba.SessionTeam) -> ba.Lstr:
|
||||
"""Return the score for the given session-team as an Lstr.
|
||||
|
||||
(properly formatted for the score type.)
|
||||
"""
|
||||
from ba._gameutils import timestring
|
||||
from ba._language import Lstr
|
||||
from ba._generated.enums import TimeFormat
|
||||
from ba._score import ScoreType
|
||||
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get team-score-str until game is set.")
|
||||
for score in list(self._scores.values()):
|
||||
if score[0]() is sessionteam:
|
||||
if score[1] is None:
|
||||
return Lstr(value='-')
|
||||
if self._scoretype is ScoreType.SECONDS:
|
||||
return timestring(
|
||||
score[1] * 1000,
|
||||
centi=False,
|
||||
timeformat=TimeFormat.MILLISECONDS,
|
||||
)
|
||||
if self._scoretype is ScoreType.MILLISECONDS:
|
||||
return timestring(
|
||||
score[1], centi=True, timeformat=TimeFormat.MILLISECONDS
|
||||
)
|
||||
return Lstr(value=str(score[1]))
|
||||
return Lstr(value='-')
|
||||
|
||||
@property
|
||||
def playerinfos(self) -> list[ba.PlayerInfo]:
|
||||
"""Get info about the players represented by the results."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get player-info until game is set.")
|
||||
assert self._playerinfos is not None
|
||||
return self._playerinfos
|
||||
|
||||
@property
|
||||
def scoretype(self) -> ba.ScoreType:
|
||||
"""The type of score."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get score-type until game is set.")
|
||||
assert self._scoretype is not None
|
||||
return self._scoretype
|
||||
|
||||
@property
|
||||
def score_label(self) -> str:
|
||||
"""The label associated with scores ('points', etc)."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get score-label until game is set.")
|
||||
assert self._score_label is not None
|
||||
return self._score_label
|
||||
|
||||
@property
|
||||
def lower_is_better(self) -> bool:
|
||||
"""Whether lower scores are better."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get lower-is-better until game is set.")
|
||||
assert self._lower_is_better is not None
|
||||
return self._lower_is_better
|
||||
|
||||
@property
|
||||
def winning_sessionteam(self) -> ba.SessionTeam | None:
|
||||
"""The winning ba.SessionTeam if there is exactly one, or else None."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get winners until game is set.")
|
||||
winners = self.winnergroups
|
||||
if winners and len(winners[0].teams) == 1:
|
||||
return winners[0].teams[0]
|
||||
return None
|
||||
|
||||
@property
|
||||
def winnergroups(self) -> list[WinnerGroup]:
|
||||
"""Get an ordered list of winner groups."""
|
||||
if not self._game_set:
|
||||
raise RuntimeError("Can't get winners until game is set.")
|
||||
|
||||
# Group by best scoring teams.
|
||||
winners: dict[int, list[ba.SessionTeam]] = {}
|
||||
scores = [
|
||||
score
|
||||
for score in self._scores.values()
|
||||
if score[0]() is not None and score[1] is not None
|
||||
]
|
||||
for score in scores:
|
||||
assert score[1] is not None
|
||||
sval = winners.setdefault(score[1], [])
|
||||
team = score[0]()
|
||||
assert team is not None
|
||||
sval.append(team)
|
||||
results: list[tuple[int | None, list[ba.SessionTeam]]] = list(
|
||||
winners.items()
|
||||
)
|
||||
results.sort(
|
||||
reverse=not self._lower_is_better,
|
||||
key=lambda x: asserttype(x[0], int),
|
||||
)
|
||||
|
||||
# Also group the 'None' scores.
|
||||
none_sessionteams: list[ba.SessionTeam] = []
|
||||
for score in self._scores.values():
|
||||
scoreteam = score[0]()
|
||||
if scoreteam is not None and score[1] is None:
|
||||
none_sessionteams.append(scoreteam)
|
||||
|
||||
# Add the Nones to the list (either as winners or losers
|
||||
# depending on the rules).
|
||||
if none_sessionteams:
|
||||
nones: list[tuple[int | None, list[ba.SessionTeam]]] = [
|
||||
(None, none_sessionteams)
|
||||
]
|
||||
if self._none_is_winner:
|
||||
results = nones + results
|
||||
else:
|
||||
results = results + nones
|
||||
|
||||
return [WinnerGroup(score, team) for score, team in results]
|
||||
476
dist/ba_data/python/ba/_gameutils.py
vendored
Normal file
476
dist/ba_data/python/ba/_gameutils.py
vendored
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Utility functionality pertaining to gameplay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._generated.enums import TimeType, TimeFormat, SpecialChar, UIScale
|
||||
from ba._error import ActivityNotFoundError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Sequence
|
||||
import ba
|
||||
|
||||
TROPHY_CHARS = {
|
||||
'1': SpecialChar.TROPHY1,
|
||||
'2': SpecialChar.TROPHY2,
|
||||
'3': SpecialChar.TROPHY3,
|
||||
'0a': SpecialChar.TROPHY0A,
|
||||
'0b': SpecialChar.TROPHY0B,
|
||||
'4': SpecialChar.TROPHY4,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameTip:
|
||||
"""Defines a tip presentable to the user at the start of a game.
|
||||
|
||||
Category: **Gameplay Classes**
|
||||
"""
|
||||
|
||||
text: str
|
||||
icon: ba.Texture | None = None
|
||||
sound: ba.Sound | None = None
|
||||
|
||||
|
||||
def get_trophy_string(trophy_id: str) -> str:
|
||||
"""Given a trophy id, returns a string to visualize it."""
|
||||
if trophy_id in TROPHY_CHARS:
|
||||
return _ba.charstr(TROPHY_CHARS[trophy_id])
|
||||
return '?'
|
||||
|
||||
|
||||
def animate(
|
||||
node: ba.Node,
|
||||
attr: str,
|
||||
keys: dict[float, float],
|
||||
loop: bool = False,
|
||||
offset: float = 0,
|
||||
timetype: ba.TimeType = TimeType.SIM,
|
||||
timeformat: ba.TimeFormat = TimeFormat.SECONDS,
|
||||
suppress_format_warning: bool = False,
|
||||
) -> ba.Node:
|
||||
"""Animate values on a target ba.Node.
|
||||
|
||||
Category: **Gameplay Functions**
|
||||
|
||||
Creates an 'animcurve' node with the provided values and time as an input,
|
||||
connect it to the provided attribute, and set it to die with the target.
|
||||
Key values are provided as time:value dictionary pairs. Time values are
|
||||
relative to the current time. By default, times are specified in seconds,
|
||||
but timeformat can also be set to MILLISECONDS to recreate the old behavior
|
||||
(prior to ba 1.5) of taking milliseconds. Returns the animcurve node.
|
||||
"""
|
||||
if timetype is TimeType.SIM:
|
||||
driver = 'time'
|
||||
else:
|
||||
raise Exception('FIXME; only SIM timetype is supported currently.')
|
||||
items = list(keys.items())
|
||||
items.sort()
|
||||
|
||||
# Temp sanity check while we transition from milliseconds to seconds
|
||||
# based time values.
|
||||
if __debug__:
|
||||
if not suppress_format_warning:
|
||||
for item in items:
|
||||
_ba.time_format_check(timeformat, item[0])
|
||||
|
||||
curve = _ba.newnode(
|
||||
'animcurve',
|
||||
owner=node,
|
||||
name='Driving ' + str(node) + ' \'' + attr + '\'',
|
||||
)
|
||||
|
||||
if timeformat is TimeFormat.SECONDS:
|
||||
mult = 1000
|
||||
elif timeformat is TimeFormat.MILLISECONDS:
|
||||
mult = 1
|
||||
else:
|
||||
raise ValueError(f'invalid timeformat value: {timeformat}')
|
||||
|
||||
curve.times = [int(mult * time) for time, val in items]
|
||||
curve.offset = _ba.time(timeformat=TimeFormat.MILLISECONDS) + int(
|
||||
mult * offset
|
||||
)
|
||||
curve.values = [val for time, val in items]
|
||||
curve.loop = loop
|
||||
|
||||
# If we're not looping, set a timer to kill this curve
|
||||
# after its done its job.
|
||||
# FIXME: Even if we are looping we should have a way to die once we
|
||||
# get disconnected.
|
||||
if not loop:
|
||||
# noinspection PyUnresolvedReferences
|
||||
_ba.timer(
|
||||
int(mult * items[-1][0]) + 1000,
|
||||
curve.delete,
|
||||
timeformat=TimeFormat.MILLISECONDS,
|
||||
)
|
||||
|
||||
# Do the connects last so all our attrs are in place when we push initial
|
||||
# values through.
|
||||
|
||||
# We operate in either activities or sessions..
|
||||
try:
|
||||
globalsnode = _ba.getactivity().globalsnode
|
||||
except ActivityNotFoundError:
|
||||
globalsnode = _ba.getsession().sessionglobalsnode
|
||||
|
||||
globalsnode.connectattr(driver, curve, 'in')
|
||||
curve.connectattr('out', node, attr)
|
||||
return curve
|
||||
|
||||
|
||||
def animate_array(
|
||||
node: ba.Node,
|
||||
attr: str,
|
||||
size: int,
|
||||
keys: dict[float, Sequence[float]],
|
||||
loop: bool = False,
|
||||
offset: float = 0,
|
||||
timetype: ba.TimeType = TimeType.SIM,
|
||||
timeformat: ba.TimeFormat = TimeFormat.SECONDS,
|
||||
suppress_format_warning: bool = False,
|
||||
) -> None:
|
||||
"""Animate an array of values on a target ba.Node.
|
||||
|
||||
Category: **Gameplay Functions**
|
||||
|
||||
Like ba.animate, but operates on array attributes.
|
||||
"""
|
||||
# pylint: disable=too-many-locals
|
||||
combine = _ba.newnode('combine', owner=node, attrs={'size': size})
|
||||
if timetype is TimeType.SIM:
|
||||
driver = 'time'
|
||||
else:
|
||||
raise Exception('FIXME: Only SIM timetype is supported currently.')
|
||||
items = list(keys.items())
|
||||
items.sort()
|
||||
|
||||
# Temp sanity check while we transition from milliseconds to seconds
|
||||
# based time values.
|
||||
if __debug__:
|
||||
if not suppress_format_warning:
|
||||
for item in items:
|
||||
# (PyCharm seems to think item is a float, not a tuple)
|
||||
_ba.time_format_check(timeformat, item[0])
|
||||
|
||||
if timeformat is TimeFormat.SECONDS:
|
||||
mult = 1000
|
||||
elif timeformat is TimeFormat.MILLISECONDS:
|
||||
mult = 1
|
||||
else:
|
||||
raise ValueError('invalid timeformat value: "' + str(timeformat) + '"')
|
||||
|
||||
# We operate in either activities or sessions..
|
||||
try:
|
||||
globalsnode = _ba.getactivity().globalsnode
|
||||
except ActivityNotFoundError:
|
||||
globalsnode = _ba.getsession().sessionglobalsnode
|
||||
|
||||
for i in range(size):
|
||||
curve = _ba.newnode(
|
||||
'animcurve',
|
||||
owner=node,
|
||||
name=(
|
||||
'Driving ' + str(node) + ' \'' + attr + '\' member ' + str(i)
|
||||
),
|
||||
)
|
||||
globalsnode.connectattr(driver, curve, 'in')
|
||||
curve.times = [int(mult * time) for time, val in items]
|
||||
curve.values = [val[i] for time, val in items]
|
||||
curve.offset = _ba.time(timeformat=TimeFormat.MILLISECONDS) + int(
|
||||
mult * offset
|
||||
)
|
||||
curve.loop = loop
|
||||
curve.connectattr('out', combine, 'input' + str(i))
|
||||
|
||||
# If we're not looping, set a timer to kill this
|
||||
# curve after its done its job.
|
||||
if not loop:
|
||||
# (PyCharm seems to think item is a float, not a tuple)
|
||||
# noinspection PyUnresolvedReferences
|
||||
_ba.timer(
|
||||
int(mult * items[-1][0]) + 1000,
|
||||
curve.delete,
|
||||
timeformat=TimeFormat.MILLISECONDS,
|
||||
)
|
||||
combine.connectattr('output', node, attr)
|
||||
|
||||
# If we're not looping, set a timer to kill the combine once
|
||||
# the job is done.
|
||||
# FIXME: Even if we are looping we should have a way to die
|
||||
# once we get disconnected.
|
||||
if not loop:
|
||||
# (PyCharm seems to think item is a float, not a tuple)
|
||||
# noinspection PyUnresolvedReferences
|
||||
_ba.timer(
|
||||
int(mult * items[-1][0]) + 1000,
|
||||
combine.delete,
|
||||
timeformat=TimeFormat.MILLISECONDS,
|
||||
)
|
||||
|
||||
|
||||
def show_damage_count(
|
||||
damage: str, position: Sequence[float], direction: Sequence[float]
|
||||
) -> None:
|
||||
"""Pop up a damage count at a position in space.
|
||||
|
||||
Category: **Gameplay Functions**
|
||||
"""
|
||||
lifespan = 1.0
|
||||
app = _ba.app
|
||||
|
||||
# FIXME: Should never vary game elements based on local config.
|
||||
# (connected clients may have differing configs so they won't
|
||||
# get the intended results).
|
||||
do_big = app.ui.uiscale is UIScale.SMALL or app.vr_mode
|
||||
txtnode = _ba.newnode(
|
||||
'text',
|
||||
attrs={
|
||||
'text': damage,
|
||||
'in_world': True,
|
||||
'h_align': 'center',
|
||||
'flatness': 1.0,
|
||||
'shadow': 1.0 if do_big else 0.7,
|
||||
'color': (1, 0.25, 0.25, 1),
|
||||
'scale': 0.015 if do_big else 0.01,
|
||||
},
|
||||
)
|
||||
# Translate upward.
|
||||
tcombine = _ba.newnode('combine', owner=txtnode, attrs={'size': 3})
|
||||
tcombine.connectattr('output', txtnode, 'position')
|
||||
v_vals = []
|
||||
pval = 0.0
|
||||
vval = 0.07
|
||||
count = 6
|
||||
for i in range(count):
|
||||
v_vals.append((float(i) / count, pval))
|
||||
pval += vval
|
||||
vval *= 0.5
|
||||
p_start = position[0]
|
||||
p_dir = direction[0]
|
||||
animate(
|
||||
tcombine,
|
||||
'input0',
|
||||
{i[0] * lifespan: p_start + p_dir * i[1] for i in v_vals},
|
||||
)
|
||||
p_start = position[1]
|
||||
p_dir = direction[1]
|
||||
animate(
|
||||
tcombine,
|
||||
'input1',
|
||||
{i[0] * lifespan: p_start + p_dir * i[1] for i in v_vals},
|
||||
)
|
||||
p_start = position[2]
|
||||
p_dir = direction[2]
|
||||
animate(
|
||||
tcombine,
|
||||
'input2',
|
||||
{i[0] * lifespan: p_start + p_dir * i[1] for i in v_vals},
|
||||
)
|
||||
animate(txtnode, 'opacity', {0.7 * lifespan: 1.0, lifespan: 0.0})
|
||||
_ba.timer(lifespan, txtnode.delete)
|
||||
|
||||
|
||||
def timestring(
|
||||
timeval: float | int,
|
||||
centi: bool = True,
|
||||
timeformat: ba.TimeFormat = TimeFormat.SECONDS,
|
||||
suppress_format_warning: bool = False,
|
||||
) -> ba.Lstr:
|
||||
"""Generate a ba.Lstr for displaying a time value.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
Given a time value, returns a ba.Lstr with:
|
||||
(hours if > 0 ) : minutes : seconds : (centiseconds if centi=True).
|
||||
|
||||
Time 'timeval' is specified in seconds by default, or 'timeformat' can
|
||||
be set to ba.TimeFormat.MILLISECONDS to accept milliseconds instead.
|
||||
|
||||
WARNING: the underlying Lstr value is somewhat large so don't use this
|
||||
to rapidly update Node text values for an onscreen timer or you may
|
||||
consume significant network bandwidth. For that purpose you should
|
||||
use a 'timedisplay' Node and attribute connections.
|
||||
|
||||
"""
|
||||
from ba._language import Lstr
|
||||
|
||||
# Temp sanity check while we transition from milliseconds to seconds
|
||||
# based time values.
|
||||
if __debug__:
|
||||
if not suppress_format_warning:
|
||||
_ba.time_format_check(timeformat, timeval)
|
||||
|
||||
# We operate on milliseconds internally.
|
||||
if timeformat is TimeFormat.SECONDS:
|
||||
timeval = int(1000 * timeval)
|
||||
elif timeformat is TimeFormat.MILLISECONDS:
|
||||
pass
|
||||
else:
|
||||
raise ValueError(f'invalid timeformat: {timeformat}')
|
||||
if not isinstance(timeval, int):
|
||||
timeval = int(timeval)
|
||||
bits = []
|
||||
subs = []
|
||||
hval = (timeval // 1000) // (60 * 60)
|
||||
if hval != 0:
|
||||
bits.append('${H}')
|
||||
subs.append(
|
||||
(
|
||||
'${H}',
|
||||
Lstr(
|
||||
resource='timeSuffixHoursText',
|
||||
subs=[('${COUNT}', str(hval))],
|
||||
),
|
||||
)
|
||||
)
|
||||
mval = ((timeval // 1000) // 60) % 60
|
||||
if mval != 0:
|
||||
bits.append('${M}')
|
||||
subs.append(
|
||||
(
|
||||
'${M}',
|
||||
Lstr(
|
||||
resource='timeSuffixMinutesText',
|
||||
subs=[('${COUNT}', str(mval))],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# We add seconds if its non-zero *or* we haven't added anything else.
|
||||
if centi:
|
||||
# pylint: disable=consider-using-f-string
|
||||
sval = timeval / 1000.0 % 60.0
|
||||
if sval >= 0.005 or not bits:
|
||||
bits.append('${S}')
|
||||
subs.append(
|
||||
(
|
||||
'${S}',
|
||||
Lstr(
|
||||
resource='timeSuffixSecondsText',
|
||||
subs=[('${COUNT}', ('%.2f' % sval))],
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
sval = timeval // 1000 % 60
|
||||
if sval != 0 or not bits:
|
||||
bits.append('${S}')
|
||||
subs.append(
|
||||
(
|
||||
'${S}',
|
||||
Lstr(
|
||||
resource='timeSuffixSecondsText',
|
||||
subs=[('${COUNT}', str(sval))],
|
||||
),
|
||||
)
|
||||
)
|
||||
return Lstr(value=' '.join(bits), subs=subs)
|
||||
|
||||
|
||||
def cameraflash(duration: float = 999.0) -> None:
|
||||
"""Create a strobing camera flash effect.
|
||||
|
||||
Category: **Gameplay Functions**
|
||||
|
||||
(as seen when a team wins a game)
|
||||
Duration is in seconds.
|
||||
"""
|
||||
# pylint: disable=too-many-locals
|
||||
import random
|
||||
from ba._nodeactor import NodeActor
|
||||
|
||||
x_spread = 10
|
||||
y_spread = 5
|
||||
positions = [
|
||||
[-x_spread, -y_spread],
|
||||
[0, -y_spread],
|
||||
[0, y_spread],
|
||||
[x_spread, -y_spread],
|
||||
[x_spread, y_spread],
|
||||
[-x_spread, y_spread],
|
||||
]
|
||||
times = [0, 2700, 1000, 1800, 500, 1400]
|
||||
|
||||
# Store this on the current activity so we only have one at a time.
|
||||
# FIXME: Need a type safe way to do this.
|
||||
activity = _ba.getactivity()
|
||||
activity.camera_flash_data = [] # type: ignore
|
||||
for i in range(6):
|
||||
light = NodeActor(
|
||||
_ba.newnode(
|
||||
'light',
|
||||
attrs={
|
||||
'position': (positions[i][0], 0, positions[i][1]),
|
||||
'radius': 1.0,
|
||||
'lights_volumes': False,
|
||||
'height_attenuated': False,
|
||||
'color': (0.2, 0.2, 0.8),
|
||||
},
|
||||
)
|
||||
)
|
||||
sval = 1.87
|
||||
iscale = 1.3
|
||||
tcombine = _ba.newnode(
|
||||
'combine',
|
||||
owner=light.node,
|
||||
attrs={
|
||||
'size': 3,
|
||||
'input0': positions[i][0],
|
||||
'input1': 0,
|
||||
'input2': positions[i][1],
|
||||
},
|
||||
)
|
||||
assert light.node
|
||||
tcombine.connectattr('output', light.node, 'position')
|
||||
xval = positions[i][0]
|
||||
yval = positions[i][1]
|
||||
spd = 0.5 + random.random()
|
||||
spd2 = 0.5 + random.random()
|
||||
animate(
|
||||
tcombine,
|
||||
'input0',
|
||||
{
|
||||
0.0: xval + 0,
|
||||
0.069 * spd: xval + 10.0,
|
||||
0.143 * spd: xval - 10.0,
|
||||
0.201 * spd: xval + 0,
|
||||
},
|
||||
loop=True,
|
||||
)
|
||||
animate(
|
||||
tcombine,
|
||||
'input2',
|
||||
{
|
||||
0.0: yval + 0,
|
||||
0.15 * spd2: yval + 10.0,
|
||||
0.287 * spd2: yval - 10.0,
|
||||
0.398 * spd2: yval + 0,
|
||||
},
|
||||
loop=True,
|
||||
)
|
||||
animate(
|
||||
light.node,
|
||||
'intensity',
|
||||
{
|
||||
0.0: 0,
|
||||
0.02 * sval: 0,
|
||||
0.05 * sval: 0.8 * iscale,
|
||||
0.08 * sval: 0,
|
||||
0.1 * sval: 0,
|
||||
},
|
||||
loop=True,
|
||||
offset=times[i],
|
||||
)
|
||||
_ba.timer(
|
||||
(times[i] + random.randint(1, int(duration)) * 40 * sval),
|
||||
light.node.delete,
|
||||
timeformat=TimeFormat.MILLISECONDS,
|
||||
)
|
||||
activity.camera_flash_data.append(light) # type: ignore
|
||||
388
dist/ba_data/python/ba/_general.py
vendored
Normal file
388
dist/ba_data/python/ba/_general.py
vendored
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Utility snippets applying to generic Python code."""
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
import weakref
|
||||
import random
|
||||
import inspect
|
||||
from typing import TYPE_CHECKING, TypeVar, Protocol
|
||||
|
||||
from efro.terminal import Clr
|
||||
import _ba
|
||||
from ba._error import print_error, print_exception
|
||||
from ba._generated.enums import TimeType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
from efro.call import Call as Call # 'as Call' so we re-export.
|
||||
|
||||
|
||||
class Existable(Protocol):
|
||||
"""A Protocol for objects supporting an exists() method.
|
||||
|
||||
Category: **Protocols**
|
||||
"""
|
||||
|
||||
def exists(self) -> bool:
|
||||
"""Whether this object exists."""
|
||||
|
||||
|
||||
ExistableT = TypeVar('ExistableT', bound=Existable)
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def existing(obj: ExistableT | None) -> ExistableT | None:
|
||||
"""Convert invalid references to None for any ba.Existable object.
|
||||
|
||||
Category: **Gameplay Functions**
|
||||
|
||||
To best support type checking, it is important that invalid references
|
||||
not be passed around and instead get converted to values of None.
|
||||
That way the type checker can properly flag attempts to pass possibly-dead
|
||||
objects (FooType | None) into functions expecting only live ones
|
||||
(FooType), etc. This call can be used on any 'existable' object
|
||||
(one with an exists() method) and will convert it to a None value
|
||||
if it does not exist.
|
||||
|
||||
For more info, see notes on 'existables' here:
|
||||
https://ballistica.net/wiki/Coding-Style-Guide
|
||||
"""
|
||||
assert obj is None or hasattr(obj, 'exists'), f'No "exists" on {obj}'
|
||||
return obj if obj is not None and obj.exists() else None
|
||||
|
||||
|
||||
def getclass(name: str, subclassof: type[T]) -> type[T]:
|
||||
"""Given a full class name such as foo.bar.MyClass, return the class.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
The class will be checked to make sure it is a subclass of the provided
|
||||
'subclassof' class, and a TypeError will be raised if not.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
splits = name.split('.')
|
||||
modulename = '.'.join(splits[:-1])
|
||||
classname = splits[-1]
|
||||
module = importlib.import_module(modulename)
|
||||
cls: type = getattr(module, classname)
|
||||
|
||||
if not issubclass(cls, subclassof):
|
||||
raise TypeError(f'{name} is not a subclass of {subclassof}.')
|
||||
return cls
|
||||
|
||||
|
||||
def json_prep(data: Any) -> Any:
|
||||
"""Return a json-friendly version of the provided data.
|
||||
|
||||
This converts any tuples to lists and any bytes to strings
|
||||
(interpreted as utf-8, ignoring errors). Logs errors (just once)
|
||||
if any data is modified/discarded/unsupported.
|
||||
"""
|
||||
|
||||
if isinstance(data, dict):
|
||||
return dict(
|
||||
(json_prep(key), json_prep(value))
|
||||
for key, value in list(data.items())
|
||||
)
|
||||
if isinstance(data, list):
|
||||
return [json_prep(element) for element in data]
|
||||
if isinstance(data, tuple):
|
||||
print_error('json_prep encountered tuple', once=True)
|
||||
return [json_prep(element) for element in data]
|
||||
if isinstance(data, bytes):
|
||||
try:
|
||||
return data.decode(errors='ignore')
|
||||
except Exception:
|
||||
from ba import _error
|
||||
|
||||
print_error('json_prep encountered utf-8 decode error', once=True)
|
||||
return data.decode(errors='ignore')
|
||||
if not isinstance(data, (str, float, bool, type(None), int)):
|
||||
print_error(
|
||||
'got unsupported type in json_prep:' + str(type(data)), once=True
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def utf8_all(data: Any) -> Any:
|
||||
"""Convert any unicode data in provided sequence(s) to utf8 bytes."""
|
||||
if isinstance(data, dict):
|
||||
return dict(
|
||||
(utf8_all(key), utf8_all(value))
|
||||
for key, value in list(data.items())
|
||||
)
|
||||
if isinstance(data, list):
|
||||
return [utf8_all(element) for element in data]
|
||||
if isinstance(data, tuple):
|
||||
return tuple(utf8_all(element) for element in data)
|
||||
if isinstance(data, str):
|
||||
return data.encode('utf-8', errors='ignore')
|
||||
return data
|
||||
|
||||
|
||||
def get_type_name(cls: type) -> str:
|
||||
"""Return a full type name including module for a class."""
|
||||
return cls.__module__ + '.' + cls.__name__
|
||||
|
||||
|
||||
class _WeakCall:
|
||||
"""Wrap a callable and arguments into a single callable object.
|
||||
|
||||
Category: **General Utility Classes**
|
||||
|
||||
When passed a bound method as the callable, the instance portion
|
||||
of it is weak-referenced, meaning the underlying instance is
|
||||
free to die if all other references to it go away. Should this
|
||||
occur, calling the WeakCall is simply a no-op.
|
||||
|
||||
Think of this as a handy way to tell an object to do something
|
||||
at some point in the future if it happens to still exist.
|
||||
|
||||
##### Examples
|
||||
**EXAMPLE A:** this code will create a FooClass instance and call its
|
||||
bar() method 5 seconds later; it will be kept alive even though
|
||||
we overwrite its variable with None because the bound method
|
||||
we pass as a timer callback (foo.bar) strong-references it
|
||||
>>> foo = FooClass()
|
||||
... ba.timer(5.0, foo.bar)
|
||||
... foo = None
|
||||
|
||||
**EXAMPLE B:** This code will *not* keep our object alive; it will die
|
||||
when we overwrite it with None and the timer will be a no-op when it
|
||||
fires
|
||||
>>> foo = FooClass()
|
||||
... ba.timer(5.0, ba.WeakCall(foo.bar))
|
||||
... foo = None
|
||||
|
||||
**EXAMPLE C:** Wrap a method call with some positional and keyword args:
|
||||
>>> myweakcall = ba.WeakCall(self.dostuff, argval1,
|
||||
... namedarg=argval2)
|
||||
... # Now we have a single callable to run that whole mess.
|
||||
... # The same as calling myobj.dostuff(argval1, namedarg=argval2)
|
||||
... # (provided my_obj still exists; this will do nothing
|
||||
... # otherwise).
|
||||
... myweakcall()
|
||||
|
||||
Note: additional args and keywords you provide to the WeakCall()
|
||||
constructor are stored as regular strong-references; you'll need
|
||||
to wrap them in weakrefs manually if desired.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **keywds: Any) -> None:
|
||||
"""Instantiate a WeakCall.
|
||||
|
||||
Pass a callable as the first arg, followed by any number of
|
||||
arguments or keywords.
|
||||
"""
|
||||
if hasattr(args[0], '__func__'):
|
||||
self._call = WeakMethod(args[0])
|
||||
else:
|
||||
app = _ba.app
|
||||
if not app.did_weak_call_warning:
|
||||
print(
|
||||
(
|
||||
'Warning: callable passed to ba.WeakCall() is not'
|
||||
' weak-referencable ('
|
||||
+ str(args[0])
|
||||
+ '); use ba.Call() instead to avoid this '
|
||||
'warning. Stack-trace:'
|
||||
)
|
||||
)
|
||||
import traceback
|
||||
|
||||
traceback.print_stack()
|
||||
app.did_weak_call_warning = True
|
||||
self._call = args[0]
|
||||
self._args = args[1:]
|
||||
self._keywds = keywds
|
||||
|
||||
def __call__(self, *args_extra: Any) -> Any:
|
||||
return self._call(*self._args + args_extra, **self._keywds)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
'<ba.WeakCall object; _call='
|
||||
+ str(self._call)
|
||||
+ ' _args='
|
||||
+ str(self._args)
|
||||
+ ' _keywds='
|
||||
+ str(self._keywds)
|
||||
+ '>'
|
||||
)
|
||||
|
||||
|
||||
class _Call:
|
||||
"""Wraps a callable and arguments into a single callable object.
|
||||
|
||||
Category: **General Utility Classes**
|
||||
|
||||
The callable is strong-referenced so it won't die until this
|
||||
object does.
|
||||
|
||||
Note that a bound method (ex: ``myobj.dosomething``) contains a reference
|
||||
to ``self`` (``myobj`` in that case), so you will be keeping that object
|
||||
alive too. Use ba.WeakCall if you want to pass a method to callback
|
||||
without keeping its object alive.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **keywds: Any):
|
||||
"""Instantiate a Call.
|
||||
|
||||
Pass a callable as the first arg, followed by any number of
|
||||
arguments or keywords.
|
||||
|
||||
##### Example
|
||||
Wrap a method call with 1 positional and 1 keyword arg:
|
||||
>>> mycall = ba.Call(myobj.dostuff, argval, namedarg=argval2)
|
||||
... # Now we have a single callable to run that whole mess.
|
||||
... # ..the same as calling myobj.dostuff(argval, namedarg=argval2)
|
||||
... mycall()
|
||||
"""
|
||||
self._call = args[0]
|
||||
self._args = args[1:]
|
||||
self._keywds = keywds
|
||||
|
||||
def __call__(self, *args_extra: Any) -> Any:
|
||||
return self._call(*self._args + args_extra, **self._keywds)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
'<ba.Call object; _call='
|
||||
+ str(self._call)
|
||||
+ ' _args='
|
||||
+ str(self._args)
|
||||
+ ' _keywds='
|
||||
+ str(self._keywds)
|
||||
+ '>'
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Some interaction between our ballistica pylint plugin
|
||||
# and this code is crashing starting on pylint 2.15.0.
|
||||
# This seems to fix things for now.
|
||||
# pylint: disable=all
|
||||
WeakCall = Call
|
||||
Call = Call
|
||||
else:
|
||||
WeakCall = _WeakCall
|
||||
WeakCall.__name__ = 'WeakCall'
|
||||
Call = _Call
|
||||
Call.__name__ = 'Call'
|
||||
|
||||
|
||||
class WeakMethod:
|
||||
"""A weak-referenced bound method.
|
||||
|
||||
Wraps a bound method using weak references so that the original is
|
||||
free to die. If called with a dead target, is simply a no-op.
|
||||
"""
|
||||
|
||||
def __init__(self, call: types.MethodType):
|
||||
assert isinstance(call, types.MethodType)
|
||||
self._func = call.__func__
|
||||
self._obj = weakref.ref(call.__self__)
|
||||
|
||||
def __call__(self, *args: Any, **keywds: Any) -> Any:
|
||||
obj = self._obj()
|
||||
if obj is None:
|
||||
return None
|
||||
return self._func(*((obj,) + args), **keywds)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return '<ba.WeakMethod object; call=' + str(self._func) + '>'
|
||||
|
||||
|
||||
def verify_object_death(obj: object) -> None:
|
||||
"""Warn if an object does not get freed within a short period.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
This can be handy to detect and prevent memory/resource leaks.
|
||||
"""
|
||||
try:
|
||||
ref = weakref.ref(obj)
|
||||
except Exception:
|
||||
print_exception('Unable to create weak-ref in verify_object_death')
|
||||
|
||||
# Use a slight range for our checks so they don't all land at once
|
||||
# if we queue a lot of them.
|
||||
delay = random.uniform(2.0, 5.5)
|
||||
with _ba.Context('ui'):
|
||||
_ba.timer(
|
||||
delay, lambda: _verify_object_death(ref), timetype=TimeType.REAL
|
||||
)
|
||||
|
||||
|
||||
def _verify_object_death(wref: weakref.ref) -> None:
|
||||
obj = wref()
|
||||
if obj is None:
|
||||
return
|
||||
|
||||
try:
|
||||
name = type(obj).__name__
|
||||
except Exception:
|
||||
print(f'Note: unable to get type name for {obj}')
|
||||
name = 'object'
|
||||
|
||||
print(
|
||||
f'{Clr.RED}Error: {name} not dying when expected to:'
|
||||
f' {Clr.BLD}{obj}{Clr.RST}\n'
|
||||
'See efro.debug for ways to debug this.'
|
||||
)
|
||||
|
||||
|
||||
def storagename(suffix: str | None = None) -> str:
|
||||
"""Generate a unique name for storing class data in shared places.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
This consists of a leading underscore, the module path at the
|
||||
call site with dots replaced by underscores, the containing class's
|
||||
qualified name, and the provided suffix. When storing data in public
|
||||
places such as 'customdata' dicts, this minimizes the chance of
|
||||
collisions with other similarly named classes.
|
||||
|
||||
Note that this will function even if called in the class definition.
|
||||
|
||||
##### Examples
|
||||
Generate a unique name for storage purposes:
|
||||
>>> class MyThingie:
|
||||
... # This will give something like
|
||||
... # '_mymodule_submodule_mythingie_data'.
|
||||
... _STORENAME = ba.storagename('data')
|
||||
...
|
||||
... # Use that name to store some data in the Activity we were
|
||||
... # passed.
|
||||
... def __init__(self, activity):
|
||||
... activity.customdata[self._STORENAME] = {}
|
||||
"""
|
||||
frame = inspect.currentframe()
|
||||
if frame is None:
|
||||
raise RuntimeError('Cannot get current stack frame.')
|
||||
fback = frame.f_back
|
||||
|
||||
# Note: We need to explicitly clear frame here to avoid a ref-loop
|
||||
# that keeps all function-dicts in the stack alive until the next
|
||||
# full GC cycle (the stack frame refers to this function's dict,
|
||||
# which refers to the stack frame).
|
||||
del frame
|
||||
|
||||
if fback is None:
|
||||
raise RuntimeError('Cannot get parent stack frame.')
|
||||
modulepath = fback.f_globals.get('__name__')
|
||||
if modulepath is None:
|
||||
raise RuntimeError('Cannot get parent stack module path.')
|
||||
assert isinstance(modulepath, str)
|
||||
qualname = fback.f_locals.get('__qualname__')
|
||||
if qualname is not None:
|
||||
assert isinstance(qualname, str)
|
||||
fullpath = f'_{modulepath}_{qualname.lower()}'
|
||||
else:
|
||||
fullpath = f'_{modulepath}'
|
||||
if suffix is not None:
|
||||
fullpath = f'{fullpath}_{suffix}'
|
||||
return fullpath.replace('.', '_')
|
||||
2
dist/ba_data/python/ba/_generated/__init__.py
vendored
Normal file
2
dist/ba_data/python/ba/_generated/__init__.py
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue