ba
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.
1# Released under the MIT License. See LICENSE for details. 2# 3"""The public face of Ballistica. 4 5This top level module is a collection of most commonly used functionality. 6For many modding purposes, the bits exposed here are all you'll need. 7In some specific cases you may need to pull in individual submodules instead. 8""" 9# pylint: disable=redefined-builtin 10 11from _ba import ( 12 CollideModel, 13 Context, 14 ContextCall, 15 Data, 16 InputDevice, 17 Material, 18 Model, 19 Node, 20 SessionPlayer, 21 Sound, 22 Texture, 23 Timer, 24 Vec3, 25 Widget, 26 buttonwidget, 27 camerashake, 28 checkboxwidget, 29 columnwidget, 30 containerwidget, 31 do_once, 32 emitfx, 33 getactivity, 34 getcollidemodel, 35 getmodel, 36 getnodes, 37 getsession, 38 getsound, 39 gettexture, 40 hscrollwidget, 41 imagewidget, 42 newactivity, 43 newnode, 44 playsound, 45 printnodes, 46 ls_objects, 47 ls_input_devices, 48 pushcall, 49 quit, 50 rowwidget, 51 safecolor, 52 screenmessage, 53 scrollwidget, 54 set_analytics_screen, 55 charstr, 56 textwidget, 57 time, 58 timer, 59 open_url, 60 widget, 61 clipboard_is_supported, 62 clipboard_has_text, 63 clipboard_get_text, 64 clipboard_set_text, 65 getdata, 66 in_logic_thread, 67) 68from ba._accountv2 import AccountV2Handle 69from ba._activity import Activity 70from ba._plugin import PotentialPlugin, Plugin, PluginSubsystem 71from ba._actor import Actor 72from ba._player import PlayerInfo, Player, EmptyPlayer, StandLocation 73from ba._nodeactor import NodeActor 74from ba._app import App 75from ba._cloud import CloudSubsystem 76from ba._coopgame import CoopGameActivity 77from ba._coopsession import CoopSession 78from ba._dependency import ( 79 Dependency, 80 DependencyComponent, 81 DependencySet, 82 AssetPackage, 83) 84from ba._generated.enums import ( 85 TimeType, 86 Permission, 87 TimeFormat, 88 SpecialChar, 89 InputType, 90 UIScale, 91) 92from ba._error import ( 93 print_exception, 94 print_error, 95 ContextError, 96 NotFoundError, 97 PlayerNotFoundError, 98 SessionPlayerNotFoundError, 99 NodeNotFoundError, 100 ActorNotFoundError, 101 InputDeviceNotFoundError, 102 WidgetNotFoundError, 103 ActivityNotFoundError, 104 TeamNotFoundError, 105 MapNotFoundError, 106 SessionTeamNotFoundError, 107 SessionNotFoundError, 108 DelegateNotFoundError, 109 DependencyError, 110) 111from ba._freeforallsession import FreeForAllSession 112from ba._gameactivity import GameActivity 113from ba._gameresults import GameResults 114from ba._settings import ( 115 Setting, 116 IntSetting, 117 FloatSetting, 118 ChoiceSetting, 119 BoolSetting, 120 IntChoiceSetting, 121 FloatChoiceSetting, 122) 123from ba._language import Lstr, LanguageSubsystem 124from ba._map import Map, getmaps 125from ba._session import Session 126from ba._ui import UISubsystem 127from ba._servermode import ServerController 128from ba._score import ScoreType, ScoreConfig 129from ba._stats import PlayerScoredMessage, PlayerRecord, Stats 130from ba._team import SessionTeam, Team, EmptyTeam 131from ba._teamgame import TeamGameActivity 132from ba._dualteamsession import DualTeamSession 133from ba._achievement import Achievement, AchievementSubsystem 134from ba._appconfig import AppConfig 135from ba._appdelegate import AppDelegate 136from ba._apputils import is_browser_likely_available, garbage_collect 137from ba._campaign import Campaign 138from ba._gameutils import ( 139 GameTip, 140 animate, 141 animate_array, 142 show_damage_count, 143 timestring, 144 cameraflash, 145) 146from ba._general import ( 147 WeakCall, 148 Call, 149 existing, 150 Existable, 151 verify_object_death, 152 storagename, 153 getclass, 154) 155from ba._keyboard import Keyboard 156from ba._level import Level 157from ba._lobby import Lobby, Chooser 158from ba._math import normalized_color, is_point_in_box, vec3validate 159from ba._meta import MetadataSubsystem 160from ba._messages import ( 161 UNHANDLED, 162 OutOfBoundsMessage, 163 DeathType, 164 DieMessage, 165 PlayerDiedMessage, 166 StandMessage, 167 PickUpMessage, 168 DropMessage, 169 PickedUpMessage, 170 DroppedMessage, 171 ShouldShatterMessage, 172 ImpactDamageMessage, 173 FreezeMessage, 174 ThawMessage, 175 HitMessage, 176 CelebrateMessage, 177) 178from ba._music import ( 179 setmusic, 180 MusicPlayer, 181 MusicType, 182 MusicPlayMode, 183 MusicSubsystem, 184) 185from ba._powerup import PowerupMessage, PowerupAcceptMessage 186from ba._multiteamsession import MultiTeamSession 187from ba.ui import Window, UIController, uicleanupcheck 188from ba._collision import Collision, getcollision 189 190app: App 191 192__all__ = [ 193 'AccountV2Handle', 194 'Achievement', 195 'AchievementSubsystem', 196 'Activity', 197 'ActivityNotFoundError', 198 'Actor', 199 'ActorNotFoundError', 200 'animate', 201 'animate_array', 202 'app', 203 'App', 204 'AppConfig', 205 'AppDelegate', 206 'AssetPackage', 207 'BoolSetting', 208 'buttonwidget', 209 'Call', 210 'cameraflash', 211 'camerashake', 212 'Campaign', 213 'CelebrateMessage', 214 'charstr', 215 'checkboxwidget', 216 'ChoiceSetting', 217 'Chooser', 218 'clipboard_get_text', 219 'clipboard_has_text', 220 'clipboard_is_supported', 221 'clipboard_set_text', 222 'CollideModel', 223 'Collision', 224 'columnwidget', 225 'containerwidget', 226 'Context', 227 'ContextCall', 228 'ContextError', 229 'CloudSubsystem', 230 'CoopGameActivity', 231 'CoopSession', 232 'Data', 233 'DeathType', 234 'DelegateNotFoundError', 235 'Dependency', 236 'DependencyComponent', 237 'DependencyError', 238 'DependencySet', 239 'DieMessage', 240 'do_once', 241 'DropMessage', 242 'DroppedMessage', 243 'DualTeamSession', 244 'emitfx', 245 'EmptyPlayer', 246 'EmptyTeam', 247 'Existable', 248 'existing', 249 'FloatChoiceSetting', 250 'FloatSetting', 251 'FreeForAllSession', 252 'FreezeMessage', 253 'GameActivity', 254 'GameResults', 255 'GameTip', 256 'garbage_collect', 257 'getactivity', 258 'getclass', 259 'getcollidemodel', 260 'getcollision', 261 'getdata', 262 'getmaps', 263 'getmodel', 264 'getnodes', 265 'getsession', 266 'getsound', 267 'gettexture', 268 'HitMessage', 269 'hscrollwidget', 270 'imagewidget', 271 'ImpactDamageMessage', 272 'in_logic_thread', 273 'InputDevice', 274 'InputDeviceNotFoundError', 275 'InputType', 276 'IntChoiceSetting', 277 'IntSetting', 278 'is_browser_likely_available', 279 'is_point_in_box', 280 'Keyboard', 281 'LanguageSubsystem', 282 'Level', 283 'Lobby', 284 'Lstr', 285 'Map', 286 'MapNotFoundError', 287 'Material', 288 'MetadataSubsystem', 289 'Model', 290 'MultiTeamSession', 291 'MusicPlayer', 292 'MusicPlayMode', 293 'MusicSubsystem', 294 'MusicType', 295 'newactivity', 296 'newnode', 297 'Node', 298 'NodeActor', 299 'NodeNotFoundError', 300 'normalized_color', 301 'NotFoundError', 302 'open_url', 303 'OutOfBoundsMessage', 304 'Permission', 305 'PickedUpMessage', 306 'PickUpMessage', 307 'Player', 308 'PlayerDiedMessage', 309 'PlayerInfo', 310 'PlayerNotFoundError', 311 'PlayerRecord', 312 'PlayerScoredMessage', 313 'playsound', 314 'Plugin', 315 'PluginSubsystem', 316 'PotentialPlugin', 317 'PowerupAcceptMessage', 318 'PowerupMessage', 319 'print_error', 320 'print_exception', 321 'printnodes', 322 'ls_objects', 323 'ls_input_devices', 324 'pushcall', 325 'quit', 326 'rowwidget', 327 'safecolor', 328 'ScoreConfig', 329 'ScoreType', 330 'screenmessage', 331 'scrollwidget', 332 'ServerController', 333 'Session', 334 'SessionNotFoundError', 335 'SessionPlayer', 336 'SessionPlayerNotFoundError', 337 'SessionTeam', 338 'SessionTeamNotFoundError', 339 'set_analytics_screen', 340 'setmusic', 341 'Setting', 342 'ShouldShatterMessage', 343 'show_damage_count', 344 'Sound', 345 'SpecialChar', 346 'StandLocation', 347 'StandMessage', 348 'Stats', 349 'storagename', 350 'Team', 351 'TeamGameActivity', 352 'TeamNotFoundError', 353 'Texture', 354 'textwidget', 355 'ThawMessage', 356 'time', 357 'TimeFormat', 358 'Timer', 359 'timer', 360 'timestring', 361 'TimeType', 362 'uicleanupcheck', 363 'UIController', 364 'UIScale', 365 'UISubsystem', 366 'UNHANDLED', 367 'Vec3', 368 'vec3validate', 369 'verify_object_death', 370 'WeakCall', 371 'Widget', 372 'widget', 373 'WidgetNotFoundError', 374 'Window', 375] 376 377 378# Have these things present themselves cleanly as 'ba.Foo' 379# instead of 'ba._submodule.Foo' 380def _simplify_module_names() -> None: 381 import os 382 383 # Though pdoc gets confused when we override __module__, 384 # so let's make an exception for it. 385 if os.environ.get('BA_DOCS_GENERATION', '0') != '1': 386 from efro.util import set_canonical_module 387 388 globs = globals() 389 set_canonical_module( 390 module_globals=globs, 391 names=[n for n in globs.keys() if not n.startswith('_')], 392 ) 393 394 395_simplify_module_names() 396del _simplify_module_names
407class AccountV2Handle: 408 """Handle for interacting with a V2 account. 409 410 This class supports the 'with' statement, which is how it is 411 used with some operations such as cloud messaging. 412 """ 413 414 def __init__(self) -> None: 415 self.tag = '?' 416 417 self.workspacename: str | None = None 418 self.workspaceid: str | None = None 419 420 # Login types and their display-names associated with this account. 421 self.logins: dict[LoginType, str] = {} 422 423 def __enter__(self) -> None: 424 """Support for "with" statement. 425 426 This allows cloud messages to be sent on our behalf. 427 """ 428 429 def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any: 430 """Support for "with" statement. 431 432 This allows cloud messages to be sent on our behalf. 433 """
Handle for interacting with a V2 account.
This class supports the 'with' statement, which is how it is used with some operations such as cloud messaging.
635class Achievement: 636 """Represents attributes and state for an individual achievement. 637 638 Category: **App Classes** 639 """ 640 641 def __init__( 642 self, 643 name: str, 644 icon_name: str, 645 icon_color: Sequence[float], 646 level_name: str, 647 award: int, 648 hard_mode_only: bool = False, 649 ): 650 self._name = name 651 self._icon_name = icon_name 652 self._icon_color: Sequence[float] = list(icon_color) + [1] 653 self._level_name = level_name 654 self._completion_banner_slot: int | None = None 655 self._award = award 656 self._hard_mode_only = hard_mode_only 657 658 @property 659 def name(self) -> str: 660 """The name of this achievement.""" 661 return self._name 662 663 @property 664 def level_name(self) -> str: 665 """The name of the level this achievement applies to.""" 666 return self._level_name 667 668 def get_icon_texture(self, complete: bool) -> ba.Texture: 669 """Return the icon texture to display for this achievement""" 670 return _ba.gettexture( 671 self._icon_name if complete else 'achievementEmpty' 672 ) 673 674 def get_icon_color(self, complete: bool) -> Sequence[float]: 675 """Return the color tint for this Achievement's icon.""" 676 if complete: 677 return self._icon_color 678 return 1.0, 1.0, 1.0, 0.6 679 680 @property 681 def hard_mode_only(self) -> bool: 682 """Whether this Achievement is only unlockable in hard-mode.""" 683 return self._hard_mode_only 684 685 @property 686 def complete(self) -> bool: 687 """Whether this Achievement is currently complete.""" 688 val: bool = self._getconfig()['Complete'] 689 assert isinstance(val, bool) 690 return val 691 692 def announce_completion(self, sound: bool = True) -> None: 693 """Kick off an announcement for this achievement's completion.""" 694 from ba._generated.enums import TimeType 695 696 app = _ba.app 697 698 # Even though there are technically achievements when we're not 699 # signed in, lets not show them (otherwise we tend to get 700 # confusing 'controller connected' achievements popping up while 701 # waiting to sign in which can be confusing). 702 if _internal.get_v1_account_state() != 'signed_in': 703 return 704 705 # If we're being freshly complete, display/report it and whatnot. 706 if (self, sound) not in app.ach.achievements_to_display: 707 app.ach.achievements_to_display.append((self, sound)) 708 709 # If there's no achievement display timer going, kick one off 710 # (if one's already running it will pick this up before it dies). 711 712 # Need to check last time too; its possible our timer wasn't able to 713 # clear itself if an activity died and took it down with it. 714 if ( 715 app.ach.achievement_display_timer is None 716 or _ba.time(TimeType.REAL) - app.ach.last_achievement_display_time 717 > 2.0 718 ) and _ba.getactivity(doraise=False) is not None: 719 app.ach.achievement_display_timer = _ba.Timer( 720 1.0, 721 _display_next_achievement, 722 repeat=True, 723 timetype=TimeType.BASE, 724 ) 725 726 # Show the first immediately. 727 _display_next_achievement() 728 729 def set_complete(self, complete: bool = True) -> None: 730 """Set an achievement's completed state. 731 732 note this only sets local state; use a transaction to 733 actually award achievements. 734 """ 735 config = self._getconfig() 736 if complete != config['Complete']: 737 config['Complete'] = complete 738 739 @property 740 def display_name(self) -> ba.Lstr: 741 """Return a ba.Lstr for this Achievement's name.""" 742 from ba._language import Lstr 743 744 name: ba.Lstr | str 745 try: 746 if self._level_name != '': 747 from ba._campaign import getcampaign 748 749 campaignname, campaign_level = self._level_name.split(':') 750 name = ( 751 getcampaign(campaignname) 752 .getlevel(campaign_level) 753 .displayname 754 ) 755 else: 756 name = '' 757 except Exception: 758 name = '' 759 print_exception() 760 return Lstr( 761 resource='achievements.' + self._name + '.name', 762 subs=[('${LEVEL}', name)], 763 ) 764 765 @property 766 def description(self) -> ba.Lstr: 767 """Get a ba.Lstr for the Achievement's brief description.""" 768 from ba._language import Lstr 769 770 if ( 771 'description' 772 in _ba.app.lang.get_resource('achievements')[self._name] 773 ): 774 return Lstr(resource='achievements.' + self._name + '.description') 775 return Lstr(resource='achievements.' + self._name + '.descriptionFull') 776 777 @property 778 def description_complete(self) -> ba.Lstr: 779 """Get a ba.Lstr for the Achievement's description when completed.""" 780 from ba._language import Lstr 781 782 if ( 783 'descriptionComplete' 784 in _ba.app.lang.get_resource('achievements')[self._name] 785 ): 786 return Lstr( 787 resource='achievements.' + self._name + '.descriptionComplete' 788 ) 789 return Lstr( 790 resource='achievements.' + self._name + '.descriptionFullComplete' 791 ) 792 793 @property 794 def description_full(self) -> ba.Lstr: 795 """Get a ba.Lstr for the Achievement's full description.""" 796 from ba._language import Lstr 797 798 return Lstr( 799 resource='achievements.' + self._name + '.descriptionFull', 800 subs=[ 801 ( 802 '${LEVEL}', 803 Lstr( 804 translate=( 805 'coopLevelNames', 806 ACH_LEVEL_NAMES.get(self._name, '?'), 807 ) 808 ), 809 ) 810 ], 811 ) 812 813 @property 814 def description_full_complete(self) -> ba.Lstr: 815 """Get a ba.Lstr for the Achievement's full desc. when completed.""" 816 from ba._language import Lstr 817 818 return Lstr( 819 resource='achievements.' + self._name + '.descriptionFullComplete', 820 subs=[ 821 ( 822 '${LEVEL}', 823 Lstr( 824 translate=( 825 'coopLevelNames', 826 ACH_LEVEL_NAMES.get(self._name, '?'), 827 ) 828 ), 829 ) 830 ], 831 ) 832 833 def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int: 834 """Get the ticket award value for this achievement.""" 835 val: int = _internal.get_v1_account_misc_read_val( 836 'achAward.' + self._name, self._award 837 ) * _get_ach_mult(include_pro_bonus) 838 assert isinstance(val, int) 839 return val 840 841 @property 842 def power_ranking_value(self) -> int: 843 """Get the power-ranking award value for this achievement.""" 844 val: int = _internal.get_v1_account_misc_read_val( 845 'achLeaguePoints.' + self._name, self._award 846 ) 847 assert isinstance(val, int) 848 return val 849 850 def create_display( 851 self, 852 x: float, 853 y: float, 854 delay: float, 855 outdelay: float | None = None, 856 color: Sequence[float] | None = None, 857 style: str = 'post_game', 858 ) -> list[ba.Actor]: 859 """Create a display for the Achievement. 860 861 Shows the Achievement icon, name, and description. 862 """ 863 # pylint: disable=cyclic-import 864 from ba._language import Lstr 865 from ba._generated.enums import SpecialChar 866 from ba._coopsession import CoopSession 867 from bastd.actor.image import Image 868 from bastd.actor.text import Text 869 870 # Yeah this needs cleaning up. 871 if style == 'post_game': 872 in_game_colors = False 873 in_main_menu = False 874 h_attach = Text.HAttach.CENTER 875 v_attach = Text.VAttach.CENTER 876 attach = Image.Attach.CENTER 877 elif style == 'in_game': 878 in_game_colors = True 879 in_main_menu = False 880 h_attach = Text.HAttach.LEFT 881 v_attach = Text.VAttach.TOP 882 attach = Image.Attach.TOP_LEFT 883 elif style == 'news': 884 in_game_colors = True 885 in_main_menu = True 886 h_attach = Text.HAttach.CENTER 887 v_attach = Text.VAttach.TOP 888 attach = Image.Attach.TOP_CENTER 889 else: 890 raise ValueError('invalid style "' + style + '"') 891 892 # Attempt to determine what campaign we're in 893 # (so we know whether to show "hard mode only"). 894 if in_main_menu: 895 hmo = False 896 else: 897 try: 898 session = _ba.getsession() 899 if isinstance(session, CoopSession): 900 campaign = session.campaign 901 assert campaign is not None 902 hmo = self._hard_mode_only and campaign.name == 'Easy' 903 else: 904 hmo = False 905 except Exception: 906 print_exception('Error determining campaign.') 907 hmo = False 908 909 objs: list[ba.Actor] 910 911 if in_game_colors: 912 objs = [] 913 out_delay_fin = (delay + outdelay) if outdelay is not None else None 914 if color is not None: 915 cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2], color[3]) 916 cl2 = color 917 else: 918 cl1 = (1.5, 1.5, 2, 1.0) 919 cl2 = (0.8, 0.8, 1.0, 1.0) 920 921 if hmo: 922 cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6) 923 cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2) 924 925 objs.append( 926 Image( 927 self.get_icon_texture(False), 928 host_only=True, 929 color=cl1, 930 position=(x - 25, y + 5), 931 attach=attach, 932 transition=Image.Transition.FADE_IN, 933 transition_delay=delay, 934 vr_depth=4, 935 transition_out_delay=out_delay_fin, 936 scale=(40, 40), 937 ).autoretain() 938 ) 939 txt = self.display_name 940 txt_s = 0.85 941 txt_max_w = 300 942 objs.append( 943 Text( 944 txt, 945 host_only=True, 946 maxwidth=txt_max_w, 947 position=(x, y + 2), 948 transition=Text.Transition.FADE_IN, 949 scale=txt_s, 950 flatness=0.6, 951 shadow=0.5, 952 h_attach=h_attach, 953 v_attach=v_attach, 954 color=cl2, 955 transition_delay=delay + 0.05, 956 transition_out_delay=out_delay_fin, 957 ).autoretain() 958 ) 959 txt2_s = 0.62 960 txt2_max_w = 400 961 objs.append( 962 Text( 963 self.description_full if in_main_menu else self.description, 964 host_only=True, 965 maxwidth=txt2_max_w, 966 position=(x, y - 14), 967 transition=Text.Transition.FADE_IN, 968 vr_depth=-5, 969 h_attach=h_attach, 970 v_attach=v_attach, 971 scale=txt2_s, 972 flatness=1.0, 973 shadow=0.5, 974 color=cl2, 975 transition_delay=delay + 0.1, 976 transition_out_delay=out_delay_fin, 977 ).autoretain() 978 ) 979 980 if hmo: 981 txtactor = Text( 982 Lstr(resource='difficultyHardOnlyText'), 983 host_only=True, 984 maxwidth=txt2_max_w * 0.7, 985 position=(x + 60, y + 5), 986 transition=Text.Transition.FADE_IN, 987 vr_depth=-5, 988 h_attach=h_attach, 989 v_attach=v_attach, 990 h_align=Text.HAlign.CENTER, 991 v_align=Text.VAlign.CENTER, 992 scale=txt_s * 0.8, 993 flatness=1.0, 994 shadow=0.5, 995 color=(1, 1, 0.6, 1), 996 transition_delay=delay + 0.1, 997 transition_out_delay=out_delay_fin, 998 ).autoretain() 999 txtactor.node.rotate = 10 1000 objs.append(txtactor) 1001 1002 # Ticket-award. 1003 award_x = -100 1004 objs.append( 1005 Text( 1006 _ba.charstr(SpecialChar.TICKET), 1007 host_only=True, 1008 position=(x + award_x + 33, y + 7), 1009 transition=Text.Transition.FADE_IN, 1010 scale=1.5, 1011 h_attach=h_attach, 1012 v_attach=v_attach, 1013 h_align=Text.HAlign.CENTER, 1014 v_align=Text.VAlign.CENTER, 1015 color=(1, 1, 1, 0.2 if hmo else 0.4), 1016 transition_delay=delay + 0.05, 1017 transition_out_delay=out_delay_fin, 1018 ).autoretain() 1019 ) 1020 objs.append( 1021 Text( 1022 '+' + str(self.get_award_ticket_value()), 1023 host_only=True, 1024 position=(x + award_x + 28, y + 16), 1025 transition=Text.Transition.FADE_IN, 1026 scale=0.7, 1027 flatness=1, 1028 h_attach=h_attach, 1029 v_attach=v_attach, 1030 h_align=Text.HAlign.CENTER, 1031 v_align=Text.VAlign.CENTER, 1032 color=cl2, 1033 transition_delay=delay + 0.05, 1034 transition_out_delay=out_delay_fin, 1035 ).autoretain() 1036 ) 1037 1038 else: 1039 complete = self.complete 1040 objs = [] 1041 c_icon = self.get_icon_color(complete) 1042 if hmo and not complete: 1043 c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3) 1044 objs.append( 1045 Image( 1046 self.get_icon_texture(complete), 1047 host_only=True, 1048 color=c_icon, 1049 position=(x - 25, y + 5), 1050 attach=attach, 1051 vr_depth=4, 1052 transition=Image.Transition.IN_RIGHT, 1053 transition_delay=delay, 1054 transition_out_delay=None, 1055 scale=(40, 40), 1056 ).autoretain() 1057 ) 1058 if complete: 1059 objs.append( 1060 Image( 1061 _ba.gettexture('achievementOutline'), 1062 host_only=True, 1063 model_transparent=_ba.getmodel('achievementOutline'), 1064 color=(2, 1.4, 0.4, 1), 1065 vr_depth=8, 1066 position=(x - 25, y + 5), 1067 attach=attach, 1068 transition=Image.Transition.IN_RIGHT, 1069 transition_delay=delay, 1070 transition_out_delay=None, 1071 scale=(40, 40), 1072 ).autoretain() 1073 ) 1074 else: 1075 if not complete: 1076 award_x = -100 1077 objs.append( 1078 Text( 1079 _ba.charstr(SpecialChar.TICKET), 1080 host_only=True, 1081 position=(x + award_x + 33, y + 7), 1082 transition=Text.Transition.IN_RIGHT, 1083 scale=1.5, 1084 h_attach=h_attach, 1085 v_attach=v_attach, 1086 h_align=Text.HAlign.CENTER, 1087 v_align=Text.VAlign.CENTER, 1088 color=(1, 1, 1, 0.4) 1089 if complete 1090 else (1, 1, 1, (0.1 if hmo else 0.2)), 1091 transition_delay=delay + 0.05, 1092 transition_out_delay=None, 1093 ).autoretain() 1094 ) 1095 objs.append( 1096 Text( 1097 '+' + str(self.get_award_ticket_value()), 1098 host_only=True, 1099 position=(x + award_x + 28, y + 16), 1100 transition=Text.Transition.IN_RIGHT, 1101 scale=0.7, 1102 flatness=1, 1103 h_attach=h_attach, 1104 v_attach=v_attach, 1105 h_align=Text.HAlign.CENTER, 1106 v_align=Text.VAlign.CENTER, 1107 color=( 1108 (0.8, 0.93, 0.8, 1.0) 1109 if complete 1110 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1111 ), 1112 transition_delay=delay + 0.05, 1113 transition_out_delay=None, 1114 ).autoretain() 1115 ) 1116 1117 # Show 'hard-mode-only' only over incomplete achievements 1118 # when that's the case. 1119 if hmo: 1120 txtactor = Text( 1121 Lstr(resource='difficultyHardOnlyText'), 1122 host_only=True, 1123 maxwidth=300 * 0.7, 1124 position=(x + 60, y + 5), 1125 transition=Text.Transition.FADE_IN, 1126 vr_depth=-5, 1127 h_attach=h_attach, 1128 v_attach=v_attach, 1129 h_align=Text.HAlign.CENTER, 1130 v_align=Text.VAlign.CENTER, 1131 scale=0.85 * 0.8, 1132 flatness=1.0, 1133 shadow=0.5, 1134 color=(1, 1, 0.6, 1), 1135 transition_delay=delay + 0.05, 1136 transition_out_delay=None, 1137 ).autoretain() 1138 assert txtactor.node 1139 txtactor.node.rotate = 10 1140 objs.append(txtactor) 1141 1142 objs.append( 1143 Text( 1144 self.display_name, 1145 host_only=True, 1146 maxwidth=300, 1147 position=(x, y + 2), 1148 transition=Text.Transition.IN_RIGHT, 1149 scale=0.85, 1150 flatness=0.6, 1151 h_attach=h_attach, 1152 v_attach=v_attach, 1153 color=( 1154 (0.8, 0.93, 0.8, 1.0) 1155 if complete 1156 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1157 ), 1158 transition_delay=delay + 0.05, 1159 transition_out_delay=None, 1160 ).autoretain() 1161 ) 1162 objs.append( 1163 Text( 1164 self.description_complete if complete else self.description, 1165 host_only=True, 1166 maxwidth=400, 1167 position=(x, y - 14), 1168 transition=Text.Transition.IN_RIGHT, 1169 vr_depth=-5, 1170 h_attach=h_attach, 1171 v_attach=v_attach, 1172 scale=0.62, 1173 flatness=1.0, 1174 color=( 1175 (0.6, 0.6, 0.6, 1.0) 1176 if complete 1177 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1178 ), 1179 transition_delay=delay + 0.1, 1180 transition_out_delay=None, 1181 ).autoretain() 1182 ) 1183 return objs 1184 1185 def _getconfig(self) -> dict[str, Any]: 1186 """ 1187 Return the sub-dict in settings where this achievement's 1188 state is stored, creating it if need be. 1189 """ 1190 val: dict[str, Any] = _ba.app.config.setdefault( 1191 'Achievements', {} 1192 ).setdefault(self._name, {'Complete': False}) 1193 assert isinstance(val, dict) 1194 return val 1195 1196 def _remove_banner_slot(self) -> None: 1197 assert self._completion_banner_slot is not None 1198 _ba.app.ach.achievement_completion_banner_slots.remove( 1199 self._completion_banner_slot 1200 ) 1201 self._completion_banner_slot = None 1202 1203 def show_completion_banner(self, sound: bool = True) -> None: 1204 """Create the banner/sound for an acquired achievement announcement.""" 1205 from ba import _gameutils 1206 from bastd.actor.text import Text 1207 from bastd.actor.image import Image 1208 from ba._general import WeakCall 1209 from ba._language import Lstr 1210 from ba._messages import DieMessage 1211 from ba._generated.enums import TimeType, SpecialChar 1212 1213 app = _ba.app 1214 app.ach.last_achievement_display_time = _ba.time(TimeType.REAL) 1215 1216 # Just piggy-back onto any current activity 1217 # (should we use the session instead?..) 1218 activity = _ba.getactivity(doraise=False) 1219 1220 # If this gets called while this achievement is occupying a slot 1221 # already, ignore it. (probably should never happen in real 1222 # life but whatevs). 1223 if self._completion_banner_slot is not None: 1224 return 1225 1226 if activity is None: 1227 print('show_completion_banner() called with no current activity!') 1228 return 1229 1230 if sound: 1231 _ba.playsound(_ba.getsound('achievement'), host_only=True) 1232 else: 1233 _ba.timer( 1234 0.5, lambda: _ba.playsound(_ba.getsound('ding'), host_only=True) 1235 ) 1236 1237 in_time = 0.300 1238 out_time = 3.5 1239 1240 base_vr_depth = 200 1241 1242 # Find the first free slot. 1243 i = 0 1244 while True: 1245 if i not in app.ach.achievement_completion_banner_slots: 1246 app.ach.achievement_completion_banner_slots.add(i) 1247 self._completion_banner_slot = i 1248 1249 # Remove us from that slot when we close. 1250 # Use a real-timer in the UI context so the removal runs even 1251 # if our activity/session dies. 1252 with _ba.Context('ui'): 1253 _ba.timer( 1254 in_time + out_time, 1255 self._remove_banner_slot, 1256 timetype=TimeType.REAL, 1257 ) 1258 break 1259 i += 1 1260 assert self._completion_banner_slot is not None 1261 y_offs = 110 * self._completion_banner_slot 1262 objs: list[ba.Actor] = [] 1263 obj = Image( 1264 _ba.gettexture('shadow'), 1265 position=(-30, 30 + y_offs), 1266 front=True, 1267 attach=Image.Attach.BOTTOM_CENTER, 1268 transition=Image.Transition.IN_BOTTOM, 1269 vr_depth=base_vr_depth - 100, 1270 transition_delay=in_time, 1271 transition_out_delay=out_time, 1272 color=(0.0, 0.1, 0, 1), 1273 scale=(1000, 300), 1274 ).autoretain() 1275 objs.append(obj) 1276 assert obj.node 1277 obj.node.host_only = True 1278 obj = Image( 1279 _ba.gettexture('light'), 1280 position=(-180, 60 + y_offs), 1281 front=True, 1282 attach=Image.Attach.BOTTOM_CENTER, 1283 vr_depth=base_vr_depth, 1284 transition=Image.Transition.IN_BOTTOM, 1285 transition_delay=in_time, 1286 transition_out_delay=out_time, 1287 color=(1.8, 1.8, 1.0, 0.0), 1288 scale=(40, 300), 1289 ).autoretain() 1290 objs.append(obj) 1291 assert obj.node 1292 obj.node.host_only = True 1293 obj.node.premultiplied = True 1294 combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 2}) 1295 _gameutils.animate( 1296 combine, 1297 'input0', 1298 { 1299 in_time: 0, 1300 in_time + 0.4: 30, 1301 in_time + 0.5: 40, 1302 in_time + 0.6: 30, 1303 in_time + 2.0: 0, 1304 }, 1305 ) 1306 _gameutils.animate( 1307 combine, 1308 'input1', 1309 { 1310 in_time: 0, 1311 in_time + 0.4: 200, 1312 in_time + 0.5: 500, 1313 in_time + 0.6: 200, 1314 in_time + 2.0: 0, 1315 }, 1316 ) 1317 combine.connectattr('output', obj.node, 'scale') 1318 _gameutils.animate(obj.node, 'rotate', {0: 0.0, 0.35: 360.0}, loop=True) 1319 obj = Image( 1320 self.get_icon_texture(True), 1321 position=(-180, 60 + y_offs), 1322 attach=Image.Attach.BOTTOM_CENTER, 1323 front=True, 1324 vr_depth=base_vr_depth - 10, 1325 transition=Image.Transition.IN_BOTTOM, 1326 transition_delay=in_time, 1327 transition_out_delay=out_time, 1328 scale=(100, 100), 1329 ).autoretain() 1330 objs.append(obj) 1331 assert obj.node 1332 obj.node.host_only = True 1333 1334 # Flash. 1335 color = self.get_icon_color(True) 1336 combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 3}) 1337 keys = { 1338 in_time: 1.0 * color[0], 1339 in_time + 0.4: 1.5 * color[0], 1340 in_time + 0.5: 6.0 * color[0], 1341 in_time + 0.6: 1.5 * color[0], 1342 in_time + 2.0: 1.0 * color[0], 1343 } 1344 _gameutils.animate(combine, 'input0', keys) 1345 keys = { 1346 in_time: 1.0 * color[1], 1347 in_time + 0.4: 1.5 * color[1], 1348 in_time + 0.5: 6.0 * color[1], 1349 in_time + 0.6: 1.5 * color[1], 1350 in_time + 2.0: 1.0 * color[1], 1351 } 1352 _gameutils.animate(combine, 'input1', keys) 1353 keys = { 1354 in_time: 1.0 * color[2], 1355 in_time + 0.4: 1.5 * color[2], 1356 in_time + 0.5: 6.0 * color[2], 1357 in_time + 0.6: 1.5 * color[2], 1358 in_time + 2.0: 1.0 * color[2], 1359 } 1360 _gameutils.animate(combine, 'input2', keys) 1361 combine.connectattr('output', obj.node, 'color') 1362 1363 obj = Image( 1364 _ba.gettexture('achievementOutline'), 1365 model_transparent=_ba.getmodel('achievementOutline'), 1366 position=(-180, 60 + y_offs), 1367 front=True, 1368 attach=Image.Attach.BOTTOM_CENTER, 1369 vr_depth=base_vr_depth, 1370 transition=Image.Transition.IN_BOTTOM, 1371 transition_delay=in_time, 1372 transition_out_delay=out_time, 1373 scale=(100, 100), 1374 ).autoretain() 1375 assert obj.node 1376 obj.node.host_only = True 1377 1378 # Flash. 1379 color = (2, 1.4, 0.4, 1) 1380 combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 3}) 1381 keys = { 1382 in_time: 1.0 * color[0], 1383 in_time + 0.4: 1.5 * color[0], 1384 in_time + 0.5: 6.0 * color[0], 1385 in_time + 0.6: 1.5 * color[0], 1386 in_time + 2.0: 1.0 * color[0], 1387 } 1388 _gameutils.animate(combine, 'input0', keys) 1389 keys = { 1390 in_time: 1.0 * color[1], 1391 in_time + 0.4: 1.5 * color[1], 1392 in_time + 0.5: 6.0 * color[1], 1393 in_time + 0.6: 1.5 * color[1], 1394 in_time + 2.0: 1.0 * color[1], 1395 } 1396 _gameutils.animate(combine, 'input1', keys) 1397 keys = { 1398 in_time: 1.0 * color[2], 1399 in_time + 0.4: 1.5 * color[2], 1400 in_time + 0.5: 6.0 * color[2], 1401 in_time + 0.6: 1.5 * color[2], 1402 in_time + 2.0: 1.0 * color[2], 1403 } 1404 _gameutils.animate(combine, 'input2', keys) 1405 combine.connectattr('output', obj.node, 'color') 1406 objs.append(obj) 1407 1408 objt = Text( 1409 Lstr( 1410 value='${A}:', subs=[('${A}', Lstr(resource='achievementText'))] 1411 ), 1412 position=(-120, 91 + y_offs), 1413 front=True, 1414 v_attach=Text.VAttach.BOTTOM, 1415 vr_depth=base_vr_depth - 10, 1416 transition=Text.Transition.IN_BOTTOM, 1417 flatness=0.5, 1418 transition_delay=in_time, 1419 transition_out_delay=out_time, 1420 color=(1, 1, 1, 0.8), 1421 scale=0.65, 1422 ).autoretain() 1423 objs.append(objt) 1424 assert objt.node 1425 objt.node.host_only = True 1426 1427 objt = Text( 1428 self.display_name, 1429 position=(-120, 50 + y_offs), 1430 front=True, 1431 v_attach=Text.VAttach.BOTTOM, 1432 transition=Text.Transition.IN_BOTTOM, 1433 vr_depth=base_vr_depth, 1434 flatness=0.5, 1435 transition_delay=in_time, 1436 transition_out_delay=out_time, 1437 flash=True, 1438 color=(1, 0.8, 0, 1.0), 1439 scale=1.5, 1440 ).autoretain() 1441 objs.append(objt) 1442 assert objt.node 1443 objt.node.host_only = True 1444 1445 objt = Text( 1446 _ba.charstr(SpecialChar.TICKET), 1447 position=(-120 - 170 + 5, 75 + y_offs - 20), 1448 front=True, 1449 v_attach=Text.VAttach.BOTTOM, 1450 h_align=Text.HAlign.CENTER, 1451 v_align=Text.VAlign.CENTER, 1452 transition=Text.Transition.IN_BOTTOM, 1453 vr_depth=base_vr_depth, 1454 transition_delay=in_time, 1455 transition_out_delay=out_time, 1456 flash=True, 1457 color=(0.5, 0.5, 0.5, 1), 1458 scale=3.0, 1459 ).autoretain() 1460 objs.append(objt) 1461 assert objt.node 1462 objt.node.host_only = True 1463 1464 objt = Text( 1465 '+' + str(self.get_award_ticket_value()), 1466 position=(-120 - 180 + 5, 80 + y_offs - 20), 1467 v_attach=Text.VAttach.BOTTOM, 1468 front=True, 1469 h_align=Text.HAlign.CENTER, 1470 v_align=Text.VAlign.CENTER, 1471 transition=Text.Transition.IN_BOTTOM, 1472 vr_depth=base_vr_depth, 1473 flatness=0.5, 1474 shadow=1.0, 1475 transition_delay=in_time, 1476 transition_out_delay=out_time, 1477 flash=True, 1478 color=(0, 1, 0, 1), 1479 scale=1.5, 1480 ).autoretain() 1481 objs.append(objt) 1482 assert objt.node 1483 objt.node.host_only = True 1484 1485 # Add the 'x 2' if we've got pro. 1486 if app.accounts_v1.have_pro(): 1487 objt = Text( 1488 'x 2', 1489 position=(-120 - 180 + 45, 80 + y_offs - 50), 1490 v_attach=Text.VAttach.BOTTOM, 1491 front=True, 1492 h_align=Text.HAlign.CENTER, 1493 v_align=Text.VAlign.CENTER, 1494 transition=Text.Transition.IN_BOTTOM, 1495 vr_depth=base_vr_depth, 1496 flatness=0.5, 1497 shadow=1.0, 1498 transition_delay=in_time, 1499 transition_out_delay=out_time, 1500 flash=True, 1501 color=(0.4, 0, 1, 1), 1502 scale=0.9, 1503 ).autoretain() 1504 objs.append(objt) 1505 assert objt.node 1506 objt.node.host_only = True 1507 1508 objt = Text( 1509 self.description_complete, 1510 position=(-120, 30 + y_offs), 1511 front=True, 1512 v_attach=Text.VAttach.BOTTOM, 1513 transition=Text.Transition.IN_BOTTOM, 1514 vr_depth=base_vr_depth - 10, 1515 flatness=0.5, 1516 transition_delay=in_time, 1517 transition_out_delay=out_time, 1518 color=(1.0, 0.7, 0.5, 1.0), 1519 scale=0.8, 1520 ).autoretain() 1521 objs.append(objt) 1522 assert objt.node 1523 objt.node.host_only = True 1524 1525 for actor in objs: 1526 _ba.timer( 1527 out_time + 1.000, WeakCall(actor.handlemessage, DieMessage()) 1528 )
Represents attributes and state for an individual achievement.
Category: App Classes
641 def __init__( 642 self, 643 name: str, 644 icon_name: str, 645 icon_color: Sequence[float], 646 level_name: str, 647 award: int, 648 hard_mode_only: bool = False, 649 ): 650 self._name = name 651 self._icon_name = icon_name 652 self._icon_color: Sequence[float] = list(icon_color) + [1] 653 self._level_name = level_name 654 self._completion_banner_slot: int | None = None 655 self._award = award 656 self._hard_mode_only = hard_mode_only
668 def get_icon_texture(self, complete: bool) -> ba.Texture: 669 """Return the icon texture to display for this achievement""" 670 return _ba.gettexture( 671 self._icon_name if complete else 'achievementEmpty' 672 )
Return the icon texture to display for this achievement
674 def get_icon_color(self, complete: bool) -> Sequence[float]: 675 """Return the color tint for this Achievement's icon.""" 676 if complete: 677 return self._icon_color 678 return 1.0, 1.0, 1.0, 0.6
Return the color tint for this Achievement's icon.
692 def announce_completion(self, sound: bool = True) -> None: 693 """Kick off an announcement for this achievement's completion.""" 694 from ba._generated.enums import TimeType 695 696 app = _ba.app 697 698 # Even though there are technically achievements when we're not 699 # signed in, lets not show them (otherwise we tend to get 700 # confusing 'controller connected' achievements popping up while 701 # waiting to sign in which can be confusing). 702 if _internal.get_v1_account_state() != 'signed_in': 703 return 704 705 # If we're being freshly complete, display/report it and whatnot. 706 if (self, sound) not in app.ach.achievements_to_display: 707 app.ach.achievements_to_display.append((self, sound)) 708 709 # If there's no achievement display timer going, kick one off 710 # (if one's already running it will pick this up before it dies). 711 712 # Need to check last time too; its possible our timer wasn't able to 713 # clear itself if an activity died and took it down with it. 714 if ( 715 app.ach.achievement_display_timer is None 716 or _ba.time(TimeType.REAL) - app.ach.last_achievement_display_time 717 > 2.0 718 ) and _ba.getactivity(doraise=False) is not None: 719 app.ach.achievement_display_timer = _ba.Timer( 720 1.0, 721 _display_next_achievement, 722 repeat=True, 723 timetype=TimeType.BASE, 724 ) 725 726 # Show the first immediately. 727 _display_next_achievement()
Kick off an announcement for this achievement's completion.
729 def set_complete(self, complete: bool = True) -> None: 730 """Set an achievement's completed state. 731 732 note this only sets local state; use a transaction to 733 actually award achievements. 734 """ 735 config = self._getconfig() 736 if complete != config['Complete']: 737 config['Complete'] = complete
Set an achievement's completed state.
note this only sets local state; use a transaction to actually award achievements.
833 def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int: 834 """Get the ticket award value for this achievement.""" 835 val: int = _internal.get_v1_account_misc_read_val( 836 'achAward.' + self._name, self._award 837 ) * _get_ach_mult(include_pro_bonus) 838 assert isinstance(val, int) 839 return val
Get the ticket award value for this achievement.
850 def create_display( 851 self, 852 x: float, 853 y: float, 854 delay: float, 855 outdelay: float | None = None, 856 color: Sequence[float] | None = None, 857 style: str = 'post_game', 858 ) -> list[ba.Actor]: 859 """Create a display for the Achievement. 860 861 Shows the Achievement icon, name, and description. 862 """ 863 # pylint: disable=cyclic-import 864 from ba._language import Lstr 865 from ba._generated.enums import SpecialChar 866 from ba._coopsession import CoopSession 867 from bastd.actor.image import Image 868 from bastd.actor.text import Text 869 870 # Yeah this needs cleaning up. 871 if style == 'post_game': 872 in_game_colors = False 873 in_main_menu = False 874 h_attach = Text.HAttach.CENTER 875 v_attach = Text.VAttach.CENTER 876 attach = Image.Attach.CENTER 877 elif style == 'in_game': 878 in_game_colors = True 879 in_main_menu = False 880 h_attach = Text.HAttach.LEFT 881 v_attach = Text.VAttach.TOP 882 attach = Image.Attach.TOP_LEFT 883 elif style == 'news': 884 in_game_colors = True 885 in_main_menu = True 886 h_attach = Text.HAttach.CENTER 887 v_attach = Text.VAttach.TOP 888 attach = Image.Attach.TOP_CENTER 889 else: 890 raise ValueError('invalid style "' + style + '"') 891 892 # Attempt to determine what campaign we're in 893 # (so we know whether to show "hard mode only"). 894 if in_main_menu: 895 hmo = False 896 else: 897 try: 898 session = _ba.getsession() 899 if isinstance(session, CoopSession): 900 campaign = session.campaign 901 assert campaign is not None 902 hmo = self._hard_mode_only and campaign.name == 'Easy' 903 else: 904 hmo = False 905 except Exception: 906 print_exception('Error determining campaign.') 907 hmo = False 908 909 objs: list[ba.Actor] 910 911 if in_game_colors: 912 objs = [] 913 out_delay_fin = (delay + outdelay) if outdelay is not None else None 914 if color is not None: 915 cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2], color[3]) 916 cl2 = color 917 else: 918 cl1 = (1.5, 1.5, 2, 1.0) 919 cl2 = (0.8, 0.8, 1.0, 1.0) 920 921 if hmo: 922 cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6) 923 cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2) 924 925 objs.append( 926 Image( 927 self.get_icon_texture(False), 928 host_only=True, 929 color=cl1, 930 position=(x - 25, y + 5), 931 attach=attach, 932 transition=Image.Transition.FADE_IN, 933 transition_delay=delay, 934 vr_depth=4, 935 transition_out_delay=out_delay_fin, 936 scale=(40, 40), 937 ).autoretain() 938 ) 939 txt = self.display_name 940 txt_s = 0.85 941 txt_max_w = 300 942 objs.append( 943 Text( 944 txt, 945 host_only=True, 946 maxwidth=txt_max_w, 947 position=(x, y + 2), 948 transition=Text.Transition.FADE_IN, 949 scale=txt_s, 950 flatness=0.6, 951 shadow=0.5, 952 h_attach=h_attach, 953 v_attach=v_attach, 954 color=cl2, 955 transition_delay=delay + 0.05, 956 transition_out_delay=out_delay_fin, 957 ).autoretain() 958 ) 959 txt2_s = 0.62 960 txt2_max_w = 400 961 objs.append( 962 Text( 963 self.description_full if in_main_menu else self.description, 964 host_only=True, 965 maxwidth=txt2_max_w, 966 position=(x, y - 14), 967 transition=Text.Transition.FADE_IN, 968 vr_depth=-5, 969 h_attach=h_attach, 970 v_attach=v_attach, 971 scale=txt2_s, 972 flatness=1.0, 973 shadow=0.5, 974 color=cl2, 975 transition_delay=delay + 0.1, 976 transition_out_delay=out_delay_fin, 977 ).autoretain() 978 ) 979 980 if hmo: 981 txtactor = Text( 982 Lstr(resource='difficultyHardOnlyText'), 983 host_only=True, 984 maxwidth=txt2_max_w * 0.7, 985 position=(x + 60, y + 5), 986 transition=Text.Transition.FADE_IN, 987 vr_depth=-5, 988 h_attach=h_attach, 989 v_attach=v_attach, 990 h_align=Text.HAlign.CENTER, 991 v_align=Text.VAlign.CENTER, 992 scale=txt_s * 0.8, 993 flatness=1.0, 994 shadow=0.5, 995 color=(1, 1, 0.6, 1), 996 transition_delay=delay + 0.1, 997 transition_out_delay=out_delay_fin, 998 ).autoretain() 999 txtactor.node.rotate = 10 1000 objs.append(txtactor) 1001 1002 # Ticket-award. 1003 award_x = -100 1004 objs.append( 1005 Text( 1006 _ba.charstr(SpecialChar.TICKET), 1007 host_only=True, 1008 position=(x + award_x + 33, y + 7), 1009 transition=Text.Transition.FADE_IN, 1010 scale=1.5, 1011 h_attach=h_attach, 1012 v_attach=v_attach, 1013 h_align=Text.HAlign.CENTER, 1014 v_align=Text.VAlign.CENTER, 1015 color=(1, 1, 1, 0.2 if hmo else 0.4), 1016 transition_delay=delay + 0.05, 1017 transition_out_delay=out_delay_fin, 1018 ).autoretain() 1019 ) 1020 objs.append( 1021 Text( 1022 '+' + str(self.get_award_ticket_value()), 1023 host_only=True, 1024 position=(x + award_x + 28, y + 16), 1025 transition=Text.Transition.FADE_IN, 1026 scale=0.7, 1027 flatness=1, 1028 h_attach=h_attach, 1029 v_attach=v_attach, 1030 h_align=Text.HAlign.CENTER, 1031 v_align=Text.VAlign.CENTER, 1032 color=cl2, 1033 transition_delay=delay + 0.05, 1034 transition_out_delay=out_delay_fin, 1035 ).autoretain() 1036 ) 1037 1038 else: 1039 complete = self.complete 1040 objs = [] 1041 c_icon = self.get_icon_color(complete) 1042 if hmo and not complete: 1043 c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3) 1044 objs.append( 1045 Image( 1046 self.get_icon_texture(complete), 1047 host_only=True, 1048 color=c_icon, 1049 position=(x - 25, y + 5), 1050 attach=attach, 1051 vr_depth=4, 1052 transition=Image.Transition.IN_RIGHT, 1053 transition_delay=delay, 1054 transition_out_delay=None, 1055 scale=(40, 40), 1056 ).autoretain() 1057 ) 1058 if complete: 1059 objs.append( 1060 Image( 1061 _ba.gettexture('achievementOutline'), 1062 host_only=True, 1063 model_transparent=_ba.getmodel('achievementOutline'), 1064 color=(2, 1.4, 0.4, 1), 1065 vr_depth=8, 1066 position=(x - 25, y + 5), 1067 attach=attach, 1068 transition=Image.Transition.IN_RIGHT, 1069 transition_delay=delay, 1070 transition_out_delay=None, 1071 scale=(40, 40), 1072 ).autoretain() 1073 ) 1074 else: 1075 if not complete: 1076 award_x = -100 1077 objs.append( 1078 Text( 1079 _ba.charstr(SpecialChar.TICKET), 1080 host_only=True, 1081 position=(x + award_x + 33, y + 7), 1082 transition=Text.Transition.IN_RIGHT, 1083 scale=1.5, 1084 h_attach=h_attach, 1085 v_attach=v_attach, 1086 h_align=Text.HAlign.CENTER, 1087 v_align=Text.VAlign.CENTER, 1088 color=(1, 1, 1, 0.4) 1089 if complete 1090 else (1, 1, 1, (0.1 if hmo else 0.2)), 1091 transition_delay=delay + 0.05, 1092 transition_out_delay=None, 1093 ).autoretain() 1094 ) 1095 objs.append( 1096 Text( 1097 '+' + str(self.get_award_ticket_value()), 1098 host_only=True, 1099 position=(x + award_x + 28, y + 16), 1100 transition=Text.Transition.IN_RIGHT, 1101 scale=0.7, 1102 flatness=1, 1103 h_attach=h_attach, 1104 v_attach=v_attach, 1105 h_align=Text.HAlign.CENTER, 1106 v_align=Text.VAlign.CENTER, 1107 color=( 1108 (0.8, 0.93, 0.8, 1.0) 1109 if complete 1110 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1111 ), 1112 transition_delay=delay + 0.05, 1113 transition_out_delay=None, 1114 ).autoretain() 1115 ) 1116 1117 # Show 'hard-mode-only' only over incomplete achievements 1118 # when that's the case. 1119 if hmo: 1120 txtactor = Text( 1121 Lstr(resource='difficultyHardOnlyText'), 1122 host_only=True, 1123 maxwidth=300 * 0.7, 1124 position=(x + 60, y + 5), 1125 transition=Text.Transition.FADE_IN, 1126 vr_depth=-5, 1127 h_attach=h_attach, 1128 v_attach=v_attach, 1129 h_align=Text.HAlign.CENTER, 1130 v_align=Text.VAlign.CENTER, 1131 scale=0.85 * 0.8, 1132 flatness=1.0, 1133 shadow=0.5, 1134 color=(1, 1, 0.6, 1), 1135 transition_delay=delay + 0.05, 1136 transition_out_delay=None, 1137 ).autoretain() 1138 assert txtactor.node 1139 txtactor.node.rotate = 10 1140 objs.append(txtactor) 1141 1142 objs.append( 1143 Text( 1144 self.display_name, 1145 host_only=True, 1146 maxwidth=300, 1147 position=(x, y + 2), 1148 transition=Text.Transition.IN_RIGHT, 1149 scale=0.85, 1150 flatness=0.6, 1151 h_attach=h_attach, 1152 v_attach=v_attach, 1153 color=( 1154 (0.8, 0.93, 0.8, 1.0) 1155 if complete 1156 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1157 ), 1158 transition_delay=delay + 0.05, 1159 transition_out_delay=None, 1160 ).autoretain() 1161 ) 1162 objs.append( 1163 Text( 1164 self.description_complete if complete else self.description, 1165 host_only=True, 1166 maxwidth=400, 1167 position=(x, y - 14), 1168 transition=Text.Transition.IN_RIGHT, 1169 vr_depth=-5, 1170 h_attach=h_attach, 1171 v_attach=v_attach, 1172 scale=0.62, 1173 flatness=1.0, 1174 color=( 1175 (0.6, 0.6, 0.6, 1.0) 1176 if complete 1177 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1178 ), 1179 transition_delay=delay + 0.1, 1180 transition_out_delay=None, 1181 ).autoretain() 1182 ) 1183 return objs
Create a display for the Achievement.
Shows the Achievement icon, name, and description.
67class AchievementSubsystem: 68 """Subsystem for achievement handling. 69 70 Category: **App Classes** 71 72 Access the single shared instance of this class at 'ba.app.ach'. 73 """ 74 75 def __init__(self) -> None: 76 self.achievements: list[Achievement] = [] 77 self.achievements_to_display: (list[tuple[ba.Achievement, bool]]) = [] 78 self.achievement_display_timer: _ba.Timer | None = None 79 self.last_achievement_display_time: float = 0.0 80 self.achievement_completion_banner_slots: set[int] = set() 81 self._init_achievements() 82 83 def _init_achievements(self) -> None: 84 """Fill in available achievements.""" 85 86 achs = self.achievements 87 88 # 5 89 achs.append( 90 Achievement('In Control', 'achievementInControl', (1, 1, 1), '', 5) 91 ) 92 # 15 93 achs.append( 94 Achievement( 95 'Sharing is Caring', 96 'achievementSharingIsCaring', 97 (1, 1, 1), 98 '', 99 15, 100 ) 101 ) 102 # 10 103 achs.append( 104 Achievement( 105 'Dual Wielding', 'achievementDualWielding', (1, 1, 1), '', 10 106 ) 107 ) 108 109 # 10 110 achs.append( 111 Achievement( 112 'Free Loader', 'achievementFreeLoader', (1, 1, 1), '', 10 113 ) 114 ) 115 # 20 116 achs.append( 117 Achievement( 118 'Team Player', 'achievementTeamPlayer', (1, 1, 1), '', 20 119 ) 120 ) 121 122 # 5 123 achs.append( 124 Achievement( 125 'Onslaught Training Victory', 126 'achievementOnslaught', 127 (1, 1, 1), 128 'Default:Onslaught Training', 129 5, 130 ) 131 ) 132 # 5 133 achs.append( 134 Achievement( 135 'Off You Go Then', 136 'achievementOffYouGo', 137 (1, 1.1, 1.3), 138 'Default:Onslaught Training', 139 5, 140 ) 141 ) 142 # 10 143 achs.append( 144 Achievement( 145 'Boxer', 146 'achievementBoxer', 147 (1, 0.6, 0.6), 148 'Default:Onslaught Training', 149 10, 150 hard_mode_only=True, 151 ) 152 ) 153 154 # 10 155 achs.append( 156 Achievement( 157 'Rookie Onslaught Victory', 158 'achievementOnslaught', 159 (0.5, 1.4, 0.6), 160 'Default:Rookie Onslaught', 161 10, 162 ) 163 ) 164 # 10 165 achs.append( 166 Achievement( 167 'Mine Games', 168 'achievementMine', 169 (1, 1, 1.4), 170 'Default:Rookie Onslaught', 171 10, 172 ) 173 ) 174 # 15 175 achs.append( 176 Achievement( 177 'Flawless Victory', 178 'achievementFlawlessVictory', 179 (1, 1, 1), 180 'Default:Rookie Onslaught', 181 15, 182 hard_mode_only=True, 183 ) 184 ) 185 186 # 10 187 achs.append( 188 Achievement( 189 'Rookie Football Victory', 190 'achievementFootballVictory', 191 (1.0, 1, 0.6), 192 'Default:Rookie Football', 193 10, 194 ) 195 ) 196 # 10 197 achs.append( 198 Achievement( 199 'Super Punch', 200 'achievementSuperPunch', 201 (1, 1, 1.8), 202 'Default:Rookie Football', 203 10, 204 ) 205 ) 206 # 15 207 achs.append( 208 Achievement( 209 'Rookie Football Shutout', 210 'achievementFootballShutout', 211 (1, 1, 1), 212 'Default:Rookie Football', 213 15, 214 hard_mode_only=True, 215 ) 216 ) 217 218 # 15 219 achs.append( 220 Achievement( 221 'Pro Onslaught Victory', 222 'achievementOnslaught', 223 (0.3, 1, 2.0), 224 'Default:Pro Onslaught', 225 15, 226 ) 227 ) 228 # 15 229 achs.append( 230 Achievement( 231 'Boom Goes the Dynamite', 232 'achievementTNT', 233 (1.4, 1.2, 0.8), 234 'Default:Pro Onslaught', 235 15, 236 ) 237 ) 238 # 20 239 achs.append( 240 Achievement( 241 'Pro Boxer', 242 'achievementBoxer', 243 (2, 2, 0), 244 'Default:Pro Onslaught', 245 20, 246 hard_mode_only=True, 247 ) 248 ) 249 250 # 15 251 achs.append( 252 Achievement( 253 'Pro Football Victory', 254 'achievementFootballVictory', 255 (1.3, 1.3, 2.0), 256 'Default:Pro Football', 257 15, 258 ) 259 ) 260 # 15 261 achs.append( 262 Achievement( 263 'Super Mega Punch', 264 'achievementSuperPunch', 265 (2, 1, 0.6), 266 'Default:Pro Football', 267 15, 268 ) 269 ) 270 # 20 271 achs.append( 272 Achievement( 273 'Pro Football Shutout', 274 'achievementFootballShutout', 275 (0.7, 0.7, 2.0), 276 'Default:Pro Football', 277 20, 278 hard_mode_only=True, 279 ) 280 ) 281 282 # 15 283 achs.append( 284 Achievement( 285 'Pro Runaround Victory', 286 'achievementRunaround', 287 (1, 1, 1), 288 'Default:Pro Runaround', 289 15, 290 ) 291 ) 292 # 20 293 achs.append( 294 Achievement( 295 'Precision Bombing', 296 'achievementCrossHair', 297 (1, 1, 1.3), 298 'Default:Pro Runaround', 299 20, 300 hard_mode_only=True, 301 ) 302 ) 303 # 25 304 achs.append( 305 Achievement( 306 'The Wall', 307 'achievementWall', 308 (1, 0.7, 0.7), 309 'Default:Pro Runaround', 310 25, 311 hard_mode_only=True, 312 ) 313 ) 314 315 # 30 316 achs.append( 317 Achievement( 318 'Uber Onslaught Victory', 319 'achievementOnslaught', 320 (2, 2, 1), 321 'Default:Uber Onslaught', 322 30, 323 ) 324 ) 325 # 30 326 achs.append( 327 Achievement( 328 'Gold Miner', 329 'achievementMine', 330 (2, 1.6, 0.2), 331 'Default:Uber Onslaught', 332 30, 333 hard_mode_only=True, 334 ) 335 ) 336 # 30 337 achs.append( 338 Achievement( 339 'TNT Terror', 340 'achievementTNT', 341 (2, 1.8, 0.3), 342 'Default:Uber Onslaught', 343 30, 344 hard_mode_only=True, 345 ) 346 ) 347 348 # 30 349 achs.append( 350 Achievement( 351 'Uber Football Victory', 352 'achievementFootballVictory', 353 (1.8, 1.4, 0.3), 354 'Default:Uber Football', 355 30, 356 ) 357 ) 358 # 30 359 achs.append( 360 Achievement( 361 'Got the Moves', 362 'achievementGotTheMoves', 363 (2, 1, 0), 364 'Default:Uber Football', 365 30, 366 hard_mode_only=True, 367 ) 368 ) 369 # 40 370 achs.append( 371 Achievement( 372 'Uber Football Shutout', 373 'achievementFootballShutout', 374 (2, 2, 0), 375 'Default:Uber Football', 376 40, 377 hard_mode_only=True, 378 ) 379 ) 380 381 # 30 382 achs.append( 383 Achievement( 384 'Uber Runaround Victory', 385 'achievementRunaround', 386 (1.5, 1.2, 0.2), 387 'Default:Uber Runaround', 388 30, 389 ) 390 ) 391 # 40 392 achs.append( 393 Achievement( 394 'The Great Wall', 395 'achievementWall', 396 (2, 1.7, 0.4), 397 'Default:Uber Runaround', 398 40, 399 hard_mode_only=True, 400 ) 401 ) 402 # 40 403 achs.append( 404 Achievement( 405 'Stayin\' Alive', 406 'achievementStayinAlive', 407 (2, 2, 1), 408 'Default:Uber Runaround', 409 40, 410 hard_mode_only=True, 411 ) 412 ) 413 414 # 20 415 achs.append( 416 Achievement( 417 'Last Stand Master', 418 'achievementMedalSmall', 419 (2, 1.5, 0.3), 420 'Default:The Last Stand', 421 20, 422 hard_mode_only=True, 423 ) 424 ) 425 # 40 426 achs.append( 427 Achievement( 428 'Last Stand Wizard', 429 'achievementMedalMedium', 430 (2, 1.5, 0.3), 431 'Default:The Last Stand', 432 40, 433 hard_mode_only=True, 434 ) 435 ) 436 # 60 437 achs.append( 438 Achievement( 439 'Last Stand God', 440 'achievementMedalLarge', 441 (2, 1.5, 0.3), 442 'Default:The Last Stand', 443 60, 444 hard_mode_only=True, 445 ) 446 ) 447 448 # 5 449 achs.append( 450 Achievement( 451 'Onslaught Master', 452 'achievementMedalSmall', 453 (0.7, 1, 0.7), 454 'Challenges:Infinite Onslaught', 455 5, 456 ) 457 ) 458 # 15 459 achs.append( 460 Achievement( 461 'Onslaught Wizard', 462 'achievementMedalMedium', 463 (0.7, 1.0, 0.7), 464 'Challenges:Infinite Onslaught', 465 15, 466 ) 467 ) 468 # 30 469 achs.append( 470 Achievement( 471 'Onslaught God', 472 'achievementMedalLarge', 473 (0.7, 1.0, 0.7), 474 'Challenges:Infinite Onslaught', 475 30, 476 ) 477 ) 478 479 # 5 480 achs.append( 481 Achievement( 482 'Runaround Master', 483 'achievementMedalSmall', 484 (1.0, 1.0, 1.2), 485 'Challenges:Infinite Runaround', 486 5, 487 ) 488 ) 489 # 15 490 achs.append( 491 Achievement( 492 'Runaround Wizard', 493 'achievementMedalMedium', 494 (1.0, 1.0, 1.2), 495 'Challenges:Infinite Runaround', 496 15, 497 ) 498 ) 499 # 30 500 achs.append( 501 Achievement( 502 'Runaround God', 503 'achievementMedalLarge', 504 (1.0, 1.0, 1.2), 505 'Challenges:Infinite Runaround', 506 30, 507 ) 508 ) 509 510 def award_local_achievement(self, achname: str) -> None: 511 """For non-game-based achievements such as controller-connection.""" 512 try: 513 ach = self.get_achievement(achname) 514 if not ach.complete: 515 516 # Report new achievements to the game-service. 517 _internal.report_achievement(achname) 518 519 # And to our account. 520 _internal.add_transaction( 521 {'type': 'ACHIEVEMENT', 'name': achname} 522 ) 523 524 # Now attempt to show a banner. 525 self.display_achievement_banner(achname) 526 527 except Exception: 528 print_exception() 529 530 def display_achievement_banner(self, achname: str) -> None: 531 """Display a completion banner for an achievement. 532 533 (internal) 534 535 Used for server-driven achievements. 536 """ 537 try: 538 # FIXME: Need to get these using the UI context or some other 539 # purely local context somehow instead of trying to inject these 540 # into whatever activity happens to be active 541 # (since that won't work while in client mode). 542 activity = _ba.get_foreground_host_activity() 543 if activity is not None: 544 with _ba.Context(activity): 545 self.get_achievement(achname).announce_completion() 546 except Exception: 547 print_exception('error showing server ach') 548 549 def set_completed_achievements(self, achs: Sequence[str]) -> None: 550 """Set the current state of completed achievements. 551 552 (internal) 553 554 All achievements not included here will be set incomplete. 555 """ 556 557 # Note: This gets called whenever game-center/game-circle/etc tells 558 # us which achievements we currently have. We always defer to them, 559 # even if that means we have to un-set an achievement we think we have. 560 561 cfg = _ba.app.config 562 cfg['Achievements'] = {} 563 for a_name in achs: 564 self.get_achievement(a_name).set_complete(True) 565 cfg.commit() 566 567 def get_achievement(self, name: str) -> Achievement: 568 """Return an Achievement by name.""" 569 achs = [a for a in self.achievements if a.name == name] 570 assert len(achs) < 2 571 if not achs: 572 raise ValueError("Invalid achievement name: '" + name + "'") 573 return achs[0] 574 575 def achievements_for_coop_level(self, level_name: str) -> list[Achievement]: 576 """Given a level name, return achievements available for it.""" 577 578 # For the Easy campaign we return achievements for the Default 579 # campaign too. (want the user to see what achievements are part of the 580 # level even if they can't unlock them all on easy mode). 581 return [ 582 a 583 for a in self.achievements 584 if a.level_name 585 in (level_name, level_name.replace('Easy', 'Default')) 586 ] 587 588 def _test(self) -> None: 589 """For testing achievement animations.""" 590 from ba._generated.enums import TimeType 591 592 def testcall1() -> None: 593 self.achievements[0].announce_completion() 594 self.achievements[1].announce_completion() 595 self.achievements[2].announce_completion() 596 597 def testcall2() -> None: 598 self.achievements[3].announce_completion() 599 self.achievements[4].announce_completion() 600 self.achievements[5].announce_completion() 601 602 _ba.timer(3.0, testcall1, timetype=TimeType.BASE) 603 _ba.timer(7.0, testcall2, timetype=TimeType.BASE)
Subsystem for achievement handling.
Category: App Classes
Access the single shared instance of this class at 'ba.app.ach'.
75 def __init__(self) -> None: 76 self.achievements: list[Achievement] = [] 77 self.achievements_to_display: (list[tuple[ba.Achievement, bool]]) = [] 78 self.achievement_display_timer: _ba.Timer | None = None 79 self.last_achievement_display_time: float = 0.0 80 self.achievement_completion_banner_slots: set[int] = set() 81 self._init_achievements()
510 def award_local_achievement(self, achname: str) -> None: 511 """For non-game-based achievements such as controller-connection.""" 512 try: 513 ach = self.get_achievement(achname) 514 if not ach.complete: 515 516 # Report new achievements to the game-service. 517 _internal.report_achievement(achname) 518 519 # And to our account. 520 _internal.add_transaction( 521 {'type': 'ACHIEVEMENT', 'name': achname} 522 ) 523 524 # Now attempt to show a banner. 525 self.display_achievement_banner(achname) 526 527 except Exception: 528 print_exception()
For non-game-based achievements such as controller-connection.
567 def get_achievement(self, name: str) -> Achievement: 568 """Return an Achievement by name.""" 569 achs = [a for a in self.achievements if a.name == name] 570 assert len(achs) < 2 571 if not achs: 572 raise ValueError("Invalid achievement name: '" + name + "'") 573 return achs[0]
Return an Achievement by name.
575 def achievements_for_coop_level(self, level_name: str) -> list[Achievement]: 576 """Given a level name, return achievements available for it.""" 577 578 # For the Easy campaign we return achievements for the Default 579 # campaign too. (want the user to see what achievements are part of the 580 # level even if they can't unlock them all on easy mode). 581 return [ 582 a 583 for a in self.achievements 584 if a.level_name 585 in (level_name, level_name.replace('Easy', 'Default')) 586 ]
Given a level name, return achievements available for it.
33class Activity(DependencyComponent, Generic[PlayerType, TeamType]): 34 """Units of execution wrangled by a ba.Session. 35 36 Category: Gameplay Classes 37 38 Examples of Activities include games, score-screens, cutscenes, etc. 39 A ba.Session has one 'current' Activity at any time, though their existence 40 can overlap during transitions. 41 """ 42 43 # pylint: disable=too-many-public-methods 44 45 settings_raw: dict[str, Any] 46 """The settings dict passed in when the activity was made. 47 This attribute is deprecated and should be avoided when possible; 48 activities should pull all values they need from the 'settings' arg 49 passed to the Activity __init__ call.""" 50 51 teams: list[TeamType] 52 """The list of ba.Team-s in the Activity. This gets populated just 53 before on_begin() is called and is updated automatically as players 54 join or leave the game. (at least in free-for-all mode where every 55 player gets their own team; in teams mode there are always 2 teams 56 regardless of the player count).""" 57 58 players: list[PlayerType] 59 """The list of ba.Player-s in the Activity. This gets populated just 60 before on_begin() is called and is updated automatically as players 61 join or leave the game.""" 62 63 announce_player_deaths = False 64 """Whether to print every time a player dies. This can be pertinent 65 in games such as Death-Match but can be annoying in games where it 66 doesn't matter.""" 67 68 is_joining_activity = False 69 """Joining activities are for waiting for initial player joins. 70 They are treated slightly differently than regular activities, 71 mainly in that all players are passed to the activity at once 72 instead of as each joins.""" 73 74 allow_pausing = False 75 """Whether game-time should still progress when in menus/etc.""" 76 77 allow_kick_idle_players = True 78 """Whether idle players can potentially be kicked (should not happen in 79 menus/etc).""" 80 81 use_fixed_vr_overlay = False 82 """In vr mode, this determines whether overlay nodes (text, images, etc) 83 are created at a fixed position in space or one that moves based on 84 the current map. Generally this should be on for games and off for 85 transitions/score-screens/etc. that persist between maps.""" 86 87 slow_motion = False 88 """If True, runs in slow motion and turns down sound pitch.""" 89 90 inherits_slow_motion = False 91 """Set this to True to inherit slow motion setting from previous 92 activity (useful for transitions to avoid hitches).""" 93 94 inherits_music = False 95 """Set this to True to keep playing the music from the previous activity 96 (without even restarting it).""" 97 98 inherits_vr_camera_offset = False 99 """Set this to true to inherit VR camera offsets from the previous 100 activity (useful for preventing sporadic camera movement 101 during transitions).""" 102 103 inherits_vr_overlay_center = False 104 """Set this to true to inherit (non-fixed) VR overlay positioning from 105 the previous activity (useful for prevent sporadic overlay jostling 106 during transitions).""" 107 108 inherits_tint = False 109 """Set this to true to inherit screen tint/vignette colors from the 110 previous activity (useful to prevent sudden color changes during 111 transitions).""" 112 113 allow_mid_activity_joins: bool = True 114 """Whether players should be allowed to join in the middle of this 115 activity. Note that Sessions may not allow mid-activity-joins even 116 if the activity says its ok.""" 117 118 transition_time = 0.0 119 """If the activity fades or transitions in, it should set the length of 120 time here so that previous activities will be kept alive for that 121 long (avoiding 'holes' in the screen) 122 This value is given in real-time seconds.""" 123 124 can_show_ad_on_death = False 125 """Is it ok to show an ad after this activity ends before showing 126 the next activity?""" 127 128 def __init__(self, settings: dict): 129 """Creates an Activity in the current ba.Session. 130 131 The activity will not be actually run until ba.Session.setactivity 132 is called. 'settings' should be a dict of key/value pairs specific 133 to the activity. 134 135 Activities should preload as much of their media/etc as possible in 136 their constructor, but none of it should actually be used until they 137 are transitioned in. 138 """ 139 super().__init__() 140 141 # Create our internal engine data. 142 self._activity_data = _ba.register_activity(self) 143 144 assert isinstance(settings, dict) 145 assert _ba.getactivity() is self 146 147 self._globalsnode: ba.Node | None = None 148 149 # Player/Team types should have been specified as type args; 150 # grab those. 151 self._playertype: type[PlayerType] 152 self._teamtype: type[TeamType] 153 self._setup_player_and_team_types() 154 155 # FIXME: Relocate or remove the need for this stuff. 156 self.paused_text: ba.Actor | None = None 157 158 self._session = weakref.ref(_ba.getsession()) 159 160 # Preloaded data for actors, maps, etc; indexed by type. 161 self.preloads: dict[type, Any] = {} 162 163 # Hopefully can eventually kill this; activities should 164 # validate/store whatever settings they need at init time 165 # (in a more type-safe way). 166 self.settings_raw = settings 167 168 self._has_transitioned_in = False 169 self._has_begun = False 170 self._has_ended = False 171 self._activity_death_check_timer: ba.Timer | None = None 172 self._expired = False 173 self._delay_delete_players: list[PlayerType] = [] 174 self._delay_delete_teams: list[TeamType] = [] 175 self._players_that_left: list[weakref.ref[PlayerType]] = [] 176 self._teams_that_left: list[weakref.ref[TeamType]] = [] 177 self._transitioning_out = False 178 179 # A handy place to put most actors; this list is pruned of dead 180 # actors regularly and these actors are insta-killed as the activity 181 # is dying. 182 self._actor_refs: list[ba.Actor] = [] 183 self._actor_weak_refs: list[weakref.ref[ba.Actor]] = [] 184 self._last_prune_dead_actors_time = _ba.time() 185 self._prune_dead_actors_timer: ba.Timer | None = None 186 187 self.teams = [] 188 self.players = [] 189 190 self.lobby = None 191 self._stats: ba.Stats | None = None 192 self._customdata: dict | None = {} 193 194 def __del__(self) -> None: 195 196 # If the activity has been run then we should have already cleaned 197 # it up, but we still need to run expire calls for un-run activities. 198 if not self._expired: 199 with _ba.Context('empty'): 200 self._expire() 201 202 # Inform our owner that we officially kicked the bucket. 203 if self._transitioning_out: 204 session = self._session() 205 if session is not None: 206 _ba.pushcall( 207 Call( 208 session.transitioning_out_activity_was_freed, 209 self.can_show_ad_on_death, 210 ) 211 ) 212 213 @property 214 def globalsnode(self) -> ba.Node: 215 """The 'globals' ba.Node for the activity. This contains various 216 global controls and values. 217 """ 218 node = self._globalsnode 219 if not node: 220 raise NodeNotFoundError() 221 return node 222 223 @property 224 def stats(self) -> ba.Stats: 225 """The stats instance accessible while the activity is running. 226 227 If access is attempted before or after, raises a ba.NotFoundError. 228 """ 229 if self._stats is None: 230 from ba._error import NotFoundError 231 232 raise NotFoundError() 233 return self._stats 234 235 def on_expire(self) -> None: 236 """Called when your activity is being expired. 237 238 If your activity has created anything explicitly that may be retaining 239 a strong reference to the activity and preventing it from dying, you 240 should clear that out here. From this point on your activity's sole 241 purpose in life is to hit zero references and die so the next activity 242 can begin. 243 """ 244 245 @property 246 def customdata(self) -> dict: 247 """Entities needing to store simple data with an activity can put it 248 here. This dict will be deleted when the activity expires, so contained 249 objects generally do not need to worry about handling expired 250 activities. 251 """ 252 assert not self._expired 253 assert isinstance(self._customdata, dict) 254 return self._customdata 255 256 @property 257 def expired(self) -> bool: 258 """Whether the activity is expired. 259 260 An activity is set as expired when shutting down. 261 At this point no new nodes, timers, etc should be made, 262 run, etc, and the activity should be considered a 'zombie'. 263 """ 264 return self._expired 265 266 @property 267 def playertype(self) -> type[PlayerType]: 268 """The type of ba.Player this Activity is using.""" 269 return self._playertype 270 271 @property 272 def teamtype(self) -> type[TeamType]: 273 """The type of ba.Team this Activity is using.""" 274 return self._teamtype 275 276 def set_has_ended(self, val: bool) -> None: 277 """(internal)""" 278 self._has_ended = val 279 280 def expire(self) -> None: 281 """Begin the process of tearing down the activity. 282 283 (internal) 284 """ 285 from ba._generated.enums import TimeType 286 287 # Create a real-timer that watches a weak-ref of this activity 288 # and reports any lingering references keeping it alive. 289 # We store the timer on the activity so as soon as the activity dies 290 # it gets cleaned up. 291 with _ba.Context('ui'): 292 ref = weakref.ref(self) 293 self._activity_death_check_timer = _ba.Timer( 294 5.0, 295 Call(self._check_activity_death, ref, [0]), 296 repeat=True, 297 timetype=TimeType.REAL, 298 ) 299 300 # Run _expire in an empty context; nothing should be happening in 301 # there except deleting things which requires no context. 302 # (plus, _expire() runs in the destructor for un-run activities 303 # and we can't properly provide context in that situation anyway; might 304 # as well be consistent). 305 if not self._expired: 306 with _ba.Context('empty'): 307 self._expire() 308 else: 309 raise RuntimeError( 310 f'destroy() called when' f' already expired for {self}' 311 ) 312 313 def retain_actor(self, actor: ba.Actor) -> None: 314 """Add a strong-reference to a ba.Actor to this Activity. 315 316 The reference will be lazily released once ba.Actor.exists() 317 returns False for the Actor. The ba.Actor.autoretain() method 318 is a convenient way to access this same functionality. 319 """ 320 if __debug__: 321 from ba._actor import Actor 322 323 assert isinstance(actor, Actor) 324 self._actor_refs.append(actor) 325 326 def add_actor_weak_ref(self, actor: ba.Actor) -> None: 327 """Add a weak-reference to a ba.Actor to the ba.Activity. 328 329 (called by the ba.Actor base class) 330 """ 331 if __debug__: 332 from ba._actor import Actor 333 334 assert isinstance(actor, Actor) 335 self._actor_weak_refs.append(weakref.ref(actor)) 336 337 @property 338 def session(self) -> ba.Session: 339 """The ba.Session this ba.Activity belongs go. 340 341 Raises a ba.SessionNotFoundError if the Session no longer exists. 342 """ 343 session = self._session() 344 if session is None: 345 from ba._error import SessionNotFoundError 346 347 raise SessionNotFoundError() 348 return session 349 350 def on_player_join(self, player: PlayerType) -> None: 351 """Called when a new ba.Player has joined the Activity. 352 353 (including the initial set of Players) 354 """ 355 356 def on_player_leave(self, player: PlayerType) -> None: 357 """Called when a ba.Player is leaving the Activity.""" 358 359 def on_team_join(self, team: TeamType) -> None: 360 """Called when a new ba.Team joins the Activity. 361 362 (including the initial set of Teams) 363 """ 364 365 def on_team_leave(self, team: TeamType) -> None: 366 """Called when a ba.Team leaves the Activity.""" 367 368 def on_transition_in(self) -> None: 369 """Called when the Activity is first becoming visible. 370 371 Upon this call, the Activity should fade in backgrounds, 372 start playing music, etc. It does not yet have access to players 373 or teams, however. They remain owned by the previous Activity 374 up until ba.Activity.on_begin() is called. 375 """ 376 377 def on_transition_out(self) -> None: 378 """Called when your activity begins transitioning out. 379 380 Note that this may happen at any time even if ba.Activity.end() has 381 not been called. 382 """ 383 384 def on_begin(self) -> None: 385 """Called once the previous ba.Activity has finished transitioning out. 386 387 At this point the activity's initial players and teams are filled in 388 and it should begin its actual game logic. 389 """ 390 391 def handlemessage(self, msg: Any) -> Any: 392 """General message handling; can be passed any message object.""" 393 del msg # Unused arg. 394 return UNHANDLED 395 396 def has_transitioned_in(self) -> bool: 397 """Return whether ba.Activity.on_transition_in() 398 has been called.""" 399 return self._has_transitioned_in 400 401 def has_begun(self) -> bool: 402 """Return whether ba.Activity.on_begin() has been called.""" 403 return self._has_begun 404 405 def has_ended(self) -> bool: 406 """Return whether the activity has commenced ending.""" 407 return self._has_ended 408 409 def is_transitioning_out(self) -> bool: 410 """Return whether ba.Activity.on_transition_out() has been called.""" 411 return self._transitioning_out 412 413 def transition_in(self, prev_globals: ba.Node | None) -> None: 414 """Called by Session to kick off transition-in. 415 416 (internal) 417 """ 418 assert not self._has_transitioned_in 419 self._has_transitioned_in = True 420 421 # Set up the globals node based on our settings. 422 with _ba.Context(self): 423 glb = self._globalsnode = _ba.newnode('globals') 424 425 # Now that it's going to be front and center, 426 # set some global values based on what the activity wants. 427 glb.use_fixed_vr_overlay = self.use_fixed_vr_overlay 428 glb.allow_kick_idle_players = self.allow_kick_idle_players 429 if self.inherits_slow_motion and prev_globals is not None: 430 glb.slow_motion = prev_globals.slow_motion 431 else: 432 glb.slow_motion = self.slow_motion 433 if self.inherits_music and prev_globals is not None: 434 glb.music_continuous = True # Prevent restarting same music. 435 glb.music = prev_globals.music 436 glb.music_count += 1 437 if self.inherits_vr_camera_offset and prev_globals is not None: 438 glb.vr_camera_offset = prev_globals.vr_camera_offset 439 if self.inherits_vr_overlay_center and prev_globals is not None: 440 glb.vr_overlay_center = prev_globals.vr_overlay_center 441 glb.vr_overlay_center_enabled = ( 442 prev_globals.vr_overlay_center_enabled 443 ) 444 445 # If they want to inherit tint from the previous self. 446 if self.inherits_tint and prev_globals is not None: 447 glb.tint = prev_globals.tint 448 glb.vignette_outer = prev_globals.vignette_outer 449 glb.vignette_inner = prev_globals.vignette_inner 450 451 # Start pruning our various things periodically. 452 self._prune_dead_actors() 453 self._prune_dead_actors_timer = _ba.Timer( 454 5.17, self._prune_dead_actors, repeat=True 455 ) 456 457 _ba.timer(13.3, self._prune_delay_deletes, repeat=True) 458 459 # Also start our low-level scene running. 460 self._activity_data.start() 461 462 try: 463 self.on_transition_in() 464 except Exception: 465 print_exception(f'Error in on_transition_in for {self}.') 466 467 # Tell the C++ layer that this activity is the main one, so it uses 468 # settings from our globals, directs various events to us, etc. 469 self._activity_data.make_foreground() 470 471 def transition_out(self) -> None: 472 """Called by the Session to start us transitioning out.""" 473 assert not self._transitioning_out 474 self._transitioning_out = True 475 with _ba.Context(self): 476 try: 477 self.on_transition_out() 478 except Exception: 479 print_exception(f'Error in on_transition_out for {self}.') 480 481 def begin(self, session: ba.Session) -> None: 482 """Begin the activity. 483 484 (internal) 485 """ 486 487 assert not self._has_begun 488 489 # Inherit stats from the session. 490 self._stats = session.stats 491 492 # Add session's teams in. 493 for team in session.sessionteams: 494 self.add_team(team) 495 496 # Add session's players in. 497 for player in session.sessionplayers: 498 self.add_player(player) 499 500 self._has_begun = True 501 502 # Let the activity do its thing. 503 with _ba.Context(self): 504 # Note: do we want to catch errors here? 505 # Currently I believe we wind up canceling the 506 # activity launch; just wanna be sure that is intentional. 507 self.on_begin() 508 509 def end( 510 self, results: Any = None, delay: float = 0.0, force: bool = False 511 ) -> None: 512 """Commences Activity shutdown and delivers results to the ba.Session. 513 514 'delay' is the time delay before the Activity actually ends 515 (in seconds). Further calls to end() will be ignored up until 516 this time, unless 'force' is True, in which case the new results 517 will replace the old. 518 """ 519 520 # Ask the session to end us. 521 self.session.end_activity(self, results, delay, force) 522 523 def create_player(self, sessionplayer: ba.SessionPlayer) -> PlayerType: 524 """Create the Player instance for this Activity. 525 526 Subclasses can override this if the activity's player class 527 requires a custom constructor; otherwise it will be called with 528 no args. Note that the player object should not be used at this 529 point as it is not yet fully wired up; wait for 530 ba.Activity.on_player_join() for that. 531 """ 532 del sessionplayer # Unused. 533 player = self._playertype() 534 return player 535 536 def create_team(self, sessionteam: ba.SessionTeam) -> TeamType: 537 """Create the Team instance for this Activity. 538 539 Subclasses can override this if the activity's team class 540 requires a custom constructor; otherwise it will be called with 541 no args. Note that the team object should not be used at this 542 point as it is not yet fully wired up; wait for on_team_join() 543 for that. 544 """ 545 del sessionteam # Unused. 546 team = self._teamtype() 547 return team 548 549 def add_player(self, sessionplayer: ba.SessionPlayer) -> None: 550 """(internal)""" 551 assert sessionplayer.sessionteam is not None 552 sessionplayer.resetinput() 553 sessionteam = sessionplayer.sessionteam 554 assert sessionplayer in sessionteam.players 555 team = sessionteam.activityteam 556 assert team is not None 557 sessionplayer.setactivity(self) 558 with _ba.Context(self): 559 sessionplayer.activityplayer = player = self.create_player( 560 sessionplayer 561 ) 562 player.postinit(sessionplayer) 563 564 assert player not in team.players 565 team.players.append(player) 566 assert player in team.players 567 568 assert player not in self.players 569 self.players.append(player) 570 assert player in self.players 571 572 try: 573 self.on_player_join(player) 574 except Exception: 575 print_exception(f'Error in on_player_join for {self}.') 576 577 def remove_player(self, sessionplayer: ba.SessionPlayer) -> None: 578 """Remove a player from the Activity while it is running. 579 580 (internal) 581 """ 582 assert not self.expired 583 584 player: Any = sessionplayer.activityplayer 585 assert isinstance(player, self._playertype) 586 team: Any = sessionplayer.sessionteam.activityteam 587 assert isinstance(team, self._teamtype) 588 589 assert player in team.players 590 team.players.remove(player) 591 assert player not in team.players 592 593 assert player in self.players 594 self.players.remove(player) 595 assert player not in self.players 596 597 # This should allow our ba.Player instance to die. 598 # Complain if that doesn't happen. 599 # verify_object_death(player) 600 601 with _ba.Context(self): 602 try: 603 self.on_player_leave(player) 604 except Exception: 605 print_exception(f'Error in on_player_leave for {self}.') 606 try: 607 player.leave() 608 except Exception: 609 print_exception(f'Error on leave for {player} in {self}.') 610 611 self._reset_session_player_for_no_activity(sessionplayer) 612 613 # Add the player to a list to keep it around for a while. This is 614 # to discourage logic from firing on player object death, which 615 # may not happen until activity end if something is holding refs 616 # to it. 617 self._delay_delete_players.append(player) 618 self._players_that_left.append(weakref.ref(player)) 619 620 def add_team(self, sessionteam: ba.SessionTeam) -> None: 621 """Add a team to the Activity 622 623 (internal) 624 """ 625 assert not self.expired 626 627 with _ba.Context(self): 628 sessionteam.activityteam = team = self.create_team(sessionteam) 629 team.postinit(sessionteam) 630 self.teams.append(team) 631 try: 632 self.on_team_join(team) 633 except Exception: 634 print_exception(f'Error in on_team_join for {self}.') 635 636 def remove_team(self, sessionteam: ba.SessionTeam) -> None: 637 """Remove a team from a Running Activity 638 639 (internal) 640 """ 641 assert not self.expired 642 assert sessionteam.activityteam is not None 643 644 team: Any = sessionteam.activityteam 645 assert isinstance(team, self._teamtype) 646 647 assert team in self.teams 648 self.teams.remove(team) 649 assert team not in self.teams 650 651 with _ba.Context(self): 652 # Make a decent attempt to persevere if user code breaks. 653 try: 654 self.on_team_leave(team) 655 except Exception: 656 print_exception(f'Error in on_team_leave for {self}.') 657 try: 658 team.leave() 659 except Exception: 660 print_exception(f'Error on leave for {team} in {self}.') 661 662 sessionteam.activityteam = None 663 664 # Add the team to a list to keep it around for a while. This is 665 # to discourage logic from firing on team object death, which 666 # may not happen until activity end if something is holding refs 667 # to it. 668 self._delay_delete_teams.append(team) 669 self._teams_that_left.append(weakref.ref(team)) 670 671 def _reset_session_player_for_no_activity( 672 self, sessionplayer: ba.SessionPlayer 673 ) -> None: 674 675 # Let's be extra-defensive here: killing a node/input-call/etc 676 # could trigger user-code resulting in errors, but we would still 677 # like to complete the reset if possible. 678 try: 679 sessionplayer.setnode(None) 680 except Exception: 681 print_exception( 682 f'Error resetting SessionPlayer node on {sessionplayer}' 683 f' for {self}.' 684 ) 685 try: 686 sessionplayer.resetinput() 687 except Exception: 688 print_exception( 689 f'Error resetting SessionPlayer input on {sessionplayer}' 690 f' for {self}.' 691 ) 692 693 # These should never fail I think... 694 sessionplayer.setactivity(None) 695 sessionplayer.activityplayer = None 696 697 # noinspection PyUnresolvedReferences 698 def _setup_player_and_team_types(self) -> None: 699 """Pull player and team types from our typing.Generic params.""" 700 701 # TODO: There are proper calls for pulling these in Python 3.8; 702 # should update this code when we adopt that. 703 # NOTE: If we get Any as PlayerType or TeamType (generally due 704 # to no generic params being passed) we automatically use the 705 # base class types, but also warn the user since this will mean 706 # less type safety for that class. (its better to pass the base 707 # player/team types explicitly vs. having them be Any) 708 if not TYPE_CHECKING: 709 self._playertype = type(self).__orig_bases__[-1].__args__[0] 710 if not isinstance(self._playertype, type): 711 self._playertype = Player 712 print( 713 f'ERROR: {type(self)} was not passed a Player' 714 f' type argument; please explicitly pass ba.Player' 715 f' if you do not want to override it.' 716 ) 717 self._teamtype = type(self).__orig_bases__[-1].__args__[1] 718 if not isinstance(self._teamtype, type): 719 self._teamtype = Team 720 print( 721 f'ERROR: {type(self)} was not passed a Team' 722 f' type argument; please explicitly pass ba.Team' 723 f' if you do not want to override it.' 724 ) 725 assert issubclass(self._playertype, Player) 726 assert issubclass(self._teamtype, Team) 727 728 @classmethod 729 def _check_activity_death( 730 cls, activity_ref: weakref.ref[Activity], counter: list[int] 731 ) -> None: 732 """Sanity check to make sure an Activity was destroyed properly. 733 734 Receives a weakref to a ba.Activity which should have torn itself 735 down due to no longer being referenced anywhere. Will complain 736 and/or print debugging info if the Activity still exists. 737 """ 738 try: 739 activity = activity_ref() 740 print( 741 'ERROR: Activity is not dying when expected:', 742 activity, 743 '(warning ' + str(counter[0] + 1) + ')', 744 ) 745 print( 746 'This means something is still strong-referencing it.\n' 747 'Check out methods such as efro.debug.printrefs() to' 748 ' help debug this sort of thing.' 749 ) 750 # Note: no longer calling gc.get_referrers() here because it's 751 # usage can bork stuff. (see notes at top of efro.debug) 752 counter[0] += 1 753 if counter[0] == 4: 754 print('Killing app due to stuck activity... :-(') 755 _ba.quit() 756 757 except Exception: 758 print_exception('Error on _check_activity_death/') 759 760 def _expire(self) -> None: 761 """Put the activity in a state where it can be garbage-collected. 762 763 This involves clearing anything that might be holding a reference 764 to it, etc. 765 """ 766 assert not self._expired 767 self._expired = True 768 769 try: 770 self.on_expire() 771 except Exception: 772 print_exception(f'Error in Activity on_expire() for {self}.') 773 774 try: 775 self._customdata = None 776 except Exception: 777 print_exception(f'Error clearing customdata for {self}.') 778 779 # Don't want to be holding any delay-delete refs at this point. 780 self._prune_delay_deletes() 781 782 self._expire_actors() 783 self._expire_players() 784 self._expire_teams() 785 786 # This will kill all low level stuff: Timers, Nodes, etc., which 787 # should clear up any remaining refs to our Activity and allow us 788 # to die peacefully. 789 try: 790 self._activity_data.expire() 791 except Exception: 792 print_exception(f'Error expiring _activity_data for {self}.') 793 794 def _expire_actors(self) -> None: 795 # Expire all Actors. 796 for actor_ref in self._actor_weak_refs: 797 actor = actor_ref() 798 if actor is not None: 799 verify_object_death(actor) 800 try: 801 actor.on_expire() 802 except Exception: 803 print_exception( 804 f'Error in Actor.on_expire()' f' for {actor_ref()}.' 805 ) 806 807 def _expire_players(self) -> None: 808 809 # Issue warnings for any players that left the game but don't 810 # get freed soon. 811 for ex_player in (p() for p in self._players_that_left): 812 if ex_player is not None: 813 verify_object_death(ex_player) 814 815 for player in self.players: 816 # This should allow our ba.Player instance to be freed. 817 # Complain if that doesn't happen. 818 verify_object_death(player) 819 820 try: 821 player.expire() 822 except Exception: 823 print_exception(f'Error expiring {player}') 824 825 # Reset the SessionPlayer to a not-in-an-activity state. 826 try: 827 sessionplayer = player.sessionplayer 828 self._reset_session_player_for_no_activity(sessionplayer) 829 except SessionPlayerNotFoundError: 830 # Conceivably, someone could have held on to a Player object 831 # until now whos underlying SessionPlayer left long ago... 832 pass 833 except Exception: 834 print_exception(f'Error expiring {player}.') 835 836 def _expire_teams(self) -> None: 837 838 # Issue warnings for any teams that left the game but don't 839 # get freed soon. 840 for ex_team in (p() for p in self._teams_that_left): 841 if ex_team is not None: 842 verify_object_death(ex_team) 843 844 for team in self.teams: 845 # This should allow our ba.Team instance to die. 846 # Complain if that doesn't happen. 847 verify_object_death(team) 848 849 try: 850 team.expire() 851 except Exception: 852 print_exception(f'Error expiring {team}') 853 854 try: 855 sessionteam = team.sessionteam 856 sessionteam.activityteam = None 857 except SessionTeamNotFoundError: 858 # It is expected that Team objects may last longer than 859 # the SessionTeam they came from (game objects may hold 860 # team references past the point at which the underlying 861 # player/team has left the game) 862 pass 863 except Exception: 864 print_exception(f'Error expiring Team {team}.') 865 866 def _prune_delay_deletes(self) -> None: 867 self._delay_delete_players.clear() 868 self._delay_delete_teams.clear() 869 870 # Clear out any dead weak-refs. 871 self._teams_that_left = [ 872 t for t in self._teams_that_left if t() is not None 873 ] 874 self._players_that_left = [ 875 p for p in self._players_that_left if p() is not None 876 ] 877 878 def _prune_dead_actors(self) -> None: 879 self._last_prune_dead_actors_time = _ba.time() 880 881 # Prune our strong refs when the Actor's exists() call gives False 882 self._actor_refs = [a for a in self._actor_refs if a.exists()] 883 884 # Prune our weak refs once the Actor object has been freed. 885 self._actor_weak_refs = [ 886 a for a in self._actor_weak_refs if a() is not None 887 ]
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.
128 def __init__(self, settings: dict): 129 """Creates an Activity in the current ba.Session. 130 131 The activity will not be actually run until ba.Session.setactivity 132 is called. 'settings' should be a dict of key/value pairs specific 133 to the activity. 134 135 Activities should preload as much of their media/etc as possible in 136 their constructor, but none of it should actually be used until they 137 are transitioned in. 138 """ 139 super().__init__() 140 141 # Create our internal engine data. 142 self._activity_data = _ba.register_activity(self) 143 144 assert isinstance(settings, dict) 145 assert _ba.getactivity() is self 146 147 self._globalsnode: ba.Node | None = None 148 149 # Player/Team types should have been specified as type args; 150 # grab those. 151 self._playertype: type[PlayerType] 152 self._teamtype: type[TeamType] 153 self._setup_player_and_team_types() 154 155 # FIXME: Relocate or remove the need for this stuff. 156 self.paused_text: ba.Actor | None = None 157 158 self._session = weakref.ref(_ba.getsession()) 159 160 # Preloaded data for actors, maps, etc; indexed by type. 161 self.preloads: dict[type, Any] = {} 162 163 # Hopefully can eventually kill this; activities should 164 # validate/store whatever settings they need at init time 165 # (in a more type-safe way). 166 self.settings_raw = settings 167 168 self._has_transitioned_in = False 169 self._has_begun = False 170 self._has_ended = False 171 self._activity_death_check_timer: ba.Timer | None = None 172 self._expired = False 173 self._delay_delete_players: list[PlayerType] = [] 174 self._delay_delete_teams: list[TeamType] = [] 175 self._players_that_left: list[weakref.ref[PlayerType]] = [] 176 self._teams_that_left: list[weakref.ref[TeamType]] = [] 177 self._transitioning_out = False 178 179 # A handy place to put most actors; this list is pruned of dead 180 # actors regularly and these actors are insta-killed as the activity 181 # is dying. 182 self._actor_refs: list[ba.Actor] = [] 183 self._actor_weak_refs: list[weakref.ref[ba.Actor]] = [] 184 self._last_prune_dead_actors_time = _ba.time() 185 self._prune_dead_actors_timer: ba.Timer | None = None 186 187 self.teams = [] 188 self.players = [] 189 190 self.lobby = None 191 self._stats: ba.Stats | None = None 192 self._customdata: dict | None = {}
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.
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.
The list of ba.Team-s in the Activity. This gets populated just before on_begin() is called and is updated automatically as players join or leave the game. (at least in free-for-all mode where every player gets their own team; in teams mode there are always 2 teams regardless of the player count).
The list of ba.Player-s in the Activity. This gets populated just before on_begin() is called and is updated automatically as players join or leave the game.
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.
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.
Whether idle players can potentially be kicked (should not happen in menus/etc).
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.
Set this to True to inherit slow motion setting from previous activity (useful for transitions to avoid hitches).
Set this to True to keep playing the music from the previous activity (without even restarting it).
Set this to true to inherit VR camera offsets from the previous activity (useful for preventing sporadic camera movement during transitions).
Set this to true to inherit (non-fixed) VR overlay positioning from the previous activity (useful for prevent sporadic overlay jostling during transitions).
Set this to true to inherit screen tint/vignette colors from the previous activity (useful to prevent sudden color changes during transitions).
Whether players should be allowed to join in the middle of this activity. Note that Sessions may not allow mid-activity-joins even if the activity says its ok.
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.
Is it ok to show an ad after this activity ends before showing the next activity?
The 'globals' ba.Node for the activity. This contains various global controls and values.
The stats instance accessible while the activity is running.
If access is attempted before or after, raises a ba.NotFoundError.
235 def on_expire(self) -> None: 236 """Called when your activity is being expired. 237 238 If your activity has created anything explicitly that may be retaining 239 a strong reference to the activity and preventing it from dying, you 240 should clear that out here. From this point on your activity's sole 241 purpose in life is to hit zero references and die so the next activity 242 can begin. 243 """
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.
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.
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'.
313 def retain_actor(self, actor: ba.Actor) -> None: 314 """Add a strong-reference to a ba.Actor to this Activity. 315 316 The reference will be lazily released once ba.Actor.exists() 317 returns False for the Actor. The ba.Actor.autoretain() method 318 is a convenient way to access this same functionality. 319 """ 320 if __debug__: 321 from ba._actor import Actor 322 323 assert isinstance(actor, Actor) 324 self._actor_refs.append(actor)
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.
326 def add_actor_weak_ref(self, actor: ba.Actor) -> None: 327 """Add a weak-reference to a ba.Actor to the ba.Activity. 328 329 (called by the ba.Actor base class) 330 """ 331 if __debug__: 332 from ba._actor import Actor 333 334 assert isinstance(actor, Actor) 335 self._actor_weak_refs.append(weakref.ref(actor))
Add a weak-reference to a ba.Actor to the ba.Activity.
(called by the ba.Actor base class)
The ba.Session this ba.Activity belongs go.
Raises a ba.SessionNotFoundError if the Session no longer exists.
350 def on_player_join(self, player: PlayerType) -> None: 351 """Called when a new ba.Player has joined the Activity. 352 353 (including the initial set of Players) 354 """
Called when a new ba.Player has joined the Activity.
(including the initial set of Players)
356 def on_player_leave(self, player: PlayerType) -> None: 357 """Called when a ba.Player is leaving the Activity."""
Called when a ba.Player is leaving the Activity.
359 def on_team_join(self, team: TeamType) -> None: 360 """Called when a new ba.Team joins the Activity. 361 362 (including the initial set of Teams) 363 """
Called when a new ba.Team joins the Activity.
(including the initial set of Teams)
365 def on_team_leave(self, team: TeamType) -> None: 366 """Called when a ba.Team leaves the Activity."""
Called when a ba.Team leaves the Activity.
368 def on_transition_in(self) -> None: 369 """Called when the Activity is first becoming visible. 370 371 Upon this call, the Activity should fade in backgrounds, 372 start playing music, etc. It does not yet have access to players 373 or teams, however. They remain owned by the previous Activity 374 up until ba.Activity.on_begin() is called. 375 """
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.
377 def on_transition_out(self) -> None: 378 """Called when your activity begins transitioning out. 379 380 Note that this may happen at any time even if ba.Activity.end() has 381 not been called. 382 """
Called when your activity begins transitioning out.
Note that this may happen at any time even if ba.Activity.end() has not been called.
384 def on_begin(self) -> None: 385 """Called once the previous ba.Activity has finished transitioning out. 386 387 At this point the activity's initial players and teams are filled in 388 and it should begin its actual game logic. 389 """
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.
391 def handlemessage(self, msg: Any) -> Any: 392 """General message handling; can be passed any message object.""" 393 del msg # Unused arg. 394 return UNHANDLED
General message handling; can be passed any message object.
396 def has_transitioned_in(self) -> bool: 397 """Return whether ba.Activity.on_transition_in() 398 has been called.""" 399 return self._has_transitioned_in
Return whether ba.Activity.on_transition_in() has been called.
401 def has_begun(self) -> bool: 402 """Return whether ba.Activity.on_begin() has been called.""" 403 return self._has_begun
Return whether ba.Activity.on_begin() has been called.
405 def has_ended(self) -> bool: 406 """Return whether the activity has commenced ending.""" 407 return self._has_ended
Return whether the activity has commenced ending.
409 def is_transitioning_out(self) -> bool: 410 """Return whether ba.Activity.on_transition_out() has been called.""" 411 return self._transitioning_out
Return whether ba.Activity.on_transition_out() has been called.
471 def transition_out(self) -> None: 472 """Called by the Session to start us transitioning out.""" 473 assert not self._transitioning_out 474 self._transitioning_out = True 475 with _ba.Context(self): 476 try: 477 self.on_transition_out() 478 except Exception: 479 print_exception(f'Error in on_transition_out for {self}.')
Called by the Session to start us transitioning out.
509 def end( 510 self, results: Any = None, delay: float = 0.0, force: bool = False 511 ) -> None: 512 """Commences Activity shutdown and delivers results to the ba.Session. 513 514 'delay' is the time delay before the Activity actually ends 515 (in seconds). Further calls to end() will be ignored up until 516 this time, unless 'force' is True, in which case the new results 517 will replace the old. 518 """ 519 520 # Ask the session to end us. 521 self.session.end_activity(self, results, delay, force)
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.
523 def create_player(self, sessionplayer: ba.SessionPlayer) -> PlayerType: 524 """Create the Player instance for this Activity. 525 526 Subclasses can override this if the activity's player class 527 requires a custom constructor; otherwise it will be called with 528 no args. Note that the player object should not be used at this 529 point as it is not yet fully wired up; wait for 530 ba.Activity.on_player_join() for that. 531 """ 532 del sessionplayer # Unused. 533 player = self._playertype() 534 return player
Create the Player instance for this Activity.
Subclasses can override this if the activity's player class requires a custom constructor; otherwise it will be called with no args. Note that the player object should not be used at this point as it is not yet fully wired up; wait for ba.Activity.on_player_join() for that.
536 def create_team(self, sessionteam: ba.SessionTeam) -> TeamType: 537 """Create the Team instance for this Activity. 538 539 Subclasses can override this if the activity's team class 540 requires a custom constructor; otherwise it will be called with 541 no args. Note that the team object should not be used at this 542 point as it is not yet fully wired up; wait for on_team_join() 543 for that. 544 """ 545 del sessionteam # Unused. 546 team = self._teamtype() 547 return team
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.
Inherited Members
108class ActivityNotFoundError(NotFoundError): 109 """Exception raised when an expected ba.Activity does not exist. 110 111 Category: **Exception Classes** 112 """
Exception raised when an expected ba.Activity does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
22class Actor: 23 """High level logical entities in a ba.Activity. 24 25 Category: **Gameplay Classes** 26 27 Actors act as controllers, combining some number of ba.Nodes, 28 ba.Textures, ba.Sounds, etc. into a high-level cohesive unit. 29 30 Some example actors include the Bomb, Flag, and Spaz classes that 31 live in the bastd.actor.* modules. 32 33 One key feature of Actors is that they generally 'die' 34 (killing off or transitioning out their nodes) when the last Python 35 reference to them disappears, so you can use logic such as: 36 37 ##### Example 38 >>> # Create a flag Actor in our game activity: 39 ... from bastd.actor.flag import Flag 40 ... self.flag = Flag(position=(0, 10, 0)) 41 ... 42 ... # Later, destroy the flag. 43 ... # (provided nothing else is holding a reference to it) 44 ... # We could also just assign a new flag to this value. 45 ... # Either way, the old flag disappears. 46 ... self.flag = None 47 48 This is in contrast to the behavior of the more low level ba.Nodes, 49 which are always explicitly created and destroyed and don't care 50 how many Python references to them exist. 51 52 Note, however, that you can use the ba.Actor.autoretain() method 53 if you want an Actor to stick around until explicitly killed 54 regardless of references. 55 56 Another key feature of ba.Actor is its ba.Actor.handlemessage() method, 57 which takes a single arbitrary object as an argument. This provides a safe 58 way to communicate between ba.Actor, ba.Activity, ba.Session, and any other 59 class providing a handlemessage() method. The most universally handled 60 message type for Actors is the ba.DieMessage. 61 62 Another way to kill the flag from the example above: 63 We can safely call this on any type with a 'handlemessage' method 64 (though its not guaranteed to always have a meaningful effect). 65 In this case the Actor instance will still be around, but its 66 ba.Actor.exists() and ba.Actor.is_alive() methods will both return False. 67 >>> self.flag.handlemessage(ba.DieMessage()) 68 """ 69 70 def __init__(self) -> None: 71 """Instantiates an Actor in the current ba.Activity.""" 72 73 if __debug__: 74 self._root_actor_init_called = True 75 activity = _ba.getactivity() 76 self._activity = weakref.ref(activity) 77 activity.add_actor_weak_ref(self) 78 79 def __del__(self) -> None: 80 try: 81 # Unexpired Actors send themselves a DieMessage when going down. 82 # That way we can treat DieMessage handling as the single 83 # point-of-action for death. 84 if not self.expired: 85 self.handlemessage(DieMessage()) 86 except Exception: 87 print_exception('exception in ba.Actor.__del__() for', self) 88 89 def handlemessage(self, msg: Any) -> Any: 90 """General message handling; can be passed any message object.""" 91 assert not self.expired 92 93 # By default, actors going out-of-bounds simply kill themselves. 94 if isinstance(msg, OutOfBoundsMessage): 95 return self.handlemessage(DieMessage(how=DeathType.OUT_OF_BOUNDS)) 96 97 return UNHANDLED 98 99 def autoretain(self: ActorT) -> ActorT: 100 """Keep this Actor alive without needing to hold a reference to it. 101 102 This keeps the ba.Actor in existence by storing a reference to it 103 with the ba.Activity it was created in. The reference is lazily 104 released once ba.Actor.exists() returns False for it or when the 105 Activity is set as expired. This can be a convenient alternative 106 to storing references explicitly just to keep a ba.Actor from dying. 107 For convenience, this method returns the ba.Actor it is called with, 108 enabling chained statements such as: myflag = ba.Flag().autoretain() 109 """ 110 activity = self._activity() 111 if activity is None: 112 raise ActivityNotFoundError() 113 activity.retain_actor(self) 114 return self 115 116 def on_expire(self) -> None: 117 """Called for remaining `ba.Actor`s when their ba.Activity shuts down. 118 119 Actors can use this opportunity to clear callbacks or other 120 references which have the potential of keeping the ba.Activity 121 alive inadvertently (Activities can not exit cleanly while 122 any Python references to them remain.) 123 124 Once an actor is expired (see ba.Actor.is_expired()) it should no 125 longer perform any game-affecting operations (creating, modifying, 126 or deleting nodes, media, timers, etc.) Attempts to do so will 127 likely result in errors. 128 """ 129 130 @property 131 def expired(self) -> bool: 132 """Whether the Actor is expired. 133 134 (see ba.Actor.on_expire()) 135 """ 136 activity = self.getactivity(doraise=False) 137 return True if activity is None else activity.expired 138 139 def exists(self) -> bool: 140 """Returns whether the Actor is still present in a meaningful way. 141 142 Note that a dying character should still return True here as long as 143 their corpse is visible; this is about presence, not being 'alive' 144 (see ba.Actor.is_alive() for that). 145 146 If this returns False, it is assumed the Actor can be completely 147 deleted without affecting the game; this call is often used 148 when pruning lists of Actors, such as with ba.Actor.autoretain() 149 150 The default implementation of this method always return True. 151 152 Note that the boolean operator for the Actor class calls this method, 153 so a simple "if myactor" test will conveniently do the right thing 154 even if myactor is set to None. 155 """ 156 return True 157 158 def __bool__(self) -> bool: 159 # Cleaner way to test existence; friendlier to None values. 160 return self.exists() 161 162 def is_alive(self) -> bool: 163 """Returns whether the Actor is 'alive'. 164 165 What this means is up to the Actor. 166 It is not a requirement for Actors to be able to die; 167 just that they report whether they consider themselves 168 to be alive or not. In cases where dead/alive is 169 irrelevant, True should be returned. 170 """ 171 return True 172 173 @property 174 def activity(self) -> ba.Activity: 175 """The Activity this Actor was created in. 176 177 Raises a ba.ActivityNotFoundError if the Activity no longer exists. 178 """ 179 activity = self._activity() 180 if activity is None: 181 raise ActivityNotFoundError() 182 return activity 183 184 # Overloads to convey our exact return type depending on 'doraise' value. 185 186 @overload 187 def getactivity(self, doraise: Literal[True] = True) -> ba.Activity: 188 ... 189 190 @overload 191 def getactivity(self, doraise: Literal[False]) -> ba.Activity | None: 192 ... 193 194 def getactivity(self, doraise: bool = True) -> ba.Activity | None: 195 """Return the ba.Activity this Actor is associated with. 196 197 If the Activity no longer exists, raises a ba.ActivityNotFoundError 198 or returns None depending on whether 'doraise' is True. 199 """ 200 activity = self._activity() 201 if activity is None and doraise: 202 raise ActivityNotFoundError() 203 return activity
High level logical entities in a ba.Activity.
Category: Gameplay Classes
Actors act as controllers, combining some number of ba.Nodes, ba.Textures, ba.Sounds, etc. into a high-level cohesive unit.
Some example actors include the Bomb, Flag, and Spaz classes that live in the bastd.actor.* modules.
One key feature of Actors is that they generally 'die' (killing off or transitioning out their nodes) when the last Python reference to them disappears, so you can use logic such as:
Example
>>> # Create a flag Actor in our game activity:
... from bastd.actor.flag import Flag
... self.flag = Flag(position=(0, 10, 0))
...
... # Later, destroy the flag.
... # (provided nothing else is holding a reference to it)
... # We could also just assign a new flag to this value.
... # Either way, the old flag disappears.
... self.flag = None
This is in contrast to the behavior of the more low level ba.Nodes, which are always explicitly created and destroyed and don't care how many Python references to them exist.
Note, however, that you can use the ba.Actor.autoretain() method if you want an Actor to stick around until explicitly killed regardless of references.
Another key feature of ba.Actor is its ba.Actor.handlemessage() method, which takes a single arbitrary object as an argument. This provides a safe way to communicate between ba.Actor, ba.Activity, ba.Session, and any other class providing a handlemessage() method. The most universally handled message type for Actors is the ba.DieMessage.
Another way to kill the flag from the example above: We can safely call this on any type with a 'handlemessage' method (though its not guaranteed to always have a meaningful effect). In this case the Actor instance will still be around, but its ba.Actor.exists() and ba.Actor.is_alive() methods will both return False.
>>> self.flag.handlemessage(ba.DieMessage())
70 def __init__(self) -> None: 71 """Instantiates an Actor in the current ba.Activity.""" 72 73 if __debug__: 74 self._root_actor_init_called = True 75 activity = _ba.getactivity() 76 self._activity = weakref.ref(activity) 77 activity.add_actor_weak_ref(self)
Instantiates an Actor in the current ba.Activity.
89 def handlemessage(self, msg: Any) -> Any: 90 """General message handling; can be passed any message object.""" 91 assert not self.expired 92 93 # By default, actors going out-of-bounds simply kill themselves. 94 if isinstance(msg, OutOfBoundsMessage): 95 return self.handlemessage(DieMessage(how=DeathType.OUT_OF_BOUNDS)) 96 97 return UNHANDLED
General message handling; can be passed any message object.
99 def autoretain(self: ActorT) -> ActorT: 100 """Keep this Actor alive without needing to hold a reference to it. 101 102 This keeps the ba.Actor in existence by storing a reference to it 103 with the ba.Activity it was created in. The reference is lazily 104 released once ba.Actor.exists() returns False for it or when the 105 Activity is set as expired. This can be a convenient alternative 106 to storing references explicitly just to keep a ba.Actor from dying. 107 For convenience, this method returns the ba.Actor it is called with, 108 enabling chained statements such as: myflag = ba.Flag().autoretain() 109 """ 110 activity = self._activity() 111 if activity is None: 112 raise ActivityNotFoundError() 113 activity.retain_actor(self) 114 return self
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()
116 def on_expire(self) -> None: 117 """Called for remaining `ba.Actor`s when their ba.Activity shuts down. 118 119 Actors can use this opportunity to clear callbacks or other 120 references which have the potential of keeping the ba.Activity 121 alive inadvertently (Activities can not exit cleanly while 122 any Python references to them remain.) 123 124 Once an actor is expired (see ba.Actor.is_expired()) it should no 125 longer perform any game-affecting operations (creating, modifying, 126 or deleting nodes, media, timers, etc.) Attempts to do so will 127 likely result in errors. 128 """
Called for remaining ba.Actor
s when their ba.Activity shuts down.
Actors can use this opportunity to clear callbacks or other references which have the potential of keeping the ba.Activity alive inadvertently (Activities can not exit cleanly while any Python references to them remain.)
Once an actor is expired (see ba.Actor.is_expired()) it should no longer perform any game-affecting operations (creating, modifying, or deleting nodes, media, timers, etc.) Attempts to do so will likely result in errors.
139 def exists(self) -> bool: 140 """Returns whether the Actor is still present in a meaningful way. 141 142 Note that a dying character should still return True here as long as 143 their corpse is visible; this is about presence, not being 'alive' 144 (see ba.Actor.is_alive() for that). 145 146 If this returns False, it is assumed the Actor can be completely 147 deleted without affecting the game; this call is often used 148 when pruning lists of Actors, such as with ba.Actor.autoretain() 149 150 The default implementation of this method always return True. 151 152 Note that the boolean operator for the Actor class calls this method, 153 so a simple "if myactor" test will conveniently do the right thing 154 even if myactor is set to None. 155 """ 156 return True
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.
162 def is_alive(self) -> bool: 163 """Returns whether the Actor is 'alive'. 164 165 What this means is up to the Actor. 166 It is not a requirement for Actors to be able to die; 167 just that they report whether they consider themselves 168 to be alive or not. In cases where dead/alive is 169 irrelevant, True should be returned. 170 """ 171 return True
Returns whether the Actor is 'alive'.
What this means is up to the Actor. It is not a requirement for Actors to be able to die; just that they report whether they consider themselves to be alive or not. In cases where dead/alive is irrelevant, True should be returned.
The Activity this Actor was created in.
Raises a ba.ActivityNotFoundError if the Activity no longer exists.
194 def getactivity(self, doraise: bool = True) -> ba.Activity | None: 195 """Return the ba.Activity this Actor is associated with. 196 197 If the Activity no longer exists, raises a ba.ActivityNotFoundError 198 or returns None depending on whether 'doraise' is True. 199 """ 200 activity = self._activity() 201 if activity is None and doraise: 202 raise ActivityNotFoundError() 203 return 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.
101class ActorNotFoundError(NotFoundError): 102 """Exception raised when an expected ba.Actor does not exist. 103 104 Category: **Exception Classes** 105 """
Exception raised when an expected ba.Actor does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
48def animate( 49 node: ba.Node, 50 attr: str, 51 keys: dict[float, float], 52 loop: bool = False, 53 offset: float = 0, 54 timetype: ba.TimeType = TimeType.SIM, 55 timeformat: ba.TimeFormat = TimeFormat.SECONDS, 56 suppress_format_warning: bool = False, 57) -> ba.Node: 58 """Animate values on a target ba.Node. 59 60 Category: **Gameplay Functions** 61 62 Creates an 'animcurve' node with the provided values and time as an input, 63 connect it to the provided attribute, and set it to die with the target. 64 Key values are provided as time:value dictionary pairs. Time values are 65 relative to the current time. By default, times are specified in seconds, 66 but timeformat can also be set to MILLISECONDS to recreate the old behavior 67 (prior to ba 1.5) of taking milliseconds. Returns the animcurve node. 68 """ 69 if timetype is TimeType.SIM: 70 driver = 'time' 71 else: 72 raise Exception('FIXME; only SIM timetype is supported currently.') 73 items = list(keys.items()) 74 items.sort() 75 76 # Temp sanity check while we transition from milliseconds to seconds 77 # based time values. 78 if __debug__: 79 if not suppress_format_warning: 80 for item in items: 81 _ba.time_format_check(timeformat, item[0]) 82 83 curve = _ba.newnode( 84 'animcurve', 85 owner=node, 86 name='Driving ' + str(node) + ' \'' + attr + '\'', 87 ) 88 89 if timeformat is TimeFormat.SECONDS: 90 mult = 1000 91 elif timeformat is TimeFormat.MILLISECONDS: 92 mult = 1 93 else: 94 raise ValueError(f'invalid timeformat value: {timeformat}') 95 96 curve.times = [int(mult * time) for time, val in items] 97 curve.offset = _ba.time(timeformat=TimeFormat.MILLISECONDS) + int( 98 mult * offset 99 ) 100 curve.values = [val for time, val in items] 101 curve.loop = loop 102 103 # If we're not looping, set a timer to kill this curve 104 # after its done its job. 105 # FIXME: Even if we are looping we should have a way to die once we 106 # get disconnected. 107 if not loop: 108 # noinspection PyUnresolvedReferences 109 _ba.timer( 110 int(mult * items[-1][0]) + 1000, 111 curve.delete, 112 timeformat=TimeFormat.MILLISECONDS, 113 ) 114 115 # Do the connects last so all our attrs are in place when we push initial 116 # values through. 117 118 # We operate in either activities or sessions.. 119 try: 120 globalsnode = _ba.getactivity().globalsnode 121 except ActivityNotFoundError: 122 globalsnode = _ba.getsession().sessionglobalsnode 123 124 globalsnode.connectattr(driver, curve, 'in') 125 curve.connectattr('out', node, attr) 126 return curve
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.
129def animate_array( 130 node: ba.Node, 131 attr: str, 132 size: int, 133 keys: dict[float, Sequence[float]], 134 loop: bool = False, 135 offset: float = 0, 136 timetype: ba.TimeType = TimeType.SIM, 137 timeformat: ba.TimeFormat = TimeFormat.SECONDS, 138 suppress_format_warning: bool = False, 139) -> None: 140 """Animate an array of values on a target ba.Node. 141 142 Category: **Gameplay Functions** 143 144 Like ba.animate, but operates on array attributes. 145 """ 146 # pylint: disable=too-many-locals 147 combine = _ba.newnode('combine', owner=node, attrs={'size': size}) 148 if timetype is TimeType.SIM: 149 driver = 'time' 150 else: 151 raise Exception('FIXME: Only SIM timetype is supported currently.') 152 items = list(keys.items()) 153 items.sort() 154 155 # Temp sanity check while we transition from milliseconds to seconds 156 # based time values. 157 if __debug__: 158 if not suppress_format_warning: 159 for item in items: 160 # (PyCharm seems to think item is a float, not a tuple) 161 _ba.time_format_check(timeformat, item[0]) 162 163 if timeformat is TimeFormat.SECONDS: 164 mult = 1000 165 elif timeformat is TimeFormat.MILLISECONDS: 166 mult = 1 167 else: 168 raise ValueError('invalid timeformat value: "' + str(timeformat) + '"') 169 170 # We operate in either activities or sessions.. 171 try: 172 globalsnode = _ba.getactivity().globalsnode 173 except ActivityNotFoundError: 174 globalsnode = _ba.getsession().sessionglobalsnode 175 176 for i in range(size): 177 curve = _ba.newnode( 178 'animcurve', 179 owner=node, 180 name=( 181 'Driving ' + str(node) + ' \'' + attr + '\' member ' + str(i) 182 ), 183 ) 184 globalsnode.connectattr(driver, curve, 'in') 185 curve.times = [int(mult * time) for time, val in items] 186 curve.values = [val[i] for time, val in items] 187 curve.offset = _ba.time(timeformat=TimeFormat.MILLISECONDS) + int( 188 mult * offset 189 ) 190 curve.loop = loop 191 curve.connectattr('out', combine, 'input' + str(i)) 192 193 # If we're not looping, set a timer to kill this 194 # curve after its done its job. 195 if not loop: 196 # (PyCharm seems to think item is a float, not a tuple) 197 # noinspection PyUnresolvedReferences 198 _ba.timer( 199 int(mult * items[-1][0]) + 1000, 200 curve.delete, 201 timeformat=TimeFormat.MILLISECONDS, 202 ) 203 combine.connectattr('output', node, attr) 204 205 # If we're not looping, set a timer to kill the combine once 206 # the job is done. 207 # FIXME: Even if we are looping we should have a way to die 208 # once we get disconnected. 209 if not loop: 210 # (PyCharm seems to think item is a float, not a tuple) 211 # noinspection PyUnresolvedReferences 212 _ba.timer( 213 int(mult * items[-1][0]) + 1000, 214 combine.delete, 215 timeformat=TimeFormat.MILLISECONDS, 216 )
Animate an array of values on a target ba.Node.
Category: Gameplay Functions
Like ba.animate, but operates on array attributes.
40class App: 41 """A class for high level app functionality and state. 42 43 Category: **App Classes** 44 45 Use ba.app to access the single shared instance of this class. 46 47 Note that properties not documented here should be considered internal 48 and subject to change without warning. 49 """ 50 51 # pylint: disable=too-many-public-methods 52 53 # Implementations for these will be filled in by internal libs. 54 accounts_v2: AccountV2Subsystem 55 cloud: CloudSubsystem 56 57 log_handler: efro.log.LogHandler 58 health_monitor: AppHealthMonitor 59 60 class State(Enum): 61 """High level state the app can be in.""" 62 63 # The launch process has not yet begun. 64 INITIAL = 0 65 66 # Our app subsystems are being inited but should not yet interact. 67 LAUNCHING = 1 68 69 # App subsystems are inited and interacting, but the app has not 70 # yet embarked on a high level course of action. It is doing initial 71 # account logins, workspace & asset downloads, etc. in order to 72 # prepare for this. 73 LOADING = 2 74 75 # All pieces are in place and the app is now doing its thing. 76 RUNNING = 3 77 78 # The app is backgrounded or otherwise suspended. 79 PAUSED = 4 80 81 # The app is shutting down. 82 SHUTTING_DOWN = 5 83 84 @property 85 def aioloop(self) -> asyncio.AbstractEventLoop: 86 """The Logic Thread's Asyncio Event Loop. 87 88 This allow async tasks to be run in the logic thread. 89 Note that, at this time, the asyncio loop is encapsulated 90 and explicitly stepped by the engine's logic thread loop and 91 thus things like asyncio.get_running_loop() will not return this 92 loop from most places in the logic thread; only from within a 93 task explicitly created in this loop. 94 """ 95 assert self._aioloop is not None 96 return self._aioloop 97 98 @property 99 def build_number(self) -> int: 100 """Integer build number. 101 102 This value increases by at least 1 with each release of the game. 103 It is independent of the human readable ba.App.version string. 104 """ 105 assert isinstance(self._env['build_number'], int) 106 return self._env['build_number'] 107 108 @property 109 def device_name(self) -> str: 110 """Name of the device running the game.""" 111 assert isinstance(self._env['device_name'], str) 112 return self._env['device_name'] 113 114 @property 115 def config_file_path(self) -> str: 116 """Where the game's config file is stored on disk.""" 117 assert isinstance(self._env['config_file_path'], str) 118 return self._env['config_file_path'] 119 120 @property 121 def user_agent_string(self) -> str: 122 """String containing various bits of info about OS/device/etc.""" 123 assert isinstance(self._env['user_agent_string'], str) 124 return self._env['user_agent_string'] 125 126 @property 127 def version(self) -> str: 128 """Human-readable version string; something like '1.3.24'. 129 130 This should not be interpreted as a number; it may contain 131 string elements such as 'alpha', 'beta', 'test', etc. 132 If a numeric version is needed, use 'ba.App.build_number'. 133 """ 134 assert isinstance(self._env['version'], str) 135 return self._env['version'] 136 137 @property 138 def debug_build(self) -> bool: 139 """Whether the app was compiled in debug mode. 140 141 Debug builds generally run substantially slower than non-debug 142 builds due to compiler optimizations being disabled and extra 143 checks being run. 144 """ 145 assert isinstance(self._env['debug_build'], bool) 146 return self._env['debug_build'] 147 148 @property 149 def test_build(self) -> bool: 150 """Whether the game was compiled in test mode. 151 152 Test mode enables extra checks and features that are useful for 153 release testing but which do not slow the game down significantly. 154 """ 155 assert isinstance(self._env['test_build'], bool) 156 return self._env['test_build'] 157 158 @property 159 def python_directory_user(self) -> str: 160 """Path where the app looks for custom user scripts.""" 161 assert isinstance(self._env['python_directory_user'], str) 162 return self._env['python_directory_user'] 163 164 @property 165 def python_directory_app(self) -> str: 166 """Path where the app looks for its bundled scripts.""" 167 assert isinstance(self._env['python_directory_app'], str) 168 return self._env['python_directory_app'] 169 170 @property 171 def python_directory_app_site(self) -> str: 172 """Path containing pip packages bundled with the app.""" 173 assert isinstance(self._env['python_directory_app_site'], str) 174 return self._env['python_directory_app_site'] 175 176 @property 177 def config(self) -> ba.AppConfig: 178 """The ba.AppConfig instance representing the app's config state.""" 179 assert self._config is not None 180 return self._config 181 182 @property 183 def platform(self) -> str: 184 """Name of the current platform. 185 186 Examples are: 'mac', 'windows', android'. 187 """ 188 assert isinstance(self._env['platform'], str) 189 return self._env['platform'] 190 191 @property 192 def subplatform(self) -> str: 193 """String for subplatform. 194 195 Can be empty. For the 'android' platform, subplatform may 196 be 'google', 'amazon', etc. 197 """ 198 assert isinstance(self._env['subplatform'], str) 199 return self._env['subplatform'] 200 201 @property 202 def api_version(self) -> int: 203 """The game's api version. 204 205 Only Python modules and packages associated with the current API 206 version number will be detected by the game (see the ba_meta tag). 207 This value will change whenever backward-incompatible changes are 208 introduced to game APIs. When that happens, scripts should be updated 209 accordingly and set to target the new API version number. 210 """ 211 from ba._meta import CURRENT_API_VERSION 212 213 return CURRENT_API_VERSION 214 215 @property 216 def on_tv(self) -> bool: 217 """Whether the game is currently running on a TV.""" 218 assert isinstance(self._env['on_tv'], bool) 219 return self._env['on_tv'] 220 221 @property 222 def vr_mode(self) -> bool: 223 """Whether the game is currently running in VR.""" 224 assert isinstance(self._env['vr_mode'], bool) 225 return self._env['vr_mode'] 226 227 @property 228 def ui_bounds(self) -> tuple[float, float, float, float]: 229 """Bounds of the 'safe' screen area in ui space. 230 231 This tuple contains: (x-min, x-max, y-min, y-max) 232 """ 233 return _ba.uibounds() 234 235 def __init__(self) -> None: 236 """(internal) 237 238 Do not instantiate this class; use ba.app to access 239 the single shared instance. 240 """ 241 # pylint: disable=too-many-statements 242 243 self.state = self.State.INITIAL 244 245 self._bootstrapping_completed = False 246 self._called_on_app_launching = False 247 self._launch_completed = False 248 self._initial_sign_in_completed = False 249 self._meta_scan_completed = False 250 self._called_on_app_loading = False 251 self._called_on_app_running = False 252 self._app_paused = False 253 254 # Config. 255 self.config_file_healthy = False 256 257 # This is incremented any time the app is backgrounded/foregrounded; 258 # can be a simple way to determine if network data should be 259 # refreshed/etc. 260 self.fg_state = 0 261 262 self._aioloop: asyncio.AbstractEventLoop | None = None 263 264 self._env = _ba.env() 265 self.protocol_version: int = self._env['protocol_version'] 266 assert isinstance(self.protocol_version, int) 267 self.toolbar_test: bool = self._env['toolbar_test'] 268 assert isinstance(self.toolbar_test, bool) 269 self.demo_mode: bool = self._env['demo_mode'] 270 assert isinstance(self.demo_mode, bool) 271 self.arcade_mode: bool = self._env['arcade_mode'] 272 assert isinstance(self.arcade_mode, bool) 273 self.headless_mode: bool = self._env['headless_mode'] 274 assert isinstance(self.headless_mode, bool) 275 self.iircade_mode: bool = self._env['iircade_mode'] 276 assert isinstance(self.iircade_mode, bool) 277 self.allow_ticket_purchases: bool = not self.iircade_mode 278 279 # Default executor which can be used for misc background processing. 280 # It should also be passed to any asyncio loops we create so that 281 # everything shares the same single set of threads. 282 self.threadpool = ThreadPoolExecutor(thread_name_prefix='baworker') 283 284 # Misc. 285 self.tips: list[str] = [] 286 self.stress_test_reset_timer: ba.Timer | None = None 287 self.did_weak_call_warning = False 288 289 self.log_have_new = False 290 self.log_upload_timer_started = False 291 self._config: ba.AppConfig | None = None 292 self.printed_live_object_warning = False 293 294 # We include this extra hash with shared input-mapping names so 295 # that we don't share mappings between differently-configured 296 # systems. For instance, different android devices may give different 297 # key values for the same controller type so we keep their mappings 298 # distinct. 299 self.input_map_hash: str | None = None 300 301 # Co-op Campaigns. 302 self.campaigns: dict[str, ba.Campaign] = {} 303 self.custom_coop_practice_games: list[str] = [] 304 305 # Server Mode. 306 self.server: ba.ServerController | None = None 307 308 self.components = AppComponentSubsystem() 309 self.meta = MetadataSubsystem() 310 self.accounts_v1 = AccountV1Subsystem() 311 self.plugins = PluginSubsystem() 312 self.music = MusicSubsystem() 313 self.lang = LanguageSubsystem() 314 self.ach = AchievementSubsystem() 315 self.ui = UISubsystem() 316 self.ads = AdsSubsystem() 317 self.net = NetworkSubsystem() 318 self.workspaces = WorkspaceSubsystem() 319 320 # Lobby. 321 self.lobby_random_profile_index: int = 1 322 self.lobby_random_char_index_offset = random.randrange(1000) 323 self.lobby_account_profile_device_id: int | None = None 324 325 # Main Menu. 326 self.main_menu_did_initial_transition = False 327 self.main_menu_last_news_fetch_time: float | None = None 328 329 # Spaz. 330 self.spaz_appearances: dict[str, spazappearance.Appearance] = {} 331 self.last_spaz_turbo_warn_time: float = -99999.0 332 333 # Maps. 334 self.maps: dict[str, type[ba.Map]] = {} 335 336 # Gameplay. 337 self.teams_series_length = 7 338 self.ffa_series_length = 24 339 self.coop_session_args: dict = {} 340 341 self.value_test_defaults: dict = {} 342 self.first_main_menu = True # FIXME: Move to mainmenu class. 343 self.did_menu_intro = False # FIXME: Move to mainmenu class. 344 self.main_menu_window_refresh_check_count = 0 # FIXME: Mv to mainmenu. 345 self.main_menu_resume_callbacks: list = [] # Can probably go away. 346 self.special_offer: dict | None = None 347 self.ping_thread_count = 0 348 self.invite_confirm_windows: list[Any] = [] # FIXME: Don't use Any. 349 self.store_layout: dict[str, list[dict[str, Any]]] | None = None 350 self.store_items: dict[str, dict] | None = None 351 self.pro_sale_start_time: int | None = None 352 self.pro_sale_start_val: int | None = None 353 354 self.delegate: ba.AppDelegate | None = None 355 self._asyncio_timer: ba.Timer | None = None 356 357 def on_app_launching(self) -> None: 358 """Called when the app is first entering the launching state.""" 359 # pylint: disable=cyclic-import 360 # pylint: disable=too-many-locals 361 from ba import _asyncio 362 from ba import _appconfig 363 from ba import _map 364 from ba import _campaign 365 from bastd import appdelegate 366 from bastd import maps as stdmaps 367 from bastd.actor import spazappearance 368 from ba._generated.enums import TimeType 369 from ba._apputils import ( 370 log_dumped_app_state, 371 handle_leftover_v1_cloud_log_file, 372 AppHealthMonitor, 373 ) 374 375 assert _ba.in_logic_thread() 376 377 self._aioloop = _asyncio.setup_asyncio() 378 self.health_monitor = AppHealthMonitor() 379 380 cfg = self.config 381 382 self.delegate = appdelegate.AppDelegate() 383 384 self.ui.on_app_launch() 385 386 spazappearance.register_appearances() 387 _campaign.init_campaigns() 388 389 # FIXME: This should not be hard-coded. 390 for maptype in [ 391 stdmaps.HockeyStadium, 392 stdmaps.FootballStadium, 393 stdmaps.Bridgit, 394 stdmaps.BigG, 395 stdmaps.Roundabout, 396 stdmaps.MonkeyFace, 397 stdmaps.ZigZag, 398 stdmaps.ThePad, 399 stdmaps.DoomShroom, 400 stdmaps.LakeFrigid, 401 stdmaps.TipTop, 402 stdmaps.CragCastle, 403 stdmaps.TowerD, 404 stdmaps.HappyThoughts, 405 stdmaps.StepRightUp, 406 stdmaps.Courtyard, 407 stdmaps.Rampage, 408 ]: 409 _map.register_map(maptype) 410 411 # Non-test, non-debug builds should generally be blessed; warn if not. 412 # (so I don't accidentally release a build that can't play tourneys) 413 if ( 414 not self.debug_build 415 and not self.test_build 416 and not _internal.is_blessed() 417 ): 418 _ba.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0)) 419 420 # If there's a leftover log file, attempt to upload it to the 421 # master-server and/or get rid of it. 422 handle_leftover_v1_cloud_log_file() 423 424 # Only do this stuff if our config file is healthy so we don't 425 # overwrite a broken one or whatnot and wipe out data. 426 if not self.config_file_healthy: 427 if self.platform in ('mac', 'linux', 'windows'): 428 from bastd.ui.configerror import ConfigErrorWindow 429 430 _ba.pushcall(ConfigErrorWindow) 431 return 432 433 # For now on other systems we just overwrite the bum config. 434 # At this point settings are already set; lets just commit them 435 # to disk. 436 _appconfig.commit_app_config(force=True) 437 438 self.music.on_app_launch() 439 440 launch_count = cfg.get('launchCount', 0) 441 launch_count += 1 442 443 # So we know how many times we've run the game at various 444 # version milestones. 445 for key in ('lc14173', 'lc14292'): 446 cfg.setdefault(key, launch_count) 447 448 cfg['launchCount'] = launch_count 449 cfg.commit() 450 451 # Run a test in a few seconds to see if we should pop up an existing 452 # pending special offer. 453 def check_special_offer() -> None: 454 from bastd.ui.specialoffer import show_offer 455 456 config = self.config 457 if ( 458 'pendingSpecialOffer' in config 459 and _internal.get_public_login_id() 460 == config['pendingSpecialOffer']['a'] 461 ): 462 self.special_offer = config['pendingSpecialOffer']['o'] 463 show_offer() 464 465 if not self.headless_mode: 466 _ba.timer(3.0, check_special_offer, timetype=TimeType.REAL) 467 468 # Get meta-system scanning built-in stuff in the bg. 469 self.meta.start_scan(scan_complete_cb=self.on_meta_scan_complete) 470 471 self.accounts_v2.on_app_launch() 472 self.accounts_v1.on_app_launch() 473 474 # See note below in on_app_pause. 475 if self.state != self.State.LAUNCHING: 476 logging.error( 477 'on_app_launch found state %s; expected LAUNCHING.', self.state 478 ) 479 480 # If any traceback dumps happened last run, log and clear them. 481 log_dumped_app_state() 482 483 self._launch_completed = True 484 self._update_state() 485 486 def on_app_loading(self) -> None: 487 """Called when initially entering the loading state.""" 488 489 def on_app_running(self) -> None: 490 """Called when initially entering the running state.""" 491 492 self.plugins.on_app_running() 493 494 # from ba._dependency import test_depset 495 # test_depset() 496 497 def on_bootstrapping_completed(self) -> None: 498 """Called by the C++ layer once its ready to rock.""" 499 assert _ba.in_logic_thread() 500 assert not self._bootstrapping_completed 501 self._bootstrapping_completed = True 502 self._update_state() 503 504 def on_meta_scan_complete(self) -> None: 505 """Called by meta-scan when it is done doing its thing.""" 506 assert _ba.in_logic_thread() 507 self.plugins.on_meta_scan_complete() 508 509 assert not self._meta_scan_completed 510 self._meta_scan_completed = True 511 self._update_state() 512 513 def _update_state(self) -> None: 514 assert _ba.in_logic_thread() 515 516 if self._app_paused: 517 # Entering paused state: 518 if self.state is not self.State.PAUSED: 519 self.state = self.State.PAUSED 520 self.cloud.on_app_pause() 521 self.accounts_v1.on_app_pause() 522 self.plugins.on_app_pause() 523 self.health_monitor.on_app_pause() 524 else: 525 # Leaving paused state: 526 if self.state is self.State.PAUSED: 527 self.fg_state += 1 528 self.cloud.on_app_resume() 529 self.accounts_v1.on_app_resume() 530 self.music.on_app_resume() 531 self.plugins.on_app_resume() 532 self.health_monitor.on_app_resume() 533 534 # Handle initially entering or returning to other states. 535 if self._initial_sign_in_completed and self._meta_scan_completed: 536 self.state = self.State.RUNNING 537 if not self._called_on_app_running: 538 self._called_on_app_running = True 539 self.on_app_running() 540 elif self._launch_completed: 541 self.state = self.State.LOADING 542 if not self._called_on_app_loading: 543 self._called_on_app_loading = True 544 self.on_app_loading() 545 else: 546 # Only thing left is launching. We shouldn't be getting 547 # called before at least that is complete. 548 assert self._bootstrapping_completed 549 self.state = self.State.LAUNCHING 550 if not self._called_on_app_launching: 551 self._called_on_app_launching = True 552 self.on_app_launching() 553 554 def on_app_pause(self) -> None: 555 """Called when the app goes to a suspended state.""" 556 557 assert not self._app_paused # Should avoid redundant calls. 558 self._app_paused = True 559 self._update_state() 560 561 def on_app_resume(self) -> None: 562 """Run when the app resumes from a suspended state.""" 563 564 assert self._app_paused # Should avoid redundant calls. 565 self._app_paused = False 566 self._update_state() 567 568 def on_app_shutdown(self) -> None: 569 """(internal)""" 570 self.state = self.State.SHUTTING_DOWN 571 self.music.on_app_shutdown() 572 self.plugins.on_app_shutdown() 573 574 def read_config(self) -> None: 575 """(internal)""" 576 from ba._appconfig import read_config 577 578 self._config, self.config_file_healthy = read_config() 579 580 def pause(self) -> None: 581 """Pause the game due to a user request or menu popping up. 582 583 If there's a foreground host-activity that says it's pausable, tell it 584 to pause ..we now no longer pause if there are connected clients. 585 """ 586 activity: ba.Activity | None = _ba.get_foreground_host_activity() 587 if ( 588 activity is not None 589 and activity.allow_pausing 590 and not _ba.have_connected_clients() 591 ): 592 from ba._language import Lstr 593 from ba._nodeactor import NodeActor 594 595 # FIXME: Shouldn't be touching scene stuff here; 596 # should just pass the request on to the host-session. 597 with _ba.Context(activity): 598 globs = activity.globalsnode 599 if not globs.paused: 600 _ba.playsound(_ba.getsound('refWhistle')) 601 globs.paused = True 602 603 # FIXME: This should not be an attr on Actor. 604 activity.paused_text = NodeActor( 605 _ba.newnode( 606 'text', 607 attrs={ 608 'text': Lstr(resource='pausedByHostText'), 609 'client_only': True, 610 'flatness': 1.0, 611 'h_align': 'center', 612 }, 613 ) 614 ) 615 616 def resume(self) -> None: 617 """Resume the game due to a user request or menu closing. 618 619 If there's a foreground host-activity that's currently paused, tell it 620 to resume. 621 """ 622 623 # FIXME: Shouldn't be touching scene stuff here; 624 # should just pass the request on to the host-session. 625 activity = _ba.get_foreground_host_activity() 626 if activity is not None: 627 with _ba.Context(activity): 628 globs = activity.globalsnode 629 if globs.paused: 630 _ba.playsound(_ba.getsound('refWhistle')) 631 globs.paused = False 632 633 # FIXME: This should not be an actor attr. 634 activity.paused_text = None 635 636 def add_coop_practice_level(self, level: Level) -> None: 637 """Adds an individual level to the 'practice' section in Co-op.""" 638 639 # Assign this level to our catch-all campaign. 640 self.campaigns['Challenges'].addlevel(level) 641 642 # Make note to add it to our challenges UI. 643 self.custom_coop_practice_games.append(f'Challenges:{level.name}') 644 645 def return_to_main_menu_session_gracefully( 646 self, reset_ui: bool = True 647 ) -> None: 648 """Attempt to cleanly get back to the main menu.""" 649 # pylint: disable=cyclic-import 650 from ba import _benchmark 651 from ba._general import Call 652 from bastd.mainmenu import MainMenuSession 653 654 if reset_ui: 655 _ba.app.ui.clear_main_menu_window() 656 657 if isinstance(_ba.get_foreground_host_session(), MainMenuSession): 658 # It may be possible we're on the main menu but the screen is faded 659 # so fade back in. 660 _ba.fade_screen(True) 661 return 662 663 _benchmark.stop_stress_test() # Stop stress-test if in progress. 664 665 # If we're in a host-session, tell them to end. 666 # This lets them tear themselves down gracefully. 667 host_session: ba.Session | None = _ba.get_foreground_host_session() 668 if host_session is not None: 669 670 # Kick off a little transaction so we'll hopefully have all the 671 # latest account state when we get back to the menu. 672 _internal.add_transaction( 673 {'type': 'END_SESSION', 'sType': str(type(host_session))} 674 ) 675 _internal.run_transactions() 676 677 host_session.end() 678 679 # Otherwise just force the issue. 680 else: 681 _ba.pushcall(Call(_ba.new_host_session, MainMenuSession)) 682 683 def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None: 684 """(internal)""" 685 686 # If there's no main menu up, just call immediately. 687 if not self.ui.has_main_menu_window(): 688 with _ba.Context('ui'): 689 call() 690 else: 691 self.main_menu_resume_callbacks.append(call) 692 693 def launch_coop_game( 694 self, game: str, force: bool = False, args: dict | None = None 695 ) -> bool: 696 """High level way to launch a local co-op session.""" 697 # pylint: disable=cyclic-import 698 from ba._campaign import getcampaign 699 from bastd.ui.coop.level import CoopLevelLockedWindow 700 701 if args is None: 702 args = {} 703 if game == '': 704 raise ValueError('empty game name') 705 campaignname, levelname = game.split(':') 706 campaign = getcampaign(campaignname) 707 708 # If this campaign is sequential, make sure we've completed the 709 # one before this. 710 if campaign.sequential and not force: 711 for level in campaign.levels: 712 if level.name == levelname: 713 break 714 if not level.complete: 715 CoopLevelLockedWindow( 716 campaign.getlevel(levelname).displayname, 717 campaign.getlevel(level.name).displayname, 718 ) 719 return False 720 721 # Ok, we're good to go. 722 self.coop_session_args = { 723 'campaign': campaignname, 724 'level': levelname, 725 } 726 for arg_name, arg_val in list(args.items()): 727 self.coop_session_args[arg_name] = arg_val 728 729 def _fade_end() -> None: 730 from ba import _coopsession 731 732 try: 733 _ba.new_host_session(_coopsession.CoopSession) 734 except Exception: 735 from ba import _error 736 737 _error.print_exception() 738 from bastd.mainmenu import MainMenuSession 739 740 _ba.new_host_session(MainMenuSession) 741 742 _ba.fade_screen(False, endcall=_fade_end) 743 return True 744 745 def handle_deep_link(self, url: str) -> None: 746 """Handle a deep link URL.""" 747 from ba._language import Lstr 748 749 appname = _ba.appname() 750 if url.startswith(f'{appname}://code/'): 751 code = url.replace(f'{appname}://code/', '') 752 self.accounts_v1.add_pending_promo_code(code) 753 else: 754 _ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0)) 755 _ba.playsound(_ba.getsound('error')) 756 757 def on_initial_sign_in_completed(self) -> None: 758 """Callback to be run after initial sign-in (or lack thereof). 759 760 This period includes things such as syncing account workspaces 761 or other data so it may take a substantial amount of time. 762 This should also run after a short amount of time if no login 763 has occurred. 764 """ 765 # Tell meta it can start scanning extra stuff that just showed up 766 # (account workspaces). 767 self.meta.start_extra_scan() 768 769 self._initial_sign_in_completed = True 770 self._update_state()
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.
The Logic Thread's Asyncio Event Loop.
This allow async tasks to be run in the logic thread. Note that, at this time, the asyncio loop is encapsulated and explicitly stepped by the engine's logic thread loop and thus things like asyncio.get_running_loop() will not return this loop from most places in the logic thread; only from within a task explicitly created in this loop.
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.
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'.
Whether the app was compiled in debug mode.
Debug builds generally run substantially slower than non-debug builds due to compiler optimizations being disabled and extra checks being run.
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.
String for subplatform.
Can be empty. For the 'android' platform, subplatform may be 'google', 'amazon', etc.
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.
Bounds of the 'safe' screen area in ui space.
This tuple contains: (x-min, x-max, y-min, y-max)
357 def on_app_launching(self) -> None: 358 """Called when the app is first entering the launching state.""" 359 # pylint: disable=cyclic-import 360 # pylint: disable=too-many-locals 361 from ba import _asyncio 362 from ba import _appconfig 363 from ba import _map 364 from ba import _campaign 365 from bastd import appdelegate 366 from bastd import maps as stdmaps 367 from bastd.actor import spazappearance 368 from ba._generated.enums import TimeType 369 from ba._apputils import ( 370 log_dumped_app_state, 371 handle_leftover_v1_cloud_log_file, 372 AppHealthMonitor, 373 ) 374 375 assert _ba.in_logic_thread() 376 377 self._aioloop = _asyncio.setup_asyncio() 378 self.health_monitor = AppHealthMonitor() 379 380 cfg = self.config 381 382 self.delegate = appdelegate.AppDelegate() 383 384 self.ui.on_app_launch() 385 386 spazappearance.register_appearances() 387 _campaign.init_campaigns() 388 389 # FIXME: This should not be hard-coded. 390 for maptype in [ 391 stdmaps.HockeyStadium, 392 stdmaps.FootballStadium, 393 stdmaps.Bridgit, 394 stdmaps.BigG, 395 stdmaps.Roundabout, 396 stdmaps.MonkeyFace, 397 stdmaps.ZigZag, 398 stdmaps.ThePad, 399 stdmaps.DoomShroom, 400 stdmaps.LakeFrigid, 401 stdmaps.TipTop, 402 stdmaps.CragCastle, 403 stdmaps.TowerD, 404 stdmaps.HappyThoughts, 405 stdmaps.StepRightUp, 406 stdmaps.Courtyard, 407 stdmaps.Rampage, 408 ]: 409 _map.register_map(maptype) 410 411 # Non-test, non-debug builds should generally be blessed; warn if not. 412 # (so I don't accidentally release a build that can't play tourneys) 413 if ( 414 not self.debug_build 415 and not self.test_build 416 and not _internal.is_blessed() 417 ): 418 _ba.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0)) 419 420 # If there's a leftover log file, attempt to upload it to the 421 # master-server and/or get rid of it. 422 handle_leftover_v1_cloud_log_file() 423 424 # Only do this stuff if our config file is healthy so we don't 425 # overwrite a broken one or whatnot and wipe out data. 426 if not self.config_file_healthy: 427 if self.platform in ('mac', 'linux', 'windows'): 428 from bastd.ui.configerror import ConfigErrorWindow 429 430 _ba.pushcall(ConfigErrorWindow) 431 return 432 433 # For now on other systems we just overwrite the bum config. 434 # At this point settings are already set; lets just commit them 435 # to disk. 436 _appconfig.commit_app_config(force=True) 437 438 self.music.on_app_launch() 439 440 launch_count = cfg.get('launchCount', 0) 441 launch_count += 1 442 443 # So we know how many times we've run the game at various 444 # version milestones. 445 for key in ('lc14173', 'lc14292'): 446 cfg.setdefault(key, launch_count) 447 448 cfg['launchCount'] = launch_count 449 cfg.commit() 450 451 # Run a test in a few seconds to see if we should pop up an existing 452 # pending special offer. 453 def check_special_offer() -> None: 454 from bastd.ui.specialoffer import show_offer 455 456 config = self.config 457 if ( 458 'pendingSpecialOffer' in config 459 and _internal.get_public_login_id() 460 == config['pendingSpecialOffer']['a'] 461 ): 462 self.special_offer = config['pendingSpecialOffer']['o'] 463 show_offer() 464 465 if not self.headless_mode: 466 _ba.timer(3.0, check_special_offer, timetype=TimeType.REAL) 467 468 # Get meta-system scanning built-in stuff in the bg. 469 self.meta.start_scan(scan_complete_cb=self.on_meta_scan_complete) 470 471 self.accounts_v2.on_app_launch() 472 self.accounts_v1.on_app_launch() 473 474 # See note below in on_app_pause. 475 if self.state != self.State.LAUNCHING: 476 logging.error( 477 'on_app_launch found state %s; expected LAUNCHING.', self.state 478 ) 479 480 # If any traceback dumps happened last run, log and clear them. 481 log_dumped_app_state() 482 483 self._launch_completed = True 484 self._update_state()
Called when the app is first entering the launching state.
489 def on_app_running(self) -> None: 490 """Called when initially entering the running state.""" 491 492 self.plugins.on_app_running() 493 494 # from ba._dependency import test_depset 495 # test_depset()
Called when initially entering the running state.
497 def on_bootstrapping_completed(self) -> None: 498 """Called by the C++ layer once its ready to rock.""" 499 assert _ba.in_logic_thread() 500 assert not self._bootstrapping_completed 501 self._bootstrapping_completed = True 502 self._update_state()
Called by the C++ layer once its ready to rock.
504 def on_meta_scan_complete(self) -> None: 505 """Called by meta-scan when it is done doing its thing.""" 506 assert _ba.in_logic_thread() 507 self.plugins.on_meta_scan_complete() 508 509 assert not self._meta_scan_completed 510 self._meta_scan_completed = True 511 self._update_state()
Called by meta-scan when it is done doing its thing.
554 def on_app_pause(self) -> None: 555 """Called when the app goes to a suspended state.""" 556 557 assert not self._app_paused # Should avoid redundant calls. 558 self._app_paused = True 559 self._update_state()
Called when the app goes to a suspended state.
561 def on_app_resume(self) -> None: 562 """Run when the app resumes from a suspended state.""" 563 564 assert self._app_paused # Should avoid redundant calls. 565 self._app_paused = False 566 self._update_state()
Run when the app resumes from a suspended state.
580 def pause(self) -> None: 581 """Pause the game due to a user request or menu popping up. 582 583 If there's a foreground host-activity that says it's pausable, tell it 584 to pause ..we now no longer pause if there are connected clients. 585 """ 586 activity: ba.Activity | None = _ba.get_foreground_host_activity() 587 if ( 588 activity is not None 589 and activity.allow_pausing 590 and not _ba.have_connected_clients() 591 ): 592 from ba._language import Lstr 593 from ba._nodeactor import NodeActor 594 595 # FIXME: Shouldn't be touching scene stuff here; 596 # should just pass the request on to the host-session. 597 with _ba.Context(activity): 598 globs = activity.globalsnode 599 if not globs.paused: 600 _ba.playsound(_ba.getsound('refWhistle')) 601 globs.paused = True 602 603 # FIXME: This should not be an attr on Actor. 604 activity.paused_text = NodeActor( 605 _ba.newnode( 606 'text', 607 attrs={ 608 'text': Lstr(resource='pausedByHostText'), 609 'client_only': True, 610 'flatness': 1.0, 611 'h_align': 'center', 612 }, 613 ) 614 )
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.
616 def resume(self) -> None: 617 """Resume the game due to a user request or menu closing. 618 619 If there's a foreground host-activity that's currently paused, tell it 620 to resume. 621 """ 622 623 # FIXME: Shouldn't be touching scene stuff here; 624 # should just pass the request on to the host-session. 625 activity = _ba.get_foreground_host_activity() 626 if activity is not None: 627 with _ba.Context(activity): 628 globs = activity.globalsnode 629 if globs.paused: 630 _ba.playsound(_ba.getsound('refWhistle')) 631 globs.paused = False 632 633 # FIXME: This should not be an actor attr. 634 activity.paused_text = 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.
636 def add_coop_practice_level(self, level: Level) -> None: 637 """Adds an individual level to the 'practice' section in Co-op.""" 638 639 # Assign this level to our catch-all campaign. 640 self.campaigns['Challenges'].addlevel(level) 641 642 # Make note to add it to our challenges UI. 643 self.custom_coop_practice_games.append(f'Challenges:{level.name}')
Adds an individual level to the 'practice' section in Co-op.
693 def launch_coop_game( 694 self, game: str, force: bool = False, args: dict | None = None 695 ) -> bool: 696 """High level way to launch a local co-op session.""" 697 # pylint: disable=cyclic-import 698 from ba._campaign import getcampaign 699 from bastd.ui.coop.level import CoopLevelLockedWindow 700 701 if args is None: 702 args = {} 703 if game == '': 704 raise ValueError('empty game name') 705 campaignname, levelname = game.split(':') 706 campaign = getcampaign(campaignname) 707 708 # If this campaign is sequential, make sure we've completed the 709 # one before this. 710 if campaign.sequential and not force: 711 for level in campaign.levels: 712 if level.name == levelname: 713 break 714 if not level.complete: 715 CoopLevelLockedWindow( 716 campaign.getlevel(levelname).displayname, 717 campaign.getlevel(level.name).displayname, 718 ) 719 return False 720 721 # Ok, we're good to go. 722 self.coop_session_args = { 723 'campaign': campaignname, 724 'level': levelname, 725 } 726 for arg_name, arg_val in list(args.items()): 727 self.coop_session_args[arg_name] = arg_val 728 729 def _fade_end() -> None: 730 from ba import _coopsession 731 732 try: 733 _ba.new_host_session(_coopsession.CoopSession) 734 except Exception: 735 from ba import _error 736 737 _error.print_exception() 738 from bastd.mainmenu import MainMenuSession 739 740 _ba.new_host_session(MainMenuSession) 741 742 _ba.fade_screen(False, endcall=_fade_end) 743 return True
High level way to launch a local co-op session.
745 def handle_deep_link(self, url: str) -> None: 746 """Handle a deep link URL.""" 747 from ba._language import Lstr 748 749 appname = _ba.appname() 750 if url.startswith(f'{appname}://code/'): 751 code = url.replace(f'{appname}://code/', '') 752 self.accounts_v1.add_pending_promo_code(code) 753 else: 754 _ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0)) 755 _ba.playsound(_ba.getsound('error'))
Handle a deep link URL.
757 def on_initial_sign_in_completed(self) -> None: 758 """Callback to be run after initial sign-in (or lack thereof). 759 760 This period includes things such as syncing account workspaces 761 or other data so it may take a substantial amount of time. 762 This should also run after a short amount of time if no login 763 has occurred. 764 """ 765 # Tell meta it can start scanning extra stuff that just showed up 766 # (account workspaces). 767 self.meta.start_extra_scan() 768 769 self._initial_sign_in_completed = True 770 self._update_state()
Callback to be run after initial sign-in (or lack thereof).
This period includes things such as syncing account workspaces or other data so it may take a substantial amount of time. This should also run after a short amount of time if no login has occurred.
60 class State(Enum): 61 """High level state the app can be in.""" 62 63 # The launch process has not yet begun. 64 INITIAL = 0 65 66 # Our app subsystems are being inited but should not yet interact. 67 LAUNCHING = 1 68 69 # App subsystems are inited and interacting, but the app has not 70 # yet embarked on a high level course of action. It is doing initial 71 # account logins, workspace & asset downloads, etc. in order to 72 # prepare for this. 73 LOADING = 2 74 75 # All pieces are in place and the app is now doing its thing. 76 RUNNING = 3 77 78 # The app is backgrounded or otherwise suspended. 79 PAUSED = 4 80 81 # The app is shutting down. 82 SHUTTING_DOWN = 5
High level state the app can be in.
Inherited Members
- enum.Enum
- name
- value
15class AppConfig(dict): 16 """A special dict that holds the game's persistent configuration values. 17 18 Category: **App Classes** 19 20 It also provides methods for fetching values with app-defined fallback 21 defaults, applying contained values to the game, and committing the 22 config to storage. 23 24 Call ba.appconfig() to get the single shared instance of this class. 25 26 AppConfig data is stored as json on disk on so make sure to only place 27 json-friendly values in it (dict, list, str, float, int, bool). 28 Be aware that tuples will be quietly converted to lists when stored. 29 """ 30 31 def resolve(self, key: str) -> Any: 32 """Given a string key, return a config value (type varies). 33 34 This will substitute application defaults for values not present in 35 the config dict, filter some invalid values, etc. Note that these 36 values do not represent the state of the app; simply the state of its 37 config. Use ba.App to access actual live state. 38 39 Raises an Exception for unrecognized key names. To get the list of keys 40 supported by this method, use ba.AppConfig.builtin_keys(). Note that it 41 is perfectly legal to store other data in the config; it just needs to 42 be accessed through standard dict methods and missing values handled 43 manually. 44 """ 45 return _ba.resolve_appconfig_value(key) 46 47 def default_value(self, key: str) -> Any: 48 """Given a string key, return its predefined default value. 49 50 This is the value that will be returned by ba.AppConfig.resolve() if 51 the key is not present in the config dict or of an incompatible type. 52 53 Raises an Exception for unrecognized key names. To get the list of keys 54 supported by this method, use ba.AppConfig.builtin_keys(). Note that it 55 is perfectly legal to store other data in the config; it just needs to 56 be accessed through standard dict methods and missing values handled 57 manually. 58 """ 59 return _ba.get_appconfig_default_value(key) 60 61 def builtin_keys(self) -> list[str]: 62 """Return the list of valid key names recognized by ba.AppConfig. 63 64 This set of keys can be used with resolve(), default_value(), etc. 65 It does not vary across platforms and may include keys that are 66 obsolete or not relevant on the current running version. (for instance, 67 VR related keys on non-VR platforms). This is to minimize the amount 68 of platform checking necessary) 69 70 Note that it is perfectly legal to store arbitrary named data in the 71 config, but in that case it is up to the user to test for the existence 72 of the key in the config dict, fall back to consistent defaults, etc. 73 """ 74 return _ba.get_appconfig_builtin_keys() 75 76 def apply(self) -> None: 77 """Apply config values to the running app.""" 78 _ba.apply_config() 79 80 def commit(self) -> None: 81 """Commits the config to local storage. 82 83 Note that this call is asynchronous so the actual write to disk may not 84 occur immediately. 85 """ 86 commit_app_config() 87 88 def apply_and_commit(self) -> None: 89 """Run apply() followed by commit(); for convenience. 90 91 (This way the commit() will not occur if apply() hits invalid data) 92 """ 93 self.apply() 94 self.commit()
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.
31 def resolve(self, key: str) -> Any: 32 """Given a string key, return a config value (type varies). 33 34 This will substitute application defaults for values not present in 35 the config dict, filter some invalid values, etc. Note that these 36 values do not represent the state of the app; simply the state of its 37 config. Use ba.App to access actual live state. 38 39 Raises an Exception for unrecognized key names. To get the list of keys 40 supported by this method, use ba.AppConfig.builtin_keys(). Note that it 41 is perfectly legal to store other data in the config; it just needs to 42 be accessed through standard dict methods and missing values handled 43 manually. 44 """ 45 return _ba.resolve_appconfig_value(key)
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.
47 def default_value(self, key: str) -> Any: 48 """Given a string key, return its predefined default value. 49 50 This is the value that will be returned by ba.AppConfig.resolve() if 51 the key is not present in the config dict or of an incompatible type. 52 53 Raises an Exception for unrecognized key names. To get the list of keys 54 supported by this method, use ba.AppConfig.builtin_keys(). Note that it 55 is perfectly legal to store other data in the config; it just needs to 56 be accessed through standard dict methods and missing values handled 57 manually. 58 """ 59 return _ba.get_appconfig_default_value(key)
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.
61 def builtin_keys(self) -> list[str]: 62 """Return the list of valid key names recognized by ba.AppConfig. 63 64 This set of keys can be used with resolve(), default_value(), etc. 65 It does not vary across platforms and may include keys that are 66 obsolete or not relevant on the current running version. (for instance, 67 VR related keys on non-VR platforms). This is to minimize the amount 68 of platform checking necessary) 69 70 Note that it is perfectly legal to store arbitrary named data in the 71 config, but in that case it is up to the user to test for the existence 72 of the key in the config dict, fall back to consistent defaults, etc. 73 """ 74 return _ba.get_appconfig_builtin_keys()
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.
80 def commit(self) -> None: 81 """Commits the config to local storage. 82 83 Note that this call is asynchronous so the actual write to disk may not 84 occur immediately. 85 """ 86 commit_app_config()
Commits the config to local storage.
Note that this call is asynchronous so the actual write to disk may not occur immediately.
88 def apply_and_commit(self) -> None: 89 """Run apply() followed by commit(); for convenience. 90 91 (This way the commit() will not occur if apply() hits invalid data) 92 """ 93 self.apply() 94 self.commit()
Run apply() followed by commit(); for convenience.
(This way the commit() will not occur if apply() hits invalid data)
Inherited Members
- builtins.dict
- get
- setdefault
- pop
- popitem
- keys
- items
- values
- update
- fromkeys
- clear
- copy
14class AppDelegate: 15 """Defines handlers for high level app functionality. 16 17 Category: App Classes 18 """ 19 20 def create_default_game_settings_ui( 21 self, 22 gameclass: type[ba.GameActivity], 23 sessiontype: type[ba.Session], 24 settings: dict | None, 25 completion_call: Callable[[dict | None], None], 26 ) -> None: 27 """Launch a UI to configure the given game config. 28 29 It should manipulate the contents of config and call completion_call 30 when done. 31 """ 32 del gameclass, sessiontype, settings, completion_call # Unused. 33 from ba import _error 34 35 _error.print_error( 36 "create_default_game_settings_ui needs to be overridden" 37 )
Defines handlers for high level app functionality.
Category: App Classes
20 def create_default_game_settings_ui( 21 self, 22 gameclass: type[ba.GameActivity], 23 sessiontype: type[ba.Session], 24 settings: dict | None, 25 completion_call: Callable[[dict | None], None], 26 ) -> None: 27 """Launch a UI to configure the given game config. 28 29 It should manipulate the contents of config and call completion_call 30 when done. 31 """ 32 del gameclass, sessiontype, settings, completion_call # Unused. 33 from ba import _error 34 35 _error.print_error( 36 "create_default_game_settings_ui needs to be overridden" 37 )
Launch a UI to configure the given game config.
It should manipulate the contents of config and call completion_call when done.
300class AssetPackage(DependencyComponent): 301 """ba.DependencyComponent representing a bundled package of game assets. 302 303 Category: **Asset Classes** 304 """ 305 306 def __init__(self) -> None: 307 super().__init__() 308 309 # This is used internally by the get_package_xxx calls. 310 self.context = _ba.Context('current') 311 312 entry = self._dep_entry() 313 assert entry is not None 314 assert isinstance(entry.config, str) 315 self.package_id = entry.config 316 print(f'LOADING ASSET PACKAGE {self.package_id}') 317 318 @classmethod 319 def dep_is_present(cls, config: Any = None) -> bool: 320 assert isinstance(config, str) 321 322 # Temp: hard-coding for a single asset-package at the moment. 323 if config == 'stdassets@1': 324 return True 325 return False 326 327 def gettexture(self, name: str) -> ba.Texture: 328 """Load a named ba.Texture from the AssetPackage. 329 330 Behavior is similar to ba.gettexture() 331 """ 332 return _ba.get_package_texture(self, name) 333 334 def getmodel(self, name: str) -> ba.Model: 335 """Load a named ba.Model from the AssetPackage. 336 337 Behavior is similar to ba.getmodel() 338 """ 339 return _ba.get_package_model(self, name) 340 341 def getcollidemodel(self, name: str) -> ba.CollideModel: 342 """Load a named ba.CollideModel from the AssetPackage. 343 344 Behavior is similar to ba.getcollideModel() 345 """ 346 return _ba.get_package_collide_model(self, name) 347 348 def getsound(self, name: str) -> ba.Sound: 349 """Load a named ba.Sound from the AssetPackage. 350 351 Behavior is similar to ba.getsound() 352 """ 353 return _ba.get_package_sound(self, name) 354 355 def getdata(self, name: str) -> ba.Data: 356 """Load a named ba.Data from the AssetPackage. 357 358 Behavior is similar to ba.getdata() 359 """ 360 return _ba.get_package_data(self, name)
ba.DependencyComponent representing a bundled package of game assets.
Category: Asset Classes
306 def __init__(self) -> None: 307 super().__init__() 308 309 # This is used internally by the get_package_xxx calls. 310 self.context = _ba.Context('current') 311 312 entry = self._dep_entry() 313 assert entry is not None 314 assert isinstance(entry.config, str) 315 self.package_id = entry.config 316 print(f'LOADING ASSET PACKAGE {self.package_id}')
Instantiate a DependencyComponent.
318 @classmethod 319 def dep_is_present(cls, config: Any = None) -> bool: 320 assert isinstance(config, str) 321 322 # Temp: hard-coding for a single asset-package at the moment. 323 if config == 'stdassets@1': 324 return True 325 return False
Return whether this component/config is present on this device.
327 def gettexture(self, name: str) -> ba.Texture: 328 """Load a named ba.Texture from the AssetPackage. 329 330 Behavior is similar to ba.gettexture() 331 """ 332 return _ba.get_package_texture(self, name)
Load a named ba.Texture from the AssetPackage.
Behavior is similar to ba.gettexture()
334 def getmodel(self, name: str) -> ba.Model: 335 """Load a named ba.Model from the AssetPackage. 336 337 Behavior is similar to ba.getmodel() 338 """ 339 return _ba.get_package_model(self, name)
Load a named ba.Model from the AssetPackage.
Behavior is similar to ba.getmodel()
341 def getcollidemodel(self, name: str) -> ba.CollideModel: 342 """Load a named ba.CollideModel from the AssetPackage. 343 344 Behavior is similar to ba.getcollideModel() 345 """ 346 return _ba.get_package_collide_model(self, name)
Load a named ba.CollideModel from the AssetPackage.
Behavior is similar to ba.getcollideModel()
348 def getsound(self, name: str) -> ba.Sound: 349 """Load a named ba.Sound from the AssetPackage. 350 351 Behavior is similar to ba.getsound() 352 """ 353 return _ba.get_package_sound(self, name)
Load a named ba.Sound from the AssetPackage.
Behavior is similar to ba.getsound()
355 def getdata(self, name: str) -> ba.Data: 356 """Load a named ba.Data from the AssetPackage. 357 358 Behavior is similar to ba.getdata() 359 """ 360 return _ba.get_package_data(self, name)
Load a named ba.Data from the AssetPackage.
Behavior is similar to ba.getdata()
Inherited Members
26@dataclass 27class BoolSetting(Setting): 28 """A boolean game setting. 29 30 Category: Settings Classes 31 """ 32 33 default: bool
A boolean game setting.
Category: Settings Classes
378def cameraflash(duration: float = 999.0) -> None: 379 """Create a strobing camera flash effect. 380 381 Category: **Gameplay Functions** 382 383 (as seen when a team wins a game) 384 Duration is in seconds. 385 """ 386 # pylint: disable=too-many-locals 387 import random 388 from ba._nodeactor import NodeActor 389 390 x_spread = 10 391 y_spread = 5 392 positions = [ 393 [-x_spread, -y_spread], 394 [0, -y_spread], 395 [0, y_spread], 396 [x_spread, -y_spread], 397 [x_spread, y_spread], 398 [-x_spread, y_spread], 399 ] 400 times = [0, 2700, 1000, 1800, 500, 1400] 401 402 # Store this on the current activity so we only have one at a time. 403 # FIXME: Need a type safe way to do this. 404 activity = _ba.getactivity() 405 activity.camera_flash_data = [] # type: ignore 406 for i in range(6): 407 light = NodeActor( 408 _ba.newnode( 409 'light', 410 attrs={ 411 'position': (positions[i][0], 0, positions[i][1]), 412 'radius': 1.0, 413 'lights_volumes': False, 414 'height_attenuated': False, 415 'color': (0.2, 0.2, 0.8), 416 }, 417 ) 418 ) 419 sval = 1.87 420 iscale = 1.3 421 tcombine = _ba.newnode( 422 'combine', 423 owner=light.node, 424 attrs={ 425 'size': 3, 426 'input0': positions[i][0], 427 'input1': 0, 428 'input2': positions[i][1], 429 }, 430 ) 431 assert light.node 432 tcombine.connectattr('output', light.node, 'position') 433 xval = positions[i][0] 434 yval = positions[i][1] 435 spd = 0.5 + random.random() 436 spd2 = 0.5 + random.random() 437 animate( 438 tcombine, 439 'input0', 440 { 441 0.0: xval + 0, 442 0.069 * spd: xval + 10.0, 443 0.143 * spd: xval - 10.0, 444 0.201 * spd: xval + 0, 445 }, 446 loop=True, 447 ) 448 animate( 449 tcombine, 450 'input2', 451 { 452 0.0: yval + 0, 453 0.15 * spd2: yval + 10.0, 454 0.287 * spd2: yval - 10.0, 455 0.398 * spd2: yval + 0, 456 }, 457 loop=True, 458 ) 459 animate( 460 light.node, 461 'intensity', 462 { 463 0.0: 0, 464 0.02 * sval: 0, 465 0.05 * sval: 0.8 * iscale, 466 0.08 * sval: 0, 467 0.1 * sval: 0, 468 }, 469 loop=True, 470 offset=times[i], 471 ) 472 _ba.timer( 473 (times[i] + random.randint(1, int(duration)) * 40 * sval), 474 light.node.delete, 475 timeformat=TimeFormat.MILLISECONDS, 476 ) 477 activity.camera_flash_data.append(light) # type: ignore
Create a strobing camera flash effect.
Category: Gameplay Functions
(as seen when a team wins a game) Duration is in seconds.
1330def camerashake(intensity: float = 1.0) -> None: 1331 1332 """Shake the camera. 1333 1334 Category: **Gameplay Functions** 1335 1336 Note that some cameras and/or platforms (such as VR) may not display 1337 camera-shake, so do not rely on this always being visible to the 1338 player as a gameplay cue. 1339 """ 1340 return None
Shake the camera.
Category: Gameplay Functions
Note that some cameras and/or platforms (such as VR) may not display camera-shake, so do not rely on this always being visible to the player as a gameplay cue.
26class Campaign: 27 """Represents a unique set or series of ba.Level-s. 28 29 Category: **App Classes** 30 """ 31 32 def __init__( 33 self, 34 name: str, 35 sequential: bool = True, 36 levels: list[ba.Level] | None = None, 37 ): 38 self._name = name 39 self._sequential = sequential 40 self._levels: list[ba.Level] = [] 41 if levels is not None: 42 for level in levels: 43 self.addlevel(level) 44 45 @property 46 def name(self) -> str: 47 """The name of the Campaign.""" 48 return self._name 49 50 @property 51 def sequential(self) -> bool: 52 """Whether this Campaign's levels must be played in sequence.""" 53 return self._sequential 54 55 def addlevel(self, level: ba.Level, index: int | None = None) -> None: 56 """Adds a ba.Level to the Campaign.""" 57 if level.campaign is not None: 58 raise RuntimeError('Level already belongs to a campaign.') 59 level.set_campaign(self, len(self._levels)) 60 if index is None: 61 self._levels.append(level) 62 else: 63 self._levels.insert(index, level) 64 65 @property 66 def levels(self) -> list[ba.Level]: 67 """The list of ba.Level-s in the Campaign.""" 68 return self._levels 69 70 def getlevel(self, name: str) -> ba.Level: 71 """Return a contained ba.Level by name.""" 72 from ba import _error 73 74 for level in self._levels: 75 if level.name == name: 76 return level 77 raise _error.NotFoundError( 78 "Level '" + name + "' not found in campaign '" + self.name + "'" 79 ) 80 81 def reset(self) -> None: 82 """Reset state for the Campaign.""" 83 _ba.app.config.setdefault('Campaigns', {})[self._name] = {} 84 85 # FIXME should these give/take ba.Level instances instead of level names?.. 86 def set_selected_level(self, levelname: str) -> None: 87 """Set the Level currently selected in the UI (by name).""" 88 self.configdict['Selection'] = levelname 89 _ba.app.config.commit() 90 91 def get_selected_level(self) -> str: 92 """Return the name of the Level currently selected in the UI.""" 93 return self.configdict.get('Selection', self._levels[0].name) 94 95 @property 96 def configdict(self) -> dict[str, Any]: 97 """Return the live config dict for this campaign.""" 98 val: dict[str, Any] = _ba.app.config.setdefault( 99 'Campaigns', {} 100 ).setdefault(self._name, {}) 101 assert isinstance(val, dict) 102 return val
Represents a unique set or series of ba.Level-s.
Category: App Classes
55 def addlevel(self, level: ba.Level, index: int | None = None) -> None: 56 """Adds a ba.Level to the Campaign.""" 57 if level.campaign is not None: 58 raise RuntimeError('Level already belongs to a campaign.') 59 level.set_campaign(self, len(self._levels)) 60 if index is None: 61 self._levels.append(level) 62 else: 63 self._levels.insert(index, level)
Adds a ba.Level to the Campaign.
70 def getlevel(self, name: str) -> ba.Level: 71 """Return a contained ba.Level by name.""" 72 from ba import _error 73 74 for level in self._levels: 75 if level.name == name: 76 return level 77 raise _error.NotFoundError( 78 "Level '" + name + "' not found in campaign '" + self.name + "'" 79 )
Return a contained ba.Level by name.
81 def reset(self) -> None: 82 """Reset state for the Campaign.""" 83 _ba.app.config.setdefault('Campaigns', {})[self._name] = {}
Reset state for the Campaign.
86 def set_selected_level(self, levelname: str) -> None: 87 """Set the Level currently selected in the UI (by name).""" 88 self.configdict['Selection'] = levelname 89 _ba.app.config.commit()
Set the Level currently selected in the UI (by name).
226@dataclass 227class CelebrateMessage: 228 """Tells an object to celebrate. 229 230 Category: **Message Classes** 231 """ 232 233 duration: float = 10.0 234 """Amount of time to celebrate in seconds."""
Tells an object to celebrate.
Category: Message Classes
1375def charstr(char_id: ba.SpecialChar) -> str: 1376 1377 """Get a unicode string representing a special character. 1378 1379 Category: **General Utility Functions** 1380 1381 Note that these utilize the private-use block of unicode characters 1382 (U+E000-U+F8FF) and are specific to the game; exporting or rendering 1383 them elsewhere will be meaningless. 1384 1385 See ba.SpecialChar for the list of available characters. 1386 """ 1387 return str()
Get a unicode string representing a special character.
Category: General Utility Functions
Note that these utilize the private-use block of unicode characters (U+E000-U+F8FF) and are specific to the game; exporting or rendering them elsewhere will be meaningless.
See ba.SpecialChar for the list of available characters.
1400def checkboxwidget( 1401 edit: ba.Widget | None = None, 1402 parent: ba.Widget | None = None, 1403 size: Sequence[float] | None = None, 1404 position: Sequence[float] | None = None, 1405 text: str | ba.Lstr | None = None, 1406 value: bool | None = None, 1407 on_value_change_call: Callable[[bool], None] | None = None, 1408 on_select_call: Callable[[], None] | None = None, 1409 text_scale: float | None = None, 1410 textcolor: Sequence[float] | None = None, 1411 scale: float | None = None, 1412 is_radio_button: bool | None = None, 1413 maxwidth: float | None = None, 1414 autoselect: bool | None = None, 1415 color: Sequence[float] | None = None, 1416) -> ba.Widget: 1417 1418 """Create or edit a check-box widget. 1419 1420 Category: **User Interface Functions** 1421 1422 Pass a valid existing ba.Widget as 'edit' to modify it; otherwise 1423 a new one is created and returned. Arguments that are not set to None 1424 are applied to the Widget. 1425 """ 1426 import ba # pylint: disable=cyclic-import 1427 1428 return ba.Widget()
Create or edit a check-box widget.
Category: User Interface Functions
Pass a valid existing ba.Widget as 'edit' to modify it; otherwise a new one is created and returned. Arguments that are not set to None are applied to the Widget.
62@dataclass 63class ChoiceSetting(Setting): 64 """A setting with multiple choices. 65 66 Category: Settings Classes 67 """ 68 69 choices: list[tuple[str, Any]]
A setting with multiple choices.
Category: Settings Classes
173class Chooser: 174 """A character/team selector for a ba.Player. 175 176 Category: Gameplay Classes 177 """ 178 179 def __del__(self) -> None: 180 181 # Just kill off our base node; the rest should go down with it. 182 if self._text_node: 183 self._text_node.delete() 184 185 def __init__( 186 self, vpos: float, sessionplayer: _ba.SessionPlayer, lobby: 'Lobby' 187 ) -> None: 188 self._deek_sound = _ba.getsound('deek') 189 self._click_sound = _ba.getsound('click01') 190 self._punchsound = _ba.getsound('punch01') 191 self._swish_sound = _ba.getsound('punchSwish') 192 self._errorsound = _ba.getsound('error') 193 self._mask_texture = _ba.gettexture('characterIconMask') 194 self._vpos = vpos 195 self._lobby = weakref.ref(lobby) 196 self._sessionplayer = sessionplayer 197 self._inited = False 198 self._dead = False 199 self._text_node: ba.Node | None = None 200 self._profilename = '' 201 self._profilenames: list[str] = [] 202 self._ready: bool = False 203 self._character_names: list[str] = [] 204 self._last_change: Sequence[float | int] = (0, 0) 205 self._profiles: dict[str, dict[str, Any]] = {} 206 207 app = _ba.app 208 209 # Load available player profiles either from the local config or 210 # from the remote device. 211 self.reload_profiles() 212 213 # Note: this is just our local index out of available teams; *not* 214 # the team-id! 215 self._selected_team_index: int = self.lobby.next_add_team 216 217 # Store a persistent random character index and colors; we'll use this 218 # for the '_random' profile. Let's use their input_device id to seed 219 # it. This will give a persistent character for them between games 220 # and will distribute characters nicely if everyone is random. 221 self._random_color, self._random_highlight = get_player_profile_colors( 222 None 223 ) 224 225 # To calc our random character we pick a random one out of our 226 # unlocked list and then locate that character's index in the full 227 # list. 228 char_index_offset = app.lobby_random_char_index_offset 229 self._random_character_index = ( 230 sessionplayer.inputdevice.id + char_index_offset 231 ) % len(self._character_names) 232 233 # Attempt to set an initial profile based on what was used previously 234 # for this input-device, etc. 235 self._profileindex = self._select_initial_profile() 236 self._profilename = self._profilenames[self._profileindex] 237 238 self._text_node = _ba.newnode( 239 'text', 240 delegate=self, 241 attrs={ 242 'position': (-100, self._vpos), 243 'maxwidth': 160, 244 'shadow': 0.5, 245 'vr_depth': -20, 246 'h_align': 'left', 247 'v_align': 'center', 248 'v_attach': 'top', 249 }, 250 ) 251 animate(self._text_node, 'scale', {0: 0, 0.1: 1.0}) 252 self.icon = _ba.newnode( 253 'image', 254 owner=self._text_node, 255 attrs={ 256 'position': (-130, self._vpos + 20), 257 'mask_texture': self._mask_texture, 258 'vr_depth': -10, 259 'attach': 'topCenter', 260 }, 261 ) 262 263 animate_array(self.icon, 'scale', 2, {0: (0, 0), 0.1: (45, 45)}) 264 265 # Set our initial name to '<choosing player>' in case anyone asks. 266 self._sessionplayer.setname( 267 Lstr(resource='choosingPlayerText').evaluate(), real=False 268 ) 269 270 # Init these to our rando but they should get switched to the 271 # selected profile (if any) right after. 272 self._character_index = self._random_character_index 273 self._color = self._random_color 274 self._highlight = self._random_highlight 275 276 self.update_from_profile() 277 self.update_position() 278 self._inited = True 279 280 self._set_ready(False) 281 282 def _select_initial_profile(self) -> int: 283 app = _ba.app 284 profilenames = self._profilenames 285 inputdevice = self._sessionplayer.inputdevice 286 287 # If we've got a set profile name for this device, work backwards 288 # from that to get our index. 289 dprofilename = app.config.get('Default Player Profiles', {}).get( 290 inputdevice.name + ' ' + inputdevice.unique_identifier 291 ) 292 if dprofilename is not None and dprofilename in profilenames: 293 # If we got '__account__' and its local and we haven't marked 294 # anyone as the 'account profile' device yet, mark this guy as 295 # it. (prevents the next joiner from getting the account 296 # profile too). 297 if ( 298 dprofilename == '__account__' 299 and not inputdevice.is_remote_client 300 and app.lobby_account_profile_device_id is None 301 ): 302 app.lobby_account_profile_device_id = inputdevice.id 303 return profilenames.index(dprofilename) 304 305 # We want to mark the first local input-device in the game 306 # as the 'account profile' device. 307 if ( 308 not inputdevice.is_remote_client 309 and not inputdevice.is_controller_app 310 ): 311 if ( 312 app.lobby_account_profile_device_id is None 313 and '__account__' in profilenames 314 ): 315 app.lobby_account_profile_device_id = inputdevice.id 316 317 # If this is the designated account-profile-device, try to default 318 # to the account profile. 319 if ( 320 inputdevice.id == app.lobby_account_profile_device_id 321 and '__account__' in profilenames 322 ): 323 return profilenames.index('__account__') 324 325 # If this is the controller app, it defaults to using a random 326 # profile (since we can pull the random name from the app). 327 if inputdevice.is_controller_app and '_random' in profilenames: 328 return profilenames.index('_random') 329 330 # If its a client connection, for now just force 331 # the account profile if possible.. (need to provide a 332 # way for clients to specify/remember their default 333 # profile on remote servers that do not already know them). 334 if inputdevice.is_remote_client and '__account__' in profilenames: 335 return profilenames.index('__account__') 336 337 # Cycle through our non-random profiles once; after 338 # that, everyone gets random. 339 while app.lobby_random_profile_index < len( 340 profilenames 341 ) and profilenames[app.lobby_random_profile_index] in ( 342 '_random', 343 '__account__', 344 '_edit', 345 ): 346 app.lobby_random_profile_index += 1 347 if app.lobby_random_profile_index < len(profilenames): 348 profileindex = app.lobby_random_profile_index 349 app.lobby_random_profile_index += 1 350 return profileindex 351 assert '_random' in profilenames 352 return profilenames.index('_random') 353 354 @property 355 def sessionplayer(self) -> ba.SessionPlayer: 356 """The ba.SessionPlayer associated with this chooser.""" 357 return self._sessionplayer 358 359 @property 360 def ready(self) -> bool: 361 """Whether this chooser is checked in as ready.""" 362 return self._ready 363 364 def set_vpos(self, vpos: float) -> None: 365 """(internal)""" 366 self._vpos = vpos 367 368 def set_dead(self, val: bool) -> None: 369 """(internal)""" 370 self._dead = val 371 372 @property 373 def sessionteam(self) -> ba.SessionTeam: 374 """Return this chooser's currently selected ba.SessionTeam.""" 375 return self.lobby.sessionteams[self._selected_team_index] 376 377 @property 378 def lobby(self) -> ba.Lobby: 379 """The chooser's ba.Lobby.""" 380 lobby = self._lobby() 381 if lobby is None: 382 raise NotFoundError('Lobby does not exist.') 383 return lobby 384 385 def get_lobby(self) -> ba.Lobby | None: 386 """Return this chooser's lobby if it still exists; otherwise None.""" 387 return self._lobby() 388 389 def update_from_profile(self) -> None: 390 """Set character/colors based on the current profile.""" 391 self._profilename = self._profilenames[self._profileindex] 392 if self._profilename == '_edit': 393 pass 394 elif self._profilename == '_random': 395 self._character_index = self._random_character_index 396 self._color = self._random_color 397 self._highlight = self._random_highlight 398 else: 399 character = self._profiles[self._profilename]['character'] 400 401 # At the moment we're not properly pulling the list 402 # of available characters from clients, so profiles might use a 403 # character not in their list. For now, just go ahead and add 404 # a character name to their list as long as we're aware of it. 405 # This just means they won't always be able to override their 406 # character to others they own, but profile characters 407 # should work (and we validate profiles on the master server 408 # so no exploit opportunities) 409 if ( 410 character not in self._character_names 411 and character in _ba.app.spaz_appearances 412 ): 413 self._character_names.append(character) 414 self._character_index = self._character_names.index(character) 415 self._color, self._highlight = get_player_profile_colors( 416 self._profilename, profiles=self._profiles 417 ) 418 self._update_icon() 419 self._update_text() 420 421 def reload_profiles(self) -> None: 422 """Reload all player profiles.""" 423 from ba._general import json_prep 424 425 app = _ba.app 426 427 # Re-construct our profile index and other stuff since the profile 428 # list might have changed. 429 input_device = self._sessionplayer.inputdevice 430 is_remote = input_device.is_remote_client 431 is_test_input = input_device.name.startswith('TestInput') 432 433 # Pull this player's list of unlocked characters. 434 if is_remote: 435 # TODO: Pull this from the remote player. 436 # (but make sure to filter it to the ones we've got). 437 self._character_names = ['Spaz'] 438 else: 439 self._character_names = self.lobby.character_names_local_unlocked 440 441 # If we're a local player, pull our local profiles from the config. 442 # Otherwise ask the remote-input-device for its profile list. 443 if is_remote: 444 self._profiles = input_device.get_player_profiles() 445 else: 446 self._profiles = app.config.get('Player Profiles', {}) 447 448 # These may have come over the wire from an older 449 # (non-unicode/non-json) version. 450 # Make sure they conform to our standards 451 # (unicode strings, no tuples, etc) 452 self._profiles = json_prep(self._profiles) 453 454 # Filter out any characters we're unaware of. 455 for profile in list(self._profiles.items()): 456 if profile[1].get('character', '') not in app.spaz_appearances: 457 profile[1]['character'] = 'Spaz' 458 459 # Add in a random one so we're ok even if there's no user profiles. 460 self._profiles['_random'] = {} 461 462 # In kiosk mode we disable account profiles to force random. 463 if app.demo_mode or app.arcade_mode: 464 if '__account__' in self._profiles: 465 del self._profiles['__account__'] 466 467 # For local devices, add it an 'edit' option which will pop up 468 # the profile window. 469 if ( 470 not is_remote 471 and not is_test_input 472 and not (app.demo_mode or app.arcade_mode) 473 ): 474 self._profiles['_edit'] = {} 475 476 # Build a sorted name list we can iterate through. 477 self._profilenames = list(self._profiles.keys()) 478 self._profilenames.sort(key=lambda x: x.lower()) 479 480 if self._profilename in self._profilenames: 481 self._profileindex = self._profilenames.index(self._profilename) 482 else: 483 self._profileindex = 0 484 # noinspection PyUnresolvedReferences 485 self._profilename = self._profilenames[self._profileindex] 486 487 def update_position(self) -> None: 488 """Update this chooser's position.""" 489 490 assert self._text_node 491 spacing = 350 492 sessionteams = self.lobby.sessionteams 493 offs = ( 494 spacing * -0.5 * len(sessionteams) 495 + spacing * self._selected_team_index 496 + 250 497 ) 498 if len(sessionteams) > 1: 499 offs -= 35 500 animate_array( 501 self._text_node, 502 'position', 503 2, 504 {0: self._text_node.position, 0.1: (-100 + offs, self._vpos + 23)}, 505 ) 506 animate_array( 507 self.icon, 508 'position', 509 2, 510 {0: self.icon.position, 0.1: (-130 + offs, self._vpos + 22)}, 511 ) 512 513 def get_character_name(self) -> str: 514 """Return the selected character name.""" 515 return self._character_names[self._character_index] 516 517 def _do_nothing(self) -> None: 518 """Does nothing! (hacky way to disable callbacks)""" 519 520 def _getname(self, full: bool = False) -> str: 521 name_raw = name = self._profilenames[self._profileindex] 522 clamp = False 523 if name == '_random': 524 try: 525 name = self._sessionplayer.inputdevice.get_default_player_name() 526 except Exception: 527 print_exception('Error getting _random chooser name.') 528 name = 'Invalid' 529 clamp = not full 530 elif name == '__account__': 531 try: 532 name = self._sessionplayer.inputdevice.get_v1_account_name(full) 533 except Exception: 534 print_exception('Error getting account name for chooser.') 535 name = 'Invalid' 536 clamp = not full 537 elif name == '_edit': 538 # Explicitly flattening this to a str; it's only relevant on 539 # the host so that's ok. 540 name = Lstr( 541 resource='createEditPlayerText', 542 fallback_resource='editProfileWindow.titleNewText', 543 ).evaluate() 544 else: 545 # If we have a regular profile marked as global with an icon, 546 # use it (for full only). 547 if full: 548 try: 549 if self._profiles[name_raw].get('global', False): 550 icon = ( 551 self._profiles[name_raw]['icon'] 552 if 'icon' in self._profiles[name_raw] 553 else _ba.charstr(SpecialChar.LOGO) 554 ) 555 name = icon + name 556 except Exception: 557 print_exception('Error applying global icon.') 558 else: 559 # We now clamp non-full versions of names so there's at 560 # least some hope of reading them in-game. 561 clamp = True 562 563 if clamp: 564 if len(name) > 10: 565 name = name[:10] + '...' 566 return name 567 568 def _set_ready(self, ready: bool) -> None: 569 # pylint: disable=cyclic-import 570 from bastd.ui.profile import browser as pbrowser 571 from ba._general import Call 572 573 profilename = self._profilenames[self._profileindex] 574 575 # Handle '_edit' as a special case. 576 if profilename == '_edit' and ready: 577 with _ba.Context('ui'): 578 pbrowser.ProfileBrowserWindow(in_main_menu=False) 579 580 # Give their input-device UI ownership too 581 # (prevent someone else from snatching it in crowded games) 582 _ba.set_ui_input_device(self._sessionplayer.inputdevice) 583 return 584 585 if not ready: 586 self._sessionplayer.assigninput( 587 InputType.LEFT_PRESS, 588 Call(self.handlemessage, ChangeMessage('team', -1)), 589 ) 590 self._sessionplayer.assigninput( 591 InputType.RIGHT_PRESS, 592 Call(self.handlemessage, ChangeMessage('team', 1)), 593 ) 594 self._sessionplayer.assigninput( 595 InputType.BOMB_PRESS, 596 Call(self.handlemessage, ChangeMessage('character', 1)), 597 ) 598 self._sessionplayer.assigninput( 599 InputType.UP_PRESS, 600 Call(self.handlemessage, ChangeMessage('profileindex', -1)), 601 ) 602 self._sessionplayer.assigninput( 603 InputType.DOWN_PRESS, 604 Call(self.handlemessage, ChangeMessage('profileindex', 1)), 605 ) 606 self._sessionplayer.assigninput( 607 ( 608 InputType.JUMP_PRESS, 609 InputType.PICK_UP_PRESS, 610 InputType.PUNCH_PRESS, 611 ), 612 Call(self.handlemessage, ChangeMessage('ready', 1)), 613 ) 614 self._ready = False 615 self._update_text() 616 self._sessionplayer.setname('untitled', real=False) 617 else: 618 self._sessionplayer.assigninput( 619 ( 620 InputType.LEFT_PRESS, 621 InputType.RIGHT_PRESS, 622 InputType.UP_PRESS, 623 InputType.DOWN_PRESS, 624 InputType.JUMP_PRESS, 625 InputType.BOMB_PRESS, 626 InputType.PICK_UP_PRESS, 627 ), 628 self._do_nothing, 629 ) 630 self._sessionplayer.assigninput( 631 ( 632 InputType.JUMP_PRESS, 633 InputType.BOMB_PRESS, 634 InputType.PICK_UP_PRESS, 635 InputType.PUNCH_PRESS, 636 ), 637 Call(self.handlemessage, ChangeMessage('ready', 0)), 638 ) 639 640 # Store the last profile picked by this input for reuse. 641 input_device = self._sessionplayer.inputdevice 642 name = input_device.name 643 unique_id = input_device.unique_identifier 644 device_profiles = _ba.app.config.setdefault( 645 'Default Player Profiles', {} 646 ) 647 648 # Make an exception if we have no custom profiles and are set 649 # to random; in that case we'll want to start picking up custom 650 # profiles if/when one is made so keep our setting cleared. 651 special = ('_random', '_edit', '__account__') 652 have_custom_profiles = any(p not in special for p in self._profiles) 653 654 profilekey = name + ' ' + unique_id 655 if profilename == '_random' and not have_custom_profiles: 656 if profilekey in device_profiles: 657 del device_profiles[profilekey] 658 else: 659 device_profiles[profilekey] = profilename 660 _ba.app.config.commit() 661 662 # Set this player's short and full name. 663 self._sessionplayer.setname( 664 self._getname(), self._getname(full=True), real=True 665 ) 666 self._ready = True 667 self._update_text() 668 669 # Inform the session that this player is ready. 670 _ba.getsession().handlemessage(PlayerReadyMessage(self)) 671 672 def _handle_ready_msg(self, ready: bool) -> None: 673 force_team_switch = False 674 675 # Team auto-balance kicks us to another team if we try to 676 # join the team with the most players. 677 if not self._ready: 678 if _ba.app.config.get('Auto Balance Teams', False): 679 lobby = self.lobby 680 sessionteams = lobby.sessionteams 681 if len(sessionteams) > 1: 682 683 # First, calc how many players are on each team 684 # ..we need to count both active players and 685 # choosers that have been marked as ready. 686 team_player_counts = {} 687 for sessionteam in sessionteams: 688 team_player_counts[sessionteam.id] = len( 689 sessionteam.players 690 ) 691 for chooser in lobby.choosers: 692 if chooser.ready: 693 team_player_counts[chooser.sessionteam.id] += 1 694 largest_team_size = max(team_player_counts.values()) 695 smallest_team_size = min(team_player_counts.values()) 696 697 # Force switch if we're on the biggest sessionteam 698 # and there's a smaller one available. 699 if ( 700 largest_team_size != smallest_team_size 701 and team_player_counts[self.sessionteam.id] 702 >= largest_team_size 703 ): 704 force_team_switch = True 705 706 # Either force switch teams, or actually for realsies do the set-ready. 707 if force_team_switch: 708 _ba.playsound(self._errorsound) 709 self.handlemessage(ChangeMessage('team', 1)) 710 else: 711 _ba.playsound(self._punchsound) 712 self._set_ready(ready) 713 714 # TODO: should handle this at the engine layer so this is unnecessary. 715 def _handle_repeat_message_attack(self) -> None: 716 now = _ba.time() 717 count = self._last_change[1] 718 if now - self._last_change[0] < QUICK_CHANGE_INTERVAL: 719 count += 1 720 if count > MAX_QUICK_CHANGE_COUNT: 721 _ba.disconnect_client(self._sessionplayer.inputdevice.client_id) 722 elif now - self._last_change[0] > QUICK_CHANGE_RESET_INTERVAL: 723 count = 0 724 self._last_change = (now, count) 725 726 def handlemessage(self, msg: Any) -> Any: 727 """Standard generic message handler.""" 728 729 if isinstance(msg, ChangeMessage): 730 self._handle_repeat_message_attack() 731 732 # If we've been removed from the lobby, ignore this stuff. 733 if self._dead: 734 print_error('chooser got ChangeMessage after dying') 735 return 736 737 if not self._text_node: 738 print_error('got ChangeMessage after nodes died') 739 return 740 741 if msg.what == 'team': 742 sessionteams = self.lobby.sessionteams 743 if len(sessionteams) > 1: 744 _ba.playsound(self._swish_sound) 745 self._selected_team_index = ( 746 self._selected_team_index + msg.value 747 ) % len(sessionteams) 748 self._update_text() 749 self.update_position() 750 self._update_icon() 751 752 elif msg.what == 'profileindex': 753 if len(self._profilenames) == 1: 754 755 # This should be pretty hard to hit now with 756 # automatic local accounts. 757 _ba.playsound(_ba.getsound('error')) 758 else: 759 760 # Pick the next player profile and assign our name 761 # and character based on that. 762 _ba.playsound(self._deek_sound) 763 self._profileindex = (self._profileindex + msg.value) % len( 764 self._profilenames 765 ) 766 self.update_from_profile() 767 768 elif msg.what == 'character': 769 _ba.playsound(self._click_sound) 770 # update our index in our local list of characters 771 self._character_index = ( 772 self._character_index + msg.value 773 ) % len(self._character_names) 774 self._update_text() 775 self._update_icon() 776 777 elif msg.what == 'ready': 778 self._handle_ready_msg(bool(msg.value)) 779 780 def _update_text(self) -> None: 781 assert self._text_node is not None 782 if self._ready: 783 784 # Once we're ready, we've saved the name, so lets ask the system 785 # for it so we get appended numbers and stuff. 786 text = Lstr(value=self._sessionplayer.getname(full=True)) 787 text = Lstr( 788 value='${A} (${B})', 789 subs=[('${A}', text), ('${B}', Lstr(resource='readyText'))], 790 ) 791 else: 792 text = Lstr(value=self._getname(full=True)) 793 794 can_switch_teams = len(self.lobby.sessionteams) > 1 795 796 # Flash as we're coming in. 797 fin_color = _ba.safecolor(self.get_color()) + (1,) 798 if not self._inited: 799 animate_array( 800 self._text_node, 801 'color', 802 4, 803 {0.15: fin_color, 0.25: (2, 2, 2, 1), 0.35: fin_color}, 804 ) 805 else: 806 807 # Blend if we're in teams mode; switch instantly otherwise. 808 if can_switch_teams: 809 animate_array( 810 self._text_node, 811 'color', 812 4, 813 {0: self._text_node.color, 0.1: fin_color}, 814 ) 815 else: 816 self._text_node.color = fin_color 817 818 self._text_node.text = text 819 820 def get_color(self) -> Sequence[float]: 821 """Return the currently selected color.""" 822 val: Sequence[float] 823 if self.lobby.use_team_colors: 824 val = self.lobby.sessionteams[self._selected_team_index].color 825 else: 826 val = self._color 827 if len(val) != 3: 828 print('get_color: ignoring invalid color of len', len(val)) 829 val = (0, 1, 0) 830 return val 831 832 def get_highlight(self) -> Sequence[float]: 833 """Return the currently selected highlight.""" 834 if self._profilenames[self._profileindex] == '_edit': 835 return 0, 1, 0 836 837 # If we're using team colors we wanna make sure our highlight color 838 # isn't too close to any other team's color. 839 highlight = list(self._highlight) 840 if self.lobby.use_team_colors: 841 for i, sessionteam in enumerate(self.lobby.sessionteams): 842 if i != self._selected_team_index: 843 844 # Find the dominant component of this sessionteam's color 845 # and adjust ours so that the component is 846 # not super-dominant. 847 max_val = 0.0 848 max_index = 0 849 for j in range(3): 850 if sessionteam.color[j] > max_val: 851 max_val = sessionteam.color[j] 852 max_index = j 853 that_color_for_us = highlight[max_index] 854 our_second_biggest = max( 855 highlight[(max_index + 1) % 3], 856 highlight[(max_index + 2) % 3], 857 ) 858 diff = that_color_for_us - our_second_biggest 859 if diff > 0: 860 highlight[max_index] -= diff * 0.6 861 highlight[(max_index + 1) % 3] += diff * 0.3 862 highlight[(max_index + 2) % 3] += diff * 0.2 863 return highlight 864 865 def getplayer(self) -> ba.SessionPlayer: 866 """Return the player associated with this chooser.""" 867 return self._sessionplayer 868 869 def _update_icon(self) -> None: 870 if self._profilenames[self._profileindex] == '_edit': 871 tex = _ba.gettexture('black') 872 tint_tex = _ba.gettexture('black') 873 self.icon.color = (1, 1, 1) 874 self.icon.texture = tex 875 self.icon.tint_texture = tint_tex 876 self.icon.tint_color = (0, 1, 0) 877 return 878 879 try: 880 tex_name = _ba.app.spaz_appearances[ 881 self._character_names[self._character_index] 882 ].icon_texture 883 tint_tex_name = _ba.app.spaz_appearances[ 884 self._character_names[self._character_index] 885 ].icon_mask_texture 886 except Exception: 887 print_exception('Error updating char icon list') 888 tex_name = 'neoSpazIcon' 889 tint_tex_name = 'neoSpazIconColorMask' 890 891 tex = _ba.gettexture(tex_name) 892 tint_tex = _ba.gettexture(tint_tex_name) 893 894 self.icon.color = (1, 1, 1) 895 self.icon.texture = tex 896 self.icon.tint_texture = tint_tex 897 clr = self.get_color() 898 clr2 = self.get_highlight() 899 900 can_switch_teams = len(self.lobby.sessionteams) > 1 901 902 # If we're initing, flash. 903 if not self._inited: 904 animate_array( 905 self.icon, 906 'color', 907 3, 908 {0.15: (1, 1, 1), 0.25: (2, 2, 2), 0.35: (1, 1, 1)}, 909 ) 910 911 # Blend in teams mode; switch instantly in ffa-mode. 912 if can_switch_teams: 913 animate_array( 914 self.icon, 'tint_color', 3, {0: self.icon.tint_color, 0.1: clr} 915 ) 916 else: 917 self.icon.tint_color = clr 918 self.icon.tint2_color = clr2 919 920 # Store the icon info the the player. 921 self._sessionplayer.set_icon_info(tex_name, tint_tex_name, clr, clr2)
A character/team selector for a ba.Player.
Category: Gameplay Classes
185 def __init__( 186 self, vpos: float, sessionplayer: _ba.SessionPlayer, lobby: 'Lobby' 187 ) -> None: 188 self._deek_sound = _ba.getsound('deek') 189 self._click_sound = _ba.getsound('click01') 190 self._punchsound = _ba.getsound('punch01') 191 self._swish_sound = _ba.getsound('punchSwish') 192 self._errorsound = _ba.getsound('error') 193 self._mask_texture = _ba.gettexture('characterIconMask') 194 self._vpos = vpos 195 self._lobby = weakref.ref(lobby) 196 self._sessionplayer = sessionplayer 197 self._inited = False 198 self._dead = False 199 self._text_node: ba.Node | None = None 200 self._profilename = '' 201 self._profilenames: list[str] = [] 202 self._ready: bool = False 203 self._character_names: list[str] = [] 204 self._last_change: Sequence[float | int] = (0, 0) 205 self._profiles: dict[str, dict[str, Any]] = {} 206 207 app = _ba.app 208 209 # Load available player profiles either from the local config or 210 # from the remote device. 211 self.reload_profiles() 212 213 # Note: this is just our local index out of available teams; *not* 214 # the team-id! 215 self._selected_team_index: int = self.lobby.next_add_team 216 217 # Store a persistent random character index and colors; we'll use this 218 # for the '_random' profile. Let's use their input_device id to seed 219 # it. This will give a persistent character for them between games 220 # and will distribute characters nicely if everyone is random. 221 self._random_color, self._random_highlight = get_player_profile_colors( 222 None 223 ) 224 225 # To calc our random character we pick a random one out of our 226 # unlocked list and then locate that character's index in the full 227 # list. 228 char_index_offset = app.lobby_random_char_index_offset 229 self._random_character_index = ( 230 sessionplayer.inputdevice.id + char_index_offset 231 ) % len(self._character_names) 232 233 # Attempt to set an initial profile based on what was used previously 234 # for this input-device, etc. 235 self._profileindex = self._select_initial_profile() 236 self._profilename = self._profilenames[self._profileindex] 237 238 self._text_node = _ba.newnode( 239 'text', 240 delegate=self, 241 attrs={ 242 'position': (-100, self._vpos), 243 'maxwidth': 160, 244 'shadow': 0.5, 245 'vr_depth': -20, 246 'h_align': 'left', 247 'v_align': 'center', 248 'v_attach': 'top', 249 }, 250 ) 251 animate(self._text_node, 'scale', {0: 0, 0.1: 1.0}) 252 self.icon = _ba.newnode( 253 'image', 254 owner=self._text_node, 255 attrs={ 256 'position': (-130, self._vpos + 20), 257 'mask_texture': self._mask_texture, 258 'vr_depth': -10, 259 'attach': 'topCenter', 260 }, 261 ) 262 263 animate_array(self.icon, 'scale', 2, {0: (0, 0), 0.1: (45, 45)}) 264 265 # Set our initial name to '<choosing player>' in case anyone asks. 266 self._sessionplayer.setname( 267 Lstr(resource='choosingPlayerText').evaluate(), real=False 268 ) 269 270 # Init these to our rando but they should get switched to the 271 # selected profile (if any) right after. 272 self._character_index = self._random_character_index 273 self._color = self._random_color 274 self._highlight = self._random_highlight 275 276 self.update_from_profile() 277 self.update_position() 278 self._inited = True 279 280 self._set_ready(False)
385 def get_lobby(self) -> ba.Lobby | None: 386 """Return this chooser's lobby if it still exists; otherwise None.""" 387 return self._lobby()
Return this chooser's lobby if it still exists; otherwise None.
389 def update_from_profile(self) -> None: 390 """Set character/colors based on the current profile.""" 391 self._profilename = self._profilenames[self._profileindex] 392 if self._profilename == '_edit': 393 pass 394 elif self._profilename == '_random': 395 self._character_index = self._random_character_index 396 self._color = self._random_color 397 self._highlight = self._random_highlight 398 else: 399 character = self._profiles[self._profilename]['character'] 400 401 # At the moment we're not properly pulling the list 402 # of available characters from clients, so profiles might use a 403 # character not in their list. For now, just go ahead and add 404 # a character name to their list as long as we're aware of it. 405 # This just means they won't always be able to override their 406 # character to others they own, but profile characters 407 # should work (and we validate profiles on the master server 408 # so no exploit opportunities) 409 if ( 410 character not in self._character_names 411 and character in _ba.app.spaz_appearances 412 ): 413 self._character_names.append(character) 414 self._character_index = self._character_names.index(character) 415 self._color, self._highlight = get_player_profile_colors( 416 self._profilename, profiles=self._profiles 417 ) 418 self._update_icon() 419 self._update_text()
Set character/colors based on the current profile.
421 def reload_profiles(self) -> None: 422 """Reload all player profiles.""" 423 from ba._general import json_prep 424 425 app = _ba.app 426 427 # Re-construct our profile index and other stuff since the profile 428 # list might have changed. 429 input_device = self._sessionplayer.inputdevice 430 is_remote = input_device.is_remote_client 431 is_test_input = input_device.name.startswith('TestInput') 432 433 # Pull this player's list of unlocked characters. 434 if is_remote: 435 # TODO: Pull this from the remote player. 436 # (but make sure to filter it to the ones we've got). 437 self._character_names = ['Spaz'] 438 else: 439 self._character_names = self.lobby.character_names_local_unlocked 440 441 # If we're a local player, pull our local profiles from the config. 442 # Otherwise ask the remote-input-device for its profile list. 443 if is_remote: 444 self._profiles = input_device.get_player_profiles() 445 else: 446 self._profiles = app.config.get('Player Profiles', {}) 447 448 # These may have come over the wire from an older 449 # (non-unicode/non-json) version. 450 # Make sure they conform to our standards 451 # (unicode strings, no tuples, etc) 452 self._profiles = json_prep(self._profiles) 453 454 # Filter out any characters we're unaware of. 455 for profile in list(self._profiles.items()): 456 if profile[1].get('character', '') not in app.spaz_appearances: 457 profile[1]['character'] = 'Spaz' 458 459 # Add in a random one so we're ok even if there's no user profiles. 460 self._profiles['_random'] = {} 461 462 # In kiosk mode we disable account profiles to force random. 463 if app.demo_mode or app.arcade_mode: 464 if '__account__' in self._profiles: 465 del self._profiles['__account__'] 466 467 # For local devices, add it an 'edit' option which will pop up 468 # the profile window. 469 if ( 470 not is_remote 471 and not is_test_input 472 and not (app.demo_mode or app.arcade_mode) 473 ): 474 self._profiles['_edit'] = {} 475 476 # Build a sorted name list we can iterate through. 477 self._profilenames = list(self._profiles.keys()) 478 self._profilenames.sort(key=lambda x: x.lower()) 479 480 if self._profilename in self._profilenames: 481 self._profileindex = self._profilenames.index(self._profilename) 482 else: 483 self._profileindex = 0 484 # noinspection PyUnresolvedReferences 485 self._profilename = self._profilenames[self._profileindex]
Reload all player profiles.
487 def update_position(self) -> None: 488 """Update this chooser's position.""" 489 490 assert self._text_node 491 spacing = 350 492 sessionteams = self.lobby.sessionteams 493 offs = ( 494 spacing * -0.5 * len(sessionteams) 495 + spacing * self._selected_team_index 496 + 250 497 ) 498 if len(sessionteams) > 1: 499 offs -= 35 500 animate_array( 501 self._text_node, 502 'position', 503 2, 504 {0: self._text_node.position, 0.1: (-100 + offs, self._vpos + 23)}, 505 ) 506 animate_array( 507 self.icon, 508 'position', 509 2, 510 {0: self.icon.position, 0.1: (-130 + offs, self._vpos + 22)}, 511 )
Update this chooser's position.
513 def get_character_name(self) -> str: 514 """Return the selected character name.""" 515 return self._character_names[self._character_index]
Return the selected character name.
726 def handlemessage(self, msg: Any) -> Any: 727 """Standard generic message handler.""" 728 729 if isinstance(msg, ChangeMessage): 730 self._handle_repeat_message_attack() 731 732 # If we've been removed from the lobby, ignore this stuff. 733 if self._dead: 734 print_error('chooser got ChangeMessage after dying') 735 return 736 737 if not self._text_node: 738 print_error('got ChangeMessage after nodes died') 739 return 740 741 if msg.what == 'team': 742 sessionteams = self.lobby.sessionteams 743 if len(sessionteams) > 1: 744 _ba.playsound(self._swish_sound) 745 self._selected_team_index = ( 746 self._selected_team_index + msg.value 747 ) % len(sessionteams) 748 self._update_text() 749 self.update_position() 750 self._update_icon() 751 752 elif msg.what == 'profileindex': 753 if len(self._profilenames) == 1: 754 755 # This should be pretty hard to hit now with 756 # automatic local accounts. 757 _ba.playsound(_ba.getsound('error')) 758 else: 759 760 # Pick the next player profile and assign our name 761 # and character based on that. 762 _ba.playsound(self._deek_sound) 763 self._profileindex = (self._profileindex + msg.value) % len( 764 self._profilenames 765 ) 766 self.update_from_profile() 767 768 elif msg.what == 'character': 769 _ba.playsound(self._click_sound) 770 # update our index in our local list of characters 771 self._character_index = ( 772 self._character_index + msg.value 773 ) % len(self._character_names) 774 self._update_text() 775 self._update_icon() 776 777 elif msg.what == 'ready': 778 self._handle_ready_msg(bool(msg.value))
Standard generic message handler.
820 def get_color(self) -> Sequence[float]: 821 """Return the currently selected color.""" 822 val: Sequence[float] 823 if self.lobby.use_team_colors: 824 val = self.lobby.sessionteams[self._selected_team_index].color 825 else: 826 val = self._color 827 if len(val) != 3: 828 print('get_color: ignoring invalid color of len', len(val)) 829 val = (0, 1, 0) 830 return val
Return the currently selected color.
832 def get_highlight(self) -> Sequence[float]: 833 """Return the currently selected highlight.""" 834 if self._profilenames[self._profileindex] == '_edit': 835 return 0, 1, 0 836 837 # If we're using team colors we wanna make sure our highlight color 838 # isn't too close to any other team's color. 839 highlight = list(self._highlight) 840 if self.lobby.use_team_colors: 841 for i, sessionteam in enumerate(self.lobby.sessionteams): 842 if i != self._selected_team_index: 843 844 # Find the dominant component of this sessionteam's color 845 # and adjust ours so that the component is 846 # not super-dominant. 847 max_val = 0.0 848 max_index = 0 849 for j in range(3): 850 if sessionteam.color[j] > max_val: 851 max_val = sessionteam.color[j] 852 max_index = j 853 that_color_for_us = highlight[max_index] 854 our_second_biggest = max( 855 highlight[(max_index + 1) % 3], 856 highlight[(max_index + 2) % 3], 857 ) 858 diff = that_color_for_us - our_second_biggest 859 if diff > 0: 860 highlight[max_index] -= diff * 0.6 861 highlight[(max_index + 1) % 3] += diff * 0.3 862 highlight[(max_index + 2) % 3] += diff * 0.2 863 return highlight
Return the currently selected highlight.
1437def clipboard_get_text() -> str: 1438 1439 """Return text currently on the system clipboard. 1440 1441 Category: **General Utility Functions** 1442 1443 Ensure that ba.clipboard_has_text() returns True before calling 1444 this function. 1445 """ 1446 return str()
Return text currently on the system clipboard.
Category: General Utility Functions
Ensure that ba.clipboard_has_text() returns True before calling this function.
1449def clipboard_has_text() -> bool: 1450 1451 """Return whether there is currently text on the clipboard. 1452 1453 Category: **General Utility Functions** 1454 1455 This will return False if no system clipboard is available; no need 1456 to call ba.clipboard_is_supported() separately. 1457 """ 1458 return bool()
Return whether there is currently text on the clipboard.
Category: General Utility Functions
This will return False if no system clipboard is available; no need to call ba.clipboard_is_supported() separately.
1461def clipboard_is_supported() -> bool: 1462 1463 """Return whether this platform supports clipboard operations at all. 1464 1465 Category: **General Utility Functions** 1466 1467 If this returns False, UIs should not show 'copy to clipboard' 1468 buttons, etc. 1469 """ 1470 return bool()
Return whether this platform supports clipboard operations at all.
Category: General Utility Functions
If this returns False, UIs should not show 'copy to clipboard' buttons, etc.
1473def clipboard_set_text(value: str) -> None: 1474 1475 """Copy a string to the system clipboard. 1476 1477 Category: **General Utility Functions** 1478 1479 Ensure that ba.clipboard_is_supported() returns True before adding 1480 buttons/etc. that make use of this functionality. 1481 """ 1482 return None
Copy a string to the system clipboard.
Category: General Utility Functions
Ensure that ba.clipboard_is_supported() returns True before adding buttons/etc. that make use of this functionality.
82class CollideModel: 83 84 """A reference to a collide-model. 85 86 Category: **Asset Classes** 87 88 Use ba.getcollidemodel() to instantiate one. 89 """ 90 91 pass
A reference to a collide-model.
Category: Asset Classes
Use ba.getcollidemodel() to instantiate one.
17class Collision: 18 """A class providing info about occurring collisions. 19 20 Category: **Gameplay Classes** 21 """ 22 23 @property 24 def position(self) -> ba.Vec3: 25 """The position of the current collision.""" 26 return _ba.Vec3(_ba.get_collision_info('position')) 27 28 @property 29 def sourcenode(self) -> ba.Node: 30 """The node containing the material triggering the current callback. 31 32 Throws a ba.NodeNotFoundError if the node does not exist, though 33 the node should always exist (at least at the start of the collision 34 callback). 35 """ 36 node = _ba.get_collision_info('sourcenode') 37 assert isinstance(node, (_ba.Node, type(None))) 38 if not node: 39 raise NodeNotFoundError() 40 return node 41 42 @property 43 def opposingnode(self) -> ba.Node: 44 """The node the current callback material node is hitting. 45 46 Throws a ba.NodeNotFoundError if the node does not exist. 47 This can be expected in some cases such as in 'disconnect' 48 callbacks triggered by deleting a currently-colliding node. 49 """ 50 node = _ba.get_collision_info('opposingnode') 51 assert isinstance(node, (_ba.Node, type(None))) 52 if not node: 53 raise NodeNotFoundError() 54 return node 55 56 @property 57 def opposingbody(self) -> int: 58 """The body index on the opposing node in the current collision.""" 59 body = _ba.get_collision_info('opposingbody') 60 assert isinstance(body, int) 61 return body
A class providing info about occurring collisions.
Category: Gameplay Classes
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).
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.
1485def columnwidget( 1486 edit: ba.Widget | None = None, 1487 parent: ba.Widget | None = None, 1488 size: Sequence[float] | None = None, 1489 position: Sequence[float] | None = None, 1490 background: bool | None = None, 1491 selected_child: ba.Widget | None = None, 1492 visible_child: ba.Widget | None = None, 1493 single_depth: bool | None = None, 1494 print_list_exit_instructions: bool | None = None, 1495 left_border: float | None = None, 1496 top_border: float | None = None, 1497 bottom_border: float | None = None, 1498 selection_loops_to_parent: bool | None = None, 1499 border: float | None = None, 1500 margin: float | None = None, 1501 claims_left_right: bool | None = None, 1502 claims_tab: bool | None = None, 1503) -> ba.Widget: 1504 1505 """Create or edit a column widget. 1506 1507 Category: **User Interface Functions** 1508 1509 Pass a valid existing ba.Widget as 'edit' to modify it; otherwise 1510 a new one is created and returned. Arguments that are not set to None 1511 are applied to the Widget. 1512 """ 1513 import ba # pylint: disable=cyclic-import 1514 1515 return ba.Widget()
Create or edit a column widget.
Category: User Interface Functions
Pass a valid existing ba.Widget as 'edit' to modify it; otherwise a new one is created and returned. Arguments that are not set to None are applied to the Widget.
1543def containerwidget( 1544 edit: ba.Widget | None = None, 1545 parent: ba.Widget | None = None, 1546 size: Sequence[float] | None = None, 1547 position: Sequence[float] | None = None, 1548 background: bool | None = None, 1549 selected_child: ba.Widget | None = None, 1550 transition: str | None = None, 1551 cancel_button: ba.Widget | None = None, 1552 start_button: ba.Widget | None = None, 1553 root_selectable: bool | None = None, 1554 on_activate_call: Callable[[], None] | None = None, 1555 claims_left_right: bool | None = None, 1556 claims_tab: bool | None = None, 1557 selection_loops: bool | None = None, 1558 selection_loops_to_parent: bool | None = None, 1559 scale: float | None = None, 1560 on_outside_click_call: Callable[[], None] | None = None, 1561 single_depth: bool | None = None, 1562 visible_child: ba.Widget | None = None, 1563 stack_offset: Sequence[float] | None = None, 1564 color: Sequence[float] | None = None, 1565 on_cancel_call: Callable[[], None] | None = None, 1566 print_list_exit_instructions: bool | None = None, 1567 click_activate: bool | None = None, 1568 always_highlight: bool | None = None, 1569 selectable: bool | None = None, 1570 scale_origin_stack_offset: Sequence[float] | None = None, 1571 toolbar_visibility: str | None = None, 1572 on_select_call: Callable[[], None] | None = None, 1573 claim_outside_clicks: bool | None = None, 1574 claims_up_down: bool | None = None, 1575) -> ba.Widget: 1576 1577 """Create or edit a container widget. 1578 1579 Category: **User Interface Functions** 1580 1581 Pass a valid existing ba.Widget as 'edit' to modify it; otherwise 1582 a new one is created and returned. Arguments that are not set to None 1583 are applied to the Widget. 1584 """ 1585 import ba # pylint: disable=cyclic-import 1586 1587 return ba.Widget()
Create or edit a container widget.
Category: User Interface Functions
Pass a valid existing ba.Widget as 'edit' to modify it; otherwise a new one is created and returned. Arguments that are not set to None are applied to the Widget.
94class Context: 95 96 """A game context state. 97 98 Category: **General Utility Classes** 99 100 Many operations such as ba.newnode() or ba.gettexture() operate 101 implicitly on the current context. Each ba.Activity has its own 102 Context and objects within that activity (nodes, media, etc) can only 103 interact with other objects from that context. 104 105 In general, as a modder, you should not need to worry about contexts, 106 since timers and other callbacks will take care of saving and 107 restoring the context automatically, but there may be rare cases where 108 you need to deal with them, such as when loading media in for use in 109 the UI (there is a special `'ui'` context for all 110 user-interface-related functionality). 111 112 When instantiating a ba.Context instance, a single `'source'` argument 113 is passed, which can be one of the following strings/objects: 114 115 ###### `'empty'` 116 > Gives an empty context; it can be handy to run code here to ensure 117 it does no loading of media, creation of nodes, etc. 118 119 ###### `'current'` 120 > Sets the context object to the current context. 121 122 ###### `'ui'` 123 > Sets to the UI context. UI functions as well as loading of media to 124 be used in said functions must happen in the UI context. 125 126 ###### A ba.Activity instance 127 > Gives the context for the provided ba.Activity. 128 Most all code run during a game happens in an Activity's Context. 129 130 ###### A ba.Session instance 131 > Gives the context for the provided ba.Session. 132 Generally a user should not need to run anything here. 133 134 135 ##### Usage 136 Contexts are generally used with the python 'with' statement, which 137 sets the context as current on entry and resets it to the previous 138 value on exit. 139 140 ##### Example 141 Load a few textures into the UI context 142 (for use in widgets, etc): 143 >>> with ba.Context('ui'): 144 ... tex1 = ba.gettexture('foo_tex_1') 145 ... tex2 = ba.gettexture('foo_tex_2') 146 """ 147 148 def __init__(self, source: Any): 149 pass 150 151 def __enter__(self) -> None: 152 """Support for "with" statement.""" 153 pass 154 155 def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any: 156 """Support for "with" statement.""" 157 pass
A game context state.
Category: General Utility Classes
Many operations such as ba.newnode() or ba.gettexture() operate implicitly on the current context. Each ba.Activity has its own Context and objects within that activity (nodes, media, etc) can only interact with other objects from that context.
In general, as a modder, you should not need to worry about contexts,
since timers and other callbacks will take care of saving and
restoring the context automatically, but there may be rare cases where
you need to deal with them, such as when loading media in for use in
the UI (there is a special 'ui'
context for all
user-interface-related functionality).
When instantiating a ba.Context instance, a single 'source'
argument
is passed, which can be one of the following strings/objects:
'empty'
Gives an empty context; it can be handy to run code here to ensure it does no loading of media, creation of nodes, etc.
'current'
Sets the context object to the current context.
'ui'
Sets to the UI context. UI functions as well as loading of media to be used in said functions must happen in the UI context.
A ba.Activity instance
Gives the context for the provided ba.Activity. Most all code run during a game happens in an Activity's Context.
A ba.Session instance
Gives the context for the provided ba.Session. Generally a user should not need to run anything here.
Usage
Contexts are generally used with the python 'with' statement, which sets the context as current on entry and resets it to the previous value on exit.
Example
Load a few textures into the UI context (for use in widgets, etc):
>>> with ba.Context('ui'):
... tex1 = ba.gettexture('foo_tex_1')
... tex2 = ba.gettexture('foo_tex_2')
160class ContextCall: 161 162 """A context-preserving callable. 163 164 Category: **General Utility Classes** 165 166 A ContextCall wraps a callable object along with a reference 167 to the current context (see ba.Context); it handles restoring the 168 context when run and automatically clears itself if the context 169 it belongs to shuts down. 170 171 Generally you should not need to use this directly; all standard 172 Ballistica callbacks involved with timers, materials, UI functions, 173 etc. handle this under-the-hood you don't have to worry about it. 174 The only time it may be necessary is if you are implementing your 175 own callbacks, such as a worker thread that does some action and then 176 runs some game code when done. By wrapping said callback in one of 177 these, you can ensure that you will not inadvertently be keeping the 178 current activity alive or running code in a torn-down (expired) 179 context. 180 181 You can also use ba.WeakCall for similar functionality, but 182 ContextCall has the added bonus that it will not run during context 183 shutdown, whereas ba.WeakCall simply looks at whether the target 184 object still exists. 185 186 ##### Examples 187 **Example A:** code like this can inadvertently prevent our activity 188 (self) from ending until the operation completes, since the bound 189 method we're passing (self.dosomething) contains a strong-reference 190 to self). 191 >>> start_some_long_action(callback_when_done=self.dosomething) 192 193 **Example B:** in this case our activity (self) can still die 194 properly; the callback will clear itself when the activity starts 195 shutting down, becoming a harmless no-op and releasing the reference 196 to our activity. 197 198 >>> start_long_action( 199 ... callback_when_done=ba.ContextCall(self.mycallback)) 200 """ 201 202 def __init__(self, call: Callable): 203 pass
A context-preserving callable.
Category: General Utility Classes
A ContextCall wraps a callable object along with a reference to the current context (see ba.Context); it handles restoring the context when run and automatically clears itself if the context it belongs to shuts down.
Generally you should not need to use this directly; all standard Ballistica callbacks involved with timers, materials, UI functions, etc. handle this under-the-hood you don't have to worry about it. The only time it may be necessary is if you are implementing your own callbacks, such as a worker thread that does some action and then runs some game code when done. By wrapping said callback in one of these, you can ensure that you will not inadvertently be keeping the current activity alive or running code in a torn-down (expired) context.
You can also use ba.WeakCall for similar functionality, but ContextCall has the added bonus that it will not run during context shutdown, whereas ba.WeakCall simply looks at whether the target object still exists.
Examples
Example A: code like this can inadvertently prevent our activity (self) from ending until the operation completes, since the bound method we're passing (self.dosomething) contains a strong-reference to self).
>>> start_some_long_action(callback_when_done=self.dosomething)
Example B: in this case our activity (self) can still die properly; the callback will clear itself when the activity starts shutting down, becoming a harmless no-op and releasing the reference to our activity.
>>> start_long_action(
... callback_when_done=ba.ContextCall(self.mycallback))
35class ContextError(Exception): 36 """Exception raised when a call is made in an invalid context. 37 38 Category: **Exception Classes** 39 40 Examples of this include calling UI functions within an Activity context 41 or calling scene manipulation functions outside of a game context. 42 """
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.
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
26class CloudSubsystem: 27 """Manages communication with cloud components.""" 28 29 def is_connected(self) -> bool: 30 """Return whether a connection to the cloud is present. 31 32 This is a good indicator (though not for certain) that sending 33 messages will succeed. 34 """ 35 return False # Needs to be overridden 36 37 def on_app_pause(self) -> None: 38 """Should be called when the app pauses.""" 39 40 def on_app_resume(self) -> None: 41 """Should be called when the app resumes.""" 42 43 def on_connectivity_changed(self, connected: bool) -> None: 44 """Called when cloud connectivity state changes.""" 45 if DEBUG_LOG: 46 logging.debug('CloudSubsystem: Connectivity is now %s.', connected) 47 48 # Inform things that use this. 49 # (TODO: should generalize this into some sort of registration system) 50 _ba.app.accounts_v2.on_cloud_connectivity_changed(connected) 51 52 @overload 53 def send_message_cb( 54 self, 55 msg: bacommon.cloud.LoginProxyRequestMessage, 56 on_response: Callable[ 57 [bacommon.cloud.LoginProxyRequestResponse | Exception], None 58 ], 59 ) -> None: 60 ... 61 62 @overload 63 def send_message_cb( 64 self, 65 msg: bacommon.cloud.LoginProxyStateQueryMessage, 66 on_response: Callable[ 67 [bacommon.cloud.LoginProxyStateQueryResponse | Exception], None 68 ], 69 ) -> None: 70 ... 71 72 @overload 73 def send_message_cb( 74 self, 75 msg: bacommon.cloud.LoginProxyCompleteMessage, 76 on_response: Callable[[None | Exception], None], 77 ) -> None: 78 ... 79 80 @overload 81 def send_message_cb( 82 self, 83 msg: bacommon.cloud.PingMessage, 84 on_response: Callable[[bacommon.cloud.PingResponse | Exception], None], 85 ) -> None: 86 ... 87 88 @overload 89 def send_message_cb( 90 self, 91 msg: bacommon.cloud.SignInMessage, 92 on_response: Callable[ 93 [bacommon.cloud.SignInResponse | Exception], None 94 ], 95 ) -> None: 96 ... 97 98 @overload 99 def send_message_cb( 100 self, 101 msg: bacommon.cloud.ManageAccountMessage, 102 on_response: Callable[ 103 [bacommon.cloud.ManageAccountResponse | Exception], None 104 ], 105 ) -> None: 106 ... 107 108 def send_message_cb( 109 self, 110 msg: Message, 111 on_response: Callable[[Any], None], 112 ) -> None: 113 """Asynchronously send a message to the cloud from the logic thread. 114 115 The provided on_response call will be run in the logic thread 116 and passed either the response or the error that occurred. 117 """ 118 from ba._general import Call 119 120 del msg # Unused. 121 122 _ba.pushcall( 123 Call( 124 on_response, 125 RuntimeError('Cloud functionality is not available.'), 126 ) 127 ) 128 129 @overload 130 def send_message( 131 self, msg: bacommon.cloud.WorkspaceFetchMessage 132 ) -> bacommon.cloud.WorkspaceFetchResponse: 133 ... 134 135 @overload 136 def send_message( 137 self, msg: bacommon.cloud.MerchAvailabilityMessage 138 ) -> bacommon.cloud.MerchAvailabilityResponse: 139 ... 140 141 @overload 142 def send_message( 143 self, msg: bacommon.cloud.TestMessage 144 ) -> bacommon.cloud.TestResponse: 145 ... 146 147 def send_message(self, msg: Message) -> Response | None: 148 """Synchronously send a message to the cloud. 149 150 Must be called from a background thread. 151 """ 152 raise RuntimeError('Cloud functionality is not available.')
Manages communication with cloud components.
29 def is_connected(self) -> bool: 30 """Return whether a connection to the cloud is present. 31 32 This is a good indicator (though not for certain) that sending 33 messages will succeed. 34 """ 35 return False # Needs to be overridden
Return whether a connection to the cloud is present.
This is a good indicator (though not for certain) that sending messages will succeed.
43 def on_connectivity_changed(self, connected: bool) -> None: 44 """Called when cloud connectivity state changes.""" 45 if DEBUG_LOG: 46 logging.debug('CloudSubsystem: Connectivity is now %s.', connected) 47 48 # Inform things that use this. 49 # (TODO: should generalize this into some sort of registration system) 50 _ba.app.accounts_v2.on_cloud_connectivity_changed(connected)
Called when cloud connectivity state changes.
108 def send_message_cb( 109 self, 110 msg: Message, 111 on_response: Callable[[Any], None], 112 ) -> None: 113 """Asynchronously send a message to the cloud from the logic thread. 114 115 The provided on_response call will be run in the logic thread 116 and passed either the response or the error that occurred. 117 """ 118 from ba._general import Call 119 120 del msg # Unused. 121 122 _ba.pushcall( 123 Call( 124 on_response, 125 RuntimeError('Cloud functionality is not available.'), 126 ) 127 )
Asynchronously send a message to the cloud from the logic thread.
The provided on_response call will be run in the logic thread and passed either the response or the error that occurred.
147 def send_message(self, msg: Message) -> Response | None: 148 """Synchronously send a message to the cloud. 149 150 Must be called from a background thread. 151 """ 152 raise RuntimeError('Cloud functionality is not available.')
Synchronously send a message to the cloud.
Must be called from a background thread.
25class CoopGameActivity(GameActivity[PlayerType, TeamType]): 26 """Base class for cooperative-mode games. 27 28 Category: **Gameplay Classes** 29 """ 30 31 # We can assume our session is a CoopSession. 32 session: ba.CoopSession 33 34 @classmethod 35 def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool: 36 from ba._coopsession import CoopSession 37 38 return issubclass(sessiontype, CoopSession) 39 40 def __init__(self, settings: dict): 41 super().__init__(settings) 42 43 # Cache these for efficiency. 44 self._achievements_awarded: set[str] = set() 45 46 self._life_warning_beep: ba.Actor | None = None 47 self._life_warning_beep_timer: ba.Timer | None = None 48 self._warn_beeps_sound = _ba.getsound('warnBeeps') 49 50 def on_begin(self) -> None: 51 super().on_begin() 52 53 # Show achievements remaining. 54 if not (_ba.app.demo_mode or _ba.app.arcade_mode): 55 _ba.timer(3.8, WeakCall(self._show_remaining_achievements)) 56 57 # Preload achievement images in case we get some. 58 _ba.timer(2.0, WeakCall(self._preload_achievements)) 59 60 # FIXME: this is now redundant with activityutils.getscoreconfig(); 61 # need to kill this. 62 def get_score_type(self) -> str: 63 """ 64 Return the score unit this co-op game uses ('point', 'seconds', etc.) 65 """ 66 return 'points' 67 68 def _get_coop_level_name(self) -> str: 69 assert self.session.campaign is not None 70 return self.session.campaign.name + ':' + str(self.settings_raw['name']) 71 72 def celebrate(self, duration: float) -> None: 73 """Tells all existing player-controlled characters to celebrate. 74 75 Can be useful in co-op games when the good guys score or complete 76 a wave. 77 duration is given in seconds. 78 """ 79 from ba._messages import CelebrateMessage 80 81 for player in self.players: 82 if player.actor: 83 player.actor.handlemessage(CelebrateMessage(duration)) 84 85 def _preload_achievements(self) -> None: 86 achievements = _ba.app.ach.achievements_for_coop_level( 87 self._get_coop_level_name() 88 ) 89 for ach in achievements: 90 ach.get_icon_texture(True) 91 92 def _show_remaining_achievements(self) -> None: 93 # pylint: disable=cyclic-import 94 from ba._language import Lstr 95 from bastd.actor.text import Text 96 97 ts_h_offs = 30 98 v_offs = -200 99 achievements = [ 100 a 101 for a in _ba.app.ach.achievements_for_coop_level( 102 self._get_coop_level_name() 103 ) 104 if not a.complete 105 ] 106 vrmode = _ba.app.vr_mode 107 if achievements: 108 Text( 109 Lstr(resource='achievementsRemainingText'), 110 host_only=True, 111 position=(ts_h_offs - 10 + 40, v_offs - 10), 112 transition=Text.Transition.FADE_IN, 113 scale=1.1, 114 h_attach=Text.HAttach.LEFT, 115 v_attach=Text.VAttach.TOP, 116 color=(1, 1, 1.2, 1) if vrmode else (0.8, 0.8, 1.0, 1.0), 117 flatness=1.0 if vrmode else 0.6, 118 shadow=1.0 if vrmode else 0.5, 119 transition_delay=0.0, 120 transition_out_delay=1.3 if self.slow_motion else 4.0, 121 ).autoretain() 122 hval = 70 123 vval = -50 124 tdelay = 0.0 125 for ach in achievements: 126 tdelay += 0.05 127 ach.create_display( 128 hval + 40, 129 vval + v_offs, 130 0 + tdelay, 131 outdelay=1.3 if self.slow_motion else 4.0, 132 style='in_game', 133 ) 134 vval -= 55 135 136 def spawn_player_spaz( 137 self, 138 player: PlayerType, 139 position: Sequence[float] = (0.0, 0.0, 0.0), 140 angle: float | None = None, 141 ) -> PlayerSpaz: 142 """Spawn and wire up a standard player spaz.""" 143 spaz = super().spawn_player_spaz(player, position, angle) 144 145 # Deaths are noteworthy in co-op games. 146 spaz.play_big_death_sound = True 147 return spaz 148 149 def _award_achievement( 150 self, achievement_name: str, sound: bool = True 151 ) -> None: 152 """Award an achievement. 153 154 Returns True if a banner will be shown; 155 False otherwise 156 """ 157 158 if achievement_name in self._achievements_awarded: 159 return 160 161 ach = _ba.app.ach.get_achievement(achievement_name) 162 163 # If we're in the easy campaign and this achievement is hard-mode-only, 164 # ignore it. 165 try: 166 campaign = self.session.campaign 167 assert campaign is not None 168 if ach.hard_mode_only and campaign.name == 'Easy': 169 return 170 except Exception: 171 from ba._error import print_exception 172 173 print_exception() 174 175 # If we haven't awarded this one, check to see if we've got it. 176 # If not, set it through the game service *and* add a transaction 177 # for it. 178 if not ach.complete: 179 self._achievements_awarded.add(achievement_name) 180 181 # Report new achievements to the game-service. 182 _internal.report_achievement(achievement_name) 183 184 # ...and to our account. 185 _internal.add_transaction( 186 {'type': 'ACHIEVEMENT', 'name': achievement_name} 187 ) 188 189 # Now bring up a celebration banner. 190 ach.announce_completion(sound=sound) 191 192 def fade_to_red(self) -> None: 193 """Fade the screen to red; (such as when the good guys have lost).""" 194 from ba import _gameutils 195 196 c_existing = self.globalsnode.tint 197 cnode = _ba.newnode( 198 'combine', 199 attrs={ 200 'input0': c_existing[0], 201 'input1': c_existing[1], 202 'input2': c_existing[2], 203 'size': 3, 204 }, 205 ) 206 _gameutils.animate(cnode, 'input1', {0: c_existing[1], 2.0: 0}) 207 _gameutils.animate(cnode, 'input2', {0: c_existing[2], 2.0: 0}) 208 cnode.connectattr('output', self.globalsnode, 'tint') 209 210 def setup_low_life_warning_sound(self) -> None: 211 """Set up a beeping noise to play when any players are near death.""" 212 self._life_warning_beep = None 213 self._life_warning_beep_timer = _ba.Timer( 214 1.0, WeakCall(self._update_life_warning), repeat=True 215 ) 216 217 def _update_life_warning(self) -> None: 218 # Beep continuously if anyone is close to death. 219 should_beep = False 220 for player in self.players: 221 if player.is_alive(): 222 # FIXME: Should abstract this instead of 223 # reading hitpoints directly. 224 if getattr(player.actor, 'hitpoints', 999) < 200: 225 should_beep = True 226 break 227 if should_beep and self._life_warning_beep is None: 228 from ba._nodeactor import NodeActor 229 230 self._life_warning_beep = NodeActor( 231 _ba.newnode( 232 'sound', 233 attrs={ 234 'sound': self._warn_beeps_sound, 235 'positional': False, 236 'loop': True, 237 }, 238 ) 239 ) 240 if self._life_warning_beep is not None and not should_beep: 241 self._life_warning_beep = None
Base class for cooperative-mode games.
Category: Gameplay Classes
40 def __init__(self, settings: dict): 41 super().__init__(settings) 42 43 # Cache these for efficiency. 44 self._achievements_awarded: set[str] = set() 45 46 self._life_warning_beep: ba.Actor | None = None 47 self._life_warning_beep_timer: ba.Timer | None = None 48 self._warn_beeps_sound = _ba.getsound('warnBeeps')
Instantiate the Activity.
The ba.Session this ba.Activity belongs go.
Raises a ba.SessionNotFoundError if the Session no longer exists.
34 @classmethod 35 def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool: 36 from ba._coopsession import CoopSession 37 38 return issubclass(sessiontype, CoopSession)
Return whether this game supports the provided Session type.
50 def on_begin(self) -> None: 51 super().on_begin() 52 53 # Show achievements remaining. 54 if not (_ba.app.demo_mode or _ba.app.arcade_mode): 55 _ba.timer(3.8, WeakCall(self._show_remaining_achievements)) 56 57 # Preload achievement images in case we get some. 58 _ba.timer(2.0, WeakCall(self._preload_achievements))
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.
62 def get_score_type(self) -> str: 63 """ 64 Return the score unit this co-op game uses ('point', 'seconds', etc.) 65 """ 66 return 'points'
Return the score unit this co-op game uses ('point', 'seconds', etc.)
72 def celebrate(self, duration: float) -> None: 73 """Tells all existing player-controlled characters to celebrate. 74 75 Can be useful in co-op games when the good guys score or complete 76 a wave. 77 duration is given in seconds. 78 """ 79 from ba._messages import CelebrateMessage 80 81 for player in self.players: 82 if player.actor: 83 player.actor.handlemessage(CelebrateMessage(duration))
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.
136 def spawn_player_spaz( 137 self, 138 player: PlayerType, 139 position: Sequence[float] = (0.0, 0.0, 0.0), 140 angle: float | None = None, 141 ) -> PlayerSpaz: 142 """Spawn and wire up a standard player spaz.""" 143 spaz = super().spawn_player_spaz(player, position, angle) 144 145 # Deaths are noteworthy in co-op games. 146 spaz.play_big_death_sound = True 147 return spaz
Spawn and wire up a standard player spaz.
192 def fade_to_red(self) -> None: 193 """Fade the screen to red; (such as when the good guys have lost).""" 194 from ba import _gameutils 195 196 c_existing = self.globalsnode.tint 197 cnode = _ba.newnode( 198 'combine', 199 attrs={ 200 'input0': c_existing[0], 201 'input1': c_existing[1], 202 'input2': c_existing[2], 203 'size': 3, 204 }, 205 ) 206 _gameutils.animate(cnode, 'input1', {0: c_existing[1], 2.0: 0}) 207 _gameutils.animate(cnode, 'input2', {0: c_existing[2], 2.0: 0}) 208 cnode.connectattr('output', self.globalsnode, 'tint')
Fade the screen to red; (such as when the good guys have lost).
210 def setup_low_life_warning_sound(self) -> None: 211 """Set up a beeping noise to play when any players are near death.""" 212 self._life_warning_beep = None 213 self._life_warning_beep_timer = _ba.Timer( 214 1.0, WeakCall(self._update_life_warning), repeat=True 215 )
Set up a beeping noise to play when any players are near death.
Inherited Members
- GameActivity
- allow_pausing
- allow_kick_idle_players
- create_settings_ui
- getscoreconfig
- getname
- get_display_string
- get_team_display_string
- get_description
- get_description_display_string
- get_available_settings
- get_supported_maps
- get_settings_display_string
- map
- get_instance_display_string
- get_instance_scoreboard_display_string
- get_instance_description
- get_instance_description_short
- on_transition_in
- on_continue
- is_waiting_for_continue
- continue_or_end_game
- on_player_join
- handlemessage
- end
- end_game
- respawn_player
- spawn_player_if_exists
- spawn_player
- setup_standard_powerup_drops
- setup_standard_time_limit
- show_zoom_message
- Activity
- settings_raw
- teams
- players
- announce_player_deaths
- is_joining_activity
- use_fixed_vr_overlay
- slow_motion
- inherits_slow_motion
- inherits_music
- inherits_vr_camera_offset
- inherits_vr_overlay_center
- inherits_tint
- allow_mid_activity_joins
- transition_time
- can_show_ad_on_death
- globalsnode
- stats
- on_expire
- customdata
- expired
- playertype
- teamtype
- retain_actor
- add_actor_weak_ref
- on_player_leave
- on_team_join
- on_team_leave
- on_transition_out
- has_transitioned_in
- has_begun
- has_ended
- is_transitioning_out
- transition_out
- create_player
- create_team
20class CoopSession(Session): 21 """A ba.Session which runs cooperative-mode games. 22 23 Category: **Gameplay Classes** 24 25 These generally consist of 1-4 players against 26 the computer and include functionality such as 27 high score lists. 28 """ 29 30 use_teams = True 31 use_team_colors = False 32 allow_mid_activity_joins = False 33 34 # Note: even though these are instance vars, we annotate them at the 35 # class level so that docs generation can access their types. 36 37 campaign: ba.Campaign | None 38 """The ba.Campaign instance this Session represents, or None if 39 there is no associated Campaign.""" 40 41 def __init__(self) -> None: 42 """Instantiate a co-op mode session.""" 43 # pylint: disable=cyclic-import 44 from ba._campaign import getcampaign 45 from bastd.activity.coopjoin import CoopJoinActivity 46 47 _ba.increment_analytics_count('Co-op session start') 48 app = _ba.app 49 50 # If they passed in explicit min/max, honor that. 51 # Otherwise defer to user overrides or defaults. 52 if 'min_players' in app.coop_session_args: 53 min_players = app.coop_session_args['min_players'] 54 else: 55 min_players = 1 56 if 'max_players' in app.coop_session_args: 57 max_players = app.coop_session_args['max_players'] 58 else: 59 max_players = app.config.get('Coop Game Max Players', 4) 60 61 # print('FIXME: COOP SESSION WOULD CALC DEPS.') 62 depsets: Sequence[ba.DependencySet] = [] 63 64 super().__init__( 65 depsets, 66 team_names=TEAM_NAMES, 67 team_colors=TEAM_COLORS, 68 min_players=min_players, 69 max_players=max_players, 70 ) 71 72 # Tournament-ID if we correspond to a co-op tournament (otherwise None) 73 self.tournament_id: str | None = app.coop_session_args.get( 74 'tournament_id' 75 ) 76 77 self.campaign = getcampaign(app.coop_session_args['campaign']) 78 self.campaign_level_name: str = app.coop_session_args['level'] 79 80 self._ran_tutorial_activity = False 81 self._tutorial_activity: ba.Activity | None = None 82 self._custom_menu_ui: list[dict[str, Any]] = [] 83 84 # Start our joining screen. 85 self.setactivity(_ba.newactivity(CoopJoinActivity)) 86 87 self._next_game_instance: ba.GameActivity | None = None 88 self._next_game_level_name: str | None = None 89 self._update_on_deck_game_instances() 90 91 def get_current_game_instance(self) -> ba.GameActivity: 92 """Get the game instance currently being played.""" 93 return self._current_game_instance 94 95 def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool: 96 # pylint: disable=cyclic-import 97 from ba._gameactivity import GameActivity 98 99 # Disallow any joins in the middle of the game. 100 if isinstance(activity, GameActivity): 101 return False 102 103 return True 104 105 def _update_on_deck_game_instances(self) -> None: 106 # pylint: disable=cyclic-import 107 from ba._gameactivity import GameActivity 108 109 # Instantiate levels we may be running soon to let them load in the bg. 110 111 # Build an instance for the current level. 112 assert self.campaign is not None 113 level = self.campaign.getlevel(self.campaign_level_name) 114 gametype = level.gametype 115 settings = level.get_settings() 116 117 # Make sure all settings the game expects are present. 118 neededsettings = gametype.get_available_settings(type(self)) 119 for setting in neededsettings: 120 if setting.name not in settings: 121 settings[setting.name] = setting.default 122 123 newactivity = _ba.newactivity(gametype, settings) 124 assert isinstance(newactivity, GameActivity) 125 self._current_game_instance: GameActivity = newactivity 126 127 # Find the next level and build an instance for it too. 128 levels = self.campaign.levels 129 level = self.campaign.getlevel(self.campaign_level_name) 130 131 nextlevel: ba.Level | None 132 if level.index < len(levels) - 1: 133 nextlevel = levels[level.index + 1] 134 else: 135 nextlevel = None 136 if nextlevel: 137 gametype = nextlevel.gametype 138 settings = nextlevel.get_settings() 139 140 # Make sure all settings the game expects are present. 141 neededsettings = gametype.get_available_settings(type(self)) 142 for setting in neededsettings: 143 if setting.name not in settings: 144 settings[setting.name] = setting.default 145 146 # We wanna be in the activity's context while taking it down. 147 newactivity = _ba.newactivity(gametype, settings) 148 assert isinstance(newactivity, GameActivity) 149 self._next_game_instance = newactivity 150 self._next_game_level_name = nextlevel.name 151 else: 152 self._next_game_instance = None 153 self._next_game_level_name = None 154 155 # Special case: 156 # If our current level is 'onslaught training', instantiate 157 # our tutorial so its ready to go. (if we haven't run it yet). 158 if ( 159 self.campaign_level_name == 'Onslaught Training' 160 and self._tutorial_activity is None 161 and not self._ran_tutorial_activity 162 ): 163 from bastd.tutorial import TutorialActivity 164 165 self._tutorial_activity = _ba.newactivity(TutorialActivity) 166 167 def get_custom_menu_entries(self) -> list[dict[str, Any]]: 168 return self._custom_menu_ui 169 170 def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None: 171 from ba._general import WeakCall 172 173 super().on_player_leave(sessionplayer) 174 175 _ba.timer(2.0, WeakCall(self._handle_empty_activity)) 176 177 def _handle_empty_activity(self) -> None: 178 """Handle cases where all players have left the current activity.""" 179 180 from ba._gameactivity import GameActivity 181 182 activity = self.getactivity() 183 if activity is None: 184 return # Hmm what should we do in this case? 185 186 # If there are still players in the current activity, we're good. 187 if activity.players: 188 return 189 190 # If there are *not* players in the current activity but there 191 # *are* in the session: 192 if not activity.players and self.sessionplayers: 193 194 # If we're in a game, we should restart to pull in players 195 # currently waiting in the session. 196 if isinstance(activity, GameActivity): 197 198 # Never restart tourney games however; just end the session 199 # if all players are gone. 200 if self.tournament_id is not None: 201 self.end() 202 else: 203 self.restart() 204 205 # Hmm; no players anywhere. Let's end the entire session if we're 206 # running a GUI (or just the current game if we're running headless). 207 else: 208 if not _ba.app.headless_mode: 209 self.end() 210 else: 211 if isinstance(activity, GameActivity): 212 with _ba.Context(activity): 213 activity.end_game() 214 215 def _on_tournament_restart_menu_press( 216 self, resume_callback: Callable[[], Any] 217 ) -> None: 218 # pylint: disable=cyclic-import 219 from bastd.ui.tournamententry import TournamentEntryWindow 220 from ba._gameactivity import GameActivity 221 222 activity = self.getactivity() 223 if activity is not None and not activity.expired: 224 assert self.tournament_id is not None 225 assert isinstance(activity, GameActivity) 226 TournamentEntryWindow( 227 tournament_id=self.tournament_id, 228 tournament_activity=activity, 229 on_close_call=resume_callback, 230 ) 231 232 def restart(self) -> None: 233 """Restart the current game activity.""" 234 235 # Tell the current activity to end with a 'restart' outcome. 236 # We use 'force' so that we apply even if end has already been called 237 # (but is in its delay period). 238 239 # Make an exception if there's no players left. Otherwise this 240 # can override the default session end that occurs in that case. 241 if not self.sessionplayers: 242 return 243 244 # This method may get called from the UI context so make sure we 245 # explicitly run in the activity's context. 246 activity = self.getactivity() 247 if activity is not None and not activity.expired: 248 activity.can_show_ad_on_death = True 249 with _ba.Context(activity): 250 activity.end(results={'outcome': 'restart'}, force=True) 251 252 # noinspection PyUnresolvedReferences 253 def on_activity_end(self, activity: ba.Activity, results: Any) -> None: 254 """Method override for co-op sessions. 255 256 Jumps between co-op games and score screens. 257 """ 258 # pylint: disable=too-many-branches 259 # pylint: disable=too-many-locals 260 # pylint: disable=too-many-statements 261 # pylint: disable=cyclic-import 262 from ba._activitytypes import JoinActivity, TransitionActivity 263 from ba._language import Lstr 264 from ba._general import WeakCall 265 from ba._coopgame import CoopGameActivity 266 from ba._gameresults import GameResults 267 from ba._score import ScoreType 268 from ba._player import PlayerInfo 269 from bastd.tutorial import TutorialActivity 270 from bastd.activity.coopscore import CoopScoreScreen 271 272 app = _ba.app 273 274 # If we're running a TeamGameActivity we'll have a GameResults 275 # as results. Otherwise its an old CoopGameActivity so its giving 276 # us a dict of random stuff. 277 if isinstance(results, GameResults): 278 outcome = 'defeat' # This can't be 'beaten'. 279 else: 280 outcome = '' if results is None else results.get('outcome', '') 281 282 # If we're running with a gui and at any point we have no 283 # in-game players, quit out of the session (this can happen if 284 # someone leaves in the tutorial for instance). 285 if not _ba.app.headless_mode: 286 active_players = [p for p in self.sessionplayers if p.in_game] 287 if not active_players: 288 self.end() 289 return 290 291 # If we're in a between-round activity or a restart-activity, 292 # hop into a round. 293 if isinstance( 294 activity, (JoinActivity, CoopScoreScreen, TransitionActivity) 295 ): 296 297 if outcome == 'next_level': 298 if self._next_game_instance is None: 299 raise RuntimeError() 300 assert self._next_game_level_name is not None 301 self.campaign_level_name = self._next_game_level_name 302 next_game = self._next_game_instance 303 else: 304 next_game = self._current_game_instance 305 306 # Special case: if we're coming from a joining-activity 307 # and will be going into onslaught-training, show the 308 # tutorial first. 309 if ( 310 isinstance(activity, JoinActivity) 311 and self.campaign_level_name == 'Onslaught Training' 312 and not (app.demo_mode or app.arcade_mode) 313 ): 314 if self._tutorial_activity is None: 315 raise RuntimeError('Tutorial not preloaded properly.') 316 self.setactivity(self._tutorial_activity) 317 self._tutorial_activity = None 318 self._ran_tutorial_activity = True 319 self._custom_menu_ui = [] 320 321 # Normal case; launch the next round. 322 else: 323 324 # Reset stats for the new activity. 325 self.stats.reset() 326 for player in self.sessionplayers: 327 328 # Skip players that are still choosing a team. 329 if player.in_game: 330 self.stats.register_sessionplayer(player) 331 self.stats.setactivity(next_game) 332 333 # Now flip the current activity.. 334 self.setactivity(next_game) 335 336 if not (app.demo_mode or app.arcade_mode): 337 if self.tournament_id is not None: 338 self._custom_menu_ui = [ 339 { 340 'label': Lstr(resource='restartText'), 341 'resume_on_call': False, 342 'call': WeakCall( 343 self._on_tournament_restart_menu_press 344 ), 345 } 346 ] 347 else: 348 self._custom_menu_ui = [ 349 { 350 'label': Lstr(resource='restartText'), 351 'call': WeakCall(self.restart), 352 } 353 ] 354 355 # If we were in a tutorial, just pop a transition to get to the 356 # actual round. 357 elif isinstance(activity, TutorialActivity): 358 self.setactivity(_ba.newactivity(TransitionActivity)) 359 else: 360 361 playerinfos: list[ba.PlayerInfo] 362 363 # Generic team games. 364 if isinstance(results, GameResults): 365 playerinfos = results.playerinfos 366 score = results.get_sessionteam_score(results.sessionteams[0]) 367 fail_message = None 368 score_order = ( 369 'decreasing' if results.lower_is_better else 'increasing' 370 ) 371 if results.scoretype in ( 372 ScoreType.SECONDS, 373 ScoreType.MILLISECONDS, 374 ): 375 scoretype = 'time' 376 377 # ScoreScreen wants hundredths of a second. 378 if score is not None: 379 if results.scoretype is ScoreType.SECONDS: 380 score *= 100 381 elif results.scoretype is ScoreType.MILLISECONDS: 382 score //= 10 383 else: 384 raise RuntimeError('FIXME') 385 else: 386 if results.scoretype is not ScoreType.POINTS: 387 print(f'Unknown ScoreType:' f' "{results.scoretype}"') 388 scoretype = 'points' 389 390 # Old coop-game-specific results; should migrate away from these. 391 else: 392 playerinfos = results.get('playerinfos') 393 score = results['score'] if 'score' in results else None 394 fail_message = ( 395 results['fail_message'] 396 if 'fail_message' in results 397 else None 398 ) 399 score_order = ( 400 results['score_order'] 401 if 'score_order' in results 402 else 'increasing' 403 ) 404 activity_score_type = ( 405 activity.get_score_type() 406 if isinstance(activity, CoopGameActivity) 407 else None 408 ) 409 assert activity_score_type is not None 410 scoretype = activity_score_type 411 412 # Validate types. 413 if playerinfos is not None: 414 assert isinstance(playerinfos, list) 415 assert (isinstance(i, PlayerInfo) for i in playerinfos) 416 417 # Looks like we were in a round - check the outcome and 418 # go from there. 419 if outcome == 'restart': 420 421 # This will pop up back in the same round. 422 self.setactivity(_ba.newactivity(TransitionActivity)) 423 else: 424 self.setactivity( 425 _ba.newactivity( 426 CoopScoreScreen, 427 { 428 'playerinfos': playerinfos, 429 'score': score, 430 'fail_message': fail_message, 431 'score_order': score_order, 432 'score_type': scoretype, 433 'outcome': outcome, 434 'campaign': self.campaign, 435 'level': self.campaign_level_name, 436 }, 437 ) 438 ) 439 440 # No matter what, get the next 2 levels ready to go. 441 self._update_on_deck_game_instances()
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.
41 def __init__(self) -> None: 42 """Instantiate a co-op mode session.""" 43 # pylint: disable=cyclic-import 44 from ba._campaign import getcampaign 45 from bastd.activity.coopjoin import CoopJoinActivity 46 47 _ba.increment_analytics_count('Co-op session start') 48 app = _ba.app 49 50 # If they passed in explicit min/max, honor that. 51 # Otherwise defer to user overrides or defaults. 52 if 'min_players' in app.coop_session_args: 53 min_players = app.coop_session_args['min_players'] 54 else: 55 min_players = 1 56 if 'max_players' in app.coop_session_args: 57 max_players = app.coop_session_args['max_players'] 58 else: 59 max_players = app.config.get('Coop Game Max Players', 4) 60 61 # print('FIXME: COOP SESSION WOULD CALC DEPS.') 62 depsets: Sequence[ba.DependencySet] = [] 63 64 super().__init__( 65 depsets, 66 team_names=TEAM_NAMES, 67 team_colors=TEAM_COLORS, 68 min_players=min_players, 69 max_players=max_players, 70 ) 71 72 # Tournament-ID if we correspond to a co-op tournament (otherwise None) 73 self.tournament_id: str | None = app.coop_session_args.get( 74 'tournament_id' 75 ) 76 77 self.campaign = getcampaign(app.coop_session_args['campaign']) 78 self.campaign_level_name: str = app.coop_session_args['level'] 79 80 self._ran_tutorial_activity = False 81 self._tutorial_activity: ba.Activity | None = None 82 self._custom_menu_ui: list[dict[str, Any]] = [] 83 84 # Start our joining screen. 85 self.setactivity(_ba.newactivity(CoopJoinActivity)) 86 87 self._next_game_instance: ba.GameActivity | None = None 88 self._next_game_level_name: str | None = None 89 self._update_on_deck_game_instances()
Instantiate a co-op mode session.
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.
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.
The ba.Campaign instance this Session represents, or None if there is no associated Campaign.
91 def get_current_game_instance(self) -> ba.GameActivity: 92 """Get the game instance currently being played.""" 93 return self._current_game_instance
Get the game instance currently being played.
95 def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool: 96 # pylint: disable=cyclic-import 97 from ba._gameactivity import GameActivity 98 99 # Disallow any joins in the middle of the game. 100 if isinstance(activity, GameActivity): 101 return False 102 103 return True
Ask ourself if we should allow joins during an Activity.
Note that for a join to be allowed, both the Session and Activity have to be ok with it (via this function and the Activity.allow_mid_activity_joins property.
170 def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None: 171 from ba._general import WeakCall 172 173 super().on_player_leave(sessionplayer) 174 175 _ba.timer(2.0, WeakCall(self._handle_empty_activity))
Called when a previously-accepted ba.SessionPlayer leaves.
232 def restart(self) -> None: 233 """Restart the current game activity.""" 234 235 # Tell the current activity to end with a 'restart' outcome. 236 # We use 'force' so that we apply even if end has already been called 237 # (but is in its delay period). 238 239 # Make an exception if there's no players left. Otherwise this 240 # can override the default session end that occurs in that case. 241 if not self.sessionplayers: 242 return 243 244 # This method may get called from the UI context so make sure we 245 # explicitly run in the activity's context. 246 activity = self.getactivity() 247 if activity is not None and not activity.expired: 248 activity.can_show_ad_on_death = True 249 with _ba.Context(activity): 250 activity.end(results={'outcome': 'restart'}, force=True)
Restart the current game activity.
253 def on_activity_end(self, activity: ba.Activity, results: Any) -> None: 254 """Method override for co-op sessions. 255 256 Jumps between co-op games and score screens. 257 """ 258 # pylint: disable=too-many-branches 259 # pylint: disable=too-many-locals 260 # pylint: disable=too-many-statements 261 # pylint: disable=cyclic-import 262 from ba._activitytypes import JoinActivity, TransitionActivity 263 from ba._language import Lstr 264 from ba._general import WeakCall 265 from ba._coopgame import CoopGameActivity 266 from ba._gameresults import GameResults 267 from ba._score import ScoreType 268 from ba._player import PlayerInfo 269 from bastd.tutorial import TutorialActivity 270 from bastd.activity.coopscore import CoopScoreScreen 271 272 app = _ba.app 273 274 # If we're running a TeamGameActivity we'll have a GameResults 275 # as results. Otherwise its an old CoopGameActivity so its giving 276 # us a dict of random stuff. 277 if isinstance(results, GameResults): 278 outcome = 'defeat' # This can't be 'beaten'. 279 else: 280 outcome = '' if results is None else results.get('outcome', '') 281 282 # If we're running with a gui and at any point we have no 283 # in-game players, quit out of the session (this can happen if 284 # someone leaves in the tutorial for instance). 285 if not _ba.app.headless_mode: 286 active_players = [p for p in self.sessionplayers if p.in_game] 287 if not active_players: 288 self.end() 289 return 290 291 # If we're in a between-round activity or a restart-activity, 292 # hop into a round. 293 if isinstance( 294 activity, (JoinActivity, CoopScoreScreen, TransitionActivity) 295 ): 296 297 if outcome == 'next_level': 298 if self._next_game_instance is None: 299 raise RuntimeError() 300 assert self._next_game_level_name is not None 301 self.campaign_level_name = self._next_game_level_name 302 next_game = self._next_game_instance 303 else: 304 next_game = self._current_game_instance 305 306 # Special case: if we're coming from a joining-activity 307 # and will be going into onslaught-training, show the 308 # tutorial first. 309 if ( 310 isinstance(activity, JoinActivity) 311 and self.campaign_level_name == 'Onslaught Training' 312 and not (app.demo_mode or app.arcade_mode) 313 ): 314 if self._tutorial_activity is None: 315 raise RuntimeError('Tutorial not preloaded properly.') 316 self.setactivity(self._tutorial_activity) 317 self._tutorial_activity = None 318 self._ran_tutorial_activity = True 319 self._custom_menu_ui = [] 320 321 # Normal case; launch the next round. 322 else: 323 324 # Reset stats for the new activity. 325 self.stats.reset() 326 for player in self.sessionplayers: 327 328 # Skip players that are still choosing a team. 329 if player.in_game: 330 self.stats.register_sessionplayer(player) 331 self.stats.setactivity(next_game) 332 333 # Now flip the current activity.. 334 self.setactivity(next_game) 335 336 if not (app.demo_mode or app.arcade_mode): 337 if self.tournament_id is not None: 338 self._custom_menu_ui = [ 339 { 340 'label': Lstr(resource='restartText'), 341 'resume_on_call': False, 342 'call': WeakCall( 343 self._on_tournament_restart_menu_press 344 ), 345 } 346 ] 347 else: 348 self._custom_menu_ui = [ 349 { 350 'label': Lstr(resource='restartText'), 351 'call': WeakCall(self.restart), 352 } 353 ] 354 355 # If we were in a tutorial, just pop a transition to get to the 356 # actual round. 357 elif isinstance(activity, TutorialActivity): 358 self.setactivity(_ba.newactivity(TransitionActivity)) 359 else: 360 361 playerinfos: list[ba.PlayerInfo] 362 363 # Generic team games. 364 if isinstance(results, GameResults): 365 playerinfos = results.playerinfos 366 score = results.get_sessionteam_score(results.sessionteams[0]) 367 fail_message = None 368 score_order = ( 369 'decreasing' if results.lower_is_better else 'increasing' 370 ) 371 if results.scoretype in ( 372 ScoreType.SECONDS, 373 ScoreType.MILLISECONDS, 374 ): 375 scoretype = 'time' 376 377 # ScoreScreen wants hundredths of a second. 378 if score is not None: 379 if results.scoretype is ScoreType.SECONDS: 380 score *= 100 381 elif results.scoretype is ScoreType.MILLISECONDS: 382 score //= 10 383 else: 384 raise RuntimeError('FIXME') 385 else: 386 if results.scoretype is not ScoreType.POINTS: 387 print(f'Unknown ScoreType:' f' "{results.scoretype}"') 388 scoretype = 'points' 389 390 # Old coop-game-specific results; should migrate away from these. 391 else: 392 playerinfos = results.get('playerinfos') 393 score = results['score'] if 'score' in results else None 394 fail_message = ( 395 results['fail_message'] 396 if 'fail_message' in results 397 else None 398 ) 399 score_order = ( 400 results['score_order'] 401 if 'score_order' in results 402 else 'increasing' 403 ) 404 activity_score_type = ( 405 activity.get_score_type() 406 if isinstance(activity, CoopGameActivity) 407 else None 408 ) 409 assert activity_score_type is not None 410 scoretype = activity_score_type 411 412 # Validate types. 413 if playerinfos is not None: 414 assert isinstance(playerinfos, list) 415 assert (isinstance(i, PlayerInfo) for i in playerinfos) 416 417 # Looks like we were in a round - check the outcome and 418 # go from there. 419 if outcome == 'restart': 420 421 # This will pop up back in the same round. 422 self.setactivity(_ba.newactivity(TransitionActivity)) 423 else: 424 self.setactivity( 425 _ba.newactivity( 426 CoopScoreScreen, 427 { 428 'playerinfos': playerinfos, 429 'score': score, 430 'fail_message': fail_message, 431 'score_order': score_order, 432 'score_type': scoretype, 433 'outcome': outcome, 434 'campaign': self.campaign, 435 'level': self.campaign_level_name, 436 }, 437 ) 438 ) 439 440 # No matter what, get the next 2 levels ready to go. 441 self._update_on_deck_game_instances()
Method override for co-op sessions.
Jumps between co-op games and score screens.
206class Data: 207 208 """A reference to a data object. 209 210 Category: **Asset Classes** 211 212 Use ba.getdata() to instantiate one. 213 """ 214 215 def getvalue(self) -> Any: 216 217 """Return the data object's value. 218 219 This can consist of anything representable by json (dicts, lists, 220 numbers, bools, None, etc). 221 Note that this call will block if the data has not yet been loaded, 222 so it can be beneficial to plan a short bit of time between when 223 the data object is requested and when it's value is accessed. 224 """ 225 return _uninferrable()
215 def getvalue(self) -> Any: 216 217 """Return the data object's value. 218 219 This can consist of anything representable by json (dicts, lists, 220 numbers, bools, None, etc). 221 Note that this call will block if the data has not yet been loaded, 222 so it can be beneficial to plan a short bit of time between when 223 the data object is requested and when it's value is accessed. 224 """ 225 return _uninferrable()
Return the data object's value.
This can consist of anything representable by json (dicts, lists, numbers, bools, None, etc). Note that this call will block if the data has not yet been loaded, so it can be beneficial to plan a short bit of time between when the data object is requested and when it's value is accessed.
37class DeathType(Enum): 38 """A reason for a death. 39 40 Category: Enums 41 """ 42 43 GENERIC = 'generic' 44 OUT_OF_BOUNDS = 'out_of_bounds' 45 IMPACT = 'impact' 46 FALL = 'fall' 47 REACHED_GOAL = 'reached_goal' 48 LEFT_GAME = 'left_game'
A reason for a death.
Category: Enums
Inherited Members
- enum.Enum
- name
- value
80class DelegateNotFoundError(NotFoundError): 81 """Exception raised when an expected delegate object does not exist. 82 83 Category: **Exception Classes** 84 """
Exception raised when an expected delegate object does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
20class Dependency(Generic[T]): 21 """A dependency on a DependencyComponent (with an optional config). 22 23 Category: **Dependency Classes** 24 25 This class is used to request and access functionality provided 26 by other DependencyComponent classes from a DependencyComponent class. 27 The class functions as a descriptor, allowing dependencies to 28 be added at a class level much the same as properties or methods 29 and then used with class instances to access those dependencies. 30 For instance, if you do 'floofcls = ba.Dependency(FloofClass)' you 31 would then be able to instantiate a FloofClass in your class's 32 methods via self.floofcls(). 33 """ 34 35 def __init__(self, cls: type[T], config: Any = None): 36 """Instantiate a Dependency given a ba.DependencyComponent type. 37 38 Optionally, an arbitrary object can be passed as 'config' to 39 influence dependency calculation for the target class. 40 """ 41 self.cls: type[T] = cls 42 self.config = config 43 self._hash: int | None = None 44 45 def get_hash(self) -> int: 46 """Return the dependency's hash, calculating it if necessary.""" 47 from efro.util import make_hash 48 49 if self._hash is None: 50 self._hash = make_hash((self.cls, self.config)) 51 return self._hash 52 53 def __get__(self, obj: Any, cls: Any = None) -> T: 54 if not isinstance(obj, DependencyComponent): 55 if obj is None: 56 raise TypeError( 57 'Dependency must be accessed through an instance.' 58 ) 59 raise TypeError( 60 f'Dependency cannot be added to class of type {type(obj)}' 61 ' (class must inherit from ba.DependencyComponent).' 62 ) 63 64 # We expect to be instantiated from an already living 65 # DependencyComponent with valid dep-data in place.. 66 assert cls is not None 67 68 # Get the DependencyEntry this instance is associated with and from 69 # there get back to the DependencySet 70 entry = getattr(obj, '_dep_entry') 71 if entry is None: 72 raise RuntimeError('Invalid dependency access.') 73 entry = entry() 74 assert isinstance(entry, DependencyEntry) 75 depset = entry.depset() 76 assert isinstance(depset, DependencySet) 77 78 if not depset.resolved: 79 raise RuntimeError( 80 "Can't access data on an unresolved DependencySet." 81 ) 82 83 # Look up the data in the set based on the hash for this Dependency. 84 assert self._hash in depset.entries 85 entry = depset.entries[self._hash] 86 assert isinstance(entry, DependencyEntry) 87 retval = entry.get_component() 88 assert isinstance(retval, self.cls) 89 return retval
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().
35 def __init__(self, cls: type[T], config: Any = None): 36 """Instantiate a Dependency given a ba.DependencyComponent type. 37 38 Optionally, an arbitrary object can be passed as 'config' to 39 influence dependency calculation for the target class. 40 """ 41 self.cls: type[T] = cls 42 self.config = config 43 self._hash: int | None = 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.
45 def get_hash(self) -> int: 46 """Return the dependency's hash, calculating it if necessary.""" 47 from efro.util import make_hash 48 49 if self._hash is None: 50 self._hash = make_hash((self.cls, self.config)) 51 return self._hash
Return the dependency's hash, calculating it if necessary.
92class DependencyComponent: 93 """Base class for all classes that can act as or use dependencies. 94 95 Category: **Dependency Classes** 96 """ 97 98 _dep_entry: weakref.ref[DependencyEntry] 99 100 def __init__(self) -> None: 101 """Instantiate a DependencyComponent.""" 102 103 # For now lets issue a warning if these are instantiated without 104 # a dep-entry; we'll make this an error once we're no longer 105 # seeing warnings. 106 # entry = getattr(self, '_dep_entry', None) 107 # if entry is None: 108 # print(f'FIXME: INSTANTIATING DEP CLASS {type(self)} DIRECTLY.') 109 110 @classmethod 111 def dep_is_present(cls, config: Any = None) -> bool: 112 """Return whether this component/config is present on this device.""" 113 del config # Unused here. 114 return True 115 116 @classmethod 117 def get_dynamic_deps(cls, config: Any = None) -> list[Dependency]: 118 """Return any dynamically-calculated deps for this component/config. 119 120 Deps declared statically as part of the class do not need to be 121 included here; this is only for additional deps that may vary based 122 on the dep config value. (for instance a map required by a game type) 123 """ 124 del config # Unused here. 125 return []
Base class for all classes that can act as or use dependencies.
Category: Dependency Classes
100 def __init__(self) -> None: 101 """Instantiate a DependencyComponent.""" 102 103 # For now lets issue a warning if these are instantiated without 104 # a dep-entry; we'll make this an error once we're no longer 105 # seeing warnings. 106 # entry = getattr(self, '_dep_entry', None) 107 # if entry is None: 108 # print(f'FIXME: INSTANTIATING DEP CLASS {type(self)} DIRECTLY.')
Instantiate a DependencyComponent.
110 @classmethod 111 def dep_is_present(cls, config: Any = None) -> bool: 112 """Return whether this component/config is present on this device.""" 113 del config # Unused here. 114 return True
Return whether this component/config is present on this device.
116 @classmethod 117 def get_dynamic_deps(cls, config: Any = None) -> list[Dependency]: 118 """Return any dynamically-calculated deps for this component/config. 119 120 Deps declared statically as part of the class do not need to be 121 included here; this is only for additional deps that may vary based 122 on the dep config value. (for instance a map required by a game type) 123 """ 124 del config # Unused here. 125 return []
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)
17class DependencyError(Exception): 18 """Exception raised when one or more ba.Dependency items are missing. 19 20 Category: **Exception Classes** 21 22 (this will generally be missing assets). 23 """ 24 25 def __init__(self, deps: list[ba.Dependency]): 26 super().__init__() 27 self._deps = deps 28 29 @property 30 def deps(self) -> list[ba.Dependency]: 31 """The list of missing dependencies causing this error.""" 32 return self._deps
Exception raised when one or more ba.Dependency items are missing.
Category: Exception Classes
(this will generally be missing assets).
Inherited Members
- builtins.BaseException
- with_traceback
172class DependencySet(Generic[T]): 173 """Set of resolved dependencies and their associated data. 174 175 Category: **Dependency Classes** 176 177 To use DependencyComponents, a set must be created, resolved, and then 178 loaded. The DependencyComponents are only valid while the set remains 179 in existence. 180 """ 181 182 def __init__(self, root_dependency: Dependency[T]): 183 # print('DepSet()') 184 self._root_dependency = root_dependency 185 self._resolved = False 186 self._loaded = False 187 188 # Dependency data indexed by hash. 189 self.entries: dict[int, DependencyEntry] = {} 190 191 # def __del__(self) -> None: 192 # print("~DepSet()") 193 194 def resolve(self) -> None: 195 """Resolve the complete set of required dependencies for this set. 196 197 Raises a ba.DependencyError if dependencies are missing (or other 198 Exception types on other errors). 199 """ 200 201 if self._resolved: 202 raise Exception('DependencySet has already been resolved.') 203 204 # print('RESOLVING DEP SET') 205 206 # First, recursively expand out all dependencies. 207 self._resolve(self._root_dependency, 0) 208 209 # Now, if any dependencies are not present, raise an Exception 210 # telling exactly which ones (so hopefully they'll be able to be 211 # downloaded/etc. 212 missing = [ 213 Dependency(entry.cls, entry.config) 214 for entry in self.entries.values() 215 if not entry.cls.dep_is_present(entry.config) 216 ] 217 if missing: 218 from ba._error import DependencyError 219 220 raise DependencyError(missing) 221 222 self._resolved = True 223 # print('RESOLVE SUCCESS!') 224 225 @property 226 def resolved(self) -> bool: 227 """Whether this set has been successfully resolved.""" 228 return self._resolved 229 230 def get_asset_package_ids(self) -> set[str]: 231 """Return the set of asset-package-ids required by this dep-set. 232 233 Must be called on a resolved dep-set. 234 """ 235 ids: set[str] = set() 236 if not self._resolved: 237 raise Exception('Must be called on a resolved dep-set.') 238 for entry in self.entries.values(): 239 if issubclass(entry.cls, AssetPackage): 240 assert isinstance(entry.config, str) 241 ids.add(entry.config) 242 return ids 243 244 def load(self) -> None: 245 """Instantiate all DependencyComponents in the set. 246 247 Returns a wrapper which can be used to instantiate the root dep. 248 """ 249 # NOTE: stuff below here should probably go in a separate 'instantiate' 250 # method or something. 251 if not self._resolved: 252 raise RuntimeError("Can't load an unresolved DependencySet") 253 254 for entry in self.entries.values(): 255 # Do a get on everything which will init all payloads 256 # in the proper order recursively. 257 entry.get_component() 258 259 self._loaded = True 260 261 @property 262 def root(self) -> T: 263 """The instantiated root DependencyComponent instance for the set.""" 264 if not self._loaded: 265 raise RuntimeError('DependencySet is not loaded.') 266 267 rootdata = self.entries[self._root_dependency.get_hash()].component 268 assert isinstance(rootdata, self._root_dependency.cls) 269 return rootdata 270 271 def _resolve(self, dep: Dependency[T], recursion: int) -> None: 272 273 # Watch for wacky infinite dep loops. 274 if recursion > 10: 275 raise RecursionError('Max recursion reached') 276 277 hashval = dep.get_hash() 278 279 if hashval in self.entries: 280 # Found an already resolved one; we're done here. 281 return 282 283 # Add our entry before we recurse so we don't repeat add it if 284 # there's a dependency loop. 285 self.entries[hashval] = DependencyEntry(self, dep) 286 287 # Grab all Dependency instances we find in the class. 288 subdeps = [ 289 cls 290 for cls in dep.cls.__dict__.values() 291 if isinstance(cls, Dependency) 292 ] 293 294 # ..and add in any dynamic ones it provides. 295 subdeps += dep.cls.get_dynamic_deps(dep.config) 296 for subdep in subdeps: 297 self._resolve(subdep, recursion + 1)
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.
194 def resolve(self) -> None: 195 """Resolve the complete set of required dependencies for this set. 196 197 Raises a ba.DependencyError if dependencies are missing (or other 198 Exception types on other errors). 199 """ 200 201 if self._resolved: 202 raise Exception('DependencySet has already been resolved.') 203 204 # print('RESOLVING DEP SET') 205 206 # First, recursively expand out all dependencies. 207 self._resolve(self._root_dependency, 0) 208 209 # Now, if any dependencies are not present, raise an Exception 210 # telling exactly which ones (so hopefully they'll be able to be 211 # downloaded/etc. 212 missing = [ 213 Dependency(entry.cls, entry.config) 214 for entry in self.entries.values() 215 if not entry.cls.dep_is_present(entry.config) 216 ] 217 if missing: 218 from ba._error import DependencyError 219 220 raise DependencyError(missing) 221 222 self._resolved = True 223 # print('RESOLVE SUCCESS!')
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).
230 def get_asset_package_ids(self) -> set[str]: 231 """Return the set of asset-package-ids required by this dep-set. 232 233 Must be called on a resolved dep-set. 234 """ 235 ids: set[str] = set() 236 if not self._resolved: 237 raise Exception('Must be called on a resolved dep-set.') 238 for entry in self.entries.values(): 239 if issubclass(entry.cls, AssetPackage): 240 assert isinstance(entry.config, str) 241 ids.add(entry.config) 242 return ids
Return the set of asset-package-ids required by this dep-set.
Must be called on a resolved dep-set.
244 def load(self) -> None: 245 """Instantiate all DependencyComponents in the set. 246 247 Returns a wrapper which can be used to instantiate the root dep. 248 """ 249 # NOTE: stuff below here should probably go in a separate 'instantiate' 250 # method or something. 251 if not self._resolved: 252 raise RuntimeError("Can't load an unresolved DependencySet") 253 254 for entry in self.entries.values(): 255 # Do a get on everything which will init all payloads 256 # in the proper order recursively. 257 entry.get_component() 258 259 self._loaded = True
Instantiate all DependencyComponents in the set.
Returns a wrapper which can be used to instantiate the root dep.
51@dataclass 52class DieMessage: 53 """A message telling an object to die. 54 55 Category: **Message Classes** 56 57 Most ba.Actor-s respond to this. 58 """ 59 60 immediate: bool = False 61 """If this is set to True, the actor should disappear immediately. 62 This is for 'removing' stuff from the game more so than 'killing' 63 it. If False, the actor should die a 'normal' death and can take 64 its time with lingering corpses, sound effects, etc.""" 65 66 how: DeathType = DeathType.GENERIC 67 """The particular reason for death."""
1631def do_once() -> bool: 1632 1633 """Return whether this is the first time running a line of code. 1634 1635 Category: **General Utility Functions** 1636 1637 This is used by 'print_once()' type calls to keep from overflowing 1638 logs. The call functions by registering the filename and line where 1639 The call is made from. Returns True if this location has not been 1640 registered already, and False if it has. 1641 1642 ##### Example 1643 This print will only fire for the first loop iteration: 1644 >>> for i in range(10): 1645 ... if ba.do_once(): 1646 ... print('Hello once from loop!') 1647 """ 1648 return bool()
Return whether this is the first time running a line of code.
Category: General Utility Functions
This is used by 'print_once()' type calls to keep from overflowing logs. The call functions by registering the filename and line where The call is made from. Returns True if this location has not been registered already, and False if it has.
Example
This print will only fire for the first loop iteration:
>>> for i in range(10):
... if ba.do_once():
... print('Hello once from loop!')
159@dataclass 160class DropMessage: 161 """Tells an object that it has dropped what it was holding. 162 163 Category: **Message Classes** 164 """
Tells an object that it has dropped what it was holding.
Category: Message Classes
178@dataclass 179class DroppedMessage: 180 """Tells an object that it has been dropped. 181 182 Category: **Message Classes** 183 """ 184 185 node: ba.Node 186 """The ba.Node doing the dropping."""
Tells an object that it has been dropped.
Category: Message Classes
16class DualTeamSession(MultiTeamSession): 17 """ba.Session type for teams mode games. 18 19 Category: **Gameplay Classes** 20 """ 21 22 # Base class overrides: 23 use_teams = True 24 use_team_colors = True 25 26 _playlist_selection_var = 'Team Tournament Playlist Selection' 27 _playlist_randomize_var = 'Team Tournament Playlist Randomize' 28 _playlists_var = 'Team Tournament Playlists' 29 30 def __init__(self) -> None: 31 _ba.increment_analytics_count('Teams session start') 32 super().__init__() 33 34 def _switch_to_score_screen(self, results: ba.GameResults) -> None: 35 # pylint: disable=cyclic-import 36 from bastd.activity.drawscore import DrawScoreScreenActivity 37 from bastd.activity.dualteamscore import TeamVictoryScoreScreenActivity 38 from bastd.activity.multiteamvictory import ( 39 TeamSeriesVictoryScoreScreenActivity, 40 ) 41 42 winnergroups = results.winnergroups 43 44 # If everyone has the same score, call it a draw. 45 if len(winnergroups) < 2: 46 self.setactivity(_ba.newactivity(DrawScoreScreenActivity)) 47 else: 48 winner = winnergroups[0].teams[0] 49 winner.customdata['score'] += 1 50 51 # If a team has won, show final victory screen. 52 if winner.customdata['score'] >= (self._series_length - 1) / 2 + 1: 53 self.setactivity( 54 _ba.newactivity( 55 TeamSeriesVictoryScoreScreenActivity, {'winner': winner} 56 ) 57 ) 58 else: 59 self.setactivity( 60 _ba.newactivity( 61 TeamVictoryScoreScreenActivity, {'winner': winner} 62 ) 63 )
ba.Session type for teams mode games.
Category: Gameplay Classes
30 def __init__(self) -> None: 31 _ba.increment_analytics_count('Teams session start') 32 super().__init__()
Set up playlists and launches a ba.Activity to accept joiners.
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.
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.
Inherited Members
1657def emitfx( 1658 position: Sequence[float], 1659 velocity: Sequence[float] | None = None, 1660 count: int = 10, 1661 scale: float = 1.0, 1662 spread: float = 1.0, 1663 chunk_type: str = 'rock', 1664 emit_type: str = 'chunks', 1665 tendril_type: str = 'smoke', 1666) -> None: 1667 1668 """Emit particles, smoke, etc. into the fx sim layer. 1669 1670 Category: **Gameplay Functions** 1671 1672 The fx sim layer is a secondary dynamics simulation that runs in 1673 the background and just looks pretty; it does not affect gameplay. 1674 Note that the actual amount emitted may vary depending on graphics 1675 settings, exiting element counts, or other factors. 1676 """ 1677 return None
Emit particles, smoke, etc. into the fx sim layer.
Category: Gameplay Functions
The fx sim layer is a secondary dynamics simulation that runs in the background and just looks pretty; it does not affect gameplay. Note that the actual amount emitted may vary depending on graphics settings, exiting element counts, or other factors.
281class EmptyPlayer(Player['ba.EmptyTeam']): 282 """An empty player for use by Activities that don't need to define one. 283 284 Category: Gameplay Classes 285 286 ba.Player and ba.Team are 'Generic' types, and so passing those top level 287 classes as type arguments when defining a ba.Activity reduces type safety. 288 For example, activity.teams[0].player will have type 'Any' in that case. 289 For that reason, it is better to pass EmptyPlayer and EmptyTeam when 290 defining a ba.Activity that does not need custom types of its own. 291 292 Note that EmptyPlayer defines its team type as EmptyTeam and vice versa, 293 so if you want to define your own class for one of them you should do so 294 for both. 295 """
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.
Inherited Members
198class EmptyTeam(Team['ba.EmptyPlayer']): 199 """An empty player for use by Activities that don't need to define one. 200 201 Category: **Gameplay Classes** 202 203 ba.Player and ba.Team are 'Generic' types, and so passing those top level 204 classes as type arguments when defining a ba.Activity reduces type safety. 205 For example, activity.teams[0].player will have type 'Any' in that case. 206 For that reason, it is better to pass EmptyPlayer and EmptyTeam when 207 defining a ba.Activity that does not need custom types of its own. 208 209 Note that EmptyPlayer defines its team type as EmptyTeam and vice versa, 210 so if you want to define your own class for one of them you should do so 211 for both. 212 """
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.
Inherited Members
23class Existable(Protocol): 24 """A Protocol for objects supporting an exists() method. 25 26 Category: **Protocols** 27 """ 28 29 def exists(self) -> bool: 30 """Whether this object exists."""
A Protocol for objects supporting an exists() method.
Category: Protocols
1431def _no_init_or_replace_init(self, *args, **kwargs): 1432 cls = type(self) 1433 1434 if cls._is_protocol: 1435 raise TypeError('Protocols cannot be instantiated') 1436 1437 # Already using a custom `__init__`. No need to calculate correct 1438 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1439 if cls.__init__ is not _no_init_or_replace_init: 1440 return 1441 1442 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1443 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1444 # searches for a proper new `__init__` in the MRO. The new `__init__` 1445 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1446 # instantiation of the protocol subclass will thus use the new 1447 # `__init__` and no longer call `_no_init_or_replace_init`. 1448 for base in cls.__mro__: 1449 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1450 if init is not _no_init_or_replace_init: 1451 cls.__init__ = init 1452 break 1453 else: 1454 # should not happen 1455 cls.__init__ = object.__init__ 1456 1457 cls.__init__(self, *args, **kwargs)
37def existing(obj: ExistableT | None) -> ExistableT | None: 38 """Convert invalid references to None for any ba.Existable object. 39 40 Category: **Gameplay Functions** 41 42 To best support type checking, it is important that invalid references 43 not be passed around and instead get converted to values of None. 44 That way the type checker can properly flag attempts to pass possibly-dead 45 objects (FooType | None) into functions expecting only live ones 46 (FooType), etc. This call can be used on any 'existable' object 47 (one with an exists() method) and will convert it to a None value 48 if it does not exist. 49 50 For more info, see notes on 'existables' here: 51 https://ballistica.net/wiki/Coding-Style-Guide 52 """ 53 assert obj is None or hasattr(obj, 'exists'), f'No "exists" on {obj}' 54 return obj if obj is not None and obj.exists() else None
Convert invalid references to None for any ba.Existable object.
Category: Gameplay Functions
To best support type checking, it is important that invalid references not be passed around and instead get converted to values of None. That way the type checker can properly flag attempts to pass possibly-dead objects (FooType | None) into functions expecting only live ones (FooType), etc. This call can be used on any 'existable' object (one with an exists() method) and will convert it to a None value if it does not exist.
For more info, see notes on 'existables' here: https://ballistica.net/wiki/Coding-Style-Guide
83@dataclass 84class FloatChoiceSetting(ChoiceSetting): 85 """A float setting with multiple choices. 86 87 Category: Settings Classes 88 """ 89 90 default: float 91 choices: list[tuple[str, float]]
A float setting with multiple choices.
Category: Settings Classes
49@dataclass 50class FloatSetting(Setting): 51 """A floating point game setting. 52 53 Category: Settings Classes 54 """ 55 56 default: float 57 min_value: float = 0.0 58 max_value: float = 9999.0 59 increment: float = 1.0
A floating point game setting.
Category: Settings Classes
17class FreeForAllSession(MultiTeamSession): 18 """ba.Session type for free-for-all mode games. 19 20 Category: **Gameplay Classes** 21 """ 22 23 use_teams = False 24 use_team_colors = False 25 _playlist_selection_var = 'Free-for-All Playlist Selection' 26 _playlist_randomize_var = 'Free-for-All Playlist Randomize' 27 _playlists_var = 'Free-for-All Playlists' 28 29 def get_ffa_point_awards(self) -> dict[int, int]: 30 """Return the number of points awarded for different rankings. 31 32 This is based on the current number of players. 33 """ 34 point_awards: dict[int, int] 35 if len(self.sessionplayers) == 1: 36 point_awards = {} 37 elif len(self.sessionplayers) == 2: 38 point_awards = {0: 6} 39 elif len(self.sessionplayers) == 3: 40 point_awards = {0: 6, 1: 3} 41 elif len(self.sessionplayers) == 4: 42 point_awards = {0: 8, 1: 4, 2: 2} 43 elif len(self.sessionplayers) == 5: 44 point_awards = {0: 8, 1: 4, 2: 2} 45 elif len(self.sessionplayers) == 6: 46 point_awards = {0: 8, 1: 4, 2: 2} 47 else: 48 point_awards = {0: 8, 1: 4, 2: 2, 3: 1} 49 return point_awards 50 51 def __init__(self) -> None: 52 _ba.increment_analytics_count('Free-for-all session start') 53 super().__init__() 54 55 def _switch_to_score_screen(self, results: ba.GameResults) -> None: 56 # pylint: disable=cyclic-import 57 from efro.util import asserttype 58 from bastd.activity.drawscore import DrawScoreScreenActivity 59 from bastd.activity.multiteamvictory import ( 60 TeamSeriesVictoryScoreScreenActivity, 61 ) 62 from bastd.activity.freeforallvictory import ( 63 FreeForAllVictoryScoreScreenActivity, 64 ) 65 66 winners = results.winnergroups 67 68 # If there's multiple players and everyone has the same score, 69 # call it a draw. 70 if len(self.sessionplayers) > 1 and len(winners) < 2: 71 self.setactivity( 72 _ba.newactivity(DrawScoreScreenActivity, {'results': results}) 73 ) 74 else: 75 # Award different point amounts based on number of players. 76 point_awards = self.get_ffa_point_awards() 77 78 for i, winner in enumerate(winners): 79 for team in winner.teams: 80 points = point_awards[i] if i in point_awards else 0 81 team.customdata['previous_score'] = team.customdata['score'] 82 team.customdata['score'] += points 83 84 series_winners = [ 85 team 86 for team in self.sessionteams 87 if team.customdata['score'] >= self._ffa_series_length 88 ] 89 series_winners.sort( 90 reverse=True, 91 key=lambda t: asserttype(t.customdata['score'], int), 92 ) 93 if len(series_winners) == 1 or ( 94 len(series_winners) > 1 95 and series_winners[0].customdata['score'] 96 != series_winners[1].customdata['score'] 97 ): 98 self.setactivity( 99 _ba.newactivity( 100 TeamSeriesVictoryScoreScreenActivity, 101 {'winner': series_winners[0]}, 102 ) 103 ) 104 else: 105 self.setactivity( 106 _ba.newactivity( 107 FreeForAllVictoryScoreScreenActivity, 108 {'results': results}, 109 ) 110 )
ba.Session type for free-for-all mode games.
Category: Gameplay Classes
51 def __init__(self) -> None: 52 _ba.increment_analytics_count('Free-for-all session start') 53 super().__init__()
Set up playlists and launches a ba.Activity to accept joiners.
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.
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.
29 def get_ffa_point_awards(self) -> dict[int, int]: 30 """Return the number of points awarded for different rankings. 31 32 This is based on the current number of players. 33 """ 34 point_awards: dict[int, int] 35 if len(self.sessionplayers) == 1: 36 point_awards = {} 37 elif len(self.sessionplayers) == 2: 38 point_awards = {0: 6} 39 elif len(self.sessionplayers) == 3: 40 point_awards = {0: 6, 1: 3} 41 elif len(self.sessionplayers) == 4: 42 point_awards = {0: 8, 1: 4, 2: 2} 43 elif len(self.sessionplayers) == 5: 44 point_awards = {0: 8, 1: 4, 2: 2} 45 elif len(self.sessionplayers) == 6: 46 point_awards = {0: 8, 1: 4, 2: 2} 47 else: 48 point_awards = {0: 8, 1: 4, 2: 2, 3: 1} 49 return point_awards
Return the number of points awarded for different rankings.
This is based on the current number of players.
Inherited Members
208@dataclass 209class FreezeMessage: 210 """Tells an object to become frozen. 211 212 Category: **Message Classes** 213 214 As seen in the effects of an ice ba.Bomb. 215 """
Tells an object to become frozen.
Category: Message Classes
As seen in the effects of an ice ba.Bomb.
36class GameActivity(Activity[PlayerType, TeamType]): 37 """Common base class for all game ba.Activities. 38 39 Category: **Gameplay Classes** 40 """ 41 42 # pylint: disable=too-many-public-methods 43 44 # Tips to be presented to the user at the start of the game. 45 tips: list[str | ba.GameTip] = [] 46 47 # Default getname() will return this if not None. 48 name: str | None = None 49 50 # Default get_description() will return this if not None. 51 description: str | None = None 52 53 # Default get_available_settings() will return this if not None. 54 available_settings: list[ba.Setting] | None = None 55 56 # Default getscoreconfig() will return this if not None. 57 scoreconfig: ba.ScoreConfig | None = None 58 59 # Override some defaults. 60 allow_pausing = True 61 allow_kick_idle_players = True 62 63 # Whether to show points for kills. 64 show_kill_points = True 65 66 # If not None, the music type that should play in on_transition_in() 67 # (unless overridden by the map). 68 default_music: ba.MusicType | None = None 69 70 @classmethod 71 def create_settings_ui( 72 cls, 73 sessiontype: type[ba.Session], 74 settings: dict | None, 75 completion_call: Callable[[dict | None], None], 76 ) -> None: 77 """Launch an in-game UI to configure settings for a game type. 78 79 'sessiontype' should be the ba.Session class the game will be used in. 80 81 'settings' should be an existing settings dict (implies 'edit' 82 ui mode) or None (implies 'add' ui mode). 83 84 'completion_call' will be called with a filled-out settings dict on 85 success or None on cancel. 86 87 Generally subclasses don't need to override this; if they override 88 ba.GameActivity.get_available_settings() and 89 ba.GameActivity.get_supported_maps() they can just rely on 90 the default implementation here which calls those methods. 91 """ 92 delegate = _ba.app.delegate 93 assert delegate is not None 94 delegate.create_default_game_settings_ui( 95 cls, sessiontype, settings, completion_call 96 ) 97 98 @classmethod 99 def getscoreconfig(cls) -> ba.ScoreConfig: 100 """Return info about game scoring setup; can be overridden by games.""" 101 return cls.scoreconfig if cls.scoreconfig is not None else ScoreConfig() 102 103 @classmethod 104 def getname(cls) -> str: 105 """Return a str name for this game type. 106 107 This default implementation simply returns the 'name' class attr. 108 """ 109 return cls.name if cls.name is not None else 'Untitled Game' 110 111 @classmethod 112 def get_display_string(cls, settings: dict | None = None) -> ba.Lstr: 113 """Return a descriptive name for this game/settings combo. 114 115 Subclasses should override getname(); not this. 116 """ 117 name = Lstr(translate=('gameNames', cls.getname())) 118 119 # A few substitutions for 'Epic', 'Solo' etc. modes. 120 # FIXME: Should provide a way for game types to define filters of 121 # their own and should not rely on hard-coded settings names. 122 if settings is not None: 123 if 'Solo Mode' in settings and settings['Solo Mode']: 124 name = Lstr( 125 resource='soloNameFilterText', subs=[('${NAME}', name)] 126 ) 127 if 'Epic Mode' in settings and settings['Epic Mode']: 128 name = Lstr( 129 resource='epicNameFilterText', subs=[('${NAME}', name)] 130 ) 131 132 return name 133 134 @classmethod 135 def get_team_display_string(cls, name: str) -> ba.Lstr: 136 """Given a team name, returns a localized version of it.""" 137 return Lstr(translate=('teamNames', name)) 138 139 @classmethod 140 def get_description(cls, sessiontype: type[ba.Session]) -> str: 141 """Get a str description of this game type. 142 143 The default implementation simply returns the 'description' class var. 144 Classes which want to change their description depending on the session 145 can override this method. 146 """ 147 del sessiontype # Unused arg. 148 return cls.description if cls.description is not None else '' 149 150 @classmethod 151 def get_description_display_string( 152 cls, sessiontype: type[ba.Session] 153 ) -> ba.Lstr: 154 """Return a translated version of get_description(). 155 156 Sub-classes should override get_description(); not this. 157 """ 158 description = cls.get_description(sessiontype) 159 return Lstr(translate=('gameDescriptions', description)) 160 161 @classmethod 162 def get_available_settings( 163 cls, sessiontype: type[ba.Session] 164 ) -> list[ba.Setting]: 165 """Return a list of settings relevant to this game type when 166 running under the provided session type. 167 """ 168 del sessiontype # Unused arg. 169 return [] if cls.available_settings is None else cls.available_settings 170 171 @classmethod 172 def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]: 173 """ 174 Called by the default ba.GameActivity.create_settings_ui() 175 implementation; should return a list of map names valid 176 for this game-type for the given ba.Session type. 177 """ 178 del sessiontype # Unused arg. 179 return _map.getmaps('melee') 180 181 @classmethod 182 def get_settings_display_string(cls, config: dict[str, Any]) -> ba.Lstr: 183 """Given a game config dict, return a short description for it. 184 185 This is used when viewing game-lists or showing what game 186 is up next in a series. 187 """ 188 name = cls.get_display_string(config['settings']) 189 190 # In newer configs, map is in settings; it used to be in the 191 # config root. 192 if 'map' in config['settings']: 193 sval = Lstr( 194 value='${NAME} @ ${MAP}', 195 subs=[ 196 ('${NAME}', name), 197 ( 198 '${MAP}', 199 _map.get_map_display_string( 200 _map.get_filtered_map_name( 201 config['settings']['map'] 202 ) 203 ), 204 ), 205 ], 206 ) 207 elif 'map' in config: 208 sval = Lstr( 209 value='${NAME} @ ${MAP}', 210 subs=[ 211 ('${NAME}', name), 212 ( 213 '${MAP}', 214 _map.get_map_display_string( 215 _map.get_filtered_map_name(config['map']) 216 ), 217 ), 218 ], 219 ) 220 else: 221 print('invalid game config - expected map entry under settings') 222 sval = Lstr(value='???') 223 return sval 224 225 @classmethod 226 def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool: 227 """Return whether this game supports the provided Session type.""" 228 from ba._multiteamsession import MultiTeamSession 229 230 # By default, games support any versus mode 231 return issubclass(sessiontype, MultiTeamSession) 232 233 def __init__(self, settings: dict): 234 """Instantiate the Activity.""" 235 super().__init__(settings) 236 237 # Holds some flattened info about the player set at the point 238 # when on_begin() is called. 239 self.initialplayerinfos: list[ba.PlayerInfo] | None = None 240 241 # Go ahead and get our map loading. 242 self._map_type = _map.get_map_class(self._calc_map_name(settings)) 243 244 self._spawn_sound = _ba.getsound('spawn') 245 self._map_type.preload() 246 self._map: ba.Map | None = None 247 self._powerup_drop_timer: ba.Timer | None = None 248 self._tnt_spawners: dict[int, TNTSpawner] | None = None 249 self._tnt_drop_timer: ba.Timer | None = None 250 self._game_scoreboard_name_text: ba.Actor | None = None 251 self._game_scoreboard_description_text: ba.Actor | None = None 252 self._standard_time_limit_time: int | None = None 253 self._standard_time_limit_timer: ba.Timer | None = None 254 self._standard_time_limit_text: ba.NodeActor | None = None 255 self._standard_time_limit_text_input: ba.NodeActor | None = None 256 self._tournament_time_limit: int | None = None 257 self._tournament_time_limit_timer: ba.Timer | None = None 258 self._tournament_time_limit_title_text: ba.NodeActor | None = None 259 self._tournament_time_limit_text: ba.NodeActor | None = None 260 self._tournament_time_limit_text_input: ba.NodeActor | None = None 261 self._zoom_message_times: dict[int, float] = {} 262 self._is_waiting_for_continue = False 263 264 self._continue_cost = _internal.get_v1_account_misc_read_val( 265 'continueStartCost', 25 266 ) 267 self._continue_cost_mult = _internal.get_v1_account_misc_read_val( 268 'continuesMult', 2 269 ) 270 self._continue_cost_offset = _internal.get_v1_account_misc_read_val( 271 'continuesOffset', 0 272 ) 273 274 @property 275 def map(self) -> ba.Map: 276 """The map being used for this game. 277 278 Raises a ba.MapNotFoundError if the map does not currently exist. 279 """ 280 if self._map is None: 281 raise MapNotFoundError 282 return self._map 283 284 def get_instance_display_string(self) -> ba.Lstr: 285 """Return a name for this particular game instance.""" 286 return self.get_display_string(self.settings_raw) 287 288 # noinspection PyUnresolvedReferences 289 def get_instance_scoreboard_display_string(self) -> ba.Lstr: 290 """Return a name for this particular game instance. 291 292 This name is used above the game scoreboard in the corner 293 of the screen, so it should be as concise as possible. 294 """ 295 # If we're in a co-op session, use the level name. 296 # FIXME: Should clean this up. 297 try: 298 from ba._coopsession import CoopSession 299 300 if isinstance(self.session, CoopSession): 301 campaign = self.session.campaign 302 assert campaign is not None 303 return campaign.getlevel( 304 self.session.campaign_level_name 305 ).displayname 306 except Exception: 307 print_error('error getting campaign level name') 308 return self.get_instance_display_string() 309 310 def get_instance_description(self) -> str | Sequence: 311 """Return a description for this game instance, in English. 312 313 This is shown in the center of the screen below the game name at the 314 start of a game. It should start with a capital letter and end with a 315 period, and can be a bit more verbose than the version returned by 316 get_instance_description_short(). 317 318 Note that translation is applied by looking up the specific returned 319 value as a key, so the number of returned variations should be limited; 320 ideally just one or two. To include arbitrary values in the 321 description, you can return a sequence of values in the following 322 form instead of just a string: 323 324 # This will give us something like 'Score 3 goals.' in English 325 # and can properly translate to 'Anota 3 goles.' in Spanish. 326 # If we just returned the string 'Score 3 Goals' here, there would 327 # have to be a translation entry for each specific number. ew. 328 return ['Score ${ARG1} goals.', self.settings_raw['Score to Win']] 329 330 This way the first string can be consistently translated, with any arg 331 values then substituted into the result. ${ARG1} will be replaced with 332 the first value, ${ARG2} with the second, etc. 333 """ 334 return self.get_description(type(self.session)) 335 336 def get_instance_description_short(self) -> str | Sequence: 337 """Return a short description for this game instance in English. 338 339 This description is used above the game scoreboard in the 340 corner of the screen, so it should be as concise as possible. 341 It should be lowercase and should not contain periods or other 342 punctuation. 343 344 Note that translation is applied by looking up the specific returned 345 value as a key, so the number of returned variations should be limited; 346 ideally just one or two. To include arbitrary values in the 347 description, you can return a sequence of values in the following form 348 instead of just a string: 349 350 # This will give us something like 'score 3 goals' in English 351 # and can properly translate to 'anota 3 goles' in Spanish. 352 # If we just returned the string 'score 3 goals' here, there would 353 # have to be a translation entry for each specific number. ew. 354 return ['score ${ARG1} goals', self.settings_raw['Score to Win']] 355 356 This way the first string can be consistently translated, with any arg 357 values then substituted into the result. ${ARG1} will be replaced 358 with the first value, ${ARG2} with the second, etc. 359 360 """ 361 return '' 362 363 def on_transition_in(self) -> None: 364 super().on_transition_in() 365 366 # Make our map. 367 self._map = self._map_type() 368 369 # Give our map a chance to override the music. 370 # (for happy-thoughts and other such themed maps) 371 map_music = self._map_type.get_music_type() 372 music = map_music if map_music is not None else self.default_music 373 374 if music is not None: 375 from ba import _music 376 377 _music.setmusic(music) 378 379 def on_continue(self) -> None: 380 """ 381 This is called if a game supports and offers a continue and the player 382 accepts. In this case the player should be given an extra life or 383 whatever is relevant to keep the game going. 384 """ 385 386 def _continue_choice(self, do_continue: bool) -> None: 387 self._is_waiting_for_continue = False 388 if self.has_ended(): 389 return 390 with _ba.Context(self): 391 if do_continue: 392 _ba.playsound(_ba.getsound('shieldUp')) 393 _ba.playsound(_ba.getsound('cashRegister')) 394 _internal.add_transaction( 395 {'type': 'CONTINUE', 'cost': self._continue_cost} 396 ) 397 _internal.run_transactions() 398 self._continue_cost = ( 399 self._continue_cost * self._continue_cost_mult 400 + self._continue_cost_offset 401 ) 402 self.on_continue() 403 else: 404 self.end_game() 405 406 def is_waiting_for_continue(self) -> bool: 407 """Returns whether or not this activity is currently waiting for the 408 player to continue (or timeout)""" 409 return self._is_waiting_for_continue 410 411 def continue_or_end_game(self) -> None: 412 """If continues are allowed, prompts the player to purchase a continue 413 and calls either end_game or continue_game depending on the result""" 414 # pylint: disable=too-many-nested-blocks 415 # pylint: disable=cyclic-import 416 from bastd.ui.continues import ContinuesWindow 417 from ba._coopsession import CoopSession 418 from ba._generated.enums import TimeType 419 420 try: 421 if _internal.get_v1_account_misc_read_val('enableContinues', False): 422 session = self.session 423 424 # We only support continuing in non-tournament games. 425 tournament_id = session.tournament_id 426 if tournament_id is None: 427 428 # We currently only support continuing in sequential 429 # co-op campaigns. 430 if isinstance(session, CoopSession): 431 assert session.campaign is not None 432 if session.campaign.sequential: 433 gnode = self.globalsnode 434 435 # Only attempt this if we're not currently paused 436 # and there appears to be no UI. 437 if ( 438 not gnode.paused 439 and not _ba.app.ui.has_main_menu_window() 440 ): 441 self._is_waiting_for_continue = True 442 with _ba.Context('ui'): 443 _ba.timer( 444 0.5, 445 lambda: ContinuesWindow( 446 self, 447 self._continue_cost, 448 continue_call=WeakCall( 449 self._continue_choice, True 450 ), 451 cancel_call=WeakCall( 452 self._continue_choice, False 453 ), 454 ), 455 timetype=TimeType.REAL, 456 ) 457 return 458 459 except Exception: 460 print_exception('Error handling continues.') 461 462 self.end_game() 463 464 def on_begin(self) -> None: 465 from ba._analytics import game_begin_analytics 466 467 super().on_begin() 468 469 game_begin_analytics() 470 471 # We don't do this in on_transition_in because it may depend on 472 # players/teams which aren't available until now. 473 _ba.timer(0.001, self._show_scoreboard_info) 474 _ba.timer(1.0, self._show_info) 475 _ba.timer(2.5, self._show_tip) 476 477 # Store some basic info about players present at start time. 478 self.initialplayerinfos = [ 479 PlayerInfo(name=p.getname(full=True), character=p.character) 480 for p in self.players 481 ] 482 483 # Sort this by name so high score lists/etc will be consistent 484 # regardless of player join order. 485 self.initialplayerinfos.sort(key=lambda x: x.name) 486 487 # If this is a tournament, query info about it such as how much 488 # time is left. 489 tournament_id = self.session.tournament_id 490 if tournament_id is not None: 491 _internal.tournament_query( 492 args={ 493 'tournamentIDs': [tournament_id], 494 'source': 'in-game time remaining query', 495 }, 496 callback=WeakCall(self._on_tournament_query_response), 497 ) 498 499 def _on_tournament_query_response( 500 self, data: dict[str, Any] | None 501 ) -> None: 502 if data is not None: 503 data_t = data['t'] # This used to be the whole payload. 504 505 # Keep our cached tourney info up to date 506 _ba.app.accounts_v1.cache_tournament_info(data_t) 507 self._setup_tournament_time_limit( 508 max(5, data_t[0]['timeRemaining']) 509 ) 510 511 def on_player_join(self, player: PlayerType) -> None: 512 super().on_player_join(player) 513 514 # By default, just spawn a dude. 515 self.spawn_player(player) 516 517 def handlemessage(self, msg: Any) -> Any: 518 if isinstance(msg, PlayerDiedMessage): 519 # pylint: disable=cyclic-import 520 from bastd.actor.spaz import Spaz 521 522 player = msg.getplayer(self.playertype) 523 killer = msg.getkillerplayer(self.playertype) 524 525 # Inform our stats of the demise. 526 self.stats.player_was_killed( 527 player, killed=msg.killed, killer=killer 528 ) 529 530 # Award the killer points if he's on a different team. 531 # FIXME: This should not be linked to Spaz actors. 532 # (should move get_death_points to Actor or make it a message) 533 if killer and killer.team is not player.team: 534 assert isinstance(killer.actor, Spaz) 535 pts, importance = killer.actor.get_death_points(msg.how) 536 if not self.has_ended(): 537 self.stats.player_scored( 538 killer, 539 pts, 540 kill=True, 541 victim_player=player, 542 importance=importance, 543 showpoints=self.show_kill_points, 544 ) 545 else: 546 return super().handlemessage(msg) 547 return None 548 549 def _show_scoreboard_info(self) -> None: 550 """Create the game info display. 551 552 This is the thing in the top left corner showing the name 553 and short description of the game. 554 """ 555 # pylint: disable=too-many-locals 556 from ba._freeforallsession import FreeForAllSession 557 from ba._gameutils import animate 558 from ba._nodeactor import NodeActor 559 560 sb_name = self.get_instance_scoreboard_display_string() 561 562 # The description can be either a string or a sequence with args 563 # to swap in post-translation. 564 sb_desc_in = self.get_instance_description_short() 565 sb_desc_l: Sequence 566 if isinstance(sb_desc_in, str): 567 sb_desc_l = [sb_desc_in] # handle simple string case 568 else: 569 sb_desc_l = sb_desc_in 570 if not isinstance(sb_desc_l[0], str): 571 raise TypeError('Invalid format for instance description.') 572 573 is_empty = sb_desc_l[0] == '' 574 subs = [] 575 for i in range(len(sb_desc_l) - 1): 576 subs.append(('${ARG' + str(i + 1) + '}', str(sb_desc_l[i + 1]))) 577 translation = Lstr( 578 translate=('gameDescriptions', sb_desc_l[0]), subs=subs 579 ) 580 sb_desc = translation 581 vrmode = _ba.app.vr_mode 582 yval = -34 if is_empty else -20 583 yval -= 16 584 sbpos = ( 585 (15, yval) 586 if isinstance(self.session, FreeForAllSession) 587 else (15, yval) 588 ) 589 self._game_scoreboard_name_text = NodeActor( 590 _ba.newnode( 591 'text', 592 attrs={ 593 'text': sb_name, 594 'maxwidth': 300, 595 'position': sbpos, 596 'h_attach': 'left', 597 'vr_depth': 10, 598 'v_attach': 'top', 599 'v_align': 'bottom', 600 'color': (1.0, 1.0, 1.0, 1.0), 601 'shadow': 1.0 if vrmode else 0.6, 602 'flatness': 1.0 if vrmode else 0.5, 603 'scale': 1.1, 604 }, 605 ) 606 ) 607 608 assert self._game_scoreboard_name_text.node 609 animate( 610 self._game_scoreboard_name_text.node, 'opacity', {0: 0.0, 1.0: 1.0} 611 ) 612 613 descpos = ( 614 (17, -44 + 10) 615 if isinstance(self.session, FreeForAllSession) 616 else (17, -44 + 10) 617 ) 618 self._game_scoreboard_description_text = NodeActor( 619 _ba.newnode( 620 'text', 621 attrs={ 622 'text': sb_desc, 623 'maxwidth': 480, 624 'position': descpos, 625 'scale': 0.7, 626 'h_attach': 'left', 627 'v_attach': 'top', 628 'v_align': 'top', 629 'shadow': 1.0 if vrmode else 0.7, 630 'flatness': 1.0 if vrmode else 0.8, 631 'color': (1, 1, 1, 1) if vrmode else (0.9, 0.9, 0.9, 1.0), 632 }, 633 ) 634 ) 635 636 assert self._game_scoreboard_description_text.node 637 animate( 638 self._game_scoreboard_description_text.node, 639 'opacity', 640 {0: 0.0, 1.0: 1.0}, 641 ) 642 643 def _show_info(self) -> None: 644 """Show the game description.""" 645 from ba._gameutils import animate 646 from bastd.actor.zoomtext import ZoomText 647 648 name = self.get_instance_display_string() 649 ZoomText( 650 name, 651 maxwidth=800, 652 lifespan=2.5, 653 jitter=2.0, 654 position=(0, 180), 655 flash=False, 656 color=(0.93 * 1.25, 0.9 * 1.25, 1.0 * 1.25), 657 trailcolor=(0.15, 0.05, 1.0, 0.0), 658 ).autoretain() 659 _ba.timer(0.2, Call(_ba.playsound, _ba.getsound('gong'))) 660 661 # The description can be either a string or a sequence with args 662 # to swap in post-translation. 663 desc_in = self.get_instance_description() 664 desc_l: Sequence 665 if isinstance(desc_in, str): 666 desc_l = [desc_in] # handle simple string case 667 else: 668 desc_l = desc_in 669 if not isinstance(desc_l[0], str): 670 raise TypeError('Invalid format for instance description') 671 subs = [] 672 for i in range(len(desc_l) - 1): 673 subs.append(('${ARG' + str(i + 1) + '}', str(desc_l[i + 1]))) 674 translation = Lstr(translate=('gameDescriptions', desc_l[0]), subs=subs) 675 676 # Do some standard filters (epic mode, etc). 677 if self.settings_raw.get('Epic Mode', False): 678 translation = Lstr( 679 resource='epicDescriptionFilterText', 680 subs=[('${DESCRIPTION}', translation)], 681 ) 682 vrmode = _ba.app.vr_mode 683 dnode = _ba.newnode( 684 'text', 685 attrs={ 686 'v_attach': 'center', 687 'h_attach': 'center', 688 'h_align': 'center', 689 'color': (1, 1, 1, 1), 690 'shadow': 1.0 if vrmode else 0.5, 691 'flatness': 1.0 if vrmode else 0.5, 692 'vr_depth': -30, 693 'position': (0, 80), 694 'scale': 1.2, 695 'maxwidth': 700, 696 'text': translation, 697 }, 698 ) 699 cnode = _ba.newnode( 700 'combine', 701 owner=dnode, 702 attrs={'input0': 1.0, 'input1': 1.0, 'input2': 1.0, 'size': 4}, 703 ) 704 cnode.connectattr('output', dnode, 'color') 705 keys = {0.5: 0, 1.0: 1.0, 2.5: 1.0, 4.0: 0.0} 706 animate(cnode, 'input3', keys) 707 _ba.timer(4.0, dnode.delete) 708 709 def _show_tip(self) -> None: 710 # pylint: disable=too-many-locals 711 from ba._gameutils import animate, GameTip 712 from ba._generated.enums import SpecialChar 713 714 # If there's any tips left on the list, display one. 715 if self.tips: 716 tip = self.tips.pop(random.randrange(len(self.tips))) 717 tip_title = Lstr( 718 value='${A}:', subs=[('${A}', Lstr(resource='tipText'))] 719 ) 720 icon: ba.Texture | None = None 721 sound: ba.Sound | None = None 722 if isinstance(tip, GameTip): 723 icon = tip.icon 724 sound = tip.sound 725 tip = tip.text 726 assert isinstance(tip, str) 727 728 # Do a few substitutions. 729 tip_lstr = Lstr( 730 translate=('tips', tip), 731 subs=[('${PICKUP}', _ba.charstr(SpecialChar.TOP_BUTTON))], 732 ) 733 base_position = (75, 50) 734 tip_scale = 0.8 735 tip_title_scale = 1.2 736 vrmode = _ba.app.vr_mode 737 738 t_offs = -350.0 739 tnode = _ba.newnode( 740 'text', 741 attrs={ 742 'text': tip_lstr, 743 'scale': tip_scale, 744 'maxwidth': 900, 745 'position': (base_position[0] + t_offs, base_position[1]), 746 'h_align': 'left', 747 'vr_depth': 300, 748 'shadow': 1.0 if vrmode else 0.5, 749 'flatness': 1.0 if vrmode else 0.5, 750 'v_align': 'center', 751 'v_attach': 'bottom', 752 }, 753 ) 754 t2pos = ( 755 base_position[0] + t_offs - (20 if icon is None else 82), 756 base_position[1] + 2, 757 ) 758 t2node = _ba.newnode( 759 'text', 760 owner=tnode, 761 attrs={ 762 'text': tip_title, 763 'scale': tip_title_scale, 764 'position': t2pos, 765 'h_align': 'right', 766 'vr_depth': 300, 767 'shadow': 1.0 if vrmode else 0.5, 768 'flatness': 1.0 if vrmode else 0.5, 769 'maxwidth': 140, 770 'v_align': 'center', 771 'v_attach': 'bottom', 772 }, 773 ) 774 if icon is not None: 775 ipos = (base_position[0] + t_offs - 40, base_position[1] + 1) 776 img = _ba.newnode( 777 'image', 778 attrs={ 779 'texture': icon, 780 'position': ipos, 781 'scale': (50, 50), 782 'opacity': 1.0, 783 'vr_depth': 315, 784 'color': (1, 1, 1), 785 'absolute_scale': True, 786 'attach': 'bottomCenter', 787 }, 788 ) 789 animate(img, 'opacity', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0}) 790 _ba.timer(5.0, img.delete) 791 if sound is not None: 792 _ba.playsound(sound) 793 794 combine = _ba.newnode( 795 'combine', 796 owner=tnode, 797 attrs={'input0': 1.0, 'input1': 0.8, 'input2': 1.0, 'size': 4}, 798 ) 799 combine.connectattr('output', tnode, 'color') 800 combine.connectattr('output', t2node, 'color') 801 animate(combine, 'input3', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0}) 802 _ba.timer(5.0, tnode.delete) 803 804 def end( 805 self, results: Any = None, delay: float = 0.0, force: bool = False 806 ) -> None: 807 from ba._gameresults import GameResults 808 809 # If results is a standard team-game-results, associate it with us 810 # so it can grab our score prefs. 811 if isinstance(results, GameResults): 812 results.set_game(self) 813 814 # If we had a standard time-limit that had not expired, stop it so 815 # it doesnt tick annoyingly. 816 if ( 817 self._standard_time_limit_time is not None 818 and self._standard_time_limit_time > 0 819 ): 820 self._standard_time_limit_timer = None 821 self._standard_time_limit_text = None 822 823 # Ditto with tournament time limits. 824 if ( 825 self._tournament_time_limit is not None 826 and self._tournament_time_limit > 0 827 ): 828 self._tournament_time_limit_timer = None 829 self._tournament_time_limit_text = None 830 self._tournament_time_limit_title_text = None 831 832 super().end(results, delay, force) 833 834 def end_game(self) -> None: 835 """Tell the game to wrap up and call ba.Activity.end() immediately. 836 837 This method should be overridden by subclasses. A game should always 838 be prepared to end and deliver results, even if there is no 'winner' 839 yet; this way things like the standard time-limit 840 (ba.GameActivity.setup_standard_time_limit()) will work with the game. 841 """ 842 print( 843 'WARNING: default end_game() implementation called;' 844 ' your game should override this.' 845 ) 846 847 def respawn_player( 848 self, player: PlayerType, respawn_time: float | None = None 849 ) -> None: 850 """ 851 Given a ba.Player, sets up a standard respawn timer, 852 along with the standard counter display, etc. 853 At the end of the respawn period spawn_player() will 854 be called if the Player still exists. 855 An explicit 'respawn_time' can optionally be provided 856 (in seconds). 857 """ 858 # pylint: disable=cyclic-import 859 860 assert player 861 if respawn_time is None: 862 teamsize = len(player.team.players) 863 if teamsize == 1: 864 respawn_time = 3.0 865 elif teamsize == 2: 866 respawn_time = 5.0 867 elif teamsize == 3: 868 respawn_time = 6.0 869 else: 870 respawn_time = 7.0 871 872 # If this standard setting is present, factor it in. 873 if 'Respawn Times' in self.settings_raw: 874 respawn_time *= self.settings_raw['Respawn Times'] 875 876 # We want whole seconds. 877 assert respawn_time is not None 878 respawn_time = round(max(1.0, respawn_time), 0) 879 880 if player.actor and not self.has_ended(): 881 from bastd.actor.respawnicon import RespawnIcon 882 883 player.customdata['respawn_timer'] = _ba.Timer( 884 respawn_time, WeakCall(self.spawn_player_if_exists, player) 885 ) 886 player.customdata['respawn_icon'] = RespawnIcon( 887 player, respawn_time 888 ) 889 890 def spawn_player_if_exists(self, player: PlayerType) -> None: 891 """ 892 A utility method which calls self.spawn_player() *only* if the 893 ba.Player provided still exists; handy for use in timers and whatnot. 894 895 There is no need to override this; just override spawn_player(). 896 """ 897 if player: 898 self.spawn_player(player) 899 900 def spawn_player(self, player: PlayerType) -> ba.Actor: 901 """Spawn *something* for the provided ba.Player. 902 903 The default implementation simply calls spawn_player_spaz(). 904 """ 905 assert player # Dead references should never be passed as args. 906 907 return self.spawn_player_spaz(player) 908 909 def spawn_player_spaz( 910 self, 911 player: PlayerType, 912 position: Sequence[float] = (0, 0, 0), 913 angle: float | None = None, 914 ) -> PlayerSpaz: 915 """Create and wire up a ba.PlayerSpaz for the provided ba.Player.""" 916 # pylint: disable=too-many-locals 917 # pylint: disable=cyclic-import 918 from ba import _math 919 from ba._gameutils import animate 920 from ba._coopsession import CoopSession 921 from bastd.actor.playerspaz import PlayerSpaz 922 923 name = player.getname() 924 color = player.color 925 highlight = player.highlight 926 927 playerspaztype = getattr(player, 'playerspaztype', PlayerSpaz) 928 if not issubclass(playerspaztype, PlayerSpaz): 929 playerspaztype = PlayerSpaz 930 931 light_color = _math.normalized_color(color) 932 display_color = _ba.safecolor(color, target_intensity=0.75) 933 spaz = playerspaztype( 934 color=color, 935 highlight=highlight, 936 character=player.character, 937 player=player, 938 ) 939 940 player.actor = spaz 941 assert spaz.node 942 943 # If this is co-op and we're on Courtyard or Runaround, add the 944 # material that allows us to collide with the player-walls. 945 # FIXME: Need to generalize this. 946 if isinstance(self.session, CoopSession) and self.map.getname() in [ 947 'Courtyard', 948 'Tower D', 949 ]: 950 mat = self.map.preloaddata['collide_with_wall_material'] 951 assert isinstance(spaz.node.materials, tuple) 952 assert isinstance(spaz.node.roller_materials, tuple) 953 spaz.node.materials += (mat,) 954 spaz.node.roller_materials += (mat,) 955 956 spaz.node.name = name 957 spaz.node.name_color = display_color 958 spaz.connect_controls_to_player() 959 960 # Move to the stand position and add a flash of light. 961 spaz.handlemessage( 962 StandMessage( 963 position, angle if angle is not None else random.uniform(0, 360) 964 ) 965 ) 966 _ba.playsound(self._spawn_sound, 1, position=spaz.node.position) 967 light = _ba.newnode('light', attrs={'color': light_color}) 968 spaz.node.connectattr('position', light, 'position') 969 animate(light, 'intensity', {0: 0, 0.25: 1, 0.5: 0}) 970 _ba.timer(0.5, light.delete) 971 return spaz 972 973 def setup_standard_powerup_drops(self, enable_tnt: bool = True) -> None: 974 """Create standard powerup drops for the current map.""" 975 # pylint: disable=cyclic-import 976 from bastd.actor.powerupbox import DEFAULT_POWERUP_INTERVAL 977 978 self._powerup_drop_timer = _ba.Timer( 979 DEFAULT_POWERUP_INTERVAL, 980 WeakCall(self._standard_drop_powerups), 981 repeat=True, 982 ) 983 self._standard_drop_powerups() 984 if enable_tnt: 985 self._tnt_spawners = {} 986 self._setup_standard_tnt_drops() 987 988 def _standard_drop_powerup(self, index: int, expire: bool = True) -> None: 989 # pylint: disable=cyclic-import 990 from bastd.actor.powerupbox import PowerupBox, PowerupBoxFactory 991 992 PowerupBox( 993 position=self.map.powerup_spawn_points[index], 994 poweruptype=PowerupBoxFactory.get().get_random_powerup_type(), 995 expire=expire, 996 ).autoretain() 997 998 def _standard_drop_powerups(self) -> None: 999 """Standard powerup drop.""" 1000 1001 # Drop one powerup per point. 1002 points = self.map.powerup_spawn_points 1003 for i in range(len(points)): 1004 _ba.timer(i * 0.4, WeakCall(self._standard_drop_powerup, i)) 1005 1006 def _setup_standard_tnt_drops(self) -> None: 1007 """Standard tnt drop.""" 1008 # pylint: disable=cyclic-import 1009 from bastd.actor.bomb import TNTSpawner 1010 1011 for i, point in enumerate(self.map.tnt_points): 1012 assert self._tnt_spawners is not None 1013 if self._tnt_spawners.get(i) is None: 1014 self._tnt_spawners[i] = TNTSpawner(point) 1015 1016 def setup_standard_time_limit(self, duration: float) -> None: 1017 """ 1018 Create a standard game time-limit given the provided 1019 duration in seconds. 1020 This will be displayed at the top of the screen. 1021 If the time-limit expires, end_game() will be called. 1022 """ 1023 from ba._nodeactor import NodeActor 1024 1025 if duration <= 0.0: 1026 return 1027 self._standard_time_limit_time = int(duration) 1028 self._standard_time_limit_timer = _ba.Timer( 1029 1.0, WeakCall(self._standard_time_limit_tick), repeat=True 1030 ) 1031 self._standard_time_limit_text = NodeActor( 1032 _ba.newnode( 1033 'text', 1034 attrs={ 1035 'v_attach': 'top', 1036 'h_attach': 'center', 1037 'h_align': 'left', 1038 'color': (1.0, 1.0, 1.0, 0.5), 1039 'position': (-25, -30), 1040 'flatness': 1.0, 1041 'scale': 0.9, 1042 }, 1043 ) 1044 ) 1045 self._standard_time_limit_text_input = NodeActor( 1046 _ba.newnode( 1047 'timedisplay', attrs={'time2': duration * 1000, 'timemin': 0} 1048 ) 1049 ) 1050 self.globalsnode.connectattr( 1051 'time', self._standard_time_limit_text_input.node, 'time1' 1052 ) 1053 assert self._standard_time_limit_text_input.node 1054 assert self._standard_time_limit_text.node 1055 self._standard_time_limit_text_input.node.connectattr( 1056 'output', self._standard_time_limit_text.node, 'text' 1057 ) 1058 1059 def _standard_time_limit_tick(self) -> None: 1060 from ba._gameutils import animate 1061 1062 assert self._standard_time_limit_time is not None 1063 self._standard_time_limit_time -= 1 1064 if self._standard_time_limit_time <= 10: 1065 if self._standard_time_limit_time == 10: 1066 assert self._standard_time_limit_text is not None 1067 assert self._standard_time_limit_text.node 1068 self._standard_time_limit_text.node.scale = 1.3 1069 self._standard_time_limit_text.node.position = (-30, -45) 1070 cnode = _ba.newnode( 1071 'combine', 1072 owner=self._standard_time_limit_text.node, 1073 attrs={'size': 4}, 1074 ) 1075 cnode.connectattr( 1076 'output', self._standard_time_limit_text.node, 'color' 1077 ) 1078 animate(cnode, 'input0', {0: 1, 0.15: 1}, loop=True) 1079 animate(cnode, 'input1', {0: 1, 0.15: 0.5}, loop=True) 1080 animate(cnode, 'input2', {0: 0.1, 0.15: 0.0}, loop=True) 1081 cnode.input3 = 1.0 1082 _ba.playsound(_ba.getsound('tick')) 1083 if self._standard_time_limit_time <= 0: 1084 self._standard_time_limit_timer = None 1085 self.end_game() 1086 node = _ba.newnode( 1087 'text', 1088 attrs={ 1089 'v_attach': 'top', 1090 'h_attach': 'center', 1091 'h_align': 'center', 1092 'color': (1, 0.7, 0, 1), 1093 'position': (0, -90), 1094 'scale': 1.2, 1095 'text': Lstr(resource='timeExpiredText'), 1096 }, 1097 ) 1098 _ba.playsound(_ba.getsound('refWhistle')) 1099 animate(node, 'scale', {0.0: 0.0, 0.1: 1.4, 0.15: 1.2}) 1100 1101 def _setup_tournament_time_limit(self, duration: float) -> None: 1102 """ 1103 Create a tournament game time-limit given the provided 1104 duration in seconds. 1105 This will be displayed at the top of the screen. 1106 If the time-limit expires, end_game() will be called. 1107 """ 1108 from ba._nodeactor import NodeActor 1109 from ba._generated.enums import TimeType 1110 1111 if duration <= 0.0: 1112 return 1113 self._tournament_time_limit = int(duration) 1114 1115 # We want this timer to match the server's time as close as possible, 1116 # so lets go with base-time. Theoretically we should do real-time but 1117 # then we have to mess with contexts and whatnot since its currently 1118 # not available in activity contexts. :-/ 1119 self._tournament_time_limit_timer = _ba.Timer( 1120 1.0, 1121 WeakCall(self._tournament_time_limit_tick), 1122 repeat=True, 1123 timetype=TimeType.BASE, 1124 ) 1125 self._tournament_time_limit_title_text = NodeActor( 1126 _ba.newnode( 1127 'text', 1128 attrs={ 1129 'v_attach': 'bottom', 1130 'h_attach': 'left', 1131 'h_align': 'center', 1132 'v_align': 'center', 1133 'vr_depth': 300, 1134 'maxwidth': 100, 1135 'color': (1.0, 1.0, 1.0, 0.5), 1136 'position': (60, 50), 1137 'flatness': 1.0, 1138 'scale': 0.5, 1139 'text': Lstr(resource='tournamentText'), 1140 }, 1141 ) 1142 ) 1143 self._tournament_time_limit_text = NodeActor( 1144 _ba.newnode( 1145 'text', 1146 attrs={ 1147 'v_attach': 'bottom', 1148 'h_attach': 'left', 1149 'h_align': 'center', 1150 'v_align': 'center', 1151 'vr_depth': 300, 1152 'maxwidth': 100, 1153 'color': (1.0, 1.0, 1.0, 0.5), 1154 'position': (60, 30), 1155 'flatness': 1.0, 1156 'scale': 0.9, 1157 }, 1158 ) 1159 ) 1160 self._tournament_time_limit_text_input = NodeActor( 1161 _ba.newnode( 1162 'timedisplay', 1163 attrs={ 1164 'timemin': 0, 1165 'time2': self._tournament_time_limit * 1000, 1166 }, 1167 ) 1168 ) 1169 assert self._tournament_time_limit_text.node 1170 assert self._tournament_time_limit_text_input.node 1171 self._tournament_time_limit_text_input.node.connectattr( 1172 'output', self._tournament_time_limit_text.node, 'text' 1173 ) 1174 1175 def _tournament_time_limit_tick(self) -> None: 1176 from ba._gameutils import animate 1177 1178 assert self._tournament_time_limit is not None 1179 self._tournament_time_limit -= 1 1180 if self._tournament_time_limit <= 10: 1181 if self._tournament_time_limit == 10: 1182 assert self._tournament_time_limit_title_text is not None 1183 assert self._tournament_time_limit_title_text.node 1184 assert self._tournament_time_limit_text is not None 1185 assert self._tournament_time_limit_text.node 1186 self._tournament_time_limit_title_text.node.scale = 1.0 1187 self._tournament_time_limit_text.node.scale = 1.3 1188 self._tournament_time_limit_title_text.node.position = (80, 85) 1189 self._tournament_time_limit_text.node.position = (80, 60) 1190 cnode = _ba.newnode( 1191 'combine', 1192 owner=self._tournament_time_limit_text.node, 1193 attrs={'size': 4}, 1194 ) 1195 cnode.connectattr( 1196 'output', 1197 self._tournament_time_limit_title_text.node, 1198 'color', 1199 ) 1200 cnode.connectattr( 1201 'output', self._tournament_time_limit_text.node, 'color' 1202 ) 1203 animate(cnode, 'input0', {0: 1, 0.15: 1}, loop=True) 1204 animate(cnode, 'input1', {0: 1, 0.15: 0.5}, loop=True) 1205 animate(cnode, 'input2', {0: 0.1, 0.15: 0.0}, loop=True) 1206 cnode.input3 = 1.0 1207 _ba.playsound(_ba.getsound('tick')) 1208 if self._tournament_time_limit <= 0: 1209 self._tournament_time_limit_timer = None 1210 self.end_game() 1211 tval = Lstr( 1212 resource='tournamentTimeExpiredText', 1213 fallback_resource='timeExpiredText', 1214 ) 1215 node = _ba.newnode( 1216 'text', 1217 attrs={ 1218 'v_attach': 'top', 1219 'h_attach': 'center', 1220 'h_align': 'center', 1221 'color': (1, 0.7, 0, 1), 1222 'position': (0, -200), 1223 'scale': 1.6, 1224 'text': tval, 1225 }, 1226 ) 1227 _ba.playsound(_ba.getsound('refWhistle')) 1228 animate(node, 'scale', {0: 0.0, 0.1: 1.4, 0.15: 1.2}) 1229 1230 # Normally we just connect this to time, but since this is a bit of a 1231 # funky setup we just update it manually once per second. 1232 assert self._tournament_time_limit_text_input is not None 1233 assert self._tournament_time_limit_text_input.node 1234 self._tournament_time_limit_text_input.node.time2 = ( 1235 self._tournament_time_limit * 1000 1236 ) 1237 1238 def show_zoom_message( 1239 self, 1240 message: ba.Lstr, 1241 color: Sequence[float] = (0.9, 0.4, 0.0), 1242 scale: float = 0.8, 1243 duration: float = 2.0, 1244 trail: bool = False, 1245 ) -> None: 1246 """Zooming text used to announce game names and winners.""" 1247 # pylint: disable=cyclic-import 1248 from bastd.actor.zoomtext import ZoomText 1249 1250 # Reserve a spot on the screen (in case we get multiple of these so 1251 # they don't overlap). 1252 i = 0 1253 cur_time = _ba.time() 1254 while True: 1255 if ( 1256 i not in self._zoom_message_times 1257 or self._zoom_message_times[i] < cur_time 1258 ): 1259 self._zoom_message_times[i] = cur_time + duration 1260 break 1261 i += 1 1262 ZoomText( 1263 message, 1264 lifespan=duration, 1265 jitter=2.0, 1266 position=(0, 200 - i * 100), 1267 scale=scale, 1268 maxwidth=800, 1269 trail=trail, 1270 color=color, 1271 ).autoretain() 1272 1273 def _calc_map_name(self, settings: dict) -> str: 1274 map_name: str 1275 if 'map' in settings: 1276 map_name = settings['map'] 1277 else: 1278 # If settings doesn't specify a map, pick a random one from the 1279 # list of supported ones. 1280 unowned_maps = _store.get_unowned_maps() 1281 valid_maps: list[str] = [ 1282 m 1283 for m in self.get_supported_maps(type(self.session)) 1284 if m not in unowned_maps 1285 ] 1286 if not valid_maps: 1287 _ba.screenmessage(Lstr(resource='noValidMapsErrorText')) 1288 raise Exception('No valid maps') 1289 map_name = valid_maps[random.randrange(len(valid_maps))] 1290 return map_name
Common base class for all game ba.Activities.
Category: Gameplay Classes
233 def __init__(self, settings: dict): 234 """Instantiate the Activity.""" 235 super().__init__(settings) 236 237 # Holds some flattened info about the player set at the point 238 # when on_begin() is called. 239 self.initialplayerinfos: list[ba.PlayerInfo] | None = None 240 241 # Go ahead and get our map loading. 242 self._map_type = _map.get_map_class(self._calc_map_name(settings)) 243 244 self._spawn_sound = _ba.getsound('spawn') 245 self._map_type.preload() 246 self._map: ba.Map | None = None 247 self._powerup_drop_timer: ba.Timer | None = None 248 self._tnt_spawners: dict[int, TNTSpawner] | None = None 249 self._tnt_drop_timer: ba.Timer | None = None 250 self._game_scoreboard_name_text: ba.Actor | None = None 251 self._game_scoreboard_description_text: ba.Actor | None = None 252 self._standard_time_limit_time: int | None = None 253 self._standard_time_limit_timer: ba.Timer | None = None 254 self._standard_time_limit_text: ba.NodeActor | None = None 255 self._standard_time_limit_text_input: ba.NodeActor | None = None 256 self._tournament_time_limit: int | None = None 257 self._tournament_time_limit_timer: ba.Timer | None = None 258 self._tournament_time_limit_title_text: ba.NodeActor | None = None 259 self._tournament_time_limit_text: ba.NodeActor | None = None 260 self._tournament_time_limit_text_input: ba.NodeActor | None = None 261 self._zoom_message_times: dict[int, float] = {} 262 self._is_waiting_for_continue = False 263 264 self._continue_cost = _internal.get_v1_account_misc_read_val( 265 'continueStartCost', 25 266 ) 267 self._continue_cost_mult = _internal.get_v1_account_misc_read_val( 268 'continuesMult', 2 269 ) 270 self._continue_cost_offset = _internal.get_v1_account_misc_read_val( 271 'continuesOffset', 0 272 )
Instantiate the Activity.
Whether idle players can potentially be kicked (should not happen in menus/etc).
70 @classmethod 71 def create_settings_ui( 72 cls, 73 sessiontype: type[ba.Session], 74 settings: dict | None, 75 completion_call: Callable[[dict | None], None], 76 ) -> None: 77 """Launch an in-game UI to configure settings for a game type. 78 79 'sessiontype' should be the ba.Session class the game will be used in. 80 81 'settings' should be an existing settings dict (implies 'edit' 82 ui mode) or None (implies 'add' ui mode). 83 84 'completion_call' will be called with a filled-out settings dict on 85 success or None on cancel. 86 87 Generally subclasses don't need to override this; if they override 88 ba.GameActivity.get_available_settings() and 89 ba.GameActivity.get_supported_maps() they can just rely on 90 the default implementation here which calls those methods. 91 """ 92 delegate = _ba.app.delegate 93 assert delegate is not None 94 delegate.create_default_game_settings_ui( 95 cls, sessiontype, settings, completion_call 96 )
Launch an in-game UI to configure settings for a game type.
'sessiontype' should be the ba.Session class the game will be used in.
'settings' should be an existing settings dict (implies 'edit' ui mode) or None (implies 'add' ui mode).
'completion_call' will be called with a filled-out settings dict on success or None on cancel.
Generally subclasses don't need to override this; if they override ba.GameActivity.get_available_settings() and ba.GameActivity.get_supported_maps() they can just rely on the default implementation here which calls those methods.
98 @classmethod 99 def getscoreconfig(cls) -> ba.ScoreConfig: 100 """Return info about game scoring setup; can be overridden by games.""" 101 return cls.scoreconfig if cls.scoreconfig is not None else ScoreConfig()
Return info about game scoring setup; can be overridden by games.
103 @classmethod 104 def getname(cls) -> str: 105 """Return a str name for this game type. 106 107 This default implementation simply returns the 'name' class attr. 108 """ 109 return cls.name if cls.name is not None else 'Untitled Game'
Return a str name for this game type.
This default implementation simply returns the 'name' class attr.
111 @classmethod 112 def get_display_string(cls, settings: dict | None = None) -> ba.Lstr: 113 """Return a descriptive name for this game/settings combo. 114 115 Subclasses should override getname(); not this. 116 """ 117 name = Lstr(translate=('gameNames', cls.getname())) 118 119 # A few substitutions for 'Epic', 'Solo' etc. modes. 120 # FIXME: Should provide a way for game types to define filters of 121 # their own and should not rely on hard-coded settings names. 122 if settings is not None: 123 if 'Solo Mode' in settings and settings['Solo Mode']: 124 name = Lstr( 125 resource='soloNameFilterText', subs=[('${NAME}', name)] 126 ) 127 if 'Epic Mode' in settings and settings['Epic Mode']: 128 name = Lstr( 129 resource='epicNameFilterText', subs=[('${NAME}', name)] 130 ) 131 132 return name
Return a descriptive name for this game/settings combo.
Subclasses should override getname(); not this.
134 @classmethod 135 def get_team_display_string(cls, name: str) -> ba.Lstr: 136 """Given a team name, returns a localized version of it.""" 137 return Lstr(translate=('teamNames', name))
Given a team name, returns a localized version of it.
139 @classmethod 140 def get_description(cls, sessiontype: type[ba.Session]) -> str: 141 """Get a str description of this game type. 142 143 The default implementation simply returns the 'description' class var. 144 Classes which want to change their description depending on the session 145 can override this method. 146 """ 147 del sessiontype # Unused arg. 148 return cls.description if cls.description is not None else ''
Get a str description of this game type.
The default implementation simply returns the 'description' class var. Classes which want to change their description depending on the session can override this method.
150 @classmethod 151 def get_description_display_string( 152 cls, sessiontype: type[ba.Session] 153 ) -> ba.Lstr: 154 """Return a translated version of get_description(). 155 156 Sub-classes should override get_description(); not this. 157 """ 158 description = cls.get_description(sessiontype) 159 return Lstr(translate=('gameDescriptions', description))
Return a translated version of get_description().
Sub-classes should override get_description(); not this.
161 @classmethod 162 def get_available_settings( 163 cls, sessiontype: type[ba.Session] 164 ) -> list[ba.Setting]: 165 """Return a list of settings relevant to this game type when 166 running under the provided session type. 167 """ 168 del sessiontype # Unused arg. 169 return [] if cls.available_settings is None else cls.available_settings
Return a list of settings relevant to this game type when running under the provided session type.
171 @classmethod 172 def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]: 173 """ 174 Called by the default ba.GameActivity.create_settings_ui() 175 implementation; should return a list of map names valid 176 for this game-type for the given ba.Session type. 177 """ 178 del sessiontype # Unused arg. 179 return _map.getmaps('melee')
Called by the default ba.GameActivity.create_settings_ui() implementation; should return a list of map names valid for this game-type for the given ba.Session type.
181 @classmethod 182 def get_settings_display_string(cls, config: dict[str, Any]) -> ba.Lstr: 183 """Given a game config dict, return a short description for it. 184 185 This is used when viewing game-lists or showing what game 186 is up next in a series. 187 """ 188 name = cls.get_display_string(config['settings']) 189 190 # In newer configs, map is in settings; it used to be in the 191 # config root. 192 if 'map' in config['settings']: 193 sval = Lstr( 194 value='${NAME} @ ${MAP}', 195 subs=[ 196 ('${NAME}', name), 197 ( 198 '${MAP}', 199 _map.get_map_display_string( 200 _map.get_filtered_map_name( 201 config['settings']['map'] 202 ) 203 ), 204 ), 205 ], 206 ) 207 elif 'map' in config: 208 sval = Lstr( 209 value='${NAME} @ ${MAP}', 210 subs=[ 211 ('${NAME}', name), 212 ( 213 '${MAP}', 214 _map.get_map_display_string( 215 _map.get_filtered_map_name(config['map']) 216 ), 217 ), 218 ], 219 ) 220 else: 221 print('invalid game config - expected map entry under settings') 222 sval = Lstr(value='???') 223 return sval
Given a game config dict, return a short description for it.
This is used when viewing game-lists or showing what game is up next in a series.
225 @classmethod 226 def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool: 227 """Return whether this game supports the provided Session type.""" 228 from ba._multiteamsession import MultiTeamSession 229 230 # By default, games support any versus mode 231 return issubclass(sessiontype, MultiTeamSession)
Return whether this game supports the provided Session type.
The map being used for this game.
Raises a ba.MapNotFoundError if the map does not currently exist.
284 def get_instance_display_string(self) -> ba.Lstr: 285 """Return a name for this particular game instance.""" 286 return self.get_display_string(self.settings_raw)
Return a name for this particular game instance.
289 def get_instance_scoreboard_display_string(self) -> ba.Lstr: 290 """Return a name for this particular game instance. 291 292 This name is used above the game scoreboard in the corner 293 of the screen, so it should be as concise as possible. 294 """ 295 # If we're in a co-op session, use the level name. 296 # FIXME: Should clean this up. 297 try: 298 from ba._coopsession import CoopSession 299 300 if isinstance(self.session, CoopSession): 301 campaign = self.session.campaign 302 assert campaign is not None 303 return campaign.getlevel( 304 self.session.campaign_level_name 305 ).displayname 306 except Exception: 307 print_error('error getting campaign level name') 308 return self.get_instance_display_string()
Return a name for this particular game instance.
This name is used above the game scoreboard in the corner of the screen, so it should be as concise as possible.
310 def get_instance_description(self) -> str | Sequence: 311 """Return a description for this game instance, in English. 312 313 This is shown in the center of the screen below the game name at the 314 start of a game. It should start with a capital letter and end with a 315 period, and can be a bit more verbose than the version returned by 316 get_instance_description_short(). 317 318 Note that translation is applied by looking up the specific returned 319 value as a key, so the number of returned variations should be limited; 320 ideally just one or two. To include arbitrary values in the 321 description, you can return a sequence of values in the following 322 form instead of just a string: 323 324 # This will give us something like 'Score 3 goals.' in English 325 # and can properly translate to 'Anota 3 goles.' in Spanish. 326 # If we just returned the string 'Score 3 Goals' here, there would 327 # have to be a translation entry for each specific number. ew. 328 return ['Score ${ARG1} goals.', self.settings_raw['Score to Win']] 329 330 This way the first string can be consistently translated, with any arg 331 values then substituted into the result. ${ARG1} will be replaced with 332 the first value, ${ARG2} with the second, etc. 333 """ 334 return self.get_description(type(self.session))
Return a description for this game instance, in English.
This is shown in the center of the screen below the game name at the start of a game. It should start with a capital letter and end with a period, and can be a bit more verbose than the version returned by get_instance_description_short().
Note that translation is applied by looking up the specific returned value as a key, so the number of returned variations should be limited; ideally just one or two. To include arbitrary values in the description, you can return a sequence of values in the following form instead of just a string:
This will give us something like 'Score 3 goals.' in English
and can properly translate to 'Anota 3 goles.' in Spanish.
If we just returned the string 'Score 3 Goals' here, there would
have to be a translation entry for each specific number. ew.
return ['Score ${ARG1} goals.', self.settings_raw['Score to Win']]
This way the first string can be consistently translated, with any arg values then substituted into the result. ${ARG1} will be replaced with the first value, ${ARG2} with the second, etc.
336 def get_instance_description_short(self) -> str | Sequence: 337 """Return a short description for this game instance in English. 338 339 This description is used above the game scoreboard in the 340 corner of the screen, so it should be as concise as possible. 341 It should be lowercase and should not contain periods or other 342 punctuation. 343 344 Note that translation is applied by looking up the specific returned 345 value as a key, so the number of returned variations should be limited; 346 ideally just one or two. To include arbitrary values in the 347 description, you can return a sequence of values in the following form 348 instead of just a string: 349 350 # This will give us something like 'score 3 goals' in English 351 # and can properly translate to 'anota 3 goles' in Spanish. 352 # If we just returned the string 'score 3 goals' here, there would 353 # have to be a translation entry for each specific number. ew. 354 return ['score ${ARG1} goals', self.settings_raw['Score to Win']] 355 356 This way the first string can be consistently translated, with any arg 357 values then substituted into the result. ${ARG1} will be replaced 358 with the first value, ${ARG2} with the second, etc. 359 360 """ 361 return ''
Return a short description for this game instance in English.
This description is used above the game scoreboard in the corner of the screen, so it should be as concise as possible. It should be lowercase and should not contain periods or other punctuation.
Note that translation is applied by looking up the specific returned value as a key, so the number of returned variations should be limited; ideally just one or two. To include arbitrary values in the description, you can return a sequence of values in the following form instead of just a string:
This will give us something like 'score 3 goals' in English
and can properly translate to 'anota 3 goles' in Spanish.
If we just returned the string 'score 3 goals' here, there would
have to be a translation entry for each specific number. ew.
return ['score ${ARG1} goals', self.settings_raw['Score to Win']]
This way the first string can be consistently translated, with any arg values then substituted into the result. ${ARG1} will be replaced with the first value, ${ARG2} with the second, etc.
363 def on_transition_in(self) -> None: 364 super().on_transition_in() 365 366 # Make our map. 367 self._map = self._map_type() 368 369 # Give our map a chance to override the music. 370 # (for happy-thoughts and other such themed maps) 371 map_music = self._map_type.get_music_type() 372 music = map_music if map_music is not None else self.default_music 373 374 if music is not None: 375 from ba import _music 376 377 _music.setmusic(music)
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.
379 def on_continue(self) -> None: 380 """ 381 This is called if a game supports and offers a continue and the player 382 accepts. In this case the player should be given an extra life or 383 whatever is relevant to keep the game going. 384 """
This is called if a game supports and offers a continue and the player accepts. In this case the player should be given an extra life or whatever is relevant to keep the game going.
406 def is_waiting_for_continue(self) -> bool: 407 """Returns whether or not this activity is currently waiting for the 408 player to continue (or timeout)""" 409 return self._is_waiting_for_continue
Returns whether or not this activity is currently waiting for the player to continue (or timeout)
411 def continue_or_end_game(self) -> None: 412 """If continues are allowed, prompts the player to purchase a continue 413 and calls either end_game or continue_game depending on the result""" 414 # pylint: disable=too-many-nested-blocks 415 # pylint: disable=cyclic-import 416 from bastd.ui.continues import ContinuesWindow 417 from ba._coopsession import CoopSession 418 from ba._generated.enums import TimeType 419 420 try: 421 if _internal.get_v1_account_misc_read_val('enableContinues', False): 422 session = self.session 423 424 # We only support continuing in non-tournament games. 425 tournament_id = session.tournament_id 426 if tournament_id is None: 427 428 # We currently only support continuing in sequential 429 # co-op campaigns. 430 if isinstance(session, CoopSession): 431 assert session.campaign is not None 432 if session.campaign.sequential: 433 gnode = self.globalsnode 434 435 # Only attempt this if we're not currently paused 436 # and there appears to be no UI. 437 if ( 438 not gnode.paused 439 and not _ba.app.ui.has_main_menu_window() 440 ): 441 self._is_waiting_for_continue = True 442 with _ba.Context('ui'): 443 _ba.timer( 444 0.5, 445 lambda: ContinuesWindow( 446 self, 447 self._continue_cost, 448 continue_call=WeakCall( 449 self._continue_choice, True 450 ), 451 cancel_call=WeakCall( 452 self._continue_choice, False 453 ), 454 ), 455 timetype=TimeType.REAL, 456 ) 457 return 458 459 except Exception: 460 print_exception('Error handling continues.') 461 462 self.end_game()
If continues are allowed, prompts the player to purchase a continue and calls either end_game or continue_game depending on the result
464 def on_begin(self) -> None: 465 from ba._analytics import game_begin_analytics 466 467 super().on_begin() 468 469 game_begin_analytics() 470 471 # We don't do this in on_transition_in because it may depend on 472 # players/teams which aren't available until now. 473 _ba.timer(0.001, self._show_scoreboard_info) 474 _ba.timer(1.0, self._show_info) 475 _ba.timer(2.5, self._show_tip) 476 477 # Store some basic info about players present at start time. 478 self.initialplayerinfos = [ 479 PlayerInfo(name=p.getname(full=True), character=p.character) 480 for p in self.players 481 ] 482 483 # Sort this by name so high score lists/etc will be consistent 484 # regardless of player join order. 485 self.initialplayerinfos.sort(key=lambda x: x.name) 486 487 # If this is a tournament, query info about it such as how much 488 # time is left. 489 tournament_id = self.session.tournament_id 490 if tournament_id is not None: 491 _internal.tournament_query( 492 args={ 493 'tournamentIDs': [tournament_id], 494 'source': 'in-game time remaining query', 495 }, 496 callback=WeakCall(self._on_tournament_query_response), 497 )
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.
511 def on_player_join(self, player: PlayerType) -> None: 512 super().on_player_join(player) 513 514 # By default, just spawn a dude. 515 self.spawn_player(player)
Called when a new ba.Player has joined the Activity.
(including the initial set of Players)
517 def handlemessage(self, msg: Any) -> Any: 518 if isinstance(msg, PlayerDiedMessage): 519 # pylint: disable=cyclic-import 520 from bastd.actor.spaz import Spaz 521 522 player = msg.getplayer(self.playertype) 523 killer = msg.getkillerplayer(self.playertype) 524 525 # Inform our stats of the demise. 526 self.stats.player_was_killed( 527 player, killed=msg.killed, killer=killer 528 ) 529 530 # Award the killer points if he's on a different team. 531 # FIXME: This should not be linked to Spaz actors. 532 # (should move get_death_points to Actor or make it a message) 533 if killer and killer.team is not player.team: 534 assert isinstance(killer.actor, Spaz) 535 pts, importance = killer.actor.get_death_points(msg.how) 536 if not self.has_ended(): 537 self.stats.player_scored( 538 killer, 539 pts, 540 kill=True, 541 victim_player=player, 542 importance=importance, 543 showpoints=self.show_kill_points, 544 ) 545 else: 546 return super().handlemessage(msg) 547 return None
General message handling; can be passed any message object.
804 def end( 805 self, results: Any = None, delay: float = 0.0, force: bool = False 806 ) -> None: 807 from ba._gameresults import GameResults 808 809 # If results is a standard team-game-results, associate it with us 810 # so it can grab our score prefs. 811 if isinstance(results, GameResults): 812 results.set_game(self) 813 814 # If we had a standard time-limit that had not expired, stop it so 815 # it doesnt tick annoyingly. 816 if ( 817 self._standard_time_limit_time is not None 818 and self._standard_time_limit_time > 0 819 ): 820 self._standard_time_limit_timer = None 821 self._standard_time_limit_text = None 822 823 # Ditto with tournament time limits. 824 if ( 825 self._tournament_time_limit is not None 826 and self._tournament_time_limit > 0 827 ): 828 self._tournament_time_limit_timer = None 829 self._tournament_time_limit_text = None 830 self._tournament_time_limit_title_text = None 831 832 super().end(results, delay, force)
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.
834 def end_game(self) -> None: 835 """Tell the game to wrap up and call ba.Activity.end() immediately. 836 837 This method should be overridden by subclasses. A game should always 838 be prepared to end and deliver results, even if there is no 'winner' 839 yet; this way things like the standard time-limit 840 (ba.GameActivity.setup_standard_time_limit()) will work with the game. 841 """ 842 print( 843 'WARNING: default end_game() implementation called;' 844 ' your game should override this.' 845 )
Tell the game to wrap up and call ba.Activity.end() immediately.
This method should be overridden by subclasses. A game should always be prepared to end and deliver results, even if there is no 'winner' yet; this way things like the standard time-limit (ba.GameActivity.setup_standard_time_limit()) will work with the game.
847 def respawn_player( 848 self, player: PlayerType, respawn_time: float | None = None 849 ) -> None: 850 """ 851 Given a ba.Player, sets up a standard respawn timer, 852 along with the standard counter display, etc. 853 At the end of the respawn period spawn_player() will 854 be called if the Player still exists. 855 An explicit 'respawn_time' can optionally be provided 856 (in seconds). 857 """ 858 # pylint: disable=cyclic-import 859 860 assert player 861 if respawn_time is None: 862 teamsize = len(player.team.players) 863 if teamsize == 1: 864 respawn_time = 3.0 865 elif teamsize == 2: 866 respawn_time = 5.0 867 elif teamsize == 3: 868 respawn_time = 6.0 869 else: 870 respawn_time = 7.0 871 872 # If this standard setting is present, factor it in. 873 if 'Respawn Times' in self.settings_raw: 874 respawn_time *= self.settings_raw['Respawn Times'] 875 876 # We want whole seconds. 877 assert respawn_time is not None 878 respawn_time = round(max(1.0, respawn_time), 0) 879 880 if player.actor and not self.has_ended(): 881 from bastd.actor.respawnicon import RespawnIcon 882 883 player.customdata['respawn_timer'] = _ba.Timer( 884 respawn_time, WeakCall(self.spawn_player_if_exists, player) 885 ) 886 player.customdata['respawn_icon'] = RespawnIcon( 887 player, respawn_time 888 )
Given a ba.Player, sets up a standard respawn timer, along with the standard counter display, etc. At the end of the respawn period spawn_player() will be called if the Player still exists. An explicit 'respawn_time' can optionally be provided (in seconds).
890 def spawn_player_if_exists(self, player: PlayerType) -> None: 891 """ 892 A utility method which calls self.spawn_player() *only* if the 893 ba.Player provided still exists; handy for use in timers and whatnot. 894 895 There is no need to override this; just override spawn_player(). 896 """ 897 if player: 898 self.spawn_player(player)
A utility method which calls self.spawn_player() only if the ba.Player provided still exists; handy for use in timers and whatnot.
There is no need to override this; just override spawn_player().
900 def spawn_player(self, player: PlayerType) -> ba.Actor: 901 """Spawn *something* for the provided ba.Player. 902 903 The default implementation simply calls spawn_player_spaz(). 904 """ 905 assert player # Dead references should never be passed as args. 906 907 return self.spawn_player_spaz(player)
Spawn something for the provided ba.Player.
The default implementation simply calls spawn_player_spaz().
909 def spawn_player_spaz( 910 self, 911 player: PlayerType, 912 position: Sequence[float] = (0, 0, 0), 913 angle: float | None = None, 914 ) -> PlayerSpaz: 915 """Create and wire up a ba.PlayerSpaz for the provided ba.Player.""" 916 # pylint: disable=too-many-locals 917 # pylint: disable=cyclic-import 918 from ba import _math 919 from ba._gameutils import animate 920 from ba._coopsession import CoopSession 921 from bastd.actor.playerspaz import PlayerSpaz 922 923 name = player.getname() 924 color = player.color 925 highlight = player.highlight 926 927 playerspaztype = getattr(player, 'playerspaztype', PlayerSpaz) 928 if not issubclass(playerspaztype, PlayerSpaz): 929 playerspaztype = PlayerSpaz 930 931 light_color = _math.normalized_color(color) 932 display_color = _ba.safecolor(color, target_intensity=0.75) 933 spaz = playerspaztype( 934 color=color, 935 highlight=highlight, 936 character=player.character, 937 player=player, 938 ) 939 940 player.actor = spaz 941 assert spaz.node 942 943 # If this is co-op and we're on Courtyard or Runaround, add the 944 # material that allows us to collide with the player-walls. 945 # FIXME: Need to generalize this. 946 if isinstance(self.session, CoopSession) and self.map.getname() in [ 947 'Courtyard', 948 'Tower D', 949 ]: 950 mat = self.map.preloaddata['collide_with_wall_material'] 951 assert isinstance(spaz.node.materials, tuple) 952 assert isinstance(spaz.node.roller_materials, tuple) 953 spaz.node.materials += (mat,) 954 spaz.node.roller_materials += (mat,) 955 956 spaz.node.name = name 957 spaz.node.name_color = display_color 958 spaz.connect_controls_to_player() 959 960 # Move to the stand position and add a flash of light. 961 spaz.handlemessage( 962 StandMessage( 963 position, angle if angle is not None else random.uniform(0, 360) 964 ) 965 ) 966 _ba.playsound(self._spawn_sound, 1, position=spaz.node.position) 967 light = _ba.newnode('light', attrs={'color': light_color}) 968 spaz.node.connectattr('position', light, 'position') 969 animate(light, 'intensity', {0: 0, 0.25: 1, 0.5: 0}) 970 _ba.timer(0.5, light.delete) 971 return spaz
Create and wire up a ba.PlayerSpaz for the provided ba.Player.
973 def setup_standard_powerup_drops(self, enable_tnt: bool = True) -> None: 974 """Create standard powerup drops for the current map.""" 975 # pylint: disable=cyclic-import 976 from bastd.actor.powerupbox import DEFAULT_POWERUP_INTERVAL 977 978 self._powerup_drop_timer = _ba.Timer( 979 DEFAULT_POWERUP_INTERVAL, 980 WeakCall(self._standard_drop_powerups), 981 repeat=True, 982 ) 983 self._standard_drop_powerups() 984 if enable_tnt: 985 self._tnt_spawners = {} 986 self._setup_standard_tnt_drops()
Create standard powerup drops for the current map.
1016 def setup_standard_time_limit(self, duration: float) -> None: 1017 """ 1018 Create a standard game time-limit given the provided 1019 duration in seconds. 1020 This will be displayed at the top of the screen. 1021 If the time-limit expires, end_game() will be called. 1022 """ 1023 from ba._nodeactor import NodeActor 1024 1025 if duration <= 0.0: 1026 return 1027 self._standard_time_limit_time = int(duration) 1028 self._standard_time_limit_timer = _ba.Timer( 1029 1.0, WeakCall(self._standard_time_limit_tick), repeat=True 1030 ) 1031 self._standard_time_limit_text = NodeActor( 1032 _ba.newnode( 1033 'text', 1034 attrs={ 1035 'v_attach': 'top', 1036 'h_attach': 'center', 1037 'h_align': 'left', 1038 'color': (1.0, 1.0, 1.0, 0.5), 1039 'position': (-25, -30), 1040 'flatness': 1.0, 1041 'scale': 0.9, 1042 }, 1043 ) 1044 ) 1045 self._standard_time_limit_text_input = NodeActor( 1046 _ba.newnode( 1047 'timedisplay', attrs={'time2': duration * 1000, 'timemin': 0} 1048 ) 1049 ) 1050 self.globalsnode.connectattr( 1051 'time', self._standard_time_limit_text_input.node, 'time1' 1052 ) 1053 assert self._standard_time_limit_text_input.node 1054 assert self._standard_time_limit_text.node 1055 self._standard_time_limit_text_input.node.connectattr( 1056 'output', self._standard_time_limit_text.node, 'text' 1057 )
Create a standard game time-limit given the provided duration in seconds. This will be displayed at the top of the screen. If the time-limit expires, end_game() will be called.
1238 def show_zoom_message( 1239 self, 1240 message: ba.Lstr, 1241 color: Sequence[float] = (0.9, 0.4, 0.0), 1242 scale: float = 0.8, 1243 duration: float = 2.0, 1244 trail: bool = False, 1245 ) -> None: 1246 """Zooming text used to announce game names and winners.""" 1247 # pylint: disable=cyclic-import 1248 from bastd.actor.zoomtext import ZoomText 1249 1250 # Reserve a spot on the screen (in case we get multiple of these so 1251 # they don't overlap). 1252 i = 0 1253 cur_time = _ba.time() 1254 while True: 1255 if ( 1256 i not in self._zoom_message_times 1257 or self._zoom_message_times[i] < cur_time 1258 ): 1259 self._zoom_message_times[i] = cur_time + duration 1260 break 1261 i += 1 1262 ZoomText( 1263 message, 1264 lifespan=duration, 1265 jitter=2.0, 1266 position=(0, 200 - i * 100), 1267 scale=scale, 1268 maxwidth=800, 1269 trail=trail, 1270 color=color, 1271 ).autoretain()
Zooming text used to announce game names and winners.
Inherited Members
- Activity
- settings_raw
- teams
- players
- announce_player_deaths
- is_joining_activity
- use_fixed_vr_overlay
- slow_motion
- inherits_slow_motion
- inherits_music
- inherits_vr_camera_offset
- inherits_vr_overlay_center
- inherits_tint
- allow_mid_activity_joins
- transition_time
- can_show_ad_on_death
- globalsnode
- stats
- on_expire
- customdata
- expired
- playertype
- teamtype
- retain_actor
- add_actor_weak_ref
- session
- on_player_leave
- on_team_join
- on_team_leave
- on_transition_out
- has_transitioned_in
- has_begun
- has_ended
- is_transitioning_out
- transition_out
- create_player
- create_team
28class GameResults: 29 """ 30 Results for a completed game. 31 32 Category: **Gameplay Classes** 33 34 Upon completion, a game should fill one of these out and pass it to its 35 ba.Activity.end call. 36 """ 37 38 def __init__(self) -> None: 39 self._game_set = False 40 self._scores: dict[ 41 int, tuple[weakref.ref[ba.SessionTeam], int | None] 42 ] = {} 43 self._sessionteams: list[weakref.ref[ba.SessionTeam]] | None = None 44 self._playerinfos: list[ba.PlayerInfo] | None = None 45 self._lower_is_better: bool | None = None 46 self._score_label: str | None = None 47 self._none_is_winner: bool | None = None 48 self._scoretype: ba.ScoreType | None = None 49 50 def set_game(self, game: ba.GameActivity) -> None: 51 """Set the game instance these results are applying to.""" 52 if self._game_set: 53 raise RuntimeError('Game set twice for GameResults.') 54 self._game_set = True 55 self._sessionteams = [ 56 weakref.ref(team.sessionteam) for team in game.teams 57 ] 58 scoreconfig = game.getscoreconfig() 59 self._playerinfos = copy.deepcopy(game.initialplayerinfos) 60 self._lower_is_better = scoreconfig.lower_is_better 61 self._score_label = scoreconfig.label 62 self._none_is_winner = scoreconfig.none_is_winner 63 self._scoretype = scoreconfig.scoretype 64 65 def set_team_score(self, team: ba.Team, score: int | None) -> None: 66 """Set the score for a given team. 67 68 This can be a number or None. 69 (see the none_is_winner arg in the constructor) 70 """ 71 assert isinstance(team, Team) 72 sessionteam = team.sessionteam 73 self._scores[sessionteam.id] = (weakref.ref(sessionteam), score) 74 75 def get_sessionteam_score(self, sessionteam: ba.SessionTeam) -> int | None: 76 """Return the score for a given ba.SessionTeam.""" 77 assert isinstance(sessionteam, SessionTeam) 78 for score in list(self._scores.values()): 79 if score[0]() is sessionteam: 80 return score[1] 81 82 # If we have no score value, assume None. 83 return None 84 85 @property 86 def sessionteams(self) -> list[ba.SessionTeam]: 87 """Return all ba.SessionTeams in the results.""" 88 if not self._game_set: 89 raise RuntimeError("Can't get teams until game is set.") 90 teams = [] 91 assert self._sessionteams is not None 92 for team_ref in self._sessionteams: 93 team = team_ref() 94 if team is not None: 95 teams.append(team) 96 return teams 97 98 def has_score_for_sessionteam(self, sessionteam: ba.SessionTeam) -> bool: 99 """Return whether there is a score for a given session-team.""" 100 return any(s[0]() is sessionteam for s in self._scores.values()) 101 102 def get_sessionteam_score_str(self, sessionteam: ba.SessionTeam) -> ba.Lstr: 103 """Return the score for the given session-team as an Lstr. 104 105 (properly formatted for the score type.) 106 """ 107 from ba._gameutils import timestring 108 from ba._language import Lstr 109 from ba._generated.enums import TimeFormat 110 from ba._score import ScoreType 111 112 if not self._game_set: 113 raise RuntimeError("Can't get team-score-str until game is set.") 114 for score in list(self._scores.values()): 115 if score[0]() is sessionteam: 116 if score[1] is None: 117 return Lstr(value='-') 118 if self._scoretype is ScoreType.SECONDS: 119 return timestring( 120 score[1] * 1000, 121 centi=False, 122 timeformat=TimeFormat.MILLISECONDS, 123 ) 124 if self._scoretype is ScoreType.MILLISECONDS: 125 return timestring( 126 score[1], centi=True, timeformat=TimeFormat.MILLISECONDS 127 ) 128 return Lstr(value=str(score[1])) 129 return Lstr(value='-') 130 131 @property 132 def playerinfos(self) -> list[ba.PlayerInfo]: 133 """Get info about the players represented by the results.""" 134 if not self._game_set: 135 raise RuntimeError("Can't get player-info until game is set.") 136 assert self._playerinfos is not None 137 return self._playerinfos 138 139 @property 140 def scoretype(self) -> ba.ScoreType: 141 """The type of score.""" 142 if not self._game_set: 143 raise RuntimeError("Can't get score-type until game is set.") 144 assert self._scoretype is not None 145 return self._scoretype 146 147 @property 148 def score_label(self) -> str: 149 """The label associated with scores ('points', etc).""" 150 if not self._game_set: 151 raise RuntimeError("Can't get score-label until game is set.") 152 assert self._score_label is not None 153 return self._score_label 154 155 @property 156 def lower_is_better(self) -> bool: 157 """Whether lower scores are better.""" 158 if not self._game_set: 159 raise RuntimeError("Can't get lower-is-better until game is set.") 160 assert self._lower_is_better is not None 161 return self._lower_is_better 162 163 @property 164 def winning_sessionteam(self) -> ba.SessionTeam | None: 165 """The winning ba.SessionTeam if there is exactly one, or else None.""" 166 if not self._game_set: 167 raise RuntimeError("Can't get winners until game is set.") 168 winners = self.winnergroups 169 if winners and len(winners[0].teams) == 1: 170 return winners[0].teams[0] 171 return None 172 173 @property 174 def winnergroups(self) -> list[WinnerGroup]: 175 """Get an ordered list of winner groups.""" 176 if not self._game_set: 177 raise RuntimeError("Can't get winners until game is set.") 178 179 # Group by best scoring teams. 180 winners: dict[int, list[ba.SessionTeam]] = {} 181 scores = [ 182 score 183 for score in self._scores.values() 184 if score[0]() is not None and score[1] is not None 185 ] 186 for score in scores: 187 assert score[1] is not None 188 sval = winners.setdefault(score[1], []) 189 team = score[0]() 190 assert team is not None 191 sval.append(team) 192 results: list[tuple[int | None, list[ba.SessionTeam]]] = list( 193 winners.items() 194 ) 195 results.sort( 196 reverse=not self._lower_is_better, 197 key=lambda x: asserttype(x[0], int), 198 ) 199 200 # Also group the 'None' scores. 201 none_sessionteams: list[ba.SessionTeam] = [] 202 for score in self._scores.values(): 203 scoreteam = score[0]() 204 if scoreteam is not None and score[1] is None: 205 none_sessionteams.append(scoreteam) 206 207 # Add the Nones to the list (either as winners or losers 208 # depending on the rules). 209 if none_sessionteams: 210 nones: list[tuple[int | None, list[ba.SessionTeam]]] = [ 211 (None, none_sessionteams) 212 ] 213 if self._none_is_winner: 214 results = nones + results 215 else: 216 results = results + nones 217 218 return [WinnerGroup(score, team) for score, team in results]
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.
38 def __init__(self) -> None: 39 self._game_set = False 40 self._scores: dict[ 41 int, tuple[weakref.ref[ba.SessionTeam], int | None] 42 ] = {} 43 self._sessionteams: list[weakref.ref[ba.SessionTeam]] | None = None 44 self._playerinfos: list[ba.PlayerInfo] | None = None 45 self._lower_is_better: bool | None = None 46 self._score_label: str | None = None 47 self._none_is_winner: bool | None = None 48 self._scoretype: ba.ScoreType | None = None
50 def set_game(self, game: ba.GameActivity) -> None: 51 """Set the game instance these results are applying to.""" 52 if self._game_set: 53 raise RuntimeError('Game set twice for GameResults.') 54 self._game_set = True 55 self._sessionteams = [ 56 weakref.ref(team.sessionteam) for team in game.teams 57 ] 58 scoreconfig = game.getscoreconfig() 59 self._playerinfos = copy.deepcopy(game.initialplayerinfos) 60 self._lower_is_better = scoreconfig.lower_is_better 61 self._score_label = scoreconfig.label 62 self._none_is_winner = scoreconfig.none_is_winner 63 self._scoretype = scoreconfig.scoretype
Set the game instance these results are applying to.
65 def set_team_score(self, team: ba.Team, score: int | None) -> None: 66 """Set the score for a given team. 67 68 This can be a number or None. 69 (see the none_is_winner arg in the constructor) 70 """ 71 assert isinstance(team, Team) 72 sessionteam = team.sessionteam 73 self._scores[sessionteam.id] = (weakref.ref(sessionteam), score)
Set the score for a given team.
This can be a number or None. (see the none_is_winner arg in the constructor)
75 def get_sessionteam_score(self, sessionteam: ba.SessionTeam) -> int | None: 76 """Return the score for a given ba.SessionTeam.""" 77 assert isinstance(sessionteam, SessionTeam) 78 for score in list(self._scores.values()): 79 if score[0]() is sessionteam: 80 return score[1] 81 82 # If we have no score value, assume None. 83 return None
Return the score for a given ba.SessionTeam.
98 def has_score_for_sessionteam(self, sessionteam: ba.SessionTeam) -> bool: 99 """Return whether there is a score for a given session-team.""" 100 return any(s[0]() is sessionteam for s in self._scores.values())
Return whether there is a score for a given session-team.
102 def get_sessionteam_score_str(self, sessionteam: ba.SessionTeam) -> ba.Lstr: 103 """Return the score for the given session-team as an Lstr. 104 105 (properly formatted for the score type.) 106 """ 107 from ba._gameutils import timestring 108 from ba._language import Lstr 109 from ba._generated.enums import TimeFormat 110 from ba._score import ScoreType 111 112 if not self._game_set: 113 raise RuntimeError("Can't get team-score-str until game is set.") 114 for score in list(self._scores.values()): 115 if score[0]() is sessionteam: 116 if score[1] is None: 117 return Lstr(value='-') 118 if self._scoretype is ScoreType.SECONDS: 119 return timestring( 120 score[1] * 1000, 121 centi=False, 122 timeformat=TimeFormat.MILLISECONDS, 123 ) 124 if self._scoretype is ScoreType.MILLISECONDS: 125 return timestring( 126 score[1], centi=True, timeformat=TimeFormat.MILLISECONDS 127 ) 128 return Lstr(value=str(score[1])) 129 return Lstr(value='-')
Return the score for the given session-team as an Lstr.
(properly formatted for the score type.)
The winning ba.SessionTeam if there is exactly one, or else None.
29@dataclass 30class GameTip: 31 """Defines a tip presentable to the user at the start of a game. 32 33 Category: **Gameplay Classes** 34 """ 35 36 text: str 37 icon: ba.Texture | None = None 38 sound: ba.Sound | None = None
Defines a tip presentable to the user at the start of a game.
Category: Gameplay Classes
188def garbage_collect() -> None: 189 """Run an explicit pass of garbage collection. 190 191 category: General Utility Functions 192 193 May also print warnings/etc. if collection takes too long or if 194 uncollectible objects are found (so use this instead of simply 195 gc.collect(). 196 """ 197 gc.collect()
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().
2065def getactivity(doraise: bool = True) -> ba.Activity | None: 2066 """Return the current ba.Activity instance. 2067 2068 Category: **Gameplay Functions** 2069 2070 Note that this is based on context; thus code run in a timer generated 2071 in Activity 'foo' will properly return 'foo' here, even if another 2072 Activity has since been created or is transitioning in. 2073 If there is no current Activity, raises a ba.ActivityNotFoundError. 2074 If doraise is False, None will be returned instead in that case. 2075 """ 2076 return None
Return the current ba.Activity instance.
Category: Gameplay Functions
Note that this is based on context; thus code run in a timer generated in Activity 'foo' will properly return 'foo' here, even if another Activity has since been created or is transitioning in. If there is no current Activity, raises a ba.ActivityNotFoundError. If doraise is False, None will be returned instead in that case.
57def getclass(name: str, subclassof: type[T]) -> type[T]: 58 """Given a full class name such as foo.bar.MyClass, return the class. 59 60 Category: **General Utility Functions** 61 62 The class will be checked to make sure it is a subclass of the provided 63 'subclassof' class, and a TypeError will be raised if not. 64 """ 65 import importlib 66 67 splits = name.split('.') 68 modulename = '.'.join(splits[:-1]) 69 classname = splits[-1] 70 module = importlib.import_module(modulename) 71 cls: type = getattr(module, classname) 72 73 if not issubclass(cls, subclassof): 74 raise TypeError(f'{name} is not a subclass of {subclassof}.') 75 return cls
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.
2079def getcollidemodel(name: str) -> ba.CollideModel: 2080 2081 """Return a collide-model, loading it if necessary. 2082 2083 Category: **Asset Functions** 2084 2085 Collide-models are used in physics calculations for such things as 2086 terrain. 2087 2088 Note that this function returns immediately even if the media has yet 2089 to be loaded. To avoid hitches, instantiate your media objects in 2090 advance of when you will be using them, allowing time for them to load 2091 in the background if necessary. 2092 """ 2093 import ba # pylint: disable=cyclic-import 2094 2095 return ba.CollideModel()
Return a collide-model, loading it if necessary.
Category: Asset Functions
Collide-models are used in physics calculations for such things as terrain.
Note that this function returns immediately even if the media has yet to be loaded. To avoid hitches, instantiate your media objects in advance of when you will be using them, allowing time for them to load in the background if necessary.
68def getcollision() -> Collision: 69 """Return the in-progress collision. 70 71 Category: **Gameplay Functions** 72 """ 73 return _collision
Return the in-progress collision.
Category: Gameplay Functions
2098def getdata(name: str) -> ba.Data: 2099 2100 """Return a data, loading it if necessary. 2101 2102 Category: **Asset Functions** 2103 2104 Note that this function returns immediately even if the media has yet 2105 to be loaded. To avoid hitches, instantiate your media objects in 2106 advance of when you will be using them, allowing time for them to load 2107 in the background if necessary. 2108 """ 2109 import ba # pylint: disable=cyclic-import 2110 2111 return ba.Data()
Return a data, loading it if necessary.
Category: Asset Functions
Note that this function returns immediately even if the media has yet to be loaded. To avoid hitches, instantiate your media objects in advance of when you will be using them, allowing time for them to load in the background if necessary.
57def getmaps(playtype: str) -> list[str]: 58 """Return a list of ba.Map types supporting a playtype str. 59 60 Category: **Asset Functions** 61 62 Maps supporting a given playtype must provide a particular set of 63 features and lend themselves to a certain style of play. 64 65 Play Types: 66 67 'melee' 68 General fighting map. 69 Has one or more 'spawn' locations. 70 71 'team_flag' 72 For games such as Capture The Flag where each team spawns by a flag. 73 Has two or more 'spawn' locations, each with a corresponding 'flag' 74 location (based on index). 75 76 'single_flag' 77 For games such as King of the Hill or Keep Away where multiple teams 78 are fighting over a single flag. 79 Has two or more 'spawn' locations and 1 'flag_default' location. 80 81 'conquest' 82 For games such as Conquest where flags are spread throughout the map 83 - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations. 84 85 'king_of_the_hill' - has 2+ 'spawn' locations, 1+ 'flag_default' locations, 86 and 1+ 'powerup_spawn' locations 87 88 'hockey' 89 For hockey games. 90 Has two 'goal' locations, corresponding 'spawn' locations, and one 91 'flag_default' location (for where puck spawns) 92 93 'football' 94 For football games. 95 Has two 'goal' locations, corresponding 'spawn' locations, and one 96 'flag_default' location (for where flag/ball/etc. spawns) 97 98 'race' 99 For racing games where players much touch each region in order. 100 Has two or more 'race_point' locations. 101 """ 102 return sorted( 103 key 104 for key, val in _ba.app.maps.items() 105 if playtype in val.get_play_types() 106 )
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.
2139def getmodel(name: str) -> ba.Model: 2140 2141 """Return a model, loading it if necessary. 2142 2143 Category: **Asset Functions** 2144 2145 Note that this function returns immediately even if the media has yet 2146 to be loaded. To avoid hitches, instantiate your media objects in 2147 advance of when you will be using them, allowing time for them to load 2148 in the background if necessary. 2149 """ 2150 import ba # pylint: disable=cyclic-import 2151 2152 return ba.Model()
Return a model, loading it if necessary.
Category: Asset Functions
Note that this function returns immediately even if the media has yet to be loaded. To avoid hitches, instantiate your media objects in advance of when you will be using them, allowing time for them to load in the background if necessary.
2155def getnodes() -> list: 2156 2157 """Return all nodes in the current ba.Context. 2158 2159 Category: **Gameplay Functions** 2160 """ 2161 return list()
Return all nodes in the current ba.Context.
Category: Gameplay Functions
2175def getsession(doraise: bool = True) -> ba.Session | None: 2176 """Category: **Gameplay Functions** 2177 2178 Returns the current ba.Session instance. 2179 Note that this is based on context; thus code being run in the UI 2180 context will return the UI context here even if a game Session also 2181 exists, etc. If there is no current Session, an Exception is raised, or 2182 if doraise is False then None is returned instead. 2183 """ 2184 return None
Category: Gameplay Functions
Returns the current ba.Session instance. Note that this is based on context; thus code being run in the UI context will return the UI context here even if a game Session also exists, etc. If there is no current Session, an Exception is raised, or if doraise is False then None is returned instead.
2187def getsound(name: str) -> ba.Sound: 2188 2189 """Return a sound, loading it if necessary. 2190 2191 Category: **Asset Functions** 2192 2193 Note that this function returns immediately even if the media has yet 2194 to be loaded. To avoid hitches, instantiate your media objects in 2195 advance of when you will be using them, allowing time for them to load 2196 in the background if necessary. 2197 """ 2198 import ba # pylint: disable=cyclic-import 2199 2200 return ba.Sound()
Return a sound, loading it if necessary.
Category: Asset Functions
Note that this function returns immediately even if the media has yet to be loaded. To avoid hitches, instantiate your media objects in advance of when you will be using them, allowing time for them to load in the background if necessary.
2203def gettexture(name: str) -> ba.Texture: 2204 2205 """Return a texture, loading it if necessary. 2206 2207 Category: **Asset Functions** 2208 2209 Note that this function returns immediately even if the media has yet 2210 to be loaded. To avoid hitches, instantiate your media objects in 2211 advance of when you will be using them, allowing time for them to load 2212 in the background if necessary. 2213 """ 2214 import ba # pylint: disable=cyclic-import 2215 2216 return ba.Texture()
Return a texture, loading it if necessary.
Category: Asset Functions
Note that this function returns immediately even if the media has yet to be loaded. To avoid hitches, instantiate your media objects in advance of when you will be using them, allowing time for them to load in the background if necessary.
237class HitMessage: 238 """Tells an object it has been hit in some way. 239 240 Category: **Message Classes** 241 242 This is used by punches, explosions, etc to convey 243 their effect to a target. 244 """ 245 246 def __init__( 247 self, 248 srcnode: ba.Node | None = None, 249 pos: Sequence[float] | None = None, 250 velocity: Sequence[float] | None = None, 251 magnitude: float = 1.0, 252 velocity_magnitude: float = 0.0, 253 radius: float = 1.0, 254 source_player: ba.Player | None = None, 255 kick_back: float = 1.0, 256 flat_damage: float | None = None, 257 hit_type: str = 'generic', 258 force_direction: Sequence[float] | None = None, 259 hit_subtype: str = 'default', 260 ): 261 """Instantiate a message with given values.""" 262 263 self.srcnode = srcnode 264 self.pos = pos if pos is not None else _ba.Vec3() 265 self.velocity = velocity if velocity is not None else _ba.Vec3() 266 self.magnitude = magnitude 267 self.velocity_magnitude = velocity_magnitude 268 self.radius = radius 269 270 # We should not be getting passed an invalid ref. 271 assert source_player is None or source_player.exists() 272 self._source_player = source_player 273 self.kick_back = kick_back 274 self.flat_damage = flat_damage 275 self.hit_type = hit_type 276 self.hit_subtype = hit_subtype 277 self.force_direction = ( 278 force_direction if force_direction is not None else velocity 279 ) 280 281 def get_source_player( 282 self, playertype: type[PlayerType] 283 ) -> PlayerType | None: 284 """Return the source-player if one exists and is the provided type.""" 285 player: Any = self._source_player 286 287 # We should not be delivering invalid refs. 288 # (we could translate to None here but technically we are changing 289 # the message delivered which seems wrong) 290 assert player is None or player.exists() 291 292 # Return the player *only* if they're the type given. 293 return player if isinstance(player, playertype) else None
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.
246 def __init__( 247 self, 248 srcnode: ba.Node | None = None, 249 pos: Sequence[float] | None = None, 250 velocity: Sequence[float] | None = None, 251 magnitude: float = 1.0, 252 velocity_magnitude: float = 0.0, 253 radius: float = 1.0, 254 source_player: ba.Player | None = None, 255 kick_back: float = 1.0, 256 flat_damage: float | None = None, 257 hit_type: str = 'generic', 258 force_direction: Sequence[float] | None = None, 259 hit_subtype: str = 'default', 260 ): 261 """Instantiate a message with given values.""" 262 263 self.srcnode = srcnode 264 self.pos = pos if pos is not None else _ba.Vec3() 265 self.velocity = velocity if velocity is not None else _ba.Vec3() 266 self.magnitude = magnitude 267 self.velocity_magnitude = velocity_magnitude 268 self.radius = radius 269 270 # We should not be getting passed an invalid ref. 271 assert source_player is None or source_player.exists() 272 self._source_player = source_player 273 self.kick_back = kick_back 274 self.flat_damage = flat_damage 275 self.hit_type = hit_type 276 self.hit_subtype = hit_subtype 277 self.force_direction = ( 278 force_direction if force_direction is not None else velocity 279 )
Instantiate a message with given values.
281 def get_source_player( 282 self, playertype: type[PlayerType] 283 ) -> PlayerType | None: 284 """Return the source-player if one exists and is the provided type.""" 285 player: Any = self._source_player 286 287 # We should not be delivering invalid refs. 288 # (we could translate to None here but technically we are changing 289 # the message delivered which seems wrong) 290 assert player is None or player.exists() 291 292 # Return the player *only* if they're the type given. 293 return player if isinstance(player, playertype) else None
Return the source-player if one exists and is the provided type.
2282def hscrollwidget( 2283 edit: ba.Widget | None = None, 2284 parent: ba.Widget | None = None, 2285 size: Sequence[float] | None = None, 2286 position: Sequence[float] | None = None, 2287 background: bool | None = None, 2288 selected_child: ba.Widget | None = None, 2289 capture_arrows: bool | None = None, 2290 on_select_call: Callable[[], None] | None = None, 2291 center_small_content: bool | None = None, 2292 color: Sequence[float] | None = None, 2293 highlight: bool | None = None, 2294 border_opacity: float | None = None, 2295 simple_culling_h: float | None = None, 2296 claims_left_right: bool | None = None, 2297 claims_up_down: bool | None = None, 2298 claims_tab: bool | None = None, 2299) -> ba.Widget: 2300 2301 """Create or edit a horizontal scroll widget. 2302 2303 Category: **User Interface Functions** 2304 2305 Pass a valid existing ba.Widget as 'edit' to modify it; otherwise 2306 a new one is created and returned. Arguments that are not set to None 2307 are applied to the Widget. 2308 """ 2309 import ba # pylint: disable=cyclic-import 2310 2311 return ba.Widget()
Create or edit a horizontal scroll widget.
Category: User Interface Functions
Pass a valid existing ba.Widget as 'edit' to modify it; otherwise a new one is created and returned. Arguments that are not set to None are applied to the Widget.
2314def imagewidget( 2315 edit: ba.Widget | None = None, 2316 parent: ba.Widget | None = None, 2317 size: Sequence[float] | None = None, 2318 position: Sequence[float] | None = None, 2319 color: Sequence[float] | None = None, 2320 texture: ba.Texture | None = None, 2321 opacity: float | None = None, 2322 model_transparent: ba.Model | None = None, 2323 model_opaque: ba.Model | None = None, 2324 has_alpha_channel: bool = True, 2325 tint_texture: ba.Texture | None = None, 2326 tint_color: Sequence[float] | None = None, 2327 transition_delay: float | None = None, 2328 draw_controller: ba.Widget | None = None, 2329 tint2_color: Sequence[float] | None = None, 2330 tilt_scale: float | None = None, 2331 mask_texture: ba.Texture | None = None, 2332 radial_amount: float | None = None, 2333) -> ba.Widget: 2334 2335 """Create or edit an image widget. 2336 2337 Category: **User Interface Functions** 2338 2339 Pass a valid existing ba.Widget as 'edit' to modify it; otherwise 2340 a new one is created and returned. Arguments that are not set to None 2341 are applied to the Widget. 2342 """ 2343 import ba # pylint: disable=cyclic-import 2344 2345 return ba.Widget()
Create or edit an image widget.
Category: User Interface Functions
Pass a valid existing ba.Widget as 'edit' to modify it; otherwise a new one is created and returned. Arguments that are not set to None are applied to the Widget.
197@dataclass 198class ImpactDamageMessage: 199 """Tells an object that it has been jarred violently. 200 201 Category: **Message Classes** 202 """ 203 204 intensity: float 205 """The intensity of the impact."""
Tells an object that it has been jarred violently.
Category: Message Classes
228class InputDevice: 229 230 """An input-device such as a gamepad, touchscreen, or keyboard. 231 232 Category: **Gameplay Classes** 233 """ 234 235 allows_configuring: bool 236 237 """Whether the input-device can be configured.""" 238 239 has_meaningful_button_names: bool 240 241 """Whether button names returned by this instance match labels 242 on the actual device. (Can be used to determine whether to show 243 them in controls-overlays, etc.).""" 244 245 player: ba.SessionPlayer | None 246 247 """The player associated with this input device.""" 248 249 client_id: int 250 251 """The numeric client-id this device is associated with. 252 This is only meaningful for remote client inputs; for 253 all local devices this will be -1.""" 254 255 name: str 256 257 """The name of the device.""" 258 259 unique_identifier: str 260 261 """A string that can be used to persistently identify the device, 262 even among other devices of the same type. Used for saving 263 prefs, etc.""" 264 265 id: int 266 267 """The unique numeric id of this device.""" 268 269 instance_number: int 270 271 """The number of this device among devices of the same type.""" 272 273 is_controller_app: bool 274 275 """Whether this input-device represents a locally-connected 276 controller-app.""" 277 278 is_remote_client: bool 279 280 """Whether this input-device represents a remotely-connected 281 client.""" 282 283 def exists(self) -> bool: 284 285 """Return whether the underlying device for this object is 286 still present. 287 """ 288 return bool() 289 290 def get_axis_name(self, axis_id: int) -> str: 291 292 """Given an axis ID, return the name of the axis on this device. 293 294 Can return an empty string if the value is not meaningful to humans. 295 """ 296 return str() 297 298 def get_button_name(self, button_id: int) -> ba.Lstr: 299 300 """Given a button ID, return a human-readable name for that key/button. 301 302 Can return an empty string if the value is not meaningful to humans. 303 """ 304 import ba # pylint: disable=cyclic-import 305 306 return ba.Lstr(value='') 307 308 def get_default_player_name(self) -> str: 309 310 """(internal) 311 312 Returns the default player name for this device. (used for the 'random' 313 profile) 314 """ 315 return str() 316 317 def get_player_profiles(self) -> dict: 318 319 """(internal)""" 320 return dict() 321 322 def get_v1_account_name(self, full: bool) -> str: 323 324 """Returns the account name associated with this device. 325 326 (can be used to get account names for remote players) 327 """ 328 return str() 329 330 def is_connected_to_remote_player(self) -> bool: 331 332 """(internal)""" 333 return bool() 334 335 def remove_remote_player_from_game(self) -> None: 336 337 """(internal)""" 338 return None
An input-device such as a gamepad, touchscreen, or keyboard.
Category: Gameplay Classes
The numeric client-id this device is associated with. This is only meaningful for remote client inputs; for all local devices this will be -1.
A string that can be used to persistently identify the device, even among other devices of the same type. Used for saving prefs, etc.
283 def exists(self) -> bool: 284 285 """Return whether the underlying device for this object is 286 still present. 287 """ 288 return bool()
Return whether the underlying device for this object is still present.
290 def get_axis_name(self, axis_id: int) -> str: 291 292 """Given an axis ID, return the name of the axis on this device. 293 294 Can return an empty string if the value is not meaningful to humans. 295 """ 296 return str()
Given an axis ID, return the name of the axis on this device.
Can return an empty string if the value is not meaningful to humans.
322 def get_v1_account_name(self, full: bool) -> str: 323 324 """Returns the account name associated with this device. 325 326 (can be used to get account names for remote players) 327 """ 328 return str()
Returns the account name associated with this device.
(can be used to get account names for remote players)
122class InputDeviceNotFoundError(NotFoundError): 123 """Exception raised when an expected ba.InputDevice does not exist. 124 125 Category: **Exception Classes** 126 """
Exception raised when an expected ba.InputDevice does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
8class InputType(Enum): 9 """Types of input a controller can send to the game. 10 11 Category: Enums 12 13 """ 14 15 UP_DOWN = 2 16 LEFT_RIGHT = 3 17 JUMP_PRESS = 4 18 JUMP_RELEASE = 5 19 PUNCH_PRESS = 6 20 PUNCH_RELEASE = 7 21 BOMB_PRESS = 8 22 BOMB_RELEASE = 9 23 PICK_UP_PRESS = 10 24 PICK_UP_RELEASE = 11 25 RUN = 12 26 FLY_PRESS = 13 27 FLY_RELEASE = 14 28 START_PRESS = 15 29 START_RELEASE = 16 30 HOLD_POSITION_PRESS = 17 31 HOLD_POSITION_RELEASE = 18 32 LEFT_PRESS = 19 33 LEFT_RELEASE = 20 34 RIGHT_PRESS = 21 35 RIGHT_RELEASE = 22 36 UP_PRESS = 23 37 UP_RELEASE = 24 38 DOWN_PRESS = 25 39 DOWN_RELEASE = 26
Types of input a controller can send to the game.
Category: Enums
Inherited Members
- enum.Enum
- name
- value
72@dataclass 73class IntChoiceSetting(ChoiceSetting): 74 """An int setting with multiple choices. 75 76 Category: Settings Classes 77 """ 78 79 default: int 80 choices: list[tuple[str, int]]
An int setting with multiple choices.
Category: Settings Classes
36@dataclass 37class IntSetting(Setting): 38 """An integer game setting. 39 40 Category: Settings Classes 41 """ 42 43 default: int 44 min_value: int = 0 45 max_value: int = 9999 46 increment: int = 1
An integer game setting.
Category: Settings Classes
23def is_browser_likely_available() -> bool: 24 """Return whether a browser likely exists on the current device. 25 26 category: General Utility Functions 27 28 If this returns False you may want to avoid calling ba.show_url() 29 with any lengthy addresses. (ba.show_url() will display an address 30 as a string in a window if unable to bring up a browser, but that 31 is only useful for simple URLs.) 32 """ 33 app = _ba.app 34 platform = app.platform 35 touchscreen = _ba.getinputdevice('TouchScreen', '#1', doraise=False) 36 37 # If we're on a vr device or an android device with no touchscreen, 38 # assume no browser. 39 # FIXME: Might not be the case anymore; should make this definable 40 # at the platform level. 41 if app.vr_mode or (platform == 'android' and touchscreen is None): 42 return False 43 44 # Anywhere else assume we've got one. 45 return True
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.)
38def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool: 39 """Return whether a given point is within a given box. 40 41 category: General Utility Functions 42 43 For use with standard def boxes (position|rotate|scale). 44 """ 45 return ( 46 (abs(pnt[0] - box[0]) <= box[6] * 0.5) 47 and (abs(pnt[1] - box[1]) <= box[7] * 0.5) 48 and (abs(pnt[2] - box[2]) <= box[8] * 0.5) 49 )
Return whether a given point is within a given box.
category: General Utility Functions
For use with standard def boxes (position|rotate|scale).
14class Keyboard: 15 """Chars definitions for on-screen keyboard. 16 17 Category: **App Classes** 18 19 Keyboards are discoverable by the meta-tag system 20 and the user can select which one they want to use. 21 On-screen keyboard uses chars from active ba.Keyboard. 22 """ 23 24 name: str 25 """Displays when user selecting this keyboard.""" 26 27 chars: list[tuple[str, ...]] 28 """Used for row/column lengths.""" 29 30 pages: dict[str, tuple[str, ...]] 31 """Extra chars like emojis.""" 32 33 nums: tuple[str, ...] 34 """The 'num' page."""
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.
18class LanguageSubsystem: 19 """Wraps up language related app functionality. 20 21 Category: **App Classes** 22 23 To use this class, access the single instance of it at 'ba.app.lang'. 24 """ 25 26 def __init__(self) -> None: 27 self.language_target: AttrDict | None = None 28 self.language_merged: AttrDict | None = None 29 self.default_language = self._get_default_language() 30 31 def _can_display_language(self, language: str) -> bool: 32 """Tell whether we can display a particular language. 33 34 On some platforms we don't have unicode rendering yet 35 which limits the languages we can draw. 36 """ 37 38 # We don't yet support full unicode display on windows or linux :-(. 39 if ( 40 language 41 in { 42 'Chinese', 43 'ChineseTraditional', 44 'Persian', 45 'Korean', 46 'Arabic', 47 'Hindi', 48 'Vietnamese', 49 'Thai', 50 'Tamil', 51 } 52 and not _ba.can_display_full_unicode() 53 ): 54 return False 55 return True 56 57 @property 58 def locale(self) -> str: 59 """Raw country/language code detected by the game (such as 'en_US'). 60 61 Generally for language-specific code you should look at 62 ba.App.language, which is the language the game is using 63 (which may differ from locale if the user sets a language, etc.) 64 """ 65 env = _ba.env() 66 assert isinstance(env['locale'], str) 67 return env['locale'] 68 69 def _get_default_language(self) -> str: 70 languages = { 71 'ar': 'Arabic', 72 'be': 'Belarussian', 73 'zh': 'Chinese', 74 'hr': 'Croatian', 75 'cs': 'Czech', 76 'da': 'Danish', 77 'nl': 'Dutch', 78 'eo': 'Esperanto', 79 'fil': 'Filipino', 80 'fr': 'French', 81 'de': 'German', 82 'el': 'Greek', 83 'hi': 'Hindi', 84 'hu': 'Hungarian', 85 'id': 'Indonesian', 86 'it': 'Italian', 87 'ko': 'Korean', 88 'ms': 'Malay', 89 'fa': 'Persian', 90 'pl': 'Polish', 91 'pt': 'Portuguese', 92 'ro': 'Romanian', 93 'ru': 'Russian', 94 'sr': 'Serbian', 95 'es': 'Spanish', 96 'sk': 'Slovak', 97 'sv': 'Swedish', 98 'ta': 'Tamil', 99 'th': 'Thai', 100 'tr': 'Turkish', 101 'uk': 'Ukrainian', 102 'vec': 'Venetian', 103 'vi': 'Vietnamese', 104 } 105 106 # Special case for Chinese: map specific variations to traditional. 107 # (otherwise will map to 'Chinese' which is simplified) 108 if self.locale in ('zh_HANT', 'zh_TW'): 109 language = 'ChineseTraditional' 110 else: 111 language = languages.get(self.locale[:2], 'English') 112 if not self._can_display_language(language): 113 language = 'English' 114 return language 115 116 @property 117 def language(self) -> str: 118 """The name of the language the game is running in. 119 120 This can be selected explicitly by the user or may be set 121 automatically based on ba.App.locale or other factors. 122 """ 123 assert isinstance(_ba.app.config, dict) 124 return _ba.app.config.get('Lang', self.default_language) 125 126 @property 127 def available_languages(self) -> list[str]: 128 """A list of all available languages. 129 130 Note that languages that may be present in game assets but which 131 are not displayable on the running version of the game are not 132 included here. 133 """ 134 langs = set() 135 try: 136 names = os.listdir('ba_data/data/languages') 137 names = [n.replace('.json', '').capitalize() for n in names] 138 139 # FIXME: our simple capitalization fails on multi-word names; 140 # should handle this in a better way... 141 for i, name in enumerate(names): 142 if name == 'Chinesetraditional': 143 names[i] = 'ChineseTraditional' 144 except Exception: 145 from ba import _error 146 147 _error.print_exception() 148 names = [] 149 for name in names: 150 if self._can_display_language(name): 151 langs.add(name) 152 return sorted( 153 name for name in names if self._can_display_language(name) 154 ) 155 156 def setlanguage( 157 self, 158 language: str | None, 159 print_change: bool = True, 160 store_to_config: bool = True, 161 ) -> None: 162 """Set the active language used for the game. 163 164 Pass None to use OS default language. 165 """ 166 # pylint: disable=too-many-locals 167 # pylint: disable=too-many-statements 168 # pylint: disable=too-many-branches 169 cfg = _ba.app.config 170 cur_language = cfg.get('Lang', None) 171 172 # Store this in the config if its changing. 173 if language != cur_language and store_to_config: 174 if language is None: 175 if 'Lang' in cfg: 176 del cfg['Lang'] # Clear it out for default. 177 else: 178 cfg['Lang'] = language 179 cfg.commit() 180 switched = True 181 else: 182 switched = False 183 184 with open( 185 'ba_data/data/languages/english.json', encoding='utf-8' 186 ) as infile: 187 lenglishvalues = json.loads(infile.read()) 188 189 # None implies default. 190 if language is None: 191 language = self.default_language 192 try: 193 if language == 'English': 194 lmodvalues = None 195 else: 196 lmodfile = ( 197 'ba_data/data/languages/' + language.lower() + '.json' 198 ) 199 with open(lmodfile, encoding='utf-8') as infile: 200 lmodvalues = json.loads(infile.read()) 201 except Exception: 202 from ba import _error 203 204 _error.print_exception('Exception importing language:', language) 205 _ba.screenmessage( 206 "Error setting language to '" 207 + language 208 + "'; see log for details", 209 color=(1, 0, 0), 210 ) 211 switched = False 212 lmodvalues = None 213 214 # Create an attrdict of *just* our target language. 215 self.language_target = AttrDict() 216 langtarget = self.language_target 217 assert langtarget is not None 218 _add_to_attr_dict( 219 langtarget, lmodvalues if lmodvalues is not None else lenglishvalues 220 ) 221 222 # Create an attrdict of our target language overlaid 223 # on our base (english). 224 languages = [lenglishvalues] 225 if lmodvalues is not None: 226 languages.append(lmodvalues) 227 lfull = AttrDict() 228 for lmod in languages: 229 _add_to_attr_dict(lfull, lmod) 230 self.language_merged = lfull 231 232 # Pass some keys/values in for low level code to use; 233 # start with everything in their 'internal' section. 234 internal_vals = [ 235 v for v in list(lfull['internal'].items()) if isinstance(v[1], str) 236 ] 237 238 # Cherry-pick various other values to include. 239 # (should probably get rid of the 'internal' section 240 # and do everything this way) 241 for value in [ 242 'replayNameDefaultText', 243 'replayWriteErrorText', 244 'replayVersionErrorText', 245 'replayReadErrorText', 246 ]: 247 internal_vals.append((value, lfull[value])) 248 internal_vals.append( 249 ('axisText', lfull['configGamepadWindow']['axisText']) 250 ) 251 internal_vals.append(('buttonText', lfull['buttonText'])) 252 lmerged = self.language_merged 253 assert lmerged is not None 254 random_names = [ 255 n.strip() for n in lmerged['randomPlayerNamesText'].split(',') 256 ] 257 random_names = [n for n in random_names if n != ''] 258 _ba.set_internal_language_keys(internal_vals, random_names) 259 if switched and print_change: 260 _ba.screenmessage( 261 Lstr( 262 resource='languageSetText', 263 subs=[ 264 ('${LANGUAGE}', Lstr(translate=('languages', language))) 265 ], 266 ), 267 color=(0, 1, 0), 268 ) 269 270 def get_resource( 271 self, 272 resource: str, 273 fallback_resource: str | None = None, 274 fallback_value: Any = None, 275 ) -> Any: 276 """Return a translation resource by name. 277 278 DEPRECATED; use ba.Lstr functionality for these purposes. 279 """ 280 try: 281 # If we have no language set, go ahead and set it. 282 if self.language_merged is None: 283 language = self.language 284 try: 285 self.setlanguage( 286 language, print_change=False, store_to_config=False 287 ) 288 except Exception: 289 from ba import _error 290 291 _error.print_exception( 292 'exception setting language to', language 293 ) 294 295 # Try english as a fallback. 296 if language != 'English': 297 print('Resorting to fallback language (English)') 298 try: 299 self.setlanguage( 300 'English', 301 print_change=False, 302 store_to_config=False, 303 ) 304 except Exception: 305 _error.print_exception( 306 'error setting language to english fallback' 307 ) 308 309 # If they provided a fallback_resource value, try the 310 # target-language-only dict first and then fall back to trying the 311 # fallback_resource value in the merged dict. 312 if fallback_resource is not None: 313 try: 314 values = self.language_target 315 splits = resource.split('.') 316 dicts = splits[:-1] 317 key = splits[-1] 318 for dct in dicts: 319 assert values is not None 320 values = values[dct] 321 assert values is not None 322 val = values[key] 323 return val 324 except Exception: 325 # FIXME: Shouldn't we try the fallback resource in the 326 # merged dict AFTER we try the main resource in the 327 # merged dict? 328 try: 329 values = self.language_merged 330 splits = fallback_resource.split('.') 331 dicts = splits[:-1] 332 key = splits[-1] 333 for dct in dicts: 334 assert values is not None 335 values = values[dct] 336 assert values is not None 337 val = values[key] 338 return val 339 340 except Exception: 341 # If we got nothing for fallback_resource, default 342 # to the normal code which checks or primary 343 # value in the merge dict; there's a chance we can 344 # get an english value for it (which we weren't 345 # looking for the first time through). 346 pass 347 348 values = self.language_merged 349 splits = resource.split('.') 350 dicts = splits[:-1] 351 key = splits[-1] 352 for dct in dicts: 353 assert values is not None 354 values = values[dct] 355 assert values is not None 356 val = values[key] 357 return val 358 359 except Exception: 360 # Ok, looks like we couldn't find our main or fallback resource 361 # anywhere. Now if we've been given a fallback value, return it; 362 # otherwise fail. 363 from ba import _error 364 365 if fallback_value is not None: 366 return fallback_value 367 raise _error.NotFoundError( 368 f"Resource not found: '{resource}'" 369 ) from None 370 371 def translate( 372 self, 373 category: str, 374 strval: str, 375 raise_exceptions: bool = False, 376 print_errors: bool = False, 377 ) -> str: 378 """Translate a value (or return the value if no translation available) 379 380 DEPRECATED; use ba.Lstr functionality for these purposes. 381 """ 382 try: 383 translated = self.get_resource('translations')[category][strval] 384 except Exception as exc: 385 if raise_exceptions: 386 raise 387 if print_errors: 388 print( 389 ( 390 'Translate error: category=\'' 391 + category 392 + '\' name=\'' 393 + strval 394 + '\' exc=' 395 + str(exc) 396 + '' 397 ) 398 ) 399 translated = None 400 translated_out: str 401 if translated is None: 402 translated_out = strval 403 else: 404 translated_out = translated 405 assert isinstance(translated_out, str) 406 return translated_out 407 408 def is_custom_unicode_char(self, char: str) -> bool: 409 """Return whether a char is in the custom unicode range we use.""" 410 assert isinstance(char, str) 411 if len(char) != 1: 412 raise ValueError('Invalid Input; must be length 1') 413 return 0xE000 <= ord(char) <= 0xF8FF
Wraps up language related app functionality.
Category: App Classes
To use this class, access the single instance of it at 'ba.app.lang'.
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.)
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.
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.
156 def setlanguage( 157 self, 158 language: str | None, 159 print_change: bool = True, 160 store_to_config: bool = True, 161 ) -> None: 162 """Set the active language used for the game. 163 164 Pass None to use OS default language. 165 """ 166 # pylint: disable=too-many-locals 167 # pylint: disable=too-many-statements 168 # pylint: disable=too-many-branches 169 cfg = _ba.app.config 170 cur_language = cfg.get('Lang', None) 171 172 # Store this in the config if its changing. 173 if language != cur_language and store_to_config: 174 if language is None: 175 if 'Lang' in cfg: 176 del cfg['Lang'] # Clear it out for default. 177 else: 178 cfg['Lang'] = language 179 cfg.commit() 180 switched = True 181 else: 182 switched = False 183 184 with open( 185 'ba_data/data/languages/english.json', encoding='utf-8' 186 ) as infile: 187 lenglishvalues = json.loads(infile.read()) 188 189 # None implies default. 190 if language is None: 191 language = self.default_language 192 try: 193 if language == 'English': 194 lmodvalues = None 195 else: 196 lmodfile = ( 197 'ba_data/data/languages/' + language.lower() + '.json' 198 ) 199 with open(lmodfile, encoding='utf-8') as infile: 200 lmodvalues = json.loads(infile.read()) 201 except Exception: 202 from ba import _error 203 204 _error.print_exception('Exception importing language:', language) 205 _ba.screenmessage( 206 "Error setting language to '" 207 + language 208 + "'; see log for details", 209 color=(1, 0, 0), 210 ) 211 switched = False 212 lmodvalues = None 213 214 # Create an attrdict of *just* our target language. 215 self.language_target = AttrDict() 216 langtarget = self.language_target 217 assert langtarget is not None 218 _add_to_attr_dict( 219 langtarget, lmodvalues if lmodvalues is not None else lenglishvalues 220 ) 221 222 # Create an attrdict of our target language overlaid 223 # on our base (english). 224 languages = [lenglishvalues] 225 if lmodvalues is not None: 226 languages.append(lmodvalues) 227 lfull = AttrDict() 228 for lmod in languages: 229 _add_to_attr_dict(lfull, lmod) 230 self.language_merged = lfull 231 232 # Pass some keys/values in for low level code to use; 233 # start with everything in their 'internal' section. 234 internal_vals = [ 235 v for v in list(lfull['internal'].items()) if isinstance(v[1], str) 236 ] 237 238 # Cherry-pick various other values to include. 239 # (should probably get rid of the 'internal' section 240 # and do everything this way) 241 for value in [ 242 'replayNameDefaultText', 243 'replayWriteErrorText', 244 'replayVersionErrorText', 245 'replayReadErrorText', 246 ]: 247 internal_vals.append((value, lfull[value])) 248 internal_vals.append( 249 ('axisText', lfull['configGamepadWindow']['axisText']) 250 ) 251 internal_vals.append(('buttonText', lfull['buttonText'])) 252 lmerged = self.language_merged 253 assert lmerged is not None 254 random_names = [ 255 n.strip() for n in lmerged['randomPlayerNamesText'].split(',') 256 ] 257 random_names = [n for n in random_names if n != ''] 258 _ba.set_internal_language_keys(internal_vals, random_names) 259 if switched and print_change: 260 _ba.screenmessage( 261 Lstr( 262 resource='languageSetText', 263 subs=[ 264 ('${LANGUAGE}', Lstr(translate=('languages', language))) 265 ], 266 ), 267 color=(0, 1, 0), 268 )
Set the active language used for the game.
Pass None to use OS default language.
270 def get_resource( 271 self, 272 resource: str, 273 fallback_resource: str | None = None, 274 fallback_value: Any = None, 275 ) -> Any: 276 """Return a translation resource by name. 277 278 DEPRECATED; use ba.Lstr functionality for these purposes. 279 """ 280 try: 281 # If we have no language set, go ahead and set it. 282 if self.language_merged is None: 283 language = self.language 284 try: 285 self.setlanguage( 286 language, print_change=False, store_to_config=False 287 ) 288 except Exception: 289 from ba import _error 290 291 _error.print_exception( 292 'exception setting language to', language 293 ) 294 295 # Try english as a fallback. 296 if language != 'English': 297 print('Resorting to fallback language (English)') 298 try: 299 self.setlanguage( 300 'English', 301 print_change=False, 302 store_to_config=False, 303 ) 304 except Exception: 305 _error.print_exception( 306 'error setting language to english fallback' 307 ) 308 309 # If they provided a fallback_resource value, try the 310 # target-language-only dict first and then fall back to trying the 311 # fallback_resource value in the merged dict. 312 if fallback_resource is not None: 313 try: 314 values = self.language_target 315 splits = resource.split('.') 316 dicts = splits[:-1] 317 key = splits[-1] 318 for dct in dicts: 319 assert values is not None 320 values = values[dct] 321 assert values is not None 322 val = values[key] 323 return val 324 except Exception: 325 # FIXME: Shouldn't we try the fallback resource in the 326 # merged dict AFTER we try the main resource in the 327 # merged dict? 328 try: 329 values = self.language_merged 330 splits = fallback_resource.split('.') 331 dicts = splits[:-1] 332 key = splits[-1] 333 for dct in dicts: 334 assert values is not None 335 values = values[dct] 336 assert values is not None 337 val = values[key] 338 return val 339 340 except Exception: 341 # If we got nothing for fallback_resource, default 342 # to the normal code which checks or primary 343 # value in the merge dict; there's a chance we can 344 # get an english value for it (which we weren't 345 # looking for the first time through). 346 pass 347 348 values = self.language_merged 349 splits = resource.split('.') 350 dicts = splits[:-1] 351 key = splits[-1] 352 for dct in dicts: 353 assert values is not None 354 values = values[dct] 355 assert values is not None 356 val = values[key] 357 return val 358 359 except Exception: 360 # Ok, looks like we couldn't find our main or fallback resource 361 # anywhere. Now if we've been given a fallback value, return it; 362 # otherwise fail. 363 from ba import _error 364 365 if fallback_value is not None: 366 return fallback_value 367 raise _error.NotFoundError( 368 f"Resource not found: '{resource}'" 369 ) from None
Return a translation resource by name.
DEPRECATED; use ba.Lstr functionality for these purposes.
371 def translate( 372 self, 373 category: str, 374 strval: str, 375 raise_exceptions: bool = False, 376 print_errors: bool = False, 377 ) -> str: 378 """Translate a value (or return the value if no translation available) 379 380 DEPRECATED; use ba.Lstr functionality for these purposes. 381 """ 382 try: 383 translated = self.get_resource('translations')[category][strval] 384 except Exception as exc: 385 if raise_exceptions: 386 raise 387 if print_errors: 388 print( 389 ( 390 'Translate error: category=\'' 391 + category 392 + '\' name=\'' 393 + strval 394 + '\' exc=' 395 + str(exc) 396 + '' 397 ) 398 ) 399 translated = None 400 translated_out: str 401 if translated is None: 402 translated_out = strval 403 else: 404 translated_out = translated 405 assert isinstance(translated_out, str) 406 return translated_out
Translate a value (or return the value if no translation available)
DEPRECATED; use ba.Lstr functionality for these purposes.
408 def is_custom_unicode_char(self, char: str) -> bool: 409 """Return whether a char is in the custom unicode range we use.""" 410 assert isinstance(char, str) 411 if len(char) != 1: 412 raise ValueError('Invalid Input; must be length 1') 413 return 0xE000 <= ord(char) <= 0xF8FF
Return whether a char is in the custom unicode range we use.
18class Level: 19 """An entry in a ba.Campaign consisting of a name, game type, and settings. 20 21 Category: **Gameplay Classes** 22 """ 23 24 def __init__( 25 self, 26 name: str, 27 gametype: type[ba.GameActivity], 28 settings: dict, 29 preview_texture_name: str, 30 displayname: str | None = None, 31 ): 32 self._name = name 33 self._gametype = gametype 34 self._settings = settings 35 self._preview_texture_name = preview_texture_name 36 self._displayname = displayname 37 self._campaign: weakref.ref[ba.Campaign] | None = None 38 self._index: int | None = None 39 self._score_version_string: str | None = None 40 41 def __repr__(self) -> str: 42 cls = type(self) 43 return f"<{cls.__module__}.{cls.__name__} '{self._name}'>" 44 45 @property 46 def name(self) -> str: 47 """The unique name for this Level.""" 48 return self._name 49 50 def get_settings(self) -> dict[str, Any]: 51 """Returns the settings for this Level.""" 52 settings = copy.deepcopy(self._settings) 53 54 # So the game knows what the level is called. 55 # Hmm; seems hacky; I think we should take this out. 56 settings['name'] = self._name 57 return settings 58 59 @property 60 def preview_texture_name(self) -> str: 61 """The preview texture name for this Level.""" 62 return self._preview_texture_name 63 64 def get_preview_texture(self) -> ba.Texture: 65 """Load/return the preview Texture for this Level.""" 66 return _ba.gettexture(self._preview_texture_name) 67 68 @property 69 def displayname(self) -> ba.Lstr: 70 """The localized name for this Level.""" 71 from ba import _language 72 73 return _language.Lstr( 74 translate=( 75 'coopLevelNames', 76 self._displayname 77 if self._displayname is not None 78 else self._name, 79 ), 80 subs=[ 81 ('${GAME}', self._gametype.get_display_string(self._settings)) 82 ], 83 ) 84 85 @property 86 def gametype(self) -> type[ba.GameActivity]: 87 """The type of game used for this Level.""" 88 return self._gametype 89 90 @property 91 def campaign(self) -> ba.Campaign | None: 92 """The ba.Campaign this Level is associated with, or None.""" 93 return None if self._campaign is None else self._campaign() 94 95 @property 96 def index(self) -> int: 97 """The zero-based index of this Level in its ba.Campaign. 98 99 Access results in a RuntimeError if the Level is not assigned to a 100 Campaign. 101 """ 102 if self._index is None: 103 raise RuntimeError('Level is not part of a Campaign') 104 return self._index 105 106 @property 107 def complete(self) -> bool: 108 """Whether this Level has been completed.""" 109 config = self._get_config_dict() 110 return config.get('Complete', False) 111 112 def set_complete(self, val: bool) -> None: 113 """Set whether or not this level is complete.""" 114 old_val = self.complete 115 assert isinstance(old_val, bool) 116 assert isinstance(val, bool) 117 if val != old_val: 118 config = self._get_config_dict() 119 config['Complete'] = val 120 121 def get_high_scores(self) -> dict: 122 """Return the current high scores for this Level.""" 123 config = self._get_config_dict() 124 high_scores_key = 'High Scores' + self.get_score_version_string() 125 if high_scores_key not in config: 126 return {} 127 return copy.deepcopy(config[high_scores_key]) 128 129 def set_high_scores(self, high_scores: dict) -> None: 130 """Set high scores for this level.""" 131 config = self._get_config_dict() 132 high_scores_key = 'High Scores' + self.get_score_version_string() 133 config[high_scores_key] = high_scores 134 135 def get_score_version_string(self) -> str: 136 """Return the score version string for this Level. 137 138 If a Level's gameplay changes significantly, its version string 139 can be changed to separate its new high score lists/etc. from the old. 140 """ 141 if self._score_version_string is None: 142 scorever = self._gametype.getscoreconfig().version 143 if scorever != '': 144 scorever = ' ' + scorever 145 self._score_version_string = scorever 146 assert self._score_version_string is not None 147 return self._score_version_string 148 149 @property 150 def rating(self) -> float: 151 """The current rating for this Level.""" 152 return self._get_config_dict().get('Rating', 0.0) 153 154 def set_rating(self, rating: float) -> None: 155 """Set a rating for this Level, replacing the old ONLY IF higher.""" 156 old_rating = self.rating 157 config = self._get_config_dict() 158 config['Rating'] = max(old_rating, rating) 159 160 def _get_config_dict(self) -> dict[str, Any]: 161 """Return/create the persistent state dict for this level. 162 163 The referenced dict exists under the game's config dict and 164 can be modified in place.""" 165 campaign = self.campaign 166 if campaign is None: 167 raise RuntimeError('Level is not in a campaign.') 168 configdict = campaign.configdict 169 val: dict[str, Any] = configdict.setdefault( 170 self._name, {'Rating': 0.0, 'Complete': False} 171 ) 172 assert isinstance(val, dict) 173 return val 174 175 def set_campaign(self, campaign: ba.Campaign, index: int) -> None: 176 """For use by ba.Campaign when adding levels to itself. 177 178 (internal)""" 179 self._campaign = weakref.ref(campaign) 180 self._index = index
An entry in a ba.Campaign consisting of a name, game type, and settings.
Category: Gameplay Classes
24 def __init__( 25 self, 26 name: str, 27 gametype: type[ba.GameActivity], 28 settings: dict, 29 preview_texture_name: str, 30 displayname: str | None = None, 31 ): 32 self._name = name 33 self._gametype = gametype 34 self._settings = settings 35 self._preview_texture_name = preview_texture_name 36 self._displayname = displayname 37 self._campaign: weakref.ref[ba.Campaign] | None = None 38 self._index: int | None = None 39 self._score_version_string: str | None = None
50 def get_settings(self) -> dict[str, Any]: 51 """Returns the settings for this Level.""" 52 settings = copy.deepcopy(self._settings) 53 54 # So the game knows what the level is called. 55 # Hmm; seems hacky; I think we should take this out. 56 settings['name'] = self._name 57 return settings
Returns the settings for this Level.
64 def get_preview_texture(self) -> ba.Texture: 65 """Load/return the preview Texture for this Level.""" 66 return _ba.gettexture(self._preview_texture_name)
Load/return the preview Texture for this Level.
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.
112 def set_complete(self, val: bool) -> None: 113 """Set whether or not this level is complete.""" 114 old_val = self.complete 115 assert isinstance(old_val, bool) 116 assert isinstance(val, bool) 117 if val != old_val: 118 config = self._get_config_dict() 119 config['Complete'] = val
Set whether or not this level is complete.
121 def get_high_scores(self) -> dict: 122 """Return the current high scores for this Level.""" 123 config = self._get_config_dict() 124 high_scores_key = 'High Scores' + self.get_score_version_string() 125 if high_scores_key not in config: 126 return {} 127 return copy.deepcopy(config[high_scores_key])
Return the current high scores for this Level.
129 def set_high_scores(self, high_scores: dict) -> None: 130 """Set high scores for this level.""" 131 config = self._get_config_dict() 132 high_scores_key = 'High Scores' + self.get_score_version_string() 133 config[high_scores_key] = high_scores
Set high scores for this level.
135 def get_score_version_string(self) -> str: 136 """Return the score version string for this Level. 137 138 If a Level's gameplay changes significantly, its version string 139 can be changed to separate its new high score lists/etc. from the old. 140 """ 141 if self._score_version_string is None: 142 scorever = self._gametype.getscoreconfig().version 143 if scorever != '': 144 scorever = ' ' + scorever 145 self._score_version_string = scorever 146 assert self._score_version_string is not None 147 return self._score_version_string
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.
154 def set_rating(self, rating: float) -> None: 155 """Set a rating for this Level, replacing the old ONLY IF higher.""" 156 old_rating = self.rating 157 config = self._get_config_dict() 158 config['Rating'] = max(old_rating, rating)
Set a rating for this Level, replacing the old ONLY IF higher.
924class Lobby: 925 """Container for ba.Choosers. 926 927 Category: Gameplay Classes 928 """ 929 930 def __del__(self) -> None: 931 932 # Reset any players that still have a chooser in us. 933 # (should allow the choosers to die). 934 sessionplayers = [ 935 c.sessionplayer for c in self.choosers if c.sessionplayer 936 ] 937 for sessionplayer in sessionplayers: 938 sessionplayer.resetinput() 939 940 def __init__(self) -> None: 941 from ba._team import SessionTeam 942 from ba._coopsession import CoopSession 943 944 session = _ba.getsession() 945 self._use_team_colors = session.use_team_colors 946 if session.use_teams: 947 self._sessionteams = [ 948 weakref.ref(team) for team in session.sessionteams 949 ] 950 else: 951 self._dummy_teams = SessionTeam() 952 self._sessionteams = [weakref.ref(self._dummy_teams)] 953 v_offset = -150 if isinstance(session, CoopSession) else -50 954 self.choosers: list[Chooser] = [] 955 self.base_v_offset = v_offset 956 self.update_positions() 957 self._next_add_team = 0 958 self.character_names_local_unlocked: list[str] = [] 959 self._vpos = 0 960 961 # Grab available profiles. 962 self.reload_profiles() 963 964 self._join_info_text = None 965 966 @property 967 def next_add_team(self) -> int: 968 """(internal)""" 969 return self._next_add_team 970 971 @property 972 def use_team_colors(self) -> bool: 973 """A bool for whether this lobby is using team colors. 974 975 If False, inidividual player colors are used instead. 976 """ 977 return self._use_team_colors 978 979 @property 980 def sessionteams(self) -> list[ba.SessionTeam]: 981 """ba.SessionTeams available in this lobby.""" 982 allteams = [] 983 for tref in self._sessionteams: 984 team = tref() 985 assert team is not None 986 allteams.append(team) 987 return allteams 988 989 def get_choosers(self) -> list[Chooser]: 990 """Return the lobby's current choosers.""" 991 return self.choosers 992 993 def create_join_info(self) -> JoinInfo: 994 """Create a display of on-screen information for joiners. 995 996 (how to switch teams, players, etc.) 997 Intended for use in initial joining-screens. 998 """ 999 return JoinInfo(self) 1000 1001 def reload_profiles(self) -> None: 1002 """Reload available player profiles.""" 1003 # pylint: disable=cyclic-import 1004 from bastd.actor.spazappearance import get_appearances 1005 1006 # We may have gained or lost character names if the user 1007 # bought something; reload these too. 1008 self.character_names_local_unlocked = get_appearances() 1009 self.character_names_local_unlocked.sort(key=lambda x: x.lower()) 1010 1011 # Do any overall prep we need to such as creating account profile. 1012 _ba.app.accounts_v1.ensure_have_account_player_profile() 1013 for chooser in self.choosers: 1014 try: 1015 chooser.reload_profiles() 1016 chooser.update_from_profile() 1017 except Exception: 1018 print_exception('Error reloading profiles.') 1019 1020 def update_positions(self) -> None: 1021 """Update positions for all choosers.""" 1022 self._vpos = -100 + self.base_v_offset 1023 for chooser in self.choosers: 1024 chooser.set_vpos(self._vpos) 1025 chooser.update_position() 1026 self._vpos -= 48 1027 1028 def check_all_ready(self) -> bool: 1029 """Return whether all choosers are marked ready.""" 1030 return all(chooser.ready for chooser in self.choosers) 1031 1032 def add_chooser(self, sessionplayer: ba.SessionPlayer) -> None: 1033 """Add a chooser to the lobby for the provided player.""" 1034 self.choosers.append( 1035 Chooser(vpos=self._vpos, sessionplayer=sessionplayer, lobby=self) 1036 ) 1037 self._next_add_team = (self._next_add_team + 1) % len( 1038 self._sessionteams 1039 ) 1040 self._vpos -= 48 1041 1042 def remove_chooser(self, player: ba.SessionPlayer) -> None: 1043 """Remove a single player's chooser; does not kick them. 1044 1045 This is used when a player enters the game and no longer 1046 needs a chooser.""" 1047 found = False 1048 chooser = None 1049 for chooser in self.choosers: 1050 if chooser.getplayer() is player: 1051 found = True 1052 1053 # Mark it as dead since there could be more 1054 # change-commands/etc coming in still for it; 1055 # want to avoid duplicate player-adds/etc. 1056 chooser.set_dead(True) 1057 self.choosers.remove(chooser) 1058 break 1059 if not found: 1060 print_error(f'remove_chooser did not find player {player}') 1061 elif chooser in self.choosers: 1062 print_error(f'chooser remains after removal for {player}') 1063 self.update_positions() 1064 1065 def remove_all_choosers(self) -> None: 1066 """Remove all choosers without kicking players. 1067 1068 This is called after all players check in and enter a game. 1069 """ 1070 self.choosers = [] 1071 self.update_positions() 1072 1073 def remove_all_choosers_and_kick_players(self) -> None: 1074 """Remove all player choosers and kick attached players.""" 1075 1076 # Copy the list; it can change under us otherwise. 1077 for chooser in list(self.choosers): 1078 if chooser.sessionplayer: 1079 chooser.sessionplayer.remove_from_game() 1080 self.remove_all_choosers()
Container for ba.Choosers.
Category: Gameplay Classes
940 def __init__(self) -> None: 941 from ba._team import SessionTeam 942 from ba._coopsession import CoopSession 943 944 session = _ba.getsession() 945 self._use_team_colors = session.use_team_colors 946 if session.use_teams: 947 self._sessionteams = [ 948 weakref.ref(team) for team in session.sessionteams 949 ] 950 else: 951 self._dummy_teams = SessionTeam() 952 self._sessionteams = [weakref.ref(self._dummy_teams)] 953 v_offset = -150 if isinstance(session, CoopSession) else -50 954 self.choosers: list[Chooser] = [] 955 self.base_v_offset = v_offset 956 self.update_positions() 957 self._next_add_team = 0 958 self.character_names_local_unlocked: list[str] = [] 959 self._vpos = 0 960 961 # Grab available profiles. 962 self.reload_profiles() 963 964 self._join_info_text = None
A bool for whether this lobby is using team colors.
If False, inidividual player colors are used instead.
989 def get_choosers(self) -> list[Chooser]: 990 """Return the lobby's current choosers.""" 991 return self.choosers
Return the lobby's current choosers.
993 def create_join_info(self) -> JoinInfo: 994 """Create a display of on-screen information for joiners. 995 996 (how to switch teams, players, etc.) 997 Intended for use in initial joining-screens. 998 """ 999 return JoinInfo(self)
Create a display of on-screen information for joiners.
(how to switch teams, players, etc.) Intended for use in initial joining-screens.
1001 def reload_profiles(self) -> None: 1002 """Reload available player profiles.""" 1003 # pylint: disable=cyclic-import 1004 from bastd.actor.spazappearance import get_appearances 1005 1006 # We may have gained or lost character names if the user 1007 # bought something; reload these too. 1008 self.character_names_local_unlocked = get_appearances() 1009 self.character_names_local_unlocked.sort(key=lambda x: x.lower()) 1010 1011 # Do any overall prep we need to such as creating account profile. 1012 _ba.app.accounts_v1.ensure_have_account_player_profile() 1013 for chooser in self.choosers: 1014 try: 1015 chooser.reload_profiles() 1016 chooser.update_from_profile() 1017 except Exception: 1018 print_exception('Error reloading profiles.')
Reload available player profiles.
1020 def update_positions(self) -> None: 1021 """Update positions for all choosers.""" 1022 self._vpos = -100 + self.base_v_offset 1023 for chooser in self.choosers: 1024 chooser.set_vpos(self._vpos) 1025 chooser.update_position() 1026 self._vpos -= 48
Update positions for all choosers.
1028 def check_all_ready(self) -> bool: 1029 """Return whether all choosers are marked ready.""" 1030 return all(chooser.ready for chooser in self.choosers)
Return whether all choosers are marked ready.
1032 def add_chooser(self, sessionplayer: ba.SessionPlayer) -> None: 1033 """Add a chooser to the lobby for the provided player.""" 1034 self.choosers.append( 1035 Chooser(vpos=self._vpos, sessionplayer=sessionplayer, lobby=self) 1036 ) 1037 self._next_add_team = (self._next_add_team + 1) % len( 1038 self._sessionteams 1039 ) 1040 self._vpos -= 48
Add a chooser to the lobby for the provided player.
1042 def remove_chooser(self, player: ba.SessionPlayer) -> None: 1043 """Remove a single player's chooser; does not kick them. 1044 1045 This is used when a player enters the game and no longer 1046 needs a chooser.""" 1047 found = False 1048 chooser = None 1049 for chooser in self.choosers: 1050 if chooser.getplayer() is player: 1051 found = True 1052 1053 # Mark it as dead since there could be more 1054 # change-commands/etc coming in still for it; 1055 # want to avoid duplicate player-adds/etc. 1056 chooser.set_dead(True) 1057 self.choosers.remove(chooser) 1058 break 1059 if not found: 1060 print_error(f'remove_chooser did not find player {player}') 1061 elif chooser in self.choosers: 1062 print_error(f'chooser remains after removal for {player}') 1063 self.update_positions()
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.
1065 def remove_all_choosers(self) -> None: 1066 """Remove all choosers without kicking players. 1067 1068 This is called after all players check in and enter a game. 1069 """ 1070 self.choosers = [] 1071 self.update_positions()
Remove all choosers without kicking players.
This is called after all players check in and enter a game.
1073 def remove_all_choosers_and_kick_players(self) -> None: 1074 """Remove all player choosers and kick attached players.""" 1075 1076 # Copy the list; it can change under us otherwise. 1077 for chooser in list(self.choosers): 1078 if chooser.sessionplayer: 1079 chooser.sessionplayer.remove_from_game() 1080 self.remove_all_choosers()
Remove all player choosers and kick attached players.
416class Lstr: 417 """Used to define strings in a language-independent way. 418 419 Category: **General Utility Classes** 420 421 These should be used whenever possible in place of hard-coded strings 422 so that in-game or UI elements show up correctly on all clients in their 423 currently-active language. 424 425 To see available resource keys, look at any of the bs_language_*.py files 426 in the game or the translations pages at legacy.ballistica.net/translate. 427 428 ##### Examples 429 EXAMPLE 1: specify a string from a resource path 430 >>> mynode.text = ba.Lstr(resource='audioSettingsWindow.titleText') 431 432 EXAMPLE 2: specify a translated string via a category and english 433 value; if a translated value is available, it will be used; otherwise 434 the english value will be. To see available translation categories, 435 look under the 'translations' resource section. 436 >>> mynode.text = ba.Lstr(translate=('gameDescriptions', 437 ... 'Defeat all enemies')) 438 439 EXAMPLE 3: specify a raw value and some substitutions. Substitutions 440 can be used with resource and translate modes as well. 441 >>> mynode.text = ba.Lstr(value='${A} / ${B}', 442 ... subs=[('${A}', str(score)), ('${B}', str(total))]) 443 444 EXAMPLE 4: ba.Lstr's can be nested. This example would display the 445 resource at res_a but replace ${NAME} with the value of the 446 resource at res_b 447 >>> mytextnode.text = ba.Lstr( 448 ... resource='res_a', 449 ... subs=[('${NAME}', ba.Lstr(resource='res_b'))]) 450 """ 451 452 # pylint: disable=dangerous-default-value 453 # noinspection PyDefaultArgument 454 @overload 455 def __init__( 456 self, 457 *, 458 resource: str, 459 fallback_resource: str = '', 460 fallback_value: str = '', 461 subs: Sequence[tuple[str, str | Lstr]] = [], 462 ) -> None: 463 """Create an Lstr from a string resource.""" 464 465 # noinspection PyShadowingNames,PyDefaultArgument 466 @overload 467 def __init__( 468 self, 469 *, 470 translate: tuple[str, str], 471 subs: Sequence[tuple[str, str | Lstr]] = [], 472 ) -> None: 473 """Create an Lstr by translating a string in a category.""" 474 475 # noinspection PyDefaultArgument 476 @overload 477 def __init__( 478 self, *, value: str, subs: Sequence[tuple[str, str | Lstr]] = [] 479 ) -> None: 480 """Create an Lstr from a raw string value.""" 481 482 # pylint: enable=redefined-outer-name, dangerous-default-value 483 484 def __init__(self, *args: Any, **keywds: Any) -> None: 485 """Instantiate a Lstr. 486 487 Pass a value for either 'resource', 'translate', 488 or 'value'. (see Lstr help for examples). 489 'subs' can be a sequence of 2-member sequences consisting of values 490 and replacements. 491 'fallback_resource' can be a resource key that will be used if the 492 main one is not present for 493 the current language in place of falling back to the english value 494 ('resource' mode only). 495 'fallback_value' can be a literal string that will be used if neither 496 the resource nor the fallback resource is found ('resource' mode only). 497 """ 498 # pylint: disable=too-many-branches 499 if args: 500 raise TypeError('Lstr accepts only keyword arguments') 501 502 # Basically just store the exact args they passed. 503 # However if they passed any Lstr values for subs, 504 # replace them with that Lstr's dict. 505 self.args = keywds 506 our_type = type(self) 507 508 if isinstance(self.args.get('value'), our_type): 509 raise TypeError("'value' must be a regular string; not an Lstr") 510 511 if 'subs' in self.args: 512 subs_new = [] 513 for key, value in keywds['subs']: 514 if isinstance(value, our_type): 515 subs_new.append((key, value.args)) 516 else: 517 subs_new.append((key, value)) 518 self.args['subs'] = subs_new 519 520 # As of protocol 31 we support compact key names 521 # ('t' instead of 'translate', etc). Convert as needed. 522 if 'translate' in keywds: 523 keywds['t'] = keywds['translate'] 524 del keywds['translate'] 525 if 'resource' in keywds: 526 keywds['r'] = keywds['resource'] 527 del keywds['resource'] 528 if 'value' in keywds: 529 keywds['v'] = keywds['value'] 530 del keywds['value'] 531 if 'fallback' in keywds: 532 from ba import _error 533 534 _error.print_error( 535 'deprecated "fallback" arg passed to Lstr(); use ' 536 'either "fallback_resource" or "fallback_value"', 537 once=True, 538 ) 539 keywds['f'] = keywds['fallback'] 540 del keywds['fallback'] 541 if 'fallback_resource' in keywds: 542 keywds['f'] = keywds['fallback_resource'] 543 del keywds['fallback_resource'] 544 if 'subs' in keywds: 545 keywds['s'] = keywds['subs'] 546 del keywds['subs'] 547 if 'fallback_value' in keywds: 548 keywds['fv'] = keywds['fallback_value'] 549 del keywds['fallback_value'] 550 551 def evaluate(self) -> str: 552 """Evaluate the Lstr and returns a flat string in the current language. 553 554 You should avoid doing this as much as possible and instead pass 555 and store Lstr values. 556 """ 557 return _ba.evaluate_lstr(self._get_json()) 558 559 def is_flat_value(self) -> bool: 560 """Return whether the Lstr is a 'flat' value. 561 562 This is defined as a simple string value incorporating no translations, 563 resources, or substitutions. In this case it may be reasonable to 564 replace it with a raw string value, perform string manipulation on it, 565 etc. 566 """ 567 return bool('v' in self.args and not self.args.get('s', [])) 568 569 def _get_json(self) -> str: 570 try: 571 return json.dumps(self.args, separators=(',', ':')) 572 except Exception: 573 from ba import _error 574 575 _error.print_exception('_get_json failed for', self.args) 576 return 'JSON_ERR' 577 578 def __str__(self) -> str: 579 return '<ba.Lstr: ' + self._get_json() + '>' 580 581 def __repr__(self) -> str: 582 return '<ba.Lstr: ' + self._get_json() + '>' 583 584 @staticmethod 585 def from_json(json_string: str) -> ba.Lstr: 586 """Given a json string, returns a ba.Lstr. Does no data validation.""" 587 lstr = Lstr(value='') 588 lstr.args = json.loads(json_string) 589 return 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 legacy.ballistica.net/translate.
Examples
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: ba.Lstr's can be nested. This example would display the resource at res_a but replace ${NAME} with the value of the resource at res_b
484 def __init__(self, *args: Any, **keywds: Any) -> None: 485 """Instantiate a Lstr. 486 487 Pass a value for either 'resource', 'translate', 488 or 'value'. (see Lstr help for examples). 489 'subs' can be a sequence of 2-member sequences consisting of values 490 and replacements. 491 'fallback_resource' can be a resource key that will be used if the 492 main one is not present for 493 the current language in place of falling back to the english value 494 ('resource' mode only). 495 'fallback_value' can be a literal string that will be used if neither 496 the resource nor the fallback resource is found ('resource' mode only). 497 """ 498 # pylint: disable=too-many-branches 499 if args: 500 raise TypeError('Lstr accepts only keyword arguments') 501 502 # Basically just store the exact args they passed. 503 # However if they passed any Lstr values for subs, 504 # replace them with that Lstr's dict. 505 self.args = keywds 506 our_type = type(self) 507 508 if isinstance(self.args.get('value'), our_type): 509 raise TypeError("'value' must be a regular string; not an Lstr") 510 511 if 'subs' in self.args: 512 subs_new = [] 513 for key, value in keywds['subs']: 514 if isinstance(value, our_type): 515 subs_new.append((key, value.args)) 516 else: 517 subs_new.append((key, value)) 518 self.args['subs'] = subs_new 519 520 # As of protocol 31 we support compact key names 521 # ('t' instead of 'translate', etc). Convert as needed. 522 if 'translate' in keywds: 523 keywds['t'] = keywds['translate'] 524 del keywds['translate'] 525 if 'resource' in keywds: 526 keywds['r'] = keywds['resource'] 527 del keywds['resource'] 528 if 'value' in keywds: 529 keywds['v'] = keywds['value'] 530 del keywds['value'] 531 if 'fallback' in keywds: 532 from ba import _error 533 534 _error.print_error( 535 'deprecated "fallback" arg passed to Lstr(); use ' 536 'either "fallback_resource" or "fallback_value"', 537 once=True, 538 ) 539 keywds['f'] = keywds['fallback'] 540 del keywds['fallback'] 541 if 'fallback_resource' in keywds: 542 keywds['f'] = keywds['fallback_resource'] 543 del keywds['fallback_resource'] 544 if 'subs' in keywds: 545 keywds['s'] = keywds['subs'] 546 del keywds['subs'] 547 if 'fallback_value' in keywds: 548 keywds['fv'] = keywds['fallback_value'] 549 del keywds['fallback_value']
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).
551 def evaluate(self) -> str: 552 """Evaluate the Lstr and returns a flat string in the current language. 553 554 You should avoid doing this as much as possible and instead pass 555 and store Lstr values. 556 """ 557 return _ba.evaluate_lstr(self._get_json())
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.
559 def is_flat_value(self) -> bool: 560 """Return whether the Lstr is a 'flat' value. 561 562 This is defined as a simple string value incorporating no translations, 563 resources, or substitutions. In this case it may be reasonable to 564 replace it with a raw string value, perform string manipulation on it, 565 etc. 566 """ 567 return bool('v' in self.args and not self.args.get('s', []))
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.
123class Map(Actor): 124 """A game map. 125 126 Category: **Gameplay Classes** 127 128 Consists of a collection of terrain nodes, metadata, and other 129 functionality comprising a game map. 130 """ 131 132 defs: Any = None 133 name = 'Map' 134 _playtypes: list[str] = [] 135 136 @classmethod 137 def preload(cls) -> None: 138 """Preload map media. 139 140 This runs the class's on_preload() method as needed to prep it to run. 141 Preloading should generally be done in a ba.Activity's __init__ method. 142 Note that this is a classmethod since it is not operate on map 143 instances but rather on the class itself before instances are made 144 """ 145 activity = _ba.getactivity() 146 if cls not in activity.preloads: 147 activity.preloads[cls] = cls.on_preload() 148 149 @classmethod 150 def get_play_types(cls) -> list[str]: 151 """Return valid play types for this map.""" 152 return [] 153 154 @classmethod 155 def get_preview_texture_name(cls) -> str | None: 156 """Return the name of the preview texture for this map.""" 157 return None 158 159 @classmethod 160 def on_preload(cls) -> Any: 161 """Called when the map is being preloaded. 162 163 It should return any media/data it requires to operate 164 """ 165 return None 166 167 @classmethod 168 def getname(cls) -> str: 169 """Return the unique name of this map, in English.""" 170 return cls.name 171 172 @classmethod 173 def get_music_type(cls) -> ba.MusicType | None: 174 """Return a music-type string that should be played on this map. 175 176 If None is returned, default music will be used. 177 """ 178 return None 179 180 def __init__( 181 self, vr_overlay_offset: Sequence[float] | None = None 182 ) -> None: 183 """Instantiate a map.""" 184 super().__init__() 185 186 # This is expected to always be a ba.Node object (whether valid or not) 187 # should be set to something meaningful by child classes. 188 self.node: _ba.Node | None = None 189 190 # Make our class' preload-data available to us 191 # (and instruct the user if we weren't preloaded properly). 192 try: 193 self.preloaddata = _ba.getactivity().preloads[type(self)] 194 except Exception as exc: 195 from ba import _error 196 197 raise _error.NotFoundError( 198 'Preload data not found for ' 199 + str(type(self)) 200 + '; make sure to call the type\'s preload()' 201 ' staticmethod in the activity constructor' 202 ) from exc 203 204 # Set various globals. 205 gnode = _ba.getactivity().globalsnode 206 207 # Set area-of-interest bounds. 208 aoi_bounds = self.get_def_bound_box('area_of_interest_bounds') 209 if aoi_bounds is None: 210 print('WARNING: no "aoi_bounds" found for map:', self.getname()) 211 aoi_bounds = (-1, -1, -1, 1, 1, 1) 212 gnode.area_of_interest_bounds = aoi_bounds 213 214 # Set map bounds. 215 map_bounds = self.get_def_bound_box('map_bounds') 216 if map_bounds is None: 217 print('WARNING: no "map_bounds" found for map:', self.getname()) 218 map_bounds = (-30, -10, -30, 30, 100, 30) 219 _ba.set_map_bounds(map_bounds) 220 221 # Set shadow ranges. 222 try: 223 gnode.shadow_range = [ 224 self.defs.points[v][1] 225 for v in [ 226 'shadow_lower_bottom', 227 'shadow_lower_top', 228 'shadow_upper_bottom', 229 'shadow_upper_top', 230 ] 231 ] 232 except Exception: 233 pass 234 235 # In vr, set a fixed point in space for the overlay to show up at. 236 # By default we use the bounds center but allow the map to override it. 237 center = ( 238 (aoi_bounds[0] + aoi_bounds[3]) * 0.5, 239 (aoi_bounds[1] + aoi_bounds[4]) * 0.5, 240 (aoi_bounds[2] + aoi_bounds[5]) * 0.5, 241 ) 242 if vr_overlay_offset is not None: 243 center = ( 244 center[0] + vr_overlay_offset[0], 245 center[1] + vr_overlay_offset[1], 246 center[2] + vr_overlay_offset[2], 247 ) 248 gnode.vr_overlay_center = center 249 gnode.vr_overlay_center_enabled = True 250 251 self.spawn_points = self.get_def_points('spawn') or [(0, 0, 0, 0, 0, 0)] 252 self.ffa_spawn_points = self.get_def_points('ffa_spawn') or [ 253 (0, 0, 0, 0, 0, 0) 254 ] 255 self.spawn_by_flag_points = self.get_def_points('spawn_by_flag') or [ 256 (0, 0, 0, 0, 0, 0) 257 ] 258 self.flag_points = self.get_def_points('flag') or [(0, 0, 0)] 259 260 # We just want points. 261 self.flag_points = [p[:3] for p in self.flag_points] 262 self.flag_points_default = self.get_def_point('flag_default') or ( 263 0, 264 1, 265 0, 266 ) 267 self.powerup_spawn_points = self.get_def_points('powerup_spawn') or [ 268 (0, 0, 0) 269 ] 270 271 # We just want points. 272 self.powerup_spawn_points = [p[:3] for p in self.powerup_spawn_points] 273 self.tnt_points = self.get_def_points('tnt') or [] 274 275 # We just want points. 276 self.tnt_points = [p[:3] for p in self.tnt_points] 277 278 self.is_hockey = False 279 self.is_flying = False 280 281 # FIXME: this should be part of game; not map. 282 # Let's select random index for first spawn point, 283 # so that no one is offended by the constant spawn on the edge. 284 self._next_ffa_start_index = random.randrange( 285 len(self.ffa_spawn_points) 286 ) 287 288 def is_point_near_edge(self, point: ba.Vec3, running: bool = False) -> bool: 289 """Return whether the provided point is near an edge of the map. 290 291 Simple bot logic uses this call to determine if they 292 are approaching a cliff or wall. If this returns True they will 293 generally not walk/run any farther away from the origin. 294 If 'running' is True, the buffer should be a bit larger. 295 """ 296 del point, running # Unused. 297 return False 298 299 def get_def_bound_box( 300 self, name: str 301 ) -> tuple[float, float, float, float, float, float] | None: 302 """Return a 6 member bounds tuple or None if it is not defined.""" 303 try: 304 box = self.defs.boxes[name] 305 return ( 306 box[0] - box[6] / 2.0, 307 box[1] - box[7] / 2.0, 308 box[2] - box[8] / 2.0, 309 box[0] + box[6] / 2.0, 310 box[1] + box[7] / 2.0, 311 box[2] + box[8] / 2.0, 312 ) 313 except Exception: 314 return None 315 316 def get_def_point(self, name: str) -> Sequence[float] | None: 317 """Return a single defined point or a default value in its absence.""" 318 val = self.defs.points.get(name) 319 return ( 320 None 321 if val is None 322 else _math.vec3validate(val) 323 if __debug__ 324 else val 325 ) 326 327 def get_def_points(self, name: str) -> list[Sequence[float]]: 328 """Return a list of named points. 329 330 Return as many sequential ones are defined (flag1, flag2, flag3), etc. 331 If none are defined, returns an empty list. 332 """ 333 point_list = [] 334 if self.defs and name + '1' in self.defs.points: 335 i = 1 336 while name + str(i) in self.defs.points: 337 pts = self.defs.points[name + str(i)] 338 if len(pts) == 6: 339 point_list.append(pts) 340 else: 341 if len(pts) != 3: 342 raise ValueError('invalid point') 343 point_list.append(pts + (0, 0, 0)) 344 i += 1 345 return point_list 346 347 def get_start_position(self, team_index: int) -> Sequence[float]: 348 """Return a random starting position for the given team index.""" 349 pnt = self.spawn_points[team_index % len(self.spawn_points)] 350 x_range = (-0.5, 0.5) if pnt[3] == 0.0 else (-pnt[3], pnt[3]) 351 z_range = (-0.5, 0.5) if pnt[5] == 0.0 else (-pnt[5], pnt[5]) 352 pnt = ( 353 pnt[0] + random.uniform(*x_range), 354 pnt[1], 355 pnt[2] + random.uniform(*z_range), 356 ) 357 return pnt 358 359 def get_ffa_start_position( 360 self, players: Sequence[ba.Player] 361 ) -> Sequence[float]: 362 """Return a random starting position in one of the FFA spawn areas. 363 364 If a list of ba.Player-s is provided; the returned points will be 365 as far from these players as possible. 366 """ 367 368 # Get positions for existing players. 369 player_pts = [] 370 for player in players: 371 if player.is_alive(): 372 player_pts.append(player.position) 373 374 def _getpt() -> Sequence[float]: 375 point = self.ffa_spawn_points[self._next_ffa_start_index] 376 self._next_ffa_start_index = (self._next_ffa_start_index + 1) % len( 377 self.ffa_spawn_points 378 ) 379 x_range = (-0.5, 0.5) if point[3] == 0.0 else (-point[3], point[3]) 380 z_range = (-0.5, 0.5) if point[5] == 0.0 else (-point[5], point[5]) 381 point = ( 382 point[0] + random.uniform(*x_range), 383 point[1], 384 point[2] + random.uniform(*z_range), 385 ) 386 return point 387 388 if not player_pts: 389 return _getpt() 390 391 # Let's calc several start points and then pick whichever is 392 # farthest from all existing players. 393 farthestpt_dist = -1.0 394 farthestpt = None 395 for _i in range(10): 396 testpt = _ba.Vec3(_getpt()) 397 closest_player_dist = 9999.0 398 for ppt in player_pts: 399 dist = (ppt - testpt).length() 400 if dist < closest_player_dist: 401 closest_player_dist = dist 402 if closest_player_dist > farthestpt_dist: 403 farthestpt_dist = closest_player_dist 404 farthestpt = testpt 405 assert farthestpt is not None 406 return tuple(farthestpt) 407 408 def get_flag_position( 409 self, team_index: int | None = None 410 ) -> Sequence[float]: 411 """Return a flag position on the map for the given team index. 412 413 Pass None to get the default flag point. 414 (used for things such as king-of-the-hill) 415 """ 416 if team_index is None: 417 return self.flag_points_default[:3] 418 return self.flag_points[team_index % len(self.flag_points)][:3] 419 420 def exists(self) -> bool: 421 return bool(self.node) 422 423 def handlemessage(self, msg: Any) -> Any: 424 from ba import _messages 425 426 if isinstance(msg, _messages.DieMessage): 427 if self.node: 428 self.node.delete() 429 else: 430 return super().handlemessage(msg) 431 return None
A game map.
Category: Gameplay Classes
Consists of a collection of terrain nodes, metadata, and other functionality comprising a game map.
180 def __init__( 181 self, vr_overlay_offset: Sequence[float] | None = None 182 ) -> None: 183 """Instantiate a map.""" 184 super().__init__() 185 186 # This is expected to always be a ba.Node object (whether valid or not) 187 # should be set to something meaningful by child classes. 188 self.node: _ba.Node | None = None 189 190 # Make our class' preload-data available to us 191 # (and instruct the user if we weren't preloaded properly). 192 try: 193 self.preloaddata = _ba.getactivity().preloads[type(self)] 194 except Exception as exc: 195 from ba import _error 196 197 raise _error.NotFoundError( 198 'Preload data not found for ' 199 + str(type(self)) 200 + '; make sure to call the type\'s preload()' 201 ' staticmethod in the activity constructor' 202 ) from exc 203 204 # Set various globals. 205 gnode = _ba.getactivity().globalsnode 206 207 # Set area-of-interest bounds. 208 aoi_bounds = self.get_def_bound_box('area_of_interest_bounds') 209 if aoi_bounds is None: 210 print('WARNING: no "aoi_bounds" found for map:', self.getname()) 211 aoi_bounds = (-1, -1, -1, 1, 1, 1) 212 gnode.area_of_interest_bounds = aoi_bounds 213 214 # Set map bounds. 215 map_bounds = self.get_def_bound_box('map_bounds') 216 if map_bounds is None: 217 print('WARNING: no "map_bounds" found for map:', self.getname()) 218 map_bounds = (-30, -10, -30, 30, 100, 30) 219 _ba.set_map_bounds(map_bounds) 220 221 # Set shadow ranges. 222 try: 223 gnode.shadow_range = [ 224 self.defs.points[v][1] 225 for v in [ 226 'shadow_lower_bottom', 227 'shadow_lower_top', 228 'shadow_upper_bottom', 229 'shadow_upper_top', 230 ] 231 ] 232 except Exception: 233 pass 234 235 # In vr, set a fixed point in space for the overlay to show up at. 236 # By default we use the bounds center but allow the map to override it. 237 center = ( 238 (aoi_bounds[0] + aoi_bounds[3]) * 0.5, 239 (aoi_bounds[1] + aoi_bounds[4]) * 0.5, 240 (aoi_bounds[2] + aoi_bounds[5]) * 0.5, 241 ) 242 if vr_overlay_offset is not None: 243 center = ( 244 center[0] + vr_overlay_offset[0], 245 center[1] + vr_overlay_offset[1], 246 center[2] + vr_overlay_offset[2], 247 ) 248 gnode.vr_overlay_center = center 249 gnode.vr_overlay_center_enabled = True 250 251 self.spawn_points = self.get_def_points('spawn') or [(0, 0, 0, 0, 0, 0)] 252 self.ffa_spawn_points = self.get_def_points('ffa_spawn') or [ 253 (0, 0, 0, 0, 0, 0) 254 ] 255 self.spawn_by_flag_points = self.get_def_points('spawn_by_flag') or [ 256 (0, 0, 0, 0, 0, 0) 257 ] 258 self.flag_points = self.get_def_points('flag') or [(0, 0, 0)] 259 260 # We just want points. 261 self.flag_points = [p[:3] for p in self.flag_points] 262 self.flag_points_default = self.get_def_point('flag_default') or ( 263 0, 264 1, 265 0, 266 ) 267 self.powerup_spawn_points = self.get_def_points('powerup_spawn') or [ 268 (0, 0, 0) 269 ] 270 271 # We just want points. 272 self.powerup_spawn_points = [p[:3] for p in self.powerup_spawn_points] 273 self.tnt_points = self.get_def_points('tnt') or [] 274 275 # We just want points. 276 self.tnt_points = [p[:3] for p in self.tnt_points] 277 278 self.is_hockey = False 279 self.is_flying = False 280 281 # FIXME: this should be part of game; not map. 282 # Let's select random index for first spawn point, 283 # so that no one is offended by the constant spawn on the edge. 284 self._next_ffa_start_index = random.randrange( 285 len(self.ffa_spawn_points) 286 )
Instantiate a map.
136 @classmethod 137 def preload(cls) -> None: 138 """Preload map media. 139 140 This runs the class's on_preload() method as needed to prep it to run. 141 Preloading should generally be done in a ba.Activity's __init__ method. 142 Note that this is a classmethod since it is not operate on map 143 instances but rather on the class itself before instances are made 144 """ 145 activity = _ba.getactivity() 146 if cls not in activity.preloads: 147 activity.preloads[cls] = cls.on_preload()
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
149 @classmethod 150 def get_play_types(cls) -> list[str]: 151 """Return valid play types for this map.""" 152 return []
Return valid play types for this map.
154 @classmethod 155 def get_preview_texture_name(cls) -> str | None: 156 """Return the name of the preview texture for this map.""" 157 return None
Return the name of the preview texture for this map.
159 @classmethod 160 def on_preload(cls) -> Any: 161 """Called when the map is being preloaded. 162 163 It should return any media/data it requires to operate 164 """ 165 return None
Called when the map is being preloaded.
It should return any media/data it requires to operate
167 @classmethod 168 def getname(cls) -> str: 169 """Return the unique name of this map, in English.""" 170 return cls.name
Return the unique name of this map, in English.
172 @classmethod 173 def get_music_type(cls) -> ba.MusicType | None: 174 """Return a music-type string that should be played on this map. 175 176 If None is returned, default music will be used. 177 """ 178 return None
Return a music-type string that should be played on this map.
If None is returned, default music will be used.
288 def is_point_near_edge(self, point: ba.Vec3, running: bool = False) -> bool: 289 """Return whether the provided point is near an edge of the map. 290 291 Simple bot logic uses this call to determine if they 292 are approaching a cliff or wall. If this returns True they will 293 generally not walk/run any farther away from the origin. 294 If 'running' is True, the buffer should be a bit larger. 295 """ 296 del point, running # Unused. 297 return False
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.
299 def get_def_bound_box( 300 self, name: str 301 ) -> tuple[float, float, float, float, float, float] | None: 302 """Return a 6 member bounds tuple or None if it is not defined.""" 303 try: 304 box = self.defs.boxes[name] 305 return ( 306 box[0] - box[6] / 2.0, 307 box[1] - box[7] / 2.0, 308 box[2] - box[8] / 2.0, 309 box[0] + box[6] / 2.0, 310 box[1] + box[7] / 2.0, 311 box[2] + box[8] / 2.0, 312 ) 313 except Exception: 314 return None
Return a 6 member bounds tuple or None if it is not defined.
316 def get_def_point(self, name: str) -> Sequence[float] | None: 317 """Return a single defined point or a default value in its absence.""" 318 val = self.defs.points.get(name) 319 return ( 320 None 321 if val is None 322 else _math.vec3validate(val) 323 if __debug__ 324 else val 325 )
Return a single defined point or a default value in its absence.
327 def get_def_points(self, name: str) -> list[Sequence[float]]: 328 """Return a list of named points. 329 330 Return as many sequential ones are defined (flag1, flag2, flag3), etc. 331 If none are defined, returns an empty list. 332 """ 333 point_list = [] 334 if self.defs and name + '1' in self.defs.points: 335 i = 1 336 while name + str(i) in self.defs.points: 337 pts = self.defs.points[name + str(i)] 338 if len(pts) == 6: 339 point_list.append(pts) 340 else: 341 if len(pts) != 3: 342 raise ValueError('invalid point') 343 point_list.append(pts + (0, 0, 0)) 344 i += 1 345 return point_list
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.
347 def get_start_position(self, team_index: int) -> Sequence[float]: 348 """Return a random starting position for the given team index.""" 349 pnt = self.spawn_points[team_index % len(self.spawn_points)] 350 x_range = (-0.5, 0.5) if pnt[3] == 0.0 else (-pnt[3], pnt[3]) 351 z_range = (-0.5, 0.5) if pnt[5] == 0.0 else (-pnt[5], pnt[5]) 352 pnt = ( 353 pnt[0] + random.uniform(*x_range), 354 pnt[1], 355 pnt[2] + random.uniform(*z_range), 356 ) 357 return pnt
Return a random starting position for the given team index.
359 def get_ffa_start_position( 360 self, players: Sequence[ba.Player] 361 ) -> Sequence[float]: 362 """Return a random starting position in one of the FFA spawn areas. 363 364 If a list of ba.Player-s is provided; the returned points will be 365 as far from these players as possible. 366 """ 367 368 # Get positions for existing players. 369 player_pts = [] 370 for player in players: 371 if player.is_alive(): 372 player_pts.append(player.position) 373 374 def _getpt() -> Sequence[float]: 375 point = self.ffa_spawn_points[self._next_ffa_start_index] 376 self._next_ffa_start_index = (self._next_ffa_start_index + 1) % len( 377 self.ffa_spawn_points 378 ) 379 x_range = (-0.5, 0.5) if point[3] == 0.0 else (-point[3], point[3]) 380 z_range = (-0.5, 0.5) if point[5] == 0.0 else (-point[5], point[5]) 381 point = ( 382 point[0] + random.uniform(*x_range), 383 point[1], 384 point[2] + random.uniform(*z_range), 385 ) 386 return point 387 388 if not player_pts: 389 return _getpt() 390 391 # Let's calc several start points and then pick whichever is 392 # farthest from all existing players. 393 farthestpt_dist = -1.0 394 farthestpt = None 395 for _i in range(10): 396 testpt = _ba.Vec3(_getpt()) 397 closest_player_dist = 9999.0 398 for ppt in player_pts: 399 dist = (ppt - testpt).length() 400 if dist < closest_player_dist: 401 closest_player_dist = dist 402 if closest_player_dist > farthestpt_dist: 403 farthestpt_dist = closest_player_dist 404 farthestpt = testpt 405 assert farthestpt is not None 406 return tuple(farthestpt)
Return a random starting position in one of the FFA spawn areas.
If a list of ba.Player-s is provided; the returned points will be as far from these players as possible.
408 def get_flag_position( 409 self, team_index: int | None = None 410 ) -> Sequence[float]: 411 """Return a flag position on the map for the given team index. 412 413 Pass None to get the default flag point. 414 (used for things such as king-of-the-hill) 415 """ 416 if team_index is None: 417 return self.flag_points_default[:3] 418 return self.flag_points[team_index % len(self.flag_points)][:3]
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)
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.
423 def handlemessage(self, msg: Any) -> Any: 424 from ba import _messages 425 426 if isinstance(msg, _messages.DieMessage): 427 if self.node: 428 self.node.delete() 429 else: 430 return super().handlemessage(msg) 431 return None
General message handling; can be passed any message object.
Inherited Members
73class MapNotFoundError(NotFoundError): 74 """Exception raised when an expected ba.Map does not exist. 75 76 Category: **Exception Classes** 77 """
Exception raised when an expected ba.Map does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
341class Material: 342 343 """An entity applied to game objects to modify collision behavior. 344 345 Category: **Gameplay Classes** 346 347 A material can affect physical characteristics, generate sounds, 348 or trigger callback functions when collisions occur. 349 350 Materials are applied to 'parts', which are groups of one or more 351 rigid bodies created as part of a ba.Node. Nodes can have any number 352 of parts, each with its own set of materials. Generally materials are 353 specified as array attributes on the Node. The `spaz` node, for 354 example, has various attributes such as `materials`, 355 `roller_materials`, and `punch_materials`, which correspond 356 to the various parts it creates. 357 358 Use ba.Material to instantiate a blank material, and then use its 359 ba.Material.add_actions() method to define what the material does. 360 """ 361 362 def __init__(self, label: str | None = None): 363 pass 364 365 label: str 366 367 """A label for the material; only used for debugging.""" 368 369 def add_actions( 370 self, actions: tuple, conditions: tuple | None = None 371 ) -> None: 372 373 """Add one or more actions to the material, optionally with conditions. 374 375 ##### Conditions 376 Conditions are provided as tuples which can be combined 377 to form boolean logic. A single condition might look like 378 `('condition_name', cond_arg)`, or a more complex nested one 379 might look like `(('some_condition', cond_arg), 'or', 380 ('another_condition', cond2_arg))`. 381 382 `'and'`, `'or'`, and `'xor'` are available to chain 383 together 2 conditions, as seen above. 384 385 ##### Available Conditions 386 ###### `('they_have_material', material)` 387 > Does the part we're hitting have a given ba.Material? 388 389 ###### `('they_dont_have_material', material)` 390 > Does the part we're hitting not have a given ba.Material? 391 392 ###### `('eval_colliding')` 393 > Is `'collide'` true at this point 394 in material evaluation? (see the `modify_part_collision` action) 395 396 ###### `('eval_not_colliding')` 397 > Is 'collide' false at this point 398 in material evaluation? (see the `modify_part_collision` action) 399 400 ###### `('we_are_younger_than', age)` 401 > Is our part younger than `age` (in milliseconds)? 402 403 ###### `('we_are_older_than', age)` 404 > Is our part older than `age` (in milliseconds)? 405 406 ###### `('they_are_younger_than', age)` 407 > Is the part we're hitting younger than `age` (in milliseconds)? 408 409 ###### `('they_are_older_than', age)` 410 > Is the part we're hitting older than `age` (in milliseconds)? 411 412 ###### `('they_are_same_node_as_us')` 413 > Does the part we're hitting belong to the same ba.Node as us? 414 415 ###### `('they_are_different_node_than_us')` 416 > Does the part we're hitting belong to a different ba.Node than us? 417 418 ##### Actions 419 In a similar manner, actions are specified as tuples. 420 Multiple actions can be specified by providing a tuple 421 of tuples. 422 423 ##### Available Actions 424 ###### `('call', when, callable)` 425 > Calls the provided callable; 426 `when` can be either `'at_connect'` or `'at_disconnect'`. 427 `'at_connect'` means to fire 428 when the two parts first come in contact; `'at_disconnect'` 429 means to fire once they cease being in contact. 430 431 ###### `('message', who, when, message_obj)` 432 > Sends a message object; 433 `who` can be either `'our_node'` or `'their_node'`, `when` can be 434 `'at_connect'` or `'at_disconnect'`, and `message_obj` is the message 435 object to send. 436 This has the same effect as calling the node's 437 ba.Node.handlemessage() method. 438 439 ###### `('modify_part_collision', attr, value)` 440 > Changes some 441 characteristic of the physical collision that will occur between 442 our part and their part. This change will remain in effect as 443 long as the two parts remain overlapping. This means if you have a 444 part with a material that turns `'collide'` off against parts 445 younger than 100ms, and it touches another part that is 50ms old, 446 it will continue to not collide with that part until they separate, 447 even if the 100ms threshold is passed. Options for attr/value are: 448 `'physical'` (boolean value; whether a *physical* response will 449 occur at all), `'friction'` (float value; how friction-y the 450 physical response will be), `'collide'` (boolean value; 451 whether *any* collision will occur at all, including non-physical 452 stuff like callbacks), `'use_node_collide'` 453 (boolean value; whether to honor modify_node_collision 454 overrides for this collision), `'stiffness'` (float value, 455 how springy the physical response is), `'damping'` (float 456 value, how damped the physical response is), `'bounce'` (float 457 value; how bouncy the physical response is). 458 459 ###### `('modify_node_collision', attr, value)` 460 > Similar to 461 `modify_part_collision`, but operates at a node-level. 462 collision attributes set here will remain in effect as long as 463 *anything* from our part's node and their part's node overlap. 464 A key use of this functionality is to prevent new nodes from 465 colliding with each other if they appear overlapped; 466 if `modify_part_collision` is used, only the individual 467 parts that were overlapping would avoid contact, but other parts 468 could still contact leaving the two nodes 'tangled up'. Using 469 `modify_node_collision` ensures that the nodes must completely 470 separate before they can start colliding. Currently the only attr 471 available here is `'collide'` (a boolean value). 472 473 ###### `('sound', sound, volume)` 474 > Plays a ba.Sound when a collision 475 occurs, at a given volume, regardless of the collision speed/etc. 476 477 ###### `('impact_sound', sound, targetImpulse, volume)` 478 > Plays a sound 479 when a collision occurs, based on the speed of impact. 480 Provide a ba.Sound, a target-impulse, and a volume. 481 482 ###### `('skid_sound', sound, targetImpulse, volume)` 483 > Plays a sound 484 during a collision when parts are 'scraping' against each other. 485 Provide a ba.Sound, a target-impulse, and a volume. 486 487 ###### `('roll_sound', sound, targetImpulse, volume)` 488 > Plays a sound 489 during a collision when parts are 'rolling' against each other. 490 Provide a ba.Sound, a target-impulse, and a volume. 491 492 ##### Examples 493 **Example 1:** create a material that lets us ignore 494 collisions against any nodes we touch in the first 495 100 ms of our existence; handy for preventing us from 496 exploding outward if we spawn on top of another object: 497 >>> m = ba.Material() 498 ... m.add_actions( 499 ... conditions=(('we_are_younger_than', 100), 500 ... 'or', ('they_are_younger_than', 100)), 501 ... actions=('modify_node_collision', 'collide', False)) 502 503 **Example 2:** send a ba.DieMessage to anything we touch, but cause 504 no physical response. This should cause any ba.Actor to drop dead: 505 >>> m = ba.Material() 506 ... m.add_actions( 507 ... actions=(('modify_part_collision', 'physical', False), 508 ... ('message', 'their_node', 'at_connect', 509 ... ba.DieMessage()))) 510 511 **Example 3:** play some sounds when we're contacting the ground: 512 >>> m = ba.Material() 513 ... m.add_actions( 514 ... conditions=('they_have_material', 515 ... shared.footing_material), 516 ... actions=(('impact_sound', ba.getsound('metalHit'), 2, 5), 517 ... ('skid_sound', ba.getsound('metalSkid'), 2, 5))) 518 """ 519 return None
An entity applied to game objects to modify collision behavior.
Category: Gameplay Classes
A material can affect physical characteristics, generate sounds, or trigger callback functions when collisions occur.
Materials are applied to 'parts', which are groups of one or more
rigid bodies created as part of a ba.Node. Nodes can have any number
of parts, each with its own set of materials. Generally materials are
specified as array attributes on the Node. The spaz
node, for
example, has various attributes such as materials
,
roller_materials
, and punch_materials
, which correspond
to the various parts it creates.
Use ba.Material to instantiate a blank material, and then use its ba.Material.add_actions() method to define what the material does.
369 def add_actions( 370 self, actions: tuple, conditions: tuple | None = None 371 ) -> None: 372 373 """Add one or more actions to the material, optionally with conditions. 374 375 ##### Conditions 376 Conditions are provided as tuples which can be combined 377 to form boolean logic. A single condition might look like 378 `('condition_name', cond_arg)`, or a more complex nested one 379 might look like `(('some_condition', cond_arg), 'or', 380 ('another_condition', cond2_arg))`. 381 382 `'and'`, `'or'`, and `'xor'` are available to chain 383 together 2 conditions, as seen above. 384 385 ##### Available Conditions 386 ###### `('they_have_material', material)` 387 > Does the part we're hitting have a given ba.Material? 388 389 ###### `('they_dont_have_material', material)` 390 > Does the part we're hitting not have a given ba.Material? 391 392 ###### `('eval_colliding')` 393 > Is `'collide'` true at this point 394 in material evaluation? (see the `modify_part_collision` action) 395 396 ###### `('eval_not_colliding')` 397 > Is 'collide' false at this point 398 in material evaluation? (see the `modify_part_collision` action) 399 400 ###### `('we_are_younger_than', age)` 401 > Is our part younger than `age` (in milliseconds)? 402 403 ###### `('we_are_older_than', age)` 404 > Is our part older than `age` (in milliseconds)? 405 406 ###### `('they_are_younger_than', age)` 407 > Is the part we're hitting younger than `age` (in milliseconds)? 408 409 ###### `('they_are_older_than', age)` 410 > Is the part we're hitting older than `age` (in milliseconds)? 411 412 ###### `('they_are_same_node_as_us')` 413 > Does the part we're hitting belong to the same ba.Node as us? 414 415 ###### `('they_are_different_node_than_us')` 416 > Does the part we're hitting belong to a different ba.Node than us? 417 418 ##### Actions 419 In a similar manner, actions are specified as tuples. 420 Multiple actions can be specified by providing a tuple 421 of tuples. 422 423 ##### Available Actions 424 ###### `('call', when, callable)` 425 > Calls the provided callable; 426 `when` can be either `'at_connect'` or `'at_disconnect'`. 427 `'at_connect'` means to fire 428 when the two parts first come in contact; `'at_disconnect'` 429 means to fire once they cease being in contact. 430 431 ###### `('message', who, when, message_obj)` 432 > Sends a message object; 433 `who` can be either `'our_node'` or `'their_node'`, `when` can be 434 `'at_connect'` or `'at_disconnect'`, and `message_obj` is the message 435 object to send. 436 This has the same effect as calling the node's 437 ba.Node.handlemessage() method. 438 439 ###### `('modify_part_collision', attr, value)` 440 > Changes some 441 characteristic of the physical collision that will occur between 442 our part and their part. This change will remain in effect as 443 long as the two parts remain overlapping. This means if you have a 444 part with a material that turns `'collide'` off against parts 445 younger than 100ms, and it touches another part that is 50ms old, 446 it will continue to not collide with that part until they separate, 447 even if the 100ms threshold is passed. Options for attr/value are: 448 `'physical'` (boolean value; whether a *physical* response will 449 occur at all), `'friction'` (float value; how friction-y the 450 physical response will be), `'collide'` (boolean value; 451 whether *any* collision will occur at all, including non-physical 452 stuff like callbacks), `'use_node_collide'` 453 (boolean value; whether to honor modify_node_collision 454 overrides for this collision), `'stiffness'` (float value, 455 how springy the physical response is), `'damping'` (float 456 value, how damped the physical response is), `'bounce'` (float 457 value; how bouncy the physical response is). 458 459 ###### `('modify_node_collision', attr, value)` 460 > Similar to 461 `modify_part_collision`, but operates at a node-level. 462 collision attributes set here will remain in effect as long as 463 *anything* from our part's node and their part's node overlap. 464 A key use of this functionality is to prevent new nodes from 465 colliding with each other if they appear overlapped; 466 if `modify_part_collision` is used, only the individual 467 parts that were overlapping would avoid contact, but other parts 468 could still contact leaving the two nodes 'tangled up'. Using 469 `modify_node_collision` ensures that the nodes must completely 470 separate before they can start colliding. Currently the only attr 471 available here is `'collide'` (a boolean value). 472 473 ###### `('sound', sound, volume)` 474 > Plays a ba.Sound when a collision 475 occurs, at a given volume, regardless of the collision speed/etc. 476 477 ###### `('impact_sound', sound, targetImpulse, volume)` 478 > Plays a sound 479 when a collision occurs, based on the speed of impact. 480 Provide a ba.Sound, a target-impulse, and a volume. 481 482 ###### `('skid_sound', sound, targetImpulse, volume)` 483 > Plays a sound 484 during a collision when parts are 'scraping' against each other. 485 Provide a ba.Sound, a target-impulse, and a volume. 486 487 ###### `('roll_sound', sound, targetImpulse, volume)` 488 > Plays a sound 489 during a collision when parts are 'rolling' against each other. 490 Provide a ba.Sound, a target-impulse, and a volume. 491 492 ##### Examples 493 **Example 1:** create a material that lets us ignore 494 collisions against any nodes we touch in the first 495 100 ms of our existence; handy for preventing us from 496 exploding outward if we spawn on top of another object: 497 >>> m = ba.Material() 498 ... m.add_actions( 499 ... conditions=(('we_are_younger_than', 100), 500 ... 'or', ('they_are_younger_than', 100)), 501 ... actions=('modify_node_collision', 'collide', False)) 502 503 **Example 2:** send a ba.DieMessage to anything we touch, but cause 504 no physical response. This should cause any ba.Actor to drop dead: 505 >>> m = ba.Material() 506 ... m.add_actions( 507 ... actions=(('modify_part_collision', 'physical', False), 508 ... ('message', 'their_node', 'at_connect', 509 ... ba.DieMessage()))) 510 511 **Example 3:** play some sounds when we're contacting the ground: 512 >>> m = ba.Material() 513 ... m.add_actions( 514 ... conditions=('they_have_material', 515 ... shared.footing_material), 516 ... actions=(('impact_sound', ba.getsound('metalHit'), 2, 5), 517 ... ('skid_sound', ba.getsound('metalSkid'), 2, 5))) 518 """ 519 return None
Add one or more actions to the material, optionally with conditions.
Conditions
Conditions are provided as tuples which can be combined
to form boolean logic. A single condition might look like
('condition_name', cond_arg)
, or a more complex nested one
might look like (('some_condition', cond_arg), 'or',
('another_condition', cond2_arg))
.
'and'
, 'or'
, and 'xor'
are available to chain
together 2 conditions, as seen above.
Available Conditions
('they_have_material', material)
Does the part we're hitting have a given ba.Material?
('they_dont_have_material', material)
Does the part we're hitting not have a given ba.Material?
('eval_colliding')
Is
'collide'
true at this point in material evaluation? (see themodify_part_collision
action)
('eval_not_colliding')
Is 'collide' false at this point in material evaluation? (see the
modify_part_collision
action)
('we_are_younger_than', age)
Is our part younger than
age
(in milliseconds)?
('we_are_older_than', age)
Is our part older than
age
(in milliseconds)?
('they_are_younger_than', age)
Is the part we're hitting younger than
age
(in milliseconds)?
('they_are_older_than', age)
Is the part we're hitting older than
age
(in milliseconds)?
('they_are_same_node_as_us')
Does the part we're hitting belong to the same ba.Node as us?
('they_are_different_node_than_us')
Does the part we're hitting belong to a different ba.Node than us?
Actions
In a similar manner, actions are specified as tuples. Multiple actions can be specified by providing a tuple of tuples.
Available Actions
('call', when, callable)
Calls the provided callable;
when
can be either'at_connect'
or'at_disconnect'
.'at_connect'
means to fire when the two parts first come in contact;'at_disconnect'
means to fire once they cease being in contact.
('message', who, when, message_obj)
Sends a message object;
who
can be either'our_node'
or'their_node'
,when
can be'at_connect'
or'at_disconnect'
, andmessage_obj
is the message object to send. This has the same effect as calling the node's ba.Node.handlemessage() method.
('modify_part_collision', attr, value)
Changes some characteristic of the physical collision that will occur between our part and their part. This change will remain in effect as long as the two parts remain overlapping. This means if you have a part with a material that turns
'collide'
off against parts younger than 100ms, and it touches another part that is 50ms old, it will continue to not collide with that part until they separate, even if the 100ms threshold is passed. Options for attr/value are:'physical'
(boolean value; whether a physical response will occur at all),'friction'
(float value; how friction-y the physical response will be),'collide'
(boolean value; whether any collision will occur at all, including non-physical stuff like callbacks),'use_node_collide'
(boolean value; whether to honor modify_node_collision overrides for this collision),'stiffness'
(float value, how springy the physical response is),'damping'
(float value, how damped the physical response is),'bounce'
(float value; how bouncy the physical response is).
('modify_node_collision', attr, value)
Similar to
modify_part_collision
, but operates at a node-level. collision attributes set here will remain in effect as long as anything from our part's node and their part's node overlap. A key use of this functionality is to prevent new nodes from colliding with each other if they appear overlapped; ifmodify_part_collision
is used, only the individual parts that were overlapping would avoid contact, but other parts could still contact leaving the two nodes 'tangled up'. Usingmodify_node_collision
ensures that the nodes must completely separate before they can start colliding. Currently the only attr available here is'collide'
(a boolean value).
('sound', sound, volume)
Plays a ba.Sound when a collision occurs, at a given volume, regardless of the collision speed/etc.
('impact_sound', sound, targetImpulse, volume)
Plays a sound when a collision occurs, based on the speed of impact. Provide a ba.Sound, a target-impulse, and a volume.
('skid_sound', sound, targetImpulse, volume)
Plays a sound during a collision when parts are 'scraping' against each other. Provide a ba.Sound, a target-impulse, and a volume.
('roll_sound', sound, targetImpulse, volume)
Plays a sound during a collision when parts are 'rolling' against each other. Provide a ba.Sound, a target-impulse, and a volume.
Examples
Example 1: create a material that lets us ignore collisions against any nodes we touch in the first 100 ms of our existence; handy for preventing us from exploding outward if we spawn on top of another object:
>>> m = ba.Material()
... m.add_actions(
... conditions=(('we_are_younger_than', 100),
... 'or', ('they_are_younger_than', 100)),
... actions=('modify_node_collision', 'collide', False))
Example 2: send a ba.DieMessage to anything we touch, but cause no physical response. This should cause any ba.Actor to drop dead:
>>> m = ba.Material()
... m.add_actions(
... actions=(('modify_part_collision', 'physical', False),
... ('message', 'their_node', 'at_connect',
... ba.DieMessage())))
Example 3: play some sounds when we're contacting the ground:
>>> m = ba.Material()
... m.add_actions(
... conditions=('they_have_material',
... shared.footing_material),
... actions=(('impact_sound', ba.getsound('metalHit'), 2, 5),
... ('skid_sound', ba.getsound('metalSkid'), 2, 5)))
53class MetadataSubsystem: 54 """Subsystem for working with script metadata in the app. 55 56 Category: **App Classes** 57 58 Access the single shared instance of this class at 'ba.app.meta'. 59 """ 60 61 def __init__(self) -> None: 62 63 self._scan: DirectoryScan | None = None 64 65 # Can be populated before starting the scan. 66 self.extra_scan_dirs: list[str] = [] 67 68 # Results populated once scan is complete. 69 self.scanresults: ScanResults | None = None 70 71 self._scan_complete_cb: Callable[[], None] | None = None 72 73 def start_scan(self, scan_complete_cb: Callable[[], None]) -> None: 74 """Begin the overall scan. 75 76 This will start scanning built in directories (which for vanilla 77 installs should be the vast majority of the work). This should only 78 be called once. 79 """ 80 assert self._scan_complete_cb is None 81 assert self._scan is None 82 83 self._scan_complete_cb = scan_complete_cb 84 self._scan = DirectoryScan( 85 [_ba.app.python_directory_app, _ba.app.python_directory_user] 86 ) 87 88 Thread(target=self._run_scan_in_bg, daemon=True).start() 89 90 def start_extra_scan(self) -> None: 91 """Proceed to the extra_scan_dirs portion of the scan. 92 93 This is for parts of the scan that must be delayed until 94 workspace sync completion or other such events. This must be 95 called exactly once. 96 """ 97 assert self._scan is not None 98 self._scan.set_extras(self.extra_scan_dirs) 99 100 def load_exported_classes( 101 self, 102 cls: type[T], 103 completion_cb: Callable[[list[type[T]]], None], 104 completion_cb_in_bg_thread: bool = False, 105 ) -> None: 106 """High level function to load meta-exported classes. 107 108 Will wait for scanning to complete if necessary, and will load all 109 registered classes of a particular type in a background thread before 110 calling the passed callback in the logic thread. Errors may be logged 111 to messaged to the user in some way but the callback will be called 112 regardless. 113 To run the completion callback directly in the bg thread where the 114 loading work happens, pass completion_cb_in_bg_thread=True. 115 """ 116 Thread( 117 target=tpartial( 118 self._load_exported_classes, 119 cls, 120 completion_cb, 121 completion_cb_in_bg_thread, 122 ), 123 daemon=True, 124 ).start() 125 126 def _load_exported_classes( 127 self, 128 cls: type[T], 129 completion_cb: Callable[[list[type[T]]], None], 130 completion_cb_in_bg_thread: bool, 131 ) -> None: 132 from ba._general import getclass 133 134 classes: list[type[T]] = [] 135 try: 136 classnames = self._wait_for_scan_results().exports_of_class(cls) 137 for classname in classnames: 138 try: 139 classes.append(getclass(classname, cls)) 140 except Exception: 141 logging.exception('error importing %s', classname) 142 143 except Exception: 144 logging.exception('Error loading exported classes.') 145 146 completion_call = tpartial(completion_cb, classes) 147 if completion_cb_in_bg_thread: 148 completion_call() 149 else: 150 _ba.pushcall(completion_call, from_other_thread=True) 151 152 def _wait_for_scan_results(self) -> ScanResults: 153 """Return scan results, blocking if the scan is not yet complete.""" 154 if self.scanresults is None: 155 if _ba.in_logic_thread(): 156 logging.warning( 157 'ba.meta._wait_for_scan_results()' 158 ' called in logic thread before scan completed;' 159 ' this can cause hitches.' 160 ) 161 162 # Now wait a bit for the scan to complete. 163 # Eventually error though if it doesn't. 164 starttime = time.time() 165 while self.scanresults is None: 166 time.sleep(0.05) 167 if time.time() - starttime > 10.0: 168 raise TimeoutError( 169 'timeout waiting for meta scan to complete.' 170 ) 171 return self.scanresults 172 173 def _run_scan_in_bg(self) -> None: 174 """Runs a scan (for use in background thread).""" 175 try: 176 assert self._scan is not None 177 self._scan.run() 178 results = self._scan.results 179 self._scan = None 180 except Exception as exc: 181 results = ScanResults(errors=[f'Scan exception: {exc}']) 182 183 # Place results and tell the logic thread they're ready. 184 self.scanresults = results 185 _ba.pushcall(self._handle_scan_results, from_other_thread=True) 186 187 def _handle_scan_results(self) -> None: 188 """Called in the logic thread with results of a completed scan.""" 189 from ba._language import Lstr 190 191 assert _ba.in_logic_thread() 192 193 results = self.scanresults 194 assert results is not None 195 196 # Spit out any warnings/errors that happened. 197 # Warnings generally only get printed locally for users' benefit 198 # (things like out-of-date scripts being ignored, etc.) 199 # Errors are more serious and will get included in the regular log. 200 if results.warnings or results.errors: 201 import textwrap 202 203 _ba.screenmessage( 204 Lstr(resource='scanScriptsErrorText'), color=(1, 0, 0) 205 ) 206 _ba.playsound(_ba.getsound('error')) 207 if results.warnings: 208 allwarnings = textwrap.indent( 209 '\n'.join(results.warnings), 'Warning (meta-scan): ' 210 ) 211 logging.warning(allwarnings) 212 if results.errors: 213 allerrors = textwrap.indent( 214 '\n'.join(results.errors), 'Error (meta-scan): ' 215 ) 216 logging.error(allerrors) 217 218 # Let the game know we're done. 219 assert self._scan_complete_cb is not None 220 self._scan_complete_cb()
Subsystem for working with script metadata in the app.
Category: App Classes
Access the single shared instance of this class at 'ba.app.meta'.
61 def __init__(self) -> None: 62 63 self._scan: DirectoryScan | None = None 64 65 # Can be populated before starting the scan. 66 self.extra_scan_dirs: list[str] = [] 67 68 # Results populated once scan is complete. 69 self.scanresults: ScanResults | None = None 70 71 self._scan_complete_cb: Callable[[], None] | None = None
73 def start_scan(self, scan_complete_cb: Callable[[], None]) -> None: 74 """Begin the overall scan. 75 76 This will start scanning built in directories (which for vanilla 77 installs should be the vast majority of the work). This should only 78 be called once. 79 """ 80 assert self._scan_complete_cb is None 81 assert self._scan is None 82 83 self._scan_complete_cb = scan_complete_cb 84 self._scan = DirectoryScan( 85 [_ba.app.python_directory_app, _ba.app.python_directory_user] 86 ) 87 88 Thread(target=self._run_scan_in_bg, daemon=True).start()
Begin the overall scan.
This will start scanning built in directories (which for vanilla installs should be the vast majority of the work). This should only be called once.
90 def start_extra_scan(self) -> None: 91 """Proceed to the extra_scan_dirs portion of the scan. 92 93 This is for parts of the scan that must be delayed until 94 workspace sync completion or other such events. This must be 95 called exactly once. 96 """ 97 assert self._scan is not None 98 self._scan.set_extras(self.extra_scan_dirs)
Proceed to the extra_scan_dirs portion of the scan.
This is for parts of the scan that must be delayed until workspace sync completion or other such events. This must be called exactly once.
100 def load_exported_classes( 101 self, 102 cls: type[T], 103 completion_cb: Callable[[list[type[T]]], None], 104 completion_cb_in_bg_thread: bool = False, 105 ) -> None: 106 """High level function to load meta-exported classes. 107 108 Will wait for scanning to complete if necessary, and will load all 109 registered classes of a particular type in a background thread before 110 calling the passed callback in the logic thread. Errors may be logged 111 to messaged to the user in some way but the callback will be called 112 regardless. 113 To run the completion callback directly in the bg thread where the 114 loading work happens, pass completion_cb_in_bg_thread=True. 115 """ 116 Thread( 117 target=tpartial( 118 self._load_exported_classes, 119 cls, 120 completion_cb, 121 completion_cb_in_bg_thread, 122 ), 123 daemon=True, 124 ).start()
High level function to load meta-exported classes.
Will wait for scanning to complete if necessary, and will load all registered classes of a particular type in a background thread before calling the passed callback in the logic thread. Errors may be logged to messaged to the user in some way but the callback will be called regardless. To run the completion callback directly in the bg thread where the loading work happens, pass completion_cb_in_bg_thread=True.
522class Model: 523 524 """A reference to a model. 525 526 Category: **Asset Classes** 527 528 Models are used for drawing. 529 Use ba.getmodel() to instantiate one. 530 """ 531 532 pass
A reference to a model.
Category: Asset Classes
Models are used for drawing. Use ba.getmodel() to instantiate one.
23class MultiTeamSession(Session): 24 """Common base class for ba.DualTeamSession and ba.FreeForAllSession. 25 26 Category: **Gameplay Classes** 27 28 Free-for-all-mode is essentially just teams-mode with each ba.Player having 29 their own ba.Team, so there is much overlap in functionality. 30 """ 31 32 # These should be overridden. 33 _playlist_selection_var = 'UNSET Playlist Selection' 34 _playlist_randomize_var = 'UNSET Playlist Randomize' 35 _playlists_var = 'UNSET Playlists' 36 37 def __init__(self) -> None: 38 """Set up playlists and launches a ba.Activity to accept joiners.""" 39 # pylint: disable=cyclic-import 40 from ba import _playlist 41 from bastd.activity.multiteamjoin import MultiTeamJoinActivity 42 43 app = _ba.app 44 cfg = app.config 45 46 if self.use_teams: 47 team_names = cfg.get('Custom Team Names', DEFAULT_TEAM_NAMES) 48 team_colors = cfg.get('Custom Team Colors', DEFAULT_TEAM_COLORS) 49 else: 50 team_names = None 51 team_colors = None 52 53 # print('FIXME: TEAM BASE SESSION WOULD CALC DEPS.') 54 depsets: Sequence[ba.DependencySet] = [] 55 56 super().__init__( 57 depsets, 58 team_names=team_names, 59 team_colors=team_colors, 60 min_players=1, 61 max_players=self.get_max_players(), 62 ) 63 64 self._series_length = app.teams_series_length 65 self._ffa_series_length = app.ffa_series_length 66 67 show_tutorial = cfg.get('Show Tutorial', True) 68 69 self._tutorial_activity_instance: ba.Activity | None 70 if show_tutorial: 71 from bastd.tutorial import TutorialActivity 72 73 # Get this loading. 74 self._tutorial_activity_instance = _ba.newactivity(TutorialActivity) 75 else: 76 self._tutorial_activity_instance = None 77 78 self._playlist_name = cfg.get( 79 self._playlist_selection_var, '__default__' 80 ) 81 self._playlist_randomize = cfg.get(self._playlist_randomize_var, False) 82 83 # Which game activity we're on. 84 self._game_number = 0 85 86 playlists = cfg.get(self._playlists_var, {}) 87 88 if ( 89 self._playlist_name != '__default__' 90 and self._playlist_name in playlists 91 ): 92 93 # Make sure to copy this, as we muck with it in place once we've 94 # got it and we don't want that to affect our config. 95 playlist = copy.deepcopy(playlists[self._playlist_name]) 96 else: 97 if self.use_teams: 98 playlist = _playlist.get_default_teams_playlist() 99 else: 100 playlist = _playlist.get_default_free_for_all_playlist() 101 102 # Resolve types and whatnot to get our final playlist. 103 playlist_resolved = _playlist.filter_playlist( 104 playlist, 105 sessiontype=type(self), 106 add_resolved_type=True, 107 name='default teams' if self.use_teams else 'default ffa', 108 ) 109 110 if not playlist_resolved: 111 raise RuntimeError('Playlist contains no valid games.') 112 113 self._playlist = ShuffleList( 114 playlist_resolved, shuffle=self._playlist_randomize 115 ) 116 117 # Get a game on deck ready to go. 118 self._current_game_spec: dict[str, Any] | None = None 119 self._next_game_spec: dict[str, Any] = self._playlist.pull_next() 120 self._next_game: type[ba.GameActivity] = self._next_game_spec[ 121 'resolved_type' 122 ] 123 124 # Go ahead and instantiate the next game we'll 125 # use so it has lots of time to load. 126 self._instantiate_next_game() 127 128 # Start in our custom join screen. 129 self.setactivity(_ba.newactivity(MultiTeamJoinActivity)) 130 131 def get_ffa_series_length(self) -> int: 132 """Return free-for-all series length.""" 133 return self._ffa_series_length 134 135 def get_series_length(self) -> int: 136 """Return teams series length.""" 137 return self._series_length 138 139 def get_next_game_description(self) -> ba.Lstr: 140 """Returns a description of the next game on deck.""" 141 # pylint: disable=cyclic-import 142 from ba._gameactivity import GameActivity 143 144 gametype: type[GameActivity] = self._next_game_spec['resolved_type'] 145 assert issubclass(gametype, GameActivity) 146 return gametype.get_settings_display_string(self._next_game_spec) 147 148 def get_game_number(self) -> int: 149 """Returns which game in the series is currently being played.""" 150 return self._game_number 151 152 def on_team_join(self, team: ba.SessionTeam) -> None: 153 team.customdata['previous_score'] = team.customdata['score'] = 0 154 155 def get_max_players(self) -> int: 156 """Return max number of ba.Player-s allowed to join the game at once""" 157 if self.use_teams: 158 return _ba.app.config.get('Team Game Max Players', 8) 159 return _ba.app.config.get('Free-for-All Max Players', 8) 160 161 def _instantiate_next_game(self) -> None: 162 self._next_game_instance = _ba.newactivity( 163 self._next_game_spec['resolved_type'], 164 self._next_game_spec['settings'], 165 ) 166 167 def on_activity_end(self, activity: ba.Activity, results: Any) -> None: 168 # pylint: disable=cyclic-import 169 from bastd.tutorial import TutorialActivity 170 from bastd.activity.multiteamvictory import ( 171 TeamSeriesVictoryScoreScreenActivity, 172 ) 173 from ba._activitytypes import ( 174 TransitionActivity, 175 JoinActivity, 176 ScoreScreenActivity, 177 ) 178 179 # If we have a tutorial to show, that's the first thing we do no 180 # matter what. 181 if self._tutorial_activity_instance is not None: 182 self.setactivity(self._tutorial_activity_instance) 183 self._tutorial_activity_instance = None 184 185 # If we're leaving the tutorial activity, pop a transition activity 186 # to transition us into a round gracefully (otherwise we'd snap from 187 # one terrain to another instantly). 188 elif isinstance(activity, TutorialActivity): 189 self.setactivity(_ba.newactivity(TransitionActivity)) 190 191 # If we're in a between-round activity or a restart-activity, hop 192 # into a round. 193 elif isinstance( 194 activity, (JoinActivity, TransitionActivity, ScoreScreenActivity) 195 ): 196 197 # If we're coming from a series-end activity, reset scores. 198 if isinstance(activity, TeamSeriesVictoryScoreScreenActivity): 199 self.stats.reset() 200 self._game_number = 0 201 for team in self.sessionteams: 202 team.customdata['score'] = 0 203 204 # Otherwise just set accum (per-game) scores. 205 else: 206 self.stats.reset_accum() 207 208 next_game = self._next_game_instance 209 210 self._current_game_spec = self._next_game_spec 211 self._next_game_spec = self._playlist.pull_next() 212 self._game_number += 1 213 214 # Instantiate the next now so they have plenty of time to load. 215 self._instantiate_next_game() 216 217 # (Re)register all players and wire stats to our next activity. 218 for player in self.sessionplayers: 219 # ..but only ones who have been placed on a team 220 # (ie: no longer sitting in the lobby). 221 try: 222 has_team = player.sessionteam is not None 223 except NotFoundError: 224 has_team = False 225 if has_team: 226 self.stats.register_sessionplayer(player) 227 self.stats.setactivity(next_game) 228 229 # Now flip the current activity. 230 self.setactivity(next_game) 231 232 # If we're leaving a round, go to the score screen. 233 else: 234 self._switch_to_score_screen(results) 235 236 def _switch_to_score_screen(self, results: Any) -> None: 237 """Switch to a score screen after leaving a round.""" 238 del results # Unused arg. 239 print_error('this should be overridden') 240 241 def announce_game_results( 242 self, 243 activity: ba.GameActivity, 244 results: ba.GameResults, 245 delay: float, 246 announce_winning_team: bool = True, 247 ) -> None: 248 """Show basic game result at the end of a game. 249 250 (before transitioning to a score screen). 251 This will include a zoom-text of 'BLUE WINS' 252 or whatnot, along with a possible audio 253 announcement of the same. 254 """ 255 # pylint: disable=cyclic-import 256 # pylint: disable=too-many-locals 257 from ba._math import normalized_color 258 from ba._general import Call 259 from ba._gameutils import cameraflash 260 from ba._language import Lstr 261 from ba._freeforallsession import FreeForAllSession 262 from ba._messages import CelebrateMessage 263 264 _ba.timer(delay, Call(_ba.playsound, _ba.getsound('boxingBell'))) 265 266 if announce_winning_team: 267 winning_sessionteam = results.winning_sessionteam 268 if winning_sessionteam is not None: 269 # Have all players celebrate. 270 celebrate_msg = CelebrateMessage(duration=10.0) 271 assert winning_sessionteam.activityteam is not None 272 for player in winning_sessionteam.activityteam.players: 273 if player.actor: 274 player.actor.handlemessage(celebrate_msg) 275 cameraflash() 276 277 # Some languages say "FOO WINS" different for teams vs players. 278 if isinstance(self, FreeForAllSession): 279 wins_resource = 'winsPlayerText' 280 else: 281 wins_resource = 'winsTeamText' 282 wins_text = Lstr( 283 resource=wins_resource, 284 subs=[('${NAME}', winning_sessionteam.name)], 285 ) 286 activity.show_zoom_message( 287 wins_text, 288 scale=0.85, 289 color=normalized_color(winning_sessionteam.color), 290 )
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.
37 def __init__(self) -> None: 38 """Set up playlists and launches a ba.Activity to accept joiners.""" 39 # pylint: disable=cyclic-import 40 from ba import _playlist 41 from bastd.activity.multiteamjoin import MultiTeamJoinActivity 42 43 app = _ba.app 44 cfg = app.config 45 46 if self.use_teams: 47 team_names = cfg.get('Custom Team Names', DEFAULT_TEAM_NAMES) 48 team_colors = cfg.get('Custom Team Colors', DEFAULT_TEAM_COLORS) 49 else: 50 team_names = None 51 team_colors = None 52 53 # print('FIXME: TEAM BASE SESSION WOULD CALC DEPS.') 54 depsets: Sequence[ba.DependencySet] = [] 55 56 super().__init__( 57 depsets, 58 team_names=team_names, 59 team_colors=team_colors, 60 min_players=1, 61 max_players=self.get_max_players(), 62 ) 63 64 self._series_length = app.teams_series_length 65 self._ffa_series_length = app.ffa_series_length 66 67 show_tutorial = cfg.get('Show Tutorial', True) 68 69 self._tutorial_activity_instance: ba.Activity | None 70 if show_tutorial: 71 from bastd.tutorial import TutorialActivity 72 73 # Get this loading. 74 self._tutorial_activity_instance = _ba.newactivity(TutorialActivity) 75 else: 76 self._tutorial_activity_instance = None 77 78 self._playlist_name = cfg.get( 79 self._playlist_selection_var, '__default__' 80 ) 81 self._playlist_randomize = cfg.get(self._playlist_randomize_var, False) 82 83 # Which game activity we're on. 84 self._game_number = 0 85 86 playlists = cfg.get(self._playlists_var, {}) 87 88 if ( 89 self._playlist_name != '__default__' 90 and self._playlist_name in playlists 91 ): 92 93 # Make sure to copy this, as we muck with it in place once we've 94 # got it and we don't want that to affect our config. 95 playlist = copy.deepcopy(playlists[self._playlist_name]) 96 else: 97 if self.use_teams: 98 playlist = _playlist.get_default_teams_playlist() 99 else: 100 playlist = _playlist.get_default_free_for_all_playlist() 101 102 # Resolve types and whatnot to get our final playlist. 103 playlist_resolved = _playlist.filter_playlist( 104 playlist, 105 sessiontype=type(self), 106 add_resolved_type=True, 107 name='default teams' if self.use_teams else 'default ffa', 108 ) 109 110 if not playlist_resolved: 111 raise RuntimeError('Playlist contains no valid games.') 112 113 self._playlist = ShuffleList( 114 playlist_resolved, shuffle=self._playlist_randomize 115 ) 116 117 # Get a game on deck ready to go. 118 self._current_game_spec: dict[str, Any] | None = None 119 self._next_game_spec: dict[str, Any] = self._playlist.pull_next() 120 self._next_game: type[ba.GameActivity] = self._next_game_spec[ 121 'resolved_type' 122 ] 123 124 # Go ahead and instantiate the next game we'll 125 # use so it has lots of time to load. 126 self._instantiate_next_game() 127 128 # Start in our custom join screen. 129 self.setactivity(_ba.newactivity(MultiTeamJoinActivity))
Set up playlists and launches a ba.Activity to accept joiners.
131 def get_ffa_series_length(self) -> int: 132 """Return free-for-all series length.""" 133 return self._ffa_series_length
Return free-for-all series length.
135 def get_series_length(self) -> int: 136 """Return teams series length.""" 137 return self._series_length
Return teams series length.
139 def get_next_game_description(self) -> ba.Lstr: 140 """Returns a description of the next game on deck.""" 141 # pylint: disable=cyclic-import 142 from ba._gameactivity import GameActivity 143 144 gametype: type[GameActivity] = self._next_game_spec['resolved_type'] 145 assert issubclass(gametype, GameActivity) 146 return gametype.get_settings_display_string(self._next_game_spec)
Returns a description of the next game on deck.
148 def get_game_number(self) -> int: 149 """Returns which game in the series is currently being played.""" 150 return self._game_number
Returns which game in the series is currently being played.
152 def on_team_join(self, team: ba.SessionTeam) -> None: 153 team.customdata['previous_score'] = team.customdata['score'] = 0
Called when a new ba.Team joins the session.
155 def get_max_players(self) -> int: 156 """Return max number of ba.Player-s allowed to join the game at once""" 157 if self.use_teams: 158 return _ba.app.config.get('Team Game Max Players', 8) 159 return _ba.app.config.get('Free-for-All Max Players', 8)
Return max number of ba.Player-s allowed to join the game at once
167 def on_activity_end(self, activity: ba.Activity, results: Any) -> None: 168 # pylint: disable=cyclic-import 169 from bastd.tutorial import TutorialActivity 170 from bastd.activity.multiteamvictory import ( 171 TeamSeriesVictoryScoreScreenActivity, 172 ) 173 from ba._activitytypes import ( 174 TransitionActivity, 175 JoinActivity, 176 ScoreScreenActivity, 177 ) 178 179 # If we have a tutorial to show, that's the first thing we do no 180 # matter what. 181 if self._tutorial_activity_instance is not None: 182 self.setactivity(self._tutorial_activity_instance) 183 self._tutorial_activity_instance = None 184 185 # If we're leaving the tutorial activity, pop a transition activity 186 # to transition us into a round gracefully (otherwise we'd snap from 187 # one terrain to another instantly). 188 elif isinstance(activity, TutorialActivity): 189 self.setactivity(_ba.newactivity(TransitionActivity)) 190 191 # If we're in a between-round activity or a restart-activity, hop 192 # into a round. 193 elif isinstance( 194 activity, (JoinActivity, TransitionActivity, ScoreScreenActivity) 195 ): 196 197 # If we're coming from a series-end activity, reset scores. 198 if isinstance(activity, TeamSeriesVictoryScoreScreenActivity): 199 self.stats.reset() 200 self._game_number = 0 201 for team in self.sessionteams: 202 team.customdata['score'] = 0 203 204 # Otherwise just set accum (per-game) scores. 205 else: 206 self.stats.reset_accum() 207 208 next_game = self._next_game_instance 209 210 self._current_game_spec = self._next_game_spec 211 self._next_game_spec = self._playlist.pull_next() 212 self._game_number += 1 213 214 # Instantiate the next now so they have plenty of time to load. 215 self._instantiate_next_game() 216 217 # (Re)register all players and wire stats to our next activity. 218 for player in self.sessionplayers: 219 # ..but only ones who have been placed on a team 220 # (ie: no longer sitting in the lobby). 221 try: 222 has_team = player.sessionteam is not None 223 except NotFoundError: 224 has_team = False 225 if has_team: 226 self.stats.register_sessionplayer(player) 227 self.stats.setactivity(next_game) 228 229 # Now flip the current activity. 230 self.setactivity(next_game) 231 232 # If we're leaving a round, go to the score screen. 233 else: 234 self._switch_to_score_screen(results)
Called when the current ba.Activity has ended.
The ba.Session should look at the results and start another ba.Activity.
241 def announce_game_results( 242 self, 243 activity: ba.GameActivity, 244 results: ba.GameResults, 245 delay: float, 246 announce_winning_team: bool = True, 247 ) -> None: 248 """Show basic game result at the end of a game. 249 250 (before transitioning to a score screen). 251 This will include a zoom-text of 'BLUE WINS' 252 or whatnot, along with a possible audio 253 announcement of the same. 254 """ 255 # pylint: disable=cyclic-import 256 # pylint: disable=too-many-locals 257 from ba._math import normalized_color 258 from ba._general import Call 259 from ba._gameutils import cameraflash 260 from ba._language import Lstr 261 from ba._freeforallsession import FreeForAllSession 262 from ba._messages import CelebrateMessage 263 264 _ba.timer(delay, Call(_ba.playsound, _ba.getsound('boxingBell'))) 265 266 if announce_winning_team: 267 winning_sessionteam = results.winning_sessionteam 268 if winning_sessionteam is not None: 269 # Have all players celebrate. 270 celebrate_msg = CelebrateMessage(duration=10.0) 271 assert winning_sessionteam.activityteam is not None 272 for player in winning_sessionteam.activityteam.players: 273 if player.actor: 274 player.actor.handlemessage(celebrate_msg) 275 cameraflash() 276 277 # Some languages say "FOO WINS" different for teams vs players. 278 if isinstance(self, FreeForAllSession): 279 wins_resource = 'winsPlayerText' 280 else: 281 wins_resource = 'winsTeamText' 282 wins_text = Lstr( 283 resource=wins_resource, 284 subs=[('${NAME}', winning_sessionteam.name)], 285 ) 286 activity.show_zoom_message( 287 wins_text, 288 scale=0.85, 289 color=normalized_color(winning_sessionteam.color), 290 )
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.
402class MusicPlayer: 403 """Wrangles soundtrack music playback. 404 405 Category: **App Classes** 406 407 Music can be played either through the game itself 408 or via a platform-specific external player. 409 """ 410 411 def __init__(self) -> None: 412 self._have_set_initial_volume = False 413 self._entry_to_play: Any = None 414 self._volume = 1.0 415 self._actually_playing = False 416 417 def select_entry( 418 self, 419 callback: Callable[[Any], None], 420 current_entry: Any, 421 selection_target_name: str, 422 ) -> Any: 423 """Summons a UI to select a new soundtrack entry.""" 424 return self.on_select_entry( 425 callback, current_entry, selection_target_name 426 ) 427 428 def set_volume(self, volume: float) -> None: 429 """Set player volume (value should be between 0 and 1).""" 430 self._volume = volume 431 self.on_set_volume(volume) 432 self._update_play_state() 433 434 def play(self, entry: Any) -> None: 435 """Play provided entry.""" 436 if not self._have_set_initial_volume: 437 self._volume = _ba.app.config.resolve('Music Volume') 438 self.on_set_volume(self._volume) 439 self._have_set_initial_volume = True 440 self._entry_to_play = copy.deepcopy(entry) 441 442 # If we're currently *actually* playing something, 443 # switch to the new thing. 444 # Otherwise update state which will start us playing *only* 445 # if proper (volume > 0, etc). 446 if self._actually_playing: 447 self.on_play(self._entry_to_play) 448 else: 449 self._update_play_state() 450 451 def stop(self) -> None: 452 """Stop any playback that is occurring.""" 453 self._entry_to_play = None 454 self._update_play_state() 455 456 def shutdown(self) -> None: 457 """Shutdown music playback completely.""" 458 self.on_app_shutdown() 459 460 def on_select_entry( 461 self, 462 callback: Callable[[Any], None], 463 current_entry: Any, 464 selection_target_name: str, 465 ) -> Any: 466 """Present a GUI to select an entry. 467 468 The callback should be called with a valid entry or None to 469 signify that the default soundtrack should be used..""" 470 471 # Subclasses should override the following: 472 473 def on_set_volume(self, volume: float) -> None: 474 """Called when the volume should be changed.""" 475 476 def on_play(self, entry: Any) -> None: 477 """Called when a new song/playlist/etc should be played.""" 478 479 def on_stop(self) -> None: 480 """Called when the music should stop.""" 481 482 def on_app_shutdown(self) -> None: 483 """Called on final app shutdown.""" 484 485 def _update_play_state(self) -> None: 486 487 # If we aren't playing, should be, and have positive volume, do so. 488 if not self._actually_playing: 489 if self._entry_to_play is not None and self._volume > 0.0: 490 self.on_play(self._entry_to_play) 491 self._actually_playing = True 492 else: 493 if self._actually_playing and ( 494 self._entry_to_play is None or self._volume <= 0.0 495 ): 496 self.on_stop() 497 self._actually_playing = False
Wrangles soundtrack music playback.
Category: App Classes
Music can be played either through the game itself or via a platform-specific external player.
417 def select_entry( 418 self, 419 callback: Callable[[Any], None], 420 current_entry: Any, 421 selection_target_name: str, 422 ) -> Any: 423 """Summons a UI to select a new soundtrack entry.""" 424 return self.on_select_entry( 425 callback, current_entry, selection_target_name 426 )
Summons a UI to select a new soundtrack entry.
428 def set_volume(self, volume: float) -> None: 429 """Set player volume (value should be between 0 and 1).""" 430 self._volume = volume 431 self.on_set_volume(volume) 432 self._update_play_state()
Set player volume (value should be between 0 and 1).
434 def play(self, entry: Any) -> None: 435 """Play provided entry.""" 436 if not self._have_set_initial_volume: 437 self._volume = _ba.app.config.resolve('Music Volume') 438 self.on_set_volume(self._volume) 439 self._have_set_initial_volume = True 440 self._entry_to_play = copy.deepcopy(entry) 441 442 # If we're currently *actually* playing something, 443 # switch to the new thing. 444 # Otherwise update state which will start us playing *only* 445 # if proper (volume > 0, etc). 446 if self._actually_playing: 447 self.on_play(self._entry_to_play) 448 else: 449 self._update_play_state()
Play provided entry.
451 def stop(self) -> None: 452 """Stop any playback that is occurring.""" 453 self._entry_to_play = None 454 self._update_play_state()
Stop any playback that is occurring.
456 def shutdown(self) -> None: 457 """Shutdown music playback completely.""" 458 self.on_app_shutdown()
Shutdown music playback completely.
460 def on_select_entry( 461 self, 462 callback: Callable[[Any], None], 463 current_entry: Any, 464 selection_target_name: str, 465 ) -> Any: 466 """Present a GUI to select an entry. 467 468 The callback should be called with a valid entry or None to 469 signify that the default soundtrack should be used.."""
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..
473 def on_set_volume(self, volume: float) -> None: 474 """Called when the volume should be changed."""
Called when the volume should be changed.
476 def on_play(self, entry: Any) -> None: 477 """Called when a new song/playlist/etc should be played."""
Called when a new song/playlist/etc should be played.
53class MusicPlayMode(Enum): 54 """Influences behavior when playing music. 55 56 Category: **Enums** 57 """ 58 59 REGULAR = 'regular' 60 TEST = 'test'
Influences behavior when playing music.
Category: Enums
Inherited Members
- enum.Enum
- name
- value
112class MusicSubsystem: 113 """Subsystem for music playback in the app. 114 115 Category: **App Classes** 116 117 Access the single shared instance of this class at 'ba.app.music'. 118 """ 119 120 def __init__(self) -> None: 121 # pylint: disable=cyclic-import 122 self._music_node: _ba.Node | None = None 123 self._music_mode: MusicPlayMode = MusicPlayMode.REGULAR 124 self._music_player: MusicPlayer | None = None 125 self._music_player_type: type[MusicPlayer] | None = None 126 self.music_types: dict[MusicPlayMode, MusicType | None] = { 127 MusicPlayMode.REGULAR: None, 128 MusicPlayMode.TEST: None, 129 } 130 131 # Set up custom music players for platforms that support them. 132 # FIXME: should generalize this to support arbitrary players per 133 # platform (which can be discovered via ba_meta). 134 # Our standard asset playback should probably just be one of them 135 # instead of a special case. 136 if self.supports_soundtrack_entry_type('musicFile'): 137 from ba.osmusic import OSMusicPlayer 138 139 self._music_player_type = OSMusicPlayer 140 elif self.supports_soundtrack_entry_type('iTunesPlaylist'): 141 from ba.macmusicapp import MacMusicAppMusicPlayer 142 143 self._music_player_type = MacMusicAppMusicPlayer 144 145 def on_app_launch(self) -> None: 146 """Should be called by app on_app_launch().""" 147 148 # If we're using a non-default playlist, lets go ahead and get our 149 # music-player going since it may hitch (better while we're faded 150 # out than later). 151 try: 152 cfg = _ba.app.config 153 if 'Soundtrack' in cfg and cfg['Soundtrack'] not in [ 154 '__default__', 155 'Default Soundtrack', 156 ]: 157 self.get_music_player() 158 except Exception: 159 from ba import _error 160 161 _error.print_exception('error prepping music-player') 162 163 def on_app_shutdown(self) -> None: 164 """Should be called when the app is shutting down.""" 165 if self._music_player is not None: 166 self._music_player.shutdown() 167 168 def have_music_player(self) -> bool: 169 """Returns whether a music player is present.""" 170 return self._music_player_type is not None 171 172 def get_music_player(self) -> MusicPlayer: 173 """Returns the system music player, instantiating if necessary.""" 174 if self._music_player is None: 175 if self._music_player_type is None: 176 raise TypeError('no music player type set') 177 self._music_player = self._music_player_type() 178 return self._music_player 179 180 def music_volume_changed(self, val: float) -> None: 181 """Should be called when changing the music volume.""" 182 if self._music_player is not None: 183 self._music_player.set_volume(val) 184 185 def set_music_play_mode( 186 self, mode: MusicPlayMode, force_restart: bool = False 187 ) -> None: 188 """Sets music play mode; used for soundtrack testing/etc.""" 189 old_mode = self._music_mode 190 self._music_mode = mode 191 if old_mode != self._music_mode or force_restart: 192 193 # If we're switching into test mode we don't 194 # actually play anything until its requested. 195 # If we're switching *out* of test mode though 196 # we want to go back to whatever the normal song was. 197 if mode is MusicPlayMode.REGULAR: 198 mtype = self.music_types[MusicPlayMode.REGULAR] 199 self.do_play_music(None if mtype is None else mtype.value) 200 201 def supports_soundtrack_entry_type(self, entry_type: str) -> bool: 202 """Return whether provided soundtrack entry type is supported here.""" 203 uas = _ba.env()['user_agent_string'] 204 assert isinstance(uas, str) 205 206 # FIXME: Generalize this. 207 if entry_type == 'iTunesPlaylist': 208 return 'Mac' in uas 209 if entry_type in ('musicFile', 'musicFolder'): 210 return ( 211 'android' in uas 212 and _ba.android_get_external_files_dir() is not None 213 ) 214 if entry_type == 'default': 215 return True 216 return False 217 218 def get_soundtrack_entry_type(self, entry: Any) -> str: 219 """Given a soundtrack entry, returns its type, taking into 220 account what is supported locally.""" 221 try: 222 if entry is None: 223 entry_type = 'default' 224 225 # Simple string denotes iTunesPlaylist (legacy format). 226 elif isinstance(entry, str): 227 entry_type = 'iTunesPlaylist' 228 229 # For other entries we expect type and name strings in a dict. 230 elif ( 231 isinstance(entry, dict) 232 and 'type' in entry 233 and isinstance(entry['type'], str) 234 and 'name' in entry 235 and isinstance(entry['name'], str) 236 ): 237 entry_type = entry['type'] 238 else: 239 raise TypeError( 240 'invalid soundtrack entry: ' 241 + str(entry) 242 + ' (type ' 243 + str(type(entry)) 244 + ')' 245 ) 246 if self.supports_soundtrack_entry_type(entry_type): 247 return entry_type 248 raise ValueError('invalid soundtrack entry:' + str(entry)) 249 except Exception: 250 from ba import _error 251 252 _error.print_exception() 253 return 'default' 254 255 def get_soundtrack_entry_name(self, entry: Any) -> str: 256 """Given a soundtrack entry, returns its name.""" 257 try: 258 if entry is None: 259 raise TypeError('entry is None') 260 261 # Simple string denotes an iTunesPlaylist name (legacy entry). 262 if isinstance(entry, str): 263 return entry 264 265 # For other entries we expect type and name strings in a dict. 266 if ( 267 isinstance(entry, dict) 268 and 'type' in entry 269 and isinstance(entry['type'], str) 270 and 'name' in entry 271 and isinstance(entry['name'], str) 272 ): 273 return entry['name'] 274 raise ValueError('invalid soundtrack entry:' + str(entry)) 275 except Exception: 276 from ba import _error 277 278 _error.print_exception() 279 return 'default' 280 281 def on_app_resume(self) -> None: 282 """Should be run when the app resumes from a suspended state.""" 283 if _ba.is_os_playing_music(): 284 self.do_play_music(None) 285 286 def do_play_music( 287 self, 288 musictype: MusicType | str | None, 289 continuous: bool = False, 290 mode: MusicPlayMode = MusicPlayMode.REGULAR, 291 testsoundtrack: dict[str, Any] | None = None, 292 ) -> None: 293 """Plays the requested music type/mode. 294 295 For most cases, setmusic() is the proper call to use, which itself 296 calls this. Certain cases, however, such as soundtrack testing, may 297 require calling this directly. 298 """ 299 300 # We can be passed a MusicType or the string value corresponding 301 # to one. 302 if musictype is not None: 303 try: 304 musictype = MusicType(musictype) 305 except ValueError: 306 print(f"Invalid music type: '{musictype}'") 307 musictype = None 308 309 with _ba.Context('ui'): 310 311 # If they don't want to restart music and we're already 312 # playing what's requested, we're done. 313 if continuous and self.music_types[mode] is musictype: 314 return 315 self.music_types[mode] = musictype 316 317 # If the OS tells us there's currently music playing, 318 # all our operations default to playing nothing. 319 if _ba.is_os_playing_music(): 320 musictype = None 321 322 # If we're not in the mode this music is being set for, 323 # don't actually change what's playing. 324 if mode != self._music_mode: 325 return 326 327 # Some platforms have a special music-player for things like iTunes 328 # soundtracks, mp3s, etc. if this is the case, attempt to grab an 329 # entry for this music-type, and if we have one, have the 330 # music-player play it. If not, we'll play game music ourself. 331 if musictype is not None and self._music_player_type is not None: 332 if testsoundtrack is not None: 333 soundtrack = testsoundtrack 334 else: 335 soundtrack = self._get_user_soundtrack() 336 entry = soundtrack.get(musictype.value) 337 else: 338 entry = None 339 340 # Go through music-player. 341 if entry is not None: 342 self._play_music_player_music(entry) 343 344 # Handle via internal music. 345 else: 346 self._play_internal_music(musictype) 347 348 def _get_user_soundtrack(self) -> dict[str, Any]: 349 """Return current user soundtrack or empty dict otherwise.""" 350 cfg = _ba.app.config 351 soundtrack: dict[str, Any] = {} 352 soundtrackname = cfg.get('Soundtrack') 353 if soundtrackname is not None and soundtrackname != '__default__': 354 try: 355 soundtrack = cfg.get('Soundtracks', {})[soundtrackname] 356 except Exception as exc: 357 print(f'Error looking up user soundtrack: {exc}') 358 soundtrack = {} 359 return soundtrack 360 361 def _play_music_player_music(self, entry: Any) -> None: 362 363 # Stop any existing internal music. 364 if self._music_node is not None: 365 self._music_node.delete() 366 self._music_node = None 367 368 # Do the thing. 369 self.get_music_player().play(entry) 370 371 def _play_internal_music(self, musictype: MusicType | None) -> None: 372 373 # Stop any existing music-player playback. 374 if self._music_player is not None: 375 self._music_player.stop() 376 377 # Stop any existing internal music. 378 if self._music_node: 379 self._music_node.delete() 380 self._music_node = None 381 382 # Start up new internal music. 383 if musictype is not None: 384 385 entry = ASSET_SOUNDTRACK_ENTRIES.get(musictype) 386 if entry is None: 387 print(f"Unknown music: '{musictype}'") 388 entry = ASSET_SOUNDTRACK_ENTRIES[MusicType.FLAG_CATCHER] 389 390 self._music_node = _ba.newnode( 391 type='sound', 392 attrs={ 393 'sound': _ba.getsound(entry.assetname), 394 'positional': False, 395 'music': True, 396 'volume': entry.volume * 5.0, 397 'loop': entry.loop, 398 }, 399 )
Subsystem for music playback in the app.
Category: App Classes
Access the single shared instance of this class at 'ba.app.music'.
120 def __init__(self) -> None: 121 # pylint: disable=cyclic-import 122 self._music_node: _ba.Node | None = None 123 self._music_mode: MusicPlayMode = MusicPlayMode.REGULAR 124 self._music_player: MusicPlayer | None = None 125 self._music_player_type: type[MusicPlayer] | None = None 126 self.music_types: dict[MusicPlayMode, MusicType | None] = { 127 MusicPlayMode.REGULAR: None, 128 MusicPlayMode.TEST: None, 129 } 130 131 # Set up custom music players for platforms that support them. 132 # FIXME: should generalize this to support arbitrary players per 133 # platform (which can be discovered via ba_meta). 134 # Our standard asset playback should probably just be one of them 135 # instead of a special case. 136 if self.supports_soundtrack_entry_type('musicFile'): 137 from ba.osmusic import OSMusicPlayer 138 139 self._music_player_type = OSMusicPlayer 140 elif self.supports_soundtrack_entry_type('iTunesPlaylist'): 141 from ba.macmusicapp import MacMusicAppMusicPlayer 142 143 self._music_player_type = MacMusicAppMusicPlayer
145 def on_app_launch(self) -> None: 146 """Should be called by app on_app_launch().""" 147 148 # If we're using a non-default playlist, lets go ahead and get our 149 # music-player going since it may hitch (better while we're faded 150 # out than later). 151 try: 152 cfg = _ba.app.config 153 if 'Soundtrack' in cfg and cfg['Soundtrack'] not in [ 154 '__default__', 155 'Default Soundtrack', 156 ]: 157 self.get_music_player() 158 except Exception: 159 from ba import _error 160 161 _error.print_exception('error prepping music-player')
Should be called by app on_app_launch().
163 def on_app_shutdown(self) -> None: 164 """Should be called when the app is shutting down.""" 165 if self._music_player is not None: 166 self._music_player.shutdown()
Should be called when the app is shutting down.
168 def have_music_player(self) -> bool: 169 """Returns whether a music player is present.""" 170 return self._music_player_type is not None
Returns whether a music player is present.
172 def get_music_player(self) -> MusicPlayer: 173 """Returns the system music player, instantiating if necessary.""" 174 if self._music_player is None: 175 if self._music_player_type is None: 176 raise TypeError('no music player type set') 177 self._music_player = self._music_player_type() 178 return self._music_player
Returns the system music player, instantiating if necessary.
180 def music_volume_changed(self, val: float) -> None: 181 """Should be called when changing the music volume.""" 182 if self._music_player is not None: 183 self._music_player.set_volume(val)
Should be called when changing the music volume.
185 def set_music_play_mode( 186 self, mode: MusicPlayMode, force_restart: bool = False 187 ) -> None: 188 """Sets music play mode; used for soundtrack testing/etc.""" 189 old_mode = self._music_mode 190 self._music_mode = mode 191 if old_mode != self._music_mode or force_restart: 192 193 # If we're switching into test mode we don't 194 # actually play anything until its requested. 195 # If we're switching *out* of test mode though 196 # we want to go back to whatever the normal song was. 197 if mode is MusicPlayMode.REGULAR: 198 mtype = self.music_types[MusicPlayMode.REGULAR] 199 self.do_play_music(None if mtype is None else mtype.value)
Sets music play mode; used for soundtrack testing/etc.
201 def supports_soundtrack_entry_type(self, entry_type: str) -> bool: 202 """Return whether provided soundtrack entry type is supported here.""" 203 uas = _ba.env()['user_agent_string'] 204 assert isinstance(uas, str) 205 206 # FIXME: Generalize this. 207 if entry_type == 'iTunesPlaylist': 208 return 'Mac' in uas 209 if entry_type in ('musicFile', 'musicFolder'): 210 return ( 211 'android' in uas 212 and _ba.android_get_external_files_dir() is not None 213 ) 214 if entry_type == 'default': 215 return True 216 return False
Return whether provided soundtrack entry type is supported here.
218 def get_soundtrack_entry_type(self, entry: Any) -> str: 219 """Given a soundtrack entry, returns its type, taking into 220 account what is supported locally.""" 221 try: 222 if entry is None: 223 entry_type = 'default' 224 225 # Simple string denotes iTunesPlaylist (legacy format). 226 elif isinstance(entry, str): 227 entry_type = 'iTunesPlaylist' 228 229 # For other entries we expect type and name strings in a dict. 230 elif ( 231 isinstance(entry, dict) 232 and 'type' in entry 233 and isinstance(entry['type'], str) 234 and 'name' in entry 235 and isinstance(entry['name'], str) 236 ): 237 entry_type = entry['type'] 238 else: 239 raise TypeError( 240 'invalid soundtrack entry: ' 241 + str(entry) 242 + ' (type ' 243 + str(type(entry)) 244 + ')' 245 ) 246 if self.supports_soundtrack_entry_type(entry_type): 247 return entry_type 248 raise ValueError('invalid soundtrack entry:' + str(entry)) 249 except Exception: 250 from ba import _error 251 252 _error.print_exception() 253 return 'default'
Given a soundtrack entry, returns its type, taking into account what is supported locally.
255 def get_soundtrack_entry_name(self, entry: Any) -> str: 256 """Given a soundtrack entry, returns its name.""" 257 try: 258 if entry is None: 259 raise TypeError('entry is None') 260 261 # Simple string denotes an iTunesPlaylist name (legacy entry). 262 if isinstance(entry, str): 263 return entry 264 265 # For other entries we expect type and name strings in a dict. 266 if ( 267 isinstance(entry, dict) 268 and 'type' in entry 269 and isinstance(entry['type'], str) 270 and 'name' in entry 271 and isinstance(entry['name'], str) 272 ): 273 return entry['name'] 274 raise ValueError('invalid soundtrack entry:' + str(entry)) 275 except Exception: 276 from ba import _error 277 278 _error.print_exception() 279 return 'default'
Given a soundtrack entry, returns its name.
281 def on_app_resume(self) -> None: 282 """Should be run when the app resumes from a suspended state.""" 283 if _ba.is_os_playing_music(): 284 self.do_play_music(None)
Should be run when the app resumes from a suspended state.
286 def do_play_music( 287 self, 288 musictype: MusicType | str | None, 289 continuous: bool = False, 290 mode: MusicPlayMode = MusicPlayMode.REGULAR, 291 testsoundtrack: dict[str, Any] | None = None, 292 ) -> None: 293 """Plays the requested music type/mode. 294 295 For most cases, setmusic() is the proper call to use, which itself 296 calls this. Certain cases, however, such as soundtrack testing, may 297 require calling this directly. 298 """ 299 300 # We can be passed a MusicType or the string value corresponding 301 # to one. 302 if musictype is not None: 303 try: 304 musictype = MusicType(musictype) 305 except ValueError: 306 print(f"Invalid music type: '{musictype}'") 307 musictype = None 308 309 with _ba.Context('ui'): 310 311 # If they don't want to restart music and we're already 312 # playing what's requested, we're done. 313 if continuous and self.music_types[mode] is musictype: 314 return 315 self.music_types[mode] = musictype 316 317 # If the OS tells us there's currently music playing, 318 # all our operations default to playing nothing. 319 if _ba.is_os_playing_music(): 320 musictype = None 321 322 # If we're not in the mode this music is being set for, 323 # don't actually change what's playing. 324 if mode != self._music_mode: 325 return 326 327 # Some platforms have a special music-player for things like iTunes 328 # soundtracks, mp3s, etc. if this is the case, attempt to grab an 329 # entry for this music-type, and if we have one, have the 330 # music-player play it. If not, we'll play game music ourself. 331 if musictype is not None and self._music_player_type is not None: 332 if testsoundtrack is not None: 333 soundtrack = testsoundtrack 334 else: 335 soundtrack = self._get_user_soundtrack() 336 entry = soundtrack.get(musictype.value) 337 else: 338 entry = None 339 340 # Go through music-player. 341 if entry is not None: 342 self._play_music_player_music(entry) 343 344 # Handle via internal music. 345 else: 346 self._play_internal_music(musictype)
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.
19class MusicType(Enum): 20 """Types of music available to play in-game. 21 22 Category: **Enums** 23 24 These do not correspond to specific pieces of music, but rather to 25 'situations'. The actual music played for each type can be overridden 26 by the game or by the user. 27 """ 28 29 MENU = 'Menu' 30 VICTORY = 'Victory' 31 CHAR_SELECT = 'CharSelect' 32 RUN_AWAY = 'RunAway' 33 ONSLAUGHT = 'Onslaught' 34 KEEP_AWAY = 'Keep Away' 35 RACE = 'Race' 36 EPIC_RACE = 'Epic Race' 37 SCORES = 'Scores' 38 GRAND_ROMP = 'GrandRomp' 39 TO_THE_DEATH = 'ToTheDeath' 40 CHOSEN_ONE = 'Chosen One' 41 FORWARD_MARCH = 'ForwardMarch' 42 FLAG_CATCHER = 'FlagCatcher' 43 SURVIVAL = 'Survival' 44 EPIC = 'Epic' 45 SPORTS = 'Sports' 46 HOCKEY = 'Hockey' 47 FOOTBALL = 'Football' 48 FLYING = 'Flying' 49 SCARY = 'Scary' 50 MARCHING = 'Marching'
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.
Inherited Members
- enum.Enum
- name
- value
2567def newactivity( 2568 activity_type: type[ba.Activity], settings: dict | None = None 2569) -> ba.Activity: 2570 2571 """Instantiates a ba.Activity given a type object. 2572 2573 Category: **General Utility Functions** 2574 2575 Activities require special setup and thus cannot be directly 2576 instantiated; you must go through this function. 2577 """ 2578 import ba # pylint: disable=cyclic-import 2579 2580 return ba.Activity(settings={})
Instantiates a ba.Activity given a type object.
Category: General Utility Functions
Activities require special setup and thus cannot be directly instantiated; you must go through this function.
2583def newnode( 2584 type: str, 2585 owner: ba.Node | None = None, 2586 attrs: dict | None = None, 2587 name: str | None = None, 2588 delegate: Any = None, 2589) -> Node: 2590 2591 """Add a node of the given type to the game. 2592 2593 Category: **Gameplay Functions** 2594 2595 If a dict is provided for 'attributes', the node's initial attributes 2596 will be set based on them. 2597 2598 'name', if provided, will be stored with the node purely for debugging 2599 purposes. If no name is provided, an automatic one will be generated 2600 such as 'terrain@foo.py:30'. 2601 2602 If 'delegate' is provided, Python messages sent to the node will go to 2603 that object's handlemessage() method. Note that the delegate is stored 2604 as a weak-ref, so the node itself will not keep the object alive. 2605 2606 if 'owner' is provided, the node will be automatically killed when that 2607 object dies. 'owner' can be another node or a ba.Actor 2608 """ 2609 return Node()
Add a node of the given type to the game.
Category: Gameplay Functions
If a dict is provided for 'attributes', the node's initial attributes will be set based on them.
'name', if provided, will be stored with the node purely for debugging purposes. If no name is provided, an automatic one will be generated such as 'terrain@foo.py:30'.
If 'delegate' is provided, Python messages sent to the node will go to that object's handlemessage() method. Note that the delegate is stored as a weak-ref, so the node itself will not keep the object alive.
if 'owner' is provided, the node will be automatically killed when that object dies. 'owner' can be another node or a ba.Actor
535class Node: 536 537 """Reference to a Node; the low level building block of the game. 538 539 Category: **Gameplay Classes** 540 541 At its core, a game is nothing more than a scene of Nodes 542 with attributes getting interconnected or set over time. 543 544 A ba.Node instance should be thought of as a weak-reference 545 to a game node; *not* the node itself. This means a Node's 546 lifecycle is completely independent of how many Python references 547 to it exist. To explicitly add a new node to the game, use 548 ba.newnode(), and to explicitly delete one, use ba.Node.delete(). 549 ba.Node.exists() can be used to determine if a Node still points to 550 a live node in the game. 551 552 You can use `ba.Node(None)` to instantiate an invalid 553 Node reference (sometimes used as attr values/etc). 554 """ 555 556 # Note attributes: 557 # NOTE: I'm just adding *all* possible node attrs here 558 # now now since we have a single ba.Node type; in the 559 # future I hope to create proper individual classes 560 # corresponding to different node types with correct 561 # attributes per node-type. 562 color: Sequence[float] = (0.0, 0.0, 0.0) 563 size: Sequence[float] = (0.0, 0.0, 0.0) 564 position: Sequence[float] = (0.0, 0.0, 0.0) 565 position_center: Sequence[float] = (0.0, 0.0, 0.0) 566 position_forward: Sequence[float] = (0.0, 0.0, 0.0) 567 punch_position: Sequence[float] = (0.0, 0.0, 0.0) 568 punch_velocity: Sequence[float] = (0.0, 0.0, 0.0) 569 velocity: Sequence[float] = (0.0, 0.0, 0.0) 570 name_color: Sequence[float] = (0.0, 0.0, 0.0) 571 tint_color: Sequence[float] = (0.0, 0.0, 0.0) 572 tint2_color: Sequence[float] = (0.0, 0.0, 0.0) 573 text: ba.Lstr | str = '' 574 texture: ba.Texture | None = None 575 tint_texture: ba.Texture | None = None 576 times: Sequence[int] = (1, 2, 3, 4, 5) 577 values: Sequence[float] = (1.0, 2.0, 3.0, 4.0) 578 offset: float = 0.0 579 input0: float = 0.0 580 input1: float = 0.0 581 input2: float = 0.0 582 input3: float = 0.0 583 flashing: bool = False 584 scale: float | Sequence[float] = 0.0 585 opacity: float = 0.0 586 loop: bool = False 587 time1: int = 0 588 time2: int = 0 589 timemax: int = 0 590 client_only: bool = False 591 materials: Sequence[Material] = () 592 roller_materials: Sequence[Material] = () 593 name: str = '' 594 punch_materials: Sequence[ba.Material] = () 595 pickup_materials: Sequence[ba.Material] = () 596 extras_material: Sequence[ba.Material] = () 597 rotate: float = 0.0 598 hold_node: ba.Node | None = None 599 hold_body: int = 0 600 host_only: bool = False 601 premultiplied: bool = False 602 source_player: ba.Player | None = None 603 model_opaque: ba.Model | None = None 604 model_transparent: ba.Model | None = None 605 damage_smoothed: float = 0.0 606 gravity_scale: float = 1.0 607 punch_power: float = 0.0 608 punch_momentum_linear: Sequence[float] = (0.0, 0.0, 0.0) 609 punch_momentum_angular: float = 0.0 610 rate: int = 0 611 vr_depth: float = 0.0 612 is_area_of_interest: bool = False 613 jump_pressed: bool = False 614 pickup_pressed: bool = False 615 punch_pressed: bool = False 616 bomb_pressed: bool = False 617 fly_pressed: bool = False 618 hold_position_pressed: bool = False 619 knockout: float = 0.0 620 invincible: bool = False 621 stick_to_owner: bool = False 622 damage: int = 0 623 run: float = 0.0 624 move_up_down: float = 0.0 625 move_left_right: float = 0.0 626 curse_death_time: int = 0 627 boxing_gloves: bool = False 628 hockey: bool = False 629 use_fixed_vr_overlay: bool = False 630 allow_kick_idle_players: bool = False 631 music_continuous: bool = False 632 music_count: int = 0 633 hurt: float = 0.0 634 always_show_health_bar: bool = False 635 mini_billboard_1_texture: ba.Texture | None = None 636 mini_billboard_1_start_time: int = 0 637 mini_billboard_1_end_time: int = 0 638 mini_billboard_2_texture: ba.Texture | None = None 639 mini_billboard_2_start_time: int = 0 640 mini_billboard_2_end_time: int = 0 641 mini_billboard_3_texture: ba.Texture | None = None 642 mini_billboard_3_start_time: int = 0 643 mini_billboard_3_end_time: int = 0 644 boxing_gloves_flashing: bool = False 645 dead: bool = False 646 floor_reflection: bool = False 647 debris_friction: float = 0.0 648 debris_kill_height: float = 0.0 649 vr_near_clip: float = 0.0 650 shadow_ortho: bool = False 651 happy_thoughts_mode: bool = False 652 shadow_offset: Sequence[float] = (0.0, 0.0) 653 paused: bool = False 654 time: int = 0 655 ambient_color: Sequence[float] = (1.0, 1.0, 1.0) 656 camera_mode: str = 'rotate' 657 frozen: bool = False 658 area_of_interest_bounds: Sequence[float] = (-1, -1, -1, 1, 1, 1) 659 shadow_range: Sequence[float] = (0, 0, 0, 0) 660 counter_text: str = '' 661 counter_texture: ba.Texture | None = None 662 shattered: int = 0 663 billboard_texture: ba.Texture | None = None 664 billboard_cross_out: bool = False 665 billboard_opacity: float = 0.0 666 slow_motion: bool = False 667 music: str = '' 668 vr_camera_offset: Sequence[float] = (0.0, 0.0, 0.0) 669 vr_overlay_center: Sequence[float] = (0.0, 0.0, 0.0) 670 vr_overlay_center_enabled: bool = False 671 vignette_outer: Sequence[float] = (0.0, 0.0) 672 vignette_inner: Sequence[float] = (0.0, 0.0) 673 tint: Sequence[float] = (1.0, 1.0, 1.0) 674 675 def add_death_action(self, action: Callable[[], None]) -> None: 676 677 """Add a callable object to be called upon this node's death. 678 Note that these actions are run just after the node dies, not before. 679 """ 680 return None 681 682 def connectattr(self, srcattr: str, dstnode: Node, dstattr: str) -> None: 683 684 """Connect one of this node's attributes to an attribute on another 685 node. This will immediately set the target attribute's value to that 686 of the source attribute, and will continue to do so once per step 687 as long as the two nodes exist. The connection can be severed by 688 setting the target attribute to any value or connecting another 689 node attribute to it. 690 691 ##### Example 692 Create a locator and attach a light to it: 693 >>> light = ba.newnode('light') 694 ... loc = ba.newnode('locator', attrs={'position': (0, 10, 0)}) 695 ... loc.connectattr('position', light, 'position') 696 """ 697 return None 698 699 def delete(self, ignore_missing: bool = True) -> None: 700 701 """Delete the node. Ignores already-deleted nodes if `ignore_missing` 702 is True; otherwise a ba.NodeNotFoundError is thrown. 703 """ 704 return None 705 706 def exists(self) -> bool: 707 708 """Returns whether the Node still exists. 709 Most functionality will fail on a nonexistent Node, so it's never a bad 710 idea to check this. 711 712 Note that you can also use the boolean operator for this same 713 functionality, so a statement such as "if mynode" will do 714 the right thing both for Node objects and values of None. 715 """ 716 return bool() 717 718 # Show that ur return type varies based on "doraise" value: 719 @overload 720 def getdelegate( 721 self, type: type[_T], doraise: Literal[False] = False 722 ) -> _T | None: 723 ... 724 725 @overload 726 def getdelegate(self, type: type[_T], doraise: Literal[True]) -> _T: 727 ... 728 729 def getdelegate(self, type: Any, doraise: bool = False) -> Any: 730 731 """Return the node's current delegate object if it matches 732 a certain type. 733 734 If the node has no delegate or it is not an instance of the passed 735 type, then None will be returned. If 'doraise' is True, then an 736 ba.DelegateNotFoundError will be raised instead. 737 """ 738 return None 739 740 def getname(self) -> str: 741 742 """Return the name assigned to a Node; used mainly for debugging""" 743 return str() 744 745 def getnodetype(self) -> str: 746 747 """Return the type of Node referenced by this object as a string. 748 (Note this is different from the Python type which is always ba.Node) 749 """ 750 return str() 751 752 def handlemessage(self, *args: Any) -> None: 753 754 """General message handling; can be passed any message object. 755 756 All standard message objects are forwarded along to the ba.Node's 757 delegate for handling (generally the ba.Actor that made the node). 758 759 ba.Node-s are unique, however, in that they can be passed a second 760 form of message; 'node-messages'. These consist of a string type-name 761 as a first argument along with the args specific to that type name 762 as additional arguments. 763 Node-messages communicate directly with the low-level node layer 764 and are delivered simultaneously on all game clients, 765 acting as an alternative to setting node attributes. 766 """ 767 return None
Reference to a Node; the low level building block of the game.
Category: Gameplay Classes
At its core, a game is nothing more than a scene of Nodes with attributes getting interconnected or set over time.
A ba.Node instance should be thought of as a weak-reference to a game node; not the node itself. This means a Node's lifecycle is completely independent of how many Python references to it exist. To explicitly add a new node to the game, use ba.newnode(), and to explicitly delete one, use ba.Node.delete(). ba.Node.exists() can be used to determine if a Node still points to a live node in the game.
You can use ba.Node(None)
to instantiate an invalid
Node reference (sometimes used as attr values/etc).
675 def add_death_action(self, action: Callable[[], None]) -> None: 676 677 """Add a callable object to be called upon this node's death. 678 Note that these actions are run just after the node dies, not before. 679 """ 680 return None
Add a callable object to be called upon this node's death. Note that these actions are run just after the node dies, not before.
682 def connectattr(self, srcattr: str, dstnode: Node, dstattr: str) -> None: 683 684 """Connect one of this node's attributes to an attribute on another 685 node. This will immediately set the target attribute's value to that 686 of the source attribute, and will continue to do so once per step 687 as long as the two nodes exist. The connection can be severed by 688 setting the target attribute to any value or connecting another 689 node attribute to it. 690 691 ##### Example 692 Create a locator and attach a light to it: 693 >>> light = ba.newnode('light') 694 ... loc = ba.newnode('locator', attrs={'position': (0, 10, 0)}) 695 ... loc.connectattr('position', light, 'position') 696 """ 697 return None
Connect one of this node's attributes to an attribute on another node. This will immediately set the target attribute's value to that of the source attribute, and will continue to do so once per step as long as the two nodes exist. The connection can be severed by setting the target attribute to any value or connecting another node attribute to it.
Example
Create a locator and attach a light to it:
>>> light = ba.newnode('light')
... loc = ba.newnode('locator', attrs={'position': (0, 10, 0)})
... loc.connectattr('position', light, 'position')
699 def delete(self, ignore_missing: bool = True) -> None: 700 701 """Delete the node. Ignores already-deleted nodes if `ignore_missing` 702 is True; otherwise a ba.NodeNotFoundError is thrown. 703 """ 704 return None
Delete the node. Ignores already-deleted nodes if ignore_missing
is True; otherwise a ba.NodeNotFoundError is thrown.
706 def exists(self) -> bool: 707 708 """Returns whether the Node still exists. 709 Most functionality will fail on a nonexistent Node, so it's never a bad 710 idea to check this. 711 712 Note that you can also use the boolean operator for this same 713 functionality, so a statement such as "if mynode" will do 714 the right thing both for Node objects and values of None. 715 """ 716 return bool()
Returns whether the Node still exists. Most functionality will fail on a nonexistent Node, so it's never a bad idea to check this.
Note that you can also use the boolean operator for this same functionality, so a statement such as "if mynode" will do the right thing both for Node objects and values of None.
729 def getdelegate(self, type: Any, doraise: bool = False) -> Any: 730 731 """Return the node's current delegate object if it matches 732 a certain type. 733 734 If the node has no delegate or it is not an instance of the passed 735 type, then None will be returned. If 'doraise' is True, then an 736 ba.DelegateNotFoundError will be raised instead. 737 """ 738 return None
Return the node's current delegate object if it matches a certain type.
If the node has no delegate or it is not an instance of the passed type, then None will be returned. If 'doraise' is True, then an ba.DelegateNotFoundError will be raised instead.
740 def getname(self) -> str: 741 742 """Return the name assigned to a Node; used mainly for debugging""" 743 return str()
Return the name assigned to a Node; used mainly for debugging
745 def getnodetype(self) -> str: 746 747 """Return the type of Node referenced by this object as a string. 748 (Note this is different from the Python type which is always ba.Node) 749 """ 750 return str()
Return the type of Node referenced by this object as a string. (Note this is different from the Python type which is always ba.Node)
752 def handlemessage(self, *args: Any) -> None: 753 754 """General message handling; can be passed any message object. 755 756 All standard message objects are forwarded along to the ba.Node's 757 delegate for handling (generally the ba.Actor that made the node). 758 759 ba.Node-s are unique, however, in that they can be passed a second 760 form of message; 'node-messages'. These consist of a string type-name 761 as a first argument along with the args specific to that type name 762 as additional arguments. 763 Node-messages communicate directly with the low-level node layer 764 and are delivered simultaneously on all game clients, 765 acting as an alternative to setting node attributes. 766 """ 767 return None
General message handling; can be passed any message object.
All standard message objects are forwarded along to the ba.Node's delegate for handling (generally the ba.Actor that made the node).
ba.Node-s are unique, however, in that they can be passed a second form of message; 'node-messages'. These consist of a string type-name as a first argument along with the args specific to that type name as additional arguments. Node-messages communicate directly with the low-level node layer and are delivered simultaneously on all game clients, acting as an alternative to setting node attributes.
18class NodeActor(Actor): 19 """A simple ba.Actor type that wraps a single ba.Node. 20 21 Category: **Gameplay Classes** 22 23 This Actor will delete its Node when told to die, and it's 24 exists() call will return whether the Node still exists or not. 25 """ 26 27 def __init__(self, node: ba.Node): 28 super().__init__() 29 self.node = node 30 31 def handlemessage(self, msg: Any) -> Any: 32 if isinstance(msg, DieMessage): 33 if self.node: 34 self.node.delete() 35 return None 36 return super().handlemessage(msg) 37 38 def exists(self) -> bool: 39 return bool(self.node)
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.
31 def handlemessage(self, msg: Any) -> Any: 32 if isinstance(msg, DieMessage): 33 if self.node: 34 self.node.delete() 35 return None 36 return super().handlemessage(msg)
General message handling; can be passed any message object.
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.
Inherited Members
94class NodeNotFoundError(NotFoundError): 95 """Exception raised when an expected ba.Node does not exist. 96 97 Category: **Exception Classes** 98 """
Exception raised when an expected ba.Node does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
52def normalized_color(color: Sequence[float]) -> tuple[float, ...]: 53 """Scale a color so its largest value is 1; useful for coloring lights. 54 55 category: General Utility Functions 56 """ 57 color_biased = tuple(max(c, 0.01) for c in color) # account for black 58 mult = 1.0 / max(color_biased) 59 return tuple(c * mult for c in color_biased)
Scale a color so its largest value is 1; useful for coloring lights.
category: General Utility Functions
45class NotFoundError(Exception): 46 """Exception raised when a referenced object does not exist. 47 48 Category: **Exception Classes** 49 """
Exception raised when a referenced object does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
2630def open_url(address: str, force_internal: bool = False) -> None: 2631 2632 """Open a provided URL. 2633 2634 Category: **General Utility Functions** 2635 2636 Open the provided url in a web-browser, or display the URL 2637 string in a window if that isn't possible (or if force_internal 2638 is True). 2639 """ 2640 return None
Open a provided URL.
Category: General Utility Functions
Open the provided url in a web-browser, or display the URL string in a window if that isn't possible (or if force_internal is True).
29@dataclass 30class OutOfBoundsMessage: 31 """A message telling an object that it is out of bounds. 32 33 Category: Message Classes 34 """
A message telling an object that it is out of bounds.
Category: Message Classes
101class Permission(Enum): 102 """Permissions that can be requested from the OS. 103 104 Category: Enums 105 """ 106 107 STORAGE = 0
Permissions that can be requested from the OS.
Category: Enums
Inherited Members
- enum.Enum
- name
- value
167@dataclass 168class PickedUpMessage: 169 """Tells an object that it has been picked up by something. 170 171 Category: **Message Classes** 172 """ 173 174 node: ba.Node 175 """The ba.Node doing the picking up."""
Tells an object that it has been picked up by something.
Category: Message Classes
148@dataclass 149class PickUpMessage: 150 """Tells an object that it has picked something up. 151 152 Category: **Message Classes** 153 """ 154 155 node: ba.Node 156 """The ba.Node that is getting picked up."""
Tells an object that it has picked something up.
Category: Message Classes
51class Player(Generic[TeamType]): 52 """A player in a specific ba.Activity. 53 54 Category: Gameplay Classes 55 56 These correspond to ba.SessionPlayer objects, but are associated with a 57 single ba.Activity instance. This allows activities to specify their 58 own custom ba.Player types. 59 """ 60 61 # These are instance attrs but we define them at the type level so 62 # their type annotations are introspectable (for docs generation). 63 character: str 64 65 actor: ba.Actor | None 66 """The ba.Actor associated with the player.""" 67 68 color: Sequence[float] 69 highlight: Sequence[float] 70 71 _team: TeamType 72 _sessionplayer: ba.SessionPlayer 73 _nodeactor: ba.NodeActor | None 74 _expired: bool 75 _postinited: bool 76 _customdata: dict 77 78 # NOTE: avoiding having any __init__() here since it seems to not 79 # get called by default if a dataclass inherits from us. 80 # This also lets us keep trivial player classes cleaner by skipping 81 # the super().__init__() line. 82 83 def postinit(self, sessionplayer: ba.SessionPlayer) -> None: 84 """Wire up a newly created player. 85 86 (internal) 87 """ 88 from ba._nodeactor import NodeActor 89 90 # Sanity check; if a dataclass is created that inherits from us, 91 # it will define an equality operator by default which will break 92 # internal game logic. So complain loudly if we find one. 93 if type(self).__eq__ is not object.__eq__: 94 raise RuntimeError( 95 f'Player class {type(self)} defines an equality' 96 f' operator (__eq__) which will break internal' 97 f' logic. Please remove it.\n' 98 f'For dataclasses you can do "dataclass(eq=False)"' 99 f' in the class decorator.' 100 ) 101 102 self.actor = None 103 self.character = '' 104 self._nodeactor: ba.NodeActor | None = None 105 self._sessionplayer = sessionplayer 106 self.character = sessionplayer.character 107 self.color = sessionplayer.color 108 self.highlight = sessionplayer.highlight 109 self._team = cast(TeamType, sessionplayer.sessionteam.activityteam) 110 assert self._team is not None 111 self._customdata = {} 112 self._expired = False 113 self._postinited = True 114 node = _ba.newnode('player', attrs={'playerID': sessionplayer.id}) 115 self._nodeactor = NodeActor(node) 116 sessionplayer.setnode(node) 117 118 def leave(self) -> None: 119 """Called when the Player leaves a running game. 120 121 (internal) 122 """ 123 assert self._postinited 124 assert not self._expired 125 try: 126 # If they still have an actor, kill it. 127 if self.actor: 128 self.actor.handlemessage(DieMessage(how=DeathType.LEFT_GAME)) 129 self.actor = None 130 except Exception: 131 print_exception(f'Error killing actor on leave for {self}') 132 self._nodeactor = None 133 del self._team 134 del self._customdata 135 136 def expire(self) -> None: 137 """Called when the Player is expiring (when its Activity does so). 138 139 (internal) 140 """ 141 assert self._postinited 142 assert not self._expired 143 self._expired = True 144 145 try: 146 self.on_expire() 147 except Exception: 148 print_exception(f'Error in on_expire for {self}.') 149 150 self._nodeactor = None 151 self.actor = None 152 del self._team 153 del self._customdata 154 155 def on_expire(self) -> None: 156 """Can be overridden to handle player expiration. 157 158 The player expires when the Activity it is a part of expires. 159 Expired players should no longer run any game logic (which will 160 likely error). They should, however, remove any references to 161 players/teams/games/etc. which could prevent them from being freed. 162 """ 163 164 @property 165 def team(self) -> TeamType: 166 """The ba.Team for this player.""" 167 assert self._postinited 168 assert not self._expired 169 return self._team 170 171 @property 172 def customdata(self) -> dict: 173 """Arbitrary values associated with the player. 174 Though it is encouraged that most player values be properly defined 175 on the ba.Player subclass, it may be useful for player-agnostic 176 objects to store values here. This dict is cleared when the player 177 leaves or expires so objects stored here will be disposed of at 178 the expected time, unlike the Player instance itself which may 179 continue to be referenced after it is no longer part of the game. 180 """ 181 assert self._postinited 182 assert not self._expired 183 return self._customdata 184 185 @property 186 def sessionplayer(self) -> ba.SessionPlayer: 187 """Return the ba.SessionPlayer corresponding to this Player. 188 189 Throws a ba.SessionPlayerNotFoundError if it does not exist. 190 """ 191 assert self._postinited 192 if bool(self._sessionplayer): 193 return self._sessionplayer 194 raise SessionPlayerNotFoundError() 195 196 @property 197 def node(self) -> ba.Node: 198 """A ba.Node of type 'player' associated with this Player. 199 200 This node can be used to get a generic player position/etc. 201 """ 202 assert self._postinited 203 assert not self._expired 204 assert self._nodeactor 205 return self._nodeactor.node 206 207 @property 208 def position(self) -> ba.Vec3: 209 """The position of the player, as defined by its current ba.Actor. 210 211 If the player currently has no actor, raises a ba.ActorNotFoundError. 212 """ 213 assert self._postinited 214 assert not self._expired 215 if self.actor is None: 216 raise ActorNotFoundError 217 return _ba.Vec3(self.node.position) 218 219 def exists(self) -> bool: 220 """Whether the underlying player still exists. 221 222 This will return False if the underlying ba.SessionPlayer has 223 left the game or if the ba.Activity this player was associated 224 with has ended. 225 Most functionality will fail on a nonexistent player. 226 Note that you can also use the boolean operator for this same 227 functionality, so a statement such as "if player" will do 228 the right thing both for Player objects and values of None. 229 """ 230 assert self._postinited 231 return self._sessionplayer.exists() and not self._expired 232 233 def getname(self, full: bool = False, icon: bool = True) -> str: 234 """ 235 Returns the player's name. If icon is True, the long version of the 236 name may include an icon. 237 """ 238 assert self._postinited 239 assert not self._expired 240 return self._sessionplayer.getname(full=full, icon=icon) 241 242 def is_alive(self) -> bool: 243 """ 244 Returns True if the player has a ba.Actor assigned and its 245 is_alive() method return True. False is returned otherwise. 246 """ 247 assert self._postinited 248 assert not self._expired 249 return self.actor is not None and self.actor.is_alive() 250 251 def get_icon(self) -> dict[str, Any]: 252 """ 253 Returns the character's icon (images, colors, etc contained in a dict) 254 """ 255 assert self._postinited 256 assert not self._expired 257 return self._sessionplayer.get_icon() 258 259 def assigninput( 260 self, inputtype: ba.InputType | tuple[ba.InputType, ...], call: Callable 261 ) -> None: 262 """ 263 Set the python callable to be run for one or more types of input. 264 """ 265 assert self._postinited 266 assert not self._expired 267 return self._sessionplayer.assigninput(type=inputtype, call=call) 268 269 def resetinput(self) -> None: 270 """ 271 Clears out the player's assigned input actions. 272 """ 273 assert self._postinited 274 assert not self._expired 275 self._sessionplayer.resetinput() 276 277 def __bool__(self) -> bool: 278 return self.exists()
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.
155 def on_expire(self) -> None: 156 """Can be overridden to handle player expiration. 157 158 The player expires when the Activity it is a part of expires. 159 Expired players should no longer run any game logic (which will 160 likely error). They should, however, remove any references to 161 players/teams/games/etc. which could prevent them from being freed. 162 """
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.
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.
Return the ba.SessionPlayer corresponding to this Player.
Throws a ba.SessionPlayerNotFoundError if it does not exist.
A ba.Node of type 'player' associated with this Player.
This node can be used to get a generic player position/etc.
The position of the player, as defined by its current ba.Actor.
If the player currently has no actor, raises a ba.ActorNotFoundError.
219 def exists(self) -> bool: 220 """Whether the underlying player still exists. 221 222 This will return False if the underlying ba.SessionPlayer has 223 left the game or if the ba.Activity this player was associated 224 with has ended. 225 Most functionality will fail on a nonexistent player. 226 Note that you can also use the boolean operator for this same 227 functionality, so a statement such as "if player" will do 228 the right thing both for Player objects and values of None. 229 """ 230 assert self._postinited 231 return self._sessionplayer.exists() and not self._expired
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.
233 def getname(self, full: bool = False, icon: bool = True) -> str: 234 """ 235 Returns the player's name. If icon is True, the long version of the 236 name may include an icon. 237 """ 238 assert self._postinited 239 assert not self._expired 240 return self._sessionplayer.getname(full=full, icon=icon)
Returns the player's name. If icon is True, the long version of the name may include an icon.
242 def is_alive(self) -> bool: 243 """ 244 Returns True if the player has a ba.Actor assigned and its 245 is_alive() method return True. False is returned otherwise. 246 """ 247 assert self._postinited 248 assert not self._expired 249 return self.actor is not None and self.actor.is_alive()
Returns True if the player has a ba.Actor assigned and its is_alive() method return True. False is returned otherwise.
251 def get_icon(self) -> dict[str, Any]: 252 """ 253 Returns the character's icon (images, colors, etc contained in a dict) 254 """ 255 assert self._postinited 256 assert not self._expired 257 return self._sessionplayer.get_icon()
Returns the character's icon (images, colors, etc contained in a dict)
259 def assigninput( 260 self, inputtype: ba.InputType | tuple[ba.InputType, ...], call: Callable 261 ) -> None: 262 """ 263 Set the python callable to be run for one or more types of input. 264 """ 265 assert self._postinited 266 assert not self._expired 267 return self._sessionplayer.assigninput(type=inputtype, call=call)
Set the python callable to be run for one or more types of input.
75class PlayerDiedMessage: 76 """A message saying a ba.Player has died. 77 78 Category: **Message Classes** 79 """ 80 81 killed: bool 82 """If True, the player was killed; 83 If False, they left the game or the round ended.""" 84 85 how: ba.DeathType 86 """The particular type of death.""" 87 88 def __init__( 89 self, 90 player: ba.Player, 91 was_killed: bool, 92 killerplayer: ba.Player | None, 93 how: ba.DeathType, 94 ): 95 """Instantiate a message with the given values.""" 96 97 # Invalid refs should never be passed as args. 98 assert player.exists() 99 self._player = player 100 101 # Invalid refs should never be passed as args. 102 assert killerplayer is None or killerplayer.exists() 103 self._killerplayer = killerplayer 104 self.killed = was_killed 105 self.how = how 106 107 def getkillerplayer( 108 self, playertype: type[PlayerType] 109 ) -> PlayerType | None: 110 """Return the ba.Player responsible for the killing, if any. 111 112 Pass the Player type being used by the current game. 113 """ 114 assert isinstance(self._killerplayer, (playertype, type(None))) 115 return self._killerplayer 116 117 def getplayer(self, playertype: type[PlayerType]) -> PlayerType: 118 """Return the ba.Player that died. 119 120 The type of player for the current activity should be passed so that 121 the type-checker properly identifies the returned value as one. 122 """ 123 player: Any = self._player 124 assert isinstance(player, playertype) 125 126 # We should never be delivering invalid refs. 127 # (could theoretically happen if someone holds on to us) 128 assert player.exists() 129 return player
A message saying a ba.Player has died.
Category: Message Classes
88 def __init__( 89 self, 90 player: ba.Player, 91 was_killed: bool, 92 killerplayer: ba.Player | None, 93 how: ba.DeathType, 94 ): 95 """Instantiate a message with the given values.""" 96 97 # Invalid refs should never be passed as args. 98 assert player.exists() 99 self._player = player 100 101 # Invalid refs should never be passed as args. 102 assert killerplayer is None or killerplayer.exists() 103 self._killerplayer = killerplayer 104 self.killed = was_killed 105 self.how = how
Instantiate a message with the given values.
107 def getkillerplayer( 108 self, playertype: type[PlayerType] 109 ) -> PlayerType | None: 110 """Return the ba.Player responsible for the killing, if any. 111 112 Pass the Player type being used by the current game. 113 """ 114 assert isinstance(self._killerplayer, (playertype, type(None))) 115 return self._killerplayer
Return the ba.Player responsible for the killing, if any.
Pass the Player type being used by the current game.
117 def getplayer(self, playertype: type[PlayerType]) -> PlayerType: 118 """Return the ba.Player that died. 119 120 The type of player for the current activity should be passed so that 121 the type-checker properly identifies the returned value as one. 122 """ 123 player: Any = self._player 124 assert isinstance(player, playertype) 125 126 # We should never be delivering invalid refs. 127 # (could theoretically happen if someone holds on to us) 128 assert player.exists() 129 return player
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.
29@dataclass 30class PlayerInfo: 31 """Holds basic info about a player. 32 33 Category: Gameplay Classes 34 """ 35 36 name: str 37 character: str
Holds basic info about a player.
Category: Gameplay Classes
52class PlayerNotFoundError(NotFoundError): 53 """Exception raised when an expected ba.Player does not exist. 54 55 Category: **Exception Classes** 56 """
Exception raised when an expected ba.Player does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
37class PlayerRecord: 38 """Stats for an individual player in a ba.Stats object. 39 40 Category: **Gameplay Classes** 41 42 This does not necessarily correspond to a ba.Player that is 43 still present (stats may be retained for players that leave 44 mid-game) 45 """ 46 47 character: str 48 49 def __init__( 50 self, 51 name: str, 52 name_full: str, 53 sessionplayer: ba.SessionPlayer, 54 stats: ba.Stats, 55 ): 56 self.name = name 57 self.name_full = name_full 58 self.score = 0 59 self.accumscore = 0 60 self.kill_count = 0 61 self.accum_kill_count = 0 62 self.killed_count = 0 63 self.accum_killed_count = 0 64 self._multi_kill_timer: ba.Timer | None = None 65 self._multi_kill_count = 0 66 self._stats = weakref.ref(stats) 67 self._last_sessionplayer: ba.SessionPlayer | None = None 68 self._sessionplayer: ba.SessionPlayer | None = None 69 self._sessionteam: weakref.ref[ba.SessionTeam] | None = None 70 self.streak = 0 71 self.associate_with_sessionplayer(sessionplayer) 72 73 @property 74 def team(self) -> ba.SessionTeam: 75 """The ba.SessionTeam the last associated player was last on. 76 77 This can still return a valid result even if the player is gone. 78 Raises a ba.SessionTeamNotFoundError if the team no longer exists. 79 """ 80 assert self._sessionteam is not None 81 team = self._sessionteam() 82 if team is None: 83 raise SessionTeamNotFoundError() 84 return team 85 86 @property 87 def player(self) -> ba.SessionPlayer: 88 """Return the instance's associated ba.SessionPlayer. 89 90 Raises a ba.SessionPlayerNotFoundError if the player 91 no longer exists. 92 """ 93 if not self._sessionplayer: 94 raise SessionPlayerNotFoundError() 95 return self._sessionplayer 96 97 def getname(self, full: bool = False) -> str: 98 """Return the player entry's name.""" 99 return self.name_full if full else self.name 100 101 def get_icon(self) -> dict[str, Any]: 102 """Get the icon for this instance's player.""" 103 player = self._last_sessionplayer 104 assert player is not None 105 return player.get_icon() 106 107 def cancel_multi_kill_timer(self) -> None: 108 """Cancel any multi-kill timer for this player entry.""" 109 self._multi_kill_timer = None 110 111 def getactivity(self) -> ba.Activity | None: 112 """Return the ba.Activity this instance is currently associated with. 113 114 Returns None if the activity no longer exists.""" 115 stats = self._stats() 116 if stats is not None: 117 return stats.getactivity() 118 return None 119 120 def associate_with_sessionplayer( 121 self, sessionplayer: ba.SessionPlayer 122 ) -> None: 123 """Associate this entry with a ba.SessionPlayer.""" 124 self._sessionteam = weakref.ref(sessionplayer.sessionteam) 125 self.character = sessionplayer.character 126 self._last_sessionplayer = sessionplayer 127 self._sessionplayer = sessionplayer 128 self.streak = 0 129 130 def _end_multi_kill(self) -> None: 131 self._multi_kill_timer = None 132 self._multi_kill_count = 0 133 134 def get_last_sessionplayer(self) -> ba.SessionPlayer: 135 """Return the last ba.Player we were associated with.""" 136 assert self._last_sessionplayer is not None 137 return self._last_sessionplayer 138 139 def submit_kill(self, showpoints: bool = True) -> None: 140 """Submit a kill for this player entry.""" 141 # FIXME Clean this up. 142 # pylint: disable=too-many-statements 143 from ba._language import Lstr 144 from ba._general import Call 145 146 self._multi_kill_count += 1 147 stats = self._stats() 148 assert stats 149 if self._multi_kill_count == 1: 150 score = 0 151 name = None 152 delay = 0.0 153 color = (0.0, 0.0, 0.0, 1.0) 154 scale = 1.0 155 sound = None 156 elif self._multi_kill_count == 2: 157 score = 20 158 name = Lstr(resource='twoKillText') 159 color = (0.1, 1.0, 0.0, 1) 160 scale = 1.0 161 delay = 0.0 162 sound = stats.orchestrahitsound1 163 elif self._multi_kill_count == 3: 164 score = 40 165 name = Lstr(resource='threeKillText') 166 color = (1.0, 0.7, 0.0, 1) 167 scale = 1.1 168 delay = 0.3 169 sound = stats.orchestrahitsound2 170 elif self._multi_kill_count == 4: 171 score = 60 172 name = Lstr(resource='fourKillText') 173 color = (1.0, 1.0, 0.0, 1) 174 scale = 1.2 175 delay = 0.6 176 sound = stats.orchestrahitsound3 177 elif self._multi_kill_count == 5: 178 score = 80 179 name = Lstr(resource='fiveKillText') 180 color = (1.0, 0.5, 0.0, 1) 181 scale = 1.3 182 delay = 0.9 183 sound = stats.orchestrahitsound4 184 else: 185 score = 100 186 name = Lstr( 187 resource='multiKillText', 188 subs=[('${COUNT}', str(self._multi_kill_count))], 189 ) 190 color = (1.0, 0.5, 0.0, 1) 191 scale = 1.3 192 delay = 1.0 193 sound = stats.orchestrahitsound4 194 195 def _apply( 196 name2: Lstr, 197 score2: int, 198 showpoints2: bool, 199 color2: tuple[float, float, float, float], 200 scale2: float, 201 sound2: ba.Sound | None, 202 ) -> None: 203 from bastd.actor.popuptext import PopupText 204 205 # Only award this if they're still alive and we can get 206 # a current position for them. 207 our_pos: ba.Vec3 | None = None 208 if self._sessionplayer: 209 if self._sessionplayer.activityplayer is not None: 210 try: 211 our_pos = self._sessionplayer.activityplayer.position 212 except NotFoundError: 213 pass 214 if our_pos is None: 215 return 216 217 # Jitter position a bit since these often come in clusters. 218 our_pos = _ba.Vec3( 219 our_pos[0] + (random.random() - 0.5) * 2.0, 220 our_pos[1] + (random.random() - 0.5) * 2.0, 221 our_pos[2] + (random.random() - 0.5) * 2.0, 222 ) 223 activity = self.getactivity() 224 if activity is not None: 225 PopupText( 226 Lstr( 227 value=(('+' + str(score2) + ' ') if showpoints2 else '') 228 + '${N}', 229 subs=[('${N}', name2)], 230 ), 231 color=color2, 232 scale=scale2, 233 position=our_pos, 234 ).autoretain() 235 if sound2: 236 _ba.playsound(sound2) 237 238 self.score += score2 239 self.accumscore += score2 240 241 # Inform a running game of the score. 242 if score2 != 0 and activity is not None: 243 activity.handlemessage(PlayerScoredMessage(score=score2)) 244 245 if name is not None: 246 _ba.timer( 247 0.3 + delay, 248 Call(_apply, name, score, showpoints, color, scale, sound), 249 ) 250 251 # Keep the tally rollin'... 252 # set a timer for a bit in the future. 253 self._multi_kill_timer = _ba.Timer(1.0, self._end_multi_kill)
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)
49 def __init__( 50 self, 51 name: str, 52 name_full: str, 53 sessionplayer: ba.SessionPlayer, 54 stats: ba.Stats, 55 ): 56 self.name = name 57 self.name_full = name_full 58 self.score = 0 59 self.accumscore = 0 60 self.kill_count = 0 61 self.accum_kill_count = 0 62 self.killed_count = 0 63 self.accum_killed_count = 0 64 self._multi_kill_timer: ba.Timer | None = None 65 self._multi_kill_count = 0 66 self._stats = weakref.ref(stats) 67 self._last_sessionplayer: ba.SessionPlayer | None = None 68 self._sessionplayer: ba.SessionPlayer | None = None 69 self._sessionteam: weakref.ref[ba.SessionTeam] | None = None 70 self.streak = 0 71 self.associate_with_sessionplayer(sessionplayer)
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.
Return the instance's associated ba.SessionPlayer.
Raises a ba.SessionPlayerNotFoundError if the player no longer exists.
97 def getname(self, full: bool = False) -> str: 98 """Return the player entry's name.""" 99 return self.name_full if full else self.name
Return the player entry's name.
101 def get_icon(self) -> dict[str, Any]: 102 """Get the icon for this instance's player.""" 103 player = self._last_sessionplayer 104 assert player is not None 105 return player.get_icon()
Get the icon for this instance's player.
107 def cancel_multi_kill_timer(self) -> None: 108 """Cancel any multi-kill timer for this player entry.""" 109 self._multi_kill_timer = None
Cancel any multi-kill timer for this player entry.
111 def getactivity(self) -> ba.Activity | None: 112 """Return the ba.Activity this instance is currently associated with. 113 114 Returns None if the activity no longer exists.""" 115 stats = self._stats() 116 if stats is not None: 117 return stats.getactivity() 118 return None
Return the ba.Activity this instance is currently associated with.
Returns None if the activity no longer exists.
120 def associate_with_sessionplayer( 121 self, sessionplayer: ba.SessionPlayer 122 ) -> None: 123 """Associate this entry with a ba.SessionPlayer.""" 124 self._sessionteam = weakref.ref(sessionplayer.sessionteam) 125 self.character = sessionplayer.character 126 self._last_sessionplayer = sessionplayer 127 self._sessionplayer = sessionplayer 128 self.streak = 0
Associate this entry with a ba.SessionPlayer.
134 def get_last_sessionplayer(self) -> ba.SessionPlayer: 135 """Return the last ba.Player we were associated with.""" 136 assert self._last_sessionplayer is not None 137 return self._last_sessionplayer
Return the last ba.Player we were associated with.
139 def submit_kill(self, showpoints: bool = True) -> None: 140 """Submit a kill for this player entry.""" 141 # FIXME Clean this up. 142 # pylint: disable=too-many-statements 143 from ba._language import Lstr 144 from ba._general import Call 145 146 self._multi_kill_count += 1 147 stats = self._stats() 148 assert stats 149 if self._multi_kill_count == 1: 150 score = 0 151 name = None 152 delay = 0.0 153 color = (0.0, 0.0, 0.0, 1.0) 154 scale = 1.0 155 sound = None 156 elif self._multi_kill_count == 2: 157 score = 20 158 name = Lstr(resource='twoKillText') 159 color = (0.1, 1.0, 0.0, 1) 160 scale = 1.0 161 delay = 0.0 162 sound = stats.orchestrahitsound1 163 elif self._multi_kill_count == 3: 164 score = 40 165 name = Lstr(resource='threeKillText') 166 color = (1.0, 0.7, 0.0, 1) 167 scale = 1.1 168 delay = 0.3 169 sound = stats.orchestrahitsound2 170 elif self._multi_kill_count == 4: 171 score = 60 172 name = Lstr(resource='fourKillText') 173 color = (1.0, 1.0, 0.0, 1) 174 scale = 1.2 175 delay = 0.6 176 sound = stats.orchestrahitsound3 177 elif self._multi_kill_count == 5: 178 score = 80 179 name = Lstr(resource='fiveKillText') 180 color = (1.0, 0.5, 0.0, 1) 181 scale = 1.3 182 delay = 0.9 183 sound = stats.orchestrahitsound4 184 else: 185 score = 100 186 name = Lstr( 187 resource='multiKillText', 188 subs=[('${COUNT}', str(self._multi_kill_count))], 189 ) 190 color = (1.0, 0.5, 0.0, 1) 191 scale = 1.3 192 delay = 1.0 193 sound = stats.orchestrahitsound4 194 195 def _apply( 196 name2: Lstr, 197 score2: int, 198 showpoints2: bool, 199 color2: tuple[float, float, float, float], 200 scale2: float, 201 sound2: ba.Sound | None, 202 ) -> None: 203 from bastd.actor.popuptext import PopupText 204 205 # Only award this if they're still alive and we can get 206 # a current position for them. 207 our_pos: ba.Vec3 | None = None 208 if self._sessionplayer: 209 if self._sessionplayer.activityplayer is not None: 210 try: 211 our_pos = self._sessionplayer.activityplayer.position 212 except NotFoundError: 213 pass 214 if our_pos is None: 215 return 216 217 # Jitter position a bit since these often come in clusters. 218 our_pos = _ba.Vec3( 219 our_pos[0] + (random.random() - 0.5) * 2.0, 220 our_pos[1] + (random.random() - 0.5) * 2.0, 221 our_pos[2] + (random.random() - 0.5) * 2.0, 222 ) 223 activity = self.getactivity() 224 if activity is not None: 225 PopupText( 226 Lstr( 227 value=(('+' + str(score2) + ' ') if showpoints2 else '') 228 + '${N}', 229 subs=[('${N}', name2)], 230 ), 231 color=color2, 232 scale=scale2, 233 position=our_pos, 234 ).autoretain() 235 if sound2: 236 _ba.playsound(sound2) 237 238 self.score += score2 239 self.accumscore += score2 240 241 # Inform a running game of the score. 242 if score2 != 0 and activity is not None: 243 activity.handlemessage(PlayerScoredMessage(score=score2)) 244 245 if name is not None: 246 _ba.timer( 247 0.3 + delay, 248 Call(_apply, name, score, showpoints, color, scale, sound), 249 ) 250 251 # Keep the tally rollin'... 252 # set a timer for a bit in the future. 253 self._multi_kill_timer = _ba.Timer(1.0, self._end_multi_kill)
Submit a kill for this player entry.
26@dataclass 27class PlayerScoredMessage: 28 """Informs something that a ba.Player scored. 29 30 Category: **Message Classes** 31 """ 32 33 score: int 34 """The score value."""
Informs something that a ba.Player scored.
Category: Message Classes
2643def playsound( 2644 sound: Sound, 2645 volume: float = 1.0, 2646 position: Sequence[float] | None = None, 2647 host_only: bool = False, 2648) -> None: 2649 2650 """Play a ba.Sound a single time. 2651 2652 Category: **Gameplay Functions** 2653 2654 If position is not provided, the sound will be at a constant volume 2655 everywhere. Position should be a float tuple of size 3. 2656 """ 2657 return None
Play a ba.Sound a single time.
Category: Gameplay Functions
If position is not provided, the sound will be at a constant volume everywhere. Position should be a float tuple of size 3.
214class Plugin: 215 """A plugin to alter app behavior in some way. 216 217 Category: **App Classes** 218 219 Plugins are discoverable by the meta-tag system 220 and the user can select which ones they want to activate. 221 Active plugins are then called at specific times as the 222 app is running in order to modify its behavior in some way. 223 """ 224 225 def on_app_running(self) -> None: 226 """Called when the app reaches the running state.""" 227 228 def on_app_pause(self) -> None: 229 """Called after pausing game activity.""" 230 231 def on_app_resume(self) -> None: 232 """Called after the game continues.""" 233 234 def on_app_shutdown(self) -> None: 235 """Called before closing the application.""" 236 237 def has_settings_ui(self) -> bool: 238 """Called to ask if we have settings UI we can show.""" 239 return False 240 241 def show_settings_ui(self, source_widget: ba.Widget | None) -> None: 242 """Called to show our settings UI."""
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.
18class PluginSubsystem: 19 """Subsystem for plugin handling in the app. 20 21 Category: **App Classes** 22 23 Access the single shared instance of this class at `ba.app.plugins`. 24 """ 25 26 AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY = 'Auto Enable New Plugins' 27 AUTO_ENABLE_NEW_PLUGINS_DEFAULT = True 28 29 def __init__(self) -> None: 30 self.potential_plugins: list[ba.PotentialPlugin] = [] 31 self.active_plugins: dict[str, ba.Plugin] = {} 32 33 def on_meta_scan_complete(self) -> None: 34 """Should be called when meta-scanning is complete.""" 35 from ba._language import Lstr 36 37 plugs = _ba.app.plugins 38 config_changed = False 39 found_new = False 40 plugstates: dict[str, dict] = _ba.app.config.setdefault('Plugins', {}) 41 assert isinstance(plugstates, dict) 42 43 results = _ba.app.meta.scanresults 44 assert results is not None 45 46 # Create a potential-plugin for each class we found in the scan. 47 for class_path in results.exports_of_class(Plugin): 48 plugs.potential_plugins.append( 49 PotentialPlugin( 50 display_name=Lstr(value=class_path), 51 class_path=class_path, 52 available=True, 53 ) 54 ) 55 if ( 56 _ba.app.config.get( 57 self.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY, 58 self.AUTO_ENABLE_NEW_PLUGINS_DEFAULT, 59 ) 60 is True 61 ): 62 if class_path not in plugstates: 63 # Go ahead and enable new plugins by default, but we'll 64 # inform the user that they need to restart to pick them up. 65 # they can also disable them in settings so they never load. 66 plugstates[class_path] = {'enabled': True} 67 config_changed = True 68 found_new = True 69 70 plugs.potential_plugins.sort(key=lambda p: p.class_path) 71 72 # Note: these days we complete meta-scan and immediately activate 73 # plugins, so we don't need the message about 'restart to activate' 74 # anymore. 75 if found_new and bool(False): 76 _ba.screenmessage( 77 Lstr(resource='pluginsDetectedText'), color=(0, 1, 0) 78 ) 79 _ba.playsound(_ba.getsound('ding')) 80 81 if config_changed: 82 _ba.app.config.commit() 83 84 def on_app_running(self) -> None: 85 """Should be called when the app reaches the running state.""" 86 # Load up our plugins and go ahead and call their on_app_running calls. 87 self.load_plugins() 88 for plugin in self.active_plugins.values(): 89 try: 90 plugin.on_app_running() 91 except Exception: 92 from ba import _error 93 94 _error.print_exception('Error in plugin on_app_running()') 95 96 def on_app_pause(self) -> None: 97 """Called when the app goes to a suspended state.""" 98 for plugin in self.active_plugins.values(): 99 try: 100 plugin.on_app_pause() 101 except Exception: 102 from ba import _error 103 104 _error.print_exception('Error in plugin on_app_pause()') 105 106 def on_app_resume(self) -> None: 107 """Run when the app resumes from a suspended state.""" 108 for plugin in self.active_plugins.values(): 109 try: 110 plugin.on_app_resume() 111 except Exception: 112 from ba import _error 113 114 _error.print_exception('Error in plugin on_app_resume()') 115 116 def on_app_shutdown(self) -> None: 117 """Called when the app is being closed.""" 118 for plugin in self.active_plugins.values(): 119 try: 120 plugin.on_app_shutdown() 121 except Exception: 122 from ba import _error 123 124 _error.print_exception('Error in plugin on_app_shutdown()') 125 126 def load_plugins(self) -> None: 127 """(internal)""" 128 from ba._general import getclass 129 from ba._language import Lstr 130 131 # Note: the plugins we load is purely based on what's enabled 132 # in the app config. Its not our job to look at meta stuff here. 133 plugstates: dict[str, dict] = _ba.app.config.get('Plugins', {}) 134 assert isinstance(plugstates, dict) 135 plugkeys: list[str] = sorted( 136 key for key, val in plugstates.items() if val.get('enabled', False) 137 ) 138 disappeared_plugs: set[str] = set() 139 for plugkey in plugkeys: 140 try: 141 cls = getclass(plugkey, Plugin) 142 except ModuleNotFoundError: 143 disappeared_plugs.add(plugkey) 144 continue 145 except Exception as exc: 146 _ba.playsound(_ba.getsound('error')) 147 _ba.screenmessage( 148 Lstr( 149 resource='pluginClassLoadErrorText', 150 subs=[('${PLUGIN}', plugkey), ('${ERROR}', str(exc))], 151 ), 152 color=(1, 0, 0), 153 ) 154 logging.exception("Error loading plugin class '%s'", plugkey) 155 continue 156 try: 157 plugin = cls() 158 assert plugkey not in self.active_plugins 159 self.active_plugins[plugkey] = plugin 160 except Exception as exc: 161 from ba import _error 162 163 _ba.playsound(_ba.getsound('error')) 164 _ba.screenmessage( 165 Lstr( 166 resource='pluginInitErrorText', 167 subs=[('${PLUGIN}', plugkey), ('${ERROR}', str(exc))], 168 ), 169 color=(1, 0, 0), 170 ) 171 _error.print_exception(f"Error initing plugin: '{plugkey}'.") 172 173 # If plugins disappeared, let the user know gently and remove them 174 # from the config so we'll again let the user know if they later 175 # reappear. This makes it much smoother to switch between users 176 # or workspaces. 177 if disappeared_plugs: 178 _ba.playsound(_ba.getsound('shieldDown')) 179 _ba.screenmessage( 180 Lstr( 181 resource='pluginsRemovedText', 182 subs=[('${NUM}', str(len(disappeared_plugs)))], 183 ), 184 color=(1, 1, 0), 185 ) 186 plugnames = ', '.join(disappeared_plugs) 187 logging.info( 188 '%d plugin(s) no longer found: %s.', 189 len(disappeared_plugs), 190 plugnames, 191 ) 192 for goneplug in disappeared_plugs: 193 del _ba.app.config['Plugins'][goneplug] 194 _ba.app.config.commit()
Subsystem for plugin handling in the app.
Category: App Classes
Access the single shared instance of this class at ba.app.plugins
.
33 def on_meta_scan_complete(self) -> None: 34 """Should be called when meta-scanning is complete.""" 35 from ba._language import Lstr 36 37 plugs = _ba.app.plugins 38 config_changed = False 39 found_new = False 40 plugstates: dict[str, dict] = _ba.app.config.setdefault('Plugins', {}) 41 assert isinstance(plugstates, dict) 42 43 results = _ba.app.meta.scanresults 44 assert results is not None 45 46 # Create a potential-plugin for each class we found in the scan. 47 for class_path in results.exports_of_class(Plugin): 48 plugs.potential_plugins.append( 49 PotentialPlugin( 50 display_name=Lstr(value=class_path), 51 class_path=class_path, 52 available=True, 53 ) 54 ) 55 if ( 56 _ba.app.config.get( 57 self.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY, 58 self.AUTO_ENABLE_NEW_PLUGINS_DEFAULT, 59 ) 60 is True 61 ): 62 if class_path not in plugstates: 63 # Go ahead and enable new plugins by default, but we'll 64 # inform the user that they need to restart to pick them up. 65 # they can also disable them in settings so they never load. 66 plugstates[class_path] = {'enabled': True} 67 config_changed = True 68 found_new = True 69 70 plugs.potential_plugins.sort(key=lambda p: p.class_path) 71 72 # Note: these days we complete meta-scan and immediately activate 73 # plugins, so we don't need the message about 'restart to activate' 74 # anymore. 75 if found_new and bool(False): 76 _ba.screenmessage( 77 Lstr(resource='pluginsDetectedText'), color=(0, 1, 0) 78 ) 79 _ba.playsound(_ba.getsound('ding')) 80 81 if config_changed: 82 _ba.app.config.commit()
Should be called when meta-scanning is complete.
84 def on_app_running(self) -> None: 85 """Should be called when the app reaches the running state.""" 86 # Load up our plugins and go ahead and call their on_app_running calls. 87 self.load_plugins() 88 for plugin in self.active_plugins.values(): 89 try: 90 plugin.on_app_running() 91 except Exception: 92 from ba import _error 93 94 _error.print_exception('Error in plugin on_app_running()')
Should be called when the app reaches the running state.
96 def on_app_pause(self) -> None: 97 """Called when the app goes to a suspended state.""" 98 for plugin in self.active_plugins.values(): 99 try: 100 plugin.on_app_pause() 101 except Exception: 102 from ba import _error 103 104 _error.print_exception('Error in plugin on_app_pause()')
Called when the app goes to a suspended state.
106 def on_app_resume(self) -> None: 107 """Run when the app resumes from a suspended state.""" 108 for plugin in self.active_plugins.values(): 109 try: 110 plugin.on_app_resume() 111 except Exception: 112 from ba import _error 113 114 _error.print_exception('Error in plugin on_app_resume()')
Run when the app resumes from a suspended state.
116 def on_app_shutdown(self) -> None: 117 """Called when the app is being closed.""" 118 for plugin in self.active_plugins.values(): 119 try: 120 plugin.on_app_shutdown() 121 except Exception: 122 from ba import _error 123 124 _error.print_exception('Error in plugin on_app_shutdown()')
Called when the app is being closed.
197@dataclass 198class PotentialPlugin: 199 """Represents a ba.Plugin which can potentially be loaded. 200 201 Category: **App Classes** 202 203 These generally represent plugins which were detected by the 204 meta-tag scan. However they may also represent plugins which 205 were previously set to be loaded but which were unable to be 206 for some reason. In that case, 'available' will be set to False. 207 """ 208 209 display_name: ba.Lstr 210 class_path: str 211 available: bool
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.
36@dataclass 37class PowerupAcceptMessage: 38 """A message informing a ba.Powerup that it was accepted. 39 40 Category: **Message Classes** 41 42 This is generally sent in response to a ba.PowerupMessage 43 to inform the box (or whoever granted it) that it can go away. 44 """
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.
16@dataclass 17class PowerupMessage: 18 """A message telling an object to accept a powerup. 19 20 Category: **Message Classes** 21 22 This message is normally received by touching a ba.PowerupBox. 23 """ 24 25 poweruptype: str 26 """The type of powerup to be granted (a string). 27 See ba.Powerup.poweruptype for available type values.""" 28 29 sourcenode: ba.Node | None = None 30 """The node the powerup game from, or None otherwise. 31 If a powerup is accepted, a ba.PowerupAcceptMessage should be sent 32 back to the sourcenode to inform it of the fact. This will generally 33 cause the powerup box to make a sound and disappear or whatnot."""
A message telling an object to accept a powerup.
Category: Message Classes
This message is normally received by touching a ba.PowerupBox.
The type of powerup to be granted (a string). See ba.Powerup.poweruptype for available type values.
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.
182def print_error(err_str: str, once: bool = False) -> None: 183 """Print info about an error along with pertinent context state. 184 185 Category: **General Utility Functions** 186 187 Prints all positional arguments provided along with various info about the 188 current context. 189 Pass the keyword 'once' as True if you want the call to only happen 190 one time from an exact calling location. 191 """ 192 import traceback 193 194 try: 195 # If we're only printing once and already have, bail. 196 if once: 197 if not _ba.do_once(): 198 return 199 200 print('ERROR:', err_str) 201 _ba.print_context() 202 203 # Basically the output of traceback.print_stack() 204 stackstr = ''.join(traceback.format_stack()) 205 print(stackstr, end='') 206 except Exception: 207 print('ERROR: exception in ba.print_error():') 208 traceback.print_exc()
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.
141def print_exception(*args: Any, **keywds: Any) -> None: 142 """Print info about an exception along with pertinent context state. 143 144 Category: **General Utility Functions** 145 146 Prints all arguments provided along with various info about the 147 current context and the outstanding exception. 148 Pass the keyword 'once' as True if you want the call to only happen 149 one time from an exact calling location. 150 """ 151 import traceback 152 153 if keywds: 154 allowed_keywds = ['once'] 155 if any(keywd not in allowed_keywds for keywd in keywds): 156 raise TypeError('invalid keyword(s)') 157 try: 158 # If we're only printing once and already have, bail. 159 if keywds.get('once', False): 160 if not _ba.do_once(): 161 return 162 163 err_str = ' '.join([str(a) for a in args]) 164 print('ERROR:', err_str) 165 _ba.print_context() 166 print('PRINTED-FROM:') 167 168 # Basically the output of traceback.print_stack() 169 stackstr = ''.join(traceback.format_stack()) 170 print(stackstr, end='') 171 print('EXCEPTION:') 172 173 # Basically the output of traceback.print_exc() 174 excstr = traceback.format_exc() 175 print('\n'.join(' ' + l for l in excstr.splitlines())) 176 except Exception: 177 # I suppose using print_exception here would be a bad idea. 178 print('ERROR: exception in ba.print_exception():') 179 traceback.print_exc()
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.
2678def printnodes() -> None: 2679 2680 """Print various info about existing nodes; useful for debugging. 2681 2682 Category: **Gameplay Functions** 2683 """ 2684 return None
Print various info about existing nodes; useful for debugging.
Category: Gameplay Functions
2457def ls_objects() -> None: 2458 2459 """Log debugging info about C++ level objects. 2460 2461 Category: **General Utility Functions** 2462 2463 This call only functions in debug builds of the game. 2464 It prints various info about the current object count, etc. 2465 """ 2466 return None
Log debugging info about C++ level objects.
Category: General Utility Functions
This call only functions in debug builds of the game. It prints various info about the current object count, etc.
2445def ls_input_devices() -> None: 2446 2447 """Print debugging info about game objects. 2448 2449 Category: **General Utility Functions** 2450 2451 This call only functions in debug builds of the game. 2452 It prints various info about the current object count, etc. 2453 """ 2454 return None
Print debugging info about game objects.
Category: General Utility Functions
This call only functions in debug builds of the game. It prints various info about the current object count, etc.
2687def pushcall( 2688 call: Callable, 2689 from_other_thread: bool = False, 2690 suppress_other_thread_warning: bool = False, 2691 other_thread_use_fg_context: bool = False, 2692 raw: bool = False, 2693) -> None: 2694 2695 """Push a call to the logic event-loop. 2696 Category: **General Utility Functions** 2697 2698 This call expects to be used in the logic thread, and will automatically 2699 save and restore the ba.Context to behave seamlessly. 2700 2701 If you want to push a call from outside of the logic thread, 2702 however, you can pass 'from_other_thread' as True. In this case 2703 the call will always run in the UI context on the logic thread 2704 or whichever context is in the foreground if 2705 other_thread_use_fg_context is True. 2706 Passing raw=True will disable thread checks and context sets/restores. 2707 """ 2708 return None
Push a call to the logic event-loop. Category: General Utility Functions
This call expects to be used in the logic thread, and will automatically save and restore the ba.Context to behave seamlessly.
If you want to push a call from outside of the logic thread, however, you can pass 'from_other_thread' as True. In this case the call will always run in the UI context on the logic thread or whichever context is in the foreground if other_thread_use_fg_context is True. Passing raw=True will disable thread checks and context sets/restores.
2711def quit(soft: bool = False, back: bool = False) -> None: 2712 2713 """Quit the game. 2714 2715 Category: **General Utility Functions** 2716 2717 On systems like android, 'soft' will end the activity but keep the 2718 app running. 2719 """ 2720 return None
Quit the game.
Category: General Utility Functions
On systems like android, 'soft' will end the activity but keep the app running.
2787def rowwidget( 2788 edit: ba.Widget | None = None, 2789 parent: ba.Widget | None = None, 2790 size: Sequence[float] | None = None, 2791 position: Sequence[float] | None = None, 2792 background: bool | None = None, 2793 selected_child: ba.Widget | None = None, 2794 visible_child: ba.Widget | None = None, 2795 claims_left_right: bool | None = None, 2796 claims_tab: bool | None = None, 2797 selection_loops_to_parent: bool | None = None, 2798) -> ba.Widget: 2799 2800 """Create or edit a row widget. 2801 2802 Category: **User Interface Functions** 2803 2804 Pass a valid existing ba.Widget as 'edit' to modify it; otherwise 2805 a new one is created and returned. Arguments that are not set to None 2806 are applied to the Widget. 2807 """ 2808 import ba # pylint: disable=cyclic-import 2809 2810 return ba.Widget()
Create or edit a row widget.
Category: User Interface Functions
Pass a valid existing ba.Widget as 'edit' to modify it; otherwise a new one is created and returned. Arguments that are not set to None are applied to the Widget.
2813def safecolor( 2814 color: Sequence[float], target_intensity: float = 0.6 2815) -> tuple[float, ...]: 2816 2817 """Given a color tuple, return a color safe to display as text. 2818 2819 Category: **General Utility Functions** 2820 2821 Accepts tuples of length 3 or 4. This will slightly brighten very 2822 dark colors, etc. 2823 """ 2824 return (0.0, 0.0, 0.0)
Given a color tuple, return a color safe to display as text.
Category: General Utility Functions
Accepts tuples of length 3 or 4. This will slightly brighten very dark colors, etc.
28@dataclass 29class ScoreConfig: 30 """Settings for how a game handles scores. 31 32 Category: **Gameplay Classes** 33 """ 34 35 label: str = 'Score' 36 """A label show to the user for scores; 'Score', 'Time Survived', etc.""" 37 38 scoretype: ba.ScoreType = ScoreType.POINTS 39 """How the score value should be displayed.""" 40 41 lower_is_better: bool = False 42 """Whether lower scores are preferable. Higher scores are by default.""" 43 44 none_is_winner: bool = False 45 """Whether a value of None is considered better than other scores. 46 By default it is not.""" 47 48 version: str = '' 49 """To change high-score lists used by a game without renaming the game, 50 change this. Defaults to an empty string."""
Settings for how a game handles scores.
Category: Gameplay Classes
16@unique 17class ScoreType(Enum): 18 """Type of scores. 19 20 Category: **Enums** 21 """ 22 23 SECONDS = 's' 24 MILLISECONDS = 'ms' 25 POINTS = 'p'
Type of scores.
Category: Enums
Inherited Members
- enum.Enum
- name
- value
2827def screenmessage( 2828 message: str | ba.Lstr, 2829 color: Sequence[float] | None = None, 2830 top: bool = False, 2831 image: dict[str, Any] | None = None, 2832 log: bool = False, 2833 clients: Sequence[int] | None = None, 2834 transient: bool = False, 2835) -> None: 2836 2837 """Print a message to the local client's screen, in a given color. 2838 2839 Category: **General Utility Functions** 2840 2841 If 'top' is True, the message will go to the top message area. 2842 For 'top' messages, 'image' must be a dict containing 'texture' 2843 and 'tint_texture' textures and 'tint_color' and 'tint2_color' 2844 colors. This defines an icon to display alongside the message. 2845 If 'log' is True, the message will also be submitted to the log. 2846 'clients' can be a list of client-ids the message should be sent 2847 to, or None to specify that everyone should receive it. 2848 If 'transient' is True, the message will not be included in the 2849 game-stream and thus will not show up when viewing replays. 2850 Currently the 'clients' option only works for transient messages. 2851 """ 2852 return None
Print a message to the local client's screen, in a given color.
Category: General Utility Functions
If 'top' is True, the message will go to the top message area. For 'top' messages, 'image' must be a dict containing 'texture' and 'tint_texture' textures and 'tint_color' and 'tint2_color' colors. This defines an icon to display alongside the message. If 'log' is True, the message will also be submitted to the log. 'clients' can be a list of client-ids the message should be sent to, or None to specify that everyone should receive it. If 'transient' is True, the message will not be included in the game-stream and thus will not show up when viewing replays. Currently the 'clients' option only works for transient messages.
2855def scrollwidget( 2856 edit: ba.Widget | None = None, 2857 parent: ba.Widget | None = None, 2858 size: Sequence[float] | None = None, 2859 position: Sequence[float] | None = None, 2860 background: bool | None = None, 2861 selected_child: ba.Widget | None = None, 2862 capture_arrows: bool = False, 2863 on_select_call: Callable | None = None, 2864 center_small_content: bool | None = None, 2865 color: Sequence[float] | None = None, 2866 highlight: bool | None = None, 2867 border_opacity: float | None = None, 2868 simple_culling_v: float | None = None, 2869 selection_loops_to_parent: bool | None = None, 2870 claims_left_right: bool | None = None, 2871 claims_up_down: bool | None = None, 2872 claims_tab: bool | None = None, 2873 autoselect: bool | None = None, 2874) -> ba.Widget: 2875 2876 """Create or edit a scroll widget. 2877 2878 Category: **User Interface Functions** 2879 2880 Pass a valid existing ba.Widget as 'edit' to modify it; otherwise 2881 a new one is created and returned. Arguments that are not set to None 2882 are applied to the Widget. 2883 """ 2884 import ba # pylint: disable=cyclic-import 2885 2886 return ba.Widget()
Create or edit a scroll widget.
Category: User Interface Functions
Pass a valid existing ba.Widget as 'edit' to modify it; otherwise a new one is created and returned. Arguments that are not set to None are applied to the Widget.
93class ServerController: 94 """Overall controller for the app in server mode. 95 96 Category: **App Classes** 97 """ 98 99 def __init__(self, config: ServerConfig) -> None: 100 101 self._config = config 102 self._playlist_name = '__default__' 103 self._ran_access_check = False 104 self._prep_timer: ba.Timer | None = None 105 self._next_stuck_login_warn_time = time.time() + 10.0 106 self._first_run = True 107 self._shutdown_reason: ShutdownReason | None = None 108 self._executing_shutdown = False 109 110 # Make note if they want us to import a playlist; 111 # we'll need to do that first if so. 112 self._playlist_fetch_running = self._config.playlist_code is not None 113 self._playlist_fetch_sent_request = False 114 self._playlist_fetch_got_response = False 115 self._playlist_fetch_code = -1 116 117 # Now sit around doing any pre-launch prep such as waiting for 118 # account sign-in or fetching playlists; this will kick off the 119 # session once done. 120 with _ba.Context('ui'): 121 self._prep_timer = _ba.Timer( 122 0.25, 123 self._prepare_to_serve, 124 timetype=TimeType.REAL, 125 repeat=True, 126 ) 127 128 def print_client_list(self) -> None: 129 """Print info about all connected clients.""" 130 import json 131 132 roster = _ba.get_game_roster() 133 title1 = 'Client ID' 134 title2 = 'Account Name' 135 title3 = 'Players' 136 col1 = 10 137 col2 = 16 138 out = ( 139 f'{Clr.BLD}' 140 f'{title1:<{col1}} {title2:<{col2}} {title3}' 141 f'{Clr.RST}' 142 ) 143 for client in roster: 144 if client['client_id'] == -1: 145 continue 146 spec = json.loads(client['spec_string']) 147 name = spec['n'] 148 players = ', '.join(n['name'] for n in client['players']) 149 clientid = client['client_id'] 150 out += f'\n{clientid:<{col1}} {name:<{col2}} {players}' 151 print(out) 152 153 def kick(self, client_id: int, ban_time: int | None) -> None: 154 """Kick the provided client id. 155 156 ban_time is provided in seconds. 157 If ban_time is None, ban duration will be determined automatically. 158 Pass 0 or a negative number for no ban time. 159 """ 160 161 # FIXME: this case should be handled under the hood. 162 if ban_time is None: 163 ban_time = 300 164 165 _ba.disconnect_client(client_id=client_id, ban_time=ban_time) 166 167 def shutdown(self, reason: ShutdownReason, immediate: bool) -> None: 168 """Set the app to quit either now or at the next clean opportunity.""" 169 self._shutdown_reason = reason 170 if immediate: 171 print(f'{Clr.SBLU}Immediate shutdown initiated.{Clr.RST}') 172 self._execute_shutdown() 173 else: 174 print( 175 f'{Clr.SBLU}Shutdown initiated;' 176 f' server process will exit at the next clean opportunity.' 177 f'{Clr.RST}' 178 ) 179 180 def handle_transition(self) -> bool: 181 """Handle transitioning to a new ba.Session or quitting the app. 182 183 Will be called once at the end of an activity that is marked as 184 a good 'end-point' (such as a final score screen). 185 Should return True if action will be handled by us; False if the 186 session should just continue on it's merry way. 187 """ 188 if self._shutdown_reason is not None: 189 self._execute_shutdown() 190 return True 191 return False 192 193 def _execute_shutdown(self) -> None: 194 from ba._language import Lstr 195 196 if self._executing_shutdown: 197 return 198 self._executing_shutdown = True 199 timestrval = time.strftime('%c') 200 if self._shutdown_reason is ShutdownReason.RESTARTING: 201 _ba.screenmessage( 202 Lstr(resource='internal.serverRestartingText'), 203 color=(1, 0.5, 0.0), 204 ) 205 print( 206 f'{Clr.SBLU}Exiting for server-restart' 207 f' at {timestrval}.{Clr.RST}' 208 ) 209 else: 210 _ba.screenmessage( 211 Lstr(resource='internal.serverShuttingDownText'), 212 color=(1, 0.5, 0.0), 213 ) 214 print( 215 f'{Clr.SBLU}Exiting for server-shutdown' 216 f' at {timestrval}.{Clr.RST}' 217 ) 218 with _ba.Context('ui'): 219 _ba.timer(2.0, _ba.quit, timetype=TimeType.REAL) 220 221 def _run_access_check(self) -> None: 222 """Check with the master server to see if we're likely joinable.""" 223 from ba._net import master_server_get 224 225 master_server_get( 226 'bsAccessCheck', 227 {'port': _ba.get_game_port(), 'b': _ba.app.build_number}, 228 callback=self._access_check_response, 229 ) 230 231 def _access_check_response(self, data: dict[str, Any] | None) -> None: 232 import os 233 234 if data is None: 235 print('error on UDP port access check (internet down?)') 236 else: 237 addr = data['address'] 238 port = data['port'] 239 show_addr = os.environ.get('BA_ACCESS_CHECK_VERBOSE', '0') == '1' 240 if show_addr: 241 addrstr = f' {addr}' 242 poststr = '' 243 else: 244 addrstr = '' 245 poststr = ( 246 '\nSet environment variable BA_ACCESS_CHECK_VERBOSE=1' 247 ' for more info.' 248 ) 249 if data['accessible']: 250 print( 251 f'{Clr.SBLU}Master server access check of{addrstr}' 252 f' udp port {port} succeeded.\n' 253 f'Your server appears to be' 254 f' joinable from the internet.{poststr}{Clr.RST}' 255 ) 256 else: 257 print( 258 f'{Clr.SRED}Master server access check of{addrstr}' 259 f' udp port {port} failed.\n' 260 f'Your server does not appear to be' 261 f' joinable from the internet.{poststr}{Clr.RST}' 262 ) 263 264 def _prepare_to_serve(self) -> None: 265 """Run in a timer to do prep before beginning to serve.""" 266 signed_in = get_v1_account_state() == 'signed_in' 267 if not signed_in: 268 269 # Signing in to the local server account should not take long; 270 # complain if it does... 271 curtime = time.time() 272 if curtime > self._next_stuck_login_warn_time: 273 print('Still waiting for account sign-in...') 274 self._next_stuck_login_warn_time = curtime + 10.0 275 return 276 277 can_launch = False 278 279 # If we're fetching a playlist, we need to do that first. 280 if not self._playlist_fetch_running: 281 can_launch = True 282 else: 283 if not self._playlist_fetch_sent_request: 284 print( 285 f'{Clr.SBLU}Requesting shared-playlist' 286 f' {self._config.playlist_code}...{Clr.RST}' 287 ) 288 add_transaction( 289 { 290 'type': 'IMPORT_PLAYLIST', 291 'code': str(self._config.playlist_code), 292 'overwrite': True, 293 }, 294 callback=self._on_playlist_fetch_response, 295 ) 296 run_transactions() 297 self._playlist_fetch_sent_request = True 298 299 if self._playlist_fetch_got_response: 300 self._playlist_fetch_running = False 301 can_launch = True 302 303 if can_launch: 304 self._prep_timer = None 305 _ba.pushcall(self._launch_server_session) 306 307 def _on_playlist_fetch_response( 308 self, 309 result: dict[str, Any] | None, 310 ) -> None: 311 if result is None: 312 print('Error fetching playlist; aborting.') 313 sys.exit(-1) 314 315 # Once we get here, simply modify our config to use this playlist. 316 typename = ( 317 'teams' 318 if result['playlistType'] == 'Team Tournament' 319 else 'ffa' 320 if result['playlistType'] == 'Free-for-All' 321 else '??' 322 ) 323 plistname = result['playlistName'] 324 print(f'{Clr.SBLU}Got playlist: "{plistname}" ({typename}).{Clr.RST}') 325 self._playlist_fetch_got_response = True 326 self._config.session_type = typename 327 self._playlist_name = result['playlistName'] 328 329 def _get_session_type(self) -> type[ba.Session]: 330 # Convert string session type to the class. 331 # Hmm should we just keep this as a string? 332 if self._config.session_type == 'ffa': 333 return FreeForAllSession 334 if self._config.session_type == 'teams': 335 return DualTeamSession 336 if self._config.session_type == 'coop': 337 return CoopSession 338 raise RuntimeError( 339 f'Invalid session_type: "{self._config.session_type}"' 340 ) 341 342 def _launch_server_session(self) -> None: 343 """Kick off a host-session based on the current server config.""" 344 # pylint: disable=too-many-branches 345 app = _ba.app 346 appcfg = app.config 347 sessiontype = self._get_session_type() 348 349 if get_v1_account_state() != 'signed_in': 350 print( 351 'WARNING: launch_server_session() expects to run ' 352 'with a signed in server account' 353 ) 354 355 # If we didn't fetch a playlist but there's an inline one in the 356 # server-config, pull it in to the game config and use it. 357 if ( 358 self._config.playlist_code is None 359 and self._config.playlist_inline is not None 360 ): 361 self._playlist_name = 'ServerModePlaylist' 362 if sessiontype is FreeForAllSession: 363 ptypename = 'Free-for-All' 364 elif sessiontype is DualTeamSession: 365 ptypename = 'Team Tournament' 366 elif sessiontype is CoopSession: 367 ptypename = 'Coop' 368 else: 369 raise RuntimeError(f'Unknown session type {sessiontype}') 370 371 # Need to add this in a transaction instead of just setting 372 # it directly or it will get overwritten by the master-server. 373 add_transaction( 374 { 375 'type': 'ADD_PLAYLIST', 376 'playlistType': ptypename, 377 'playlistName': self._playlist_name, 378 'playlist': self._config.playlist_inline, 379 } 380 ) 381 run_transactions() 382 383 if self._first_run: 384 curtimestr = time.strftime('%c') 385 startupmsg = ( 386 f'{Clr.BLD}{Clr.BLU}{_ba.appnameupper()} {app.version}' 387 f' ({app.build_number})' 388 f' entering server-mode {curtimestr}{Clr.RST}' 389 ) 390 logging.info(startupmsg) 391 392 if sessiontype is FreeForAllSession: 393 appcfg['Free-for-All Playlist Selection'] = self._playlist_name 394 appcfg[ 395 'Free-for-All Playlist Randomize' 396 ] = self._config.playlist_shuffle 397 elif sessiontype is DualTeamSession: 398 appcfg['Team Tournament Playlist Selection'] = self._playlist_name 399 appcfg[ 400 'Team Tournament Playlist Randomize' 401 ] = self._config.playlist_shuffle 402 elif sessiontype is CoopSession: 403 app.coop_session_args = { 404 'campaign': self._config.coop_campaign, 405 'level': self._config.coop_level, 406 } 407 else: 408 raise RuntimeError(f'Unknown session type {sessiontype}') 409 410 app.teams_series_length = self._config.teams_series_length 411 app.ffa_series_length = self._config.ffa_series_length 412 413 _ba.set_authenticate_clients(self._config.authenticate_clients) 414 415 _ba.set_enable_default_kick_voting( 416 self._config.enable_default_kick_voting 417 ) 418 _ba.set_admins(self._config.admins) 419 420 # Call set-enabled last (will push state to the cloud). 421 _ba.set_public_party_max_size(self._config.max_party_size) 422 _ba.set_public_party_queue_enabled(self._config.enable_queue) 423 _ba.set_public_party_name(self._config.party_name) 424 _ba.set_public_party_stats_url(self._config.stats_url) 425 _ba.set_public_party_enabled(self._config.party_is_public) 426 427 # And here.. we.. go. 428 if self._config.stress_test_players is not None: 429 # Special case: run a stress test. 430 from ba.internal import run_stress_test 431 432 run_stress_test( 433 playlist_type='Random', 434 playlist_name='__default__', 435 player_count=self._config.stress_test_players, 436 round_duration=30, 437 ) 438 else: 439 _ba.new_host_session(sessiontype) 440 441 # Run an access check if we're trying to make a public party. 442 if not self._ran_access_check and self._config.party_is_public: 443 self._run_access_check() 444 self._ran_access_check = True
Overall controller for the app in server mode.
Category: App Classes
99 def __init__(self, config: ServerConfig) -> None: 100 101 self._config = config 102 self._playlist_name = '__default__' 103 self._ran_access_check = False 104 self._prep_timer: ba.Timer | None = None 105 self._next_stuck_login_warn_time = time.time() + 10.0 106 self._first_run = True 107 self._shutdown_reason: ShutdownReason | None = None 108 self._executing_shutdown = False 109 110 # Make note if they want us to import a playlist; 111 # we'll need to do that first if so. 112 self._playlist_fetch_running = self._config.playlist_code is not None 113 self._playlist_fetch_sent_request = False 114 self._playlist_fetch_got_response = False 115 self._playlist_fetch_code = -1 116 117 # Now sit around doing any pre-launch prep such as waiting for 118 # account sign-in or fetching playlists; this will kick off the 119 # session once done. 120 with _ba.Context('ui'): 121 self._prep_timer = _ba.Timer( 122 0.25, 123 self._prepare_to_serve, 124 timetype=TimeType.REAL, 125 repeat=True, 126 )
128 def print_client_list(self) -> None: 129 """Print info about all connected clients.""" 130 import json 131 132 roster = _ba.get_game_roster() 133 title1 = 'Client ID' 134 title2 = 'Account Name' 135 title3 = 'Players' 136 col1 = 10 137 col2 = 16 138 out = ( 139 f'{Clr.BLD}' 140 f'{title1:<{col1}} {title2:<{col2}} {title3}' 141 f'{Clr.RST}' 142 ) 143 for client in roster: 144 if client['client_id'] == -1: 145 continue 146 spec = json.loads(client['spec_string']) 147 name = spec['n'] 148 players = ', '.join(n['name'] for n in client['players']) 149 clientid = client['client_id'] 150 out += f'\n{clientid:<{col1}} {name:<{col2}} {players}' 151 print(out)
Print info about all connected clients.
153 def kick(self, client_id: int, ban_time: int | None) -> None: 154 """Kick the provided client id. 155 156 ban_time is provided in seconds. 157 If ban_time is None, ban duration will be determined automatically. 158 Pass 0 or a negative number for no ban time. 159 """ 160 161 # FIXME: this case should be handled under the hood. 162 if ban_time is None: 163 ban_time = 300 164 165 _ba.disconnect_client(client_id=client_id, ban_time=ban_time)
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.
167 def shutdown(self, reason: ShutdownReason, immediate: bool) -> None: 168 """Set the app to quit either now or at the next clean opportunity.""" 169 self._shutdown_reason = reason 170 if immediate: 171 print(f'{Clr.SBLU}Immediate shutdown initiated.{Clr.RST}') 172 self._execute_shutdown() 173 else: 174 print( 175 f'{Clr.SBLU}Shutdown initiated;' 176 f' server process will exit at the next clean opportunity.' 177 f'{Clr.RST}' 178 )
Set the app to quit either now or at the next clean opportunity.
180 def handle_transition(self) -> bool: 181 """Handle transitioning to a new ba.Session or quitting the app. 182 183 Will be called once at the end of an activity that is marked as 184 a good 'end-point' (such as a final score screen). 185 Should return True if action will be handled by us; False if the 186 session should just continue on it's merry way. 187 """ 188 if self._shutdown_reason is not None: 189 self._execute_shutdown() 190 return True 191 return False
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.
20class Session: 21 """Defines a high level series of ba.Activity-es with a common purpose. 22 23 Category: **Gameplay Classes** 24 25 Examples of sessions are ba.FreeForAllSession, ba.DualTeamSession, and 26 ba.CoopSession. 27 28 A Session is responsible for wrangling and transitioning between various 29 ba.Activity instances such as mini-games and score-screens, and for 30 maintaining state between them (players, teams, score tallies, etc). 31 """ 32 33 use_teams: bool = False 34 """Whether this session groups players into an explicit set of 35 teams. If this is off, a unique team is generated for each 36 player that joins.""" 37 38 use_team_colors: bool = True 39 """Whether players on a team should all adopt the colors of that 40 team instead of their own profile colors. This only applies if 41 use_teams is enabled.""" 42 43 # Note: even though these are instance vars, we annotate and document them 44 # at the class level so that looks better and nobody get lost while 45 # reading large __init__ 46 47 lobby: ba.Lobby 48 """The ba.Lobby instance where new ba.Player-s go to select a 49 Profile/Team/etc. before being added to games. 50 Be aware this value may be None if a Session does not allow 51 any such selection.""" 52 53 max_players: int 54 """The maximum number of players allowed in the Session.""" 55 56 min_players: int 57 """The minimum number of players who must be present for the Session 58 to proceed past the initial joining screen""" 59 60 sessionplayers: list[ba.SessionPlayer] 61 """All ba.SessionPlayers in the Session. Most things should use the 62 list of ba.Player-s in ba.Activity; not this. Some players, such as 63 those who have not yet selected a character, will only be 64 found on this list.""" 65 66 customdata: dict 67 """A shared dictionary for objects to use as storage on this session. 68 Ensure that keys here are unique to avoid collisions.""" 69 70 sessionteams: list[ba.SessionTeam] 71 """All the ba.SessionTeams in the Session. Most things should use the 72 list of ba.Team-s in ba.Activity; not this.""" 73 74 def __init__( 75 self, 76 depsets: Sequence[ba.DependencySet], 77 team_names: Sequence[str] | None = None, 78 team_colors: Sequence[Sequence[float]] | None = None, 79 min_players: int = 1, 80 max_players: int = 8, 81 ): 82 """Instantiate a session. 83 84 depsets should be a sequence of successfully resolved ba.DependencySet 85 instances; one for each ba.Activity the session may potentially run. 86 """ 87 # pylint: disable=too-many-statements 88 # pylint: disable=too-many-locals 89 # pylint: disable=cyclic-import 90 # pylint: disable=too-many-branches 91 from ba._lobby import Lobby 92 from ba._stats import Stats 93 from ba._gameactivity import GameActivity 94 from ba._activity import Activity 95 from ba._team import SessionTeam 96 from ba._error import DependencyError 97 from ba._dependency import Dependency, AssetPackage 98 from efro.util import empty_weakref 99 100 # First off, resolve all dependency-sets we were passed. 101 # If things are missing, we'll try to gather them into a single 102 # missing-deps exception if possible to give the caller a clean 103 # path to download missing stuff and try again. 104 missing_asset_packages: set[str] = set() 105 for depset in depsets: 106 try: 107 depset.resolve() 108 except DependencyError as exc: 109 # Gather/report missing assets only; barf on anything else. 110 if all(issubclass(d.cls, AssetPackage) for d in exc.deps): 111 for dep in exc.deps: 112 assert isinstance(dep.config, str) 113 missing_asset_packages.add(dep.config) 114 else: 115 missing_info = [(d.cls, d.config) for d in exc.deps] 116 raise RuntimeError( 117 f'Missing non-asset dependencies: {missing_info}' 118 ) from exc 119 120 # Throw a combined exception if we found anything missing. 121 if missing_asset_packages: 122 raise DependencyError( 123 [ 124 Dependency(AssetPackage, set_id) 125 for set_id in missing_asset_packages 126 ] 127 ) 128 129 # Ok; looks like our dependencies check out. 130 # Now give the engine a list of asset-set-ids to pass along to clients. 131 required_asset_packages: set[str] = set() 132 for depset in depsets: 133 required_asset_packages.update(depset.get_asset_package_ids()) 134 135 # print('Would set host-session asset-reqs to:', 136 # required_asset_packages) 137 138 # Init our C++ layer data. 139 self._sessiondata = _ba.register_session(self) 140 141 # Should remove this if possible. 142 self.tournament_id: str | None = None 143 144 self.sessionteams = [] 145 self.sessionplayers = [] 146 self.min_players = min_players 147 self.max_players = max_players 148 149 self.customdata = {} 150 self._in_set_activity = False 151 self._next_team_id = 0 152 self._activity_retained: ba.Activity | None = None 153 self._launch_end_session_activity_time: float | None = None 154 self._activity_end_timer: ba.Timer | None = None 155 self._activity_weak = empty_weakref(Activity) 156 self._next_activity: ba.Activity | None = None 157 self._wants_to_end = False 158 self._ending = False 159 self._activity_should_end_immediately = False 160 self._activity_should_end_immediately_results: ( 161 ba.GameResults | None 162 ) = None 163 self._activity_should_end_immediately_delay = 0.0 164 165 # Create static teams if we're using them. 166 if self.use_teams: 167 if team_names is None: 168 raise RuntimeError( 169 'use_teams is True but team_names not provided.' 170 ) 171 if team_colors is None: 172 raise RuntimeError( 173 'use_teams is True but team_colors not provided.' 174 ) 175 if len(team_colors) != len(team_names): 176 raise RuntimeError( 177 f'Got {len(team_names)} team_names' 178 f' and {len(team_colors)} team_colors;' 179 f' these numbers must match.' 180 ) 181 for i, color in enumerate(team_colors): 182 team = SessionTeam( 183 team_id=self._next_team_id, 184 name=GameActivity.get_team_display_string(team_names[i]), 185 color=color, 186 ) 187 self.sessionteams.append(team) 188 self._next_team_id += 1 189 try: 190 with _ba.Context(self): 191 self.on_team_join(team) 192 except Exception: 193 print_exception(f'Error in on_team_join for {self}.') 194 195 self.lobby = Lobby() 196 self.stats = Stats() 197 198 # Instantiate our session globals node which will apply its settings. 199 self._sessionglobalsnode = _ba.newnode('sessionglobals') 200 201 @property 202 def sessionglobalsnode(self) -> ba.Node: 203 """The sessionglobals ba.Node for the session.""" 204 node = self._sessionglobalsnode 205 if not node: 206 raise NodeNotFoundError() 207 return node 208 209 def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool: 210 """Ask ourself if we should allow joins during an Activity. 211 212 Note that for a join to be allowed, both the Session and Activity 213 have to be ok with it (via this function and the 214 Activity.allow_mid_activity_joins property. 215 """ 216 del activity # Unused. 217 return True 218 219 def on_player_request(self, player: ba.SessionPlayer) -> bool: 220 """Called when a new ba.Player wants to join the Session. 221 222 This should return True or False to accept/reject. 223 """ 224 225 # Limit player counts *unless* we're in a stress test. 226 if _ba.app.stress_test_reset_timer is None: 227 228 if len(self.sessionplayers) >= self.max_players: 229 # Print a rejection message *only* to the client trying to 230 # join (prevents spamming everyone else in the game). 231 _ba.playsound(_ba.getsound('error')) 232 _ba.screenmessage( 233 Lstr( 234 resource='playerLimitReachedText', 235 subs=[('${COUNT}', str(self.max_players))], 236 ), 237 color=(0.8, 0.0, 0.0), 238 clients=[player.inputdevice.client_id], 239 transient=True, 240 ) 241 return False 242 243 _ba.playsound(_ba.getsound('dripity')) 244 return True 245 246 def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None: 247 """Called when a previously-accepted ba.SessionPlayer leaves.""" 248 249 if sessionplayer not in self.sessionplayers: 250 print( 251 'ERROR: Session.on_player_leave called' 252 ' for player not in our list.' 253 ) 254 return 255 256 _ba.playsound(_ba.getsound('playerLeft')) 257 258 activity = self._activity_weak() 259 260 if not sessionplayer.in_game: 261 262 # Ok, the player is still in the lobby; simply remove them. 263 with _ba.Context(self): 264 try: 265 self.lobby.remove_chooser(sessionplayer) 266 except Exception: 267 print_exception('Error in Lobby.remove_chooser().') 268 else: 269 # Ok, they've already entered the game. Remove them from 270 # teams/activities/etc. 271 sessionteam = sessionplayer.sessionteam 272 assert sessionteam is not None 273 274 _ba.screenmessage( 275 Lstr( 276 resource='playerLeftText', 277 subs=[('${PLAYER}', sessionplayer.getname(full=True))], 278 ) 279 ) 280 281 # Remove them from their SessionTeam. 282 if sessionplayer in sessionteam.players: 283 sessionteam.players.remove(sessionplayer) 284 else: 285 print( 286 'SessionPlayer not found in SessionTeam' 287 ' in on_player_leave.' 288 ) 289 290 # Grab their activity-specific player instance. 291 player = sessionplayer.activityplayer 292 assert isinstance(player, (Player, type(None))) 293 294 # Remove them from any current Activity. 295 if player is not None and activity is not None: 296 if player in activity.players: 297 activity.remove_player(sessionplayer) 298 else: 299 print('Player not found in Activity in on_player_leave.') 300 301 # If we're a non-team session, remove their team too. 302 if not self.use_teams: 303 self._remove_player_team(sessionteam, activity) 304 305 # Now remove them from the session list. 306 self.sessionplayers.remove(sessionplayer) 307 308 def _remove_player_team( 309 self, sessionteam: ba.SessionTeam, activity: ba.Activity | None 310 ) -> None: 311 """Remove the player-specific team in non-teams mode.""" 312 313 # They should have been the only one on their team. 314 assert not sessionteam.players 315 316 # Remove their Team from the Activity. 317 if activity is not None: 318 if sessionteam.activityteam in activity.teams: 319 activity.remove_team(sessionteam) 320 else: 321 print('Team not found in Activity in on_player_leave.') 322 323 # And then from the Session. 324 with _ba.Context(self): 325 if sessionteam in self.sessionteams: 326 try: 327 self.sessionteams.remove(sessionteam) 328 self.on_team_leave(sessionteam) 329 except Exception: 330 print_exception( 331 f'Error in on_team_leave for Session {self}.' 332 ) 333 else: 334 print('Team no in Session teams in on_player_leave.') 335 try: 336 sessionteam.leave() 337 except Exception: 338 print_exception( 339 f'Error clearing sessiondata' 340 f' for team {sessionteam} in session {self}.' 341 ) 342 343 def end(self) -> None: 344 """Initiates an end to the session and a return to the main menu. 345 346 Note that this happens asynchronously, allowing the 347 session and its activities to shut down gracefully. 348 """ 349 self._wants_to_end = True 350 if self._next_activity is None: 351 self._launch_end_session_activity() 352 353 def _launch_end_session_activity(self) -> None: 354 """(internal)""" 355 from ba._activitytypes import EndSessionActivity 356 from ba._generated.enums import TimeType 357 358 with _ba.Context(self): 359 curtime = _ba.time(TimeType.REAL) 360 if self._ending: 361 # Ignore repeats unless its been a while. 362 assert self._launch_end_session_activity_time is not None 363 since_last = curtime - self._launch_end_session_activity_time 364 if since_last < 30.0: 365 return 366 print_error( 367 '_launch_end_session_activity called twice (since_last=' 368 + str(since_last) 369 + ')' 370 ) 371 self._launch_end_session_activity_time = curtime 372 self.setactivity(_ba.newactivity(EndSessionActivity)) 373 self._wants_to_end = False 374 self._ending = True # Prevent further actions. 375 376 def on_team_join(self, team: ba.SessionTeam) -> None: 377 """Called when a new ba.Team joins the session.""" 378 379 def on_team_leave(self, team: ba.SessionTeam) -> None: 380 """Called when a ba.Team is leaving the session.""" 381 382 def end_activity( 383 self, activity: ba.Activity, results: Any, delay: float, force: bool 384 ) -> None: 385 """Commence shutdown of a ba.Activity (if not already occurring). 386 387 'delay' is the time delay before the Activity actually ends 388 (in seconds). Further calls to end() will be ignored up until 389 this time, unless 'force' is True, in which case the new results 390 will replace the old. 391 """ 392 from ba._general import Call 393 from ba._generated.enums import TimeType 394 395 # Only pay attention if this is coming from our current activity. 396 if activity is not self._activity_retained: 397 return 398 399 # If this activity hasn't begun yet, just set it up to end immediately 400 # once it does. 401 if not activity.has_begun(): 402 # activity.set_immediate_end(results, delay, force) 403 if not self._activity_should_end_immediately or force: 404 self._activity_should_end_immediately = True 405 self._activity_should_end_immediately_results = results 406 self._activity_should_end_immediately_delay = delay 407 408 # The activity has already begun; get ready to end it. 409 else: 410 if (not activity.has_ended()) or force: 411 activity.set_has_ended(True) 412 413 # Set a timer to set in motion this activity's demise. 414 self._activity_end_timer = _ba.Timer( 415 delay, 416 Call(self._complete_end_activity, activity, results), 417 timetype=TimeType.BASE, 418 ) 419 420 def handlemessage(self, msg: Any) -> Any: 421 """General message handling; can be passed any message object.""" 422 from ba._lobby import PlayerReadyMessage 423 from ba._messages import PlayerProfilesChangedMessage, UNHANDLED 424 425 if isinstance(msg, PlayerReadyMessage): 426 self._on_player_ready(msg.chooser) 427 428 elif isinstance(msg, PlayerProfilesChangedMessage): 429 # If we have a current activity with a lobby, ask it to reload 430 # profiles. 431 with _ba.Context(self): 432 self.lobby.reload_profiles() 433 return None 434 435 else: 436 return UNHANDLED 437 return None 438 439 class _SetActivityScopedLock: 440 def __init__(self, session: ba.Session) -> None: 441 self._session = session 442 if session._in_set_activity: 443 raise RuntimeError('Session.setactivity() called recursively.') 444 self._session._in_set_activity = True 445 446 def __del__(self) -> None: 447 self._session._in_set_activity = False 448 449 def setactivity(self, activity: ba.Activity) -> None: 450 """Assign a new current ba.Activity for the session. 451 452 Note that this will not change the current context to the new 453 Activity's. Code must be run in the new activity's methods 454 (on_transition_in, etc) to get it. (so you can't do 455 session.setactivity(foo) and then ba.newnode() to add a node to foo) 456 """ 457 from ba._generated.enums import TimeType 458 459 # Make sure we don't get called recursively. 460 _rlock = self._SetActivityScopedLock(self) 461 462 if activity.session is not _ba.getsession(): 463 raise RuntimeError("Provided Activity's Session is not current.") 464 465 # Quietly ignore this if the whole session is going down. 466 if self._ending: 467 return 468 469 if activity is self._activity_retained: 470 print_error('Activity set to already-current activity.') 471 return 472 473 if self._next_activity is not None: 474 raise RuntimeError( 475 'Activity switch already in progress (to ' 476 + str(self._next_activity) 477 + ')' 478 ) 479 480 prev_activity = self._activity_retained 481 prev_globals = ( 482 prev_activity.globalsnode if prev_activity is not None else None 483 ) 484 485 # Let the activity do its thing. 486 activity.transition_in(prev_globals) 487 488 self._next_activity = activity 489 490 # If we have a current activity, tell it it's transitioning out; 491 # the next one will become current once this one dies. 492 if prev_activity is not None: 493 prev_activity.transition_out() 494 495 # Setting this to None should free up the old activity to die, 496 # which will call begin_next_activity. 497 # We can still access our old activity through 498 # self._activity_weak() to keep it up to date on player 499 # joins/departures/etc until it dies. 500 self._activity_retained = None 501 502 # There's no existing activity; lets just go ahead with the begin call. 503 else: 504 self.begin_next_activity() 505 506 # We want to call destroy() for the previous activity once it should 507 # tear itself down, clear out any self-refs, etc. After this call 508 # the activity should have no refs left to it and should die (which 509 # will trigger the next activity to run). 510 if prev_activity is not None: 511 with _ba.Context('ui'): 512 _ba.timer( 513 max(0.0, activity.transition_time), 514 prev_activity.expire, 515 timetype=TimeType.REAL, 516 ) 517 self._in_set_activity = False 518 519 def getactivity(self) -> ba.Activity | None: 520 """Return the current foreground activity for this session.""" 521 return self._activity_weak() 522 523 def get_custom_menu_entries(self) -> list[dict[str, Any]]: 524 """Subclasses can override this to provide custom menu entries. 525 526 The returned value should be a list of dicts, each containing 527 a 'label' and 'call' entry, with 'label' being the text for 528 the entry and 'call' being the callable to trigger if the entry 529 is pressed. 530 """ 531 return [] 532 533 def _complete_end_activity( 534 self, activity: ba.Activity, results: Any 535 ) -> None: 536 # Run the subclass callback in the session context. 537 try: 538 with _ba.Context(self): 539 self.on_activity_end(activity, results) 540 except Exception: 541 print_exception( 542 f'Error in on_activity_end() for session {self}' 543 f' activity {activity} with results {results}' 544 ) 545 546 def _request_player(self, sessionplayer: ba.SessionPlayer) -> bool: 547 """Called by the native layer when a player wants to join.""" 548 549 # If we're ending, allow no new players. 550 if self._ending: 551 return False 552 553 # Ask the ba.Session subclass to approve/deny this request. 554 try: 555 with _ba.Context(self): 556 result = self.on_player_request(sessionplayer) 557 except Exception: 558 print_exception(f'Error in on_player_request for {self}') 559 result = False 560 561 # If they said yes, add the player to the lobby. 562 if result: 563 self.sessionplayers.append(sessionplayer) 564 with _ba.Context(self): 565 try: 566 self.lobby.add_chooser(sessionplayer) 567 except Exception: 568 print_exception('Error in lobby.add_chooser().') 569 570 return result 571 572 def on_activity_end(self, activity: ba.Activity, results: Any) -> None: 573 """Called when the current ba.Activity has ended. 574 575 The ba.Session should look at the results and start 576 another ba.Activity. 577 """ 578 579 def begin_next_activity(self) -> None: 580 """Called once the previous activity has been totally torn down. 581 582 This means we're ready to begin the next one 583 """ 584 if self._next_activity is None: 585 # Should this ever happen? 586 print_error('begin_next_activity() called with no _next_activity') 587 return 588 589 # We store both a weak and a strong ref to the new activity; 590 # the strong is to keep it alive and the weak is so we can access 591 # it even after we've released the strong-ref to allow it to die. 592 self._activity_retained = self._next_activity 593 self._activity_weak = weakref.ref(self._next_activity) 594 self._next_activity = None 595 self._activity_should_end_immediately = False 596 597 # Kick out anyone loitering in the lobby. 598 self.lobby.remove_all_choosers_and_kick_players() 599 600 # Kick off the activity. 601 self._activity_retained.begin(self) 602 603 # If we want to completely end the session, we can now kick that off. 604 if self._wants_to_end: 605 self._launch_end_session_activity() 606 else: 607 # Otherwise, if the activity has already been told to end, 608 # do so now. 609 if self._activity_should_end_immediately: 610 self._activity_retained.end( 611 self._activity_should_end_immediately_results, 612 self._activity_should_end_immediately_delay, 613 ) 614 615 def _on_player_ready(self, chooser: ba.Chooser) -> None: 616 """Called when a ba.Player has checked themself ready.""" 617 lobby = chooser.lobby 618 activity = self._activity_weak() 619 620 # This happens sometimes. That seems like it shouldn't be happening; 621 # when would we have a session and a chooser with players but no 622 # active activity? 623 if activity is None: 624 print('_on_player_ready called with no activity.') 625 return 626 627 # In joining-activities, we wait till all choosers are ready 628 # and then create all players at once. 629 if activity.is_joining_activity: 630 if not lobby.check_all_ready(): 631 return 632 choosers = lobby.get_choosers() 633 min_players = self.min_players 634 if len(choosers) >= min_players: 635 for lch in lobby.get_choosers(): 636 self._add_chosen_player(lch) 637 lobby.remove_all_choosers() 638 639 # Get our next activity going. 640 self._complete_end_activity(activity, {}) 641 else: 642 _ba.screenmessage( 643 Lstr( 644 resource='notEnoughPlayersText', 645 subs=[('${COUNT}', str(min_players))], 646 ), 647 color=(1, 1, 0), 648 ) 649 _ba.playsound(_ba.getsound('error')) 650 651 # Otherwise just add players on the fly. 652 else: 653 self._add_chosen_player(chooser) 654 lobby.remove_chooser(chooser.getplayer()) 655 656 def transitioning_out_activity_was_freed( 657 self, can_show_ad_on_death: bool 658 ) -> None: 659 """(internal)""" 660 # pylint: disable=cyclic-import 661 from ba._apputils import garbage_collect 662 663 # Since things should be generally still right now, it's a good time 664 # to run garbage collection to clear out any circular dependency 665 # loops. We keep this disabled normally to avoid non-deterministic 666 # hitches. 667 garbage_collect() 668 669 with _ba.Context(self): 670 if can_show_ad_on_death: 671 _ba.app.ads.call_after_ad(self.begin_next_activity) 672 else: 673 _ba.pushcall(self.begin_next_activity) 674 675 def _add_chosen_player(self, chooser: ba.Chooser) -> ba.SessionPlayer: 676 from ba._team import SessionTeam 677 678 sessionplayer = chooser.getplayer() 679 assert sessionplayer in self.sessionplayers, ( 680 'SessionPlayer not found in session ' 681 'player-list after chooser selection.' 682 ) 683 684 activity = self._activity_weak() 685 assert activity is not None 686 687 # Reset the player's input here, as it is probably 688 # referencing the chooser which could inadvertently keep it alive. 689 sessionplayer.resetinput() 690 691 # We can pass it to the current activity if it has already begun 692 # (otherwise it'll get passed once begin is called). 693 pass_to_activity = ( 694 activity.has_begun() and not activity.is_joining_activity 695 ) 696 697 # However, if we're not allowing mid-game joins, don't actually pass; 698 # just announce the arrival and say they'll partake next round. 699 if pass_to_activity: 700 if not ( 701 activity.allow_mid_activity_joins 702 and self.should_allow_mid_activity_joins(activity) 703 ): 704 pass_to_activity = False 705 with _ba.Context(self): 706 _ba.screenmessage( 707 Lstr( 708 resource='playerDelayedJoinText', 709 subs=[ 710 ('${PLAYER}', sessionplayer.getname(full=True)) 711 ], 712 ), 713 color=(0, 1, 0), 714 ) 715 716 # If we're a non-team session, each player gets their own team. 717 # (keeps mini-game coding simpler if we can always deal with teams). 718 if self.use_teams: 719 sessionteam = chooser.sessionteam 720 else: 721 our_team_id = self._next_team_id 722 self._next_team_id += 1 723 sessionteam = SessionTeam( 724 team_id=our_team_id, 725 color=chooser.get_color(), 726 name=chooser.getplayer().getname(full=True, icon=False), 727 ) 728 729 # Add player's team to the Session. 730 self.sessionteams.append(sessionteam) 731 732 with _ba.Context(self): 733 try: 734 self.on_team_join(sessionteam) 735 except Exception: 736 print_exception(f'Error in on_team_join for {self}.') 737 738 # Add player's team to the Activity. 739 if pass_to_activity: 740 activity.add_team(sessionteam) 741 742 assert sessionplayer not in sessionteam.players 743 sessionteam.players.append(sessionplayer) 744 sessionplayer.setdata( 745 team=sessionteam, 746 character=chooser.get_character_name(), 747 color=chooser.get_color(), 748 highlight=chooser.get_highlight(), 749 ) 750 751 self.stats.register_sessionplayer(sessionplayer) 752 if pass_to_activity: 753 activity.add_player(sessionplayer) 754 return sessionplayer
Defines a high level series of ba.Activity-es 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).
74 def __init__( 75 self, 76 depsets: Sequence[ba.DependencySet], 77 team_names: Sequence[str] | None = None, 78 team_colors: Sequence[Sequence[float]] | None = None, 79 min_players: int = 1, 80 max_players: int = 8, 81 ): 82 """Instantiate a session. 83 84 depsets should be a sequence of successfully resolved ba.DependencySet 85 instances; one for each ba.Activity the session may potentially run. 86 """ 87 # pylint: disable=too-many-statements 88 # pylint: disable=too-many-locals 89 # pylint: disable=cyclic-import 90 # pylint: disable=too-many-branches 91 from ba._lobby import Lobby 92 from ba._stats import Stats 93 from ba._gameactivity import GameActivity 94 from ba._activity import Activity 95 from ba._team import SessionTeam 96 from ba._error import DependencyError 97 from ba._dependency import Dependency, AssetPackage 98 from efro.util import empty_weakref 99 100 # First off, resolve all dependency-sets we were passed. 101 # If things are missing, we'll try to gather them into a single 102 # missing-deps exception if possible to give the caller a clean 103 # path to download missing stuff and try again. 104 missing_asset_packages: set[str] = set() 105 for depset in depsets: 106 try: 107 depset.resolve() 108 except DependencyError as exc: 109 # Gather/report missing assets only; barf on anything else. 110 if all(issubclass(d.cls, AssetPackage) for d in exc.deps): 111 for dep in exc.deps: 112 assert isinstance(dep.config, str) 113 missing_asset_packages.add(dep.config) 114 else: 115 missing_info = [(d.cls, d.config) for d in exc.deps] 116 raise RuntimeError( 117 f'Missing non-asset dependencies: {missing_info}' 118 ) from exc 119 120 # Throw a combined exception if we found anything missing. 121 if missing_asset_packages: 122 raise DependencyError( 123 [ 124 Dependency(AssetPackage, set_id) 125 for set_id in missing_asset_packages 126 ] 127 ) 128 129 # Ok; looks like our dependencies check out. 130 # Now give the engine a list of asset-set-ids to pass along to clients. 131 required_asset_packages: set[str] = set() 132 for depset in depsets: 133 required_asset_packages.update(depset.get_asset_package_ids()) 134 135 # print('Would set host-session asset-reqs to:', 136 # required_asset_packages) 137 138 # Init our C++ layer data. 139 self._sessiondata = _ba.register_session(self) 140 141 # Should remove this if possible. 142 self.tournament_id: str | None = None 143 144 self.sessionteams = [] 145 self.sessionplayers = [] 146 self.min_players = min_players 147 self.max_players = max_players 148 149 self.customdata = {} 150 self._in_set_activity = False 151 self._next_team_id = 0 152 self._activity_retained: ba.Activity | None = None 153 self._launch_end_session_activity_time: float | None = None 154 self._activity_end_timer: ba.Timer | None = None 155 self._activity_weak = empty_weakref(Activity) 156 self._next_activity: ba.Activity | None = None 157 self._wants_to_end = False 158 self._ending = False 159 self._activity_should_end_immediately = False 160 self._activity_should_end_immediately_results: ( 161 ba.GameResults | None 162 ) = None 163 self._activity_should_end_immediately_delay = 0.0 164 165 # Create static teams if we're using them. 166 if self.use_teams: 167 if team_names is None: 168 raise RuntimeError( 169 'use_teams is True but team_names not provided.' 170 ) 171 if team_colors is None: 172 raise RuntimeError( 173 'use_teams is True but team_colors not provided.' 174 ) 175 if len(team_colors) != len(team_names): 176 raise RuntimeError( 177 f'Got {len(team_names)} team_names' 178 f' and {len(team_colors)} team_colors;' 179 f' these numbers must match.' 180 ) 181 for i, color in enumerate(team_colors): 182 team = SessionTeam( 183 team_id=self._next_team_id, 184 name=GameActivity.get_team_display_string(team_names[i]), 185 color=color, 186 ) 187 self.sessionteams.append(team) 188 self._next_team_id += 1 189 try: 190 with _ba.Context(self): 191 self.on_team_join(team) 192 except Exception: 193 print_exception(f'Error in on_team_join for {self}.') 194 195 self.lobby = Lobby() 196 self.stats = Stats() 197 198 # Instantiate our session globals node which will apply its settings. 199 self._sessionglobalsnode = _ba.newnode('sessionglobals')
Instantiate a session.
depsets should be a sequence of successfully resolved ba.DependencySet instances; one for each ba.Activity the session may potentially run.
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.
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.
The minimum number of players who must be present for the Session to proceed past the initial joining screen
All ba.SessionPlayers in the Session. Most things should use the list of ba.Player-s in ba.Activity; not this. Some players, such as those who have not yet selected a character, will only be found on this list.
A shared dictionary for objects to use as storage on this session. Ensure that keys here are unique to avoid collisions.
All the ba.SessionTeams in the Session. Most things should use the list of ba.Team-s in ba.Activity; not this.
209 def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool: 210 """Ask ourself if we should allow joins during an Activity. 211 212 Note that for a join to be allowed, both the Session and Activity 213 have to be ok with it (via this function and the 214 Activity.allow_mid_activity_joins property. 215 """ 216 del activity # Unused. 217 return True
Ask ourself if we should allow joins during an Activity.
Note that for a join to be allowed, both the Session and Activity have to be ok with it (via this function and the Activity.allow_mid_activity_joins property.
219 def on_player_request(self, player: ba.SessionPlayer) -> bool: 220 """Called when a new ba.Player wants to join the Session. 221 222 This should return True or False to accept/reject. 223 """ 224 225 # Limit player counts *unless* we're in a stress test. 226 if _ba.app.stress_test_reset_timer is None: 227 228 if len(self.sessionplayers) >= self.max_players: 229 # Print a rejection message *only* to the client trying to 230 # join (prevents spamming everyone else in the game). 231 _ba.playsound(_ba.getsound('error')) 232 _ba.screenmessage( 233 Lstr( 234 resource='playerLimitReachedText', 235 subs=[('${COUNT}', str(self.max_players))], 236 ), 237 color=(0.8, 0.0, 0.0), 238 clients=[player.inputdevice.client_id], 239 transient=True, 240 ) 241 return False 242 243 _ba.playsound(_ba.getsound('dripity')) 244 return True
Called when a new ba.Player wants to join the Session.
This should return True or False to accept/reject.
246 def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None: 247 """Called when a previously-accepted ba.SessionPlayer leaves.""" 248 249 if sessionplayer not in self.sessionplayers: 250 print( 251 'ERROR: Session.on_player_leave called' 252 ' for player not in our list.' 253 ) 254 return 255 256 _ba.playsound(_ba.getsound('playerLeft')) 257 258 activity = self._activity_weak() 259 260 if not sessionplayer.in_game: 261 262 # Ok, the player is still in the lobby; simply remove them. 263 with _ba.Context(self): 264 try: 265 self.lobby.remove_chooser(sessionplayer) 266 except Exception: 267 print_exception('Error in Lobby.remove_chooser().') 268 else: 269 # Ok, they've already entered the game. Remove them from 270 # teams/activities/etc. 271 sessionteam = sessionplayer.sessionteam 272 assert sessionteam is not None 273 274 _ba.screenmessage( 275 Lstr( 276 resource='playerLeftText', 277 subs=[('${PLAYER}', sessionplayer.getname(full=True))], 278 ) 279 ) 280 281 # Remove them from their SessionTeam. 282 if sessionplayer in sessionteam.players: 283 sessionteam.players.remove(sessionplayer) 284 else: 285 print( 286 'SessionPlayer not found in SessionTeam' 287 ' in on_player_leave.' 288 ) 289 290 # Grab their activity-specific player instance. 291 player = sessionplayer.activityplayer 292 assert isinstance(player, (Player, type(None))) 293 294 # Remove them from any current Activity. 295 if player is not None and activity is not None: 296 if player in activity.players: 297 activity.remove_player(sessionplayer) 298 else: 299 print('Player not found in Activity in on_player_leave.') 300 301 # If we're a non-team session, remove their team too. 302 if not self.use_teams: 303 self._remove_player_team(sessionteam, activity) 304 305 # Now remove them from the session list. 306 self.sessionplayers.remove(sessionplayer)
Called when a previously-accepted ba.SessionPlayer leaves.
343 def end(self) -> None: 344 """Initiates an end to the session and a return to the main menu. 345 346 Note that this happens asynchronously, allowing the 347 session and its activities to shut down gracefully. 348 """ 349 self._wants_to_end = True 350 if self._next_activity is None: 351 self._launch_end_session_activity()
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.
376 def on_team_join(self, team: ba.SessionTeam) -> None: 377 """Called when a new ba.Team joins the session."""
Called when a new ba.Team joins the session.
379 def on_team_leave(self, team: ba.SessionTeam) -> None: 380 """Called when a ba.Team is leaving the session."""
Called when a ba.Team is leaving the session.
382 def end_activity( 383 self, activity: ba.Activity, results: Any, delay: float, force: bool 384 ) -> None: 385 """Commence shutdown of a ba.Activity (if not already occurring). 386 387 'delay' is the time delay before the Activity actually ends 388 (in seconds). Further calls to end() will be ignored up until 389 this time, unless 'force' is True, in which case the new results 390 will replace the old. 391 """ 392 from ba._general import Call 393 from ba._generated.enums import TimeType 394 395 # Only pay attention if this is coming from our current activity. 396 if activity is not self._activity_retained: 397 return 398 399 # If this activity hasn't begun yet, just set it up to end immediately 400 # once it does. 401 if not activity.has_begun(): 402 # activity.set_immediate_end(results, delay, force) 403 if not self._activity_should_end_immediately or force: 404 self._activity_should_end_immediately = True 405 self._activity_should_end_immediately_results = results 406 self._activity_should_end_immediately_delay = delay 407 408 # The activity has already begun; get ready to end it. 409 else: 410 if (not activity.has_ended()) or force: 411 activity.set_has_ended(True) 412 413 # Set a timer to set in motion this activity's demise. 414 self._activity_end_timer = _ba.Timer( 415 delay, 416 Call(self._complete_end_activity, activity, results), 417 timetype=TimeType.BASE, 418 )
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.
420 def handlemessage(self, msg: Any) -> Any: 421 """General message handling; can be passed any message object.""" 422 from ba._lobby import PlayerReadyMessage 423 from ba._messages import PlayerProfilesChangedMessage, UNHANDLED 424 425 if isinstance(msg, PlayerReadyMessage): 426 self._on_player_ready(msg.chooser) 427 428 elif isinstance(msg, PlayerProfilesChangedMessage): 429 # If we have a current activity with a lobby, ask it to reload 430 # profiles. 431 with _ba.Context(self): 432 self.lobby.reload_profiles() 433 return None 434 435 else: 436 return UNHANDLED 437 return None
General message handling; can be passed any message object.
449 def setactivity(self, activity: ba.Activity) -> None: 450 """Assign a new current ba.Activity for the session. 451 452 Note that this will not change the current context to the new 453 Activity's. Code must be run in the new activity's methods 454 (on_transition_in, etc) to get it. (so you can't do 455 session.setactivity(foo) and then ba.newnode() to add a node to foo) 456 """ 457 from ba._generated.enums import TimeType 458 459 # Make sure we don't get called recursively. 460 _rlock = self._SetActivityScopedLock(self) 461 462 if activity.session is not _ba.getsession(): 463 raise RuntimeError("Provided Activity's Session is not current.") 464 465 # Quietly ignore this if the whole session is going down. 466 if self._ending: 467 return 468 469 if activity is self._activity_retained: 470 print_error('Activity set to already-current activity.') 471 return 472 473 if self._next_activity is not None: 474 raise RuntimeError( 475 'Activity switch already in progress (to ' 476 + str(self._next_activity) 477 + ')' 478 ) 479 480 prev_activity = self._activity_retained 481 prev_globals = ( 482 prev_activity.globalsnode if prev_activity is not None else None 483 ) 484 485 # Let the activity do its thing. 486 activity.transition_in(prev_globals) 487 488 self._next_activity = activity 489 490 # If we have a current activity, tell it it's transitioning out; 491 # the next one will become current once this one dies. 492 if prev_activity is not None: 493 prev_activity.transition_out() 494 495 # Setting this to None should free up the old activity to die, 496 # which will call begin_next_activity. 497 # We can still access our old activity through 498 # self._activity_weak() to keep it up to date on player 499 # joins/departures/etc until it dies. 500 self._activity_retained = None 501 502 # There's no existing activity; lets just go ahead with the begin call. 503 else: 504 self.begin_next_activity() 505 506 # We want to call destroy() for the previous activity once it should 507 # tear itself down, clear out any self-refs, etc. After this call 508 # the activity should have no refs left to it and should die (which 509 # will trigger the next activity to run). 510 if prev_activity is not None: 511 with _ba.Context('ui'): 512 _ba.timer( 513 max(0.0, activity.transition_time), 514 prev_activity.expire, 515 timetype=TimeType.REAL, 516 ) 517 self._in_set_activity = False
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)
519 def getactivity(self) -> ba.Activity | None: 520 """Return the current foreground activity for this session.""" 521 return self._activity_weak()
Return the current foreground activity for this session.
572 def on_activity_end(self, activity: ba.Activity, results: Any) -> None: 573 """Called when the current ba.Activity has ended. 574 575 The ba.Session should look at the results and start 576 another ba.Activity. 577 """
Called when the current ba.Activity has ended.
The ba.Session should look at the results and start another ba.Activity.
579 def begin_next_activity(self) -> None: 580 """Called once the previous activity has been totally torn down. 581 582 This means we're ready to begin the next one 583 """ 584 if self._next_activity is None: 585 # Should this ever happen? 586 print_error('begin_next_activity() called with no _next_activity') 587 return 588 589 # We store both a weak and a strong ref to the new activity; 590 # the strong is to keep it alive and the weak is so we can access 591 # it even after we've released the strong-ref to allow it to die. 592 self._activity_retained = self._next_activity 593 self._activity_weak = weakref.ref(self._next_activity) 594 self._next_activity = None 595 self._activity_should_end_immediately = False 596 597 # Kick out anyone loitering in the lobby. 598 self.lobby.remove_all_choosers_and_kick_players() 599 600 # Kick off the activity. 601 self._activity_retained.begin(self) 602 603 # If we want to completely end the session, we can now kick that off. 604 if self._wants_to_end: 605 self._launch_end_session_activity() 606 else: 607 # Otherwise, if the activity has already been told to end, 608 # do so now. 609 if self._activity_should_end_immediately: 610 self._activity_retained.end( 611 self._activity_should_end_immediately_results, 612 self._activity_should_end_immediately_delay, 613 )
Called once the previous activity has been totally torn down.
This means we're ready to begin the next one
115class SessionNotFoundError(NotFoundError): 116 """Exception raised when an expected ba.Session does not exist. 117 118 Category: **Exception Classes** 119 """
Exception raised when an expected ba.Session does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
782class SessionPlayer: 783 784 """A reference to a player in the ba.Session. 785 786 Category: **Gameplay Classes** 787 788 These are created and managed internally and 789 provided to your ba.Session/ba.Activity instances. 790 Be aware that, like `ba.Node`s, ba.SessionPlayer objects are 'weak' 791 references under-the-hood; a player can leave the game at 792 any point. For this reason, you should make judicious use of the 793 ba.SessionPlayer.exists() method (or boolean operator) to ensure 794 that a SessionPlayer is still present if retaining references to one 795 for any length of time. 796 """ 797 798 id: int 799 800 """The unique numeric ID of the Player. 801 802 Note that you can also use the boolean operator for this same 803 functionality, so a statement such as "if player" will do 804 the right thing both for Player objects and values of None.""" 805 806 in_game: bool 807 808 """This bool value will be True once the Player has completed 809 any lobby character/team selection.""" 810 811 sessionteam: ba.SessionTeam 812 813 """The ba.SessionTeam this Player is on. If the SessionPlayer 814 is still in its lobby selecting a team/etc. then a 815 ba.SessionTeamNotFoundError will be raised.""" 816 817 inputdevice: ba.InputDevice 818 819 """The input device associated with the player.""" 820 821 color: Sequence[float] 822 823 """The base color for this Player. 824 In team games this will match the ba.SessionTeam's color.""" 825 826 highlight: Sequence[float] 827 828 """A secondary color for this player. 829 This is used for minor highlights and accents 830 to allow a player to stand apart from his teammates 831 who may all share the same team (primary) color.""" 832 833 character: str 834 835 """The character this player has selected in their profile.""" 836 837 activityplayer: ba.Player | None 838 839 """The current game-specific instance for this player.""" 840 841 def assigninput( 842 self, type: ba.InputType | tuple[ba.InputType, ...], call: Callable 843 ) -> None: 844 845 """Set the python callable to be run for one or more types of input.""" 846 return None 847 848 def exists(self) -> bool: 849 850 """Return whether the underlying player is still in the game.""" 851 return bool() 852 853 def get_icon(self) -> dict[str, Any]: 854 855 """Returns the character's icon (images, colors, etc contained 856 in a dict. 857 """ 858 return {'foo': 'bar'} 859 860 def get_icon_info(self) -> dict[str, Any]: 861 862 """(internal)""" 863 return {'foo': 'bar'} 864 865 def get_v1_account_id(self) -> str: 866 867 """Return the V1 Account ID this player is signed in under, if 868 there is one and it can be determined with relative certainty. 869 Returns None otherwise. Note that this may require an active 870 internet connection (especially for network-connected players) 871 and may return None for a short while after a player initially 872 joins (while verification occurs). 873 """ 874 return str() 875 876 def getname(self, full: bool = False, icon: bool = True) -> str: 877 878 """Returns the player's name. If icon is True, the long version of the 879 name may include an icon. 880 """ 881 return str() 882 883 def remove_from_game(self) -> None: 884 885 """Removes the player from the game.""" 886 return None 887 888 def resetinput(self) -> None: 889 890 """Clears out the player's assigned input actions.""" 891 return None 892 893 def set_icon_info( 894 self, 895 texture: str, 896 tint_texture: str, 897 tint_color: Sequence[float], 898 tint2_color: Sequence[float], 899 ) -> None: 900 901 """(internal)""" 902 return None 903 904 def setactivity(self, activity: ba.Activity | None) -> None: 905 906 """(internal)""" 907 return None 908 909 def setdata( 910 self, 911 team: ba.SessionTeam, 912 character: str, 913 color: Sequence[float], 914 highlight: Sequence[float], 915 ) -> None: 916 917 """(internal)""" 918 return None 919 920 def setname( 921 self, name: str, full_name: str | None = None, real: bool = True 922 ) -> None: 923 924 """Set the player's name to the provided string. 925 A number will automatically be appended if the name is not unique from 926 other players. 927 """ 928 return None 929 930 def setnode(self, node: Node | None) -> None: 931 932 """(internal)""" 933 return None
A reference to a player in the ba.Session.
Category: Gameplay Classes
These are created and managed internally and
provided to your ba.Session/ba.Activity instances.
Be aware that, like ba.Node
s, ba.SessionPlayer objects are 'weak'
references under-the-hood; a player can leave the game at
any point. For this reason, you should make judicious use of the
ba.SessionPlayer.exists() method (or boolean operator) to ensure
that a SessionPlayer is still present if retaining references to one
for any length of time.
The unique numeric ID of the 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.
This bool value will be True once the Player has completed any lobby character/team selection.
The ba.SessionTeam this Player is on. If the SessionPlayer is still in its lobby selecting a team/etc. then a ba.SessionTeamNotFoundError will be raised.
The base color for this Player. In team games this will match the ba.SessionTeam's color.
A secondary color for this player. This is used for minor highlights and accents to allow a player to stand apart from his teammates who may all share the same team (primary) color.
841 def assigninput( 842 self, type: ba.InputType | tuple[ba.InputType, ...], call: Callable 843 ) -> None: 844 845 """Set the python callable to be run for one or more types of input.""" 846 return None
Set the python callable to be run for one or more types of input.
848 def exists(self) -> bool: 849 850 """Return whether the underlying player is still in the game.""" 851 return bool()
Return whether the underlying player is still in the game.
853 def get_icon(self) -> dict[str, Any]: 854 855 """Returns the character's icon (images, colors, etc contained 856 in a dict. 857 """ 858 return {'foo': 'bar'}
Returns the character's icon (images, colors, etc contained in a dict.
865 def get_v1_account_id(self) -> str: 866 867 """Return the V1 Account ID this player is signed in under, if 868 there is one and it can be determined with relative certainty. 869 Returns None otherwise. Note that this may require an active 870 internet connection (especially for network-connected players) 871 and may return None for a short while after a player initially 872 joins (while verification occurs). 873 """ 874 return str()
Return the V1 Account ID this player is signed in under, if there is one and it can be determined with relative certainty. Returns None otherwise. Note that this may require an active internet connection (especially for network-connected players) and may return None for a short while after a player initially joins (while verification occurs).
876 def getname(self, full: bool = False, icon: bool = True) -> str: 877 878 """Returns the player's name. If icon is True, the long version of the 879 name may include an icon. 880 """ 881 return str()
Returns the player's name. If icon is True, the long version of the name may include an icon.
883 def remove_from_game(self) -> None: 884 885 """Removes the player from the game.""" 886 return None
Removes the player from the game.
888 def resetinput(self) -> None: 889 890 """Clears out the player's assigned input actions.""" 891 return None
Clears out the player's assigned input actions.
920 def setname( 921 self, name: str, full_name: str | None = None, real: bool = True 922 ) -> None: 923 924 """Set the player's name to the provided string. 925 A number will automatically be appended if the name is not unique from 926 other players. 927 """ 928 return None
Set the player's name to the provided string. A number will automatically be appended if the name is not unique from other players.
59class SessionPlayerNotFoundError(NotFoundError): 60 """Exception raised when an expected ba.SessionPlayer does not exist. 61 62 Category: **Exception Classes** 63 """
Exception raised when an expected ba.SessionPlayer does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
18class SessionTeam: 19 """A team of one or more ba.SessionPlayers. 20 21 Category: **Gameplay Classes** 22 23 Note that a SessionPlayer *always* has a SessionTeam; 24 in some cases, such as free-for-all ba.Sessions, 25 each SessionTeam consists of just one SessionPlayer. 26 """ 27 28 # Annotate our attr types at the class level so they're introspectable. 29 30 name: ba.Lstr | str 31 """The team's name.""" 32 33 color: tuple[float, ...] # FIXME: can't we make this fixed len? 34 """The team's color.""" 35 36 players: list[ba.SessionPlayer] 37 """The list of ba.SessionPlayer-s on the team.""" 38 39 customdata: dict 40 """A dict for use by the current ba.Session for 41 storing data associated with this team. 42 Unlike customdata, this persists for the duration 43 of the session.""" 44 45 id: int 46 """The unique numeric id of the team.""" 47 48 def __init__( 49 self, 50 team_id: int = 0, 51 name: ba.Lstr | str = '', 52 color: Sequence[float] = (1.0, 1.0, 1.0), 53 ): 54 """Instantiate a ba.SessionTeam. 55 56 In most cases, all teams are provided to you by the ba.Session, 57 ba.Session, so calling this shouldn't be necessary. 58 """ 59 60 self.id = team_id 61 self.name = name 62 self.color = tuple(color) 63 self.players = [] 64 self.customdata = {} 65 self.activityteam: Team | None = None 66 67 def leave(self) -> None: 68 """(internal)""" 69 self.customdata = {}
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.
48 def __init__( 49 self, 50 team_id: int = 0, 51 name: ba.Lstr | str = '', 52 color: Sequence[float] = (1.0, 1.0, 1.0), 53 ): 54 """Instantiate a ba.SessionTeam. 55 56 In most cases, all teams are provided to you by the ba.Session, 57 ba.Session, so calling this shouldn't be necessary. 58 """ 59 60 self.id = team_id 61 self.name = name 62 self.color = tuple(color) 63 self.players = [] 64 self.customdata = {} 65 self.activityteam: Team | None = None
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.
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.
87class SessionTeamNotFoundError(NotFoundError): 88 """Exception raised when an expected ba.SessionTeam does not exist. 89 90 Category: **Exception Classes** 91 """
Exception raised when an expected ba.SessionTeam does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
2895def set_analytics_screen(screen: str) -> None: 2896 2897 """Used for analytics to see where in the app players spend their time. 2898 2899 Category: **General Utility Functions** 2900 2901 Generally called when opening a new window or entering some UI. 2902 'screen' should be a string description of an app location 2903 ('Main Menu', etc.) 2904 """ 2905 return None
Used for analytics to see where in the app players spend their time.
Category: General Utility Functions
Generally called when opening a new window or entering some UI. 'screen' should be a string description of an app location ('Main Menu', etc.)
500def setmusic(musictype: ba.MusicType | None, continuous: bool = False) -> None: 501 """Set the app to play (or stop playing) a certain type of music. 502 503 category: **Gameplay Functions** 504 505 This function will handle loading and playing sound assets as necessary, 506 and also supports custom user soundtracks on specific platforms so the 507 user can override particular game music with their own. 508 509 Pass None to stop music. 510 511 if 'continuous' is True and musictype is the same as what is already 512 playing, the playing track will not be restarted. 513 """ 514 515 # All we do here now is set a few music attrs on the current globals 516 # node. The foreground globals' current playing music then gets fed to 517 # the do_play_music call in our music controller. This way we can 518 # seamlessly support custom soundtracks in replays/etc since we're being 519 # driven purely by node data. 520 gnode = _ba.getactivity().globalsnode 521 gnode.music_continuous = continuous 522 gnode.music = '' if musictype is None else musictype.value 523 gnode.music_count += 1
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.
15@dataclass 16class Setting: 17 """Defines a user-controllable setting for a game or other entity. 18 19 Category: Gameplay Classes 20 """ 21 22 name: str 23 default: Any
Defines a user-controllable setting for a game or other entity.
Category: Gameplay Classes
189@dataclass 190class ShouldShatterMessage: 191 """Tells an object that it should shatter. 192 193 Category: **Message Classes** 194 """
Tells an object that it should shatter.
Category: Message Classes
219def show_damage_count( 220 damage: str, position: Sequence[float], direction: Sequence[float] 221) -> None: 222 """Pop up a damage count at a position in space. 223 224 Category: **Gameplay Functions** 225 """ 226 lifespan = 1.0 227 app = _ba.app 228 229 # FIXME: Should never vary game elements based on local config. 230 # (connected clients may have differing configs so they won't 231 # get the intended results). 232 do_big = app.ui.uiscale is UIScale.SMALL or app.vr_mode 233 txtnode = _ba.newnode( 234 'text', 235 attrs={ 236 'text': damage, 237 'in_world': True, 238 'h_align': 'center', 239 'flatness': 1.0, 240 'shadow': 1.0 if do_big else 0.7, 241 'color': (1, 0.25, 0.25, 1), 242 'scale': 0.015 if do_big else 0.01, 243 }, 244 ) 245 # Translate upward. 246 tcombine = _ba.newnode('combine', owner=txtnode, attrs={'size': 3}) 247 tcombine.connectattr('output', txtnode, 'position') 248 v_vals = [] 249 pval = 0.0 250 vval = 0.07 251 count = 6 252 for i in range(count): 253 v_vals.append((float(i) / count, pval)) 254 pval += vval 255 vval *= 0.5 256 p_start = position[0] 257 p_dir = direction[0] 258 animate( 259 tcombine, 260 'input0', 261 {i[0] * lifespan: p_start + p_dir * i[1] for i in v_vals}, 262 ) 263 p_start = position[1] 264 p_dir = direction[1] 265 animate( 266 tcombine, 267 'input1', 268 {i[0] * lifespan: p_start + p_dir * i[1] for i in v_vals}, 269 ) 270 p_start = position[2] 271 p_dir = direction[2] 272 animate( 273 tcombine, 274 'input2', 275 {i[0] * lifespan: p_start + p_dir * i[1] for i in v_vals}, 276 ) 277 animate(txtnode, 'opacity', {0.7 * lifespan: 1.0, lifespan: 0.0}) 278 _ba.timer(lifespan, txtnode.delete)
Pop up a damage count at a position in space.
Category: Gameplay Functions
110class SpecialChar(Enum): 111 """Special characters the game can print. 112 113 Category: Enums 114 """ 115 116 DOWN_ARROW = 0 117 UP_ARROW = 1 118 LEFT_ARROW = 2 119 RIGHT_ARROW = 3 120 TOP_BUTTON = 4 121 LEFT_BUTTON = 5 122 RIGHT_BUTTON = 6 123 BOTTOM_BUTTON = 7 124 DELETE = 8 125 SHIFT = 9 126 BACK = 10 127 LOGO_FLAT = 11 128 REWIND_BUTTON = 12 129 PLAY_PAUSE_BUTTON = 13 130 FAST_FORWARD_BUTTON = 14 131 DPAD_CENTER_BUTTON = 15 132 OUYA_BUTTON_O = 16 133 OUYA_BUTTON_U = 17 134 OUYA_BUTTON_Y = 18 135 OUYA_BUTTON_A = 19 136 OUYA_LOGO = 20 137 LOGO = 21 138 TICKET = 22 139 GOOGLE_PLAY_GAMES_LOGO = 23 140 GAME_CENTER_LOGO = 24 141 DICE_BUTTON1 = 25 142 DICE_BUTTON2 = 26 143 DICE_BUTTON3 = 27 144 DICE_BUTTON4 = 28 145 GAME_CIRCLE_LOGO = 29 146 PARTY_ICON = 30 147 TEST_ACCOUNT = 31 148 TICKET_BACKING = 32 149 TROPHY1 = 33 150 TROPHY2 = 34 151 TROPHY3 = 35 152 TROPHY0A = 36 153 TROPHY0B = 37 154 TROPHY4 = 38 155 LOCAL_ACCOUNT = 39 156 ALIBABA_LOGO = 40 157 FLAG_UNITED_STATES = 41 158 FLAG_MEXICO = 42 159 FLAG_GERMANY = 43 160 FLAG_BRAZIL = 44 161 FLAG_RUSSIA = 45 162 FLAG_CHINA = 46 163 FLAG_UNITED_KINGDOM = 47 164 FLAG_CANADA = 48 165 FLAG_INDIA = 49 166 FLAG_JAPAN = 50 167 FLAG_FRANCE = 51 168 FLAG_INDONESIA = 52 169 FLAG_ITALY = 53 170 FLAG_SOUTH_KOREA = 54 171 FLAG_NETHERLANDS = 55 172 FEDORA = 56 173 HAL = 57 174 CROWN = 58 175 YIN_YANG = 59 176 EYE_BALL = 60 177 SKULL = 61 178 HEART = 62 179 DRAGON = 63 180 HELMET = 64 181 MUSHROOM = 65 182 NINJA_STAR = 66 183 VIKING_HELMET = 67 184 MOON = 68 185 SPIDER = 69 186 FIREBALL = 70 187 FLAG_UNITED_ARAB_EMIRATES = 71 188 FLAG_QATAR = 72 189 FLAG_EGYPT = 73 190 FLAG_KUWAIT = 74 191 FLAG_ALGERIA = 75 192 FLAG_SAUDI_ARABIA = 76 193 FLAG_MALAYSIA = 77 194 FLAG_CZECH_REPUBLIC = 78 195 FLAG_AUSTRALIA = 79 196 FLAG_SINGAPORE = 80 197 OCULUS_LOGO = 81 198 STEAM_LOGO = 82 199 NVIDIA_LOGO = 83 200 FLAG_IRAN = 84 201 FLAG_POLAND = 85 202 FLAG_ARGENTINA = 86 203 FLAG_PHILIPPINES = 87 204 FLAG_CHILE = 88 205 MIKIROG = 89 206 V2_LOGO = 90
Special characters the game can print.
Category: Enums
Inherited Members
- enum.Enum
- name
- value
40@dataclass 41class StandLocation: 42 """Describes a point in space and an angle to face. 43 44 Category: Gameplay Classes 45 """ 46 47 position: ba.Vec3 48 angle: float | None = None
Describes a point in space and an angle to face.
Category: Gameplay Classes
132@dataclass 133class StandMessage: 134 """A message telling an object to move to a position in space. 135 136 Category: **Message Classes** 137 138 Used when teleporting players to home base, etc. 139 """ 140 141 position: Sequence[float] = (0.0, 0.0, 0.0) 142 """Where to move to.""" 143 144 angle: float = 0.0 145 """The angle to face (in degrees)"""
A message telling an object to move to a position in space.
Category: Message Classes
Used when teleporting players to home base, etc.
256class Stats: 257 """Manages scores and statistics for a ba.Session. 258 259 Category: **Gameplay Classes** 260 """ 261 262 def __init__(self) -> None: 263 self._activity: weakref.ref[ba.Activity] | None = None 264 self._player_records: dict[str, PlayerRecord] = {} 265 self.orchestrahitsound1: ba.Sound | None = None 266 self.orchestrahitsound2: ba.Sound | None = None 267 self.orchestrahitsound3: ba.Sound | None = None 268 self.orchestrahitsound4: ba.Sound | None = None 269 270 def setactivity(self, activity: ba.Activity | None) -> None: 271 """Set the current activity for this instance.""" 272 273 self._activity = None if activity is None else weakref.ref(activity) 274 275 # Load our media into this activity's context. 276 if activity is not None: 277 if activity.expired: 278 print_error('unexpected finalized activity') 279 else: 280 with _ba.Context(activity): 281 self._load_activity_media() 282 283 def getactivity(self) -> ba.Activity | None: 284 """Get the activity associated with this instance. 285 286 May return None. 287 """ 288 if self._activity is None: 289 return None 290 return self._activity() 291 292 def _load_activity_media(self) -> None: 293 self.orchestrahitsound1 = _ba.getsound('orchestraHit') 294 self.orchestrahitsound2 = _ba.getsound('orchestraHit2') 295 self.orchestrahitsound3 = _ba.getsound('orchestraHit3') 296 self.orchestrahitsound4 = _ba.getsound('orchestraHit4') 297 298 def reset(self) -> None: 299 """Reset the stats instance completely.""" 300 301 # Just to be safe, lets make sure no multi-kill timers are gonna go off 302 # for no-longer-on-the-list players. 303 for p_entry in list(self._player_records.values()): 304 p_entry.cancel_multi_kill_timer() 305 self._player_records = {} 306 307 def reset_accum(self) -> None: 308 """Reset per-sound sub-scores.""" 309 for s_player in list(self._player_records.values()): 310 s_player.cancel_multi_kill_timer() 311 s_player.accumscore = 0 312 s_player.accum_kill_count = 0 313 s_player.accum_killed_count = 0 314 s_player.streak = 0 315 316 def register_sessionplayer(self, player: ba.SessionPlayer) -> None: 317 """Register a ba.SessionPlayer with this score-set.""" 318 assert player.exists() # Invalid refs should never be passed to funcs. 319 name = player.getname() 320 if name in self._player_records: 321 # If the player already exists, update his character and such as 322 # it may have changed. 323 self._player_records[name].associate_with_sessionplayer(player) 324 else: 325 name_full = player.getname(full=True) 326 self._player_records[name] = PlayerRecord( 327 name, name_full, player, self 328 ) 329 330 def get_records(self) -> dict[str, ba.PlayerRecord]: 331 """Get PlayerRecord corresponding to still-existing players.""" 332 records = {} 333 334 # Go through our player records and return ones whose player id still 335 # corresponds to a player with that name. 336 for record_id, record in self._player_records.items(): 337 lastplayer = record.get_last_sessionplayer() 338 if lastplayer and lastplayer.getname() == record_id: 339 records[record_id] = record 340 return records 341 342 def player_scored( 343 self, 344 player: ba.Player, 345 base_points: int = 1, 346 target: Sequence[float] | None = None, 347 kill: bool = False, 348 victim_player: ba.Player | None = None, 349 scale: float = 1.0, 350 color: Sequence[float] | None = None, 351 title: str | ba.Lstr | None = None, 352 screenmessage: bool = True, 353 display: bool = True, 354 importance: int = 1, 355 showpoints: bool = True, 356 big_message: bool = False, 357 ) -> int: 358 """Register a score for the player. 359 360 Return value is actual score with multipliers and such factored in. 361 """ 362 # FIXME: Tidy this up. 363 # pylint: disable=cyclic-import 364 # pylint: disable=too-many-branches 365 # pylint: disable=too-many-locals 366 # pylint: disable=too-many-statements 367 from bastd.actor.popuptext import PopupText 368 from ba import _math 369 from ba._gameactivity import GameActivity 370 from ba._language import Lstr 371 372 del victim_player # Currently unused. 373 name = player.getname() 374 s_player = self._player_records[name] 375 376 if kill: 377 s_player.submit_kill(showpoints=showpoints) 378 379 display_color: Sequence[float] = (1.0, 1.0, 1.0, 1.0) 380 381 if color is not None: 382 display_color = color 383 elif importance != 1: 384 display_color = (1.0, 1.0, 0.4, 1.0) 385 points = base_points 386 387 # If they want a big announcement, throw a zoom-text up there. 388 if display and big_message: 389 try: 390 assert self._activity is not None 391 activity = self._activity() 392 if isinstance(activity, GameActivity): 393 name_full = player.getname(full=True, icon=False) 394 activity.show_zoom_message( 395 Lstr( 396 resource='nameScoresText', 397 subs=[('${NAME}', name_full)], 398 ), 399 color=_math.normalized_color(player.team.color), 400 ) 401 except Exception: 402 print_exception('error showing big_message') 403 404 # If we currently have a actor, pop up a score over it. 405 if display and showpoints: 406 our_pos = player.node.position if player.node else None 407 if our_pos is not None: 408 if target is None: 409 target = our_pos 410 411 # If display-pos is *way* lower than us, raise it up 412 # (so we can still see scores from dudes that fell off cliffs). 413 display_pos = ( 414 target[0], 415 max(target[1], our_pos[1] - 2.0), 416 min(target[2], our_pos[2] + 2.0), 417 ) 418 activity = self.getactivity() 419 if activity is not None: 420 if title is not None: 421 sval = Lstr( 422 value='+${A} ${B}', 423 subs=[('${A}', str(points)), ('${B}', title)], 424 ) 425 else: 426 sval = Lstr(value='+${A}', subs=[('${A}', str(points))]) 427 PopupText( 428 sval, 429 color=display_color, 430 scale=1.2 * scale, 431 position=display_pos, 432 ).autoretain() 433 434 # Tally kills. 435 if kill: 436 s_player.accum_kill_count += 1 437 s_player.kill_count += 1 438 439 # Report non-kill scorings. 440 try: 441 if screenmessage and not kill: 442 _ba.screenmessage( 443 Lstr(resource='nameScoresText', subs=[('${NAME}', name)]), 444 top=True, 445 color=player.color, 446 image=player.get_icon(), 447 ) 448 except Exception: 449 print_exception('error announcing score') 450 451 s_player.score += points 452 s_player.accumscore += points 453 454 # Inform a running game of the score. 455 if points != 0: 456 activity = self._activity() if self._activity is not None else None 457 if activity is not None: 458 activity.handlemessage(PlayerScoredMessage(score=points)) 459 460 return points 461 462 def player_was_killed( 463 self, 464 player: ba.Player, 465 killed: bool = False, 466 killer: ba.Player | None = None, 467 ) -> None: 468 """Should be called when a player is killed.""" 469 from ba._language import Lstr 470 471 name = player.getname() 472 prec = self._player_records[name] 473 prec.streak = 0 474 if killed: 475 prec.accum_killed_count += 1 476 prec.killed_count += 1 477 try: 478 if killed and _ba.getactivity().announce_player_deaths: 479 if killer is player: 480 _ba.screenmessage( 481 Lstr( 482 resource='nameSuicideText', subs=[('${NAME}', name)] 483 ), 484 top=True, 485 color=player.color, 486 image=player.get_icon(), 487 ) 488 elif killer is not None: 489 if killer.team is player.team: 490 _ba.screenmessage( 491 Lstr( 492 resource='nameBetrayedText', 493 subs=[ 494 ('${NAME}', killer.getname()), 495 ('${VICTIM}', name), 496 ], 497 ), 498 top=True, 499 color=killer.color, 500 image=killer.get_icon(), 501 ) 502 else: 503 _ba.screenmessage( 504 Lstr( 505 resource='nameKilledText', 506 subs=[ 507 ('${NAME}', killer.getname()), 508 ('${VICTIM}', name), 509 ], 510 ), 511 top=True, 512 color=killer.color, 513 image=killer.get_icon(), 514 ) 515 else: 516 _ba.screenmessage( 517 Lstr(resource='nameDiedText', subs=[('${NAME}', name)]), 518 top=True, 519 color=player.color, 520 image=player.get_icon(), 521 ) 522 except Exception: 523 print_exception('error announcing kill')
Manages scores and statistics for a ba.Session.
Category: Gameplay Classes
262 def __init__(self) -> None: 263 self._activity: weakref.ref[ba.Activity] | None = None 264 self._player_records: dict[str, PlayerRecord] = {} 265 self.orchestrahitsound1: ba.Sound | None = None 266 self.orchestrahitsound2: ba.Sound | None = None 267 self.orchestrahitsound3: ba.Sound | None = None 268 self.orchestrahitsound4: ba.Sound | None = None
270 def setactivity(self, activity: ba.Activity | None) -> None: 271 """Set the current activity for this instance.""" 272 273 self._activity = None if activity is None else weakref.ref(activity) 274 275 # Load our media into this activity's context. 276 if activity is not None: 277 if activity.expired: 278 print_error('unexpected finalized activity') 279 else: 280 with _ba.Context(activity): 281 self._load_activity_media()
Set the current activity for this instance.
283 def getactivity(self) -> ba.Activity | None: 284 """Get the activity associated with this instance. 285 286 May return None. 287 """ 288 if self._activity is None: 289 return None 290 return self._activity()
Get the activity associated with this instance.
May return None.
298 def reset(self) -> None: 299 """Reset the stats instance completely.""" 300 301 # Just to be safe, lets make sure no multi-kill timers are gonna go off 302 # for no-longer-on-the-list players. 303 for p_entry in list(self._player_records.values()): 304 p_entry.cancel_multi_kill_timer() 305 self._player_records = {}
Reset the stats instance completely.
307 def reset_accum(self) -> None: 308 """Reset per-sound sub-scores.""" 309 for s_player in list(self._player_records.values()): 310 s_player.cancel_multi_kill_timer() 311 s_player.accumscore = 0 312 s_player.accum_kill_count = 0 313 s_player.accum_killed_count = 0 314 s_player.streak = 0
Reset per-sound sub-scores.
316 def register_sessionplayer(self, player: ba.SessionPlayer) -> None: 317 """Register a ba.SessionPlayer with this score-set.""" 318 assert player.exists() # Invalid refs should never be passed to funcs. 319 name = player.getname() 320 if name in self._player_records: 321 # If the player already exists, update his character and such as 322 # it may have changed. 323 self._player_records[name].associate_with_sessionplayer(player) 324 else: 325 name_full = player.getname(full=True) 326 self._player_records[name] = PlayerRecord( 327 name, name_full, player, self 328 )
Register a ba.SessionPlayer with this score-set.
330 def get_records(self) -> dict[str, ba.PlayerRecord]: 331 """Get PlayerRecord corresponding to still-existing players.""" 332 records = {} 333 334 # Go through our player records and return ones whose player id still 335 # corresponds to a player with that name. 336 for record_id, record in self._player_records.items(): 337 lastplayer = record.get_last_sessionplayer() 338 if lastplayer and lastplayer.getname() == record_id: 339 records[record_id] = record 340 return records
Get PlayerRecord corresponding to still-existing players.
342 def player_scored( 343 self, 344 player: ba.Player, 345 base_points: int = 1, 346 target: Sequence[float] | None = None, 347 kill: bool = False, 348 victim_player: ba.Player | None = None, 349 scale: float = 1.0, 350 color: Sequence[float] | None = None, 351 title: str | ba.Lstr | None = None, 352 screenmessage: bool = True, 353 display: bool = True, 354 importance: int = 1, 355 showpoints: bool = True, 356 big_message: bool = False, 357 ) -> int: 358 """Register a score for the player. 359 360 Return value is actual score with multipliers and such factored in. 361 """ 362 # FIXME: Tidy this up. 363 # pylint: disable=cyclic-import 364 # pylint: disable=too-many-branches 365 # pylint: disable=too-many-locals 366 # pylint: disable=too-many-statements 367 from bastd.actor.popuptext import PopupText 368 from ba import _math 369 from ba._gameactivity import GameActivity 370 from ba._language import Lstr 371 372 del victim_player # Currently unused. 373 name = player.getname() 374 s_player = self._player_records[name] 375 376 if kill: 377 s_player.submit_kill(showpoints=showpoints) 378 379 display_color: Sequence[float] = (1.0, 1.0, 1.0, 1.0) 380 381 if color is not None: 382 display_color = color 383 elif importance != 1: 384 display_color = (1.0, 1.0, 0.4, 1.0) 385 points = base_points 386 387 # If they want a big announcement, throw a zoom-text up there. 388 if display and big_message: 389 try: 390 assert self._activity is not None 391 activity = self._activity() 392 if isinstance(activity, GameActivity): 393 name_full = player.getname(full=True, icon=False) 394 activity.show_zoom_message( 395 Lstr( 396 resource='nameScoresText', 397 subs=[('${NAME}', name_full)], 398 ), 399 color=_math.normalized_color(player.team.color), 400 ) 401 except Exception: 402 print_exception('error showing big_message') 403 404 # If we currently have a actor, pop up a score over it. 405 if display and showpoints: 406 our_pos = player.node.position if player.node else None 407 if our_pos is not None: 408 if target is None: 409 target = our_pos 410 411 # If display-pos is *way* lower than us, raise it up 412 # (so we can still see scores from dudes that fell off cliffs). 413 display_pos = ( 414 target[0], 415 max(target[1], our_pos[1] - 2.0), 416 min(target[2], our_pos[2] + 2.0), 417 ) 418 activity = self.getactivity() 419 if activity is not None: 420 if title is not None: 421 sval = Lstr( 422 value='+${A} ${B}', 423 subs=[('${A}', str(points)), ('${B}', title)], 424 ) 425 else: 426 sval = Lstr(value='+${A}', subs=[('${A}', str(points))]) 427 PopupText( 428 sval, 429 color=display_color, 430 scale=1.2 * scale, 431 position=display_pos, 432 ).autoretain() 433 434 # Tally kills. 435 if kill: 436 s_player.accum_kill_count += 1 437 s_player.kill_count += 1 438 439 # Report non-kill scorings. 440 try: 441 if screenmessage and not kill: 442 _ba.screenmessage( 443 Lstr(resource='nameScoresText', subs=[('${NAME}', name)]), 444 top=True, 445 color=player.color, 446 image=player.get_icon(), 447 ) 448 except Exception: 449 print_exception('error announcing score') 450 451 s_player.score += points 452 s_player.accumscore += points 453 454 # Inform a running game of the score. 455 if points != 0: 456 activity = self._activity() if self._activity is not None else None 457 if activity is not None: 458 activity.handlemessage(PlayerScoredMessage(score=points)) 459 460 return points
Register a score for the player.
Return value is actual score with multipliers and such factored in.
462 def player_was_killed( 463 self, 464 player: ba.Player, 465 killed: bool = False, 466 killer: ba.Player | None = None, 467 ) -> None: 468 """Should be called when a player is killed.""" 469 from ba._language import Lstr 470 471 name = player.getname() 472 prec = self._player_records[name] 473 prec.streak = 0 474 if killed: 475 prec.accum_killed_count += 1 476 prec.killed_count += 1 477 try: 478 if killed and _ba.getactivity().announce_player_deaths: 479 if killer is player: 480 _ba.screenmessage( 481 Lstr( 482 resource='nameSuicideText', subs=[('${NAME}', name)] 483 ), 484 top=True, 485 color=player.color, 486 image=player.get_icon(), 487 ) 488 elif killer is not None: 489 if killer.team is player.team: 490 _ba.screenmessage( 491 Lstr( 492 resource='nameBetrayedText', 493 subs=[ 494 ('${NAME}', killer.getname()), 495 ('${VICTIM}', name), 496 ], 497 ), 498 top=True, 499 color=killer.color, 500 image=killer.get_icon(), 501 ) 502 else: 503 _ba.screenmessage( 504 Lstr( 505 resource='nameKilledText', 506 subs=[ 507 ('${NAME}', killer.getname()), 508 ('${VICTIM}', name), 509 ], 510 ), 511 top=True, 512 color=killer.color, 513 image=killer.get_icon(), 514 ) 515 else: 516 _ba.screenmessage( 517 Lstr(resource='nameDiedText', subs=[('${NAME}', name)]), 518 top=True, 519 color=player.color, 520 image=player.get_icon(), 521 ) 522 except Exception: 523 print_exception('error announcing kill')
Should be called when a player is killed.
339def storagename(suffix: str | None = None) -> str: 340 """Generate a unique name for storing class data in shared places. 341 342 Category: **General Utility Functions** 343 344 This consists of a leading underscore, the module path at the 345 call site with dots replaced by underscores, the containing class's 346 qualified name, and the provided suffix. When storing data in public 347 places such as 'customdata' dicts, this minimizes the chance of 348 collisions with other similarly named classes. 349 350 Note that this will function even if called in the class definition. 351 352 ##### Examples 353 Generate a unique name for storage purposes: 354 >>> class MyThingie: 355 ... # This will give something like 356 ... # '_mymodule_submodule_mythingie_data'. 357 ... _STORENAME = ba.storagename('data') 358 ... 359 ... # Use that name to store some data in the Activity we were 360 ... # passed. 361 ... def __init__(self, activity): 362 ... activity.customdata[self._STORENAME] = {} 363 """ 364 frame = inspect.currentframe() 365 if frame is None: 366 raise RuntimeError('Cannot get current stack frame.') 367 fback = frame.f_back 368 369 # Note: We need to explicitly clear frame here to avoid a ref-loop 370 # that keeps all function-dicts in the stack alive until the next 371 # full GC cycle (the stack frame refers to this function's dict, 372 # which refers to the stack frame). 373 del frame 374 375 if fback is None: 376 raise RuntimeError('Cannot get parent stack frame.') 377 modulepath = fback.f_globals.get('__name__') 378 if modulepath is None: 379 raise RuntimeError('Cannot get parent stack module path.') 380 assert isinstance(modulepath, str) 381 qualname = fback.f_locals.get('__qualname__') 382 if qualname is not None: 383 assert isinstance(qualname, str) 384 fullpath = f'_{modulepath}_{qualname.lower()}' 385 else: 386 fullpath = f'_{modulepath}' 387 if suffix is not None: 388 fullpath = f'{fullpath}_{suffix}' 389 return fullpath.replace('.', '_')
Generate a unique name for storing class data in shared places.
Category: General Utility Functions
This consists of a leading underscore, the module path at the call site with dots replaced by underscores, the containing class's qualified name, and the provided suffix. When storing data in public places such as 'customdata' dicts, this minimizes the chance of collisions with other similarly named classes.
Note that this will function even if called in the class definition.
Examples
Generate a unique name for storage purposes:
>>> class MyThingie:
... # This will give something like
... # '_mymodule_submodule_mythingie_data'.
... _STORENAME = ba.storagename('data')
...
... # Use that name to store some data in the Activity we were
... # passed.
... def __init__(self, activity):
... activity.customdata[self._STORENAME] = {}
77class Team(Generic[PlayerType]): 78 """A team in a specific ba.Activity. 79 80 Category: **Gameplay Classes** 81 82 These correspond to ba.SessionTeam objects, but are created per activity 83 so that the activity can use its own custom team subclass. 84 """ 85 86 # Defining these types at the class level instead of in __init__ so 87 # that types are introspectable (these are still instance attrs). 88 players: list[PlayerType] 89 id: int 90 name: ba.Lstr | str 91 color: tuple[float, ...] # FIXME: can't we make this fixed length? 92 _sessionteam: weakref.ref[SessionTeam] 93 _expired: bool 94 _postinited: bool 95 _customdata: dict 96 97 # NOTE: avoiding having any __init__() here since it seems to not 98 # get called by default if a dataclass inherits from us. 99 100 def postinit(self, sessionteam: SessionTeam) -> None: 101 """Wire up a newly created SessionTeam. 102 103 (internal) 104 """ 105 106 # Sanity check; if a dataclass is created that inherits from us, 107 # it will define an equality operator by default which will break 108 # internal game logic. So complain loudly if we find one. 109 if type(self).__eq__ is not object.__eq__: 110 raise RuntimeError( 111 f'Team class {type(self)} defines an equality' 112 f' operator (__eq__) which will break internal' 113 f' logic. Please remove it.\n' 114 f'For dataclasses you can do "dataclass(eq=False)"' 115 f' in the class decorator.' 116 ) 117 118 self.players = [] 119 self._sessionteam = weakref.ref(sessionteam) 120 self.id = sessionteam.id 121 self.name = sessionteam.name 122 self.color = sessionteam.color 123 self._customdata = {} 124 self._expired = False 125 self._postinited = True 126 127 def manual_init( 128 self, team_id: int, name: ba.Lstr | str, color: tuple[float, ...] 129 ) -> None: 130 """Manually init a team for uses such as bots.""" 131 self.id = team_id 132 self.name = name 133 self.color = color 134 self._customdata = {} 135 self._expired = False 136 self._postinited = True 137 138 @property 139 def customdata(self) -> dict: 140 """Arbitrary values associated with the team. 141 Though it is encouraged that most player values be properly defined 142 on the ba.Team subclass, it may be useful for player-agnostic 143 objects to store values here. This dict is cleared when the team 144 leaves or expires so objects stored here will be disposed of at 145 the expected time, unlike the Team instance itself which may 146 continue to be referenced after it is no longer part of the game. 147 """ 148 assert self._postinited 149 assert not self._expired 150 return self._customdata 151 152 def leave(self) -> None: 153 """Called when the Team leaves a running game. 154 155 (internal) 156 """ 157 assert self._postinited 158 assert not self._expired 159 del self._customdata 160 del self.players 161 162 def expire(self) -> None: 163 """Called when the Team is expiring (due to the Activity expiring). 164 165 (internal) 166 """ 167 assert self._postinited 168 assert not self._expired 169 self._expired = True 170 171 try: 172 self.on_expire() 173 except Exception: 174 print_exception(f'Error in on_expire for {self}.') 175 176 del self._customdata 177 del self.players 178 179 def on_expire(self) -> None: 180 """Can be overridden to handle team expiration.""" 181 182 @property 183 def sessionteam(self) -> SessionTeam: 184 """Return the ba.SessionTeam corresponding to this Team. 185 186 Throws a ba.SessionTeamNotFoundError if there is none. 187 """ 188 assert self._postinited 189 if self._sessionteam is not None: 190 sessionteam = self._sessionteam() 191 if sessionteam is not None: 192 return sessionteam 193 from ba import _error 194 195 raise _error.SessionTeamNotFoundError()
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.
127 def manual_init( 128 self, team_id: int, name: ba.Lstr | str, color: tuple[float, ...] 129 ) -> None: 130 """Manually init a team for uses such as bots.""" 131 self.id = team_id 132 self.name = name 133 self.color = color 134 self._customdata = {} 135 self._expired = False 136 self._postinited = True
Manually init a team for uses such as bots.
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.
Return the ba.SessionTeam corresponding to this Team.
Throws a ba.SessionTeamNotFoundError if there is none.
27class TeamGameActivity(GameActivity[PlayerType, TeamType]): 28 """Base class for teams and free-for-all mode games. 29 30 Category: **Gameplay Classes** 31 32 (Free-for-all is essentially just a special case where every 33 ba.Player has their own ba.Team) 34 """ 35 36 @classmethod 37 def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool: 38 """ 39 Class method override; 40 returns True for ba.DualTeamSessions and ba.FreeForAllSessions; 41 False otherwise. 42 """ 43 return issubclass(sessiontype, DualTeamSession) or issubclass( 44 sessiontype, FreeForAllSession 45 ) 46 47 def __init__(self, settings: dict): 48 super().__init__(settings) 49 50 # By default we don't show kill-points in free-for-all sessions. 51 # (there's usually some activity-specific score and we don't 52 # wanna confuse things) 53 if isinstance(self.session, FreeForAllSession): 54 self.show_kill_points = False 55 56 def on_transition_in(self) -> None: 57 # pylint: disable=cyclic-import 58 from ba._coopsession import CoopSession 59 from bastd.actor.controlsguide import ControlsGuide 60 61 super().on_transition_in() 62 63 # On the first game, show the controls UI momentarily. 64 # (unless we're being run in co-op mode, in which case we leave 65 # it up to them) 66 if not isinstance(self.session, CoopSession): 67 attrname = '_have_shown_ctrl_help_overlay' 68 if not getattr(self.session, attrname, False): 69 delay = 4.0 70 lifespan = 10.0 71 if self.slow_motion: 72 lifespan *= 0.3 73 ControlsGuide( 74 delay=delay, 75 lifespan=lifespan, 76 scale=0.8, 77 position=(380, 200), 78 bright=True, 79 ).autoretain() 80 setattr(self.session, attrname, True) 81 82 def on_begin(self) -> None: 83 super().on_begin() 84 try: 85 # Award a few achievements. 86 if isinstance(self.session, FreeForAllSession): 87 if len(self.players) >= 2: 88 _ba.app.ach.award_local_achievement('Free Loader') 89 elif isinstance(self.session, DualTeamSession): 90 if len(self.players) >= 4: 91 from ba import _achievement 92 93 _ba.app.ach.award_local_achievement('Team Player') 94 except Exception: 95 from ba import _error 96 97 _error.print_exception() 98 99 def spawn_player_spaz( 100 self, 101 player: PlayerType, 102 position: Sequence[float] | None = None, 103 angle: float | None = None, 104 ) -> PlayerSpaz: 105 """ 106 Method override; spawns and wires up a standard ba.PlayerSpaz for 107 a ba.Player. 108 109 If position or angle is not supplied, a default will be chosen based 110 on the ba.Player and their ba.Team. 111 """ 112 if position is None: 113 # In teams-mode get our team-start-location. 114 if isinstance(self.session, DualTeamSession): 115 position = self.map.get_start_position(player.team.id) 116 else: 117 # Otherwise do free-for-all spawn locations. 118 position = self.map.get_ffa_start_position(self.players) 119 120 return super().spawn_player_spaz(player, position, angle) 121 122 # FIXME: need to unify these arguments with GameActivity.end() 123 def end( # type: ignore 124 self, 125 results: Any = None, 126 announce_winning_team: bool = True, 127 announce_delay: float = 0.1, 128 force: bool = False, 129 ) -> None: 130 """ 131 End the game and announce the single winning team 132 unless 'announce_winning_team' is False. 133 (for results without a single most-important winner). 134 """ 135 # pylint: disable=arguments-renamed 136 from ba._coopsession import CoopSession 137 from ba._multiteamsession import MultiTeamSession 138 from ba._general import Call 139 140 # Announce win (but only for the first finish() call) 141 # (also don't announce in co-op sessions; we leave that up to them). 142 session = self.session 143 if not isinstance(session, CoopSession): 144 do_announce = not self.has_ended() 145 super().end(results, delay=2.0 + announce_delay, force=force) 146 147 # Need to do this *after* end end call so that results is valid. 148 assert isinstance(results, GameResults) 149 if do_announce and isinstance(session, MultiTeamSession): 150 session.announce_game_results( 151 self, 152 results, 153 delay=announce_delay, 154 announce_winning_team=announce_winning_team, 155 ) 156 157 # For co-op we just pass this up the chain with a delay added 158 # (in most cases). Team games expect a delay for the announce 159 # portion in teams/ffa mode so this keeps it consistent. 160 else: 161 # don't want delay on restarts.. 162 if ( 163 isinstance(results, dict) 164 and 'outcome' in results 165 and results['outcome'] == 'restart' 166 ): 167 delay = 0.0 168 else: 169 delay = 2.0 170 _ba.timer(0.1, Call(_ba.playsound, _ba.getsound('boxingBell'))) 171 super().end(results, delay=delay, force=force)
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)
47 def __init__(self, settings: dict): 48 super().__init__(settings) 49 50 # By default we don't show kill-points in free-for-all sessions. 51 # (there's usually some activity-specific score and we don't 52 # wanna confuse things) 53 if isinstance(self.session, FreeForAllSession): 54 self.show_kill_points = False
Instantiate the Activity.
36 @classmethod 37 def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool: 38 """ 39 Class method override; 40 returns True for ba.DualTeamSessions and ba.FreeForAllSessions; 41 False otherwise. 42 """ 43 return issubclass(sessiontype, DualTeamSession) or issubclass( 44 sessiontype, FreeForAllSession 45 )
Class method override; returns True for ba.DualTeamSessions and ba.FreeForAllSessions; False otherwise.
56 def on_transition_in(self) -> None: 57 # pylint: disable=cyclic-import 58 from ba._coopsession import CoopSession 59 from bastd.actor.controlsguide import ControlsGuide 60 61 super().on_transition_in() 62 63 # On the first game, show the controls UI momentarily. 64 # (unless we're being run in co-op mode, in which case we leave 65 # it up to them) 66 if not isinstance(self.session, CoopSession): 67 attrname = '_have_shown_ctrl_help_overlay' 68 if not getattr(self.session, attrname, False): 69 delay = 4.0 70 lifespan = 10.0 71 if self.slow_motion: 72 lifespan *= 0.3 73 ControlsGuide( 74 delay=delay, 75 lifespan=lifespan, 76 scale=0.8, 77 position=(380, 200), 78 bright=True, 79 ).autoretain() 80 setattr(self.session, attrname, True)
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.
82 def on_begin(self) -> None: 83 super().on_begin() 84 try: 85 # Award a few achievements. 86 if isinstance(self.session, FreeForAllSession): 87 if len(self.players) >= 2: 88 _ba.app.ach.award_local_achievement('Free Loader') 89 elif isinstance(self.session, DualTeamSession): 90 if len(self.players) >= 4: 91 from ba import _achievement 92 93 _ba.app.ach.award_local_achievement('Team Player') 94 except Exception: 95 from ba import _error 96 97 _error.print_exception()
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.
99 def spawn_player_spaz( 100 self, 101 player: PlayerType, 102 position: Sequence[float] | None = None, 103 angle: float | None = None, 104 ) -> PlayerSpaz: 105 """ 106 Method override; spawns and wires up a standard ba.PlayerSpaz for 107 a ba.Player. 108 109 If position or angle is not supplied, a default will be chosen based 110 on the ba.Player and their ba.Team. 111 """ 112 if position is None: 113 # In teams-mode get our team-start-location. 114 if isinstance(self.session, DualTeamSession): 115 position = self.map.get_start_position(player.team.id) 116 else: 117 # Otherwise do free-for-all spawn locations. 118 position = self.map.get_ffa_start_position(self.players) 119 120 return super().spawn_player_spaz(player, position, angle)
123 def end( # type: ignore 124 self, 125 results: Any = None, 126 announce_winning_team: bool = True, 127 announce_delay: float = 0.1, 128 force: bool = False, 129 ) -> None: 130 """ 131 End the game and announce the single winning team 132 unless 'announce_winning_team' is False. 133 (for results without a single most-important winner). 134 """ 135 # pylint: disable=arguments-renamed 136 from ba._coopsession import CoopSession 137 from ba._multiteamsession import MultiTeamSession 138 from ba._general import Call 139 140 # Announce win (but only for the first finish() call) 141 # (also don't announce in co-op sessions; we leave that up to them). 142 session = self.session 143 if not isinstance(session, CoopSession): 144 do_announce = not self.has_ended() 145 super().end(results, delay=2.0 + announce_delay, force=force) 146 147 # Need to do this *after* end end call so that results is valid. 148 assert isinstance(results, GameResults) 149 if do_announce and isinstance(session, MultiTeamSession): 150 session.announce_game_results( 151 self, 152 results, 153 delay=announce_delay, 154 announce_winning_team=announce_winning_team, 155 ) 156 157 # For co-op we just pass this up the chain with a delay added 158 # (in most cases). Team games expect a delay for the announce 159 # portion in teams/ffa mode so this keeps it consistent. 160 else: 161 # don't want delay on restarts.. 162 if ( 163 isinstance(results, dict) 164 and 'outcome' in results 165 and results['outcome'] == 'restart' 166 ): 167 delay = 0.0 168 else: 169 delay = 2.0 170 _ba.timer(0.1, Call(_ba.playsound, _ba.getsound('boxingBell'))) 171 super().end(results, delay=delay, force=force)
End the game and announce the single winning team unless 'announce_winning_team' is False. (for results without a single most-important winner).
Inherited Members
- GameActivity
- allow_pausing
- allow_kick_idle_players
- create_settings_ui
- getscoreconfig
- getname
- get_display_string
- get_team_display_string
- get_description
- get_description_display_string
- get_available_settings
- get_supported_maps
- get_settings_display_string
- map
- get_instance_display_string
- get_instance_scoreboard_display_string
- get_instance_description
- get_instance_description_short
- on_continue
- is_waiting_for_continue
- continue_or_end_game
- on_player_join
- handlemessage
- end_game
- respawn_player
- spawn_player_if_exists
- spawn_player
- setup_standard_powerup_drops
- setup_standard_time_limit
- show_zoom_message
- Activity
- settings_raw
- teams
- players
- announce_player_deaths
- is_joining_activity
- use_fixed_vr_overlay
- slow_motion
- inherits_slow_motion
- inherits_music
- inherits_vr_camera_offset
- inherits_vr_overlay_center
- inherits_tint
- allow_mid_activity_joins
- transition_time
- can_show_ad_on_death
- globalsnode
- stats
- on_expire
- customdata
- expired
- playertype
- teamtype
- retain_actor
- add_actor_weak_ref
- session
- on_player_leave
- on_team_join
- on_team_leave
- on_transition_out
- has_transitioned_in
- has_begun
- has_ended
- is_transitioning_out
- transition_out
- create_player
- create_team
66class TeamNotFoundError(NotFoundError): 67 """Exception raised when an expected ba.Team does not exist. 68 69 Category: **Exception Classes** 70 """
Exception raised when an expected ba.Team does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
3146def textwidget( 3147 edit: ba.Widget | None = None, 3148 parent: ba.Widget | None = None, 3149 size: Sequence[float] | None = None, 3150 position: Sequence[float] | None = None, 3151 text: str | ba.Lstr | None = None, 3152 v_align: str | None = None, 3153 h_align: str | None = None, 3154 editable: bool | None = None, 3155 padding: float | None = None, 3156 on_return_press_call: Callable[[], None] | None = None, 3157 on_activate_call: Callable[[], None] | None = None, 3158 selectable: bool | None = None, 3159 query: ba.Widget | None = None, 3160 max_chars: int | None = None, 3161 color: Sequence[float] | None = None, 3162 click_activate: bool | None = None, 3163 on_select_call: Callable[[], None] | None = None, 3164 always_highlight: bool | None = None, 3165 draw_controller: ba.Widget | None = None, 3166 scale: float | None = None, 3167 corner_scale: float | None = None, 3168 description: str | ba.Lstr | None = None, 3169 transition_delay: float | None = None, 3170 maxwidth: float | None = None, 3171 max_height: float | None = None, 3172 flatness: float | None = None, 3173 shadow: float | None = None, 3174 autoselect: bool | None = None, 3175 rotate: float | None = None, 3176 enabled: bool | None = None, 3177 force_internal_editing: bool | None = None, 3178 always_show_carat: bool | None = None, 3179 big: bool | None = None, 3180 extra_touch_border_scale: float | None = None, 3181 res_scale: float | None = None, 3182) -> Widget: 3183 3184 """Create or edit a text widget. 3185 3186 Category: **User Interface Functions** 3187 3188 Pass a valid existing ba.Widget as 'edit' to modify it; otherwise 3189 a new one is created and returned. Arguments that are not set to None 3190 are applied to the Widget. 3191 """ 3192 return Widget()
Create or edit a text widget.
Category: User Interface Functions
Pass a valid existing ba.Widget as 'edit' to modify it; otherwise a new one is created and returned. Arguments that are not set to None are applied to the Widget.
218@dataclass 219class ThawMessage: 220 """Tells an object to stop being frozen. 221 222 Category: **Message Classes** 223 """
Tells an object to stop being frozen.
Category: Message Classes
3223def time( 3224 timetype: ba.TimeType = TimeType.SIM, 3225 timeformat: ba.TimeFormat = TimeFormat.SECONDS, 3226) -> Any: 3227 3228 """Return the current time. 3229 3230 Category: **General Utility Functions** 3231 3232 The time returned depends on the current ba.Context and timetype. 3233 3234 timetype can be either SIM, BASE, or REAL. It defaults to 3235 SIM. Types are explained below: 3236 3237 - SIM time maps to local simulation time in ba.Activity or ba.Session 3238 Contexts. This means that it may progress slower in slow-motion play 3239 modes, stop when the game is paused, etc. This time type is not 3240 available in UI contexts. 3241 - BASE time is also linked to gameplay in ba.Activity or ba.Session 3242 Contexts, but it progresses at a constant rate regardless of 3243 slow-motion states or pausing. It can, however, slow down or stop 3244 in certain cases such as network outages or game slowdowns due to 3245 cpu load. Like 'sim' time, this is unavailable in UI contexts. 3246 - REAL time always maps to actual clock time with a bit of filtering 3247 added, regardless of Context. (The filtering prevents it from going 3248 backwards or jumping forward by large amounts due to the app being 3249 backgrounded, system time changing, etc.) 3250 Real time timers are currently only available in the UI context. 3251 3252 The 'timeformat' arg defaults to SECONDS which returns float seconds, 3253 but it can also be MILLISECONDS to return integer milliseconds. 3254 3255 Note: If you need pure unfiltered clock time, just use the standard 3256 Python functions such as time.time(). 3257 """ 3258 return None
Return the current time.
Category: General Utility Functions
The time returned depends on the current ba.Context and timetype.
timetype can be either SIM, BASE, or REAL. It defaults to SIM. Types are explained below:
- SIM time maps to local simulation time in ba.Activity or ba.Session Contexts. This means that it may progress slower in slow-motion play modes, stop when the game is paused, etc. This time type is not available in UI contexts.
- BASE time is also linked to gameplay in ba.Activity or ba.Session Contexts, but it progresses at a constant rate regardless of slow-motion states or pausing. It can, however, slow down or stop in certain cases such as network outages or game slowdowns due to cpu load. Like 'sim' time, this is unavailable in UI contexts.
- REAL time always maps to actual clock time with a bit of filtering added, regardless of Context. (The filtering prevents it from going backwards or jumping forward by large amounts due to the app being backgrounded, system time changing, etc.) Real time timers are currently only available in the UI context.
The 'timeformat' arg defaults to SECONDS which returns float seconds, but it can also be MILLISECONDS to return integer milliseconds.
Note: If you need pure unfiltered clock time, just use the standard Python functions such as time.time().
91class TimeFormat(Enum): 92 """Specifies the format time values are provided in. 93 94 Category: Enums 95 """ 96 97 SECONDS = 0 98 MILLISECONDS = 1
Specifies the format time values are provided in.
Category: Enums
Inherited Members
- enum.Enum
- name
- value
960class Timer: 961 962 """Timers are used to run code at later points in time. 963 964 Category: **General Utility Classes** 965 966 This class encapsulates a timer in the current ba.Context. 967 The underlying timer will be destroyed when either this object is 968 no longer referenced or when its Context (Activity, etc.) dies. If you 969 do not want to worry about keeping a reference to your timer around, 970 you should use the ba.timer() function instead. 971 972 ###### time 973 > Length of time (in seconds by default) that the timer will wait 974 before firing. Note that the actual delay experienced may vary 975 depending on the timetype. (see below) 976 977 ###### call 978 > A callable Python object. Note that the timer will retain a 979 strong reference to the callable for as long as it exists, so you 980 may want to look into concepts such as ba.WeakCall if that is not 981 desired. 982 983 ###### repeat 984 > If True, the timer will fire repeatedly, with each successive 985 firing having the same delay as the first. 986 987 ###### timetype 988 > A ba.TimeType value determining which timeline the timer is 989 placed onto. 990 991 ###### timeformat 992 > A ba.TimeFormat value determining how the passed time is 993 interpreted. 994 995 ##### Example 996 997 Use a Timer object to print repeatedly for a few seconds: 998 >>> def say_it(): 999 ... ba.screenmessage('BADGER!') 1000 ... def stop_saying_it(): 1001 ... self.t = None 1002 ... ba.screenmessage('MUSHROOM MUSHROOM!') 1003 ... # Create our timer; it will run as long as we have the self.t ref. 1004 ... self.t = ba.Timer(0.3, say_it, repeat=True) 1005 ... # Now fire off a one-shot timer to kill it. 1006 ... ba.timer(3.89, stop_saying_it) 1007 """ 1008 1009 def __init__( 1010 self, 1011 time: float, 1012 call: Callable[[], Any], 1013 repeat: bool = False, 1014 timetype: ba.TimeType = TimeType.SIM, 1015 timeformat: ba.TimeFormat = TimeFormat.SECONDS, 1016 suppress_format_warning: bool = False, 1017 ): 1018 pass
Timers are used to run code at later points in time.
Category: General Utility Classes
This class encapsulates a timer in the current ba.Context. The underlying timer will be destroyed when either this object is no longer referenced or when its Context (Activity, etc.) dies. If you do not want to worry about keeping a reference to your timer around, you should use the ba.timer() function instead.
time
Length of time (in seconds by default) that the timer will wait before firing. Note that the actual delay experienced may vary depending on the timetype. (see below)
call
A callable Python object. Note that the timer will retain a strong reference to the callable for as long as it exists, so you may want to look into concepts such as ba.WeakCall if that is not desired.
repeat
If True, the timer will fire repeatedly, with each successive firing having the same delay as the first.
timetype
A ba.TimeType value determining which timeline the timer is placed onto.
timeformat
A ba.TimeFormat value determining how the passed time is interpreted.
Example
Use a Timer object to print repeatedly for a few seconds:
>>> def say_it():
... ba.screenmessage('BADGER!')
... def stop_saying_it():
... self.t = None
... ba.screenmessage('MUSHROOM MUSHROOM!')
... # Create our timer; it will run as long as we have the self.t ref.
... self.t = ba.Timer(0.3, say_it, repeat=True)
... # Now fire off a one-shot timer to kill it.
... ba.timer(3.89, stop_saying_it)
3273def timer( 3274 time: float, 3275 call: Callable[[], Any], 3276 repeat: bool = False, 3277 timetype: ba.TimeType = TimeType.SIM, 3278 timeformat: ba.TimeFormat = TimeFormat.SECONDS, 3279 suppress_format_warning: bool = False, 3280) -> None: 3281 3282 """Schedule a call to run at a later point in time. 3283 3284 Category: **General Utility Functions** 3285 3286 This function adds a timer to the current ba.Context. 3287 This timer cannot be canceled or modified once created. If you 3288 require the ability to do so, use the ba.Timer class instead. 3289 3290 ##### Arguments 3291 ###### time (float) 3292 > Length of time (in seconds by default) that the timer will wait 3293 before firing. Note that the actual delay experienced may vary 3294 depending on the timetype. (see below) 3295 3296 ###### call (Callable[[], Any]) 3297 > A callable Python object. Note that the timer will retain a 3298 strong reference to the callable for as long as it exists, so you 3299 may want to look into concepts such as ba.WeakCall if that is not 3300 desired. 3301 3302 ###### repeat (bool) 3303 > If True, the timer will fire repeatedly, with each successive 3304 firing having the same delay as the first. 3305 3306 ###### timetype (ba.TimeType) 3307 > Can be either `SIM`, `BASE`, or `REAL`. It defaults to 3308 `SIM`. 3309 3310 ###### timeformat (ba.TimeFormat) 3311 > Defaults to seconds but can also be milliseconds. 3312 3313 - SIM time maps to local simulation time in ba.Activity or ba.Session 3314 Contexts. This means that it may progress slower in slow-motion play 3315 modes, stop when the game is paused, etc. This time type is not 3316 available in UI contexts. 3317 - BASE time is also linked to gameplay in ba.Activity or ba.Session 3318 Contexts, but it progresses at a constant rate regardless of 3319 slow-motion states or pausing. It can, however, slow down or stop 3320 in certain cases such as network outages or game slowdowns due to 3321 cpu load. Like 'sim' time, this is unavailable in UI contexts. 3322 - REAL time always maps to actual clock time with a bit of filtering 3323 added, regardless of Context. (The filtering prevents it from going 3324 backwards or jumping forward by large amounts due to the app being 3325 backgrounded, system time changing, etc.) 3326 Real time timers are currently only available in the UI context. 3327 3328 ##### Examples 3329 Print some stuff through time: 3330 >>> ba.screenmessage('hello from now!') 3331 >>> ba.timer(1.0, ba.Call(ba.screenmessage, 'hello from the future!')) 3332 >>> ba.timer(2.0, ba.Call(ba.screenmessage, 3333 ... 'hello from the future 2!')) 3334 """ 3335 return None
Schedule a call to run at a later point in time.
Category: General Utility Functions
This function adds a timer to the current ba.Context. This timer cannot be canceled or modified once created. If you require the ability to do so, use the ba.Timer class instead.
Arguments
time (float)
Length of time (in seconds by default) that the timer will wait before firing. Note that the actual delay experienced may vary depending on the timetype. (see below)
call (Callable[[], Any])
A callable Python object. Note that the timer will retain a strong reference to the callable for as long as it exists, so you may want to look into concepts such as ba.WeakCall if that is not desired.
repeat (bool)
If True, the timer will fire repeatedly, with each successive firing having the same delay as the first.
timetype (ba.TimeType)
Can be either
SIM
,BASE
, orREAL
. It defaults toSIM
.
timeformat (ba.TimeFormat)
Defaults to seconds but can also be milliseconds.
- SIM time maps to local simulation time in ba.Activity or ba.Session Contexts. This means that it may progress slower in slow-motion play modes, stop when the game is paused, etc. This time type is not available in UI contexts.
- BASE time is also linked to gameplay in ba.Activity or ba.Session Contexts, but it progresses at a constant rate regardless of slow-motion states or pausing. It can, however, slow down or stop in certain cases such as network outages or game slowdowns due to cpu load. Like 'sim' time, this is unavailable in UI contexts.
- REAL time always maps to actual clock time with a bit of filtering added, regardless of Context. (The filtering prevents it from going backwards or jumping forward by large amounts due to the app being backgrounded, system time changing, etc.) Real time timers are currently only available in the UI context.
Examples
Print some stuff through time:
>>> ba.screenmessage('hello from now!')
>>> ba.timer(1.0, ba.Call(ba.screenmessage, 'hello from the future!'))
>>> ba.timer(2.0, ba.Call(ba.screenmessage,
... 'hello from the future 2!'))
281def timestring( 282 timeval: float | int, 283 centi: bool = True, 284 timeformat: ba.TimeFormat = TimeFormat.SECONDS, 285 suppress_format_warning: bool = False, 286) -> ba.Lstr: 287 """Generate a ba.Lstr for displaying a time value. 288 289 Category: **General Utility Functions** 290 291 Given a time value, returns a ba.Lstr with: 292 (hours if > 0 ) : minutes : seconds : (centiseconds if centi=True). 293 294 Time 'timeval' is specified in seconds by default, or 'timeformat' can 295 be set to ba.TimeFormat.MILLISECONDS to accept milliseconds instead. 296 297 WARNING: the underlying Lstr value is somewhat large so don't use this 298 to rapidly update Node text values for an onscreen timer or you may 299 consume significant network bandwidth. For that purpose you should 300 use a 'timedisplay' Node and attribute connections. 301 302 """ 303 from ba._language import Lstr 304 305 # Temp sanity check while we transition from milliseconds to seconds 306 # based time values. 307 if __debug__: 308 if not suppress_format_warning: 309 _ba.time_format_check(timeformat, timeval) 310 311 # We operate on milliseconds internally. 312 if timeformat is TimeFormat.SECONDS: 313 timeval = int(1000 * timeval) 314 elif timeformat is TimeFormat.MILLISECONDS: 315 pass 316 else: 317 raise ValueError(f'invalid timeformat: {timeformat}') 318 if not isinstance(timeval, int): 319 timeval = int(timeval) 320 bits = [] 321 subs = [] 322 hval = (timeval // 1000) // (60 * 60) 323 if hval != 0: 324 bits.append('${H}') 325 subs.append( 326 ( 327 '${H}', 328 Lstr( 329 resource='timeSuffixHoursText', 330 subs=[('${COUNT}', str(hval))], 331 ), 332 ) 333 ) 334 mval = ((timeval // 1000) // 60) % 60 335 if mval != 0: 336 bits.append('${M}') 337 subs.append( 338 ( 339 '${M}', 340 Lstr( 341 resource='timeSuffixMinutesText', 342 subs=[('${COUNT}', str(mval))], 343 ), 344 ) 345 ) 346 347 # We add seconds if its non-zero *or* we haven't added anything else. 348 if centi: 349 # pylint: disable=consider-using-f-string 350 sval = timeval / 1000.0 % 60.0 351 if sval >= 0.005 or not bits: 352 bits.append('${S}') 353 subs.append( 354 ( 355 '${S}', 356 Lstr( 357 resource='timeSuffixSecondsText', 358 subs=[('${COUNT}', ('%.2f' % sval))], 359 ), 360 ) 361 ) 362 else: 363 sval = timeval // 1000 % 60 364 if sval != 0 or not bits: 365 bits.append('${S}') 366 subs.append( 367 ( 368 '${S}', 369 Lstr( 370 resource='timeSuffixSecondsText', 371 subs=[('${COUNT}', str(sval))], 372 ), 373 ) 374 ) 375 return Lstr(value=' '.join(bits), subs=subs)
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.
68class TimeType(Enum): 69 """Specifies the type of time for various operations to target/use. 70 71 Category: Enums 72 73 'sim' time is the local simulation time for an activity or session. 74 It can proceed at different rates depending on game speed, stops 75 for pauses, etc. 76 77 'base' is the baseline time for an activity or session. It proceeds 78 consistently regardless of game speed or pausing, but may stop during 79 occurrences such as network outages. 80 81 'real' time is mostly based on clock time, with a few exceptions. It may 82 not advance while the app is backgrounded for instance. (the engine 83 attempts to prevent single large time jumps from occurring) 84 """ 85 86 SIM = 0 87 BASE = 1 88 REAL = 2
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)
Inherited Members
- enum.Enum
- name
- value
171def uicleanupcheck(obj: Any, widget: ba.Widget) -> None: 172 """Add a check to ensure a widget-owning object gets cleaned up properly. 173 174 Category: User Interface Functions 175 176 This adds a check which will print an error message if the provided 177 object still exists ~5 seconds after the provided ba.Widget dies. 178 179 This is a good sanity check for any sort of object that wraps or 180 controls a ba.Widget. For instance, a 'Window' class instance has 181 no reason to still exist once its root container ba.Widget has fully 182 transitioned out and been destroyed. Circular references or careless 183 strong referencing can lead to such objects never getting destroyed, 184 however, and this helps detect such cases to avoid memory leaks. 185 """ 186 if DEBUG_UI_CLEANUP_CHECKS: 187 print(f'adding uicleanup to {obj}') 188 if not isinstance(widget, _ba.Widget): 189 raise TypeError('widget arg is not a ba.Widget') 190 191 if bool(False): 192 193 def foobar() -> None: 194 """Just testing.""" 195 if DEBUG_UI_CLEANUP_CHECKS: 196 print('uicleanupcheck widget dying...') 197 198 widget.add_delete_callback(foobar) 199 200 _ba.app.ui.cleanupchecks.append( 201 UICleanupCheck( 202 obj=weakref.ref(obj), widget=widget, widget_death_time=None 203 ) 204 )
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.
121class UIController: 122 """Wrangles ba.UILocations. 123 124 Category: User Interface Classes 125 """ 126 127 def __init__(self) -> None: 128 129 # FIXME: document why we have separate stacks for game and menu... 130 self._main_stack_game: list[UIEntry] = [] 131 self._main_stack_menu: list[UIEntry] = [] 132 133 # This points at either the game or menu stack. 134 self._main_stack: list[UIEntry] | None = None 135 136 # There's only one of these since we don't need to preserve its state 137 # between sessions. 138 self._dialog_stack: list[UIEntry] = [] 139 140 def show_main_menu(self, in_game: bool = True) -> None: 141 """Show the main menu, clearing other UIs from location stacks.""" 142 self._main_stack = [] 143 self._dialog_stack = [] 144 self._main_stack = ( 145 self._main_stack_game if in_game else self._main_stack_menu 146 ) 147 self._main_stack.append(UIEntry('mainmenu', self)) 148 self._update_ui() 149 150 def _update_ui(self) -> None: 151 """Instantiate the topmost ui in our stacks.""" 152 153 # First tell any existing UIs to get outta here. 154 for stack in (self._dialog_stack, self._main_stack): 155 assert stack is not None 156 for entry in stack: 157 entry.destroy() 158 159 # Now create the topmost one if there is one. 160 entrynew = ( 161 self._dialog_stack[-1] 162 if self._dialog_stack 163 else self._main_stack[-1] 164 if self._main_stack 165 else None 166 ) 167 if entrynew is not None: 168 entrynew.create()
Wrangles ba.UILocations.
Category: User Interface Classes
127 def __init__(self) -> None: 128 129 # FIXME: document why we have separate stacks for game and menu... 130 self._main_stack_game: list[UIEntry] = [] 131 self._main_stack_menu: list[UIEntry] = [] 132 133 # This points at either the game or menu stack. 134 self._main_stack: list[UIEntry] | None = None 135 136 # There's only one of these since we don't need to preserve its state 137 # between sessions. 138 self._dialog_stack: list[UIEntry] = []
42class UIScale(Enum): 43 """The overall scale the UI is being rendered for. Note that this is 44 independent of pixel resolution. For example, a phone and a desktop PC 45 might render the game at similar pixel resolutions but the size they 46 display content at will vary significantly. 47 48 Category: Enums 49 50 'large' is used for devices such as desktop PCs where fine details can 51 be clearly seen. UI elements are generally smaller on the screen 52 and more content can be seen at once. 53 54 'medium' is used for devices such as tablets, TVs, or VR headsets. 55 This mode strikes a balance between clean readability and amount of 56 content visible. 57 58 'small' is used primarily for phones or other small devices where 59 content needs to be presented as large and clear in order to remain 60 readable from an average distance. 61 """ 62 63 LARGE = 0 64 MEDIUM = 1 65 SMALL = 2
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.
Inherited Members
- enum.Enum
- name
- value
19class UISubsystem: 20 """Consolidated UI functionality for the app. 21 22 Category: **App Classes** 23 24 To use this class, access the single instance of it at 'ba.app.ui'. 25 """ 26 27 def __init__(self) -> None: 28 env = _ba.env() 29 30 self.controller: ba.UIController | None = None 31 32 self._main_menu_window: ba.Widget | None = None 33 self._main_menu_location: str | None = None 34 35 self._uiscale: ba.UIScale 36 37 interfacetype = env['ui_scale'] 38 if interfacetype == 'large': 39 self._uiscale = UIScale.LARGE 40 elif interfacetype == 'medium': 41 self._uiscale = UIScale.MEDIUM 42 elif interfacetype == 'small': 43 self._uiscale = UIScale.SMALL 44 else: 45 raise RuntimeError(f'Invalid UIScale value: {interfacetype}') 46 47 self.window_states: dict[type, Any] = {} # FIXME: Kill this. 48 self.main_menu_selection: str | None = None # FIXME: Kill this. 49 self.have_party_queue_window = False 50 self.quit_window: Any = None 51 self.dismiss_wii_remotes_window_call: (Callable[[], Any] | None) = None 52 self.cleanupchecks: list[UICleanupCheck] = [] 53 self.upkeeptimer: ba.Timer | None = None 54 self.use_toolbars = env.get('toolbar_test', True) 55 self.party_window: Any = None # FIXME: Don't use Any. 56 self.title_color = (0.72, 0.7, 0.75) 57 self.heading_color = (0.72, 0.7, 0.75) 58 self.infotextcolor = (0.7, 0.9, 0.7) 59 60 # Switch our overall game selection UI flow between Play and 61 # Private-party playlist selection modes; should do this in 62 # a more elegant way once we revamp high level UI stuff a bit. 63 self.selecting_private_party_playlist: bool = False 64 65 @property 66 def uiscale(self) -> ba.UIScale: 67 """Current ui scale for the app.""" 68 return self._uiscale 69 70 def on_app_launch(self) -> None: 71 """Should be run on app launch.""" 72 from ba.ui import UIController, ui_upkeep 73 from ba._generated.enums import TimeType 74 75 # IMPORTANT: If tweaking UI stuff, make sure it behaves for small, 76 # medium, and large UI modes. (doesn't run off screen, etc). 77 # The overrides below can be used to test with different sizes. 78 # Generally small is used on phones, medium is used on tablets/tvs, 79 # and large is on desktop computers or perhaps large tablets. When 80 # possible, run in windowed mode and resize the window to assure 81 # this holds true at all aspect ratios. 82 83 # UPDATE: A better way to test this is now by setting the environment 84 # variable BA_UI_SCALE to "small", "medium", or "large". 85 # This will affect system UIs not covered by the values below such 86 # as screen-messages. The below values remain functional, however, 87 # for cases such as Android where environment variables can't be set 88 # easily. 89 90 if bool(False): # force-test ui scale 91 self._uiscale = UIScale.SMALL 92 with _ba.Context('ui'): 93 _ba.pushcall( 94 lambda: _ba.screenmessage( 95 f'FORCING UISCALE {self._uiscale.name} FOR TESTING', 96 color=(1, 0, 1), 97 log=True, 98 ) 99 ) 100 101 self.controller = UIController() 102 103 # Kick off our periodic UI upkeep. 104 # FIXME: Can probably kill this if we do immediate UI death checks. 105 self.upkeeptimer = _ba.Timer( 106 2.6543, ui_upkeep, timetype=TimeType.REAL, repeat=True 107 ) 108 109 def set_main_menu_window(self, window: ba.Widget) -> None: 110 """Set the current 'main' window, replacing any existing.""" 111 existing = self._main_menu_window 112 from ba._generated.enums import TimeType 113 from inspect import currentframe, getframeinfo 114 115 # Let's grab the location where we were called from to report 116 # if we have to force-kill the existing window (which normally 117 # should not happen). 118 frameline = None 119 try: 120 frame = currentframe() 121 if frame is not None: 122 frame = frame.f_back 123 if frame is not None: 124 frameinfo = getframeinfo(frame) 125 frameline = f'{frameinfo.filename} {frameinfo.lineno}' 126 except Exception: 127 from ba._error import print_exception 128 129 print_exception('Error calcing line for set_main_menu_window') 130 131 # With our legacy main-menu system, the caller is responsible for 132 # clearing out the old main menu window when assigning the new. 133 # However there are corner cases where that doesn't happen and we get 134 # old windows stuck under the new main one. So let's guard against 135 # that. However, we can't simply delete the existing main window when 136 # a new one is assigned because the user may transition the old out 137 # *after* the assignment. Sigh. So, as a happy medium, let's check in 138 # on the old after a short bit of time and kill it if its still alive. 139 # That will be a bit ugly on screen but at least should un-break 140 # things. 141 def _delay_kill() -> None: 142 import time 143 144 if existing: 145 print( 146 f'Killing old main_menu_window' 147 f' when called at: {frameline} t={time.time():.3f}' 148 ) 149 existing.delete() 150 151 _ba.timer(1.0, _delay_kill, timetype=TimeType.REAL) 152 self._main_menu_window = window 153 154 def clear_main_menu_window(self, transition: str | None = None) -> None: 155 """Clear any existing 'main' window with the provided transition.""" 156 if self._main_menu_window: 157 if transition is not None: 158 _ba.containerwidget( 159 edit=self._main_menu_window, transition=transition 160 ) 161 else: 162 self._main_menu_window.delete() 163 164 def has_main_menu_window(self) -> bool: 165 """Return whether a main menu window is present.""" 166 return bool(self._main_menu_window) 167 168 def set_main_menu_location(self, location: str) -> None: 169 """Set the location represented by the current main menu window.""" 170 self._main_menu_location = location 171 172 def get_main_menu_location(self) -> str | None: 173 """Return the current named main menu location, if any.""" 174 return self._main_menu_location
Consolidated UI functionality for the app.
Category: App Classes
To use this class, access the single instance of it at 'ba.app.ui'.
27 def __init__(self) -> None: 28 env = _ba.env() 29 30 self.controller: ba.UIController | None = None 31 32 self._main_menu_window: ba.Widget | None = None 33 self._main_menu_location: str | None = None 34 35 self._uiscale: ba.UIScale 36 37 interfacetype = env['ui_scale'] 38 if interfacetype == 'large': 39 self._uiscale = UIScale.LARGE 40 elif interfacetype == 'medium': 41 self._uiscale = UIScale.MEDIUM 42 elif interfacetype == 'small': 43 self._uiscale = UIScale.SMALL 44 else: 45 raise RuntimeError(f'Invalid UIScale value: {interfacetype}') 46 47 self.window_states: dict[type, Any] = {} # FIXME: Kill this. 48 self.main_menu_selection: str | None = None # FIXME: Kill this. 49 self.have_party_queue_window = False 50 self.quit_window: Any = None 51 self.dismiss_wii_remotes_window_call: (Callable[[], Any] | None) = None 52 self.cleanupchecks: list[UICleanupCheck] = [] 53 self.upkeeptimer: ba.Timer | None = None 54 self.use_toolbars = env.get('toolbar_test', True) 55 self.party_window: Any = None # FIXME: Don't use Any. 56 self.title_color = (0.72, 0.7, 0.75) 57 self.heading_color = (0.72, 0.7, 0.75) 58 self.infotextcolor = (0.7, 0.9, 0.7) 59 60 # Switch our overall game selection UI flow between Play and 61 # Private-party playlist selection modes; should do this in 62 # a more elegant way once we revamp high level UI stuff a bit. 63 self.selecting_private_party_playlist: bool = False
70 def on_app_launch(self) -> None: 71 """Should be run on app launch.""" 72 from ba.ui import UIController, ui_upkeep 73 from ba._generated.enums import TimeType 74 75 # IMPORTANT: If tweaking UI stuff, make sure it behaves for small, 76 # medium, and large UI modes. (doesn't run off screen, etc). 77 # The overrides below can be used to test with different sizes. 78 # Generally small is used on phones, medium is used on tablets/tvs, 79 # and large is on desktop computers or perhaps large tablets. When 80 # possible, run in windowed mode and resize the window to assure 81 # this holds true at all aspect ratios. 82 83 # UPDATE: A better way to test this is now by setting the environment 84 # variable BA_UI_SCALE to "small", "medium", or "large". 85 # This will affect system UIs not covered by the values below such 86 # as screen-messages. The below values remain functional, however, 87 # for cases such as Android where environment variables can't be set 88 # easily. 89 90 if bool(False): # force-test ui scale 91 self._uiscale = UIScale.SMALL 92 with _ba.Context('ui'): 93 _ba.pushcall( 94 lambda: _ba.screenmessage( 95 f'FORCING UISCALE {self._uiscale.name} FOR TESTING', 96 color=(1, 0, 1), 97 log=True, 98 ) 99 ) 100 101 self.controller = UIController() 102 103 # Kick off our periodic UI upkeep. 104 # FIXME: Can probably kill this if we do immediate UI death checks. 105 self.upkeeptimer = _ba.Timer( 106 2.6543, ui_upkeep, timetype=TimeType.REAL, repeat=True 107 )
Should be run on app launch.
1021class Vec3(Sequence[float]): 1022 1023 """A vector of 3 floats. 1024 1025 Category: **General Utility Classes** 1026 1027 These can be created the following ways (checked in this order): 1028 - with no args, all values are set to 0 1029 - with a single numeric arg, all values are set to that value 1030 - with a single three-member sequence arg, sequence values are copied 1031 - otherwise assumes individual x/y/z args (positional or keywords) 1032 """ 1033 1034 x: float 1035 1036 """The vector's X component.""" 1037 1038 y: float 1039 1040 """The vector's Y component.""" 1041 1042 z: float 1043 1044 """The vector's Z component.""" 1045 1046 # pylint: disable=function-redefined 1047 1048 @overload 1049 def __init__(self) -> None: 1050 pass 1051 1052 @overload 1053 def __init__(self, value: float): 1054 pass 1055 1056 @overload 1057 def __init__(self, values: Sequence[float]): 1058 pass 1059 1060 @overload 1061 def __init__(self, x: float, y: float, z: float): 1062 pass 1063 1064 def __init__(self, *args: Any, **kwds: Any): 1065 pass 1066 1067 def __add__(self, other: Vec3) -> Vec3: 1068 return self 1069 1070 def __sub__(self, other: Vec3) -> Vec3: 1071 return self 1072 1073 @overload 1074 def __mul__(self, other: float) -> Vec3: 1075 return self 1076 1077 @overload 1078 def __mul__(self, other: Sequence[float]) -> Vec3: 1079 return self 1080 1081 def __mul__(self, other: Any) -> Any: 1082 return self 1083 1084 @overload 1085 def __rmul__(self, other: float) -> Vec3: 1086 return self 1087 1088 @overload 1089 def __rmul__(self, other: Sequence[float]) -> Vec3: 1090 return self 1091 1092 def __rmul__(self, other: Any) -> Any: 1093 return self 1094 1095 # (for index access) 1096 def __getitem__(self, typeargs: Any) -> Any: 1097 return 0.0 1098 1099 def __len__(self) -> int: 1100 return 3 1101 1102 # (for iterator access) 1103 def __iter__(self) -> Any: 1104 return self 1105 1106 def __next__(self) -> float: 1107 return 0.0 1108 1109 def __neg__(self) -> Vec3: 1110 return self 1111 1112 def __setitem__(self, index: int, val: float) -> None: 1113 pass 1114 1115 def cross(self, other: Vec3) -> Vec3: 1116 1117 """Returns the cross product of this vector and another.""" 1118 return Vec3() 1119 1120 def dot(self, other: Vec3) -> float: 1121 1122 """Returns the dot product of this vector and another.""" 1123 return float() 1124 1125 def length(self) -> float: 1126 1127 """Returns the length of the vector.""" 1128 return float() 1129 1130 def normalized(self) -> Vec3: 1131 1132 """Returns a normalized version of the vector.""" 1133 return Vec3()
A vector of 3 floats.
Category: General Utility Classes
These can be created the following ways (checked in this order):
- with no args, all values are set to 0
- with a single numeric arg, all values are set to that value
- with a single three-member sequence arg, sequence values are copied
- otherwise assumes individual x/y/z args (positional or keywords)
1115 def cross(self, other: Vec3) -> Vec3: 1116 1117 """Returns the cross product of this vector and another.""" 1118 return Vec3()
Returns the cross product of this vector and another.
1120 def dot(self, other: Vec3) -> float: 1121 1122 """Returns the dot product of this vector and another.""" 1123 return float()
Returns the dot product of this vector and another.
1125 def length(self) -> float: 1126 1127 """Returns the length of the vector.""" 1128 return float()
Returns the length of the vector.
1130 def normalized(self) -> Vec3: 1131 1132 """Returns a normalized version of the vector.""" 1133 return Vec3()
Returns a normalized version of the vector.
Inherited Members
- collections.abc.Sequence
- index
- count
15def vec3validate(value: Sequence[float]) -> Sequence[float]: 16 """Ensure a value is valid for use as a Vec3. 17 18 category: General Utility Functions 19 20 Raises a TypeError exception if not. 21 Valid values include any type of sequence consisting of 3 numeric values. 22 Returns the same value as passed in (but with a definite type 23 so this can be used to disambiguate 'Any' types). 24 Generally this should be used in 'if __debug__' or assert clauses 25 to keep runtime overhead minimal. 26 """ 27 from numbers import Number 28 29 if not isinstance(value, abc.Sequence): 30 raise TypeError(f"Expected a sequence; got {type(value)}") 31 if len(value) != 3: 32 raise TypeError(f"Expected a length-3 sequence (got {len(value)})") 33 if not all(isinstance(i, Number) for i in value): 34 raise TypeError(f"Non-numeric value passed for vec3: {value}") 35 return value
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.
300def verify_object_death(obj: object) -> None: 301 """Warn if an object does not get freed within a short period. 302 303 Category: **General Utility Functions** 304 305 This can be handy to detect and prevent memory/resource leaks. 306 """ 307 try: 308 ref = weakref.ref(obj) 309 except Exception: 310 print_exception('Unable to create weak-ref in verify_object_death') 311 312 # Use a slight range for our checks so they don't all land at once 313 # if we queue a lot of them. 314 delay = random.uniform(2.0, 5.5) 315 with _ba.Context('ui'): 316 _ba.timer( 317 delay, lambda: _verify_object_death(ref), timetype=TimeType.REAL 318 )
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.
1136class Widget: 1137 1138 """Internal type for low level UI elements; buttons, windows, etc. 1139 1140 Category: **User Interface Classes** 1141 1142 This class represents a weak reference to a widget object 1143 in the internal C++ layer. Currently, functions such as 1144 ba.buttonwidget() must be used to instantiate or edit these. 1145 """ 1146 1147 def activate(self) -> None: 1148 1149 """Activates a widget; the same as if it had been clicked.""" 1150 return None 1151 1152 def add_delete_callback(self, call: Callable) -> None: 1153 1154 """Add a call to be run immediately after this widget is destroyed.""" 1155 return None 1156 1157 def delete(self, ignore_missing: bool = True) -> None: 1158 1159 """Delete the Widget. Ignores already-deleted Widgets if ignore_missing 1160 is True; otherwise an Exception is thrown. 1161 """ 1162 return None 1163 1164 def exists(self) -> bool: 1165 1166 """Returns whether the Widget still exists. 1167 Most functionality will fail on a nonexistent widget. 1168 1169 Note that you can also use the boolean operator for this same 1170 functionality, so a statement such as "if mywidget" will do 1171 the right thing both for Widget objects and values of None. 1172 """ 1173 return bool() 1174 1175 def get_children(self) -> list[ba.Widget]: 1176 1177 """Returns any child Widgets of this Widget.""" 1178 return [Widget()] 1179 1180 def get_screen_space_center(self) -> tuple[float, float]: 1181 1182 """Returns the coords of the ba.Widget center relative to the center 1183 of the screen. This can be useful for placing pop-up windows and other 1184 special cases. 1185 """ 1186 return (0.0, 0.0) 1187 1188 def get_selected_child(self) -> ba.Widget | None: 1189 1190 """Returns the selected child Widget or None if nothing is selected.""" 1191 return Widget() 1192 1193 def get_widget_type(self) -> str: 1194 1195 """Return the internal type of the Widget as a string. Note that this 1196 is different from the Python ba.Widget type, which is the same for 1197 all widgets. 1198 """ 1199 return str()
Internal type for low level UI elements; buttons, windows, etc.
Category: User Interface Classes
This class represents a weak reference to a widget object in the internal C++ layer. Currently, functions such as ba.buttonwidget() must be used to instantiate or edit these.
1147 def activate(self) -> None: 1148 1149 """Activates a widget; the same as if it had been clicked.""" 1150 return None
Activates a widget; the same as if it had been clicked.
1152 def add_delete_callback(self, call: Callable) -> None: 1153 1154 """Add a call to be run immediately after this widget is destroyed.""" 1155 return None
Add a call to be run immediately after this widget is destroyed.
1157 def delete(self, ignore_missing: bool = True) -> None: 1158 1159 """Delete the Widget. Ignores already-deleted Widgets if ignore_missing 1160 is True; otherwise an Exception is thrown. 1161 """ 1162 return None
Delete the Widget. Ignores already-deleted Widgets if ignore_missing is True; otherwise an Exception is thrown.
1164 def exists(self) -> bool: 1165 1166 """Returns whether the Widget still exists. 1167 Most functionality will fail on a nonexistent widget. 1168 1169 Note that you can also use the boolean operator for this same 1170 functionality, so a statement such as "if mywidget" will do 1171 the right thing both for Widget objects and values of None. 1172 """ 1173 return bool()
Returns whether the Widget still exists. Most functionality will fail on a nonexistent widget.
Note that you can also use the boolean operator for this same functionality, so a statement such as "if mywidget" will do the right thing both for Widget objects and values of None.
1175 def get_children(self) -> list[ba.Widget]: 1176 1177 """Returns any child Widgets of this Widget.""" 1178 return [Widget()]
Returns any child Widgets of this Widget.
1180 def get_screen_space_center(self) -> tuple[float, float]: 1181 1182 """Returns the coords of the ba.Widget center relative to the center 1183 of the screen. This can be useful for placing pop-up windows and other 1184 special cases. 1185 """ 1186 return (0.0, 0.0)
Returns the coords of the ba.Widget center relative to the center of the screen. This can be useful for placing pop-up windows and other special cases.
1188 def get_selected_child(self) -> ba.Widget | None: 1189 1190 """Returns the selected child Widget or None if nothing is selected.""" 1191 return Widget()
Returns the selected child Widget or None if nothing is selected.
1193 def get_widget_type(self) -> str: 1194 1195 """Return the internal type of the Widget as a string. Note that this 1196 is different from the Python ba.Widget type, which is the same for 1197 all widgets. 1198 """ 1199 return str()
Return the internal type of the Widget as a string. Note that this is different from the Python ba.Widget type, which is the same for all widgets.
3382def widget( 3383 edit: ba.Widget | None = None, 3384 up_widget: ba.Widget | None = None, 3385 down_widget: ba.Widget | None = None, 3386 left_widget: ba.Widget | None = None, 3387 right_widget: ba.Widget | None = None, 3388 show_buffer_top: float | None = None, 3389 show_buffer_bottom: float | None = None, 3390 show_buffer_left: float | None = None, 3391 show_buffer_right: float | None = None, 3392 autoselect: bool | None = None, 3393) -> None: 3394 3395 """Edit common attributes of any widget. 3396 3397 Category: **User Interface Functions** 3398 3399 Unlike other UI calls, this can only be used to edit, not to create. 3400 """ 3401 return None
Edit common attributes of any widget.
Category: User Interface Functions
Unlike other UI calls, this can only be used to edit, not to create.
129class WidgetNotFoundError(NotFoundError): 130 """Exception raised when an expected ba.Widget does not exist. 131 132 Category: **Exception Classes** 133 """
Exception raised when an expected ba.Widget does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
26class Window: 27 """A basic window. 28 29 Category: User Interface Classes 30 """ 31 32 def __init__(self, root_widget: ba.Widget, cleanupcheck: bool = True): 33 self._root_widget = root_widget 34 35 # Complain if we outlive our root widget. 36 if cleanupcheck: 37 uicleanupcheck(self, root_widget) 38 39 def get_root_widget(self) -> ba.Widget: 40 """Return the root widget.""" 41 return self._root_widget
A basic window.
Category: User Interface Classes