mirror of
https://github.com/imayushsaini/Bombsquad-Ballistica-Modded-Server.git
synced 2025-11-07 17:36:15 +00:00
Private server
This commit is contained in:
commit
be7c837e33
668 changed files with 151282 additions and 0 deletions
94
dist/ba_data/python/ba/__init__.py
vendored
Normal file
94
dist/ba_data/python/ba/__init__.py
vendored
Normal 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
267
dist/ba_data/python/ba/_account.py
vendored
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Account related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Optional, 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
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
870
dist/ba_data/python/ba/_activity.py
vendored
Normal 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
217
dist/ba_data/python/ba/_activitytypes.py
vendored
Normal 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
200
dist/ba_data/python/ba/_actor.py
vendored
Normal 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
186
dist/ba_data/python/ba/_ads.py
vendored
Normal 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
73
dist/ba_data/python/ba/_analytics.py
vendored
Normal 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
569
dist/ba_data/python/ba/_app.py
vendored
Normal 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
166
dist/ba_data/python/ba/_appconfig.py
vendored
Normal 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
31
dist/ba_data/python/ba/_appdelegate.py
vendored
Normal 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
3
dist/ba_data/python/ba/_appmode.py
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to the high level state of the app."""
|
||||
238
dist/ba_data/python/ba/_apputils.py
vendored
Normal file
238
dist/ba_data/python/ba/_apputils.py
vendored
Normal 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
222
dist/ba_data/python/ba/_assetmanager.py
vendored
Normal 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
171
dist/ba_data/python/ba/_benchmark.py
vendored
Normal 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
352
dist/ba_data/python/ba/_campaign.py
vendored
Normal 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
72
dist/ba_data/python/ba/_collision.py
vendored
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Collision related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _ba
|
||||
from ba._error import NodeNotFoundError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ba
|
||||
|
||||
|
||||
class Collision:
|
||||
"""A class providing info about occurring collisions.
|
||||
|
||||
Category: Gameplay Classes
|
||||
"""
|
||||
|
||||
@property
|
||||
def position(self) -> ba.Vec3:
|
||||
"""The position of the current collision."""
|
||||
return _ba.Vec3(_ba.get_collision_info('position'))
|
||||
|
||||
@property
|
||||
def sourcenode(self) -> ba.Node:
|
||||
"""The node containing the material triggering the current callback.
|
||||
|
||||
Throws a ba.NodeNotFoundError if the node does not exist, though
|
||||
the node should always exist (at least at the start of the collision
|
||||
callback).
|
||||
"""
|
||||
node = _ba.get_collision_info('sourcenode')
|
||||
assert isinstance(node, (_ba.Node, type(None)))
|
||||
if not node:
|
||||
raise NodeNotFoundError()
|
||||
return node
|
||||
|
||||
@property
|
||||
def opposingnode(self) -> ba.Node:
|
||||
"""The node the current callback material node is hitting.
|
||||
|
||||
Throws a ba.NodeNotFoundError if the node does not exist.
|
||||
This can be expected in some cases such as in 'disconnect'
|
||||
callbacks triggered by deleting a currently-colliding node.
|
||||
"""
|
||||
node = _ba.get_collision_info('opposingnode')
|
||||
assert isinstance(node, (_ba.Node, type(None)))
|
||||
if not node:
|
||||
raise NodeNotFoundError()
|
||||
return node
|
||||
|
||||
@property
|
||||
def opposingbody(self) -> int:
|
||||
"""The body index on the opposing node in the current collision."""
|
||||
body = _ba.get_collision_info('opposingbody')
|
||||
assert isinstance(body, int)
|
||||
return body
|
||||
|
||||
|
||||
# Simply recycle one instance...
|
||||
_collision = Collision()
|
||||
|
||||
|
||||
def getcollision() -> Collision:
|
||||
"""Return the in-progress collision.
|
||||
|
||||
Category: Gameplay Functions
|
||||
"""
|
||||
return _collision
|
||||
270
dist/ba_data/python/ba/_coopgame.py
vendored
Normal file
270
dist/ba_data/python/ba/_coopgame.py
vendored
Normal 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
388
dist/ba_data/python/ba/_coopsession.py
vendored
Normal 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
422
dist/ba_data/python/ba/_dependency.py
vendored
Normal 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()
|
||||
57
dist/ba_data/python/ba/_dualteamsession.py
vendored
Normal file
57
dist/ba_data/python/ba/_dualteamsession.py
vendored
Normal 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
198
dist/ba_data/python/ba/_enums.py
vendored
Normal 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
193
dist/ba_data/python/ba/_error.py
vendored
Normal 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()
|
||||
97
dist/ba_data/python/ba/_freeforallsession.py
vendored
Normal file
97
dist/ba_data/python/ba/_freeforallsession.py
vendored
Normal 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
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
212
dist/ba_data/python/ba/_gameresults.py
vendored
Normal 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
397
dist/ba_data/python/ba/_gameutils.py
vendored
Normal 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
410
dist/ba_data/python/ba/_general.py
vendored
Normal 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
337
dist/ba_data/python/ba/_hooks.py
vendored
Normal 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
622
dist/ba_data/python/ba/_input.py
vendored
Normal 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
35
dist/ba_data/python/ba/_keyboard.py
vendored
Normal 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
562
dist/ba_data/python/ba/_language.py
vendored
Normal 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
168
dist/ba_data/python/ba/_level.py
vendored
Normal 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
958
dist/ba_data/python/ba/_lobby.py
vendored
Normal 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
431
dist/ba_data/python/ba/_map.py
vendored
Normal 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
55
dist/ba_data/python/ba/_math.py
vendored
Normal 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
312
dist/ba_data/python/ba/_messages.py
vendored
Normal 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
418
dist/ba_data/python/ba/_meta.py
vendored
Normal 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
|
||||
312
dist/ba_data/python/ba/_multiteamsession.py
vendored
Normal file
312
dist/ba_data/python/ba/_multiteamsession.py
vendored
Normal 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
503
dist/ba_data/python/ba/_music.py
vendored
Normal 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
186
dist/ba_data/python/ba/_netutils.py
vendored
Normal 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
38
dist/ba_data/python/ba/_nodeactor.py
vendored
Normal 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
330
dist/ba_data/python/ba/_player.py
vendored
Normal 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
495
dist/ba_data/python/ba/_playlist.py
vendored
Normal 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
96
dist/ba_data/python/ba/_plugin.py
vendored
Normal 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
54
dist/ba_data/python/ba/_powerup.py
vendored
Normal 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
93
dist/ba_data/python/ba/_profile.py
vendored
Normal 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
56
dist/ba_data/python/ba/_score.py
vendored
Normal 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
352
dist/ba_data/python/ba/_servermode.py
vendored
Normal 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
698
dist/ba_data/python/ba/_session.py
vendored
Normal 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
84
dist/ba_data/python/ba/_settings.py
vendored
Normal 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
470
dist/ba_data/python/ba/_stats.py
vendored
Normal 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
510
dist/ba_data/python/ba/_store.py
vendored
Normal 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
212
dist/ba_data/python/ba/_team.py
vendored
Normal 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
155
dist/ba_data/python/ba/_teamgame.py
vendored
Normal 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
98
dist/ba_data/python/ba/_tips.py
vendored
Normal 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
47
dist/ba_data/python/ba/_tournament.py
vendored
Normal 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
167
dist/ba_data/python/ba/_ui.py
vendored
Normal 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
8
dist/ba_data/python/ba/deprecated.py
vendored
Normal 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
41
dist/ba_data/python/ba/internal.py
vendored
Normal 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
232
dist/ba_data/python/ba/macmusicapp.py
vendored
Normal 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
151
dist/ba_data/python/ba/modutils.py
vendored
Normal 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
135
dist/ba_data/python/ba/osmusic.py
vendored
Normal 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
229
dist/ba_data/python/ba/ui/__init__.py
vendored
Normal 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
|
||||
BIN
dist/ba_data/python/ba/ui/__pycache__/__init__.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/ui/__pycache__/__init__.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/ba/ui/__pycache__/__init__.cpython-38.pyc
vendored
Normal file
BIN
dist/ba_data/python/ba/ui/__pycache__/__init__.cpython-38.pyc
vendored
Normal file
Binary file not shown.
3
dist/ba_data/python/bacommon/__init__.py
vendored
Normal file
3
dist/ba_data/python/bacommon/__init__.py
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality and data common to ballistica client and server components."""
|
||||
BIN
dist/ba_data/python/bacommon/__pycache__/__init__.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bacommon/__pycache__/__init__.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bacommon/__pycache__/__init__.cpython-38.pyc
vendored
Normal file
BIN
dist/ba_data/python/bacommon/__pycache__/__init__.cpython-38.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bacommon/__pycache__/assets.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bacommon/__pycache__/assets.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bacommon/__pycache__/err.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bacommon/__pycache__/err.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bacommon/__pycache__/servermanager.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bacommon/__pycache__/servermanager.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bacommon/__pycache__/servermanager.cpython-38.pyc
vendored
Normal file
BIN
dist/ba_data/python/bacommon/__pycache__/servermanager.cpython-38.pyc
vendored
Normal file
Binary file not shown.
58
dist/ba_data/python/bacommon/assets.py
vendored
Normal file
58
dist/ba_data/python/bacommon/assets.py
vendored
Normal 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
18
dist/ba_data/python/bacommon/err.py
vendored
Normal 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}'
|
||||
168
dist/ba_data/python/bacommon/servermanager.py
vendored
Normal file
168
dist/ba_data/python/bacommon/servermanager.py
vendored
Normal 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
5
dist/ba_data/python/bastd/__init__.py
vendored
Normal 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
|
||||
BIN
dist/ba_data/python/bastd/__pycache__/__init__.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/__pycache__/__init__.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/__pycache__/__init__.cpython-38.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/__pycache__/__init__.cpython-38.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/__pycache__/appdelegate.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/__pycache__/appdelegate.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/__pycache__/appdelegate.cpython-38.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/__pycache__/appdelegate.cpython-38.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/__pycache__/gameutils.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/__pycache__/gameutils.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/__pycache__/gameutils.cpython-38.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/__pycache__/gameutils.cpython-38.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/__pycache__/mainmenu.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/__pycache__/mainmenu.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/__pycache__/mainmenu.cpython-38.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/__pycache__/mainmenu.cpython-38.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/__pycache__/maps.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/__pycache__/maps.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/__pycache__/maps.cpython-38.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/__pycache__/maps.cpython-38.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/__pycache__/stdmap.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/__pycache__/stdmap.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/__pycache__/tutorial.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/__pycache__/tutorial.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/__pycache__/tutorial.cpython-38.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/__pycache__/tutorial.cpython-38.pyc
vendored
Normal file
Binary file not shown.
1
dist/ba_data/python/bastd/activity/__init__.py
vendored
Normal file
1
dist/ba_data/python/bastd/activity/__init__.py
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
BIN
dist/ba_data/python/bastd/activity/__pycache__/__init__.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/activity/__pycache__/__init__.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/activity/__pycache__/__init__.cpython-38.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/activity/__pycache__/__init__.cpython-38.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/activity/__pycache__/coopjoin.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/activity/__pycache__/coopjoin.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/activity/__pycache__/coopscore.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/activity/__pycache__/coopscore.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/activity/__pycache__/drawscore.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/activity/__pycache__/drawscore.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/activity/__pycache__/drawscore.cpython-38.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/activity/__pycache__/drawscore.cpython-38.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/activity/__pycache__/dualteamscore.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/activity/__pycache__/dualteamscore.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/activity/__pycache__/freeforallvictory.cpython-38.opt-1.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/activity/__pycache__/freeforallvictory.cpython-38.opt-1.pyc
vendored
Normal file
Binary file not shown.
BIN
dist/ba_data/python/bastd/activity/__pycache__/freeforallvictory.cpython-38.pyc
vendored
Normal file
BIN
dist/ba_data/python/bastd/activity/__pycache__/freeforallvictory.cpython-38.pyc
vendored
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue