Private server

This commit is contained in:
Ayush Saini 2021-03-29 03:24:13 +05:30
commit be7c837e33
668 changed files with 151282 additions and 0 deletions

94
dist/ba_data/python/ba/__init__.py vendored Normal file
View file

@ -0,0 +1,94 @@
# 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=unused-import
# 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, log, newactivity,
newnode, playsound, printnodes, printobjects, 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)
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._coopgame import CoopGameActivity
from ba._coopsession import CoopSession
from ba._dependency import (Dependency, DependencyComponent, DependencySet,
AssetPackage)
from ba._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, 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
# Change everything's listed module to simply 'ba' (instead of 'ba.foo.bar').
def _simplify_module_names() -> None:
for attr, obj in globals().items():
if not attr.startswith('_'):
if getattr(obj, '__module__', None) not in [None, 'ba']:
obj.__module__ = 'ba'
_simplify_module_names()
del _simplify_module_names

267
dist/ba_data/python/ba/_account.py vendored Normal file
View file

@ -0,0 +1,267 @@
# Released under the MIT License. See LICENSE for details.
#
"""Account related functionality."""
from __future__ import annotations
import copy
import time
from typing import TYPE_CHECKING
import _ba
if TYPE_CHECKING:
from typing import Any, Optional, Dict, List, Tuple
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._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 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 old installs
before Pro was a requirement for these.
"""
# 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._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._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()

1217
dist/ba_data/python/ba/_achievement.py vendored Normal file

File diff suppressed because it is too large Load diff

870
dist/ba_data/python/ba/_activity.py vendored Normal file
View file

@ -0,0 +1,870 @@
# 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 weakref import ReferenceType
from typing import Optional, Type, Any, Dict, List
import ba
from bastd.actor.respawnicon import RespawnIcon
PlayerType = TypeVar('PlayerType', bound=Player)
TeamType = TypeVar('TeamType', bound=Team)
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.
Attributes:
settings_raw
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
The list of ba.Teams in the Activity. This gets populated just before
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
The list of ba.Players in the Activity. This gets populated just
before on_begin() is called and is updated automatically as players
join or leave the game.
"""
# pylint: disable=too-many-public-methods
# Annotating attr types at the class level lets us introspect at runtime.
settings_raw: Dict[str, Any]
teams: List[TeamType]
players: List[PlayerType]
# 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.
announce_player_deaths = 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.
is_joining_activity = False
# Whether game-time should still progress when in menus/etc.
allow_pausing = False
# Whether idle players can potentially be kicked (should not happen in
# menus/etc).
allow_kick_idle_players = True
# 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.
use_fixed_vr_overlay = False
# If True, runs in slow motion and turns down sound pitch.
slow_motion = False
# Set this to True to inherit slow motion setting from previous
# activity (useful for transitions to avoid hitches).
inherits_slow_motion = False
# Set this to True to keep playing the music from the previous activity
# (without even restarting it).
inherits_music = False
# Set this to true to inherit VR camera offsets from the previous
# activity (useful for preventing sporadic camera movement
# during transitions).
inherits_vr_camera_offset = 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_vr_overlay_center = False
# Set this to true to inherit screen tint/vignette colors from the
# previous activity (useful to prevent sudden color changes during
# transitions).
inherits_tint = False
# 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.
transition_time = 0.0
# Is it ok to show an ad after this activity ends before showing
# the next activity?
can_show_ad_on_death = False
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: Optional[ba.Node] = 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: Optional[ba.Actor] = 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: Optional[ba.Timer] = None
self._expired = False
self._delay_delete_players: List[PlayerType] = []
self._delay_delete_teams: List[TeamType] = []
self._players_that_left: List[ReferenceType[PlayerType]] = []
self._teams_that_left: List[ReferenceType[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[ReferenceType[ba.Actor]] = []
self._last_prune_dead_actors_time = _ba.time()
self._prune_dead_actors_timer: Optional[ba.Timer] = None
self.teams = []
self.players = []
self.lobby = None
self._stats: Optional[ba.Stats] = None
self._customdata: Optional[dict] = {}
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._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 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 on_transition_in() has been called."""
return self._has_transitioned_in
def has_begun(self) -> bool:
"""Return whether 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 on_transition_out() has been called."""
return self._transitioning_out
def transition_in(self, prev_globals: Optional[ba.Node]) -> 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 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
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: ReferenceType[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:
import gc
import types
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.')
counter[0] += 1
# FIXME: Running the code below shows us references but winds up
# keeping the object alive; need to figure out why.
# For now we just print refs if the count gets to 3, and then we
# kill the app at 4 so it doesn't matter anyway.
if counter[0] == 3:
print('Activity references for', activity, ':')
refs = list(gc.get_referrers(activity))
i = 1
for ref in refs:
if isinstance(ref, types.FrameType):
continue
print(' reference', i, ':', ref)
i += 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
]

217
dist/ba_data/python/ba/_activitytypes.py vendored Normal file
View file

@ -0,0 +1,217 @@
# 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._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:
from typing import Any, Dict, Optional
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: Optional[ba.Actor] = None
self._tips_text: Optional[ba.Actor] = None
self._join_info: Optional[JoinInfo] = 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: Optional[ba.Actor] = 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: Optional[MusicType] = 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: Optional[ba.Actor] = None
self._tips_text: Optional[ba.Actor] = None
self._kicked_off_server_shutdown = False
self._kicked_off_server_restart = False
self._default_show_tips = True
self._custom_continue_message: Optional[ba.Lstr] = None
self._server_transitioning: Optional[bool] = 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()
# 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)

200
dist/ba_data/python/ba/_actor.py vendored Normal file
View file

@ -0,0 +1,200 @@
# 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
from ba._messages import DieMessage, DeathType, OutOfBoundsMessage, UNHANDLED
from ba._error import print_exception, ActivityNotFoundError
import _ba
if TYPE_CHECKING:
from typing import Any, Optional, Literal
import ba
T = TypeVar('T', 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:
# 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 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 exists()
# and 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: T) -> T:
"""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.Actors 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 are Alive or not.
"""
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]) -> Optional[ba.Activity]:
...
def getactivity(self, doraise: bool = True) -> Optional[ba.Activity]:
"""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

186
dist/ba_data/python/ba/_ads.py vendored Normal file
View file

@ -0,0 +1,186 @@
# 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
if TYPE_CHECKING:
from typing import Optional, 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: Optional[float] = None
self.last_ad_purpose = 'invalid'
self.attempted_first_ad = False
self.last_in_game_ad_remove_message_show_time: Optional[float] = None
self.last_ad_completion_time: Optional[float] = None
self.last_ad_was_short = False
def do_remove_in_game_ads_message(self) -> None:
"""(internal)"""
from ba._language import Lstr
from ba._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:
"""(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:
"""(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._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.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: Optional[float]
launch_count = app.config.get('launchCount', 0)
# If we're seeing short ads we may want to space them differently.
interval_mult = (_ba.get_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 = _ba.get_account_misc_read_val(
'ads.startVal1', 0.99)
else:
self.ad_amt = _ba.get_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 = _ba.get_account_misc_read_val(base + '.minLC', 0.0)
max_lc = _ba.get_account_misc_read_val(base + '.maxLC', 5.0)
min_lc_scale = (_ba.get_account_misc_read_val(
base + '.minLCScale', 0.25))
max_lc_scale = (_ba.get_account_misc_read_val(
base + '.maxLCScale', 0.34))
min_lc_interval = (_ba.get_account_misc_read_val(
base + '.minLCInterval', 360))
max_lc_interval = (_ba.get_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.

73
dist/ba_data/python/ba/_analytics.py vendored Normal file
View file

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

569
dist/ba_data/python/ba/_app.py vendored Normal file
View file

@ -0,0 +1,569 @@
# 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
from typing import TYPE_CHECKING
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._account import AccountSubsystem
from ba._meta import MetadataSubsystem
from ba._ads import AdsSubsystem
if TYPE_CHECKING:
import ba
from bastd.actor import spazappearance
from typing import Optional, Dict, Set, Any, Type, Tuple, Callable, List
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
@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 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 game 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
# 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._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
# Misc.
self.tips: List[str] = []
self.stress_test_reset_timer: Optional[ba.Timer] = None
self.did_weak_call_warning = False
self.ran_on_app_launch = False
self.log_have_new = False
self.log_upload_timer_started = False
self._config: Optional[ba.AppConfig] = 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: Optional[str] = None
# Co-op Campaigns.
self.campaigns: Dict[str, ba.Campaign] = {}
# Server Mode.
self.server: Optional[ba.ServerController] = None
self.meta = MetadataSubsystem()
self.accounts = AccountSubsystem()
self.plugins = PluginSubsystem()
self.music = MusicSubsystem()
self.lang = LanguageSubsystem()
self.ach = AchievementSubsystem()
self.ui = UISubsystem()
self.ads = AdsSubsystem()
# Lobby.
self.lobby_random_profile_index: int = 1
self.lobby_random_char_index_offset = random.randrange(1000)
self.lobby_account_profile_device_id: Optional[int] = None
# Main Menu.
self.main_menu_did_initial_transition = False
self.main_menu_last_news_fetch_time: Optional[float] = 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: Optional[Dict] = None
self.ping_thread_count = 0
self.invite_confirm_windows: List[Any] = [] # FIXME: Don't use Any.
self.store_layout: Optional[Dict[str, List[Dict[str, Any]]]] = None
self.store_items: Optional[Dict[str, Dict]] = None
self.pro_sale_start_time: Optional[int] = None
self.pro_sale_start_val: Optional[int] = None
self.delegate: Optional[ba.AppDelegate] = None
def on_app_launch(self) -> None:
"""Runs after the app finishes bootstrapping.
(internal)"""
# pylint: disable=too-many-locals
# pylint: disable=cyclic-import
from ba import _apputils
from ba import _appconfig
from ba import _achievement
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._enums import TimeType
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 _ba.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.
_apputils.handle_leftover_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 import configerror
configerror.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)
# Debugging - make note if we're using the local test server so we
# don't accidentally leave it on in a release.
# FIXME - should move this to the native layer.
server_addr = _ba.get_master_server_address()
if 'localhost' in server_addr:
_ba.timer(2.0,
lambda: _ba.screenmessage(
'Note: using local server',
(1, 1, 0),
log=True,
),
timetype=TimeType.REAL)
elif 'test' in server_addr:
_ba.timer(2.0,
lambda: _ba.screenmessage(
'Note: using test server-module',
(1, 1, 0),
log=True,
),
timetype=TimeType.REAL)
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 _ba.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)
self.meta.on_app_launch()
self.accounts.on_app_launch()
self.plugins.on_app_launch()
self.ran_on_app_launch = True
# from ba._dependency import test_depset
# test_depset()
def read_config(self) -> None:
"""(internal)"""
from ba import _appconfig
self._config, self.config_file_healthy = _appconfig.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: Optional[ba.Activity] = _ba.get_foreground_host_activity()
if (activity is not None and activity.allow_pausing
and not _ba.have_connected_clients()):
from ba import _gameutils
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 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: Optional[ba.Session] = _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.
_ba.add_transaction({
'type': 'END_SESSION',
'sType': str(type(host_session))
})
_ba.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 on_app_pause(self) -> None:
"""Called when the app goes to a suspended state."""
def on_app_resume(self) -> None:
"""Run when the app resumes from a suspended state."""
self.fg_state += 1
self.accounts.on_app_resume()
self.music.on_app_resume()
def launch_coop_game(self,
game: str,
force: bool = False,
args: Dict = 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 on_app_shutdown(self) -> None:
"""(internal)"""
self.music.on_app_shutdown()
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.add_pending_promo_code(code)
else:
_ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
_ba.playsound(_ba.getsound('error'))
def _test_https(self) -> None:
"""Testing https support.
(would be nice to get this working on our custom Python builds; need
to wrangle certificates somehow).
"""
import urllib.request
try:
val = urllib.request.urlopen('https://example.com').read()
print('HTTPS TEST SUCCESS', len(val))
except Exception as exc:
print('HTTPS TEST FAIL:', exc)

166
dist/ba_data/python/ba/_appconfig.py vendored Normal file
View file

@ -0,0 +1,166 @@
# 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, List, Tuple
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._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) 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 exc:
print('EXC copying broken config:', exc)
try:
_ba.log('broken config contents:\n' +
config_contents.replace('\000', '<NULL_BYTE>'),
to_stdout=False)
except Exception as exc:
print('EXC logging broken config contents:', exc)
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) 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 exc:
print('EXC reading prev backup config:', exc)
return config, config_file_healthy
def commit_app_config(force: bool = False) -> None:
"""Commit the config to persistent storage.
Category: General Utility Functions
(internal)
"""
if not _ba.app.config_file_healthy and not force:
print('Current config file is broken; '
'skipping write to avoid losing settings.')
return
_ba.mark_config_dirty()

31
dist/ba_data/python/ba/_appdelegate.py vendored Normal file
View file

@ -0,0 +1,31 @@
# 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 Type, Optional, Any, Dict, 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: Optional[dict],
completion_call: Callable[[Optional[dict]], None]) -> None:
"""Launch a UI to configure the given game config.
It should manipulate the contents of config and call completion_call
when done.
"""
del gameclass, sessiontype, settings, completion_call # Unused.
from ba import _error
_error.print_error(
"create_default_game_settings_ui needs to be overridden")

3
dist/ba_data/python/ba/_appmode.py vendored Normal file
View file

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

238
dist/ba_data/python/ba/_apputils.py vendored Normal file
View file

@ -0,0 +1,238 @@
# 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
from typing import TYPE_CHECKING
import _ba
if TYPE_CHECKING:
from typing import List, Any, Callable, Optional
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_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._netutils import master_server_post
from ba._enums import TimeType
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.getlog(),
'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.has_user_mods(),
'newsShow': _ba.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_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_log_file() -> None:
"""Handle an un-uploaded log from a previous run."""
try:
import json
from ba._netutils import master_server_post
if os.path.exists(_ba.get_log_file_path()):
with open(_ba.get_log_file_path()) 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_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_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)
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,
ignore_activity: ba.Activity = None) -> None:
"""Print warnings for remaining objects in the current context."""
# 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._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)

222
dist/ba_data/python/ba/_assetmanager.py vendored Normal file
View file

