Delete dist directory

This commit is contained in:
Mikahael 2024-02-20 22:53:45 +05:30 committed by GitHub
parent 2e2c838750
commit 867634cc5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1779 changed files with 0 additions and 565850 deletions

View file

@ -1,396 +0,0 @@
# 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

View file

@ -1,267 +0,0 @@
# 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()

View file

@ -1,322 +0,0 @@
# 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()

View file

@ -1,432 +0,0 @@
# 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.
"""

File diff suppressed because it is too large Load diff

View file

@ -1,886 +0,0 @@
# 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
]

View file

@ -1,238 +0,0 @@
# 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,
)

View file

@ -1,202 +0,0 @@
# 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

View file

@ -1,225 +0,0 @@
# 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.

View file

@ -1,83 +0,0 @@
# 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()

View file

@ -1,769 +0,0 @@
# 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()

View file

@ -1,90 +0,0 @@
# 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

View file

@ -1,177 +0,0 @@
# 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()

View file

@ -1,36 +0,0 @@
# 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"
)

View file

@ -1,3 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to the high level state of the app."""

View file

@ -1,509 +0,0 @@
# 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))

View file

@ -1,241 +0,0 @@
# 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)

View file

@ -1,92 +0,0 @@
# 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

View file

@ -1,198 +0,0 @@
# 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
)

View file

@ -1,192 +0,0 @@
# 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)

View file

@ -1,409 +0,0 @@
# 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',
),
],
)
)

View file

@ -1,196 +0,0 @@
# 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()

View file

@ -1,72 +0,0 @@
# 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

View file

@ -1,240 +0,0 @@
# 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

View file

@ -1,460 +0,0 @@
# 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()

View file

@ -1,431 +0,0 @@
# 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()

View file

@ -1,62 +0,0 @@
# 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}
)
)

View file

@ -1,199 +0,0 @@
# 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

View file

@ -1,207 +0,0 @@
# 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()

View file

@ -1,109 +0,0 @@
# 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},
)
)

File diff suppressed because it is too large Load diff

View file

@ -1,217 +0,0 @@
# 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]

View file

@ -1,476 +0,0 @@
# 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

View file

@ -1,388 +0,0 @@
# 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('.', '_')

View file

@ -1,2 +0,0 @@
# 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