@ -0,0 +1,222 @@
# 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
from pathlib import Path
import threading
import urllib.request
import logging
import weakref
import time
import os
import sys
from efro import entity
if TYPE_CHECKING:
from bacommon.assets import AssetPackageFlavor
from typing import List
class FileValue(entity.CompoundValue):
"""State for an individual file."""
class State(entity.Entity):
"""Holds all persistent state for the asset-manager."""
files = entity.CompoundDictField('files', str, FileValue())
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) as infile:
self._state = State.from_json_str(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') as outfile:
outfile.write(self._state.to_json_str())
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.
"""
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, 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)

171
dist/ba_data/python/ba/_benchmark.py vendored Normal file
View file

@ -0,0 +1,171 @@
# 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 Dict, 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._enums import TimeType
_ba.screenmessage(
'Beginning stress test.. use '
"'End Game' 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._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._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."""
_ba.screenmessage('FIXME: 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._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)

352
dist/ba_data/python/ba/_campaign.py vendored Normal file
View file

@ -0,0 +1,352 @@
# 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, List, Dict
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.Levels.
Category: App Classes
"""
def __init__(self, name: str, sequential: bool = True):
self._name = name
self._levels: List[ba.Level] = []
self._sequential = sequential
@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) -> 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))
self._levels.append(level)
@property
def levels(self) -> List[ba.Level]:
"""The list of ba.Levels 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=too-many-statements
# pylint: disable=cyclic-import
from ba 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.
campaign = Campaign('Easy')
campaign.addlevel(
_level.Level('Onslaught Training',
gametype=OnslaughtGame,
settings={'preset': 'training_easy'},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Rookie Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'rookie_easy'},
preview_texture_name='courtyardPreview'))
campaign.addlevel(
_level.Level('Rookie Football',
gametype=FootballCoopGame,
settings={'preset': 'rookie_easy'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Pro Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'pro_easy'},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Pro Football',
gametype=FootballCoopGame,
settings={'preset': 'pro_easy'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Pro Runaround',
gametype=RunaroundGame,
settings={'preset': 'pro_easy'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('Uber Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'uber_easy'},
preview_texture_name='courtyardPreview'))
campaign.addlevel(
_level.Level('Uber Football',
gametype=FootballCoopGame,
settings={'preset': 'uber_easy'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Uber Runaround',
gametype=RunaroundGame,
settings={'preset': 'uber_easy'},
preview_texture_name='towerDPreview'))
register_campaign(campaign)
# "hard" mode
campaign = Campaign('Default')
campaign.addlevel(
_level.Level('Onslaught Training',
gametype=OnslaughtGame,
settings={'preset': 'training'},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Rookie Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'rookie'},
preview_texture_name='courtyardPreview'))
campaign.addlevel(
_level.Level('Rookie Football',
gametype=FootballCoopGame,
settings={'preset': 'rookie'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Pro Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'pro'},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Pro Football',
gametype=FootballCoopGame,
settings={'preset': 'pro'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Pro Runaround',
gametype=RunaroundGame,
settings={'preset': 'pro'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('Uber Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'uber'},
preview_texture_name='courtyardPreview'))
campaign.addlevel(
_level.Level('Uber Football',
gametype=FootballCoopGame,
settings={'preset': 'uber'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Uber Runaround',
gametype=RunaroundGame,
settings={'preset': 'uber'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('The Last Stand',
gametype=TheLastStandGame,
settings={},
preview_texture_name='rampagePreview'))
register_campaign(campaign)
# challenges: our 'official' random extra co-op levels
campaign = Campaign('Challenges', sequential=False)
campaign.addlevel(
_level.Level('Infinite Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'endless'},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Infinite Runaround',
gametype=RunaroundGame,
settings={'preset': 'endless'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('Race',
displayname='${GAME}',
gametype=RaceGame,
settings={
'map': 'Big G',
'Laps': 3,
'Bomb Spawning': 0
},
preview_texture_name='bigGPreview'))
campaign.addlevel(
_level.Level('Pro Race',
displayname='Pro ${GAME}',
gametype=RaceGame,
settings={
'map': 'Big G',
'Laps': 3,
'Bomb Spawning': 1000
},
preview_texture_name='bigGPreview'))
campaign.addlevel(
_level.Level('Lake Frigid Race',
displayname='${GAME}',
gametype=RaceGame,
settings={
'map': 'Lake Frigid',
'Laps': 6,
'Mine Spawning': 2000,
'Bomb Spawning': 0
},
preview_texture_name='lakeFrigidPreview'))
campaign.addlevel(
_level.Level('Football',
displayname='${GAME}',
gametype=FootballCoopGame,
settings={'preset': 'tournament'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Pro Football',
displayname='Pro ${GAME}',
gametype=FootballCoopGame,
settings={'preset': 'tournament_pro'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Runaround',
displayname='${GAME}',
gametype=RunaroundGame,
settings={'preset': 'tournament'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('Uber Runaround',
displayname='Uber ${GAME}',
gametype=RunaroundGame,
settings={'preset': 'tournament_uber'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('The Last Stand',
displayname='${GAME}',
gametype=TheLastStandGame,
settings={'preset': 'tournament'},
preview_texture_name='rampagePreview'))
campaign.addlevel(
_level.Level('Tournament Infinite Onslaught',
displayname='Infinite Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'endless_tournament'},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Tournament Infinite Runaround',
displayname='Infinite Runaround',
gametype=RunaroundGame,
settings={'preset': 'endless_tournament'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('Target Practice',
displayname='Pro ${GAME}',
gametype=TargetPracticeGame,
settings={},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Target Practice B',
displayname='${GAME}',
gametype=TargetPracticeGame,
settings={
'Target Count': 2,
'Enable Impact Bombs': False,
'Enable Triple Bombs': False
},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Meteor Shower',
displayname='${GAME}',
gametype=MeteorShowerGame,
settings={},
preview_texture_name='rampagePreview'))
campaign.addlevel(
_level.Level('Epic Meteor Shower',
displayname='${GAME}',
gametype=MeteorShowerGame,
settings={'Epic Mode': True},
preview_texture_name='rampagePreview'))
campaign.addlevel(
_level.Level('Easter Egg Hunt',
displayname='${GAME}',
gametype=EasterEggHuntGame,
settings={},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('Pro Easter Egg Hunt',
displayname='Pro ${GAME}',
gametype=EasterEggHuntGame,
settings={'Pro Mode': True},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.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'))
campaign.addlevel(
_level.Level(name='Pro Ninja Fight',
displayname='Pro ${GAME}',
gametype=NinjaFightGame,
settings={'preset': 'pro'},
preview_texture_name='courtyardPreview'))
register_campaign(campaign)

72
dist/ba_data/python/ba/_collision.py vendored Normal file
View file

@ -0,0 +1,72 @@
# Released under the MIT License. See LICENSE for details.
#
"""Collision related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
from ba._error import NodeNotFoundError
if TYPE_CHECKING:
import ba
class Collision:
"""A class providing info about occurring collisions.
Category: Gameplay Classes
"""
@property
def position(self) -> ba.Vec3:
"""The position of the current collision."""
return _ba.Vec3(_ba.get_collision_info('position'))
@property
def sourcenode(self) -> ba.Node:
"""The node containing the material triggering the current callback.
Throws a ba.NodeNotFoundError if the node does not exist, though
the node should always exist (at least at the start of the collision
callback).
"""
node = _ba.get_collision_info('sourcenode')
assert isinstance(node, (_ba.Node, type(None)))
if not node:
raise NodeNotFoundError()
return node
@property
def opposingnode(self) -> ba.Node:
"""The node the current callback material node is hitting.
Throws a ba.NodeNotFoundError if the node does not exist.
This can be expected in some cases such as in 'disconnect'
callbacks triggered by deleting a currently-colliding node.
"""
node = _ba.get_collision_info('opposingnode')
assert isinstance(node, (_ba.Node, type(None)))
if not node:
raise NodeNotFoundError()
return node
@property
def opposingbody(self) -> int:
"""The body index on the opposing node in the current collision."""
body = _ba.get_collision_info('opposingbody')
assert isinstance(body, int)
return body
# Simply recycle one instance...
_collision = Collision()
def getcollision() -> Collision:
"""Return the in-progress collision.
Category: Gameplay Functions
"""
return _collision

270
dist/ba_data/python/ba/_coopgame.py vendored Normal file
View file

@ -0,0 +1,270 @@
# 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._gameactivity import GameActivity
from ba._general import WeakCall
if TYPE_CHECKING:
from typing import Type, Dict, Any, Set, List, Sequence, Optional
from bastd.actor.playerspaz import PlayerSpaz
import ba
PlayerType = TypeVar('PlayerType', bound='ba.Player')
TeamType = TypeVar('TeamType', bound='ba.Team')
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: Optional[ba.Actor] = None
self._life_warning_beep_timer: Optional[ba.Timer] = 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))
# Let's ask the server for a 'time-to-beat' value.
levelname = self._get_coop_level_name()
campaign = self.session.campaign
assert campaign is not None
config_str = (str(len(self.players)) + 'p' + campaign.getlevel(
self.settings_raw['name']).get_score_version_string().replace(
' ', '_'))
_ba.get_scores_to_beat(levelname, config_str,
WeakCall(self._on_got_scores_to_beat))
def _on_got_scores_to_beat(self, scores: List[Dict[str, Any]]) -> None:
pass
def _show_standard_scores_to_beat_ui(self,
scores: List[Dict[str, Any]]) -> None:
from efro.util import asserttype
from ba._gameutils import timestring, animate
from ba._nodeactor import NodeActor
from ba._enums import TimeFormat
display_type = self.get_score_type()
if scores is not None:
# Sort by originating date so that the most recent is first.
scores.sort(reverse=True, key=lambda s: asserttype(s['time'], int))
# Now make a display for the most recent challenge.
for score in scores:
if score['type'] == 'score_challenge':
tval = (score['player'] + ': ' + timestring(
int(score['value']) * 10,
timeformat=TimeFormat.MILLISECONDS).evaluate()
if display_type == 'time' else str(score['value']))
hattach = 'center' if display_type == 'time' else 'left'
halign = 'center' if display_type == 'time' else 'left'
pos = (20, -70) if display_type == 'time' else (20, -130)
txt = NodeActor(
_ba.newnode('text',
attrs={
'v_attach': 'top',
'h_attach': hattach,
'h_align': halign,
'color': (0.7, 0.4, 1, 1),
'shadow': 0.5,
'flatness': 1.0,
'position': pos,
'scale': 0.6,
'text': tval
})).autoretain()
assert txt.node is not None
animate(txt.node, 'scale', {1.0: 0.0, 1.1: 0.7, 1.2: 0.6})
break
# 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) -> 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.
_ba.report_achievement(achievement_name)
# ...and to our account.
_ba.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

388
dist/ba_data/python/ba/_coopsession.py vendored Normal file
View file

@ -0,0 +1,388 @@
# 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, List, Dict, Optional, Callable, Sequence
import ba
TEAM_COLORS = [(0.2, 0.4, 1.6)]
TEAM_NAMES = ['Good Guys']
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 = False
# Note: even though these are instance vars, we annotate them at the
# class level so that docs generation can access their types.
campaign: Optional[ba.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: Optional[str] = (
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: Optional[ba.Activity] = None
self._custom_menu_ui: List[Dict[str, Any]] = []
# Start our joining screen.
self.setactivity(_ba.newactivity(CoopJoinActivity))
self._next_game_instance: Optional[ba.GameActivity] = None
self._next_game_level_name: Optional[str] = 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 _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: Optional[ba.Level]
if level.index < len(levels) - 1:
nextlevel = levels[level.index + 1]
else:
nextlevel = None
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)
# If all our players leave we wanna quit out of the session.
_ba.timer(2.0, WeakCall(self._end_session_if_empty))
def _end_session_if_empty(self) -> None:
activity = self.getactivity()
if activity is None:
return # Hmm what should we do in this case?
# If there's still players in the current activity, we're good.
if activity.players:
return
# If there's *no* players left in the current activity but there *is*
# in the session, restart the activity to pull them into the game
# (or quit if they're just in the lobby).
if not activity.players and self.sessionplayers:
# Special exception for tourney games; don't auto-restart these.
if self.tournament_id is not None:
self.end()
else:
# Don't restart joining activities; this probably means there's
# someone with a chooser up in that case.
if not activity.is_joining_activity:
self.restart()
# Hmm; no players anywhere. lets just end the session.
else:
self.end()
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 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).
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 (isinstance(activity,
(JoinActivity, CoopScoreScreen, TransitionActivity))):
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:
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()

422
dist/ba_data/python/ba/_dependency.py vendored Normal file
View file

@ -0,0 +1,422 @@
# 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 Optional, Any, Dict, List, Set, Type
from weakref import ReferenceType
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: Optional[int] = 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: ReferenceType[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: Optional[DependencyComponent] = 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
instance._dep_entry = weakref.ref(self)
instance.__init__()
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

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

198
dist/ba_data/python/ba/_enums.py vendored Normal file
View file

@ -0,0 +1,198 @@
# Released under the MIT License. See LICENSE for details.
"""Enums generated by tools/update_python_enums_module in ba-internal."""
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

193
dist/ba_data/python/ba/_error.py vendored Normal file
View file

@ -0,0 +1,193 @@
# 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, List
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 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
"""
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

@ -0,0 +1,97 @@
# 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:
from typing import Dict
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}))

1165
dist/ba_data/python/ba/_gameactivity.py vendored Normal file

File diff suppressed because it is too large Load diff

212
dist/ba_data/python/ba/_gameresults.py vendored Normal file
View file

@ -0,0 +1,212 @@
# 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 weakref import ReferenceType
from typing import Sequence, Tuple, Any, Optional, Dict, List, Union
import ba
@dataclass
class WinnerGroup:
"""Entry for a winning team or teams calculated by game-results."""
score: Optional[int]
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[ReferenceType[ba.SessionTeam],
Optional[int]]] = {}
self._sessionteams: Optional[List[ReferenceType[
ba.SessionTeam]]] = None
self._playerinfos: Optional[List[ba.PlayerInfo]] = None
self._lower_is_better: Optional[bool] = None
self._score_label: Optional[str] = None
self._none_is_winner: Optional[bool] = None
self._scoretype: Optional[ba.ScoreType] = 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: Optional[int]) -> 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) -> Optional[int]:
"""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._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) -> Optional[ba.SessionTeam]:
"""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[Optional[int],
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[Optional[int], 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]

397
dist/ba_data/python/ba/_gameutils.py vendored Normal file
View file

@ -0,0 +1,397 @@
# 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._enums import TimeType, TimeFormat, SpecialChar, UIScale
from ba._error import ActivityNotFoundError
if TYPE_CHECKING:
from typing import Any, Dict, Sequence, Optional
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: Optional[ba.Texture] = None
sound: Optional[ba.Sound] = 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:
_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)
_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)
_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,
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:
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

410
dist/ba_data/python/ba/_general.py vendored Normal file
View file

@ -0,0 +1,410 @@
# Released under the MIT License. See LICENSE for details.
#
"""Utility snippets applying to generic Python code."""
from __future__ import annotations
import gc
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._enums import TimeType
if TYPE_CHECKING:
from types import FrameType
from typing import Any, Type, Optional
from efro.call import Call as Call # 'as Call' so we re-export.
from weakref import ReferenceType
class Existable(Protocol):
"""A Protocol for objects supporting an exists() method.
Category: Protocols
"""
def exists(self) -> bool:
"""Whether this object exists."""
...
ExistableType = TypeVar('ExistableType', bound=Existable)
T = TypeVar('T')
def existing(obj: Optional[ExistableType]) -> Optional[ExistableType]:
"""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 dead
objects (Optional[FooType]) 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 print_refs(obj: Any) -> None:
"""Print a list of known live references to an object."""
# Hmmm; I just noticed that calling this on an object
# seems to keep it alive. Should figure out why.
print('REFERENCES FOR', obj, ':')
refs = list(gc.get_referrers(obj))
i = 1
for ref in refs:
print(' ref', i, ':', ref)
i += 1
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.
# 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
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.
# Example: wrap a method call with some positional and
# keyword args:
myweakcall = ba.WeakCall(myobj.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()
"""
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, argval1, namedarg=argval2)
# Now we have a single callable to run that whole mess.
# ..the same as calling myobj.dostuff(argval1, 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:
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 print_active_refs(obj: Any) -> None:
"""Print info about things referencing a given object.
Category: General Utility Functions
Useful for tracking down cyclical references and causes for zombie objects.
"""
# pylint: disable=too-many-nested-blocks
from types import FrameType, TracebackType
refs = list(gc.get_referrers(obj))
print(f'{Clr.YLW}Active referrers to {obj}:{Clr.RST}')
for i, ref in enumerate(refs):
print(f'{Clr.YLW}#{i+1}:{Clr.BLU} {ref}{Clr.RST}')
# For certain types of objects such as stack frames, show what is
# keeping *them* alive too.
if isinstance(ref, FrameType):
print(f'{Clr.YLW} Active referrers to #{i+1}:{Clr.RST}')
refs2 = list(gc.get_referrers(ref))
for j, ref2 in enumerate(refs2):
print(f'{Clr.YLW} #a{j+1}:{Clr.BLU} {ref2}{Clr.RST}')
# Can go further down the rabbit-hole if needed...
if bool(False):
if isinstance(ref2, TracebackType):
print(f'{Clr.YLW} '
f'Active referrers to #a{j+1}:{Clr.RST}')
refs3 = list(gc.get_referrers(ref2))
for k, ref3 in enumerate(refs3):
print(f'{Clr.YLW} '
f'#b{k+1}:{Clr.BLU} {ref3}{Clr.RST}')
if isinstance(ref3, BaseException):
print(f'{Clr.YLW} Active referrers to'
f' #b{k+1}:{Clr.RST}')
refs4 = list(gc.get_referrers(ref3))
for x, ref4 in enumerate(refs4):
print(f'{Clr.YLW} #c{x+1}:{Clr.BLU}'
f' {ref4}{Clr.RST}')
def _verify_object_death(wref: ReferenceType) -> 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}')
print_active_refs(obj)
def storagename(suffix: str = 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.
# Example: 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('.', '_')

337
dist/ba_data/python/ba/_hooks.py vendored Normal file
View file

@ -0,0 +1,337 @@
# Released under the MIT License. See LICENSE for details.
#
"""Snippets of code for use by the internal C++ layer.
History: originally I would dynamically compile/eval bits of Python text
from within C++ code, but the major downside there was that none of that was
type-checked so if names or arguments changed I would never catch code breakage
until the code was next run. By defining all snippets I use here and then
capturing references to them all at launch I can immediately verify everything
I'm looking for exists and pylint/mypy can do their magic on this file.
"""
# (most of these are self-explanatory)
# pylint: disable=missing-function-docstring
from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
if TYPE_CHECKING:
from typing import List, Sequence, Optional, Dict, Any
import ba
def reset_to_main_menu() -> None:
"""Reset the game to the main menu gracefully."""
_ba.app.return_to_main_menu_session_gracefully()
def set_config_fullscreen_on() -> None:
"""The app has set fullscreen on its own and we should note it."""
_ba.app.config['Fullscreen'] = True
_ba.app.config.commit()
def set_config_fullscreen_off() -> None:
"""The app has set fullscreen on its own and we should note it."""
_ba.app.config['Fullscreen'] = False
_ba.app.config.commit()
def not_signed_in_screen_message() -> None:
from ba._language import Lstr
_ba.screenmessage(Lstr(resource='notSignedInErrorText'))
def connecting_to_party_message() -> None:
from ba._language import Lstr
_ba.screenmessage(Lstr(resource='internal.connectingToPartyText'),
color=(1, 1, 1))
def rejecting_invite_already_in_party_message() -> None:
from ba._language import Lstr
_ba.screenmessage(
Lstr(resource='internal.rejectingInviteAlreadyInPartyText'),
color=(1, 0.5, 0))
def connection_failed_message() -> None:
from ba._language import Lstr
_ba.screenmessage(Lstr(resource='internal.connectionFailedText'),
color=(1, 0.5, 0))
def temporarily_unavailable_message() -> None:
from ba._language import Lstr
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(
Lstr(resource='getTicketsWindow.unavailableTemporarilyText'),
color=(1, 0, 0))
def in_progress_message() -> None:
from ba._language import Lstr
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(Lstr(resource='getTicketsWindow.inProgressText'),
color=(1, 0, 0))
def error_message() -> None:
from ba._language import Lstr
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
def purchase_not_valid_error() -> None:
from ba._language import Lstr
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(Lstr(resource='store.purchaseNotValidError',
subs=[('${EMAIL}', 'support@froemling.net')]),
color=(1, 0, 0))
def purchase_already_in_progress_error() -> None:
from ba._language import Lstr
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(Lstr(resource='store.purchaseAlreadyInProgressText'),
color=(1, 0, 0))
def gear_vr_controller_warning() -> None:
from ba._language import Lstr
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(Lstr(resource='usesExternalControllerText'),
color=(1, 0, 0))
def orientation_reset_cb_message() -> None:
from ba._language import Lstr
_ba.screenmessage(
Lstr(resource='internal.vrOrientationResetCardboardText'),
color=(0, 1, 0))
def orientation_reset_message() -> None:
from ba._language import Lstr
_ba.screenmessage(Lstr(resource='internal.vrOrientationResetText'),
color=(0, 1, 0))
def on_app_pause() -> None:
_ba.app.on_app_pause()
def on_app_resume() -> None:
_ba.app.on_app_resume()
def launch_main_menu_session() -> None:
from bastd.mainmenu import MainMenuSession
_ba.new_host_session(MainMenuSession)
def language_test_toggle() -> None:
_ba.app.lang.setlanguage('Gibberish' if _ba.app.lang.language ==
'English' else 'English')
def award_in_control_achievement() -> None:
_ba.app.ach.award_local_achievement('In Control')
def award_dual_wielding_achievement() -> None:
_ba.app.ach.award_local_achievement('Dual Wielding')
def play_gong_sound() -> None:
_ba.playsound(_ba.getsound('gong'))
def launch_coop_game(name: str) -> None:
_ba.app.launch_coop_game(name)
def purchases_restored_message() -> None:
from ba._language import Lstr
_ba.screenmessage(Lstr(resource='getTicketsWindow.purchasesRestoredText'),
color=(0, 1, 0))
def dismiss_wii_remotes_window() -> None:
call = _ba.app.ui.dismiss_wii_remotes_window_call
if call is not None:
call()
def unavailable_message() -> None:
from ba._language import Lstr
_ba.screenmessage(Lstr(resource='getTicketsWindow.unavailableText'),
color=(1, 0, 0))
def submit_analytics_counts(sval: str) -> None:
_ba.add_transaction({'type': 'ANALYTICS_COUNTS', 'values': sval})
_ba.run_transactions()
def set_last_ad_network(sval: str) -> None:
import time
_ba.app.ads.last_ad_network = sval
_ba.app.ads.last_ad_network_set_time = time.time()
def no_game_circle_message() -> None:
from ba._language import Lstr
_ba.screenmessage(Lstr(resource='noGameCircleText'), color=(1, 0, 0))
def empty_call() -> None:
pass
def level_icon_press() -> None:
print('LEVEL ICON PRESSED')
def trophy_icon_press() -> None:
print('TROPHY ICON PRESSED')
def coin_icon_press() -> None:
print('COIN ICON PRESSED')
def ticket_icon_press() -> None:
from bastd.ui.resourcetypeinfo import ResourceTypeInfoWindow
ResourceTypeInfoWindow(
origin_widget=_ba.get_special_widget('tickets_info_button'))
def back_button_press() -> None:
_ba.back_press()
def friends_button_press() -> None:
print('FRIEND BUTTON PRESSED!')
def print_trace() -> None:
import traceback
print('Python Traceback (most recent call last):')
traceback.print_stack()
def toggle_fullscreen() -> None:
cfg = _ba.app.config
cfg['Fullscreen'] = not cfg.resolve('Fullscreen')
cfg.apply_and_commit()
def party_icon_activate(origin: Sequence[float]) -> None:
import weakref
from bastd.ui.party import PartyWindow
app = _ba.app
_ba.playsound(_ba.getsound('swish'))
# If it exists, dismiss it; otherwise make a new one.
if app.ui.party_window is not None and app.ui.party_window() is not None:
app.ui.party_window().close()
else:
app.ui.party_window = weakref.ref(PartyWindow(origin=origin))
def read_config() -> None:
_ba.app.read_config()
def ui_remote_press() -> None:
"""Handle a press by a remote device that is only usable for nav."""
from ba._language import Lstr
# Can be called without a context; need a context for getsound.
with _ba.Context('ui'):
_ba.screenmessage(Lstr(resource='internal.controllerForMenusOnlyText'),
color=(1, 0, 0))
_ba.playsound(_ba.getsound('error'))
def quit_window() -> None:
from bastd.ui.confirm import QuitWindow
QuitWindow()
def remove_in_game_ads_message() -> None:
_ba.app.ads.do_remove_in_game_ads_message()
def telnet_access_request() -> None:
from bastd.ui.telnet import TelnetAccessRequestWindow
TelnetAccessRequestWindow()
def do_quit() -> None:
_ba.quit()
def shutdown() -> None:
_ba.app.on_app_shutdown()
def gc_disable() -> None:
import gc
gc.disable()
def device_menu_press(device: ba.InputDevice) -> None:
from bastd.ui.mainmenu import MainMenuWindow
in_main_menu = _ba.app.ui.has_main_menu_window()
if not in_main_menu:
_ba.set_ui_input_device(device)
_ba.playsound(_ba.getsound('swish'))
_ba.app.ui.set_main_menu_window(MainMenuWindow().get_root_widget())
def show_url_window(address: str) -> None:
from bastd.ui.url import ShowURLWindow
ShowURLWindow(address)
def party_invite_revoke(invite_id: str) -> None:
# If there's a confirm window up for joining this particular
# invite, kill it.
for winref in _ba.app.invite_confirm_windows:
win = winref()
if win is not None and win.ew_party_invite_id == invite_id:
_ba.containerwidget(edit=win.get_root_widget(),
transition='out_right')
import privateserver as pvt
def filter_chat_message(msg: str, client_id: int) -> Optional[str]:
"""Intercept/filter chat messages.
Called for all chat messages while hosting.
Messages originating from the host will have clientID -1.
Should filter and return the string to be displayed, or return None
to ignore the message.
"""
pvt.handlechat(msg,client_id);
del client_id # Unused by default.
return msg
def local_chat_message(msg: str) -> None:
if (_ba.app.ui.party_window is not None
and _ba.app.ui.party_window() is not None):
_ba.app.ui.party_window().on_chat_message(msg)
def get_player_icon(sessionplayer: ba.SessionPlayer) -> Dict[str, Any]:
info = sessionplayer.get_icon_info()
return {
'texture': _ba.gettexture(info['texture']),
'tint_texture': _ba.gettexture(info['tint_texture']),
'tint_color': info['tint_color'],
'tint2_color': info['tint2_color']
}

622
dist/ba_data/python/ba/_input.py vendored Normal file
View file

@ -0,0 +1,622 @@
# Released under the MIT License. See LICENSE for details.
#
"""Input related functionality"""
from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
if TYPE_CHECKING:
from typing import Any, Dict, Tuple
import ba
def get_device_value(device: ba.InputDevice, name: str) -> Any:
"""Returns a mapped value for an input device.
This checks the user config and falls back to default values
where available.
"""
# pylint: disable=too-many-statements
# pylint: disable=too-many-return-statements
# pylint: disable=too-many-branches
devicename = device.name
unique_id = device.unique_identifier
app = _ba.app
useragentstring = app.user_agent_string
platform = app.platform
subplatform = app.subplatform
appconfig = _ba.app.config
# If there's an entry in our config for this controller, use it.
if 'Controllers' in appconfig:
ccfgs = appconfig['Controllers']
if devicename in ccfgs:
mapping = None
if unique_id in ccfgs[devicename]:
mapping = ccfgs[devicename][unique_id]
elif 'default' in ccfgs[devicename]:
mapping = ccfgs[devicename]['default']
if mapping is not None:
return mapping.get(name, -1)
if platform == 'windows':
# XInput (hopefully this mapping is consistent?...)
if devicename.startswith('XInput Controller'):
return {
'triggerRun2': 3,
'unassignedButtonsRun': False,
'buttonPickUp': 4,
'buttonBomb': 2,
'buttonStart': 8,
'buttonIgnored2': 7,
'triggerRun1': 6,
'buttonPunch': 3,
'buttonRun2': 5,
'buttonRun1': 6,
'buttonJump': 1,
'buttonIgnored': 11
}.get(name, -1)
# Ps4 controller.
if devicename == 'Wireless Controller':
return {
'triggerRun2': 4,
'unassignedButtonsRun': False,
'buttonPickUp': 4,
'buttonBomb': 3,
'buttonJump': 2,
'buttonStart': 10,
'buttonPunch': 1,
'buttonRun2': 5,
'buttonRun1': 6,
'triggerRun1': 5
}.get(name, -1)
# Look for some exact types.
if _ba.is_running_on_fire_tv():
if devicename in ['Thunder', 'Amazon Fire Game Controller']:
return {
'triggerRun2': 23,
'unassignedButtonsRun': False,
'buttonPickUp': 101,
'buttonBomb': 98,
'buttonJump': 97,
'analogStickDeadZone': 0.0,
'startButtonActivatesDefaultWidget': False,
'buttonStart': 83,
'buttonPunch': 100,
'buttonRun2': 103,
'buttonRun1': 104,
'triggerRun1': 24
}.get(name, -1)
if devicename == 'NYKO PLAYPAD PRO':
return {
'triggerRun2': 23,
'triggerRun1': 24,
'buttonPickUp': 101,
'buttonBomb': 98,
'buttonJump': 97,
'buttonUp': 20,
'buttonLeft': 22,
'buttonRight': 23,
'buttonStart': 83,
'buttonPunch': 100,
'buttonDown': 21
}.get(name, -1)
if devicename == 'Logitech Dual Action':
return {
'triggerRun2': 23,
'triggerRun1': 24,
'buttonPickUp': 98,
'buttonBomb': 101,
'buttonJump': 100,
'buttonStart': 109,
'buttonPunch': 97
}.get(name, -1)
if devicename == 'Xbox 360 Wireless Receiver':
return {
'triggerRun2': 23,
'triggerRun1': 24,
'buttonPickUp': 101,
'buttonBomb': 98,
'buttonJump': 97,
'buttonUp': 20,
'buttonLeft': 22,
'buttonRight': 23,
'buttonStart': 83,
'buttonPunch': 100,
'buttonDown': 21
}.get(name, -1)
if devicename == 'Microsoft X-Box 360 pad':
return {
'triggerRun2': 23,
'triggerRun1': 24,
'buttonPickUp': 101,
'buttonBomb': 98,
'buttonJump': 97,
'buttonStart': 83,
'buttonPunch': 100
}.get(name, -1)
if devicename in [
'Amazon Remote', 'Amazon Bluetooth Dev',
'Amazon Fire TV Remote'
]:
return {
'triggerRun2': 23,
'triggerRun1': 24,
'buttonPickUp': 24,
'buttonBomb': 91,
'buttonJump': 86,
'buttonUp': 20,
'buttonLeft': 22,
'startButtonActivatesDefaultWidget': False,
'buttonRight': 23,
'buttonStart': 83,
'buttonPunch': 90,
'buttonDown': 21
}.get(name, -1)
elif 'NVIDIA SHIELD;' in useragentstring:
if 'NVIDIA Controller' in devicename:
return {
'triggerRun2': 19,
'triggerRun1': 18,
'buttonPickUp': 101,
'buttonBomb': 98,
'buttonJump': 97,
'analogStickDeadZone': 0.0,
'buttonStart': 109,
'buttonPunch': 100,
'buttonIgnored': 184,
'buttonIgnored2': 86
}.get(name, -1)
elif platform == 'mac':
if devicename == 'PLAYSTATION(R)3 Controller':
return {
'buttonLeft': 8,
'buttonUp': 5,
'buttonRight': 6,
'buttonDown': 7,
'buttonJump': 15,
'buttonPunch': 16,
'buttonBomb': 14,
'buttonPickUp': 13,
'buttonStart': 4,
'buttonIgnored': 17
}.get(name, -1)
if devicename in ['Wireless 360 Controller', 'Controller']:
# Xbox360 gamepads
return {
'analogStickDeadZone': 1.2,
'buttonBomb': 13,
'buttonDown': 2,
'buttonJump': 12,
'buttonLeft': 3,
'buttonPickUp': 15,
'buttonPunch': 14,
'buttonRight': 4,
'buttonStart': 5,
'buttonUp': 1,
'triggerRun1': 5,
'triggerRun2': 6,
'buttonIgnored': 11
}.get(name, -1)
if (devicename
in ['Logitech Dual Action', 'Logitech Cordless RumblePad 2']):
return {
'buttonJump': 2,
'buttonPunch': 1,
'buttonBomb': 3,
'buttonPickUp': 4,
'buttonStart': 10
}.get(name, -1)
# Old gravis gamepad.
if devicename == 'GamePad Pro USB ':
return {
'buttonJump': 2,
'buttonPunch': 1,
'buttonBomb': 3,
'buttonPickUp': 4,
'buttonStart': 10
}.get(name, -1)
if devicename == 'Microsoft SideWinder Plug & Play Game Pad':
return {
'buttonJump': 1,
'buttonPunch': 3,
'buttonBomb': 2,
'buttonPickUp': 4,
'buttonStart': 6
}.get(name, -1)
# Saitek P2500 Rumble Force Pad.. (hopefully works for others too?..)
if devicename == 'Saitek P2500 Rumble Force Pad':
return {
'buttonJump': 3,
'buttonPunch': 1,
'buttonBomb': 4,
'buttonPickUp': 2,
'buttonStart': 11
}.get(name, -1)
# Some crazy 'Senze' dual gamepad.
if devicename == 'Twin USB Joystick':
return {
'analogStickLR': 3,
'analogStickLR_B': 7,
'analogStickUD': 4,
'analogStickUD_B': 8,
'buttonBomb': 2,
'buttonBomb_B': 14,
'buttonJump': 3,
'buttonJump_B': 15,
'buttonPickUp': 1,
'buttonPickUp_B': 13,
'buttonPunch': 4,
'buttonPunch_B': 16,
'buttonRun1': 7,
'buttonRun1_B': 19,
'buttonRun2': 8,
'buttonRun2_B': 20,
'buttonStart': 10,
'buttonStart_B': 22,
'enableSecondary': 1,
'unassignedButtonsRun': False
}.get(name, -1)
if devicename == 'USB Gamepad ': # some weird 'JITE' gamepad
return {
'analogStickLR': 4,
'analogStickUD': 5,
'buttonJump': 3,
'buttonPunch': 4,
'buttonBomb': 2,
'buttonPickUp': 1,
'buttonStart': 10
}.get(name, -1)
default_android_mapping = {
'triggerRun2': 19,
'unassignedButtonsRun': False,
'buttonPickUp': 101,
'buttonBomb': 98,
'buttonJump': 97,
'buttonStart': 83,
'buttonStart2': 109,
'buttonPunch': 100,
'buttonRun2': 104,
'buttonRun1': 103,
'triggerRun1': 18,
'buttonLeft': 22,
'buttonRight': 23,
'buttonUp': 20,
'buttonDown': 21,
'buttonVRReorient': 110
}
# Generic android...
if platform == 'android':
# Steelseries stratus xl.
if devicename == 'SteelSeries Stratus XL':
return {
'triggerRun2': 23,
'unassignedButtonsRun': False,
'buttonPickUp': 101,
'buttonBomb': 98,
'buttonJump': 97,
'buttonStart': 83,
'buttonStart2': 109,
'buttonPunch': 100,
'buttonRun2': 104,
'buttonRun1': 103,
'triggerRun1': 24,
'buttonLeft': 22,
'buttonRight': 23,
'buttonUp': 20,
'buttonDown': 21,
'buttonVRReorient': 108
}.get(name, -1)
# Adt-1 gamepad (use funky 'mode' button for start).
if devicename == 'Gamepad':
return {
'triggerRun2': 19,
'unassignedButtonsRun': False,
'buttonPickUp': 101,
'buttonBomb': 98,
'buttonJump': 97,
'buttonStart': 111,
'buttonPunch': 100,
'startButtonActivatesDefaultWidget': False,
'buttonRun2': 104,
'buttonRun1': 103,
'triggerRun1': 18
}.get(name, -1)
# Nexus player remote.
if devicename == 'Nexus Remote':
return {
'triggerRun2': 19,
'unassignedButtonsRun': False,
'buttonPickUp': 101,
'buttonBomb': 98,
'buttonJump': 97,
'buttonUp': 20,
'buttonLeft': 22,
'buttonDown': 21,
'buttonRight': 23,
'buttonStart': 83,
'buttonStart2': 109,
'buttonPunch': 24,
'buttonRun2': 104,
'buttonRun1': 103,
'triggerRun1': 18
}.get(name, -1)
if devicename == 'virtual-remote':
return {
'triggerRun2': 19,
'unassignedButtonsRun': False,
'buttonPickUp': 101,
'buttonBomb': 98,
'buttonStart': 83,
'buttonJump': 24,
'buttonUp': 20,
'buttonLeft': 22,
'buttonRight': 23,
'triggerRun1': 18,
'buttonStart2': 109,
'buttonPunch': 100,
'buttonRun2': 104,
'buttonRun1': 103,
'buttonDown': 21,
'startButtonActivatesDefaultWidget': False,
'uiOnly': True
}.get(name, -1)
# flag particular gamepads to use exact android defaults..
# (so they don't even ask to configure themselves)
if devicename in ['Samsung Game Pad EI-GP20', 'ASUS Gamepad'
] or devicename.startswith('Freefly VR Glide'):
return default_android_mapping.get(name, -1)
# Nvidia controller is default, but gets some strange
# keypresses we want to ignore.. touching the touchpad,
# so lets ignore those.
if 'NVIDIA Controller' in devicename:
return {
'triggerRun2': 19,
'unassignedButtonsRun': False,
'buttonPickUp': 101,
'buttonIgnored': 126,
'buttonIgnored2': 1,
'buttonBomb': 98,
'buttonJump': 97,
'buttonStart': 83,
'buttonStart2': 109,
'buttonPunch': 100,
'buttonRun2': 104,
'buttonRun1': 103,
'triggerRun1': 18
}.get(name, -1)
# Default keyboard vals across platforms..
if devicename == 'Keyboard' and unique_id == '#2':
if platform == 'mac' and subplatform == 'appstore':
return {
'buttonJump': 258,
'buttonPunch': 257,
'buttonBomb': 262,
'buttonPickUp': 261,
'buttonUp': 273,
'buttonDown': 274,
'buttonLeft': 276,
'buttonRight': 275,
'buttonStart': 263
}.get(name, -1)
return {
'buttonPickUp': 1073741917,
'buttonBomb': 1073741918,
'buttonJump': 1073741914,
'buttonUp': 1073741906,
'buttonLeft': 1073741904,
'buttonRight': 1073741903,
'buttonStart': 1073741919,
'buttonPunch': 1073741913,
'buttonDown': 1073741905
}.get(name, -1)
if devicename == 'Keyboard' and unique_id == '#1':
return {
'buttonJump': 107,
'buttonPunch': 106,
'buttonBomb': 111,
'buttonPickUp': 105,
'buttonUp': 119,
'buttonDown': 115,
'buttonLeft': 97,
'buttonRight': 100
}.get(name, -1)
# Ok, this gamepad's not in our specific preset list;
# fall back to some (hopefully) reasonable defaults.
# Leaving these in here for now but not gonna add any more now that we have
# fancy-pants config sharing across the internet.
if platform == 'mac':
if 'PLAYSTATION' in devicename: # ps3 gamepad?..
return {
'buttonLeft': 8,
'buttonUp': 5,
'buttonRight': 6,
'buttonDown': 7,
'buttonJump': 15,
'buttonPunch': 16,
'buttonBomb': 14,
'buttonPickUp': 13,
'buttonStart': 4
}.get(name, -1)
# Dual Action Config - hopefully applies to more...
if 'Logitech' in devicename:
return {
'buttonJump': 2,
'buttonPunch': 1,
'buttonBomb': 3,
'buttonPickUp': 4,
'buttonStart': 10
}.get(name, -1)
# Saitek P2500 Rumble Force Pad.. (hopefully works for others too?..)
if 'Saitek' in devicename:
return {
'buttonJump': 3,
'buttonPunch': 1,
'buttonBomb': 4,
'buttonPickUp': 2,
'buttonStart': 11
}.get(name, -1)
# Gravis stuff?...
if 'GamePad' in devicename:
return {
'buttonJump': 2,
'buttonPunch': 1,
'buttonBomb': 3,
'buttonPickUp': 4,
'buttonStart': 10
}.get(name, -1)
# Reasonable defaults.
if platform == 'android':
if _ba.is_running_on_fire_tv():
# Mostly same as default firetv controller.
return {
'triggerRun2': 23,
'triggerRun1': 24,
'buttonPickUp': 101,
'buttonBomb': 98,
'buttonJump': 97,
'buttonStart': 83,
'buttonPunch': 100,
'buttonDown': 21,
'buttonUp': 20,
'buttonLeft': 22,
'buttonRight': 23,
'startButtonActivatesDefaultWidget': False,
}.get(name, -1)
# Mostly same as 'Gamepad' except with 'menu' for default start
# button instead of 'mode'.
return default_android_mapping.get(name, -1)
# Is there a point to any sort of fallbacks here?.. should check.
return {
'buttonJump': 1,
'buttonPunch': 2,
'buttonBomb': 3,
'buttonPickUp': 4,
'buttonStart': 5
}.get(name, -1)
def _gen_android_input_hash() -> str:
import os
import hashlib
md5 = hashlib.md5()
# Currently we just do a single hash of *all* inputs on android
# and that's it.. good enough.
# (grabbing mappings for a specific device looks to be non-trivial)
for dirname in [
'/system/usr/keylayout', '/data/usr/keylayout',
'/data/system/devices/keylayout'
]:
try:
if os.path.isdir(dirname):
for f_name in os.listdir(dirname):
# This is usually volume keys and stuff;
# assume we can skip it?..
# (since it'll vary a lot across devices)
if f_name == 'gpio-keys.kl':
continue
try:
with open(f'{dirname}/{f_name}', 'rb') as infile:
md5.update(infile.read())
except PermissionError:
pass
except Exception:
from ba import _error
_error.print_exception(
'error in _gen_android_input_hash inner loop')
return md5.hexdigest()
def get_input_map_hash(inputdevice: ba.InputDevice) -> str:
"""Given an input device, return a hash based on its raw input values.
This lets us avoid sharing mappings across devices that may
have the same name but actually produce different input values.
(Different Android versions, for example, may return different
key codes for button presses on a given type of controller)
"""
del inputdevice # Currently unused.
app = _ba.app
try:
if app.input_map_hash is None:
if app.platform == 'android':
app.input_map_hash = _gen_android_input_hash()
else:
app.input_map_hash = ''
return app.input_map_hash
except Exception:
from ba import _error
_error.print_exception('Exception in get_input_map_hash')
return ''
def get_input_device_config(device: ba.InputDevice,
default: bool) -> Tuple[Dict, str]:
"""Given an input device, return its config dict in the app config.
The dict will be created if it does not exist.
"""
cfg = _ba.app.config
name = device.name
ccfgs: Dict[str, Any] = cfg.setdefault('Controllers', {})
ccfgs.setdefault(name, {})
unique_id = device.unique_identifier
if default:
if unique_id in ccfgs[name]:
del ccfgs[name][unique_id]
if 'default' not in ccfgs[name]:
ccfgs[name]['default'] = {}
return ccfgs[name], 'default'
if unique_id not in ccfgs[name]:
ccfgs[name][unique_id] = {}
return ccfgs[name], unique_id
def get_last_player_name_from_input_device(device: ba.InputDevice) -> str:
"""Return a reasonable player name associated with a device.
(generally the last one used there)
"""
appconfig = _ba.app.config
# Look for a default player profile name for them;
# otherwise default to their current random name.
profilename = '_random'
key_name = device.name + ' ' + device.unique_identifier
if ('Default Player Profiles' in appconfig
and key_name in appconfig['Default Player Profiles']):
profilename = appconfig['Default Player Profiles'][key_name]
if profilename == '_random':
profilename = device.get_default_player_name()
if profilename == '__account__':
profilename = _ba.get_account_display_string()
return profilename

35
dist/ba_data/python/ba/_keyboard.py vendored Normal file
View file

@ -0,0 +1,35 @@
# Released under the MIT License. See LICENSE for details.
#
"""On-screen Keyboard related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import List, Tuple, Dict
class Keyboard:
"""Chars definitions for on-screen keyboard.
Category: App Classes
Keyboards are discoverable by the meta-tag system
and the user can select which one they want to use.
On-screen keyboard uses chars from active ba.Keyboard.
Attributes:
name
Displays when user selecting this keyboard.
chars
Used for row/column lengths.
pages
Extra chars like emojis.
nums
The 'num' page.
"""
name: str
chars: List[Tuple[str, ...]]
pages: Dict[str, Tuple[str, ...]]
nums: Tuple[str, ...]

562
dist/ba_data/python/ba/_language.py vendored Normal file
View file

@ -0,0 +1,562 @@
# Released under the MIT License. See LICENSE for details.
#
"""Language related functionality."""
from __future__ import annotations
import json
import os
from typing import TYPE_CHECKING, overload
import _ba
if TYPE_CHECKING:
import ba
from typing import Any, Dict, List, Optional, Tuple, Union, Sequence
class LanguageSubsystem:
"""Wraps up language related app functionality.
Category: App Classes
To use this class, access the single instance of it at 'ba.app.lang'.
"""
def __init__(self) -> None:
self.language_target: Optional[AttrDict] = None
self.language_merged: Optional[AttrDict] = None
self.default_language = self._get_default_language()
def _can_display_language(self, language: str) -> bool:
"""Tell whether we can display a particular language.
On some platforms we don't have unicode rendering yet
which limits the languages we can draw.
"""
# We don't yet support full unicode display on windows or linux :-(.
if (language in {
'Chinese', 'ChineseTraditional', 'Persian', 'Korean', 'Arabic',
'Hindi', 'Vietnamese'
} and not _ba.can_display_full_unicode()):
return False
return True
@property
def locale(self) -> str:
"""Raw country/language code detected by the game (such as 'en_US').
Generally for language-specific code you should look at
ba.App.language, which is the language the game is using
(which may differ from locale if the user sets a language, etc.)
"""
env = _ba.env()
assert isinstance(env['locale'], str)
return env['locale']
def _get_default_language(self) -> str:
languages = {
'de': 'German',
'es': 'Spanish',
'sk': 'Slovak',
'it': 'Italian',
'nl': 'Dutch',
'da': 'Danish',
'pt': 'Portuguese',
'fr': 'French',
'el': 'Greek',
'ru': 'Russian',
'pl': 'Polish',
'sv': 'Swedish',
'eo': 'Esperanto',
'cs': 'Czech',
'hr': 'Croatian',
'hu': 'Hungarian',
'be': 'Belarussian',
'ro': 'Romanian',
'ko': 'Korean',
'fa': 'Persian',
'ar': 'Arabic',
'zh': 'Chinese',
'tr': 'Turkish',
'id': 'Indonesian',
'sr': 'Serbian',
'uk': 'Ukrainian',
'vi': 'Vietnamese',
'vec': 'Venetian',
'hi': 'Hindi'
}
# Special case for Chinese: map specific variations to traditional.
# (otherwise will map to 'Chinese' which is simplified)
if self.locale in ('zh_HANT', 'zh_TW'):
language = 'ChineseTraditional'
else:
language = languages.get(self.locale[:2], 'English')
if not self._can_display_language(language):
language = 'English'
return language
@property
def language(self) -> str:
"""The name of the language the game is running in.
This can be selected explicitly by the user or may be set
automatically based on ba.App.locale or other factors.
"""
assert isinstance(_ba.app.config, dict)
return _ba.app.config.get('Lang', self.default_language)
@property
def available_languages(self) -> List[str]:
"""A list of all available languages.
Note that languages that may be present in game assets but which
are not displayable on the running version of the game are not
included here.
"""
langs = set()
try:
names = os.listdir('ba_data/data/languages')
names = [n.replace('.json', '').capitalize() for n in names]
# FIXME: our simple capitalization fails on multi-word names;
# should handle this in a better way...
for i, name in enumerate(names):
if name == 'Chinesetraditional':
names[i] = 'ChineseTraditional'
except Exception:
from ba import _error
_error.print_exception()
names = []
for name in names:
if self._can_display_language(name):
langs.add(name)
return sorted(name for name in names
if self._can_display_language(name))
def setlanguage(self,
language: Optional[str],
print_change: bool = True,
store_to_config: bool = True) -> None:
"""Set the active language used for the game.
Pass None to use OS default language.
"""
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
# pylint: disable=too-many-branches
cfg = _ba.app.config
cur_language = cfg.get('Lang', None)
# Store this in the config if its changing.
if language != cur_language and store_to_config:
if language is None:
if 'Lang' in cfg:
del cfg['Lang'] # Clear it out for default.
else:
cfg['Lang'] = language
cfg.commit()
switched = True
else:
switched = False
with open('ba_data/data/languages/english.json') as infile:
lenglishvalues = json.loads(infile.read())
# None implies default.
if language is None:
language = self.default_language
try:
if language == 'English':
lmodvalues = None
else:
lmodfile = 'ba_data/data/languages/' + language.lower(
) + '.json'
with open(lmodfile) as infile:
lmodvalues = json.loads(infile.read())
except Exception:
from ba import _error
_error.print_exception('Exception importing language:', language)
_ba.screenmessage("Error setting language to '" + language +
"'; see log for details",
color=(1, 0, 0))
switched = False
lmodvalues = None
# Create an attrdict of *just* our target language.
self.language_target = AttrDict()
langtarget = self.language_target
assert langtarget is not None
_add_to_attr_dict(
langtarget,
lmodvalues if lmodvalues is not None else lenglishvalues)
# Create an attrdict of our target language overlaid
# on our base (english).
languages = [lenglishvalues]
if lmodvalues is not None:
languages.append(lmodvalues)
lfull = AttrDict()
for lmod in languages:
_add_to_attr_dict(lfull, lmod)
self.language_merged = lfull
# Pass some keys/values in for low level code to use;
# start with everything in their 'internal' section.
internal_vals = [
v for v in list(lfull['internal'].items())
if isinstance(v[1], str)
]
# Cherry-pick various other values to include.
# (should probably get rid of the 'internal' section
# and do everything this way)
for value in [
'replayNameDefaultText', 'replayWriteErrorText',
'replayVersionErrorText', 'replayReadErrorText'
]:
internal_vals.append((value, lfull[value]))
internal_vals.append(
('axisText', lfull['configGamepadWindow']['axisText']))
internal_vals.append(('buttonText', lfull['buttonText']))
lmerged = self.language_merged
assert lmerged is not None
random_names = [
n.strip() for n in lmerged['randomPlayerNamesText'].split(',')
]
random_names = [n for n in random_names if n != '']
_ba.set_internal_language_keys(internal_vals, random_names)
if switched and print_change:
_ba.screenmessage(Lstr(resource='languageSetText',
subs=[('${LANGUAGE}',
Lstr(translate=('languages',
language)))]),
color=(0, 1, 0))
def get_resource(self,
resource: str,
fallback_resource: str = None,
fallback_value: Any = None) -> Any:
"""Return a translation resource by name.
DEPRECATED; use ba.Lstr functionality for these purposes.
"""
try:
# If we have no language set, go ahead and set it.
if self.language_merged is None:
language = self.language
try:
self.setlanguage(language,
print_change=False,
store_to_config=False)
except Exception:
from ba import _error
_error.print_exception('exception setting language to',
language)
# Try english as a fallback.
if language != 'English':
print('Resorting to fallback language (English)')
try:
self.setlanguage('English',
print_change=False,
store_to_config=False)
except Exception:
_error.print_exception(
'error setting language to english fallback')
# If they provided a fallback_resource value, try the
# target-language-only dict first and then fall back to trying the
# fallback_resource value in the merged dict.
if fallback_resource is not None:
try:
values = self.language_target
splits = resource.split('.')
dicts = splits[:-1]
key = splits[-1]
for dct in dicts:
assert values is not None
values = values[dct]
assert values is not None
val = values[key]
return val
except Exception:
# FIXME: Shouldn't we try the fallback resource in the
# merged dict AFTER we try the main resource in the
# merged dict?
try:
values = self.language_merged
splits = fallback_resource.split('.')
dicts = splits[:-1]
key = splits[-1]
for dct in dicts:
assert values is not None
values = values[dct]
assert values is not None
val = values[key]
return val
except Exception:
# If we got nothing for fallback_resource, default
# to the normal code which checks or primary
# value in the merge dict; there's a chance we can
# get an english value for it (which we weren't
# looking for the first time through).
pass
values = self.language_merged
splits = resource.split('.')
dicts = splits[:-1]
key = splits[-1]
for dct in dicts:
assert values is not None
values = values[dct]
assert values is not None
val = values[key]
return val
except Exception:
# Ok, looks like we couldn't find our main or fallback resource
# anywhere. Now if we've been given a fallback value, return it;
# otherwise fail.
from ba import _error
if fallback_value is not None:
return fallback_value
raise _error.NotFoundError(
f"Resource not found: '{resource}'") from None
def translate(self,
category: str,
strval: str,
raise_exceptions: bool = False,
print_errors: bool = False) -> str:
"""Translate a value (or return the value if no translation available)
DEPRECATED; use ba.Lstr functionality for these purposes.
"""
try:
translated = self.get_resource('translations')[category][strval]
except Exception as exc:
if raise_exceptions:
raise
if print_errors:
print(('Translate error: category=\'' + category +
'\' name=\'' + strval + '\' exc=' + str(exc) + ''))
translated = None
translated_out: str
if translated is None:
translated_out = strval
else:
translated_out = translated
assert isinstance(translated_out, str)
return translated_out
def is_custom_unicode_char(self, char: str) -> bool:
"""Return whether a char is in the custom unicode range we use."""
assert isinstance(char, str)
if len(char) != 1:
raise ValueError('Invalid Input; must be length 1')
return 0xE000 <= ord(char) <= 0xF8FF
class Lstr:
"""Used to define strings in a language-independent way.
category: General Utility Classes
These should be used whenever possible in place of hard-coded strings
so that in-game or UI elements show up correctly on all clients in their
currently-active language.
To see available resource keys, look at any of the bs_language_*.py files
in the game or the translations pages at bombsquadgame.com/translate.
# EXAMPLE 1: specify a string from a resource path
mynode.text = ba.Lstr(resource='audioSettingsWindow.titleText')
# EXAMPLE 2: specify a translated string via a category and english value;
# if a translated value is available, it will be used; otherwise the
# english value will be. To see available translation categories, look
# under the 'translations' resource section.
mynode.text = ba.Lstr(translate=('gameDescriptions', 'Defeat all enemies'))
# EXAMPLE 3: specify a raw value and some substitutions. Substitutions can
# be used with resource and translate modes as well.
mynode.text = ba.Lstr(value='${A} / ${B}',
subs=[('${A}', str(score)), ('${B}', str(total))])
# EXAMPLE 4: Lstrs can be nested. This example would display the resource
# at res_a but replace ${NAME} with the value of the resource at res_b
mytextnode.text = ba.Lstr(resource='res_a',
subs=[('${NAME}', ba.Lstr(resource='res_b'))])
"""
# pylint: disable=dangerous-default-value
# noinspection PyDefaultArgument
@overload
def __init__(self,
*,
resource: str,
fallback_resource: str = '',
fallback_value: str = '',
subs: Sequence[Tuple[str, Union[str, Lstr]]] = []) -> None:
"""Create an Lstr from a string resource."""
...
# noinspection PyShadowingNames,PyDefaultArgument
@overload
def __init__(self,
*,
translate: Tuple[str, str],
subs: Sequence[Tuple[str, Union[str, Lstr]]] = []) -> None:
"""Create an Lstr by translating a string in a category."""
...
# noinspection PyDefaultArgument
@overload
def __init__(self,
*,
value: str,
subs: Sequence[Tuple[str, Union[str, Lstr]]] = []) -> None:
"""Create an Lstr from a raw string value."""
...
# pylint: enable=redefined-outer-name, dangerous-default-value
def __init__(self, *args: Any, **keywds: Any) -> None:
"""Instantiate a Lstr.
Pass a value for either 'resource', 'translate',
or 'value'. (see Lstr help for examples).
'subs' can be a sequence of 2-member sequences consisting of values
and replacements.
'fallback_resource' can be a resource key that will be used if the
main one is not present for
the current language in place of falling back to the english value
('resource' mode only).
'fallback_value' can be a literal string that will be used if neither
the resource nor the fallback resource is found ('resource' mode only).
"""
# pylint: disable=too-many-branches
if args:
raise TypeError('Lstr accepts only keyword arguments')
# Basically just store the exact args they passed.
# However if they passed any Lstr values for subs,
# replace them with that Lstr's dict.
self.args = keywds
our_type = type(self)
if isinstance(self.args.get('value'), our_type):
raise TypeError("'value' must be a regular string; not an Lstr")
if 'subs' in self.args:
subs_new = []
for key, value in keywds['subs']:
if isinstance(value, our_type):
subs_new.append((key, value.args))
else:
subs_new.append((key, value))
self.args['subs'] = subs_new
# As of protocol 31 we support compact key names
# ('t' instead of 'translate', etc). Convert as needed.
if 'translate' in keywds:
keywds['t'] = keywds['translate']
del keywds['translate']
if 'resource' in keywds:
keywds['r'] = keywds['resource']
del keywds['resource']
if 'value' in keywds:
keywds['v'] = keywds['value']
del keywds['value']
if 'fallback' in keywds:
from ba import _error
_error.print_error(
'deprecated "fallback" arg passed to Lstr(); use '
'either "fallback_resource" or "fallback_value"',
once=True)
keywds['f'] = keywds['fallback']
del keywds['fallback']
if 'fallback_resource' in keywds:
keywds['f'] = keywds['fallback_resource']
del keywds['fallback_resource']
if 'subs' in keywds:
keywds['s'] = keywds['subs']
del keywds['subs']
if 'fallback_value' in keywds:
keywds['fv'] = keywds['fallback_value']
del keywds['fallback_value']
def evaluate(self) -> str:
"""Evaluate the Lstr and returns a flat string in the current language.
You should avoid doing this as much as possible and instead pass
and store Lstr values.
"""
return _ba.evaluate_lstr(self._get_json())
def is_flat_value(self) -> bool:
"""Return whether the Lstr is a 'flat' value.
This is defined as a simple string value incorporating no translations,
resources, or substitutions. In this case it may be reasonable to
replace it with a raw string value, perform string manipulation on it,
etc.
"""
return bool('v' in self.args and not self.args.get('s', []))
def _get_json(self) -> str:
try:
return json.dumps(self.args, separators=(',', ':'))
except Exception:
from ba import _error
_error.print_exception('_get_json failed for', self.args)
return 'JSON_ERR'
def __str__(self) -> str:
return '<ba.Lstr: ' + self._get_json() + '>'
def __repr__(self) -> str:
return '<ba.Lstr: ' + self._get_json() + '>'
@staticmethod
def from_json(json_string: str) -> ba.Lstr:
"""Given a json string, returns a ba.Lstr. Does no data validation."""
lstr = Lstr(value='')
lstr.args = json.loads(json_string)
return lstr
def _add_to_attr_dict(dst: AttrDict, src: Dict) -> None:
for key, value in list(src.items()):
if isinstance(value, dict):
try:
dst_dict = dst[key]
except Exception:
dst_dict = dst[key] = AttrDict()
if not isinstance(dst_dict, AttrDict):
raise RuntimeError("language key '" + key +
"' is defined both as a dict and value")
_add_to_attr_dict(dst_dict, value)
else:
if not isinstance(value, (float, int, bool, str, str, type(None))):
raise TypeError("invalid value type for res '" + key + "': " +
str(type(value)))
dst[key] = value
class AttrDict(dict):
"""A dict that can be accessed with dot notation.
(so foo.bar is equivalent to foo['bar'])
"""
def __getattr__(self, attr: str) -> Any:
val = self[attr]
assert not isinstance(val, bytes)
return val
def __setattr__(self, attr: str, value: Any) -> None:
raise Exception()

168
dist/ba_data/python/ba/_level.py vendored Normal file
View file

@ -0,0 +1,168 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to individual levels in a campaign."""
from __future__ import annotations
import copy
import weakref
from typing import TYPE_CHECKING
import _ba
if TYPE_CHECKING:
from weakref import ReferenceType
from typing import Type, Any, Dict, Optional
import ba
class Level:
"""An entry in a ba.Campaign consisting of a name, game type, and settings.
category: Gameplay Classes
"""
def __init__(self,
name: str,
gametype: Type[ba.GameActivity],
settings: dict,
preview_texture_name: str,
displayname: str = None):
self._name = name
self._gametype = gametype
self._settings = settings
self._preview_texture_name = preview_texture_name
self._displayname = displayname
self._campaign: Optional[ReferenceType[ba.Campaign]] = None
self._index: Optional[int] = None
self._score_version_string: Optional[str] = None
@property
def name(self) -> str:
"""The unique name for this Level."""
return self._name
def get_settings(self) -> Dict[str, Any]:
"""Returns the settings for this Level."""
settings = copy.deepcopy(self._settings)
# So the game knows what the level is called.
# Hmm; seems hacky; I think we should take this out.
settings['name'] = self._name
return settings
@property
def preview_texture_name(self) -> str:
"""The preview texture name for this Level."""
return self._preview_texture_name
def get_preview_texture(self) -> ba.Texture:
"""Load/return the preview Texture for this Level."""
return _ba.gettexture(self._preview_texture_name)
@property
def displayname(self) -> ba.Lstr:
"""The localized name for this Level."""
from ba import _language
return _language.Lstr(
translate=('coopLevelNames', self._displayname
if self._displayname is not None else self._name),
subs=[('${GAME}',
self._gametype.get_display_string(self._settings))])
@property
def gametype(self) -> Type[ba.GameActivity]:
"""The type of game used for this Level."""
return self._gametype
@property
def campaign(self) -> Optional[ba.Campaign]:
"""The ba.Campaign this Level is associated with, or None."""
return None if self._campaign is None else self._campaign()
@property
def index(self) -> int:
"""The zero-based index of this Level in its ba.Campaign.
Access results in a RuntimeError if the Level is not assigned to a
Campaign.
"""
if self._index is None:
raise RuntimeError('Level is not part of a Campaign')
return self._index
@property
def complete(self) -> bool:
"""Whether this Level has been completed."""
config = self._get_config_dict()
return config.get('Complete', False)
def set_complete(self, val: bool) -> None:
"""Set whether or not this level is complete."""
old_val = self.complete
assert isinstance(old_val, bool)
assert isinstance(val, bool)
if val != old_val:
config = self._get_config_dict()
config['Complete'] = val
def get_high_scores(self) -> dict:
"""Return the current high scores for this Level."""
config = self._get_config_dict()
high_scores_key = 'High Scores' + self.get_score_version_string()
if high_scores_key not in config:
return {}
return copy.deepcopy(config[high_scores_key])
def set_high_scores(self, high_scores: Dict) -> None:
"""Set high scores for this level."""
config = self._get_config_dict()
high_scores_key = 'High Scores' + self.get_score_version_string()
config[high_scores_key] = high_scores
def get_score_version_string(self) -> str:
"""Return the score version string for this Level.
If a Level's gameplay changes significantly, its version string
can be changed to separate its new high score lists/etc. from the old.
"""
if self._score_version_string is None:
scorever = self._gametype.getscoreconfig().version
if scorever != '':
scorever = ' ' + scorever
self._score_version_string = scorever
assert self._score_version_string is not None
return self._score_version_string
@property
def rating(self) -> float:
"""The current rating for this Level."""
return self._get_config_dict().get('Rating', 0.0)
def set_rating(self, rating: float) -> None:
"""Set a rating for this Level, replacing the old ONLY IF higher."""
old_rating = self.rating
config = self._get_config_dict()
config['Rating'] = max(old_rating, rating)
def _get_config_dict(self) -> Dict[str, Any]:
"""Return/create the persistent state dict for this level.
The referenced dict exists under the game's config dict and
can be modified in place."""
campaign = self.campaign
if campaign is None:
raise RuntimeError('Level is not in a campaign.')
configdict = campaign.configdict
val: Dict[str, Any] = configdict.setdefault(self._name, {
'Rating': 0.0,
'Complete': False
})
assert isinstance(val, dict)
return val
def set_campaign(self, campaign: ba.Campaign, index: int) -> None:
"""For use by ba.Campaign when adding levels to itself.
(internal)"""
self._campaign = weakref.ref(campaign)
self._index = index

958
dist/ba_data/python/ba/_lobby.py vendored Normal file
View file

@ -0,0 +1,958 @@
# Released under the MIT License. See LICENSE for details.
#
"""Implements lobby system for gathering before games, char select, etc."""
from __future__ import annotations
import weakref
from dataclasses import dataclass
from typing import TYPE_CHECKING
import _ba
from ba._error import print_exception, print_error, NotFoundError
from ba._gameutils import animate, animate_array
from ba._language import Lstr
from ba._enums import SpecialChar, InputType
from ba._profile import get_player_profile_colors
if TYPE_CHECKING:
from typing import Optional, List, Dict, Any, Sequence, Union
import ba
MAX_QUICK_CHANGE_COUNT = 30
QUICK_CHANGE_INTERVAL = 0.05
QUICK_CHANGE_RESET_INTERVAL = 1.0
# Hmm should we move this to actors?..
class JoinInfo:
"""Display useful info for joiners."""
def __init__(self, lobby: ba.Lobby):
from ba._nodeactor import NodeActor
from ba._general import WeakCall
self._state = 0
self._press_to_punch: Union[str, ba.Lstr] = _ba.charstr(
SpecialChar.LEFT_BUTTON)
self._press_to_bomb: Union[str, ba.Lstr] = _ba.charstr(
SpecialChar.RIGHT_BUTTON)
self._joinmsg = Lstr(resource='pressAnyButtonToJoinText')
can_switch_teams = (len(lobby.sessionteams) > 1)
# If we have a keyboard, grab keys for punch and pickup.
# FIXME: This of course is only correct on the local device;
# Should change this for net games.
keyboard = _ba.getinputdevice('Keyboard', '#1', doraise=False)
if keyboard is not None:
self._update_for_keyboard(keyboard)
flatness = 1.0 if _ba.app.vr_mode else 0.0
self._text = NodeActor(
_ba.newnode('text',
attrs={
'position': (0, -40),
'h_attach': 'center',
'v_attach': 'top',
'h_align': 'center',
'color': (0.7, 0.7, 0.95, 1.0),
'flatness': flatness,
'text': self._joinmsg
}))
if _ba.app.demo_mode or _ba.app.arcade_mode:
self._messages = [self._joinmsg]
else:
msg1 = Lstr(resource='pressToSelectProfileText',
subs=[
('${BUTTONS}', _ba.charstr(SpecialChar.UP_ARROW) +
' ' + _ba.charstr(SpecialChar.DOWN_ARROW))
])
msg2 = Lstr(resource='pressToOverrideCharacterText',
subs=[('${BUTTONS}', Lstr(resource='bombBoldText'))])
msg3 = Lstr(value='${A} < ${B} >',
subs=[('${A}', msg2), ('${B}', self._press_to_bomb)])
self._messages = (([
Lstr(
resource='pressToSelectTeamText',
subs=[('${BUTTONS}', _ba.charstr(SpecialChar.LEFT_ARROW) +
' ' + _ba.charstr(SpecialChar.RIGHT_ARROW))],
)
] if can_switch_teams else []) + [msg1] + [msg3] + [self._joinmsg])
self._timer = _ba.Timer(4.0, WeakCall(self._update), repeat=True)
def _update_for_keyboard(self, keyboard: ba.InputDevice) -> None:
from ba import _input
punch_key = keyboard.get_button_name(
_input.get_device_value(keyboard, 'buttonPunch'))
self._press_to_punch = Lstr(resource='orText',
subs=[('${A}',
Lstr(value='\'${K}\'',
subs=[('${K}', punch_key)])),
('${B}', self._press_to_punch)])
bomb_key = keyboard.get_button_name(
_input.get_device_value(keyboard, 'buttonBomb'))
self._press_to_bomb = Lstr(resource='orText',
subs=[('${A}',
Lstr(value='\'${K}\'',
subs=[('${K}', bomb_key)])),
('${B}', self._press_to_bomb)])
self._joinmsg = Lstr(value='${A} < ${B} >',
subs=[('${A}',
Lstr(resource='pressPunchToJoinText')),
('${B}', self._press_to_punch)])
def _update(self) -> None:
assert self._text.node
self._text.node.text = self._messages[self._state]
self._state = (self._state + 1) % len(self._messages)
@dataclass
class PlayerReadyMessage:
"""Tells an object a player has been selected from the given chooser."""
chooser: ba.Chooser
@dataclass
class ChangeMessage:
"""Tells an object that a selection is being changed."""
what: str
value: int
class Chooser:
"""A character/team selector for a ba.Player.
Category: Gameplay Classes
"""
def __del__(self) -> None:
# Just kill off our base node; the rest should go down with it.
if self._text_node:
self._text_node.delete()
def __init__(self, vpos: float, sessionplayer: _ba.SessionPlayer,
lobby: 'Lobby') -> None:
self._deek_sound = _ba.getsound('deek')
self._click_sound = _ba.getsound('click01')
self._punchsound = _ba.getsound('punch01')
self._swish_sound = _ba.getsound('punchSwish')
self._errorsound = _ba.getsound('error')
self._mask_texture = _ba.gettexture('characterIconMask')
self._vpos = vpos
self._lobby = weakref.ref(lobby)
self._sessionplayer = sessionplayer
self._inited = False
self._dead = False
self._text_node: Optional[ba.Node] = None
self._profilename = ''
self._profilenames: List[str] = []
self._ready: bool = False
self._character_names: List[str] = []
self._last_change: Sequence[Union[float, int]] = (0, 0)
self._profiles: Dict[str, Dict[str, Any]] = {}
app = _ba.app
# Load available player profiles either from the local config or
# from the remote device.
self.reload_profiles()
# Note: this is just our local index out of available teams; *not*
# the team-id!
self._selected_team_index: int = self.lobby.next_add_team
# Store a persistent random character index and colors; we'll use this
# for the '_random' profile. Let's use their input_device id to seed
# it. This will give a persistent character for them between games
# and will distribute characters nicely if everyone is random.
self._random_color, self._random_highlight = (
get_player_profile_colors(None))
# To calc our random character we pick a random one out of our
# unlocked list and then locate that character's index in the full
# list.
char_index_offset = app.lobby_random_char_index_offset
self._random_character_index = (
(sessionplayer.inputdevice.id + char_index_offset) %
len(self._character_names))
# Attempt to set an initial profile based on what was used previously
# for this input-device, etc.
self._profileindex = self._select_initial_profile()
self._profilename = self._profilenames[self._profileindex]
self._text_node = _ba.newnode('text',
delegate=self,
attrs={
'position': (-100, self._vpos),
'maxwidth': 160,
'shadow': 0.5,
'vr_depth': -20,
'h_align': 'left',
'v_align': 'center',
'v_attach': 'top'
})
animate(self._text_node, 'scale', {0: 0, 0.1: 1.0})
self.icon = _ba.newnode('image',
owner=self._text_node,
attrs={
'position': (-130, self._vpos + 20),
'mask_texture': self._mask_texture,
'vr_depth': -10,
'attach': 'topCenter'
})
animate_array(self.icon, 'scale', 2, {0: (0, 0), 0.1: (45, 45)})
# Set our initial name to '<choosing player>' in case anyone asks.
self._sessionplayer.setname(
Lstr(resource='choosingPlayerText').evaluate(), real=False)
# Init these to our rando but they should get switched to the
# selected profile (if any) right after.
self._character_index = self._random_character_index
self._color = self._random_color
self._highlight = self._random_highlight
self.update_from_profile()
self.update_position()
self._inited = True
self._set_ready(False)
def _select_initial_profile(self) -> int:
app = _ba.app
profilenames = self._profilenames
inputdevice = self._sessionplayer.inputdevice
# If we've got a set profile name for this device, work backwards
# from that to get our index.
dprofilename = (app.config.get('Default Player Profiles',
{}).get(inputdevice.name + ' ' +
inputdevice.unique_identifier))
if dprofilename is not None and dprofilename in profilenames:
# If we got '__account__' and its local and we haven't marked
# anyone as the 'account profile' device yet, mark this guy as
# it. (prevents the next joiner from getting the account
# profile too).
if (dprofilename == '__account__'
and not inputdevice.is_remote_client
and app.lobby_account_profile_device_id is None):
app.lobby_account_profile_device_id = inputdevice.id
return profilenames.index(dprofilename)
# We want to mark the first local input-device in the game
# as the 'account profile' device.
if (not inputdevice.is_remote_client
and not inputdevice.is_controller_app):
if (app.lobby_account_profile_device_id is None
and '__account__' in profilenames):
app.lobby_account_profile_device_id = inputdevice.id
# If this is the designated account-profile-device, try to default
# to the account profile.
if (inputdevice.id == app.lobby_account_profile_device_id
and '__account__' in profilenames):
return profilenames.index('__account__')
# If this is the controller app, it defaults to using a random
# profile (since we can pull the random name from the app).
if inputdevice.is_controller_app and '_random' in profilenames:
return profilenames.index('_random')
# If its a client connection, for now just force
# the account profile if possible.. (need to provide a
# way for clients to specify/remember their default
# profile on remote servers that do not already know them).
if inputdevice.is_remote_client and '__account__' in profilenames:
return profilenames.index('__account__')
# Cycle through our non-random profiles once; after
# that, everyone gets random.
while (app.lobby_random_profile_index < len(profilenames)
and profilenames[app.lobby_random_profile_index]
in ('_random', '__account__', '_edit')):
app.lobby_random_profile_index += 1
if app.lobby_random_profile_index < len(profilenames):
profileindex = app.lobby_random_profile_index
app.lobby_random_profile_index += 1
return profileindex
assert '_random' in profilenames
return profilenames.index('_random')
@property
def sessionplayer(self) -> ba.SessionPlayer:
"""The ba.SessionPlayer associated with this chooser."""
return self._sessionplayer
@property
def ready(self) -> bool:
"""Whether this chooser is checked in as ready."""
return self._ready
def set_vpos(self, vpos: float) -> None:
"""(internal)"""
self._vpos = vpos
def set_dead(self, val: bool) -> None:
"""(internal)"""
self._dead = val
@property
def sessionteam(self) -> ba.SessionTeam:
"""Return this chooser's currently selected ba.SessionTeam."""
return self.lobby.sessionteams[self._selected_team_index]
@property
def lobby(self) -> ba.Lobby:
"""The chooser's ba.Lobby."""
lobby = self._lobby()
if lobby is None:
raise NotFoundError('Lobby does not exist.')
return lobby
def get_lobby(self) -> Optional[ba.Lobby]:
"""Return this chooser's lobby if it still exists; otherwise None."""
return self._lobby()
def update_from_profile(self) -> None:
"""Set character/colors based on the current profile."""
self._profilename = self._profilenames[self._profileindex]
if self._profilename == '_edit':
pass
elif self._profilename == '_random':
self._character_index = self._random_character_index
self._color = self._random_color
self._highlight = self._random_highlight
else:
character = self._profiles[self._profilename]['character']
# At the moment we're not properly pulling the list
# of available characters from clients, so profiles might use a
# character not in their list. For now, just go ahead and add
# a character name to their list as long as we're aware of it.
# This just means they won't always be able to override their
# character to others they own, but profile characters
# should work (and we validate profiles on the master server
# so no exploit opportunities)
if (character not in self._character_names
and character in _ba.app.spaz_appearances):
self._character_names.append(character)
self._character_index = self._character_names.index(character)
self._color, self._highlight = (get_player_profile_colors(
self._profilename, profiles=self._profiles))
self._update_icon()
self._update_text()
def reload_profiles(self) -> None:
"""Reload all player profiles."""
from ba._general import json_prep
app = _ba.app
# Re-construct our profile index and other stuff since the profile
# list might have changed.
input_device = self._sessionplayer.inputdevice
is_remote = input_device.is_remote_client
is_test_input = input_device.name.startswith('TestInput')
# Pull this player's list of unlocked characters.
if is_remote:
# TODO: Pull this from the remote player.
# (but make sure to filter it to the ones we've got).
self._character_names = ['Spaz']
else:
self._character_names = self.lobby.character_names_local_unlocked
# If we're a local player, pull our local profiles from the config.
# Otherwise ask the remote-input-device for its profile list.
if is_remote:
self._profiles = input_device.get_player_profiles()
else:
self._profiles = app.config.get('Player Profiles', {})
# These may have come over the wire from an older
# (non-unicode/non-json) version.
# Make sure they conform to our standards
# (unicode strings, no tuples, etc)
self._profiles = json_prep(self._profiles)
# Filter out any characters we're unaware of.
for profile in list(self._profiles.items()):
if profile[1].get('character', '') not in app.spaz_appearances:
profile[1]['character'] = 'Spaz'
# Add in a random one so we're ok even if there's no user profiles.
self._profiles['_random'] = {}
# In kiosk mode we disable account profiles to force random.
if app.demo_mode or app.arcade_mode:
if '__account__' in self._profiles:
del self._profiles['__account__']
# For local devices, add it an 'edit' option which will pop up
# the profile window.
if not is_remote and not is_test_input and not (app.demo_mode
or app.arcade_mode):
self._profiles['_edit'] = {}
# Build a sorted name list we can iterate through.
self._profilenames = list(self._profiles.keys())
self._profilenames.sort(key=lambda x: x.lower())
if self._profilename in self._profilenames:
self._profileindex = self._profilenames.index(self._profilename)
else:
self._profileindex = 0
self._profilename = self._profilenames[self._profileindex]
def update_position(self) -> None:
"""Update this chooser's position."""
assert self._text_node
spacing = 350
sessionteams = self.lobby.sessionteams
offs = (spacing * -0.5 * len(sessionteams) +
spacing * self._selected_team_index + 250)
if len(sessionteams) > 1:
offs -= 35
animate_array(self._text_node, 'position', 2, {
0: self._text_node.position,
0.1: (-100 + offs, self._vpos + 23)
})
animate_array(self.icon, 'position', 2, {
0: self.icon.position,
0.1: (-130 + offs, self._vpos + 22)
})
def get_character_name(self) -> str:
"""Return the selected character name."""
return self._character_names[self._character_index]
def _do_nothing(self) -> None:
"""Does nothing! (hacky way to disable callbacks)"""
def _getname(self, full: bool = False) -> str:
name_raw = name = self._profilenames[self._profileindex]
clamp = False
if name == '_random':
try:
name = (
self._sessionplayer.inputdevice.get_default_player_name())
except Exception:
print_exception('Error getting _random chooser name.')
name = 'Invalid'
clamp = not full
elif name == '__account__':
try:
name = self._sessionplayer.inputdevice.get_account_name(full)
except Exception:
print_exception('Error getting account name for chooser.')
name = 'Invalid'
clamp = not full
elif name == '_edit':
# Explicitly flattening this to a str; it's only relevant on
# the host so that's ok.
name = (Lstr(
resource='createEditPlayerText',
fallback_resource='editProfileWindow.titleNewText').evaluate())
else:
# If we have a regular profile marked as global with an icon,
# use it (for full only).
if full:
try:
if self._profiles[name_raw].get('global', False):
icon = (self._profiles[name_raw]['icon']
if 'icon' in self._profiles[name_raw] else
_ba.charstr(SpecialChar.LOGO))
name = icon + name
except Exception:
print_exception('Error applying global icon.')
else:
# We now clamp non-full versions of names so there's at
# least some hope of reading them in-game.
clamp = True
if clamp:
if len(name) > 10:
name = name[:10] + '...'
return name
def _set_ready(self, ready: bool) -> None:
# pylint: disable=cyclic-import
from bastd.ui.profile import browser as pbrowser
from ba._general import Call
profilename = self._profilenames[self._profileindex]
# Handle '_edit' as a special case.
if profilename == '_edit' and ready:
with _ba.Context('ui'):
pbrowser.ProfileBrowserWindow(in_main_menu=False)
# Give their input-device UI ownership too
# (prevent someone else from snatching it in crowded games)
_ba.set_ui_input_device(self._sessionplayer.inputdevice)
return
if not ready:
self._sessionplayer.assigninput(
InputType.LEFT_PRESS,
Call(self.handlemessage, ChangeMessage('team', -1)))
self._sessionplayer.assigninput(
InputType.RIGHT_PRESS,
Call(self.handlemessage, ChangeMessage('team', 1)))
self._sessionplayer.assigninput(
InputType.BOMB_PRESS,
Call(self.handlemessage, ChangeMessage('character', 1)))
self._sessionplayer.assigninput(
InputType.UP_PRESS,
Call(self.handlemessage, ChangeMessage('profileindex', -1)))
self._sessionplayer.assigninput(
InputType.DOWN_PRESS,
Call(self.handlemessage, ChangeMessage('profileindex', 1)))
self._sessionplayer.assigninput(
(InputType.JUMP_PRESS, InputType.PICK_UP_PRESS,
InputType.PUNCH_PRESS),
Call(self.handlemessage, ChangeMessage('ready', 1)))
self._ready = False
self._update_text()
self._sessionplayer.setname('untitled', real=False)
else:
self._sessionplayer.assigninput(
(InputType.LEFT_PRESS, InputType.RIGHT_PRESS,
InputType.UP_PRESS, InputType.DOWN_PRESS,
InputType.JUMP_PRESS, InputType.BOMB_PRESS,
InputType.PICK_UP_PRESS), self._do_nothing)
self._sessionplayer.assigninput(
(InputType.JUMP_PRESS, InputType.BOMB_PRESS,
InputType.PICK_UP_PRESS, InputType.PUNCH_PRESS),
Call(self.handlemessage, ChangeMessage('ready', 0)))
# Store the last profile picked by this input for reuse.
input_device = self._sessionplayer.inputdevice
name = input_device.name
unique_id = input_device.unique_identifier
device_profiles = _ba.app.config.setdefault(
'Default Player Profiles', {})
# Make an exception if we have no custom profiles and are set
# to random; in that case we'll want to start picking up custom
# profiles if/when one is made so keep our setting cleared.
special = ('_random', '_edit', '__account__')
have_custom_profiles = any(p not in special
for p in self._profiles)
profilekey = name + ' ' + unique_id
if profilename == '_random' and not have_custom_profiles:
if profilekey in device_profiles:
del device_profiles[profilekey]
else:
device_profiles[profilekey] = profilename
_ba.app.config.commit()
# Set this player's short and full name.
self._sessionplayer.setname(self._getname(),
self._getname(full=True),
real=True)
self._ready = True
self._update_text()
# Inform the session that this player is ready.
_ba.getsession().handlemessage(PlayerReadyMessage(self))
def _handle_ready_msg(self, ready: bool) -> None:
force_team_switch = False
# Team auto-balance kicks us to another team if we try to
# join the team with the most players.
if not self._ready:
if _ba.app.config.get('Auto Balance Teams', False):
lobby = self.lobby
sessionteams = lobby.sessionteams
if len(sessionteams) > 1:
# First, calc how many players are on each team
# ..we need to count both active players and
# choosers that have been marked as ready.
team_player_counts = {}
for sessionteam in sessionteams:
team_player_counts[sessionteam.id] = len(
sessionteam.players)
for chooser in lobby.choosers:
if chooser.ready:
team_player_counts[chooser.sessionteam.id] += 1
largest_team_size = max(team_player_counts.values())
smallest_team_size = (min(team_player_counts.values()))
# Force switch if we're on the biggest sessionteam
# and there's a smaller one available.
if (largest_team_size != smallest_team_size
and team_player_counts[self.sessionteam.id] >=
largest_team_size):
force_team_switch = True
# Either force switch teams, or actually for realsies do the set-ready.
if force_team_switch:
_ba.playsound(self._errorsound)
self.handlemessage(ChangeMessage('team', 1))
else:
_ba.playsound(self._punchsound)
self._set_ready(ready)
# TODO: should handle this at the engine layer so this is unnecessary.
def _handle_repeat_message_attack(self) -> None:
now = _ba.time()
count = self._last_change[1]
if now - self._last_change[0] < QUICK_CHANGE_INTERVAL:
count += 1
if count > MAX_QUICK_CHANGE_COUNT:
_ba.disconnect_client(
self._sessionplayer.inputdevice.client_id)
elif now - self._last_change[0] > QUICK_CHANGE_RESET_INTERVAL:
count = 0
self._last_change = (now, count)
def handlemessage(self, msg: Any) -> Any:
"""Standard generic message handler."""
if isinstance(msg, ChangeMessage):
self._handle_repeat_message_attack()
# If we've been removed from the lobby, ignore this stuff.
if self._dead:
print_error('chooser got ChangeMessage after dying')
return
if not self._text_node:
print_error('got ChangeMessage after nodes died')
return
if msg.what == 'team':
sessionteams = self.lobby.sessionteams
if len(sessionteams) > 1:
_ba.playsound(self._swish_sound)
self._selected_team_index = (
(self._selected_team_index + msg.value) %
len(sessionteams))
self._update_text()
self.update_position()
self._update_icon()
elif msg.what == 'profileindex':
if len(self._profilenames) == 1:
# This should be pretty hard to hit now with
# automatic local accounts.
_ba.playsound(_ba.getsound('error'))
else:
# Pick the next player profile and assign our name
# and character based on that.
_ba.playsound(self._deek_sound)
self._profileindex = ((self._profileindex + msg.value) %
len(self._profilenames))
self.update_from_profile()
elif msg.what == 'character':
_ba.playsound(self._click_sound)
# update our index in our local list of characters
self._character_index = ((self._character_index + msg.value) %
len(self._character_names))
self._update_text()
self._update_icon()
elif msg.what == 'ready':
self._handle_ready_msg(bool(msg.value))
def _update_text(self) -> None:
assert self._text_node is not None
if self._ready:
# Once we're ready, we've saved the name, so lets ask the system
# for it so we get appended numbers and stuff.
text = Lstr(value=self._sessionplayer.getname(full=True))
text = Lstr(value='${A} (${B})',
subs=[('${A}', text),
('${B}', Lstr(resource='readyText'))])
else:
text = Lstr(value=self._getname(full=True))
can_switch_teams = len(self.lobby.sessionteams) > 1
# Flash as we're coming in.
fin_color = _ba.safecolor(self.get_color()) + (1, )
if not self._inited:
animate_array(self._text_node, 'color', 4, {
0.15: fin_color,
0.25: (2, 2, 2, 1),
0.35: fin_color
})
else:
# Blend if we're in teams mode; switch instantly otherwise.
if can_switch_teams:
animate_array(self._text_node, 'color', 4, {
0: self._text_node.color,
0.1: fin_color
})
else:
self._text_node.color = fin_color
self._text_node.text = text
def get_color(self) -> Sequence[float]:
"""Return the currently selected color."""
val: Sequence[float]
if self.lobby.use_team_colors:
val = self.lobby.sessionteams[self._selected_team_index].color
else:
val = self._color
if len(val) != 3:
print('get_color: ignoring invalid color of len', len(val))
val = (0, 1, 0)
return val
def get_highlight(self) -> Sequence[float]:
"""Return the currently selected highlight."""
if self._profilenames[self._profileindex] == '_edit':
return 0, 1, 0
# If we're using team colors we wanna make sure our highlight color
# isn't too close to any other team's color.
highlight = list(self._highlight)
if self.lobby.use_team_colors:
for i, sessionteam in enumerate(self.lobby.sessionteams):
if i != self._selected_team_index:
# Find the dominant component of this sessionteam's color
# and adjust ours so that the component is
# not super-dominant.
max_val = 0.0
max_index = 0
for j in range(3):
if sessionteam.color[j] > max_val:
max_val = sessionteam.color[j]
max_index = j
that_color_for_us = highlight[max_index]
our_second_biggest = max(highlight[(max_index + 1) % 3],
highlight[(max_index + 2) % 3])
diff = (that_color_for_us - our_second_biggest)
if diff > 0:
highlight[max_index] -= diff * 0.6
highlight[(max_index + 1) % 3] += diff * 0.3
highlight[(max_index + 2) % 3] += diff * 0.2
return highlight
def getplayer(self) -> ba.SessionPlayer:
"""Return the player associated with this chooser."""
return self._sessionplayer
def _update_icon(self) -> None:
if self._profilenames[self._profileindex] == '_edit':
tex = _ba.gettexture('black')
tint_tex = _ba.gettexture('black')
self.icon.color = (1, 1, 1)
self.icon.texture = tex
self.icon.tint_texture = tint_tex
self.icon.tint_color = (0, 1, 0)
return
try:
tex_name = (_ba.app.spaz_appearances[self._character_names[
self._character_index]].icon_texture)
tint_tex_name = (_ba.app.spaz_appearances[self._character_names[
self._character_index]].icon_mask_texture)
except Exception:
print_exception('Error updating char icon list')
tex_name = 'neoSpazIcon'
tint_tex_name = 'neoSpazIconColorMask'
tex = _ba.gettexture(tex_name)
tint_tex = _ba.gettexture(tint_tex_name)
self.icon.color = (1, 1, 1)
self.icon.texture = tex
self.icon.tint_texture = tint_tex
clr = self.get_color()
clr2 = self.get_highlight()
can_switch_teams = len(self.lobby.sessionteams) > 1
# If we're initing, flash.
if not self._inited:
animate_array(self.icon, 'color', 3, {
0.15: (1, 1, 1),
0.25: (2, 2, 2),
0.35: (1, 1, 1)
})
# Blend in teams mode; switch instantly in ffa-mode.
if can_switch_teams:
animate_array(self.icon, 'tint_color', 3, {
0: self.icon.tint_color,
0.1: clr
})
else:
self.icon.tint_color = clr
self.icon.tint2_color = clr2
# Store the icon info the the player.
self._sessionplayer.set_icon_info(tex_name, tint_tex_name, clr, clr2)
class Lobby:
"""Container for ba.Choosers.
Category: Gameplay Classes
"""
def __del__(self) -> None:
# Reset any players that still have a chooser in us.
# (should allow the choosers to die).
sessionplayers = [
c.sessionplayer for c in self.choosers if c.sessionplayer
]
for sessionplayer in sessionplayers:
sessionplayer.resetinput()
def __init__(self) -> None:
from ba._team import SessionTeam
from ba._coopsession import CoopSession
session = _ba.getsession()
self._use_team_colors = session.use_team_colors
if session.use_teams:
self._sessionteams = [
weakref.ref(team) for team in session.sessionteams
]
else:
self._dummy_teams = SessionTeam()
self._sessionteams = [weakref.ref(self._dummy_teams)]
v_offset = (-150 if isinstance(session, CoopSession) else -50)
self.choosers: List[Chooser] = []
self.base_v_offset = v_offset
self.update_positions()
self._next_add_team = 0
self.character_names_local_unlocked: List[str] = []
self._vpos = 0
# Grab available profiles.
self.reload_profiles()
self._join_info_text = None
@property
def next_add_team(self) -> int:
"""(internal)"""
return self._next_add_team
@property
def use_team_colors(self) -> bool:
"""A bool for whether this lobby is using team colors.
If False, inidividual player colors are used instead.
"""
return self._use_team_colors
@property
def sessionteams(self) -> List[ba.SessionTeam]:
"""ba.SessionTeams available in this lobby."""
allteams = []
for tref in self._sessionteams:
team = tref()
assert team is not None
allteams.append(team)
return allteams
def get_choosers(self) -> List[Chooser]:
"""Return the lobby's current choosers."""
return self.choosers
def create_join_info(self) -> JoinInfo:
"""Create a display of on-screen information for joiners.
(how to switch teams, players, etc.)
Intended for use in initial joining-screens.
"""
return JoinInfo(self)
def reload_profiles(self) -> None:
"""Reload available player profiles."""
# pylint: disable=cyclic-import
from bastd.actor.spazappearance import get_appearances
# We may have gained or lost character names if the user
# bought something; reload these too.
self.character_names_local_unlocked = get_appearances()
self.character_names_local_unlocked.sort(key=lambda x: x.lower())
# Do any overall prep we need to such as creating account profile.
_ba.app.accounts.ensure_have_account_player_profile()
for chooser in self.choosers:
try:
chooser.reload_profiles()
chooser.update_from_profile()
except Exception:
print_exception('Error reloading profiles.')
def update_positions(self) -> None:
"""Update positions for all choosers."""
self._vpos = -100 + self.base_v_offset
for chooser in self.choosers:
chooser.set_vpos(self._vpos)
chooser.update_position()
self._vpos -= 48
def check_all_ready(self) -> bool:
"""Return whether all choosers are marked ready."""
return all(chooser.ready for chooser in self.choosers)
def add_chooser(self, sessionplayer: ba.SessionPlayer) -> None:
"""Add a chooser to the lobby for the provided player."""
self.choosers.append(
Chooser(vpos=self._vpos, sessionplayer=sessionplayer, lobby=self))
self._next_add_team = (self._next_add_team + 1) % len(
self._sessionteams)
self._vpos -= 48
def remove_chooser(self, player: ba.SessionPlayer) -> None:
"""Remove a single player's chooser; does not kick them.
This is used when a player enters the game and no longer
needs a chooser."""
found = False
chooser = None
for chooser in self.choosers:
if chooser.getplayer() is player:
found = True
# Mark it as dead since there could be more
# change-commands/etc coming in still for it;
# want to avoid duplicate player-adds/etc.
chooser.set_dead(True)
self.choosers.remove(chooser)
break
if not found:
print_error(f'remove_chooser did not find player {player}')
elif chooser in self.choosers:
print_error(f'chooser remains after removal for {player}')
self.update_positions()
def remove_all_choosers(self) -> None:
"""Remove all choosers without kicking players.
This is called after all players check in and enter a game.
"""
self.choosers = []
self.update_positions()
def remove_all_choosers_and_kick_players(self) -> None:
"""Remove all player choosers and kick attached players."""
# Copy the list; it can change under us otherwise.
for chooser in list(self.choosers):
if chooser.sessionplayer:
chooser.sessionplayer.remove_from_game()
self.remove_all_choosers()

431
dist/ba_data/python/ba/_map.py vendored Normal file
View file

@ -0,0 +1,431 @@
# Released under the MIT License. See LICENSE for details.
#
"""Map related functionality."""
from __future__ import annotations
import random
from typing import TYPE_CHECKING
import _ba
from ba import _math
from ba._actor import Actor
if TYPE_CHECKING:
from typing import Set, List, Type, Optional, Sequence, Any, Tuple
import ba
def preload_map_preview_media() -> None:
"""Preload media needed for map preview UIs.
Category: Asset Functions
"""
_ba.getmodel('level_select_button_opaque')
_ba.getmodel('level_select_button_transparent')
for maptype in list(_ba.app.maps.values()):
map_tex_name = maptype.get_preview_texture_name()
if map_tex_name is not None:
_ba.gettexture(map_tex_name)
def get_filtered_map_name(name: str) -> str:
"""Filter a map name to account for name changes, etc.
Category: Asset Functions
This can be used to support old playlists, etc.
"""
# Some legacy name fallbacks... can remove these eventually.
if name in ('AlwaysLand', 'Happy Land'):
name = 'Happy Thoughts'
if name == 'Hockey Arena':
name = 'Hockey Stadium'
return name
def get_map_display_string(name: str) -> ba.Lstr:
"""Return a ba.Lstr for displaying a given map\'s name.
Category: Asset Functions
"""
from ba import _language
return _language.Lstr(translate=('mapsNames', name))
def getmaps(playtype: str) -> List[str]:
"""Return a list of ba.Map types supporting a playtype str.
Category: Asset Functions
Maps supporting a given playtype must provide a particular set of
features and lend themselves to a certain style of play.
Play Types:
'melee'
General fighting map.
Has one or more 'spawn' locations.
'team_flag'
For games such as Capture The Flag where each team spawns by a flag.
Has two or more 'spawn' locations, each with a corresponding 'flag'
location (based on index).
'single_flag'
For games such as King of the Hill or Keep Away where multiple teams
are fighting over a single flag.
Has two or more 'spawn' locations and 1 'flag_default' location.
'conquest'
For games such as Conquest where flags are spread throughout the map
- has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
'king_of_the_hill' - has 2+ 'spawn' locations, 1+ 'flag_default' locations,
and 1+ 'powerup_spawn' locations
'hockey'
For hockey games.
Has two 'goal' locations, corresponding 'spawn' locations, and one
'flag_default' location (for where puck spawns)
'football'
For football games.
Has two 'goal' locations, corresponding 'spawn' locations, and one
'flag_default' location (for where flag/ball/etc. spawns)
'race'
For racing games where players much touch each region in order.
Has two or more 'race_point' locations.
"""
return sorted(key for key, val in _ba.app.maps.items()
if playtype in val.get_play_types())
def get_unowned_maps() -> List[str]:
"""Return the list of local maps not owned by the current account.
Category: Asset Functions
"""
from ba import _store
unowned_maps: Set[str] = set()
if not _ba.app.headless_mode:
for map_section in _store.get_store_layout()['maps']:
for mapitem in map_section['items']:
if not _ba.get_purchased(mapitem):
m_info = _store.get_store_item(mapitem)
unowned_maps.add(m_info['map_type'].name)
return sorted(unowned_maps)
def get_map_class(name: str) -> Type[ba.Map]:
"""Return a map type given a name.
Category: Asset Functions
"""
name = get_filtered_map_name(name)
try:
return _ba.app.maps[name]
except KeyError:
from ba import _error
raise _error.NotFoundError(f"Map not found: '{name}'") from None
class Map(Actor):
"""A game map.
Category: Gameplay Classes
Consists of a collection of terrain nodes, metadata, and other
functionality comprising a game map.
"""
defs: Any = None
name = 'Map'
_playtypes: List[str] = []
@classmethod
def preload(cls) -> None:
"""Preload map media.
This runs the class's on_preload() method as needed to prep it to run.
Preloading should generally be done in a ba.Activity's __init__ method.
Note that this is a classmethod since it is not operate on map
instances but rather on the class itself before instances are made
"""
activity = _ba.getactivity()
if cls not in activity.preloads:
activity.preloads[cls] = cls.on_preload()
@classmethod
def get_play_types(cls) -> List[str]:
"""Return valid play types for this map."""
return []
@classmethod
def get_preview_texture_name(cls) -> Optional[str]:
"""Return the name of the preview texture for this map."""
return None
@classmethod
def on_preload(cls) -> Any:
"""Called when the map is being preloaded.
It should return any media/data it requires to operate
"""
return None
@classmethod
def getname(cls) -> str:
"""Return the unique name of this map, in English."""
return cls.name
@classmethod
def get_music_type(cls) -> Optional[ba.MusicType]:
"""Return a music-type string that should be played on this map.
If None is returned, default music will be used.
"""
return None
def __init__(self,
vr_overlay_offset: Optional[Sequence[float]] = None) -> None:
"""Instantiate a map."""
from ba import _gameutils
super().__init__()
# This is expected to always be a ba.Node object (whether valid or not)
# should be set to something meaningful by child classes.
self.node: Optional[_ba.Node] = None
# Make our class' preload-data available to us
# (and instruct the user if we weren't preloaded properly).
try:
self.preloaddata = _ba.getactivity().preloads[type(self)]
except Exception as exc:
from ba import _error
raise _error.NotFoundError(
'Preload data not found for ' + str(type(self)) +
'; make sure to call the type\'s preload()'
' staticmethod in the activity constructor') from exc
# Set various globals.
gnode = _ba.getactivity().globalsnode
import ba
# I DONT THINK YOU REALLY WANT TO REMOVE MY NAME , DO YOU ?
self.hg=ba.NodeActor(
_ba.newnode('text',
attrs={
'text': "Smoothy Build\n v1.0",
'flatness': 1.0,
'h_align': 'center',
'v_attach':'bottom',
'h_attach':'right',
'scale':0.7,
'position':(-60,23),
'color':(0.3,0.3,0.3)
}))
# Set area-of-interest bounds.
aoi_bounds = self.get_def_bound_box('area_of_interest_bounds')
if aoi_bounds is None:
print('WARNING: no "aoi_bounds" found for map:', self.getname())
aoi_bounds = (-1, -1, -1, 1, 1, 1)
gnode.area_of_interest_bounds = aoi_bounds
# Set map bounds.
map_bounds = self.get_def_bound_box('map_bounds')
if map_bounds is None:
print('WARNING: no "map_bounds" found for map:', self.getname())
map_bounds = (-30, -10, -30, 30, 100, 30)
_ba.set_map_bounds(map_bounds)
# Set shadow ranges.
try:
gnode.shadow_range = [
self.defs.points[v][1] for v in [
'shadow_lower_bottom', 'shadow_lower_top',
'shadow_upper_bottom', 'shadow_upper_top'
]
]
except Exception:
pass
# In vr, set a fixed point in space for the overlay to show up at.
# By default we use the bounds center but allow the map to override it.
center = ((aoi_bounds[0] + aoi_bounds[3]) * 0.5,
(aoi_bounds[1] + aoi_bounds[4]) * 0.5,
(aoi_bounds[2] + aoi_bounds[5]) * 0.5)
if vr_overlay_offset is not None:
center = (center[0] + vr_overlay_offset[0],
center[1] + vr_overlay_offset[1],
center[2] + vr_overlay_offset[2])
gnode.vr_overlay_center = center
gnode.vr_overlay_center_enabled = True
self.spawn_points = (self.get_def_points('spawn')
or [(0, 0, 0, 0, 0, 0)])
self.ffa_spawn_points = (self.get_def_points('ffa_spawn')
or [(0, 0, 0, 0, 0, 0)])
self.spawn_by_flag_points = (self.get_def_points('spawn_by_flag')
or [(0, 0, 0, 0, 0, 0)])
self.flag_points = self.get_def_points('flag') or [(0, 0, 0)]
# We just want points.
self.flag_points = [p[:3] for p in self.flag_points]
self.flag_points_default = (self.get_def_point('flag_default')
or (0, 1, 0))
self.powerup_spawn_points = self.get_def_points('powerup_spawn') or [
(0, 0, 0)
]
# We just want points.
self.powerup_spawn_points = ([
p[:3] for p in self.powerup_spawn_points
])
self.tnt_points = self.get_def_points('tnt') or []
# We just want points.
self.tnt_points = [p[:3] for p in self.tnt_points]
self.is_hockey = False
self.is_flying = False
# FIXME: this should be part of game; not map.
self._next_ffa_start_index = 0
def is_point_near_edge(self,
point: ba.Vec3,
running: bool = False) -> bool:
"""Return whether the provided point is near an edge of the map.
Simple bot logic uses this call to determine if they
are approaching a cliff or wall. If this returns True they will
generally not walk/run any farther away from the origin.
If 'running' is True, the buffer should be a bit larger.
"""
del point, running # Unused.
return False
def get_def_bound_box(
self, name: str
) -> Optional[Tuple[float, float, float, float, float, float]]:
"""Return a 6 member bounds tuple or None if it is not defined."""
try:
box = self.defs.boxes[name]
return (box[0] - box[6] / 2.0, box[1] - box[7] / 2.0,
box[2] - box[8] / 2.0, box[0] + box[6] / 2.0,
box[1] + box[7] / 2.0, box[2] + box[8] / 2.0)
except Exception:
return None
def get_def_point(self, name: str) -> Optional[Sequence[float]]:
"""Return a single defined point or a default value in its absence."""
val = self.defs.points.get(name)
return (None if val is None else
_math.vec3validate(val) if __debug__ else val)
def get_def_points(self, name: str) -> List[Sequence[float]]:
"""Return a list of named points.
Return as many sequential ones are defined (flag1, flag2, flag3), etc.
If none are defined, returns an empty list.
"""
point_list = []
if self.defs and name + '1' in self.defs.points:
i = 1
while name + str(i) in self.defs.points:
pts = self.defs.points[name + str(i)]
if len(pts) == 6:
point_list.append(pts)
else:
if len(pts) != 3:
raise ValueError('invalid point')
point_list.append(pts + (0, 0, 0))
i += 1
return point_list
def get_start_position(self, team_index: int) -> Sequence[float]:
"""Return a random starting position for the given team index."""
pnt = self.spawn_points[team_index % len(self.spawn_points)]
x_range = (-0.5, 0.5) if pnt[3] == 0.0 else (-pnt[3], pnt[3])
z_range = (-0.5, 0.5) if pnt[5] == 0.0 else (-pnt[5], pnt[5])
pnt = (pnt[0] + random.uniform(*x_range), pnt[1],
pnt[2] + random.uniform(*z_range))
return pnt
def get_ffa_start_position(
self, players: Sequence[ba.Player]) -> Sequence[float]:
"""Return a random starting position in one of the FFA spawn areas.
If a list of ba.Players is provided; the returned points will be
as far from these players as possible.
"""
# Get positions for existing players.
player_pts = []
for player in players:
if player.is_alive():
player_pts.append(player.position)
def _getpt() -> Sequence[float]:
point = self.ffa_spawn_points[self._next_ffa_start_index]
self._next_ffa_start_index = ((self._next_ffa_start_index + 1) %
len(self.ffa_spawn_points))
x_range = (-0.5, 0.5) if point[3] == 0.0 else (-point[3], point[3])
z_range = (-0.5, 0.5) if point[5] == 0.0 else (-point[5], point[5])
point = (point[0] + random.uniform(*x_range), point[1],
point[2] + random.uniform(*z_range))
return point
if not player_pts:
return _getpt()
# Let's calc several start points and then pick whichever is
# farthest from all existing players.
farthestpt_dist = -1.0
farthestpt = None
for _i in range(10):
testpt = _ba.Vec3(_getpt())
closest_player_dist = 9999.0
for ppt in player_pts:
dist = (ppt - testpt).length()
if dist < closest_player_dist:
closest_player_dist = dist
if closest_player_dist > farthestpt_dist:
farthestpt_dist = closest_player_dist
farthestpt = testpt
assert farthestpt is not None
return tuple(farthestpt)
def get_flag_position(self, team_index: int = None) -> Sequence[float]:
"""Return a flag position on the map for the given team index.
Pass None to get the default flag point.
(used for things such as king-of-the-hill)
"""
if team_index is None:
return self.flag_points_default[:3]
return self.flag_points[team_index % len(self.flag_points)][:3]
def exists(self) -> bool:
return bool(self.node)
def handlemessage(self, msg: Any) -> Any:
from ba import _messages
if isinstance(msg, _messages.DieMessage):
if self.node:
self.node.delete()
else:
return super().handlemessage(msg)
return None
def register_map(maptype: Type[Map]) -> None:
"""Register a map class with the game."""
if maptype.name in _ba.app.maps:
raise RuntimeError('map "' + maptype.name + '" already registered')
_ba.app.maps[maptype.name] = maptype

55
dist/ba_data/python/ba/_math.py vendored Normal file
View file

@ -0,0 +1,55 @@
# Released under the MIT License. See LICENSE for details.
#
"""Math related functionality."""
from __future__ import annotations
from collections import abc
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Tuple, Sequence
def vec3validate(value: Sequence[float]) -> Sequence[float]:
"""Ensure a value is valid for use as a Vec3.
category: General Utility Functions
Raises a TypeError exception if not.
Valid values include any type of sequence consisting of 3 numeric values.
Returns the same value as passed in (but with a definite type
so this can be used to disambiguate 'Any' types).
Generally this should be used in 'if __debug__' or assert clauses
to keep runtime overhead minimal.
"""
from numbers import Number
if not isinstance(value, abc.Sequence):
raise TypeError(f"Expected a sequence; got {type(value)}")
if len(value) != 3:
raise TypeError(f"Expected a length-3 sequence (got {len(value)})")
if not all(isinstance(i, Number) for i in value):
raise TypeError(f"Non-numeric value passed for vec3: {value}")
return value
def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool:
"""Return whether a given point is within a given box.
category: General Utility Functions
For use with standard def boxes (position|rotate|scale).
"""
return ((abs(pnt[0] - box[0]) <= box[6] * 0.5)
and (abs(pnt[1] - box[1]) <= box[7] * 0.5)
and (abs(pnt[2] - box[2]) <= box[8] * 0.5))
def normalized_color(color: Sequence[float]) -> Tuple[float, ...]:
"""Scale a color so its largest value is 1; useful for coloring lights.
category: General Utility Functions
"""
color_biased = tuple(max(c, 0.01) for c in color) # account for black
mult = 1.0 / max(color_biased)
return tuple(c * mult for c in color_biased)

312
dist/ba_data/python/ba/_messages.py vendored Normal file
View file

@ -0,0 +1,312 @@
# Released under the MIT License. See LICENSE for details.
#
"""Defines some standard message objects for use with handlemessage() calls."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, TypeVar
from enum import Enum
import _ba
if TYPE_CHECKING:
from typing import Sequence, Optional, Type, Any
import ba
class _UnhandledType:
pass
# A special value that should be returned from handlemessage()
# functions for unhandled message types. This may result
# in fallback message types being attempted/etc.
UNHANDLED = _UnhandledType()
@dataclass
class OutOfBoundsMessage:
"""A message telling an object that it is out of bounds.
Category: Message Classes
"""
class DeathType(Enum):
"""A reason for a death.
Category: Enums
"""
GENERIC = 'generic'
OUT_OF_BOUNDS = 'out_of_bounds'
IMPACT = 'impact'
FALL = 'fall'
REACHED_GOAL = 'reached_goal'
LEFT_GAME = 'left_game'
@dataclass
class DieMessage:
"""A message telling an object to die.
Category: Message Classes
Most ba.Actors respond to this.
Attributes:
immediate
If this is set to True, the actor should disappear immediately.
This is for 'removing' stuff from the game more so than 'killing'
it. If False, the actor should die a 'normal' death and can take
its time with lingering corpses, sound effects, etc.
how
The particular reason for death.
"""
immediate: bool = False
how: DeathType = DeathType.GENERIC
PlayerType = TypeVar('PlayerType', bound='ba.Player')
class PlayerDiedMessage:
"""A message saying a ba.Player has died.
category: Message Classes
Attributes:
killed
If True, the player was killed;
If False, they left the game or the round ended.
how
The particular type of death.
"""
killed: bool
how: ba.DeathType
def __init__(self, player: ba.Player, was_killed: bool,
killerplayer: Optional[ba.Player], how: ba.DeathType):
"""Instantiate a message with the given values."""
# Invalid refs should never be passed as args.
assert player.exists()
self._player = player
# Invalid refs should never be passed as args.
assert killerplayer is None or killerplayer.exists()
self._killerplayer = killerplayer
self.killed = was_killed
self.how = how
def getkillerplayer(self,
playertype: Type[PlayerType]) -> Optional[PlayerType]:
"""Return the ba.Player responsible for the killing, if any.
Pass the Player type being used by the current game.
"""
assert isinstance(self._killerplayer, (playertype, type(None)))
return self._killerplayer
def getplayer(self, playertype: Type[PlayerType]) -> PlayerType:
"""Return the ba.Player that died.
The type of player for the current activity should be passed so that
the type-checker properly identifies the returned value as one.
"""
player: Any = self._player
assert isinstance(player, playertype)
# We should never be delivering invalid refs.
# (could theoretically happen if someone holds on to us)
assert player.exists()
return player
@dataclass
class StandMessage:
"""A message telling an object to move to a position in space.
Category: Message Classes
Used when teleporting players to home base, etc.
Attributes:
position
Where to move to.
angle
The angle to face (in degrees)
"""
position: Sequence[float] = (0.0, 0.0, 0.0)
angle: float = 0.0
@dataclass
class PickUpMessage:
"""Tells an object that it has picked something up.
Category: Message Classes
Attributes:
node
The ba.Node that is getting picked up.
"""
node: ba.Node
@dataclass
class DropMessage:
"""Tells an object that it has dropped what it was holding.
Category: Message Classes
"""
@dataclass
class PickedUpMessage:
"""Tells an object that it has been picked up by something.
Category: Message Classes
Attributes:
node
The ba.Node doing the picking up.
"""
node: ba.Node
@dataclass
class DroppedMessage:
"""Tells an object that it has been dropped.
Category: Message Classes
Attributes:
node
The ba.Node doing the dropping.
"""
node: ba.Node
@dataclass
class ShouldShatterMessage:
"""Tells an object that it should shatter.
Category: Message Classes
"""
@dataclass
class ImpactDamageMessage:
"""Tells an object that it has been jarred violently.
Category: Message Classes
Attributes:
intensity
The intensity of the impact.
"""
intensity: float
@dataclass
class FreezeMessage:
"""Tells an object to become frozen.
Category: Message Classes
As seen in the effects of an ice ba.Bomb.
"""
@dataclass
class ThawMessage:
"""Tells an object to stop being frozen.
Category: Message Classes
"""
@dataclass
class CelebrateMessage:
"""Tells an object to celebrate.
Category: Message Classes
Attributes:
duration
Amount of time to celebrate in seconds.
"""
duration: float = 10.0
class HitMessage:
"""Tells an object it has been hit in some way.
Category: Message Classes
This is used by punches, explosions, etc to convey
their effect to a target.
"""
def __init__(self,
srcnode: ba.Node = None,
pos: Sequence[float] = None,
velocity: Sequence[float] = None,
magnitude: float = 1.0,
velocity_magnitude: float = 0.0,
radius: float = 1.0,
source_player: ba.Player = None,
kick_back: float = 1.0,
flat_damage: float = None,
hit_type: str = 'generic',
force_direction: Sequence[float] = None,
hit_subtype: str = 'default'):
"""Instantiate a message with given values."""
self.srcnode = srcnode
self.pos = pos if pos is not None else _ba.Vec3()
self.velocity = velocity if velocity is not None else _ba.Vec3()
self.magnitude = magnitude
self.velocity_magnitude = velocity_magnitude
self.radius = radius
# We should not be getting passed an invalid ref.
assert source_player is None or source_player.exists()
self._source_player = source_player
self.kick_back = kick_back
self.flat_damage = flat_damage
self.hit_type = hit_type
self.hit_subtype = hit_subtype
self.force_direction = (force_direction
if force_direction is not None else velocity)
def get_source_player(
self, playertype: Type[PlayerType]) -> Optional[PlayerType]:
"""Return the source-player if one exists and is the provided type."""
player: Any = self._source_player
# We should not be delivering invalid refs.
# (we could translate to None here but technically we are changing
# the message delivered which seems wrong)
assert player is None or player.exists()
# Return the player *only* if they're the type given.
return player if isinstance(player, playertype) else None
@dataclass
class PlayerProfilesChangedMessage:
"""Signals player profiles may have changed and should be reloaded."""

418
dist/ba_data/python/ba/_meta.py vendored Normal file
View file

@ -0,0 +1,418 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to dynamic discoverability of classes."""
from __future__ import annotations
import os
import time
import pathlib
import threading
from typing import TYPE_CHECKING
from dataclasses import dataclass, field
import _ba
if TYPE_CHECKING:
from typing import Dict, List, Tuple, Union, Optional, Type, Set
import ba
# The meta api version of this build of the game.
# Only packages and modules requiring this exact api version
# will be considered when scanning directories.
# See: https://ballistica.net/wiki/Meta-Tags
CURRENT_API_VERSION = 6
@dataclass
class ScanResults:
"""Final results from a metadata scan."""
games: List[str] = field(default_factory=list)
plugins: List[str] = field(default_factory=list)
keyboards: List[str] = field(default_factory=list)
errors: str = ''
warnings: str = ''
class MetadataSubsystem:
"""Subsystem for working with script metadata in the app.
Category: App Classes
Access the single shared instance of this class at 'ba.app.meta'.
"""
def __init__(self) -> None:
self.metascan: Optional[ScanResults] = None
def on_app_launch(self) -> None:
"""Should be called when the app is done bootstrapping."""
# Start scanning for things exposed via ba_meta.
self.start_scan()
def start_scan(self) -> None:
"""Begin scanning script directories for scripts containing metadata.
Should be called only once at launch."""
app = _ba.app
if self.metascan is not None:
print('WARNING: meta scan run more than once.')
pythondirs = [app.python_directory_app, app.python_directory_user]
thread = ScanThread(pythondirs)
thread.start()
def handle_scan_results(self, results: ScanResults) -> None:
"""Called in the game thread with results of a completed scan."""
from ba._language import Lstr
from ba._plugin import PotentialPlugin
# Warnings generally only get printed locally for users' benefit
# (things like out-of-date scripts being ignored, etc.)
# Errors are more serious and will get included in the regular log
# warnings = results.get('warnings', '')
# errors = results.get('errors', '')
if results.warnings != '' or results.errors != '':
import textwrap
_ba.screenmessage(Lstr(resource='scanScriptsErrorText'),
color=(1, 0, 0))
_ba.playsound(_ba.getsound('error'))
if results.warnings != '':
_ba.log(textwrap.indent(results.warnings,
'Warning (meta-scan): '),
to_server=False)
if results.errors != '':
_ba.log(textwrap.indent(results.errors, 'Error (meta-scan): '))
# Handle plugins.
plugs = _ba.app.plugins
config_changed = False
found_new = False
plugstates: Dict[str, Dict] = _ba.app.config.setdefault('Plugins', {})
assert isinstance(plugstates, dict)
# Create a potential-plugin for each class we found in the scan.
for class_path in results.plugins:
plugs.potential_plugins.append(
PotentialPlugin(display_name=Lstr(value=class_path),
class_path=class_path,
available=True))
if class_path not in plugstates:
if _ba.app.headless_mode:
# If we running in headless mode, enable plugin by default
# to allow server admins to get their modified build
# working 'out-of-the-box', without manually updating the
# config.
plugstates[class_path] = {'enabled': True}
else:
# If we running in normal mode, disable plugin by default
# (user can enable it later).
plugstates[class_path] = {'enabled': False}
config_changed = True
found_new = True
# Also add a special one for any plugins set to load but *not* found
# in the scan (this way they will show up in the UI so we can disable
# them)
for class_path, plugstate in plugstates.items():
enabled = plugstate.get('enabled', False)
assert isinstance(enabled, bool)
if enabled and class_path not in results.plugins:
plugs.potential_plugins.append(
PotentialPlugin(display_name=Lstr(value=class_path),
class_path=class_path,
available=False))
plugs.potential_plugins.sort(key=lambda p: p.class_path)
if found_new:
_ba.screenmessage(Lstr(resource='pluginsDetectedText'),
color=(0, 1, 0))
_ba.playsound(_ba.getsound('ding'))
if config_changed:
_ba.app.config.commit()
def get_scan_results(self) -> ScanResults:
"""Return meta scan results; block if the scan is not yet complete."""
if self.metascan is None:
print('WARNING: ba.meta.get_scan_results()'
' called before scan completed.'
' This can cause hitches.')
# Now wait a bit for the scan to complete.
# Eventually error though if it doesn't.
starttime = time.time()
while self.metascan is None:
time.sleep(0.05)
if time.time() - starttime > 10.0:
raise TimeoutError(
'timeout waiting for meta scan to complete.')
return self.metascan
def get_game_types(self) -> List[Type[ba.GameActivity]]:
"""Return available game types."""
from ba._general import getclass
from ba._gameactivity import GameActivity
gameclassnames = self.get_scan_results().games
gameclasses = []
for gameclassname in gameclassnames:
try:
cls = getclass(gameclassname, GameActivity)
gameclasses.append(cls)
except Exception:
from ba import _error
_error.print_exception('error importing ' + str(gameclassname))
unowned = self.get_unowned_game_types()
return [cls for cls in gameclasses if cls not in unowned]
def get_unowned_game_types(self) -> Set[Type[ba.GameActivity]]:
"""Return present game types not owned by the current account."""
try:
from ba import _store
unowned_games: Set[Type[ba.GameActivity]] = set()
if not _ba.app.headless_mode:
for section in _store.get_store_layout()['minigames']:
for mname in section['items']:
if not _ba.get_purchased(mname):
m_info = _store.get_store_item(mname)
unowned_games.add(m_info['gametype'])
return unowned_games
except Exception:
from ba import _error
_error.print_exception('error calcing un-owned games')
return set()
class ScanThread(threading.Thread):
"""Thread to scan script dirs for metadata."""
def __init__(self, dirs: List[str]):
super().__init__()
self._dirs = dirs
def run(self) -> None:
from ba._general import Call
try:
scan = DirectoryScan(self._dirs)
scan.scan()
results = scan.results
except Exception as exc:
results = ScanResults(errors=f'Scan exception: {exc}')
# Push a call to the game thread to print warnings/errors
# or otherwise deal with scan results.
_ba.pushcall(Call(_ba.app.meta.handle_scan_results, results),
from_other_thread=True)
# We also, however, immediately make results available.
# This is because the game thread may be blocked waiting
# for them so we can't push a call or we'd get deadlock.
_ba.app.meta.metascan = results
class DirectoryScan:
"""Handles scanning directories for metadata."""
def __init__(self, paths: List[str]):
"""Given one or more paths, parses available meta information.
It is assumed that these paths are also in PYTHONPATH.
It is also assumed that any subdirectories are Python packages.
"""
# Skip non-existent paths completely.
self.paths = [pathlib.Path(p) for p in paths if os.path.isdir(p)]
self.results = ScanResults()
def _get_path_module_entries(
self, path: pathlib.Path, subpath: Union[str, pathlib.Path],
modules: List[Tuple[pathlib.Path, pathlib.Path]]) -> None:
"""Scan provided path and add module entries to provided list."""
try:
# Special case: let's save some time and skip the whole 'ba'
# package since we know it doesn't contain any meta tags.
fullpath = pathlib.Path(path, subpath)
entries = [(path, pathlib.Path(subpath, name))
for name in os.listdir(fullpath) if name != 'ba']
except PermissionError:
# Expected sometimes.
entries = []
except Exception as exc:
# Unexpected; report this.
self.results.errors += f'{exc}\n'
entries = []
# Now identify python packages/modules out of what we found.
for entry in entries:
if entry[1].name.endswith('.py'):
modules.append(entry)
elif (pathlib.Path(entry[0], entry[1]).is_dir() and pathlib.Path(
entry[0], entry[1], '__init__.py').is_file()):
modules.append(entry)
def scan(self) -> None:
"""Scan provided paths."""
modules: List[Tuple[pathlib.Path, pathlib.Path]] = []
for path in self.paths:
self._get_path_module_entries(path, '', modules)
for moduledir, subpath in modules:
try:
self.scan_module(moduledir, subpath)
except Exception:
import traceback
self.results.warnings += ("Error scanning '" + str(subpath) +
"': " + traceback.format_exc() +
'\n')
# Sort our results
self.results.games.sort()
self.results.plugins.sort()
def scan_module(self, moduledir: pathlib.Path,
subpath: pathlib.Path) -> None:
"""Scan an individual module and add the findings to results."""
if subpath.name.endswith('.py'):
fpath = pathlib.Path(moduledir, subpath)
ispackage = False
else:
fpath = pathlib.Path(moduledir, subpath, '__init__.py')
ispackage = True
with fpath.open() as infile:
flines = infile.readlines()
meta_lines = {
lnum: l[1:].split()
for lnum, l in enumerate(flines) if '# ba_meta ' in l
}
toplevel = len(subpath.parts) <= 1
required_api = self.get_api_requirement(subpath, meta_lines, toplevel)
# Top level modules with no discernible api version get ignored.
if toplevel and required_api is None:
return
# If we find a module requiring a different api version, warn
# and ignore.
if required_api is not None and required_api != CURRENT_API_VERSION:
self.results.warnings += (
f'Warning: {subpath} requires api {required_api} but'
f' we are running {CURRENT_API_VERSION}; ignoring module.\n')
return
# Ok; can proceed with a full scan of this module.
self._process_module_meta_tags(subpath, flines, meta_lines)
# If its a package, recurse into its subpackages.
if ispackage:
try:
submodules: List[Tuple[pathlib.Path, pathlib.Path]] = []
self._get_path_module_entries(moduledir, subpath, submodules)
for submodule in submodules:
if submodule[1].name != '__init__.py':
self.scan_module(submodule[0], submodule[1])
except Exception:
import traceback
self.results.warnings += (
f"Error scanning '{subpath}': {traceback.format_exc()}\n")
def _process_module_meta_tags(self, subpath: pathlib.Path,
flines: List[str],
meta_lines: Dict[int, List[str]]) -> None:
"""Pull data from a module based on its ba_meta tags."""
for lindex, mline in meta_lines.items():
# meta_lines is just anything containing '# ba_meta '; make sure
# the ba_meta is in the right place.
if mline[0] != 'ba_meta':
self.results.warnings += (
'Warning: ' + str(subpath) +
': malformed ba_meta statement on line ' +
str(lindex + 1) + '.\n')
elif (len(mline) == 4 and mline[1] == 'require'
and mline[2] == 'api'):
# Ignore 'require api X' lines in this pass.
pass
elif len(mline) != 3 or mline[1] != 'export':
# Currently we only support 'ba_meta export FOO';
# complain for anything else we see.
self.results.warnings += (
'Warning: ' + str(subpath) +
': unrecognized ba_meta statement on line ' +
str(lindex + 1) + '.\n')
else:
# Looks like we've got a valid export line!
modulename = '.'.join(subpath.parts)
if subpath.name.endswith('.py'):
modulename = modulename[:-3]
exporttype = mline[2]
export_class_name = self._get_export_class_name(
subpath, flines, lindex)
if export_class_name is not None:
classname = modulename + '.' + export_class_name
if exporttype == 'game':
self.results.games.append(classname)
elif exporttype == 'plugin':
self.results.plugins.append(classname)
elif exporttype == 'keyboard':
self.results.keyboards.append(classname)
else:
self.results.warnings += (
'Warning: ' + str(subpath) +
': unrecognized export type "' + exporttype +
'" on line ' + str(lindex + 1) + '.\n')
def _get_export_class_name(self, subpath: pathlib.Path, lines: List[str],
lindex: int) -> Optional[str]:
"""Given line num of an export tag, returns its operand class name."""
lindexorig = lindex
classname = None
while True:
lindex += 1
if lindex >= len(lines):
break
lbits = lines[lindex].split()
if not lbits:
continue # Skip empty lines.
if lbits[0] != 'class':
break
if len(lbits) > 1:
cbits = lbits[1].split('(')
if len(cbits) > 1 and cbits[0].isidentifier():
classname = cbits[0]
break # Success!
if classname is None:
self.results.warnings += (
'Warning: ' + str(subpath) + ': class definition not found'
' below "ba_meta export" statement on line ' +
str(lindexorig + 1) + '.\n')
return classname
def get_api_requirement(self, subpath: pathlib.Path,
meta_lines: Dict[int, List[str]],
toplevel: bool) -> Optional[int]:
"""Return an API requirement integer or None if none present.
Malformed api requirement strings will be logged as warnings.
"""
lines = [
l for l in meta_lines.values() if len(l) == 4 and l[0] == 'ba_meta'
and l[1] == 'require' and l[2] == 'api' and l[3].isdigit()
]
# We're successful if we find exactly one properly formatted line.
if len(lines) == 1:
return int(lines[0][3])
# Ok; not successful. lets issue warnings for a few error cases.
if len(lines) > 1:
self.results.warnings += (
'Warning: ' + str(subpath) +
': multiple "# ba_meta api require <NUM>" lines found;'
' ignoring module.\n')
elif not lines and toplevel and meta_lines:
# If we're a top-level module containing meta lines but
# no valid api require, complain.
self.results.warnings += (
'Warning: ' + str(subpath) +
': no valid "# ba_meta api require <NUM>" line found;'
' ignoring module.\n')
return None

View file

@ -0,0 +1,312 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to teams sessions."""
from __future__ import annotations
import copy
import random
from typing import TYPE_CHECKING
import _ba
from ba._session import Session
from ba._error import NotFoundError, print_error
if TYPE_CHECKING:
from typing import Optional, Any, Dict, List, Type, Sequence
import ba
DEFAULT_TEAM_COLORS = ((0.1, 0.25, 1.0), (1.0, 0.25, 0.2))
DEFAULT_TEAM_NAMES = ('Blue', 'Red')
class MultiTeamSession(Session):
"""Common base class for ba.DualTeamSession and ba.FreeForAllSession.
Category: Gameplay Classes
Free-for-all-mode is essentially just teams-mode with each ba.Player having
their own ba.Team, so there is much overlap in functionality.
"""
# These should be overridden.
_playlist_selection_var = 'UNSET Playlist Selection'
_playlist_randomize_var = 'UNSET Playlist Randomize'
_playlists_var = 'UNSET Playlists'
def __init__(self) -> None:
"""Set up playlists and launches a ba.Activity to accept joiners."""
# pylint: disable=cyclic-import
from ba import _playlist
from bastd.activity.multiteamjoin import MultiTeamJoinActivity
app = _ba.app
cfg = app.config
if self.use_teams:
team_names = cfg.get('Custom Team Names', DEFAULT_TEAM_NAMES)
team_colors = cfg.get('Custom Team Colors', DEFAULT_TEAM_COLORS)
else:
team_names = None
team_colors = None
# print('FIXME: TEAM BASE SESSION WOULD CALC DEPS.')
depsets: Sequence[ba.DependencySet] = []
super().__init__(depsets,
team_names=team_names,
team_colors=team_colors,
min_players=1,
max_players=self.get_max_players())
self._series_length = app.teams_series_length
self._ffa_series_length = app.ffa_series_length
show_tutorial = cfg.get('Show Tutorial', True)
self._tutorial_activity_instance: Optional[ba.Activity]
if show_tutorial:
from bastd.tutorial import TutorialActivity
# Get this loading.
self._tutorial_activity_instance = _ba.newactivity(
TutorialActivity)
else:
self._tutorial_activity_instance = None
self._playlist_name = cfg.get(self._playlist_selection_var,
'__default__')
self._playlist_randomize = cfg.get(self._playlist_randomize_var, False)
# Which game activity we're on.
self._game_number = 0
playlists = cfg.get(self._playlists_var, {})
if (self._playlist_name != '__default__'
and self._playlist_name in playlists):
# Make sure to copy this, as we muck with it in place once we've
# got it and we don't want that to affect our config.
playlist = copy.deepcopy(playlists[self._playlist_name])
else:
if self.use_teams:
playlist = _playlist.get_default_teams_playlist()
else:
playlist = _playlist.get_default_free_for_all_playlist()
# Resolve types and whatnot to get our final playlist.
playlist_resolved = _playlist.filter_playlist(playlist,
sessiontype=type(self),
add_resolved_type=True)
if not playlist_resolved:
raise RuntimeError('Playlist contains no valid games.')
self._playlist = ShuffleList(playlist_resolved,
shuffle=self._playlist_randomize)
# Get a game on deck ready to go.
self._current_game_spec: Optional[Dict[str, Any]] = None
self._next_game_spec: Dict[str, Any] = self._playlist.pull_next()
self._next_game: Type[ba.GameActivity] = (
self._next_game_spec['resolved_type'])
# Go ahead and instantiate the next game we'll
# use so it has lots of time to load.
self._instantiate_next_game()
# Start in our custom join screen.
self.setactivity(_ba.newactivity(MultiTeamJoinActivity))
def get_ffa_series_length(self) -> int:
"""Return free-for-all series length."""
return self._ffa_series_length
def get_series_length(self) -> int:
"""Return teams series length."""
return self._series_length
def get_next_game_description(self) -> ba.Lstr:
"""Returns a description of the next game on deck."""
# pylint: disable=cyclic-import
from ba._gameactivity import GameActivity
gametype: Type[GameActivity] = self._next_game_spec['resolved_type']
assert issubclass(gametype, GameActivity)
return gametype.get_settings_display_string(self._next_game_spec)
def get_game_number(self) -> int:
"""Returns which game in the series is currently being played."""
return self._game_number
def on_team_join(self, team: ba.SessionTeam) -> None:
team.customdata['previous_score'] = team.customdata['score'] = 0
def get_max_players(self) -> int:
"""Return max number of ba.Players allowed to join the game at once."""
if self.use_teams:
return _ba.app.config.get('Team Game Max Players', 8)
return _ba.app.config.get('Free-for-All Max Players', 8)
def _instantiate_next_game(self) -> None:
self._next_game_instance = _ba.newactivity(
self._next_game_spec['resolved_type'],
self._next_game_spec['settings'])
def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
# pylint: disable=cyclic-import
from bastd.tutorial import TutorialActivity
from bastd.activity.multiteamvictory import (
TeamSeriesVictoryScoreScreenActivity)
from ba._activitytypes import (TransitionActivity, JoinActivity,
ScoreScreenActivity)
# If we have a tutorial to show, that's the first thing we do no
# matter what.
if self._tutorial_activity_instance is not None:
self.setactivity(self._tutorial_activity_instance)
self._tutorial_activity_instance = None
# If we're leaving the tutorial activity, pop a transition activity
# to transition us into a round gracefully (otherwise we'd snap from
# one terrain to another instantly).
elif isinstance(activity, TutorialActivity):
self.setactivity(_ba.newactivity(TransitionActivity))
# If we're in a between-round activity or a restart-activity, hop
# into a round.
elif isinstance(
activity,
(JoinActivity, TransitionActivity, ScoreScreenActivity)):
# If we're coming from a series-end activity, reset scores.
if isinstance(activity, TeamSeriesVictoryScoreScreenActivity):
self.stats.reset()
self._game_number = 0
for team in self.sessionteams:
team.customdata['score'] = 0
# Otherwise just set accum (per-game) scores.
else:
self.stats.reset_accum()
next_game = self._next_game_instance
self._current_game_spec = self._next_game_spec
self._next_game_spec = self._playlist.pull_next()
self._game_number += 1
# Instantiate the next now so they have plenty of time to load.
self._instantiate_next_game()
# (Re)register all players and wire stats to our next activity.
for player in self.sessionplayers:
# ..but only ones who have been placed on a team
# (ie: no longer sitting in the lobby).
try:
has_team = (player.sessionteam is not None)
except NotFoundError:
has_team = False
if has_team:
self.stats.register_sessionplayer(player)
self.stats.setactivity(next_game)
# Now flip the current activity.
self.setactivity(next_game)
# If we're leaving a round, go to the score screen.
else:
self._switch_to_score_screen(results)
def _switch_to_score_screen(self, results: Any) -> None:
"""Switch to a score screen after leaving a round."""
del results # Unused arg.
print_error('this should be overridden')
def announce_game_results(self,
activity: ba.GameActivity,
results: ba.GameResults,
delay: float,
announce_winning_team: bool = True) -> None:
"""Show basic game result at the end of a game.
(before transitioning to a score screen).
This will include a zoom-text of 'BLUE WINS'
or whatnot, along with a possible audio
announcement of the same.
"""
# pylint: disable=cyclic-import
# pylint: disable=too-many-locals
from ba._math import normalized_color
from ba._general import Call
from ba._gameutils import cameraflash
from ba._language import Lstr
from ba._freeforallsession import FreeForAllSession
from ba._messages import CelebrateMessage
_ba.timer(delay, Call(_ba.playsound, _ba.getsound('boxingBell')))
if announce_winning_team:
winning_sessionteam = results.winning_sessionteam
if winning_sessionteam is not None:
# Have all players celebrate.
celebrate_msg = CelebrateMessage(duration=10.0)
assert winning_sessionteam.activityteam is not None
for player in winning_sessionteam.activityteam.players:
if player.actor:
player.actor.handlemessage(celebrate_msg)
cameraflash()
# Some languages say "FOO WINS" different for teams vs players.
if isinstance(self, FreeForAllSession):
wins_resource = 'winsPlayerText'
else:
wins_resource = 'winsTeamText'
wins_text = Lstr(resource=wins_resource,
subs=[('${NAME}', winning_sessionteam.name)])
activity.show_zoom_message(
wins_text,
scale=0.85,
color=normalized_color(winning_sessionteam.color),
)
class ShuffleList:
"""Smart shuffler for game playlists.
(avoids repeats in maps or game types)
"""
def __init__(self, items: List[Dict[str, Any]], shuffle: bool = True):
self.source_list = items
self.shuffle = shuffle
self.shuffle_list: List[Dict[str, Any]] = []
self.last_gotten: Optional[Dict[str, Any]] = None
def pull_next(self) -> Dict[str, Any]:
"""Pull and return the next item on the shuffle-list."""
# Refill our list if its empty.
if not self.shuffle_list:
self.shuffle_list = list(self.source_list)
# Ok now find an index we should pull.
index = 0
if self.shuffle:
for _i in range(4):
index = random.randrange(0, len(self.shuffle_list))
test_obj = self.shuffle_list[index]
# If the new one is the same map or game-type as the previous,
# lets try to keep looking.
if len(self.shuffle_list) > 1 and self.last_gotten is not None:
if (test_obj['settings']['map'] ==
self.last_gotten['settings']['map']):
continue
if test_obj['type'] == self.last_gotten['type']:
continue
# Sufficiently different; lets go with it.
break
obj = self.shuffle_list.pop(index)
self.last_gotten = obj
return obj

503
dist/ba_data/python/ba/_music.py vendored Normal file
View file

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

186
dist/ba_data/python/ba/_netutils.py vendored Normal file
View file

@ -0,0 +1,186 @@
# Released under the MIT License. See LICENSE for details.
#
"""Networking related functionality."""
from __future__ import annotations
import copy
import threading
import weakref
from enum import Enum
from typing import TYPE_CHECKING
import _ba
if TYPE_CHECKING:
from typing import Any, Dict, Union, Callable, Optional
import socket
import ba
ServerCallbackType = Callable[[Union[None, Dict[str, Any]]], None]
def get_ip_address_type(addr: str) -> socket.AddressFamily:
"""Return socket.AF_INET6 or socket.AF_INET4 for the provided address."""
import socket
socket_type = None
# First try it as an ipv4 address.
try:
socket.inet_pton(socket.AF_INET, addr)
socket_type = socket.AF_INET
except OSError:
pass
# Hmm apparently not ipv4; try ipv6.
if socket_type is None:
try:
socket.inet_pton(socket.AF_INET6, addr)
socket_type = socket.AF_INET6
except OSError:
pass
if socket_type is None:
raise ValueError(f'addr seems to be neither v4 or v6: {addr}')
return socket_type
class ServerResponseType(Enum):
"""How to interpret responses from the server."""
JSON = 0
class ServerCallThread(threading.Thread):
"""Thread to communicate with the master server."""
def __init__(self, request: str, request_type: str,
data: Optional[Dict[str, Any]],
callback: Optional[ServerCallbackType],
response_type: ServerResponseType):
super().__init__()
self._request = request
self._request_type = request_type
if not isinstance(response_type, ServerResponseType):
raise TypeError(f'Invalid response type: {response_type}')
self._response_type = response_type
self._data = {} if data is None else copy.deepcopy(data)
self._callback: Optional[ServerCallbackType] = callback
self._context = _ba.Context('current')
# Save and restore the context we were created from.
activity = _ba.getactivity(doraise=False)
self._activity = weakref.ref(
activity) if activity is not None else None
def _run_callback(self, arg: Union[None, Dict[str, Any]]) -> None:
# If we were created in an activity context and that activity has
# since died, do nothing.
# FIXME: Should we just be using a ContextCall instead of doing
# this check manually?
if self._activity is not None:
activity = self._activity()
if activity is None or activity.expired:
return
# Technically we could do the same check for session contexts,
# but not gonna worry about it for now.
assert self._context is not None
assert self._callback is not None
with self._context:
self._callback(arg)
def run(self) -> None:
# pylint: disable=too-many-branches
import urllib.request
import urllib.error
import json
import http.client
from ba import _general
try:
self._data = _general.utf8_all(self._data)
_ba.set_thread_name('BA_ServerCallThread')
parse = urllib.parse
if self._request_type == 'get':
response = urllib.request.urlopen(
urllib.request.Request(
(_ba.get_master_server_address() + '/' +
self._request + '?' + parse.urlencode(self._data)),
None, {'User-Agent': _ba.app.user_agent_string}))
elif self._request_type == 'post':
response = urllib.request.urlopen(
urllib.request.Request(
_ba.get_master_server_address() + '/' + self._request,
parse.urlencode(self._data).encode(),
{'User-Agent': _ba.app.user_agent_string}))
else:
raise TypeError('Invalid request_type: ' + self._request_type)
# If html request failed.
if response.getcode() != 200:
response_data = None
elif self._response_type == ServerResponseType.JSON:
raw_data = response.read()
# Empty string here means something failed server side.
if raw_data == b'':
response_data = None
else:
# Json.loads requires str in python < 3.6.
raw_data_s = raw_data.decode()
response_data = json.loads(raw_data_s)
else:
raise TypeError(f'invalid responsetype: {self._response_type}')
except Exception as exc:
import errno
do_print = False
response_data = None
# Ignore common network errors; note unexpected ones.
if isinstance(
exc,
(urllib.error.URLError, ConnectionError,
http.client.IncompleteRead, http.client.BadStatusLine)):
pass
elif isinstance(exc, OSError):
if exc.errno == 10051: # Windows unreachable network error.
pass
elif exc.errno in [
errno.ETIMEDOUT, errno.EHOSTUNREACH, errno.ENETUNREACH
]:
pass
else:
do_print = True
elif (self._response_type == ServerResponseType.JSON
and isinstance(exc, json.decoder.JSONDecodeError)):
pass
else:
do_print = True
if do_print:
# Any other error here is unexpected,
# so let's make a note of it,
print(f'Error in ServerCallThread'
f' (response-type={self._response_type},'
f' response-data={response_data}):')
import traceback
traceback.print_exc()
if self._callback is not None:
_ba.pushcall(_general.Call(self._run_callback, response_data),
from_other_thread=True)
def master_server_get(
request: str,
data: Dict[str, Any],
callback: Optional[ServerCallbackType] = None,
response_type: ServerResponseType = ServerResponseType.JSON) -> None:
"""Make a call to the master server via a http GET."""
ServerCallThread(request, 'get', data, callback, response_type).start()
def master_server_post(
request: str,
data: Dict[str, Any],
callback: Optional[ServerCallbackType] = None,
response_type: ServerResponseType = ServerResponseType.JSON) -> None:
"""Make a call to the master server via a http POST."""
ServerCallThread(request, 'post', data, callback, response_type).start()

38
dist/ba_data/python/ba/_nodeactor.py vendored Normal file
View file

@ -0,0 +1,38 @@
# Released under the MIT License. See LICENSE for details.
#
"""Defines NodeActor class."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ba._messages import DieMessage
from ba._actor import Actor
if TYPE_CHECKING:
import ba
from typing import Any
class NodeActor(Actor):
"""A simple ba.Actor type that wraps a single ba.Node.
Category: Gameplay Classes
This Actor will delete its Node when told to die, and it's
exists() call will return whether the Node still exists or not.
"""
def __init__(self, node: ba.Node):
super().__init__()
self.node = node
def handlemessage(self, msg: Any) -> Any:
if isinstance(msg, DieMessage):
if self.node:
self.node.delete()
return None
return super().handlemessage(msg)
def exists(self) -> bool:
return bool(self.node)

330
dist/ba_data/python/ba/_player.py vendored Normal file
View file

@ -0,0 +1,330 @@
# Released under the MIT License. See LICENSE for details.
#
"""Player related functionality."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, TypeVar, Generic, cast
import _ba
from ba._error import (SessionPlayerNotFoundError, print_exception,
ActorNotFoundError)
from ba._messages import DeathType, DieMessage
if TYPE_CHECKING:
from typing import (Type, Optional, Sequence, Dict, Any, Union, Tuple,
Callable)
import ba
PlayerType = TypeVar('PlayerType', bound='ba.Player')
TeamType = TypeVar('TeamType', bound='ba.Team')
@dataclass
class PlayerInfo:
"""Holds basic info about a player.
Category: Gameplay Classes
"""
name: str
character: str
@dataclass
class StandLocation:
"""Describes a point in space and an angle to face.
Category: Gameplay Classes
"""
position: ba.Vec3
angle: Optional[float] = None
class Player(Generic[TeamType]):
"""A player in a specific ba.Activity.
Category: Gameplay Classes
These correspond to ba.SessionPlayer objects, but are associated with a
single ba.Activity instance. This allows activities to specify their
own custom ba.Player types.
Attributes:
actor
The ba.Actor associated with the player.
"""
# These are instance attrs but we define them at the type level so
# their type annotations are introspectable (for docs generation).
character: str
actor: Optional[ba.Actor]
color: Sequence[float]
highlight: Sequence[float]
_team: TeamType
_sessionplayer: ba.SessionPlayer
_nodeactor: Optional[ba.NodeActor]
_expired: bool
_postinited: bool
_customdata: dict
# NOTE: avoiding having any __init__() here since it seems to not
# get called by default if a dataclass inherits from us.
# This also lets us keep trivial player classes cleaner by skipping
# the super().__init__() line.
def postinit(self, sessionplayer: ba.SessionPlayer) -> None:
"""Wire up a newly created player.
(internal)
"""
from ba._nodeactor import NodeActor
# Sanity check; if a dataclass is created that inherits from us,
# it will define an equality operator by default which will break
# internal game logic. So complain loudly if we find one.
if type(self).__eq__ is not object.__eq__:
raise RuntimeError(
f'Player class {type(self)} defines an equality'
f' operator (__eq__) which will break internal'
f' logic. Please remove it.\n'
f'For dataclasses you can do "dataclass(eq=False)"'
f' in the class decorator.')
self.actor = None
self.character = ''
self._nodeactor: Optional[ba.NodeActor] = None
self._sessionplayer = sessionplayer
self.character = sessionplayer.character
self.color = sessionplayer.color
self.highlight = sessionplayer.highlight
self._team = cast(TeamType, sessionplayer.sessionteam.activityteam)
assert self._team is not None
self._customdata = {}
self._expired = False
self._postinited = True
node = _ba.newnode('player', attrs={'playerID': sessionplayer.id})
self._nodeactor = NodeActor(node)
sessionplayer.setnode(node)
def leave(self) -> None:
"""Called when the Player leaves a running game.
(internal)
"""
assert self._postinited
assert not self._expired
try:
# If they still have an actor, kill it.
if self.actor:
self.actor.handlemessage(DieMessage(how=DeathType.LEFT_GAME))
self.actor = None
except Exception:
print_exception(f'Error killing actor on leave for {self}')
self._nodeactor = None
del self._team
del self._customdata
def expire(self) -> None:
"""Called when the Player is expiring (when its Activity does so).
(internal)
"""
assert self._postinited
assert not self._expired
self._expired = True
try:
self.on_expire()
except Exception:
print_exception(f'Error in on_expire for {self}.')
self._nodeactor = None
self.actor = None
del self._team
del self._customdata
def on_expire(self) -> None:
"""Can be overridden to handle player expiration.
The player expires when the Activity it is a part of expires.
Expired players should no longer run any game logic (which will
likely error). They should, however, remove any references to
players/teams/games/etc. which could prevent them from being freed.
"""
@property
def team(self) -> TeamType:
"""The ba.Team for this player."""
assert self._postinited
assert not self._expired
return self._team
@property
def customdata(self) -> dict:
"""Arbitrary values associated with the player.
Though it is encouraged that most player values be properly defined
on the ba.Player subclass, it may be useful for player-agnostic
objects to store values here. This dict is cleared when the player
leaves or expires so objects stored here will be disposed of at
the expected time, unlike the Player instance itself which may
continue to be referenced after it is no longer part of the game.
"""
assert self._postinited
assert not self._expired
return self._customdata
@property
def sessionplayer(self) -> ba.SessionPlayer:
"""Return the ba.SessionPlayer corresponding to this Player.
Throws a ba.SessionPlayerNotFoundError if it does not exist.
"""
assert self._postinited
if bool(self._sessionplayer):
return self._sessionplayer
raise SessionPlayerNotFoundError()
@property
def node(self) -> ba.Node:
"""A ba.Node of type 'player' associated with this Player.
This node can be used to get a generic player position/etc.
"""
assert self._postinited
assert not self._expired
assert self._nodeactor
return self._nodeactor.node
@property
def position(self) -> ba.Vec3:
"""The position of the player, as defined by its current ba.Actor.
If the player currently has no actor, raises a ba.ActorNotFoundError.
"""
assert self._postinited
assert not self._expired
if self.actor is None:
raise ActorNotFoundError
return _ba.Vec3(self.node.position)
def exists(self) -> bool:
"""Whether the underlying player still exists.
This will return False if the underlying ba.SessionPlayer has
left the game or if the ba.Activity this player was associated
with has ended.
Most functionality will fail on a nonexistent player.
Note that you can also use the boolean operator for this same
functionality, so a statement such as "if player" will do
the right thing both for Player objects and values of None.
"""
assert self._postinited
return self._sessionplayer.exists() and not self._expired
def getname(self, full: bool = False, icon: bool = True) -> str:
"""getname(full: bool = False, icon: bool = True) -> str
Returns the player's name. If icon is True, the long version of the
name may include an icon.
"""
assert self._postinited
assert not self._expired
return self._sessionplayer.getname(full=full, icon=icon)
def is_alive(self) -> bool:
"""is_alive() -> bool
Returns True if the player has a ba.Actor assigned and its
is_alive() method return True. False is returned otherwise.
"""
assert self._postinited
assert not self._expired
return self.actor is not None and self.actor.is_alive()
def get_icon(self) -> Dict[str, Any]:
"""get_icon() -> Dict[str, Any]
Returns the character's icon (images, colors, etc contained in a dict)
"""
assert self._postinited
assert not self._expired
return self._sessionplayer.get_icon()
def assigninput(self, inputtype: Union[ba.InputType, Tuple[ba.InputType,
...]],
call: Callable) -> None:
"""assigninput(type: Union[ba.InputType, Tuple[ba.InputType, ...]],
call: Callable) -> None
Set the python callable to be run for one or more types of input.
"""
assert self._postinited
assert not self._expired
return self._sessionplayer.assigninput(type=inputtype, call=call)
def resetinput(self) -> None:
"""resetinput() -> None
Clears out the player's assigned input actions.
"""
assert self._postinited
assert not self._expired
self._sessionplayer.resetinput()
def __bool__(self) -> bool:
return self.exists()
class EmptyPlayer(Player['ba.EmptyTeam']):
"""An empty player for use by Activities that don't need to define one.
Category: Gameplay Classes
ba.Player and ba.Team are 'Generic' types, and so passing those top level
classes as type arguments when defining a ba.Activity reduces type safety.
For example, activity.teams[0].player will have type 'Any' in that case.
For that reason, it is better to pass EmptyPlayer and EmptyTeam when
defining a ba.Activity that does not need custom types of its own.
Note that EmptyPlayer defines its team type as EmptyTeam and vice versa,
so if you want to define your own class for one of them you should do so
for both.
"""
# NOTE: It seems we might not need these playercast() calls; have gone
# the direction where things returning players generally take a type arg
# and do this themselves; that way the user is 'forced' to deal with types
# instead of requiring extra work by them.
def playercast(totype: Type[PlayerType], player: ba.Player) -> PlayerType:
"""Cast a ba.Player to a specific ba.Player subclass.
Category: Gameplay Functions
When writing type-checked code, sometimes code will deal with raw
ba.Player objects which need to be cast back to the game's actual
player type so that access can be properly type-checked. This function
is a safe way to do so. It ensures that Optional values are not cast
into Non-Optional, etc.
"""
assert isinstance(player, totype)
return player
# NOTE: ideally we should have a single playercast() call and use overloads
# for the optional variety, but that currently seems to not be working.
# See: https://github.com/python/mypy/issues/8800
def playercast_o(totype: Type[PlayerType],
player: Optional[ba.Player]) -> Optional[PlayerType]:
"""A variant of ba.playercast() for use with optional ba.Player values.
Category: Gameplay Functions
"""
assert isinstance(player, (totype, type(None)))
return player

495
dist/ba_data/python/ba/_playlist.py vendored Normal file
View file

@ -0,0 +1,495 @@
# Released under the MIT License. See LICENSE for details.
#
"""Playlist related functionality."""
from __future__ import annotations
import copy
from typing import Any, TYPE_CHECKING, Dict, List
if TYPE_CHECKING:
from typing import Type, Sequence
from ba import _session
PlaylistType = List[Dict[str, Any]]
def filter_playlist(playlist: PlaylistType,
sessiontype: Type[_session.Session],
add_resolved_type: bool = False,
remove_unowned: bool = True,
mark_unowned: bool = False) -> PlaylistType:
"""Return a filtered version of a playlist.
Strips out or replaces invalid or unowned game types, makes sure all
settings are present, and adds in a 'resolved_type' which is the actual
type.
"""
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
import _ba
from ba import _map
from ba import _general
from ba import _gameactivity
goodlist: List[Dict] = []
unowned_maps: Sequence[str]
if remove_unowned or mark_unowned:
unowned_maps = _map.get_unowned_maps()
unowned_game_types = _ba.app.meta.get_unowned_game_types()
else:
unowned_maps = []
unowned_game_types = set()
for entry in copy.deepcopy(playlist):
# 'map' used to be called 'level' here.
if 'level' in entry:
entry['map'] = entry['level']
del entry['level']
# We now stuff map into settings instead of it being its own thing.
if 'map' in entry:
entry['settings']['map'] = entry['map']
del entry['map']
# Update old map names to new ones.
entry['settings']['map'] = _map.get_filtered_map_name(
entry['settings']['map'])
if remove_unowned and entry['settings']['map'] in unowned_maps:
continue
# Ok, for each game in our list, try to import the module and grab
# the actual game class. add successful ones to our initial list
# to present to the user.
if not isinstance(entry['type'], str):
raise TypeError('invalid entry format')
try:
# Do some type filters for backwards compat.
if entry['type'] in ('Assault.AssaultGame',
'Happy_Thoughts.HappyThoughtsGame',
'bsAssault.AssaultGame',
'bs_assault.AssaultGame'):
entry['type'] = 'bastd.game.assault.AssaultGame'
if entry['type'] in ('King_of_the_Hill.KingOfTheHillGame',
'bsKingOfTheHill.KingOfTheHillGame',
'bs_king_of_the_hill.KingOfTheHillGame'):
entry['type'] = 'bastd.game.kingofthehill.KingOfTheHillGame'
if entry['type'] in ('Capture_the_Flag.CTFGame',
'bsCaptureTheFlag.CTFGame',
'bs_capture_the_flag.CTFGame'):
entry['type'] = (
'bastd.game.capturetheflag.CaptureTheFlagGame')
if entry['type'] in ('Death_Match.DeathMatchGame',
'bsDeathMatch.DeathMatchGame',
'bs_death_match.DeathMatchGame'):
entry['type'] = 'bastd.game.deathmatch.DeathMatchGame'
if entry['type'] in ('ChosenOne.ChosenOneGame',
'bsChosenOne.ChosenOneGame',
'bs_chosen_one.ChosenOneGame'):
entry['type'] = 'bastd.game.chosenone.ChosenOneGame'
if entry['type'] in ('Conquest.Conquest', 'Conquest.ConquestGame',
'bsConquest.ConquestGame',
'bs_conquest.ConquestGame'):
entry['type'] = 'bastd.game.conquest.ConquestGame'
if entry['type'] in ('Elimination.EliminationGame',
'bsElimination.EliminationGame',
'bs_elimination.EliminationGame'):
entry['type'] = 'bastd.game.elimination.EliminationGame'
if entry['type'] in ('Football.FootballGame',
'bsFootball.FootballTeamGame',
'bs_football.FootballTeamGame'):
entry['type'] = 'bastd.game.football.FootballTeamGame'
if entry['type'] in ('Hockey.HockeyGame', 'bsHockey.HockeyGame',
'bs_hockey.HockeyGame'):
entry['type'] = 'bastd.game.hockey.HockeyGame'
if entry['type'] in ('Keep_Away.KeepAwayGame',
'bsKeepAway.KeepAwayGame',
'bs_keep_away.KeepAwayGame'):
entry['type'] = 'bastd.game.keepaway.KeepAwayGame'
if entry['type'] in ('Race.RaceGame', 'bsRace.RaceGame',
'bs_race.RaceGame'):
entry['type'] = 'bastd.game.race.RaceGame'
if entry['type'] in ('bsEasterEggHunt.EasterEggHuntGame',
'bs_easter_egg_hunt.EasterEggHuntGame'):
entry['type'] = 'bastd.game.easteregghunt.EasterEggHuntGame'
if entry['type'] in ('bsMeteorShower.MeteorShowerGame',
'bs_meteor_shower.MeteorShowerGame'):
entry['type'] = 'bastd.game.meteorshower.MeteorShowerGame'
if entry['type'] in ('bsTargetPractice.TargetPracticeGame',
'bs_target_practice.TargetPracticeGame'):
entry['type'] = (
'bastd.game.targetpractice.TargetPracticeGame')
gameclass = _general.getclass(entry['type'],
_gameactivity.GameActivity)
if remove_unowned and gameclass in unowned_game_types:
continue
if add_resolved_type:
entry['resolved_type'] = gameclass
if mark_unowned and entry['settings']['map'] in unowned_maps:
entry['is_unowned_map'] = True
if mark_unowned and gameclass in unowned_game_types:
entry['is_unowned_game'] = True
# Make sure all settings the game defines are present.
neededsettings = gameclass.get_available_settings(sessiontype)
for setting in neededsettings:
if setting.name not in entry['settings']:
entry['settings'][setting.name] = setting.default
goodlist.append(entry)
except ImportError as exc:
print(f'Import failed while scanning playlist: {exc}')
except Exception:
from ba import _error
_error.print_exception()
return goodlist
def get_default_free_for_all_playlist() -> PlaylistType:
"""Return a default playlist for free-for-all mode."""
# NOTE: these are currently using old type/map names,
# but filtering translates them properly to the new ones.
# (is kinda a handy way to ensure filtering is working).
# Eventually should update these though.
return [{
'settings': {
'Epic Mode': False,
'Kills to Win Per Player': 10,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Doom Shroom'
},
'type': 'bs_death_match.DeathMatchGame'
}, {
'settings': {
'Chosen One Gets Gloves': True,
'Chosen One Gets Shield': False,
'Chosen One Time': 30,
'Epic Mode': 0,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Monkey Face'
},
'type': 'bs_chosen_one.ChosenOneGame'
}, {
'settings': {
'Hold Time': 30,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Zigzag'
},
'type': 'bs_king_of_the_hill.KingOfTheHillGame'
}, {
'settings': {
'Epic Mode': False,
'map': 'Rampage'
},
'type': 'bs_meteor_shower.MeteorShowerGame'
}, {
'settings': {
'Epic Mode': 1,
'Lives Per Player': 1,
'Respawn Times': 1.0,
'Time Limit': 120,
'map': 'Tip Top'
},
'type': 'bs_elimination.EliminationGame'
}, {
'settings': {
'Hold Time': 30,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'The Pad'
},
'type': 'bs_keep_away.KeepAwayGame'
}, {
'settings': {
'Epic Mode': True,
'Kills to Win Per Player': 10,
'Respawn Times': 0.25,
'Time Limit': 120,
'map': 'Rampage'
},
'type': 'bs_death_match.DeathMatchGame'
}, {
'settings': {
'Bomb Spawning': 1000,
'Epic Mode': False,
'Laps': 3,
'Mine Spawn Interval': 4000,
'Mine Spawning': 4000,
'Time Limit': 300,
'map': 'Big G'
},
'type': 'bs_race.RaceGame'
}, {
'settings': {
'Hold Time': 30,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Happy Thoughts'
},
'type': 'bs_king_of_the_hill.KingOfTheHillGame'
}, {
'settings': {
'Enable Impact Bombs': 1,
'Enable Triple Bombs': False,
'Target Count': 2,
'map': 'Doom Shroom'
},
'type': 'bs_target_practice.TargetPracticeGame'
}, {
'settings': {
'Epic Mode': False,
'Lives Per Player': 5,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Step Right Up'
},
'type': 'bs_elimination.EliminationGame'
}, {
'settings': {
'Epic Mode': False,
'Kills to Win Per Player': 10,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Crag Castle'
},
'type': 'bs_death_match.DeathMatchGame'
}, {
'map': 'Lake Frigid',
'settings': {
'Bomb Spawning': 0,
'Epic Mode': False,
'Laps': 6,
'Mine Spawning': 2000,
'Time Limit': 300,
'map': 'Lake Frigid'
},
'type': 'bs_race.RaceGame'
}]
def get_default_teams_playlist() -> PlaylistType:
"""Return a default playlist for teams mode."""
# NOTE: these are currently using old type/map names,
# but filtering translates them properly to the new ones.
# (is kinda a handy way to ensure filtering is working).
# Eventually should update these though.
return [{
'settings': {
'Epic Mode': False,
'Flag Idle Return Time': 30,
'Flag Touch Return Time': 0,
'Respawn Times': 1.0,
'Score to Win': 3,
'Time Limit': 600,
'map': 'Bridgit'
},
'type': 'bs_capture_the_flag.CTFGame'
}, {
'settings': {
'Epic Mode': False,
'Respawn Times': 1.0,
'Score to Win': 3,
'Time Limit': 600,
'map': 'Step Right Up'
},
'type': 'bs_assault.AssaultGame'
}, {
'settings': {
'Balance Total Lives': False,
'Epic Mode': False,
'Lives Per Player': 3,
'Respawn Times': 1.0,
'Solo Mode': True,
'Time Limit': 600,
'map': 'Rampage'
},
'type': 'bs_elimination.EliminationGame'
}, {
'settings': {
'Epic Mode': False,
'Kills to Win Per Player': 5,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Roundabout'
},
'type': 'bs_death_match.DeathMatchGame'
}, {
'settings': {
'Respawn Times': 1.0,
'Score to Win': 1,
'Time Limit': 600,
'map': 'Hockey Stadium'
},
'type': 'bs_hockey.HockeyGame'
}, {
'settings': {
'Hold Time': 30,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Monkey Face'
},
'type': 'bs_keep_away.KeepAwayGame'
}, {
'settings': {
'Balance Total Lives': False,
'Epic Mode': True,
'Lives Per Player': 1,
'Respawn Times': 1.0,
'Solo Mode': False,
'Time Limit': 120,
'map': 'Tip Top'
},
'type': 'bs_elimination.EliminationGame'
}, {
'settings': {
'Epic Mode': False,
'Respawn Times': 1.0,
'Score to Win': 3,
'Time Limit': 300,
'map': 'Crag Castle'
},
'type': 'bs_assault.AssaultGame'
}, {
'settings': {
'Epic Mode': False,
'Kills to Win Per Player': 5,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Doom Shroom'
},
'type': 'bs_death_match.DeathMatchGame'
}, {
'settings': {
'Epic Mode': False,
'map': 'Rampage'
},
'type': 'bs_meteor_shower.MeteorShowerGame'
}, {
'settings': {
'Epic Mode': False,
'Flag Idle Return Time': 30,
'Flag Touch Return Time': 0,
'Respawn Times': 1.0,
'Score to Win': 2,
'Time Limit': 600,
'map': 'Roundabout'
},
'type': 'bs_capture_the_flag.CTFGame'
}, {
'settings': {
'Respawn Times': 1.0,
'Score to Win': 21,
'Time Limit': 600,
'map': 'Football Stadium'
},
'type': 'bs_football.FootballTeamGame'
}, {
'settings': {
'Epic Mode': True,
'Respawn Times': 0.25,
'Score to Win': 3,
'Time Limit': 120,
'map': 'Bridgit'
},
'type': 'bs_assault.AssaultGame'
}, {
'map': 'Doom Shroom',
'settings': {
'Enable Impact Bombs': 1,
'Enable Triple Bombs': False,
'Target Count': 2,
'map': 'Doom Shroom'
},
'type': 'bs_target_practice.TargetPracticeGame'
}, {
'settings': {
'Hold Time': 30,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Tip Top'
},
'type': 'bs_king_of_the_hill.KingOfTheHillGame'
}, {
'settings': {
'Epic Mode': False,
'Respawn Times': 1.0,
'Score to Win': 2,
'Time Limit': 300,
'map': 'Zigzag'
},
'type': 'bs_assault.AssaultGame'
}, {
'settings': {
'Epic Mode': False,
'Flag Idle Return Time': 30,
'Flag Touch Return Time': 0,
'Respawn Times': 1.0,
'Score to Win': 3,
'Time Limit': 300,
'map': 'Happy Thoughts'
},
'type': 'bs_capture_the_flag.CTFGame'
}, {
'settings': {
'Bomb Spawning': 1000,
'Epic Mode': True,
'Laps': 1,
'Mine Spawning': 2000,
'Time Limit': 300,
'map': 'Big G'
},
'type': 'bs_race.RaceGame'
}, {
'settings': {
'Epic Mode': False,
'Kills to Win Per Player': 5,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Monkey Face'
},
'type': 'bs_death_match.DeathMatchGame'
}, {
'settings': {
'Hold Time': 30,
'Respawn Times': 1.0,
'Time Limit': 300,
'map': 'Lake Frigid'
},
'type': 'bs_keep_away.KeepAwayGame'
}, {
'settings': {
'Epic Mode': False,
'Flag Idle Return Time': 30,
'Flag Touch Return Time': 3,
'Respawn Times': 1.0,
'Score to Win': 2,
'Time Limit': 300,
'map': 'Tip Top'
},
'type': 'bs_capture_the_flag.CTFGame'
}, {
'settings': {
'Balance Total Lives': False,
'Epic Mode': False,
'Lives Per Player': 3,
'Respawn Times': 1.0,
'Solo Mode': False,
'Time Limit': 300,
'map': 'Crag Castle'
},
'type': 'bs_elimination.EliminationGame'
}, {
'settings': {
'Epic Mode': True,
'Respawn Times': 0.25,
'Time Limit': 120,
'map': 'Zigzag'
},
'type': 'bs_conquest.ConquestGame'
}]

96
dist/ba_data/python/ba/_plugin.py vendored Normal file
View file

@ -0,0 +1,96 @@
# Released under the MIT License. See LICENSE for details.
#
"""Plugin related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
from dataclasses import dataclass
import _ba
if TYPE_CHECKING:
from typing import List, Dict
import ba
class PluginSubsystem:
"""Subsystem for plugin handling in the app.
Category: App Classes
Access the single shared instance of this class at 'ba.app.plugins'.
"""
def __init__(self) -> None:
self.potential_plugins: List[ba.PotentialPlugin] = []
self.active_plugins: Dict[str, ba.Plugin] = {}
def on_app_launch(self) -> None:
"""Should be called at app launch time."""
# Load up our plugins and go ahead and call their on_app_launch calls.
self.load_plugins()
for plugin in self.active_plugins.values():
try:
plugin.on_app_launch()
except Exception:
from ba import _error
_error.print_exception('Error in plugin on_app_launch()')
def load_plugins(self) -> None:
"""(internal)"""
from ba._general import getclass
# Note: the plugins we load is purely based on what's enabled
# in the app config. Our meta-scan gives us a list of available
# plugins, but that is only used to give the user a list of plugins
# that they can enable. (we wouldn't want to look at meta-scan here
# anyway because it may not be done yet at this point in the launch)
plugstates: Dict[str, Dict] = _ba.app.config.get('Plugins', {})
assert isinstance(plugstates, dict)
plugkeys: List[str] = sorted(key for key, val in plugstates.items()
if val.get('enabled', False))
for plugkey in plugkeys:
try:
cls = getclass(plugkey, Plugin)
except Exception as exc:
_ba.log(f"Error loading plugin class '{plugkey}': {exc}",
to_server=False)
continue
try:
plugin = cls()
assert plugkey not in self.active_plugins
self.active_plugins[plugkey] = plugin
except Exception:
from ba import _error
_error.print_exception(f'Error loading plugin: {plugkey}')
@dataclass
class PotentialPlugin:
"""Represents a ba.Plugin which can potentially be loaded.
Category: App Classes
These generally represent plugins which were detected by the
meta-tag scan. However they may also represent plugins which
were previously set to be loaded but which were unable to be
for some reason. In that case, 'available' will be set to False.
"""
display_name: ba.Lstr
class_path: str
available: bool
class Plugin:
"""A plugin to alter app behavior in some way.
Category: App Classes
Plugins are discoverable by the meta-tag system
and the user can select which ones they want to activate.
Active plugins are then called at specific times as the
app is running in order to modify its behavior in some way.
"""
def on_app_launch(self) -> None:
"""Called when the app is being launched."""

54
dist/ba_data/python/ba/_powerup.py vendored Normal file
View file

@ -0,0 +1,54 @@
# Released under the MIT License. See LICENSE for details.
#
"""Powerup related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
from dataclasses import dataclass
if TYPE_CHECKING:
from typing import Sequence, Tuple, Optional
import ba
@dataclass
class PowerupMessage:
"""A message telling an object to accept a powerup.
Category: Message Classes
This message is normally received by touching a ba.PowerupBox.
Attributes:
poweruptype
The type of powerup to be granted (a string).
See ba.Powerup.poweruptype for available type values.
sourcenode
The node the powerup game from, or None otherwise.
If a powerup is accepted, a ba.PowerupAcceptMessage should be sent
back to the sourcenode to inform it of the fact. This will generally
cause the powerup box to make a sound and disappear or whatnot.
"""
poweruptype: str
sourcenode: Optional[ba.Node] = None
@dataclass
class PowerupAcceptMessage:
"""A message informing a ba.Powerup that it was accepted.
Category: Message Classes
This is generally sent in response to a ba.PowerupMessage
to inform the box (or whoever granted it) that it can go away.
"""
def get_default_powerup_distribution() -> Sequence[Tuple[str, int]]:
"""Standard set of powerups."""
return (('triple_bombs', 3), ('ice_bombs', 3), ('punch', 3),
('impact_bombs', 3), ('land_mines', 2), ('sticky_bombs', 3),
('shield', 2), ('health', 1), ('curse', 1))

93
dist/ba_data/python/ba/_profile.py vendored Normal file
View file

@ -0,0 +1,93 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to player profiles."""
from __future__ import annotations
import random
from typing import TYPE_CHECKING
import _ba
if TYPE_CHECKING:
from typing import List, Tuple, Any, Dict, Optional
# NOTE: player color options are enforced server-side for non-pro accounts
# so don't change these or they won't stick...
PLAYER_COLORS = [(1, 0.15, 0.15), (0.2, 1, 0.2), (0.1, 0.1, 1), (0.2, 1, 1),
(0.5, 0.25, 1.0), (1, 1, 0), (1, 0.5, 0), (1, 0.3, 0.5),
(0.1, 0.1, 0.5), (0.4, 0.2, 0.1), (0.1, 0.35, 0.1),
(1, 0.8, 0.5), (0.4, 0.05, 0.05), (0.13, 0.13, 0.13),
(0.5, 0.5, 0.5), (1, 1, 1)]
def get_player_colors() -> List[Tuple[float, float, float]]:
"""Return user-selectable player colors."""
return PLAYER_COLORS
def get_player_profile_icon(profilename: str) -> str:
"""Given a profile name, returns an icon string for it.
(non-account profiles only)
"""
from ba._enums import SpecialChar
appconfig = _ba.app.config
icon: str
try:
is_global = appconfig['Player Profiles'][profilename]['global']
except KeyError:
is_global = False
if is_global:
try:
icon = appconfig['Player Profiles'][profilename]['icon']
except KeyError:
icon = _ba.charstr(SpecialChar.LOGO)
else:
icon = ''
return icon
def get_player_profile_colors(
profilename: Optional[str],
profiles: Dict[str, Dict[str, Any]] = None
) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
"""Given a profile, return colors for them."""
appconfig = _ba.app.config
if profiles is None:
profiles = appconfig['Player Profiles']
# Special case: when being asked for a random color in kiosk mode,
# always return default purple.
if (_ba.app.demo_mode or _ba.app.arcade_mode) and profilename is None:
color = (0.5, 0.4, 1.0)
highlight = (0.4, 0.4, 0.5)
else:
try:
assert profilename is not None
color = profiles[profilename]['color']
except (KeyError, AssertionError):
# Key off name if possible.
if profilename is None:
# First 6 are bright-ish.
color = PLAYER_COLORS[random.randrange(6)]
else:
# First 6 are bright-ish.
color = PLAYER_COLORS[sum([ord(c) for c in profilename]) % 6]
try:
assert profilename is not None
highlight = profiles[profilename]['highlight']
except (KeyError, AssertionError):
# Key off name if possible.
if profilename is None:
# Last 2 are grey and white; ignore those or we
# get lots of old-looking players.
highlight = PLAYER_COLORS[random.randrange(
len(PLAYER_COLORS) - 2)]
else:
highlight = PLAYER_COLORS[sum(
[ord(c) + 1
for c in profilename]) % (len(PLAYER_COLORS) - 2)]
return color, highlight

56
dist/ba_data/python/ba/_score.py vendored Normal file
View file

@ -0,0 +1,56 @@
# Released under the MIT License. See LICENSE for details.
#
"""Score related functionality."""
from __future__ import annotations
from enum import Enum, unique
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import ba
@unique
class ScoreType(Enum):
"""Type of scores.
Category: Enums
"""
SECONDS = 's'
MILLISECONDS = 'ms'
POINTS = 'p'
@dataclass
class ScoreConfig:
"""Settings for how a game handles scores.
Category: Gameplay Classes
Attributes:
label
A label show to the user for scores; 'Score', 'Time Survived', etc.
scoretype
How the score value should be displayed.
lower_is_better
Whether lower scores are preferable. Higher scores are by default.
none_is_winner
Whether a value of None is considered better than other scores.
By default it is not.
version
To change high-score lists used by a game without renaming the game,
change this. Defaults to an empty string.
"""
label: str = 'Score'
scoretype: ba.ScoreType = ScoreType.POINTS
lower_is_better: bool = False
none_is_winner: bool = False
version: str = ''

352
dist/ba_data/python/ba/_servermode.py vendored Normal file
View file

@ -0,0 +1,352 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to running the game in server-mode."""
from __future__ import annotations
import sys
import time
from typing import TYPE_CHECKING
from efro.terminal import Clr
from bacommon.servermanager import (ServerCommand, StartServerModeCommand,
ShutdownCommand, ShutdownReason,
ChatMessageCommand, ScreenMessageCommand,
ClientListCommand, KickCommand)
import _ba
from ba._enums import TimeType
from ba._freeforallsession import FreeForAllSession
from ba._dualteamsession import DualTeamSession
if TYPE_CHECKING:
from typing import Optional, Dict, Any, Type
import ba
from bacommon.servermanager import ServerConfig
def _cmd(command_data: bytes) -> None:
"""Handle commands coming in from our server manager parent process."""
import pickle
command = pickle.loads(command_data)
assert isinstance(command, ServerCommand)
if isinstance(command, StartServerModeCommand):
assert _ba.app.server is None
_ba.app.server = ServerController(command.config)
return
if isinstance(command, ShutdownCommand):
assert _ba.app.server is not None
_ba.app.server.shutdown(reason=command.reason,
immediate=command.immediate)
return
if isinstance(command, ChatMessageCommand):
assert _ba.app.server is not None
_ba.chatmessage(command.message, clients=command.clients)
return
if isinstance(command, ScreenMessageCommand):
assert _ba.app.server is not None
# Note: we have to do transient messages if
# clients is specified, so they won't show up
# in replays.
_ba.screenmessage(command.message,
color=command.color,
clients=command.clients,
transient=command.clients is not None)
return
if isinstance(command, ClientListCommand):
assert _ba.app.server is not None
_ba.app.server.print_client_list()
return
if isinstance(command, KickCommand):
assert _ba.app.server is not None
_ba.app.server.kick(client_id=command.client_id,
ban_time=command.ban_time)
return
print(f'{Clr.SRED}ERROR: server process'
f' got unknown command: {type(command)}{Clr.RST}')
class ServerController:
"""Overall controller for the app in server mode.
Category: App Classes
"""
def __init__(self, config: ServerConfig) -> None:
self._config = config
self._playlist_name = '__default__'
self._ran_access_check = False
self._prep_timer: Optional[ba.Timer] = None
self._next_stuck_login_warn_time = time.time() + 10.0
self._first_run = True
self._shutdown_reason: Optional[ShutdownReason] = None
self._executing_shutdown = False
# Make note if they want us to import a playlist;
# we'll need to do that first if so.
self._playlist_fetch_running = self._config.playlist_code is not None
self._playlist_fetch_sent_request = False
self._playlist_fetch_got_response = False
self._playlist_fetch_code = -1
# Now sit around doing any pre-launch prep such as waiting for
# account sign-in or fetching playlists; this will kick off the
# session once done.
with _ba.Context('ui'):
self._prep_timer = _ba.Timer(0.25,
self._prepare_to_serve,
timetype=TimeType.REAL,
repeat=True)
def print_client_list(self) -> None:
"""Print info about all connected clients."""
import json
roster = _ba.get_game_roster()
title1 = 'Client ID'
title2 = 'Account Name'
title3 = 'Players'
col1 = 10
col2 = 16
out = (f'{Clr.BLD}'
f'{title1:<{col1}} {title2:<{col2}} {title3}'
f'{Clr.RST}')
for client in roster:
if client['client_id'] == -1:
continue
spec = json.loads(client['spec_string'])
name = spec['n']
players = ', '.join(n['name'] for n in client['players'])
clientid = client['client_id']
out += f'\n{clientid:<{col1}} {name:<{col2}} {players}'
print(out)
def kick(self, client_id: int, ban_time: Optional[int]) -> None:
"""Kick the provided client id.
ban_time is provided in seconds.
If ban_time is None, ban duration will be determined automatically.
Pass 0 or a negative number for no ban time.
"""
# FIXME: this case should be handled under the hood.
if ban_time is None:
ban_time = 300
_ba.disconnect_client(client_id=client_id, ban_time=ban_time)
def shutdown(self, reason: ShutdownReason, immediate: bool) -> None:
"""Set the app to quit either now or at the next clean opportunity."""
self._shutdown_reason = reason
if immediate:
print(f'{Clr.SBLU}Immediate shutdown initiated.{Clr.RST}')
self._execute_shutdown()
else:
print(f'{Clr.SBLU}Shutdown initiated;'
f' server process will exit at the next clean opportunity.'
f'{Clr.RST}')
def handle_transition(self) -> bool:
"""Handle transitioning to a new ba.Session or quitting the app.
Will be called once at the end of an activity that is marked as
a good 'end-point' (such as a final score screen).
Should return True if action will be handled by us; False if the
session should just continue on it's merry way.
"""
if self._shutdown_reason is not None:
self._execute_shutdown()
return True
return False
def _execute_shutdown(self) -> None:
from ba._language import Lstr
if self._executing_shutdown:
return
self._executing_shutdown = True
timestrval = time.strftime('%c')
if self._shutdown_reason is ShutdownReason.RESTARTING:
_ba.screenmessage(Lstr(resource='internal.serverRestartingText'),
color=(1, 0.5, 0.0))
print(f'{Clr.SBLU}Exiting for server-restart'
f' at {timestrval}.{Clr.RST}')
else:
_ba.screenmessage(Lstr(resource='internal.serverShuttingDownText'),
color=(1, 0.5, 0.0))
print(f'{Clr.SBLU}Exiting for server-shutdown'
f' at {timestrval}.{Clr.RST}')
with _ba.Context('ui'):
_ba.timer(2.0, _ba.quit, timetype=TimeType.REAL)
def _run_access_check(self) -> None:
"""Check with the master server to see if we're likely joinable."""
from ba._netutils import master_server_get
master_server_get(
'bsAccessCheck',
{
'port': _ba.get_game_port(),
'b': _ba.app.build_number
},
callback=self._access_check_response,
)
def _access_check_response(self, data: Optional[Dict[str, Any]]) -> None:
import os
if data is None:
print('error on UDP port access check (internet down?)')
else:
addr = data['address']
port = data['port']
show_addr = os.environ.get('BA_ACCESS_CHECK_VERBOSE', '0') == '1'
if show_addr:
addrstr = f' {addr}'
poststr = ''
else:
addrstr = ''
poststr = (
'\nSet environment variable BA_ACCESS_CHECK_VERBOSE=1'
' for more info.')
if data['accessible']:
print(f'{Clr.SBLU}Master server access check of{addrstr}'
f' udp port {port} succeeded.\n'
f'Your server appears to be'
f' joinable from the internet.{poststr}{Clr.RST}')
else:
print(f'{Clr.SRED}Master server access check of{addrstr}'
f' udp port {port} failed.\n'
f'Your server does not appear to be'
f' joinable from the internet.{poststr}{Clr.RST}')
def _prepare_to_serve(self) -> None:
"""Run in a timer to do prep before beginning to serve."""
signed_in = _ba.get_account_state() == 'signed_in'
if not signed_in:
# Signing in to the local server account should not take long;
# complain if it does...
curtime = time.time()
if curtime > self._next_stuck_login_warn_time:
print('Still waiting for account sign-in...')
self._next_stuck_login_warn_time = curtime + 10.0
return
can_launch = False
# If we're fetching a playlist, we need to do that first.
if not self._playlist_fetch_running:
can_launch = True
else:
if not self._playlist_fetch_sent_request:
print(f'{Clr.SBLU}Requesting shared-playlist'
f' {self._config.playlist_code}...{Clr.RST}')
_ba.add_transaction(
{
'type': 'IMPORT_PLAYLIST',
'code': str(self._config.playlist_code),
'overwrite': True
},
callback=self._on_playlist_fetch_response)
_ba.run_transactions()
self._playlist_fetch_sent_request = True
if self._playlist_fetch_got_response:
self._playlist_fetch_running = False
can_launch = True
if can_launch:
self._prep_timer = None
_ba.pushcall(self._launch_server_session)
def _on_playlist_fetch_response(
self,
result: Optional[Dict[str, Any]],
) -> None:
if result is None:
print('Error fetching playlist; aborting.')
sys.exit(-1)
# Once we get here, simply modify our config to use this playlist.
typename = (
'teams' if result['playlistType'] == 'Team Tournament' else
'ffa' if result['playlistType'] == 'Free-for-All' else '??')
plistname = result['playlistName']
print(f'{Clr.SBLU}Got playlist: "{plistname}" ({typename}).{Clr.RST}')
self._playlist_fetch_got_response = True
self._config.session_type = typename
self._playlist_name = (result['playlistName'])
def _get_session_type(self) -> Type[ba.Session]:
# Convert string session type to the class.
# Hmm should we just keep this as a string?
if self._config.session_type == 'ffa':
return FreeForAllSession
if self._config.session_type == 'teams':
return DualTeamSession
raise RuntimeError(
f'Invalid session_type: "{self._config.session_type}"')
def _launch_server_session(self) -> None:
"""Kick off a host-session based on the current server config."""
app = _ba.app
appcfg = app.config
sessiontype = self._get_session_type()
if _ba.get_account_state() != 'signed_in':
print('WARNING: launch_server_session() expects to run '
'with a signed in server account')
if self._first_run:
curtimestr = time.strftime('%c')
_ba.log(
f'{Clr.BLD}{Clr.BLU}{_ba.appnameupper()} {app.version}'
f' ({app.build_number})'
f' entering server-mode {curtimestr}{Clr.RST}',
to_server=False)
if sessiontype is FreeForAllSession:
appcfg['Free-for-All Playlist Selection'] = self._playlist_name
appcfg['Free-for-All Playlist Randomize'] = (
self._config.playlist_shuffle)
elif sessiontype is DualTeamSession:
appcfg['Team Tournament Playlist Selection'] = self._playlist_name
appcfg['Team Tournament Playlist Randomize'] = (
self._config.playlist_shuffle)
else:
raise RuntimeError(f'Unknown session type {sessiontype}')
app.teams_series_length = self._config.teams_series_length
app.ffa_series_length = self._config.ffa_series_length
_ba.set_authenticate_clients(self._config.authenticate_clients)
_ba.set_enable_default_kick_voting(
self._config.enable_default_kick_voting)
_ba.set_admins(self._config.admins)
# Call set-enabled last (will push state to the cloud).
_ba.set_public_party_max_size(self._config.max_party_size)
_ba.set_public_party_name(self._config.party_name)
_ba.set_public_party_stats_url(self._config.stats_url)
_ba.set_public_party_enabled(self._config.party_is_public)
# And here.. we.. go.
if self._config.stress_test_players is not None:
# Special case: run a stress test.
from ba.internal import run_stress_test
run_stress_test(playlist_type='Random',
playlist_name='__default__',
player_count=self._config.stress_test_players,
round_duration=30)
else:
_ba.new_host_session(sessiontype)
# Run an access check if we're trying to make a public party.
if not self._ran_access_check and self._config.party_is_public:
self._run_access_check()
self._ran_access_check = True

698
dist/ba_data/python/ba/_session.py vendored Normal file
View file

@ -0,0 +1,698 @@
# Released under the MIT License. See LICENSE for details.
#
"""Defines base session class."""
from __future__ import annotations
import weakref
from typing import TYPE_CHECKING
import _ba
from ba._error import print_error, print_exception, NodeNotFoundError
from ba._language import Lstr
from ba._player import Player
if TYPE_CHECKING:
from typing import Sequence, List, Dict, Any, Optional, Set
import ba
class Session:
"""Defines a high level series of activities with a common purpose.
category: Gameplay Classes
Examples of sessions are ba.FreeForAllSession, ba.DualTeamSession, and
ba.CoopSession.
A Session is responsible for wrangling and transitioning between various
ba.Activity instances such as mini-games and score-screens, and for
maintaining state between them (players, teams, score tallies, etc).
Attributes:
sessionteams
All the ba.SessionTeams in the Session. Most things should use the
list of ba.Teams in ba.Activity; not this.
sessionplayers
All ba.SessionPlayers in the Session. Most things should use the
list of ba.Players in ba.Activity; not this. Some players, such as
those who have not yet selected a character, will only be
found on this list.
min_players
The minimum number of players who must be present for the Session
to proceed past the initial joining screen.
max_players
The maximum number of players allowed in the Session.
lobby
The ba.Lobby instance where new ba.Players go to select a
Profile/Team/etc. before being added to games.
Be aware this value may be None if a Session does not allow
any such selection.
use_teams
Whether this session groups players into an explicit set of
teams. If this is off, a unique team is generated for each
player that joins.
use_team_colors
Whether players on a team should all adopt the colors of that
team instead of their own profile colors. This only applies if
use_teams is enabled.
allow_mid_activity_joins
Whether players should be allowed to join in the middle of
activities.
customdata
A shared dictionary for objects to use as storage on this session.
Ensure that keys here are unique to avoid collisions.
"""
use_teams: bool = False
use_team_colors: bool = True
allow_mid_activity_joins: bool = True
# Note: even though these are instance vars, we annotate them at the
# class level so that docs generation can access their types.
lobby: ba.Lobby
max_players: int
min_players: int
sessionplayers: List[ba.SessionPlayer]
customdata: dict
sessionteams: List[ba.SessionTeam]
def __init__(self,
depsets: Sequence[ba.DependencySet],
team_names: Sequence[str] = None,
team_colors: Sequence[Sequence[float]] = None,
min_players: int = 1,
max_players: int = 8):
"""Instantiate a session.
depsets should be a sequence of successfully resolved ba.DependencySet
instances; one for each ba.Activity the session may potentially run.
"""
# pylint: disable=too-many-statements
# pylint: disable=too-many-locals
# pylint: disable=cyclic-import
from ba._lobby import Lobby
from ba._stats import Stats
from ba._gameactivity import GameActivity
from ba._activity import Activity
from ba._team import SessionTeam
from ba._error import DependencyError
from ba._dependency import Dependency, AssetPackage
from efro.util import empty_weakref
# First off, resolve all dependency-sets we were passed.
# If things are missing, we'll try to gather them into a single
# missing-deps exception if possible to give the caller a clean
# path to download missing stuff and try again.
missing_asset_packages: Set[str] = set()
for depset in depsets:
try:
depset.resolve()
except DependencyError as exc:
# Gather/report missing assets only; barf on anything else.
if all(issubclass(d.cls, AssetPackage) for d in exc.deps):
for dep in exc.deps:
assert isinstance(dep.config, str)
missing_asset_packages.add(dep.config)
else:
missing_info = [(d.cls, d.config) for d in exc.deps]
raise RuntimeError(
f'Missing non-asset dependencies: {missing_info}'
) from exc
# Throw a combined exception if we found anything missing.
if missing_asset_packages:
raise DependencyError([
Dependency(AssetPackage, set_id)
for set_id in missing_asset_packages
])
# Ok; looks like our dependencies check out.
# Now give the engine a list of asset-set-ids to pass along to clients.
required_asset_packages: Set[str] = set()
for depset in depsets:
required_asset_packages.update(depset.get_asset_package_ids())
# print('Would set host-session asset-reqs to:',
# required_asset_packages)
# Init our C++ layer data.
self._sessiondata = _ba.register_session(self)
# Should remove this if possible.
self.tournament_id: Optional[str] = None
self.sessionteams = []
self.sessionplayers = []
self.min_players = min_players
self.max_players = max_players
self.customdata = {}
self._in_set_activity = False
self._next_team_id = 0
self._activity_retained: Optional[ba.Activity] = None
self._launch_end_session_activity_time: Optional[float] = None
self._activity_end_timer: Optional[ba.Timer] = None
self._activity_weak = empty_weakref(Activity)
self._next_activity: Optional[ba.Activity] = None
self._wants_to_end = False
self._ending = False
self._activity_should_end_immediately = False
self._activity_should_end_immediately_results: (
Optional[ba.GameResults]) = None
self._activity_should_end_immediately_delay = 0.0
# Create static teams if we're using them.
if self.use_teams:
assert team_names is not None
assert team_colors is not None
for i, color in enumerate(team_colors):
team = SessionTeam(team_id=self._next_team_id,
name=GameActivity.get_team_display_string(
team_names[i]),
color=color)
self.sessionteams.append(team)
self._next_team_id += 1
try:
with _ba.Context(self):
self.on_team_join(team)
except Exception:
print_exception(f'Error in on_team_join for {self}.')
self.lobby = Lobby()
self.stats = Stats()
# Instantiate our session globals node which will apply its settings.
self._sessionglobalsnode = _ba.newnode('sessionglobals')
@property
def sessionglobalsnode(self) -> ba.Node:
"""The sessionglobals ba.Node for the session."""
node = self._sessionglobalsnode
if not node:
raise NodeNotFoundError()
return node
def on_player_request(self, player: ba.SessionPlayer) -> bool:
"""Called when a new ba.Player wants to join the Session.
This should return True or False to accept/reject.
"""
import privateserver as pvt
pvt.handlerequest(player)
# Limit player counts *unless* we're in a stress test.
if _ba.app.stress_test_reset_timer is None:
if len(self.sessionplayers) >= self.max_players:
# Print a rejection message *only* to the client trying to
# join (prevents spamming everyone else in the game).
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(Lstr(resource='playerLimitReachedText',
subs=[('${COUNT}',
str(self.max_players))]),
color=(0.8, 0.0, 0.0),
clients=[player.inputdevice.client_id],
transient=True)
return False
_ba.playsound(_ba.getsound('dripity'))
return True
def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None:
"""Called when a previously-accepted ba.SessionPlayer leaves."""
if sessionplayer not in self.sessionplayers:
print('ERROR: Session.on_player_leave called'
' for player not in our list.')
return
_ba.playsound(_ba.getsound('playerLeft'))
activity = self._activity_weak()
if not sessionplayer.in_game:
# Ok, the player is still in the lobby; simply remove them.
with _ba.Context(self):
try:
self.lobby.remove_chooser(sessionplayer)
except Exception:
print_exception('Error in Lobby.remove_chooser().')
else:
# Ok, they've already entered the game. Remove them from
# teams/activities/etc.
sessionteam = sessionplayer.sessionteam
assert sessionteam is not None
_ba.screenmessage(
Lstr(resource='playerLeftText',
subs=[('${PLAYER}', sessionplayer.getname(full=True))]))
# Remove them from their SessionTeam.
if sessionplayer in sessionteam.players:
sessionteam.players.remove(sessionplayer)
else:
print('SessionPlayer not found in SessionTeam'
' in on_player_leave.')
# Grab their activity-specific player instance.
player = sessionplayer.activityplayer
assert isinstance(player, (Player, type(None)))
# Remove them from any current Activity.
if activity is not None:
if player in activity.players:
activity.remove_player(sessionplayer)
else:
print('Player not found in Activity in on_player_leave.')
# If we're a non-team session, remove their team too.
if not self.use_teams:
self._remove_player_team(sessionteam, activity)
# Now remove them from the session list.
self.sessionplayers.remove(sessionplayer)
def _remove_player_team(self, sessionteam: ba.SessionTeam,
activity: Optional[ba.Activity]) -> None:
"""Remove the player-specific team in non-teams mode."""
# They should have been the only one on their team.
assert not sessionteam.players
# Remove their Team from the Activity.
if activity is not None:
if sessionteam.activityteam in activity.teams:
activity.remove_team(sessionteam)
else:
print('Team not found in Activity in on_player_leave.')
# And then from the Session.
with _ba.Context(self):
if sessionteam in self.sessionteams:
try:
self.sessionteams.remove(sessionteam)
self.on_team_leave(sessionteam)
except Exception:
print_exception(
f'Error in on_team_leave for Session {self}.')
else:
print('Team no in Session teams in on_player_leave.')
try:
sessionteam.leave()
except Exception:
print_exception(f'Error clearing sessiondata'
f' for team {sessionteam} in session {self}.')
def end(self) -> None:
"""Initiates an end to the session and a return to the main menu.
Note that this happens asynchronously, allowing the
session and its activities to shut down gracefully.
"""
self._wants_to_end = True
if self._next_activity is None:
self._launch_end_session_activity()
def _launch_end_session_activity(self) -> None:
"""(internal)"""
from ba._activitytypes import EndSessionActivity
from ba._enums import TimeType
with _ba.Context(self):
curtime = _ba.time(TimeType.REAL)
if self._ending:
# Ignore repeats unless its been a while.
assert self._launch_end_session_activity_time is not None
since_last = (curtime - self._launch_end_session_activity_time)
if since_last < 30.0:
return
print_error(
'_launch_end_session_activity called twice (since_last=' +
str(since_last) + ')')
self._launch_end_session_activity_time = curtime
self.setactivity(_ba.newactivity(EndSessionActivity))
self._wants_to_end = False
self._ending = True # Prevent further actions.
def on_team_join(self, team: ba.SessionTeam) -> None:
"""Called when a new ba.Team joins the session."""
def on_team_leave(self, team: ba.SessionTeam) -> None:
"""Called when a ba.Team is leaving the session."""
def end_activity(self, activity: ba.Activity, results: Any, delay: float,
force: bool) -> None:
"""Commence shutdown of a ba.Activity (if not already occurring).
'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.
"""
from ba._general import Call
from ba._enums import TimeType
# Only pay attention if this is coming from our current activity.
if activity is not self._activity_retained:
return
# If this activity hasn't begun yet, just set it up to end immediately
# once it does.
if not activity.has_begun():
# activity.set_immediate_end(results, delay, force)
if not self._activity_should_end_immediately or force:
self._activity_should_end_immediately = True
self._activity_should_end_immediately_results = results
self._activity_should_end_immediately_delay = delay
# The activity has already begun; get ready to end it.
else:
if (not activity.has_ended()) or force:
activity.set_has_ended(True)
# Set a timer to set in motion this activity's demise.
self._activity_end_timer = _ba.Timer(
delay,
Call(self._complete_end_activity, activity, results),
timetype=TimeType.BASE)
def handlemessage(self, msg: Any) -> Any:
"""General message handling; can be passed any message object."""
from ba._lobby import PlayerReadyMessage
from ba._messages import PlayerProfilesChangedMessage, UNHANDLED
if isinstance(msg, PlayerReadyMessage):
self._on_player_ready(msg.chooser)
elif isinstance(msg, PlayerProfilesChangedMessage):
# If we have a current activity with a lobby, ask it to reload
# profiles.
with _ba.Context(self):
self.lobby.reload_profiles()
return None
else:
return UNHANDLED
return None
class _SetActivityScopedLock:
def __init__(self, session: ba.Session) -> None:
self._session = session
if session._in_set_activity:
raise RuntimeError('Session.setactivity() called recursively.')
self._session._in_set_activity = True
def __del__(self) -> None:
self._session._in_set_activity = False
def setactivity(self, activity: ba.Activity) -> None:
"""Assign a new current ba.Activity for the session.
Note that this will not change the current context to the new
Activity's. Code must be run in the new activity's methods
(on_transition_in, etc) to get it. (so you can't do
session.setactivity(foo) and then ba.newnode() to add a node to foo)
"""
from ba._enums import TimeType
# Make sure we don't get called recursively.
_rlock = self._SetActivityScopedLock(self)
if activity.session is not _ba.getsession():
raise RuntimeError("Provided Activity's Session is not current.")
# Quietly ignore this if the whole session is going down.
if self._ending:
return
if activity is self._activity_retained:
print_error('Activity set to already-current activity.')
return
if self._next_activity is not None:
raise RuntimeError('Activity switch already in progress (to ' +
str(self._next_activity) + ')')
prev_activity = self._activity_retained
prev_globals = (prev_activity.globalsnode
if prev_activity is not None else None)
# Let the activity do its thing.
activity.transition_in(prev_globals)
self._next_activity = activity
# If we have a current activity, tell it it's transitioning out;
# the next one will become current once this one dies.
if prev_activity is not None:
prev_activity.transition_out()
# Setting this to None should free up the old activity to die,
# which will call begin_next_activity.
# We can still access our old activity through
# self._activity_weak() to keep it up to date on player
# joins/departures/etc until it dies.
self._activity_retained = None
# There's no existing activity; lets just go ahead with the begin call.
else:
self.begin_next_activity()
# We want to call destroy() for the previous activity once it should
# tear itself down, clear out any self-refs, etc. After this call
# the activity should have no refs left to it and should die (which
# will trigger the next activity to run).
if prev_activity is not None:
with _ba.Context('ui'):
_ba.timer(max(0.0, activity.transition_time),
prev_activity.expire,
timetype=TimeType.REAL)
self._in_set_activity = False
def getactivity(self) -> Optional[ba.Activity]:
"""Return the current foreground activity for this session."""
return self._activity_weak()
def get_custom_menu_entries(self) -> List[Dict[str, Any]]:
"""Subclasses can override this to provide custom menu entries.
The returned value should be a list of dicts, each containing
a 'label' and 'call' entry, with 'label' being the text for
the entry and 'call' being the callable to trigger if the entry
is pressed.
"""
return []
def _complete_end_activity(self, activity: ba.Activity,
results: Any) -> None:
# Run the subclass callback in the session context.
try:
with _ba.Context(self):
self.on_activity_end(activity, results)
except Exception:
print_exception(f'Error in on_activity_end() for session {self}'
f' activity {activity} with results {results}')
def _request_player(self, sessionplayer: ba.SessionPlayer) -> bool:
"""Called by the native layer when a player wants to join."""
# If we're ending, allow no new players.
if self._ending:
return False
# Ask the ba.Session subclass to approve/deny this request.
try:
with _ba.Context(self):
result = self.on_player_request(sessionplayer)
except Exception:
print_exception(f'Error in on_player_request for {self}')
result = False
# If they said yes, add the player to the lobby.
if result:
self.sessionplayers.append(sessionplayer)
with _ba.Context(self):
try:
self.lobby.add_chooser(sessionplayer)
except Exception:
print_exception('Error in lobby.add_chooser().')
return result
def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
"""Called when the current ba.Activity has ended.
The ba.Session should look at the results and start
another ba.Activity.
"""
def begin_next_activity(self) -> None:
"""Called once the previous activity has been totally torn down.
This means we're ready to begin the next one
"""
if self._next_activity is None:
# Should this ever happen?
print_error('begin_next_activity() called with no _next_activity')
return
# We store both a weak and a strong ref to the new activity;
# the strong is to keep it alive and the weak is so we can access
# it even after we've released the strong-ref to allow it to die.
self._activity_retained = self._next_activity
self._activity_weak = weakref.ref(self._next_activity)
self._next_activity = None
self._activity_should_end_immediately = False
# Kick out anyone loitering in the lobby.
self.lobby.remove_all_choosers_and_kick_players()
# Kick off the activity.
self._activity_retained.begin(self)
# If we want to completely end the session, we can now kick that off.
if self._wants_to_end:
self._launch_end_session_activity()
else:
# Otherwise, if the activity has already been told to end,
# do so now.
if self._activity_should_end_immediately:
self._activity_retained.end(
self._activity_should_end_immediately_results,
self._activity_should_end_immediately_delay)
def _on_player_ready(self, chooser: ba.Chooser) -> None:
"""Called when a ba.Player has checked themself ready."""
lobby = chooser.lobby
activity = self._activity_weak()
# This happens sometimes. That seems like it shouldn't be happening;
# when would we have a session and a chooser with players but no
# active activity?
if activity is None:
print('_on_player_ready called with no activity.')
return
# In joining-activities, we wait till all choosers are ready
# and then create all players at once.
if activity.is_joining_activity:
if not lobby.check_all_ready():
return
choosers = lobby.get_choosers()
min_players = self.min_players
if len(choosers) >= min_players:
for lch in lobby.get_choosers():
self._add_chosen_player(lch)
lobby.remove_all_choosers()
# Get our next activity going.
self._complete_end_activity(activity, {})
else:
_ba.screenmessage(
Lstr(resource='notEnoughPlayersText',
subs=[('${COUNT}', str(min_players))]),
color=(1, 1, 0),
)
_ba.playsound(_ba.getsound('error'))
# Otherwise just add players on the fly.
else:
self._add_chosen_player(chooser)
lobby.remove_chooser(chooser.getplayer())
def transitioning_out_activity_was_freed(
self, can_show_ad_on_death: bool) -> None:
"""(internal)"""
from ba._apputils import garbage_collect
# Since things should be generally still right now, it's a good time
# to run garbage collection to clear out any circular dependency
# loops. We keep this disabled normally to avoid non-deterministic
# hitches.
garbage_collect()
with _ba.Context(self):
if can_show_ad_on_death:
_ba.app.ads.call_after_ad(self.begin_next_activity)
else:
_ba.pushcall(self.begin_next_activity)
def _add_chosen_player(self, chooser: ba.Chooser) -> ba.SessionPlayer:
from ba._team import SessionTeam
sessionplayer = chooser.getplayer()
assert sessionplayer in self.sessionplayers, (
'SessionPlayer not found in session '
'player-list after chooser selection.')
activity = self._activity_weak()
assert activity is not None
# Reset the player's input here, as it is probably
# referencing the chooser which could inadvertently keep it alive.
sessionplayer.resetinput()
# We can pass it to the current activity if it has already begun
# (otherwise it'll get passed once begin is called).
pass_to_activity = (activity.has_begun()
and not activity.is_joining_activity)
# However, if we're not allowing mid-game joins, don't actually pass;
# just announce the arrival and say they'll partake next round.
if pass_to_activity:
if not self.allow_mid_activity_joins:
pass_to_activity = False
with _ba.Context(self):
_ba.screenmessage(
Lstr(resource='playerDelayedJoinText',
subs=[('${PLAYER}',
sessionplayer.getname(full=True))]),
color=(0, 1, 0),
)
# If we're a non-team session, each player gets their own team.
# (keeps mini-game coding simpler if we can always deal with teams).
if self.use_teams:
sessionteam = chooser.sessionteam
else:
our_team_id = self._next_team_id
self._next_team_id += 1
sessionteam = SessionTeam(
team_id=our_team_id,
color=chooser.get_color(),
name=chooser.getplayer().getname(full=True, icon=False),
)
# Add player's team to the Session.
self.sessionteams.append(sessionteam)
with _ba.Context(self):
try:
self.on_team_join(sessionteam)
except Exception:
print_exception(f'Error in on_team_join for {self}.')
# Add player's team to the Activity.
if pass_to_activity:
activity.add_team(sessionteam)
assert sessionplayer not in sessionteam.players
sessionteam.players.append(sessionplayer)
sessionplayer.setdata(team=sessionteam,
character=chooser.get_character_name(),
color=chooser.get_color(),
highlight=chooser.get_highlight())
self.stats.register_sessionplayer(sessionplayer)
if pass_to_activity:
activity.add_player(sessionplayer)
return sessionplayer

84
dist/ba_data/python/ba/_settings.py vendored Normal file
View file

@ -0,0 +1,84 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality for user-controllable settings."""
from __future__ import annotations
from typing import TYPE_CHECKING
from dataclasses import dataclass
if TYPE_CHECKING:
from typing import Any, List, Tuple
@dataclass
class Setting:
"""Defines a user-controllable setting for a game or other entity.
Category: Gameplay Classes
"""
name: str
default: Any
@dataclass
class BoolSetting(Setting):
"""A boolean game setting.
Category: Settings Classes
"""
default: bool
@dataclass
class IntSetting(Setting):
"""An integer game setting.
Category: Settings Classes
"""
default: int
min_value: int = 0
max_value: int = 9999
increment: int = 1
@dataclass
class FloatSetting(Setting):
"""A floating point game setting.
Category: Settings Classes
"""
default: float
min_value: float = 0.0
max_value: float = 9999.0
increment: float = 1.0
@dataclass
class ChoiceSetting(Setting):
"""A setting with multiple choices.
Category: Settings Classes
"""
choices: List[Tuple[str, Any]]
@dataclass
class IntChoiceSetting(ChoiceSetting):
"""An int setting with multiple choices.
Category: Settings Classes
"""
default: int
choices: List[Tuple[str, int]]
@dataclass
class FloatChoiceSetting(ChoiceSetting):
"""A float setting with multiple choices.
Category: Settings Classes
"""
default: float
choices: List[Tuple[str, float]]

470
dist/ba_data/python/ba/_stats.py vendored Normal file
View file

@ -0,0 +1,470 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to scores and statistics."""
from __future__ import annotations
import random
import weakref
from typing import TYPE_CHECKING
from dataclasses import dataclass
import _ba
from ba._error import (print_exception, print_error, SessionTeamNotFoundError,
SessionPlayerNotFoundError, NotFoundError)
if TYPE_CHECKING:
import ba
from weakref import ReferenceType
from typing import Any, Dict, Optional, Sequence, Union, Tuple
@dataclass
class PlayerScoredMessage:
"""Informs something that a ba.Player scored.
Category: Message Classes
Attributes:
score
The score value.
"""
score: int
class PlayerRecord:
"""Stats for an individual player in a ba.Stats object.
Category: Gameplay Classes
This does not necessarily correspond to a ba.Player that is
still present (stats may be retained for players that leave
mid-game)
"""
character: str
def __init__(self, name: str, name_full: str,
sessionplayer: ba.SessionPlayer, stats: ba.Stats):
self.name = name
self.name_full = name_full
self.score = 0
self.accumscore = 0
self.kill_count = 0
self.accum_kill_count = 0
self.killed_count = 0
self.accum_killed_count = 0
self._multi_kill_timer: Optional[ba.Timer] = None
self._multi_kill_count = 0
self._stats = weakref.ref(stats)
self._last_sessionplayer: Optional[ba.SessionPlayer] = None
self._sessionplayer: Optional[ba.SessionPlayer] = None
self._sessionteam: Optional[ReferenceType[ba.SessionTeam]] = None
self.streak = 0
self.associate_with_sessionplayer(sessionplayer)
@property
def team(self) -> ba.SessionTeam:
"""The ba.SessionTeam the last associated player was last on.
This can still return a valid result even if the player is gone.
Raises a ba.SessionTeamNotFoundError if the team no longer exists.
"""
assert self._sessionteam is not None
team = self._sessionteam()
if team is None:
raise SessionTeamNotFoundError()
return team
@property
def player(self) -> ba.SessionPlayer:
"""Return the instance's associated ba.SessionPlayer.
Raises a ba.SessionPlayerNotFoundError if the player
no longer exists.
"""
if not self._sessionplayer:
raise SessionPlayerNotFoundError()
return self._sessionplayer
def getname(self, full: bool = False) -> str:
"""Return the player entry's name."""
return self.name_full if full else self.name
def get_icon(self) -> Dict[str, Any]:
"""Get the icon for this instance's player."""
player = self._last_sessionplayer
assert player is not None
return player.get_icon()
def cancel_multi_kill_timer(self) -> None:
"""Cancel any multi-kill timer for this player entry."""
self._multi_kill_timer = None
def getactivity(self) -> Optional[ba.Activity]:
"""Return the ba.Activity this instance is currently associated with.
Returns None if the activity no longer exists."""
stats = self._stats()
if stats is not None:
return stats.getactivity()
return None
def associate_with_sessionplayer(self,
sessionplayer: ba.SessionPlayer) -> None:
"""Associate this entry with a ba.SessionPlayer."""
self._sessionteam = weakref.ref(sessionplayer.sessionteam)
self.character = sessionplayer.character
self._last_sessionplayer = sessionplayer
self._sessionplayer = sessionplayer
self.streak = 0
def _end_multi_kill(self) -> None:
self._multi_kill_timer = None
self._multi_kill_count = 0
def get_last_sessionplayer(self) -> ba.SessionPlayer:
"""Return the last ba.Player we were associated with."""
assert self._last_sessionplayer is not None
return self._last_sessionplayer
def submit_kill(self, showpoints: bool = True) -> None:
"""Submit a kill for this player entry."""
# FIXME Clean this up.
# pylint: disable=too-many-statements
from ba._language import Lstr
from ba._general import Call
self._multi_kill_count += 1
stats = self._stats()
assert stats
if self._multi_kill_count == 1:
score = 0
name = None
delay = 0.0
color = (0.0, 0.0, 0.0, 1.0)
scale = 1.0
sound = None
elif self._multi_kill_count == 2:
score = 20
name = Lstr(resource='twoKillText')
color = (0.1, 1.0, 0.0, 1)
scale = 1.0
delay = 0.0
sound = stats.orchestrahitsound1
elif self._multi_kill_count == 3:
score = 40
name = Lstr(resource='threeKillText')
color = (1.0, 0.7, 0.0, 1)
scale = 1.1
delay = 0.3
sound = stats.orchestrahitsound2
elif self._multi_kill_count == 4:
score = 60
name = Lstr(resource='fourKillText')
color = (1.0, 1.0, 0.0, 1)
scale = 1.2
delay = 0.6
sound = stats.orchestrahitsound3
elif self._multi_kill_count == 5:
score = 80
name = Lstr(resource='fiveKillText')
color = (1.0, 0.5, 0.0, 1)
scale = 1.3
delay = 0.9
sound = stats.orchestrahitsound4
else:
score = 100
name = Lstr(resource='multiKillText',
subs=[('${COUNT}', str(self._multi_kill_count))])
color = (1.0, 0.5, 0.0, 1)
scale = 1.3
delay = 1.0
sound = stats.orchestrahitsound4
def _apply(name2: Lstr, score2: int, showpoints2: bool,
color2: Tuple[float, float, float, float], scale2: float,
sound2: Optional[ba.Sound]) -> None:
from bastd.actor.popuptext import PopupText
# Only award this if they're still alive and we can get
# a current position for them.
our_pos: Optional[ba.Vec3] = None
if self._sessionplayer:
if self._sessionplayer.activityplayer is not None:
try:
our_pos = self._sessionplayer.activityplayer.position
except NotFoundError:
pass
if our_pos is None:
return
# Jitter position a bit since these often come in clusters.
our_pos = _ba.Vec3(our_pos[0] + (random.random() - 0.5) * 2.0,
our_pos[1] + (random.random() - 0.5) * 2.0,
our_pos[2] + (random.random() - 0.5) * 2.0)
activity = self.getactivity()
if activity is not None:
PopupText(Lstr(
value=(('+' + str(score2) + ' ') if showpoints2 else '') +
'${N}',
subs=[('${N}', name2)]),
color=color2,
scale=scale2,
position=our_pos).autoretain()
if sound2:
_ba.playsound(sound2)
self.score += score2
self.accumscore += score2
# Inform a running game of the score.
if score2 != 0 and activity is not None:
activity.handlemessage(PlayerScoredMessage(score=score2))
if name is not None:
_ba.timer(
0.3 + delay,
Call(_apply, name, score, showpoints, color, scale, sound))
# Keep the tally rollin'...
# set a timer for a bit in the future.
self._multi_kill_timer = _ba.Timer(1.0, self._end_multi_kill)
class Stats:
"""Manages scores and statistics for a ba.Session.
category: Gameplay Classes
"""
def __init__(self) -> None:
self._activity: Optional[ReferenceType[ba.Activity]] = None
self._player_records: Dict[str, PlayerRecord] = {}
self.orchestrahitsound1: Optional[ba.Sound] = None
self.orchestrahitsound2: Optional[ba.Sound] = None
self.orchestrahitsound3: Optional[ba.Sound] = None
self.orchestrahitsound4: Optional[ba.Sound] = None
def setactivity(self, activity: Optional[ba.Activity]) -> None:
"""Set the current activity for this instance."""
self._activity = None if activity is None else weakref.ref(activity)
# Load our media into this activity's context.
if activity is not None:
if activity.expired:
print_error('unexpected finalized activity')
else:
with _ba.Context(activity):
self._load_activity_media()
def getactivity(self) -> Optional[ba.Activity]:
"""Get the activity associated with this instance.
May return None.
"""
if self._activity is None:
return None
return self._activity()
def _load_activity_media(self) -> None:
self.orchestrahitsound1 = _ba.getsound('orchestraHit')
self.orchestrahitsound2 = _ba.getsound('orchestraHit2')
self.orchestrahitsound3 = _ba.getsound('orchestraHit3')
self.orchestrahitsound4 = _ba.getsound('orchestraHit4')
def reset(self) -> None:
"""Reset the stats instance completely."""
# Just to be safe, lets make sure no multi-kill timers are gonna go off
# for no-longer-on-the-list players.
for p_entry in list(self._player_records.values()):
p_entry.cancel_multi_kill_timer()
self._player_records = {}
def reset_accum(self) -> None:
"""Reset per-sound sub-scores."""
for s_player in list(self._player_records.values()):
s_player.cancel_multi_kill_timer()
s_player.accumscore = 0
s_player.accum_kill_count = 0
s_player.accum_killed_count = 0
s_player.streak = 0
def register_sessionplayer(self, player: ba.SessionPlayer) -> None:
"""Register a ba.SessionPlayer with this score-set."""
assert player.exists() # Invalid refs should never be passed to funcs.
name = player.getname()
if name in self._player_records:
# If the player already exists, update his character and such as
# it may have changed.
self._player_records[name].associate_with_sessionplayer(player)
else:
name_full = player.getname(full=True)
self._player_records[name] = PlayerRecord(name, name_full, player,
self)
def get_records(self) -> Dict[str, ba.PlayerRecord]:
"""Get PlayerRecord corresponding to still-existing players."""
records = {}
# Go through our player records and return ones whose player id still
# corresponds to a player with that name.
for record_id, record in self._player_records.items():
lastplayer = record.get_last_sessionplayer()
if lastplayer and lastplayer.getname() == record_id:
records[record_id] = record
return records
def player_scored(self,
player: ba.Player,
base_points: int = 1,
target: Sequence[float] = None,
kill: bool = False,
victim_player: ba.Player = None,
scale: float = 1.0,
color: Sequence[float] = None,
title: Union[str, ba.Lstr] = None,
screenmessage: bool = True,
display: bool = True,
importance: int = 1,
showpoints: bool = True,
big_message: bool = False) -> int:
"""Register a score for the player.
Return value is actual score with multipliers and such factored in.
"""
# FIXME: Tidy this up.
# pylint: disable=cyclic-import
# pylint: disable=too-many-branches
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
from bastd.actor.popuptext import PopupText
from ba import _math
from ba._gameactivity import GameActivity
from ba._language import Lstr
del victim_player # Currently unused.
name = player.getname()
s_player = self._player_records[name]
if kill:
s_player.submit_kill(showpoints=showpoints)
display_color: Sequence[float] = (1.0, 1.0, 1.0, 1.0)
if color is not None:
display_color = color
elif importance != 1:
display_color = (1.0, 1.0, 0.4, 1.0)
points = base_points
# If they want a big announcement, throw a zoom-text up there.
if display and big_message:
try:
assert self._activity is not None
activity = self._activity()
if isinstance(activity, GameActivity):
name_full = player.getname(full=True, icon=False)
activity.show_zoom_message(
Lstr(resource='nameScoresText',
subs=[('${NAME}', name_full)]),
color=_math.normalized_color(player.team.color))
except Exception:
print_exception('error showing big_message')
# If we currently have a actor, pop up a score over it.
if display and showpoints:
our_pos = player.node.position if player.node else None
if our_pos is not None:
if target is None:
target = our_pos
# If display-pos is *way* lower than us, raise it up
# (so we can still see scores from dudes that fell off cliffs).
display_pos = (target[0], max(target[1], our_pos[1] - 2.0),
min(target[2], our_pos[2] + 2.0))
activity = self.getactivity()
if activity is not None:
if title is not None:
sval = Lstr(value='+${A} ${B}',
subs=[('${A}', str(points)),
('${B}', title)])
else:
sval = Lstr(value='+${A}',
subs=[('${A}', str(points))])
PopupText(sval,
color=display_color,
scale=1.2 * scale,
position=display_pos).autoretain()
# Tally kills.
if kill:
s_player.accum_kill_count += 1
s_player.kill_count += 1
# Report non-kill scorings.
try:
if screenmessage and not kill:
_ba.screenmessage(Lstr(resource='nameScoresText',
subs=[('${NAME}', name)]),
top=True,
color=player.color,
image=player.get_icon())
except Exception:
print_exception('error announcing score')
s_player.score += points
s_player.accumscore += points
# Inform a running game of the score.
if points != 0:
activity = self._activity() if self._activity is not None else None
if activity is not None:
activity.handlemessage(PlayerScoredMessage(score=points))
return points
def player_was_killed(self,
player: ba.Player,
killed: bool = False,
killer: ba.Player = None) -> None:
"""Should be called when a player is killed."""
from ba._language import Lstr
name = player.getname()
prec = self._player_records[name]
prec.streak = 0
if killed:
prec.accum_killed_count += 1
prec.killed_count += 1
try:
if killed and _ba.getactivity().announce_player_deaths:
if killer is player:
_ba.screenmessage(Lstr(resource='nameSuicideText',
subs=[('${NAME}', name)]),
top=True,
color=player.color,
image=player.get_icon())
elif killer is not None:
if killer.team is player.team:
_ba.screenmessage(Lstr(resource='nameBetrayedText',
subs=[('${NAME}',
killer.getname()),
('${VICTIM}', name)]),
top=True,
color=killer.color,
image=killer.get_icon())
else:
_ba.screenmessage(Lstr(resource='nameKilledText',
subs=[('${NAME}',
killer.getname()),
('${VICTIM}', name)]),
top=True,
color=killer.color,
image=killer.get_icon())
else:
_ba.screenmessage(Lstr(resource='nameDiedText',
subs=[('${NAME}', name)]),
top=True,
color=player.color,
image=player.get_icon())
except Exception:
print_exception('error announcing kill')

510
dist/ba_data/python/ba/_store.py vendored Normal file
View file

@ -0,0 +1,510 @@
# Released under the MIT License. See LICENSE for details.
#
"""Store related functionality for classic mode."""
from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
if TYPE_CHECKING:
from typing import Type, List, Dict, Tuple, Optional, Any
import ba
def get_store_item(item: str) -> Dict[str, Any]:
"""(internal)"""
return get_store_items()[item]
def get_store_item_name_translated(item_name: str) -> ba.Lstr:
"""Return a ba.Lstr for a store item name."""
# pylint: disable=cyclic-import
from ba import _language
from ba import _map
item_info = get_store_item(item_name)
if item_name.startswith('characters.'):
return _language.Lstr(translate=('characterNames',
item_info['character']))
if item_name in ['upgrades.pro', 'pro']:
return _language.Lstr(resource='store.bombSquadProNameText',
subs=[('${APP_NAME}',
_language.Lstr(resource='titleText'))])
if item_name.startswith('maps.'):
map_type: Type[ba.Map] = item_info['map_type']
return _map.get_map_display_string(map_type.name)
if item_name.startswith('games.'):
gametype: Type[ba.GameActivity] = item_info['gametype']
return gametype.get_display_string()
if item_name.startswith('icons.'):
return _language.Lstr(resource='editProfileWindow.iconText')
raise ValueError('unrecognized item: ' + item_name)
def get_store_item_display_size(item_name: str) -> Tuple[float, float]:
"""(internal)"""
if item_name.startswith('characters.'):
return 340 * 0.6, 430 * 0.6
if item_name in ['pro', 'upgrades.pro']:
return 650 * 0.9, 500 * 0.85
if item_name.startswith('maps.'):
return 510 * 0.6, 450 * 0.6
if item_name.startswith('icons.'):
return 265 * 0.6, 250 * 0.6
return 450 * 0.6, 450 * 0.6
def get_store_items() -> Dict[str, Dict]:
"""Returns info about purchasable items.
(internal)
"""
# pylint: disable=cyclic-import
from ba._enums import SpecialChar
from bastd import maps
if _ba.app.store_items is None:
from bastd.game import ninjafight
from bastd.game import meteorshower
from bastd.game import targetpractice
from bastd.game import easteregghunt
# IMPORTANT - need to keep this synced with the master server.
# (doing so manually for now)
_ba.app.store_items = {
'characters.kronk': {
'character': 'Kronk'
},
'characters.zoe': {
'character': 'Zoe'
},
'characters.jackmorgan': {
'character': 'Jack Morgan'
},
'characters.mel': {
'character': 'Mel'
},
'characters.snakeshadow': {
'character': 'Snake Shadow'
},
'characters.bones': {
'character': 'Bones'
},
'characters.bernard': {
'character': 'Bernard',
'highlight': (0.6, 0.5, 0.8)
},
'characters.pixie': {
'character': 'Pixel'
},
'characters.wizard': {
'character': 'Grumbledorf'
},
'characters.frosty': {
'character': 'Frosty'
},
'characters.pascal': {
'character': 'Pascal'
},
'characters.cyborg': {
'character': 'B-9000'
},
'characters.agent': {
'character': 'Agent Johnson'
},
'characters.taobaomascot': {
'character': 'Taobao Mascot'
},
'characters.santa': {
'character': 'Santa Claus'
},
'characters.bunny': {
'character': 'Easter Bunny'
},
'pro': {},
'maps.lake_frigid': {
'map_type': maps.LakeFrigid
},
'games.ninja_fight': {
'gametype': ninjafight.NinjaFightGame,
'previewTex': 'courtyardPreview'
},
'games.meteor_shower': {
'gametype': meteorshower.MeteorShowerGame,
'previewTex': 'rampagePreview'
},
'games.target_practice': {
'gametype': targetpractice.TargetPracticeGame,
'previewTex': 'doomShroomPreview'
},
'games.easter_egg_hunt': {
'gametype': easteregghunt.EasterEggHuntGame,
'previewTex': 'towerDPreview'
},
'icons.flag_us': {
'icon': _ba.charstr(SpecialChar.FLAG_UNITED_STATES)
},
'icons.flag_mexico': {
'icon': _ba.charstr(SpecialChar.FLAG_MEXICO)
},
'icons.flag_germany': {
'icon': _ba.charstr(SpecialChar.FLAG_GERMANY)
},
'icons.flag_brazil': {
'icon': _ba.charstr(SpecialChar.FLAG_BRAZIL)
},
'icons.flag_russia': {
'icon': _ba.charstr(SpecialChar.FLAG_RUSSIA)
},
'icons.flag_china': {
'icon': _ba.charstr(SpecialChar.FLAG_CHINA)
},
'icons.flag_uk': {
'icon': _ba.charstr(SpecialChar.FLAG_UNITED_KINGDOM)
},
'icons.flag_canada': {
'icon': _ba.charstr(SpecialChar.FLAG_CANADA)
},
'icons.flag_india': {
'icon': _ba.charstr(SpecialChar.FLAG_INDIA)
},
'icons.flag_japan': {
'icon': _ba.charstr(SpecialChar.FLAG_JAPAN)
},
'icons.flag_france': {
'icon': _ba.charstr(SpecialChar.FLAG_FRANCE)
},
'icons.flag_indonesia': {
'icon': _ba.charstr(SpecialChar.FLAG_INDONESIA)
},
'icons.flag_italy': {
'icon': _ba.charstr(SpecialChar.FLAG_ITALY)
},
'icons.flag_south_korea': {
'icon': _ba.charstr(SpecialChar.FLAG_SOUTH_KOREA)
},
'icons.flag_netherlands': {
'icon': _ba.charstr(SpecialChar.FLAG_NETHERLANDS)
},
'icons.flag_uae': {
'icon': _ba.charstr(SpecialChar.FLAG_UNITED_ARAB_EMIRATES)
},
'icons.flag_qatar': {
'icon': _ba.charstr(SpecialChar.FLAG_QATAR)
},
'icons.flag_egypt': {
'icon': _ba.charstr(SpecialChar.FLAG_EGYPT)
},
'icons.flag_kuwait': {
'icon': _ba.charstr(SpecialChar.FLAG_KUWAIT)
},
'icons.flag_algeria': {
'icon': _ba.charstr(SpecialChar.FLAG_ALGERIA)
},
'icons.flag_saudi_arabia': {
'icon': _ba.charstr(SpecialChar.FLAG_SAUDI_ARABIA)
},
'icons.flag_malaysia': {
'icon': _ba.charstr(SpecialChar.FLAG_MALAYSIA)
},
'icons.flag_czech_republic': {
'icon': _ba.charstr(SpecialChar.FLAG_CZECH_REPUBLIC)
},
'icons.flag_australia': {
'icon': _ba.charstr(SpecialChar.FLAG_AUSTRALIA)
},
'icons.flag_singapore': {
'icon': _ba.charstr(SpecialChar.FLAG_SINGAPORE)
},
'icons.flag_iran': {
'icon': _ba.charstr(SpecialChar.FLAG_IRAN)
},
'icons.flag_poland': {
'icon': _ba.charstr(SpecialChar.FLAG_POLAND)
},
'icons.flag_argentina': {
'icon': _ba.charstr(SpecialChar.FLAG_ARGENTINA)
},
'icons.flag_philippines': {
'icon': _ba.charstr(SpecialChar.FLAG_PHILIPPINES)
},
'icons.flag_chile': {
'icon': _ba.charstr(SpecialChar.FLAG_CHILE)
},
'icons.fedora': {
'icon': _ba.charstr(SpecialChar.FEDORA)
},
'icons.hal': {
'icon': _ba.charstr(SpecialChar.HAL)
},
'icons.crown': {
'icon': _ba.charstr(SpecialChar.CROWN)
},
'icons.yinyang': {
'icon': _ba.charstr(SpecialChar.YIN_YANG)
},
'icons.eyeball': {
'icon': _ba.charstr(SpecialChar.EYE_BALL)
},
'icons.skull': {
'icon': _ba.charstr(SpecialChar.SKULL)
},
'icons.heart': {
'icon': _ba.charstr(SpecialChar.HEART)
},
'icons.dragon': {
'icon': _ba.charstr(SpecialChar.DRAGON)
},
'icons.helmet': {
'icon': _ba.charstr(SpecialChar.HELMET)
},
'icons.mushroom': {
'icon': _ba.charstr(SpecialChar.MUSHROOM)
},
'icons.ninja_star': {
'icon': _ba.charstr(SpecialChar.NINJA_STAR)
},
'icons.viking_helmet': {
'icon': _ba.charstr(SpecialChar.VIKING_HELMET)
},
'icons.moon': {
'icon': _ba.charstr(SpecialChar.MOON)
},
'icons.spider': {
'icon': _ba.charstr(SpecialChar.SPIDER)
},
'icons.fireball': {
'icon': _ba.charstr(SpecialChar.FIREBALL)
},
'icons.mikirog': {
'icon': _ba.charstr(SpecialChar.MIKIROG)
},
}
store_items = _ba.app.store_items
assert store_items is not None
return store_items
def get_store_layout() -> Dict[str, List[Dict[str, Any]]]:
"""Return what's available in the store at a given time.
Categorized by tab and by section."""
if _ba.app.store_layout is None:
_ba.app.store_layout = {
'characters': [{
'items': []
}],
'extras': [{
'items': ['pro']
}],
'maps': [{
'items': ['maps.lake_frigid']
}],
'minigames': [],
'icons': [{
'items': [
'icons.mushroom',
'icons.heart',
'icons.eyeball',
'icons.yinyang',
'icons.hal',
'icons.flag_us',
'icons.flag_mexico',
'icons.flag_germany',
'icons.flag_brazil',
'icons.flag_russia',
'icons.flag_china',
'icons.flag_uk',
'icons.flag_canada',
'icons.flag_india',
'icons.flag_japan',
'icons.flag_france',
'icons.flag_indonesia',
'icons.flag_italy',
'icons.flag_south_korea',
'icons.flag_netherlands',
'icons.flag_uae',
'icons.flag_qatar',
'icons.flag_egypt',
'icons.flag_kuwait',
'icons.flag_algeria',
'icons.flag_saudi_arabia',
'icons.flag_malaysia',
'icons.flag_czech_republic',
'icons.flag_australia',
'icons.flag_singapore',
'icons.flag_iran',
'icons.flag_poland',
'icons.flag_argentina',
'icons.flag_philippines',
'icons.flag_chile',
'icons.moon',
'icons.fedora',
'icons.spider',
'icons.ninja_star',
'icons.skull',
'icons.dragon',
'icons.viking_helmet',
'icons.fireball',
'icons.helmet',
'icons.crown',
]
}]
}
store_layout = _ba.app.store_layout
assert store_layout is not None
store_layout['characters'] = [{
'items': [
'characters.kronk', 'characters.zoe', 'characters.jackmorgan',
'characters.mel', 'characters.snakeshadow', 'characters.bones',
'characters.bernard', 'characters.agent', 'characters.frosty',
'characters.pascal', 'characters.pixie'
]
}]
store_layout['minigames'] = [{
'items': [
'games.ninja_fight', 'games.meteor_shower', 'games.target_practice'
]
}]
if _ba.get_account_misc_read_val('xmas', False):
store_layout['characters'][0]['items'].append('characters.santa')
store_layout['characters'][0]['items'].append('characters.wizard')
store_layout['characters'][0]['items'].append('characters.cyborg')
if _ba.get_account_misc_read_val('easter', False):
store_layout['characters'].append({
'title': 'store.holidaySpecialText',
'items': ['characters.bunny']
})
store_layout['minigames'].append({
'title': 'store.holidaySpecialText',
'items': ['games.easter_egg_hunt']
})
return store_layout
def get_clean_price(price_string: str) -> str:
"""(internal)"""
# I'm not brave enough to try and do any numerical
# manipulation on formatted price strings, but lets do a
# few swap-outs to tidy things up a bit.
psubs = {
'$2.99': '$3.00',
'$4.99': '$5.00',
'$9.99': '$10.00',
'$19.99': '$20.00',
'$49.99': '$50.00'
}
return psubs.get(price_string, price_string)
def get_available_purchase_count(tab: str = None) -> int:
"""(internal)"""
try:
if _ba.get_account_state() != 'signed_in':
return 0
count = 0
our_tickets = _ba.get_account_ticket_count()
store_data = get_store_layout()
if tab is not None:
tabs = [(tab, store_data[tab])]
else:
tabs = list(store_data.items())
for tab_name, tabval in tabs:
if tab_name == 'icons':
continue # too many of these; don't show..
count = _calc_count_for_tab(tabval, our_tickets, count)
return count
except Exception:
from ba import _error
_error.print_exception('error calcing available purchases')
return 0
def _calc_count_for_tab(tabval: List[Dict[str, Any]], our_tickets: int,
count: int) -> int:
for section in tabval:
for item in section['items']:
ticket_cost = _ba.get_account_misc_read_val('price.' + item, None)
if ticket_cost is not None:
if (our_tickets >= ticket_cost
and not _ba.get_purchased(item)):
count += 1
return count
def get_available_sale_time(tab: str) -> Optional[int]:
"""(internal)"""
# pylint: disable=too-many-branches
# pylint: disable=too-many-nested-blocks
# pylint: disable=too-many-locals
try:
import datetime
from ba._enums import TimeType, TimeFormat
app = _ba.app
sale_times: List[Optional[int]] = []
# Calc time for our pro sale (old special case).
if tab == 'extras':
config = app.config
if app.accounts.have_pro():
return None
# If we haven't calced/loaded start times yet.
if app.pro_sale_start_time is None:
# If we've got a time-remaining in our config, start there.
if 'PSTR' in config:
app.pro_sale_start_time = int(
_ba.time(TimeType.REAL, TimeFormat.MILLISECONDS))
app.pro_sale_start_val = config['PSTR']
else:
# We start the timer once we get the duration from
# the server.
start_duration = _ba.get_account_misc_read_val(
'proSaleDurationMinutes', None)
if start_duration is not None:
app.pro_sale_start_time = int(
_ba.time(TimeType.REAL, TimeFormat.MILLISECONDS))
app.pro_sale_start_val = (60000 * start_duration)
# If we haven't heard from the server yet, no sale..
else:
return None
assert app.pro_sale_start_val is not None
val: Optional[int] = max(
0, app.pro_sale_start_val -
(_ba.time(TimeType.REAL, TimeFormat.MILLISECONDS) -
app.pro_sale_start_time))
# Keep the value in the config up to date. I suppose we should
# write the config occasionally but it should happen often enough
# for other reasons.
config['PSTR'] = val
if val == 0:
val = None
sale_times.append(val)
# Now look for sales in this tab.
sales_raw = _ba.get_account_misc_read_val('sales', {})
store_layout = get_store_layout()
for section in store_layout[tab]:
for item in section['items']:
if item in sales_raw:
if not _ba.get_purchased(item):
to_end = ((datetime.datetime.utcfromtimestamp(
sales_raw[item]['e']) -
datetime.datetime.utcnow()).total_seconds())
if to_end > 0:
sale_times.append(int(to_end * 1000))
# Return the smallest time I guess?
sale_times_int = [t for t in sale_times if isinstance(t, int)]
return min(sale_times_int) if sale_times_int else None
except Exception:
from ba import _error
_error.print_exception('error calcing sale time')
return None

212
dist/ba_data/python/ba/_team.py vendored Normal file
View file

@ -0,0 +1,212 @@
# Released under the MIT License. See LICENSE for details.
#
"""Team related functionality."""
from __future__ import annotations
import weakref
from typing import TYPE_CHECKING, TypeVar, Generic
from ba._error import print_exception
if TYPE_CHECKING:
from weakref import ReferenceType
from typing import Dict, List, Sequence, Tuple, Union, Optional
import ba
class SessionTeam:
"""A team of one or more ba.SessionPlayers.
Category: Gameplay Classes
Note that a SessionPlayer *always* has a SessionTeam;
in some cases, such as free-for-all ba.Sessions,
each SessionTeam consists of just one SessionPlayer.
Attributes:
name
The team's name.
id
The unique numeric id of the team.
color
The team's color.
players
The list of ba.SessionPlayers on the team.
customdata
A dict for use by the current ba.Session for
storing data associated with this team.
Unlike customdata, this persists for the duration
of the session.
"""
# Annotate our attr types at the class level so they're introspectable.
name: Union[ba.Lstr, str]
color: Tuple[float, ...] # FIXME: can't we make this fixed len?
players: List[ba.SessionPlayer]
customdata: dict
id: int
def __init__(self,
team_id: int = 0,
name: Union[ba.Lstr, str] = '',
color: Sequence[float] = (1.0, 1.0, 1.0)):
"""Instantiate a ba.SessionTeam.
In most cases, all teams are provided to you by the ba.Session,
ba.Session, so calling this shouldn't be necessary.
"""
self.id = team_id
self.name = name
self.color = tuple(color)
self.players = []
self.customdata = {}
self.activityteam: Optional[Team] = None
def leave(self) -> None:
"""(internal)"""
self.customdata = {}
PlayerType = TypeVar('PlayerType', bound='ba.Player')
class Team(Generic[PlayerType]):
"""A team in a specific ba.Activity.
Category: Gameplay Classes
These correspond to ba.SessionTeam objects, but are created per activity
so that the activity can use its own custom team subclass.
"""
# Defining these types at the class level instead of in __init__ so
# that types are introspectable (these are still instance attrs).
players: List[PlayerType]
id: int
name: Union[ba.Lstr, str]
color: Tuple[float, ...] # FIXME: can't we make this fixed length?
_sessionteam: ReferenceType[SessionTeam]
_expired: bool
_postinited: bool
_customdata: dict
# NOTE: avoiding having any __init__() here since it seems to not
# get called by default if a dataclass inherits from us.
def postinit(self, sessionteam: SessionTeam) -> None:
"""Wire up a newly created SessionTeam.
(internal)
"""
# Sanity check; if a dataclass is created that inherits from us,
# it will define an equality operator by default which will break
# internal game logic. So complain loudly if we find one.
if type(self).__eq__ is not object.__eq__:
raise RuntimeError(
f'Team class {type(self)} defines an equality'
f' operator (__eq__) which will break internal'
f' logic. Please remove it.\n'
f'For dataclasses you can do "dataclass(eq=False)"'
f' in the class decorator.')
self.players = []
self._sessionteam = weakref.ref(sessionteam)
self.id = sessionteam.id
self.name = sessionteam.name
self.color = sessionteam.color
self._customdata = {}
self._expired = False
self._postinited = True
def manual_init(self, team_id: int, name: Union[ba.Lstr, str],
color: Tuple[float, ...]) -> None:
"""Manually init a team for uses such as bots."""
self.id = team_id
self.name = name
self.color = color
self._customdata = {}
self._expired = False
self._postinited = True
@property
def customdata(self) -> dict:
"""Arbitrary values associated with the team.
Though it is encouraged that most player values be properly defined
on the ba.Team subclass, it may be useful for player-agnostic
objects to store values here. This dict is cleared when the team
leaves or expires so objects stored here will be disposed of at
the expected time, unlike the Team instance itself which may
continue to be referenced after it is no longer part of the game.
"""
assert self._postinited
assert not self._expired
return self._customdata
def leave(self) -> None:
"""Called when the Team leaves a running game.
(internal)
"""
assert self._postinited
assert not self._expired
del self._customdata
del self.players
def expire(self) -> None:
"""Called when the Team is expiring (due to the Activity expiring).
(internal)
"""
assert self._postinited
assert not self._expired
self._expired = True
try:
self.on_expire()
except Exception:
print_exception(f'Error in on_expire for {self}.')
del self._customdata
del self.players
def on_expire(self) -> None:
"""Can be overridden to handle team expiration."""
@property
def sessionteam(self) -> SessionTeam:
"""Return the ba.SessionTeam corresponding to this Team.
Throws a ba.SessionTeamNotFoundError if there is none.
"""
assert self._postinited
if self._sessionteam is not None:
sessionteam = self._sessionteam()
if sessionteam is not None:
return sessionteam
from ba import _error
raise _error.SessionTeamNotFoundError()
class EmptyTeam(Team['ba.EmptyPlayer']):
"""An empty player for use by Activities that don't need to define one.
Category: Gameplay Classes
ba.Player and ba.Team are 'Generic' types, and so passing those top level
classes as type arguments when defining a ba.Activity reduces type safety.
For example, activity.teams[0].player will have type 'Any' in that case.
For that reason, it is better to pass EmptyPlayer and EmptyTeam when
defining a ba.Activity that does not need custom types of its own.
Note that EmptyPlayer defines its team type as EmptyTeam and vice versa,
so if you want to define your own class for one of them you should do so
for both.
"""

155
dist/ba_data/python/ba/_teamgame.py vendored Normal file
View file

@ -0,0 +1,155 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to team games."""
from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar
import _ba
from ba._freeforallsession import FreeForAllSession
from ba._gameactivity import GameActivity
from ba._gameresults import GameResults
from ba._dualteamsession import DualTeamSession
if TYPE_CHECKING:
from typing import Any, Dict, Type, Sequence
from bastd.actor.playerspaz import PlayerSpaz
import ba
PlayerType = TypeVar('PlayerType', bound='ba.Player')
TeamType = TypeVar('TeamType', bound='ba.Team')
class TeamGameActivity(GameActivity[PlayerType, TeamType]):
"""Base class for teams and free-for-all mode games.
Category: Gameplay Classes
(Free-for-all is essentially just a special case where every
ba.Player has their own ba.Team)
"""
@classmethod
def supports_session_type(cls, sessiontype: Type[ba.Session]) -> bool:
"""
Class method override;
returns True for ba.DualTeamSessions and ba.FreeForAllSessions;
False otherwise.
"""
return (issubclass(sessiontype, DualTeamSession)
or issubclass(sessiontype, FreeForAllSession))
def __init__(self, settings: dict):
super().__init__(settings)
# By default we don't show kill-points in free-for-all sessions.
# (there's usually some activity-specific score and we don't
# wanna confuse things)
if isinstance(self.session, FreeForAllSession):
self.show_kill_points = False
def on_transition_in(self) -> None:
# pylint: disable=cyclic-import
from ba._coopsession import CoopSession
from bastd.actor.controlsguide import ControlsGuide
super().on_transition_in()
# On the first game, show the controls UI momentarily.
# (unless we're being run in co-op mode, in which case we leave
# it up to them)
if not isinstance(self.session, CoopSession):
attrname = '_have_shown_ctrl_help_overlay'
if not getattr(self.session, attrname, False):
delay = 4.0
lifespan = 10.0
if self.slow_motion:
lifespan *= 0.3
ControlsGuide(delay=delay,
lifespan=lifespan,
scale=0.8,
position=(380, 200),
bright=True).autoretain()
setattr(self.session, attrname, True)
def on_begin(self) -> None:
super().on_begin()
try:
# Award a few achievements.
if isinstance(self.session, FreeForAllSession):
if len(self.players) >= 2:
_ba.app.ach.award_local_achievement('Free Loader')
elif isinstance(self.session, DualTeamSession):
if len(self.players) >= 4:
from ba import _achievement
_ba.app.ach.award_local_achievement('Team Player')
except Exception:
from ba import _error
_error.print_exception()
def spawn_player_spaz(self,
player: PlayerType,
position: Sequence[float] = None,
angle: float = None) -> PlayerSpaz:
"""
Method override; spawns and wires up a standard ba.PlayerSpaz for
a ba.Player.
If position or angle is not supplied, a default will be chosen based
on the ba.Player and their ba.Team.
"""
if position is None:
# In teams-mode get our team-start-location.
if isinstance(self.session, DualTeamSession):
position = (self.map.get_start_position(player.team.id))
else:
# Otherwise do free-for-all spawn locations.
position = self.map.get_ffa_start_position(self.players)
return super().spawn_player_spaz(player, position, angle)
# FIXME: need to unify these arguments with GameActivity.end()
def end( # type: ignore
self,
results: Any = None,
announce_winning_team: bool = True,
announce_delay: float = 0.1,
force: bool = False) -> None:
"""
End the game and announce the single winning team
unless 'announce_winning_team' is False.
(for results without a single most-important winner).
"""
# pylint: disable=arguments-differ
from ba._coopsession import CoopSession
from ba._multiteamsession import MultiTeamSession
from ba._general import Call
# Announce win (but only for the first finish() call)
# (also don't announce in co-op sessions; we leave that up to them).
session = self.session
if not isinstance(session, CoopSession):
do_announce = not self.has_ended()
super().end(results, delay=2.0 + announce_delay, force=force)
# Need to do this *after* end end call so that results is valid.
assert isinstance(results, GameResults)
if do_announce and isinstance(session, MultiTeamSession):
session.announce_game_results(
self,
results,
delay=announce_delay,
announce_winning_team=announce_winning_team)
# For co-op we just pass this up the chain with a delay added
# (in most cases). Team games expect a delay for the announce
# portion in teams/ffa mode so this keeps it consistent.
else:
# don't want delay on restarts..
if (isinstance(results, dict) and 'outcome' in results
and results['outcome'] == 'restart'):
delay = 0.0
else:
delay = 2.0
_ba.timer(0.1, Call(_ba.playsound, _ba.getsound('boxingBell')))
super().end(results, delay=delay, force=force)

98
dist/ba_data/python/ba/_tips.py vendored Normal file
View file

@ -0,0 +1,98 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to game tips.
These can be shown at opportune times such as between rounds."""
import random
from typing import List
import _ba
def get_next_tip() -> str:
"""Returns the next tip to be displayed."""
app = _ba.app
if not app.tips:
for tip in get_all_tips():
app.tips.insert(random.randint(0, len(app.tips)), tip)
tip = app.tips.pop()
return tip
def get_all_tips() -> List[str]:
"""Return the complete list of tips."""
tips = [
('If you are short on controllers, install the \'${REMOTE_APP_NAME}\' '
'app\non your mobile devices to use them as controllers.'),
('Create player profiles for yourself and your friends with\nyour '
'preferred names and appearances instead of using random ones.'),
('You can \'aim\' your punches by spinning left or right.\nThis is '
'useful for knocking bad guys off edges or scoring in hockey.'),
('If you pick up a curse, your only hope for survival is to\nfind a '
'health powerup in the next few seconds.'),
('A perfectly timed running-jumping-spin-punch can kill in a single '
'hit\nand earn you lifelong respect from your friends.'),
'Always remember to floss.',
'Don\'t run all the time. Really. You will fall off cliffs.',
('In Capture-the-Flag, your own flag must be at your base to score, '
'If the other\nteam is about to score, stealing their flag can be '
'a good way to stop them.'),
('If you get a sticky-bomb stuck to you, jump around and spin in '
'circles. You might\nshake the bomb off, or if nothing else your '
'last moments will be entertaining.'),
('You take damage when you whack your head on things,\nso try to not '
'whack your head on things.'),
'If you kill an enemy in one hit you get double points for it.',
('Despite their looks, all characters\' abilities are identical,\nso '
'just pick whichever one you most closely resemble.'),
'You can throw bombs higher if you jump just before throwing.',
('Throw strength is based on the direction you are holding.\nTo toss '
'something gently in front of you, don\'t hold any direction.'),
('If someone picks you up, punch them and they\'ll let go.\nThis '
'works in real life too.'),
('Don\'t get too cocky with that energy shield; you can still get '
'yourself thrown off a cliff.'),
('Many things can be picked up and thrown, including other players. '
'Tossing\nyour enemies off cliffs can be an effective and '
'emotionally fulfilling strategy.'),
('Ice bombs are not very powerful, but they freeze\nwhoever they '
'hit, leaving them vulnerable to shattering.'),
'Don\'t spin for too long; you\'ll become dizzy and fall.',
('Run back and forth before throwing a bomb\nto \'whiplash\' it '
'and throw it farther.'),
('Punches do more damage the faster your fists are moving,\nso '
'try running, jumping, and spinning like crazy.'),
'In hockey, you\'ll maintain more speed if you turn gradually.',
('The head is the most vulnerable area, so a sticky-bomb\nto the '
'noggin usually means game-over.'),
('Hold down any button to run. You\'ll get places faster\nbut '
'won\'t turn very well, so watch out for cliffs.'),
('You can judge when a bomb is going to explode based on the\n'
'color of sparks from its fuse: yellow..orange..red..BOOM.'),
]
tips += [
'If your framerate is choppy, try turning down resolution\nor '
'visuals in the game\'s graphics settings.'
]
app = _ba.app
if app.platform in ('android', 'ios') and not app.on_tv:
tips += [
('If your device gets too warm or you\'d like to conserve '
'battery power,\nturn down "Visuals" or "Resolution" '
'in Settings->Graphics'),
]
if app.platform in ['mac', 'android']:
tips += [
'Tired of the soundtrack? Replace it with your own!'
'\nSee Settings->Audio->Soundtrack'
]
# Hot-plugging is currently only on some platforms.
# FIXME: Should add a platform entry for this so don't forget to update it.
if app.platform in ['mac', 'android', 'windows']:
tips += [
'Players can join and leave in the middle of most games,\n'
'and you can also plug and unplug controllers on the fly.',
]
return tips

47
dist/ba_data/python/ba/_tournament.py vendored Normal file
View file

@ -0,0 +1,47 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to tournament play."""
from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
if TYPE_CHECKING:
from typing import Dict, List, Any
def get_tournament_prize_strings(entry: Dict[str, Any]) -> List[str]:
"""Given a tournament entry, return strings for its prize levels."""
# pylint: disable=too-many-locals
from ba._enums import SpecialChar
from ba._gameutils import get_trophy_string
range1 = entry.get('prizeRange1')
range2 = entry.get('prizeRange2')
range3 = entry.get('prizeRange3')
prize1 = entry.get('prize1')
prize2 = entry.get('prize2')
prize3 = entry.get('prize3')
trophy_type_1 = entry.get('prizeTrophy1')
trophy_type_2 = entry.get('prizeTrophy2')
trophy_type_3 = entry.get('prizeTrophy3')
out_vals = []
for rng, prize, trophy_type in ((range1, prize1, trophy_type_1),
(range2, prize2, trophy_type_2),
(range3, prize3, trophy_type_3)):
prval = ('' if rng is None else ('#' + str(rng[0])) if
(rng[0] == rng[1]) else
('#' + str(rng[0]) + '-' + str(rng[1])))
pvval = ''
if trophy_type is not None:
pvval += get_trophy_string(trophy_type)
# If we've got trophies but not for this entry, throw some space
# in to compensate so the ticket counts line up.
if prize is not None:
pvval = _ba.charstr(
SpecialChar.TICKET_BACKING) + str(prize) + pvval
out_vals.append(prval)
out_vals.append(pvval)
return out_vals

167
dist/ba_data/python/ba/_ui.py vendored Normal file
View file

@ -0,0 +1,167 @@
# Released under the MIT License. See LICENSE for details.
#
"""User interface related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
from ba._enums import UIScale
if TYPE_CHECKING:
from typing import Optional, Dict, Any, Callable, List, Type
from ba.ui import UICleanupCheck
import ba
class UISubsystem:
"""Consolidated UI functionality for the app.
Category: App Classes
To use this class, access the single instance of it at 'ba.app.ui'.
"""
def __init__(self) -> None:
env = _ba.env()
self.controller: Optional[ba.UIController] = None
self._main_menu_window: Optional[ba.Widget] = None
self._main_menu_location: Optional[str] = None
self._uiscale: ba.UIScale
interfacetype = env['ui_scale']
if interfacetype == 'large':
self._uiscale = UIScale.LARGE
elif interfacetype == 'medium':
self._uiscale = UIScale.MEDIUM
elif interfacetype == 'small':
self._uiscale = UIScale.SMALL
else:
raise RuntimeError(f'Invalid UIScale value: {interfacetype}')
self.window_states: Dict[Type, Any] = {} # FIXME: Kill this.
self.main_menu_selection: Optional[str] = None # FIXME: Kill this.
self.have_party_queue_window = False
self.quit_window: Any = None
self.dismiss_wii_remotes_window_call: (Optional[Callable[[],
Any]]) = None
self.cleanupchecks: List[UICleanupCheck] = []
self.upkeeptimer: Optional[ba.Timer] = None
self.use_toolbars = env.get('toolbar_test', True)
self.party_window: Any = None # FIXME: Don't use Any.
self.title_color = (0.72, 0.7, 0.75)
self.heading_color = (0.72, 0.7, 0.75)
self.infotextcolor = (0.7, 0.9, 0.7)
# Switch our overall game selection UI flow between Play and
# Private-party playlist selection modes; should do this in
# a more elegant way once we revamp high level UI stuff a bit.
self.selecting_private_party_playlist: bool = False
@property
def uiscale(self) -> ba.UIScale:
"""Current ui scale for the app."""
return self._uiscale
def on_app_launch(self) -> None:
"""Should be run on app launch."""
from ba.ui import UIController, ui_upkeep
from ba._enums import TimeType
# IMPORTANT: If tweaking UI stuff, make sure it behaves for small,
# medium, and large UI modes. (doesn't run off screen, etc).
# The overrides below can be used to test with different sizes.
# Generally small is used on phones, medium is used on tablets/tvs,
# and large is on desktop computers or perhaps large tablets. When
# possible, run in windowed mode and resize the window to assure
# this holds true at all aspect ratios.
# UPDATE: A better way to test this is now by setting the environment
# variable BA_UI_SCALE to "small", "medium", or "large".
# This will affect system UIs not covered by the values below such
# as screen-messages. The below values remain functional, however,
# for cases such as Android where environment variables can't be set
# easily.
if bool(False): # force-test ui scale
self._uiscale = UIScale.SMALL
with _ba.Context('ui'):
_ba.pushcall(lambda: _ba.screenmessage(
f'FORCING UISCALE {self._uiscale.name} FOR TESTING',
color=(1, 0, 1),
log=True))
self.controller = UIController()
# Kick off our periodic UI upkeep.
# FIXME: Can probably kill this if we do immediate UI death checks.
self.upkeeptimer = _ba.Timer(2.6543,
ui_upkeep,
timetype=TimeType.REAL,
repeat=True)
def set_main_menu_window(self, window: ba.Widget) -> None:
"""Set the current 'main' window, replacing any existing."""
existing = self._main_menu_window
from ba._enums import TimeType
from inspect import currentframe, getframeinfo
# Let's grab the location where we were called from to report
# if we have to force-kill the existing window (which normally
# should not happen).
frameline = None
try:
frame = currentframe()
if frame is not None:
frame = frame.f_back
if frame is not None:
frameinfo = getframeinfo(frame)
frameline = f'{frameinfo.filename} {frameinfo.lineno}'
except Exception:
from ba._error import print_exception
print_exception('Error calcing line for set_main_menu_window')
# With our legacy main-menu system, the caller is responsible for
# clearing out the old main menu window when assigning the new.
# However there are corner cases where that doesn't happen and we get
# old windows stuck under the new main one. So let's guard against
# that. However, we can't simply delete the existing main window when
# a new one is assigned because the user may transition the old out
# *after* the assignment. Sigh. So, as a happy medium, let's check in
# on the old after a short bit of time and kill it if its still alive.
# That will be a bit ugly on screen but at least should un-break
# things.
def _delay_kill() -> None:
import time
if existing:
print(f'Killing old main_menu_window'
f' when called at: {frameline} t={time.time():.3f}')
existing.delete()
_ba.timer(1.0, _delay_kill, timetype=TimeType.REAL)
self._main_menu_window = window
def clear_main_menu_window(self, transition: str = None) -> None:
"""Clear any existing 'main' window with the provided transition."""
if self._main_menu_window:
if transition is not None:
_ba.containerwidget(edit=self._main_menu_window,
transition=transition)
else:
self._main_menu_window.delete()
def has_main_menu_window(self) -> bool:
"""Return whether a main menu window is present."""
return bool(self._main_menu_window)
def set_main_menu_location(self, location: str) -> None:
"""Set the location represented by the current main menu window."""
self._main_menu_location = location
def get_main_menu_location(self) -> Optional[str]:
"""Return the current named main menu location, if any."""
return self._main_menu_location

8
dist/ba_data/python/ba/deprecated.py vendored Normal file
View file

@ -0,0 +1,8 @@
# Released under the MIT License. See LICENSE for details.
#
"""Deprecated functionality.
Classes or functions can be relocated here when they are deprecated.
Any code using them should migrate to alternative methods, as
deprecated items will eventually be fully removed.
"""

41
dist/ba_data/python/ba/internal.py vendored Normal file
View file

@ -0,0 +1,41 @@
# Released under the MIT License. See LICENSE for details.
#
"""Exposed functionality not intended for full public use.
Classes and functions contained here, while technically 'public', may change
or disappear without warning, so should be avoided (or used sparingly and
defensively) in mods.
"""
# pylint: disable=unused-import
from ba._map import (get_unowned_maps, get_map_class, register_map,
preload_map_preview_media, get_map_display_string,
get_filtered_map_name)
from ba._appconfig import commit_app_config
from ba._input import (get_device_value, get_input_map_hash,
get_input_device_config)
from ba._general import getclass, json_prep, get_type_name
from ba._activitytypes import JoinActivity, ScoreScreenActivity
from ba._apputils import (is_browser_likely_available, get_remote_app_name,
should_submit_debug_info)
from ba._benchmark import (run_gpu_benchmark, run_cpu_benchmark,
run_media_reload_benchmark, run_stress_test)
from ba._campaign import getcampaign
from ba._messages import PlayerProfilesChangedMessage
from ba._multiteamsession import DEFAULT_TEAM_COLORS, DEFAULT_TEAM_NAMES
from ba._music import do_play_music
from ba._netutils import (master_server_get, master_server_post,
get_ip_address_type)
from ba._powerup import get_default_powerup_distribution
from ba._profile import (get_player_profile_colors, get_player_profile_icon,
get_player_colors)
from ba._tips import get_next_tip
from ba._playlist import (get_default_free_for_all_playlist,
get_default_teams_playlist, filter_playlist)
from ba._store import (get_available_sale_time, get_available_purchase_count,
get_store_item_name_translated,
get_store_item_display_size, get_store_layout,
get_store_item, get_clean_price)
from ba._tournament import get_tournament_prize_strings
from ba._gameutils import get_trophy_string

232
dist/ba_data/python/ba/macmusicapp.py vendored Normal file
View file

@ -0,0 +1,232 @@
# Released under the MIT License. See LICENSE for details.
#
"""Music playback functionality using the Mac Music (formerly iTunes) app."""
from __future__ import annotations
import threading
from typing import TYPE_CHECKING
import _ba
from ba._music import MusicPlayer
if TYPE_CHECKING:
from typing import List, Optional, Callable, Any
class MacMusicAppMusicPlayer(MusicPlayer):
"""A music-player that utilizes the macOS Music.app for playback.
Allows selecting playlists as entries.
"""
def __init__(self) -> None:
super().__init__()
self._thread = _MacMusicAppThread()
self._thread.start()
def on_select_entry(self, callback: Callable[[Any], None],
current_entry: Any, selection_target_name: str) -> Any:
# pylint: disable=cyclic-import
from bastd.ui.soundtrack import entrytypeselect as etsel
return etsel.SoundtrackEntryTypeSelectWindow(callback, current_entry,
selection_target_name)
def on_set_volume(self, volume: float) -> None:
self._thread.set_volume(volume)
def get_playlists(self, callback: Callable) -> None:
"""Asynchronously fetch the list of available iTunes playlists."""
self._thread.get_playlists(callback)
def on_play(self, entry: Any) -> None:
music = _ba.app.music
entry_type = music.get_soundtrack_entry_type(entry)
if entry_type == 'iTunesPlaylist':
self._thread.play_playlist(music.get_soundtrack_entry_name(entry))
else:
print('MacMusicAppMusicPlayer passed unrecognized entry type:',
entry_type)
def on_stop(self) -> None:
self._thread.play_playlist(None)
def on_app_shutdown(self) -> None:
self._thread.shutdown()
class _MacMusicAppThread(threading.Thread):
"""Thread which wrangles Music.app playback"""
def __init__(self) -> None:
super().__init__()
self._commands_available = threading.Event()
self._commands: List[List] = []
self._volume = 1.0
self._current_playlist: Optional[str] = None
self._orig_volume: Optional[int] = None
def run(self) -> None:
"""Run the Music.app thread."""
from ba._general import Call
from ba._language import Lstr
from ba._enums import TimeType
_ba.set_thread_name('BA_MacMusicAppThread')
_ba.mac_music_app_init()
# Let's mention to the user we're launching Music.app in case
# it causes any funny business (this used to background the app
# sometimes, though I think that is fixed now)
def do_print() -> None:
_ba.timer(1.0,
Call(_ba.screenmessage, Lstr(resource='usingItunesText'),
(0, 1, 0)),
timetype=TimeType.REAL)
_ba.pushcall(do_print, from_other_thread=True)
# Here we grab this to force the actual launch.
_ba.mac_music_app_get_volume()
_ba.mac_music_app_get_library_source()
done = False
while not done:
self._commands_available.wait()
self._commands_available.clear()
# We're not protecting this list with a mutex but we're
# just using it as a simple queue so it should be fine.
while self._commands:
cmd = self._commands.pop(0)
if cmd[0] == 'DIE':
self._handle_die_command()
done = True
break
if cmd[0] == 'PLAY':
self._handle_play_command(target=cmd[1])
elif cmd[0] == 'GET_PLAYLISTS':
self._handle_get_playlists_command(target=cmd[1])
del cmd # Allows the command data/callback/etc to be freed.
def set_volume(self, volume: float) -> None:
"""Set volume to a value between 0 and 1."""
old_volume = self._volume
self._volume = volume
# If we've got nothing we're supposed to be playing,
# don't touch itunes/music.
if self._current_playlist is None:
return
# If volume is going to zero, stop actually playing
# but don't clear playlist.
if old_volume > 0.0 and volume == 0.0:
try:
assert self._orig_volume is not None
_ba.mac_music_app_stop()
_ba.mac_music_app_set_volume(self._orig_volume)
except Exception as exc:
print('Error stopping iTunes music:', exc)
elif self._volume > 0:
# If volume was zero, store pre-playing volume and start
# playing.
if old_volume == 0.0:
self._orig_volume = _ba.mac_music_app_get_volume()
self._update_mac_music_app_volume()
if old_volume == 0.0:
self._play_current_playlist()
def play_playlist(self, musictype: Optional[str]) -> None:
"""Play the given playlist."""
self._commands.append(['PLAY', musictype])
self._commands_available.set()
def shutdown(self) -> None:
"""Request that the player shuts down."""
self._commands.append(['DIE'])
self._commands_available.set()
self.join()
def get_playlists(self, callback: Callable[[Any], None]) -> None:
"""Request the list of playlists."""
self._commands.append(['GET_PLAYLISTS', callback])
self._commands_available.set()
def _handle_get_playlists_command(
self, target: Callable[[List[str]], None]) -> None:
from ba._general import Call
try:
playlists = _ba.mac_music_app_get_playlists()
playlists = [
p for p in playlists if p not in [
'Music', 'Movies', 'TV Shows', 'Podcasts', 'iTunes\xa0U',
'Books', 'Genius', 'iTunes DJ', 'Music Videos',
'Home Videos', 'Voice Memos', 'Audiobooks'
]
]
playlists.sort(key=lambda x: x.lower())
except Exception as exc:
print('Error getting iTunes playlists:', exc)
playlists = []
_ba.pushcall(Call(target, playlists), from_other_thread=True)
def _handle_play_command(self, target: Optional[str]) -> None:
if target is None:
if self._current_playlist is not None and self._volume > 0:
try:
assert self._orig_volume is not None
_ba.mac_music_app_stop()
_ba.mac_music_app_set_volume(self._orig_volume)
except Exception as exc:
print('Error stopping iTunes music:', exc)
self._current_playlist = None
else:
# If we've got something playing with positive
# volume, stop it.
if self._current_playlist is not None and self._volume > 0:
try:
assert self._orig_volume is not None
_ba.mac_music_app_stop()
_ba.mac_music_app_set_volume(self._orig_volume)
except Exception as exc:
print('Error stopping iTunes music:', exc)
# Set our playlist and play it if our volume is up.
self._current_playlist = target
if self._volume > 0:
self._orig_volume = (_ba.mac_music_app_get_volume())
self._update_mac_music_app_volume()
self._play_current_playlist()
def _handle_die_command(self) -> None:
# Only stop if we've actually played something
# (we don't want to kill music the user has playing).
if self._current_playlist is not None and self._volume > 0:
try:
assert self._orig_volume is not None
_ba.mac_music_app_stop()
_ba.mac_music_app_set_volume(self._orig_volume)
except Exception as exc:
print('Error stopping iTunes music:', exc)
def _play_current_playlist(self) -> None:
try:
from ba._general import Call
assert self._current_playlist is not None
if _ba.mac_music_app_play_playlist(self._current_playlist):
pass
else:
_ba.pushcall(Call(
_ba.screenmessage,
_ba.app.lang.get_resource('playlistNotFoundText') +
': \'' + self._current_playlist + '\'', (1, 0, 0)),
from_other_thread=True)
except Exception:
from ba import _error
_error.print_exception(
f'error playing playlist {self._current_playlist}')
def _update_mac_music_app_volume(self) -> None:
_ba.mac_music_app_set_volume(
max(0, min(100, int(100.0 * self._volume))))

151
dist/ba_data/python/ba/modutils.py vendored Normal file
View file

@ -0,0 +1,151 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to modding."""
from __future__ import annotations
from typing import TYPE_CHECKING
import os
import _ba
if TYPE_CHECKING:
from typing import Optional, List, Sequence
def get_human_readable_user_scripts_path() -> str:
"""Return a human readable location of user-scripts.
This is NOT a valid filesystem path; may be something like "(SD Card)".
"""
from ba import _language
app = _ba.app
path: Optional[str] = app.python_directory_user
if path is None:
return '<Not Available>'
# On newer versions of android, the user's external storage dir is probably
# only visible to the user's processes and thus not really useful printed
# in its entirety; lets print it as <External Storage>/myfilepath.
if app.platform == 'android':
ext_storage_path: Optional[str] = (
_ba.android_get_external_storage_path())
if (ext_storage_path is not None
and app.python_directory_user.startswith(ext_storage_path)):
path = ('<' +
_language.Lstr(resource='externalStorageText').evaluate() +
'>' + app.python_directory_user[len(ext_storage_path):])
return path
def _request_storage_permission() -> bool:
"""If needed, requests storage permission from the user (& return true)."""
from ba._language import Lstr
from ba._enums import Permission
if not _ba.have_permission(Permission.STORAGE):
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(Lstr(resource='storagePermissionAccessText'),
color=(1, 0, 0))
_ba.timer(1.0, lambda: _ba.request_permission(Permission.STORAGE))
return True
return False
def show_user_scripts() -> None:
"""Open or nicely print the location of the user-scripts directory."""
app = _ba.app
# First off, if we need permission for this, ask for it.
if _request_storage_permission():
return
# Secondly, if the dir doesn't exist, attempt to make it.
if not os.path.exists(app.python_directory_user):
os.makedirs(app.python_directory_user)
# On android, attempt to write a file in their user-scripts dir telling
# them about modding. This also has the side-effect of allowing us to
# media-scan that dir so it shows up in android-file-transfer, since it
# doesn't seem like there's a way to inform the media scanner of an empty
# directory, which means they would have to reboot their device before
# they can see it.
if app.platform == 'android':
try:
usd: Optional[str] = app.python_directory_user
if usd is not None and os.path.isdir(usd):
file_name = usd + '/about_this_folder.txt'
with open(file_name, 'w') as outfile:
outfile.write('You can drop files in here to mod the game.'
' See settings/advanced'
' in the game for more info.')
_ba.android_media_scan_file(file_name)
except Exception:
from ba import _error
_error.print_exception('error writing about_this_folder stuff')
# On a few platforms we try to open the dir in the UI.
if app.platform in ['mac', 'windows']:
_ba.open_dir_externally(app.python_directory_user)
# Otherwise we just print a pretty version of it.
else:
_ba.screenmessage(get_human_readable_user_scripts_path())
def create_user_system_scripts() -> None:
"""Set up a copy of Ballistica system scripts under your user scripts dir.
(for editing and experiment with)
"""
import shutil
app = _ba.app
# First off, if we need permission for this, ask for it.
if _request_storage_permission():
return
path = (app.python_directory_user + '/sys/' + app.version)
pathtmp = path + '_tmp'
if os.path.exists(path):
shutil.rmtree(path)
if os.path.exists(pathtmp):
shutil.rmtree(pathtmp)
def _ignore_filter(src: str, names: Sequence[str]) -> Sequence[str]:
del src, names # Unused
# We simply skip all __pycache__ directories. (the user would have
# to blow them away anyway to make changes;
# See https://github.com/efroemling/ballistica/wiki
# /Knowledge-Nuggets#python-cache-files-gotcha
return ('__pycache__', )
print(f'COPYING "{app.python_directory_app}" -> "{pathtmp}".')
shutil.copytree(app.python_directory_app, pathtmp, ignore=_ignore_filter)
print(f'MOVING "{pathtmp}" -> "{path}".')
shutil.move(pathtmp, path)
print(f"Created system scripts at :'{path}"
f"'\nRestart {_ba.appname()} to use them."
f' (use ba.quit() to exit the game)')
if app.platform == 'android':
print('Note: the new files may not be visible via '
'android-file-transfer until you restart your device.')
def delete_user_system_scripts() -> None:
"""Clean out the scripts created by create_user_system_scripts()."""
import shutil
app = _ba.app
path = (app.python_directory_user + '/sys/' + app.version)
if os.path.exists(path):
shutil.rmtree(path)
print(f'User system scripts deleted.\n'
f'Restart {_ba.appname()} to use internal'
f' scripts. (use ba.quit() to exit the game)')
else:
print('User system scripts not found.')
# If the sys path is empty, kill it.
dpath = app.python_directory_user + '/sys'
if os.path.isdir(dpath) and not os.listdir(dpath):
os.rmdir(dpath)

135
dist/ba_data/python/ba/osmusic.py vendored Normal file
View file

@ -0,0 +1,135 @@
# Released under the MIT License. See LICENSE for details.
#
"""Music playback using OS functionality exposed through the C++ layer."""
from __future__ import annotations
import os
import random
import threading
from typing import TYPE_CHECKING
import _ba
from ba._music import MusicPlayer
if TYPE_CHECKING:
from typing import Callable, Any, Union, List, Optional
class OSMusicPlayer(MusicPlayer):
"""Music player that talks to internal C++ layer for functionality.
(internal)"""
def __init__(self) -> None:
super().__init__()
self._want_to_play = False
self._actually_playing = False
@classmethod
def get_valid_music_file_extensions(cls) -> List[str]:
"""Return file extensions for types playable on this device."""
# FIXME: should ask the C++ layer for these; just hard-coding for now.
return ['mp3', 'ogg', 'm4a', 'wav', 'flac', 'mid']
def on_select_entry(self, callback: Callable[[Any], None],
current_entry: Any, selection_target_name: str) -> Any:
# pylint: disable=cyclic-import
from bastd.ui.soundtrack.entrytypeselect import (
SoundtrackEntryTypeSelectWindow)
return SoundtrackEntryTypeSelectWindow(callback, current_entry,
selection_target_name)
def on_set_volume(self, volume: float) -> None:
_ba.music_player_set_volume(volume)
def on_play(self, entry: Any) -> None:
music = _ba.app.music
entry_type = music.get_soundtrack_entry_type(entry)
name = music.get_soundtrack_entry_name(entry)
assert name is not None
if entry_type == 'musicFile':
self._want_to_play = self._actually_playing = True
_ba.music_player_play(name)
elif entry_type == 'musicFolder':
# Launch a thread to scan this folder and give us a random
# valid file within it.
self._want_to_play = True
self._actually_playing = False
_PickFolderSongThread(name, self.get_valid_music_file_extensions(),
self._on_play_folder_cb).start()
def _on_play_folder_cb(self,
result: Union[str, List[str]],
error: Optional[str] = None) -> None:
from ba import _language
if error is not None:
rstr = (_language.Lstr(
resource='internal.errorPlayingMusicText').evaluate())
if isinstance(result, str):
err_str = (rstr.replace('${MUSIC}', os.path.basename(result)) +
'; ' + str(error))
else:
err_str = (rstr.replace('${MUSIC}', '<multiple>') + '; ' +
str(error))
_ba.screenmessage(err_str, color=(1, 0, 0))
return
# There's a chance a stop could have been issued before our thread
# returned. If that's the case, don't play.
if not self._want_to_play:
print('_on_play_folder_cb called with _want_to_play False')
else:
self._actually_playing = True
_ba.music_player_play(result)
def on_stop(self) -> None:
self._want_to_play = False
self._actually_playing = False
_ba.music_player_stop()
def on_app_shutdown(self) -> None:
_ba.music_player_shutdown()
class _PickFolderSongThread(threading.Thread):
def __init__(self, path: str, valid_extensions: List[str],
callback: Callable[[Union[str, List[str]], Optional[str]],
None]):
super().__init__()
self._valid_extensions = valid_extensions
self._callback = callback
self._path = path
def run(self) -> None:
from ba import _language
from ba._general import Call
do_print_error = True
try:
_ba.set_thread_name('BA_PickFolderSongThread')
all_files: List[str] = []
valid_extensions = ['.' + x for x in self._valid_extensions]
for root, _subdirs, filenames in os.walk(self._path):
for fname in filenames:
if any(fname.lower().endswith(ext)
for ext in valid_extensions):
all_files.insert(random.randrange(len(all_files) + 1),
root + '/' + fname)
if not all_files:
do_print_error = False
raise RuntimeError(
_language.Lstr(resource='internal.noMusicFilesInFolderText'
).evaluate())
_ba.pushcall(Call(self._callback, all_files, None),
from_other_thread=True)
except Exception as exc:
from ba import _error
if do_print_error:
_error.print_exception()
try:
err_str = str(exc)
except Exception:
err_str = '<ENCERR4523>'
_ba.pushcall(Call(self._callback, self._path, err_str),
from_other_thread=True)

229
dist/ba_data/python/ba/ui/__init__.py vendored Normal file
View file

@ -0,0 +1,229 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provide top level UI related functionality."""
from __future__ import annotations
import os
import weakref
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast, Type
import _ba
from ba._enums import TimeType
from ba._general import print_active_refs
if TYPE_CHECKING:
from typing import Optional, List, Any
from weakref import ReferenceType
import ba
# Set environment variable BA_DEBUG_UI_CLEANUP_CHECKS to 1
# to print detailed info about what is getting cleaned up when.
DEBUG_UI_CLEANUP_CHECKS = os.environ.get('BA_DEBUG_UI_CLEANUP_CHECKS') == '1'
class Window:
"""A basic window.
Category: User Interface Classes
"""
def __init__(self, root_widget: ba.Widget, cleanupcheck: bool = True):
self._root_widget = root_widget
# Complain if we outlive our root widget.
if cleanupcheck:
uicleanupcheck(self, root_widget)
def get_root_widget(self) -> ba.Widget:
"""Return the root widget."""
return self._root_widget
@dataclass
class UICleanupCheck:
"""Holds info about a uicleanupcheck target."""
obj: ReferenceType
widget: ba.Widget
widget_death_time: Optional[float]
class UILocation:
"""Defines a specific 'place' in the UI the user can navigate to.
Category: User Interface Classes
"""
def __init__(self) -> None:
pass
def save_state(self) -> None:
"""Serialize this instance's state to a dict."""
def restore_state(self) -> None:
"""Restore this instance's state from a dict."""
def push_location(self, location: str) -> None:
"""Push a new location to the stack and transition to it."""
class UILocationWindow(UILocation):
"""A UILocation consisting of a single root window widget.
Category: User Interface Classes
"""
def __init__(self) -> None:
super().__init__()
self._root_widget: Optional[ba.Widget] = None
def get_root_widget(self) -> ba.Widget:
"""Return the root widget for this window."""
assert self._root_widget is not None
return self._root_widget
class UIEntry:
"""State for a UILocation on the stack."""
def __init__(self, name: str, controller: UIController):
self._name = name
self._state = None
self._args = None
self._instance: Optional[UILocation] = None
self._controller = weakref.ref(controller)
def create(self) -> None:
"""Create an instance of our UI."""
cls = self._get_class()
self._instance = cls()
def destroy(self) -> None:
"""Transition out our UI if it exists."""
if self._instance is None:
return
print('WOULD TRANSITION OUT', self._name)
def _get_class(self) -> Type[UILocation]:
"""Returns the UI class our name points to."""
# pylint: disable=cyclic-import
# TEMP HARD CODED - WILL REPLACE THIS WITH BA_META LOOKUPS.
if self._name == 'mainmenu':
from bastd.ui import mainmenu
return cast(Type[UILocation], mainmenu.MainMenuWindow)
raise ValueError('unknown ui class ' + str(self._name))
class UIController:
"""Wrangles ba.UILocations.
Category: User Interface Classes
"""
def __init__(self) -> None:
# FIXME: document why we have separate stacks for game and menu...
self._main_stack_game: List[UIEntry] = []
self._main_stack_menu: List[UIEntry] = []
# This points at either the game or menu stack.
self._main_stack: Optional[List[UIEntry]] = None
# There's only one of these since we don't need to preserve its state
# between sessions.
self._dialog_stack: List[UIEntry] = []
def show_main_menu(self, in_game: bool = True) -> None:
"""Show the main menu, clearing other UIs from location stacks."""
self._main_stack = []
self._dialog_stack = []
self._main_stack = (self._main_stack_game
if in_game else self._main_stack_menu)
self._main_stack.append(UIEntry('mainmenu', self))
self._update_ui()
def _update_ui(self) -> None:
"""Instantiate the topmost ui in our stacks."""
# First tell any existing UIs to get outta here.
for stack in (self._dialog_stack, self._main_stack):
assert stack is not None
for entry in stack:
entry.destroy()
# Now create the topmost one if there is one.
entrynew = (self._dialog_stack[-1] if self._dialog_stack else
self._main_stack[-1] if self._main_stack else None)
if entrynew is not None:
entrynew.create()
def uicleanupcheck(obj: Any, widget: ba.Widget) -> None:
"""Add a check to ensure a widget-owning object gets cleaned up properly.
Category: User Interface Functions
This adds a check which will print an error message if the provided
object still exists ~5 seconds after the provided ba.Widget dies.
This is a good sanity check for any sort of object that wraps or
controls a ba.Widget. For instance, a 'Window' class instance has
no reason to still exist once its root container ba.Widget has fully
transitioned out and been destroyed. Circular references or careless
strong referencing can lead to such objects never getting destroyed,
however, and this helps detect such cases to avoid memory leaks.
"""
if DEBUG_UI_CLEANUP_CHECKS:
print(f'adding uicleanup to {obj}')
if not isinstance(widget, _ba.Widget):
raise TypeError('widget arg is not a ba.Widget')
if bool(False):
def foobar() -> None:
"""Just testing."""
if DEBUG_UI_CLEANUP_CHECKS:
print('uicleanupcheck widget dying...')
widget.add_delete_callback(foobar)
_ba.app.ui.cleanupchecks.append(
UICleanupCheck(obj=weakref.ref(obj),
widget=widget,
widget_death_time=None))
def ui_upkeep() -> None:
"""Run UI cleanup checks, etc. should be called periodically."""
ui = _ba.app.ui
remainingchecks = []
now = _ba.time(TimeType.REAL)
for check in ui.cleanupchecks:
obj = check.obj()
# If the object has died, ignore and don't re-add.
if obj is None:
if DEBUG_UI_CLEANUP_CHECKS:
print('uicleanupcheck object is dead; hooray!')
continue
# If the widget hadn't died yet, note if it has.
if check.widget_death_time is None:
remainingchecks.append(check)
if not check.widget:
check.widget_death_time = now
else:
# Widget was already dead; complain if its been too long.
if now - check.widget_death_time > 5.0:
print(
'WARNING:', obj,
'is still alive 5 second after its widget died;'
' you might have a memory leak.')
print_active_refs(obj)
else:
remainingchecks.append(check)
ui.cleanupchecks = remainingchecks

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,3 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality and data common to ballistica client and server components."""

Binary file not shown.

Binary file not shown.

58
dist/ba_data/python/bacommon/assets.py vendored Normal file
View file

@ -0,0 +1,58 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to cloud based assets."""
from __future__ import annotations
from typing import TYPE_CHECKING
from enum import Enum
from efro import entity
if TYPE_CHECKING:
pass
class AssetPackageFlavor(Enum):
"""Flavors for asset package outputs for different platforms/etc."""
# DXT3/DXT5 textures
DESKTOP = 'desktop'
# ASTC textures
MOBILE = 'mobile'
class AssetType(Enum):
"""Types for individual assets within a package."""
TEXTURE = 'texture'
CUBE_TEXTURE = 'cube_texture'
SOUND = 'sound'
DATA = 'data'
MESH = 'mesh'
COLLISION_MESH = 'collision_mesh'
class AssetPackageFlavorManifestValue(entity.CompoundValue):
"""A manifest of asset info for a specific flavor of an asset package."""
assetfiles = entity.DictField('assetfiles', str, entity.StringValue())
class AssetPackageFlavorManifest(entity.EntityMixin,
AssetPackageFlavorManifestValue):
"""A self contained AssetPackageFlavorManifestValue."""
class AssetPackageBuildState(entity.Entity):
"""Contains info about an in-progress asset cloud build."""
# Asset names still being built.
in_progress_builds = entity.ListField('b', entity.StringValue())
# The initial number of assets needing to be built.
initial_build_count = entity.Field('c', entity.IntValue())
# Build error string. If this is present, it should be presented
# to the user and they should required to explicitly restart the build
# in some way if desired.
error = entity.Field('e', entity.OptionalStringValue())

18
dist/ba_data/python/bacommon/err.py vendored Normal file
View file

@ -0,0 +1,18 @@
# Released under the MIT License. See LICENSE for details.
#
"""Error related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
class RemoteError(Exception):
"""An error occurred on the other end of some connection."""
def __str__(self) -> str:
s = ''.join(str(arg) for arg in self.args)
return f'Remote Exception Follows:\n{s}'

View file

@ -0,0 +1,168 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to the server manager script."""
from __future__ import annotations
from enum import Enum
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Optional, Tuple, List
@dataclass
class ServerConfig:
"""Configuration for the server manager app (<appname>_server)."""
# Name of our server in the public parties list.
party_name: str = 'FFA'
# If True, your party will show up in the global public party list
# Otherwise it will still be joinable via LAN or connecting by IP address.
party_is_public: bool = True
# If True, all connecting clients will be authenticated through the master
# server to screen for fake account info. Generally this should always
# be enabled unless you are hosting on a LAN with no internet connection.
authenticate_clients: bool = True
# IDs of server admins. Server admins are not kickable through the default
# kick vote system and they are able to kick players without a vote. To get
# your account id, enter 'getaccountid' in settings->advanced->enter-code.
admins: List[str] = field(default_factory=list)
# Whether the default kick-voting system is enabled.
enable_default_kick_voting: bool = True
# UDP port to host on. Change this to work around firewalls or run multiple
# servers on one machine.
# 43210 is the default and the only port that will show up in the LAN
# browser tab.
port: int = 43210
# Max devices in the party. Note that this does *NOT* mean max players.
# Any device in the party can have more than one player on it if they have
# multiple controllers. Also, this number currently includes the server so
# generally make it 1 bigger than you need. Max-players is not currently
# exposed but I'll try to add that soon.
max_party_size: int = 6
# Options here are 'ffa' (free-for-all) and 'teams'
# This value is only used if you do not supply a playlist_code (see below).
# In that case the default teams or free-for-all playlist gets used.
session_type: str = 'ffa'
# To host your own custom playlists, use the 'share' functionality in the
# playlist editor in the regular version of the game.
# This will give you a numeric code you can enter here to host that
# playlist.
playlist_code: Optional[int] = None
# Whether to shuffle the playlist or play its games in designated order.
playlist_shuffle: bool = True
# If True, keeps team sizes equal by disallowing joining the largest team
# (teams mode only).
auto_balance_teams: bool = True
# Whether to enable telnet access.
# IMPORTANT: This option is no longer available, as it was being used
# for exploits. Live access to the running server is still possible through
# the mgr.cmd() function in the server script. Run your server through
# tools such as 'screen' or 'tmux' and you can reconnect to it remotely
# over a secure ssh connection.
enable_telnet: bool = False
# Series length in teams mode (7 == 'best-of-7' series; a team must
# get 4 wins)
teams_series_length: int = 7
# Points to win in free-for-all mode (Points are awarded per game based on
# performance)
ffa_series_length: int = 24
# If you provide a custom stats webpage for your server, you can use
# this to provide a convenient in-game link to it in the server-browser
# beside the server name.
# if ${ACCOUNT} is present in the string, it will be replaced by the
# currently-signed-in account's id. To fetch info about an account,
# your backend server can use the following url:
# http://bombsquadgame.com/accountquery?id=ACCOUNT_ID_HERE
stats_url: Optional[str] = None
# If present, the server subprocess will attempt to gracefully exit after
# this amount of time. A graceful exit can occur at the end of a series
# or other opportune time. Server-managers set to auto-restart (the
# default) will then spin up a fresh subprocess. This mechanism can be
# useful to clear out any memory leaks or other accumulated bad state
# in the server subprocess.
clean_exit_minutes: Optional[float] = None
# If present, the server subprocess will shut down immediately after this
# amount of time. This can be useful as a fallback for clean_exit_time.
# The server manager will then spin up a fresh server subprocess if
# auto-restart is enabled (the default).
unclean_exit_minutes: Optional[float] = None
# If present, the server subprocess will shut down immediately if this
# amount of time passes with no activity from any players. The server
# manager will then spin up a fresh server subprocess if
# auto-restart is enabled (the default).
idle_exit_minutes: Optional[float] = None
# (internal) stress-testing mode.
stress_test_players: Optional[int] = None
# NOTE: as much as possible, communication from the server-manager to the
# child-process should go through these and not ad-hoc Python string commands
# since this way is type safe.
class ServerCommand:
"""Base class for commands that can be sent to the server."""
@dataclass
class StartServerModeCommand(ServerCommand):
"""Tells the app to switch into 'server' mode."""
config: ServerConfig
class ShutdownReason(Enum):
"""Reason a server is shutting down."""
NONE = 'none'
RESTARTING = 'restarting'
@dataclass
class ShutdownCommand(ServerCommand):
"""Tells the server to shut down."""
reason: ShutdownReason
immediate: bool
@dataclass
class ChatMessageCommand(ServerCommand):
"""Chat message from the server."""
message: str
clients: Optional[List[int]]
@dataclass
class ScreenMessageCommand(ServerCommand):
"""Screen-message from the server."""
message: str
color: Optional[Tuple[float, float, float]]
clients: Optional[List[int]]
@dataclass
class ClientListCommand(ServerCommand):
"""Print a list of clients."""
@dataclass
class KickCommand(ServerCommand):
"""Kick a client."""
client_id: int
ban_time: Optional[int]

5
dist/ba_data/python/bastd/__init__.py vendored Normal file
View file

@ -0,0 +1,5 @@
# Released under the MIT License. See LICENSE for details.
#
"""Ballistica standard library: games, UI, etc."""
# ba_meta require api 6

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

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