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
class AccountV2Handle:
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.

AccountV2Handle()
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] = {}
class Achievement:
 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

Achievement( name: str, icon_name: str, icon_color: Sequence[float], level_name: str, award: int, hard_mode_only: bool = False)
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
name: str

The name of this achievement.

level_name: str

The name of the level this achievement applies to.

def get_icon_texture(self, complete: bool) -> ba.Texture:
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

def get_icon_color(self, complete: bool) -> Sequence[float]:
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.

hard_mode_only: bool

Whether this Achievement is only unlockable in hard-mode.

complete: bool

Whether this Achievement is currently complete.

def announce_completion(self, sound: bool = True) -> None:
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.

def set_complete(self, complete: bool = True) -> None:
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.

display_name: ba.Lstr

Return a ba.Lstr for this Achievement's name.

description: ba.Lstr

Get a ba.Lstr for the Achievement's brief description.

description_complete: ba.Lstr

Get a ba.Lstr for the Achievement's description when completed.

description_full: ba.Lstr

Get a ba.Lstr for the Achievement's full description.

description_full_complete: ba.Lstr

Get a ba.Lstr for the Achievement's full desc. when completed.

def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
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.

power_ranking_value: int

Get the power-ranking award value for this achievement.

def create_display( self, x: float, y: float, delay: float, outdelay: float | None = None, color: Optional[Sequence[float]] = None, style: str = 'post_game') -> list[ba.Actor]:
 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.

def show_completion_banner(self, sound: bool = True) -> None:
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            )

Create the banner/sound for an acquired achievement announcement.

class AchievementSubsystem:
 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'.

AchievementSubsystem()
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()
def award_local_achievement(self, achname: str) -> None:
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.

def get_achievement(self, name: str) -> ba.Achievement:
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.

def achievements_for_coop_level(self, level_name: str) -> list[ba.Achievement]:
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.

class Activity(ba.DependencyComponent, typing.Generic[~PlayerType, ~TeamType]):
 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.

Activity(settings: dict)
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.

settings_raw: dict[str, typing.Any]

The settings dict passed in when the activity was made. This attribute is deprecated and should be avoided when possible; activities should pull all values they need from the 'settings' arg passed to the Activity __init__ call.

teams: list[~TeamType]

The list of ba.Team-s in the Activity. This gets populated just before on_begin() is called and is updated automatically as players join or leave the game. (at least in free-for-all mode where every player gets their own team; in teams mode there are always 2 teams regardless of the player count).

players: list[~PlayerType]

The list of ba.Player-s in the Activity. This gets populated just before on_begin() is called and is updated automatically as players join or leave the game.

announce_player_deaths = False

Whether to print every time a player dies. This can be pertinent in games such as Death-Match but can be annoying in games where it doesn't matter.

is_joining_activity = False

Joining activities are for waiting for initial player joins. They are treated slightly differently than regular activities, mainly in that all players are passed to the activity at once instead of as each joins.

allow_pausing = False

Whether game-time should still progress when in menus/etc.

allow_kick_idle_players = True

Whether idle players can potentially be kicked (should not happen in menus/etc).

use_fixed_vr_overlay = False

In vr mode, this determines whether overlay nodes (text, images, etc) are created at a fixed position in space or one that moves based on the current map. Generally this should be on for games and off for transitions/score-screens/etc. that persist between maps.

slow_motion = False

If True, runs in slow motion and turns down sound pitch.

inherits_slow_motion = False

Set this to True to inherit slow motion setting from previous activity (useful for transitions to avoid hitches).

inherits_music = False

Set this to True to keep playing the music from the previous activity (without even restarting it).

inherits_vr_camera_offset = False

Set this to true to inherit VR camera offsets from the previous activity (useful for preventing sporadic camera movement during transitions).

inherits_vr_overlay_center = False

Set this to true to inherit (non-fixed) VR overlay positioning from the previous activity (useful for prevent sporadic overlay jostling during transitions).

inherits_tint = False

Set this to true to inherit screen tint/vignette colors from the previous activity (useful to prevent sudden color changes during transitions).

allow_mid_activity_joins: bool = True

Whether players should be allowed to join in the middle of this activity. Note that Sessions may not allow mid-activity-joins even if the activity says its ok.

transition_time = 0.0

If the activity fades or transitions in, it should set the length of time here so that previous activities will be kept alive for that long (avoiding 'holes' in the screen) This value is given in real-time seconds.

can_show_ad_on_death = False

Is it ok to show an ad after this activity ends before showing the next activity?

globalsnode: ba.Node

The 'globals' ba.Node for the activity. This contains various global controls and values.

stats: ba.Stats

The stats instance accessible while the activity is running.

If access is attempted before or after, raises a ba.NotFoundError.

def on_expire(self) -> None:
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.

customdata: dict

Entities needing to store simple data with an activity can put it here. This dict will be deleted when the activity expires, so contained objects generally do not need to worry about handling expired activities.

expired: bool

Whether the activity is expired.

An activity is set as expired when shutting down. At this point no new nodes, timers, etc should be made, run, etc, and the activity should be considered a 'zombie'.

playertype: type[~PlayerType]

The type of ba.Player this Activity is using.

teamtype: type[~TeamType]

The type of ba.Team this Activity is using.

def retain_actor(self, actor: ba.Actor) -> None:
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.

def add_actor_weak_ref(self, actor: ba.Actor) -> None:
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)

session: ba.Session

The ba.Session this ba.Activity belongs go.

Raises a ba.SessionNotFoundError if the Session no longer exists.

def on_player_join(self, player: ~PlayerType) -> None:
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)

def on_player_leave(self, player: ~PlayerType) -> None:
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.

def on_team_join(self, team: ~TeamType) -> None:
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)

def on_team_leave(self, team: ~TeamType) -> None:
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.

def on_transition_in(self) -> None:
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.

def on_transition_out(self) -> None:
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.

def on_begin(self) -> None:
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.

def handlemessage(self, msg: Any) -> Any:
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.

def has_transitioned_in(self) -> bool:
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.

def has_begun(self) -> bool:
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.

def has_ended(self) -> bool:
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.

def is_transitioning_out(self) -> bool:
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.

def transition_out(self) -> None:
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.

def end( self, results: Any = None, delay: float = 0.0, force: bool = False) -> None:
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.

def create_player(self, sessionplayer: ba.SessionPlayer) -> ~PlayerType:
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.

def create_team(self, sessionteam: ba.SessionTeam) -> ~TeamType:
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.

class ActivityNotFoundError(ba.NotFoundError):
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
class Actor:
 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())
Actor()
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.

def handlemessage(self, msg: Any) -> Any:
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.

def autoretain(self: ~ActorT) -> ~ActorT:
 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()

def on_expire(self) -> None:
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.Actors when their ba.Activity shuts down.

Actors can use this opportunity to clear callbacks or other references which have the potential of keeping the ba.Activity alive inadvertently (Activities can not exit cleanly while any Python references to them remain.)

Once an actor is expired (see ba.Actor.is_expired()) it should no longer perform any game-affecting operations (creating, modifying, or deleting nodes, media, timers, etc.) Attempts to do so will likely result in errors.

expired: bool

Whether the Actor is expired.

(see ba.Actor.on_expire())

def exists(self) -> bool:
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.

def is_alive(self) -> bool:
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.

activity: ba.Activity

The Activity this Actor was created in.

Raises a ba.ActivityNotFoundError if the Activity no longer exists.

def getactivity(self, doraise: bool = True) -> ba.Activity | None:
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.

class ActorNotFoundError(ba.NotFoundError):
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
def animate( node: ba.Node, attr: str, keys: dict[float, float], loop: bool = False, offset: float = 0, timetype: ba.TimeType = <TimeType.SIM: 0>, timeformat: ba.TimeFormat = <TimeFormat.SECONDS: 0>, suppress_format_warning: bool = False) -> ba.Node:
 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.

def animate_array( node: ba.Node, attr: str, size: int, keys: dict[float, typing.Sequence[float]], loop: bool = False, offset: float = 0, timetype: ba.TimeType = <TimeType.SIM: 0>, timeformat: ba.TimeFormat = <TimeFormat.SECONDS: 0>, suppress_format_warning: bool = False) -> None:
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.

app: ba.App
class App:
 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.

aioloop: asyncio.events.AbstractEventLoop

The Logic Thread's Asyncio Event Loop.

This allow async tasks to be run in the logic thread. Note that, at this time, the asyncio loop is encapsulated and explicitly stepped by the engine's logic thread loop and thus things like asyncio.get_running_loop() will not return this loop from most places in the logic thread; only from within a task explicitly created in this loop.

build_number: int

Integer build number.

This value increases by at least 1 with each release of the game. It is independent of the human readable ba.App.version string.

device_name: str

Name of the device running the game.

config_file_path: str

Where the game's config file is stored on disk.

user_agent_string: str

String containing various bits of info about OS/device/etc.

version: str

Human-readable version string; something like '1.3.24'.

This should not be interpreted as a number; it may contain string elements such as 'alpha', 'beta', 'test', etc. If a numeric version is needed, use 'ba.App.build_number'.

debug_build: bool

Whether the app was compiled in debug mode.

Debug builds generally run substantially slower than non-debug builds due to compiler optimizations being disabled and extra checks being run.

test_build: bool

Whether the game was compiled in test mode.

Test mode enables extra checks and features that are useful for release testing but which do not slow the game down significantly.

python_directory_user: str

Path where the app looks for custom user scripts.

python_directory_app: str

Path where the app looks for its bundled scripts.

python_directory_app_site: str

Path containing pip packages bundled with the app.

config: ba.AppConfig

The ba.AppConfig instance representing the app's config state.

platform: str

Name of the current platform.

Examples are: 'mac', 'windows', android'.

subplatform: str

String for subplatform.

Can be empty. For the 'android' platform, subplatform may be 'google', 'amazon', etc.

api_version: int

The game's api version.

Only Python modules and packages associated with the current API version number will be detected by the game (see the ba_meta tag). This value will change whenever backward-incompatible changes are introduced to game APIs. When that happens, scripts should be updated accordingly and set to target the new API version number.

on_tv: bool

Whether the game is currently running on a TV.

vr_mode: bool

Whether the game is currently running in VR.

ui_bounds: tuple[float, float, float, float]

Bounds of the 'safe' screen area in ui space.

This tuple contains: (x-min, x-max, y-min, y-max)

def on_app_launching(self) -> None:
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.

def on_app_loading(self) -> None:
486    def on_app_loading(self) -> None:
487        """Called when initially entering the loading state."""

Called when initially entering the loading state.

def on_app_running(self) -> None:
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.

def on_bootstrapping_completed(self) -> None:
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.

def on_meta_scan_complete(self) -> None:
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.

def on_app_pause(self) -> None:
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.

def on_app_resume(self) -> None:
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.

def pause(self) -> None:
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.

def resume(self) -> None:
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.

def add_coop_practice_level(self, level: ba.Level) -> None:
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.

def return_to_main_menu_session_gracefully(self, reset_ui: bool = True) -> None:
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))

Attempt to cleanly get back to the main menu.

def launch_coop_game(self, game: str, force: bool = False, args: dict | None = None) -> bool:
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.

def on_initial_sign_in_completed(self) -> None:
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.

class App.State(enum.Enum):
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.

INITIAL = <State.INITIAL: 0>
LAUNCHING = <State.LAUNCHING: 1>
LOADING = <State.LOADING: 2>
RUNNING = <State.RUNNING: 3>
PAUSED = <State.PAUSED: 4>
SHUTTING_DOWN = <State.SHUTTING_DOWN: 5>
Inherited Members
enum.Enum
name
value
class AppConfig(builtins.dict):
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.

def resolve(self, key: str) -> Any:
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.

def default_value(self, key: str) -> Any:
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.

def builtin_keys(self) -> list[str]:
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.

def apply(self) -> None:
76    def apply(self) -> None:
77        """Apply config values to the running app."""
78        _ba.apply_config()

Apply config values to the running app.

def commit(self) -> None:
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.

def apply_and_commit(self) -> None:
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
class AppDelegate:
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

AppDelegate()
def create_default_game_settings_ui( self, gameclass: type[ba.GameActivity], sessiontype: type[ba.Session], settings: dict | None, completion_call: Callable[[dict | None], NoneType]) -> None:
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.

class AssetPackage(ba.DependencyComponent):
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

AssetPackage()
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.

@classmethod
def dep_is_present(cls, config: Any = None) -> bool:
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.

def gettexture(self, name: str) -> ba.Texture:
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()

def getmodel(self, name: str) -> ba.Model:
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()

def getcollidemodel(self, name: str) -> ba.CollideModel:
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()

def getsound(self, name: str) -> ba.Sound:
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()

def getdata(self, name: str) -> ba.Data:
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()

@dataclass
class BoolSetting(ba.Setting):
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

BoolSetting(name: str, default: bool)
def buttonwidget( edit: ba.Widget | None = None, parent: ba.Widget | None = None, size: Optional[Sequence[float]] = None, position: Optional[Sequence[float]] = None, on_activate_call: Optional[Callable] = None, label: str | ba.Lstr | None = None, color: Optional[Sequence[float]] = None, down_widget: ba.Widget | None = None, up_widget: ba.Widget | None = None, left_widget: ba.Widget | None = None, right_widget: ba.Widget | None = None, texture: ba.Texture | None = None, text_scale: float | None = None, textcolor: Optional[Sequence[float]] = None, enable_sound: bool | None = None, model_transparent: ba.Model | None = None, model_opaque: ba.Model | None = None, repeat: bool | None = None, scale: float | None = None, transition_delay: float | None = None, on_select_call: Optional[Callable] = None, button_type: str | None = None, extra_touch_border_scale: float | None = None, selectable: bool | None = None, show_buffer_top: float | None = None, icon: ba.Texture | None = None, iconscale: float | None = None, icon_tint: float | None = None, icon_color: Optional[Sequence[float]] = None, autoselect: bool | None = None, mask_texture: ba.Texture | None = None, tint_texture: ba.Texture | None = None, tint_color: Optional[Sequence[float]] = None, tint2_color: Optional[Sequence[float]] = None, text_flatness: float | None = None, text_res_scale: float | None = None, enabled: bool | None = None) -> ba.Widget:
1277def buttonwidget(
1278    edit: ba.Widget | None = None,
1279    parent: ba.Widget | None = None,
1280    size: Sequence[float] | None = None,
1281    position: Sequence[float] | None = None,
1282    on_activate_call: Callable | None = None,
1283    label: str | ba.Lstr | None = None,
1284    color: Sequence[float] | None = None,
1285    down_widget: ba.Widget | None = None,
1286    up_widget: ba.Widget | None = None,
1287    left_widget: ba.Widget | None = None,
1288    right_widget: ba.Widget | None = None,
1289    texture: ba.Texture | None = None,
1290    text_scale: float | None = None,
1291    textcolor: Sequence[float] | None = None,
1292    enable_sound: bool | None = None,
1293    model_transparent: ba.Model | None = None,
1294    model_opaque: ba.Model | None = None,
1295    repeat: bool | None = None,
1296    scale: float | None = None,
1297    transition_delay: float | None = None,
1298    on_select_call: Callable | None = None,
1299    button_type: str | None = None,
1300    extra_touch_border_scale: float | None = None,
1301    selectable: bool | None = None,
1302    show_buffer_top: float | None = None,
1303    icon: ba.Texture | None = None,
1304    iconscale: float | None = None,
1305    icon_tint: float | None = None,
1306    icon_color: Sequence[float] | None = None,
1307    autoselect: bool | None = None,
1308    mask_texture: ba.Texture | None = None,
1309    tint_texture: ba.Texture | None = None,
1310    tint_color: Sequence[float] | None = None,
1311    tint2_color: Sequence[float] | None = None,
1312    text_flatness: float | None = None,
1313    text_res_scale: float | None = None,
1314    enabled: bool | None = None,
1315) -> ba.Widget:
1316
1317    """Create or edit a button widget.
1318
1319    Category: **User Interface Functions**
1320
1321    Pass a valid existing ba.Widget as 'edit' to modify it; otherwise
1322    a new one is created and returned. Arguments that are not set to None
1323    are applied to the Widget.
1324    """
1325    import ba  # pylint: disable=cyclic-import
1326
1327    return ba.Widget()

Create or edit a button 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.

Call = <class 'ba._general._Call'>
def cameraflash(duration: float = 999.0) -> None:
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.

def camerashake(intensity: float = 1.0) -> None:
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.

class Campaign:
 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

Campaign( name: str, sequential: bool = True, levels: list[ba.Level] | None = None)
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)
name: str

The name of the Campaign.

sequential: bool

Whether this Campaign's levels must be played in sequence.

def addlevel(self, level: ba.Level, index: int | None = None) -> None:
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.

levels: list[ba.Level]

The list of ba.Level-s in the Campaign.

def getlevel(self, name: str) -> ba.Level:
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.

def reset(self) -> None:
81    def reset(self) -> None:
82        """Reset state for the Campaign."""
83        _ba.app.config.setdefault('Campaigns', {})[self._name] = {}

Reset state for the Campaign.

def set_selected_level(self, levelname: str) -> None:
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).

def get_selected_level(self) -> str:
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)

Return the name of the Level currently selected in the UI.

configdict: dict[str, typing.Any]

Return the live config dict for this campaign.

@dataclass
class CelebrateMessage:
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

CelebrateMessage(duration: float = 10.0)
duration: float = 10.0

Amount of time to celebrate in seconds.

def charstr(char_id: ba.SpecialChar) -> str:
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.

def checkboxwidget( edit: ba.Widget | None = None, parent: ba.Widget | None = None, size: Optional[Sequence[float]] = None, position: Optional[Sequence[float]] = None, text: str | ba.Lstr | None = None, value: bool | None = None, on_value_change_call: Optional[Callable[[bool], NoneType]] = None, on_select_call: Optional[Callable[[], NoneType]] = None, text_scale: float | None = None, textcolor: Optional[Sequence[float]] = None, scale: float | None = None, is_radio_button: bool | None = None, maxwidth: float | None = None, autoselect: bool | None = None, color: Optional[Sequence[float]] = None) -> ba.Widget:
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.

@dataclass
class ChoiceSetting(ba.Setting):
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

ChoiceSetting(name: str, default: Any, choices: list[tuple[str, typing.Any]])
class Chooser:
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

Chooser( vpos: float, sessionplayer: ba.SessionPlayer, lobby: ba.Lobby)
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)
sessionplayer: ba.SessionPlayer

The ba.SessionPlayer associated with this chooser.

ready: bool

Whether this chooser is checked in as ready.

sessionteam: ba.SessionTeam

Return this chooser's currently selected ba.SessionTeam.

lobby: ba.Lobby

The chooser's ba.Lobby.

def get_lobby(self) -> ba.Lobby | None:
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.

def update_from_profile(self) -> 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.

def reload_profiles(self) -> None:
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.

def update_position(self) -> None:
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.

def get_character_name(self) -> str:
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.

def handlemessage(self, msg: Any) -> Any:
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.

def get_color(self) -> Sequence[float]:
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.

def get_highlight(self) -> Sequence[float]:
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.

def getplayer(self) -> ba.SessionPlayer:
865    def getplayer(self) -> ba.SessionPlayer:
866        """Return the player associated with this chooser."""
867        return self._sessionplayer

Return the player associated with this chooser.

def clipboard_get_text() -> str:
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.

def clipboard_has_text() -> bool:
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.

def clipboard_is_supported() -> bool:
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.

def clipboard_set_text(value: str) -> None:
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.

class CollideModel:
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.

CollideModel()
class Collision:
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

Collision()
position: ba.Vec3

The position of the current collision.

sourcenode: ba.Node

The node containing the material triggering the current callback.

Throws a ba.NodeNotFoundError if the node does not exist, though the node should always exist (at least at the start of the collision callback).

opposingnode: ba.Node

The node the current callback material node is hitting.

Throws a ba.NodeNotFoundError if the node does not exist. This can be expected in some cases such as in 'disconnect' callbacks triggered by deleting a currently-colliding node.

opposingbody: int

The body index on the opposing node in the current collision.

def columnwidget( edit: ba.Widget | None = None, parent: ba.Widget | None = None, size: Optional[Sequence[float]] = None, position: Optional[Sequence[float]] = None, background: bool | None = None, selected_child: ba.Widget | None = None, visible_child: ba.Widget | None = None, single_depth: bool | None = None, print_list_exit_instructions: bool | None = None, left_border: float | None = None, top_border: float | None = None, bottom_border: float | None = None, selection_loops_to_parent: bool | None = None, border: float | None = None, margin: float | None = None, claims_left_right: bool | None = None, claims_tab: bool | None = None) -> ba.Widget:
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.

def containerwidget( edit: ba.Widget | None = None, parent: ba.Widget | None = None, size: Optional[Sequence[float]] = None, position: Optional[Sequence[float]] = None, background: bool | None = None, selected_child: ba.Widget | None = None, transition: str | None = None, cancel_button: ba.Widget | None = None, start_button: ba.Widget | None = None, root_selectable: bool | None = None, on_activate_call: Optional[Callable[[], NoneType]] = None, claims_left_right: bool | None = None, claims_tab: bool | None = None, selection_loops: bool | None = None, selection_loops_to_parent: bool | None = None, scale: float | None = None, on_outside_click_call: Optional[Callable[[], NoneType]] = None, single_depth: bool | None = None, visible_child: ba.Widget | None = None, stack_offset: Optional[Sequence[float]] = None, color: Optional[Sequence[float]] = None, on_cancel_call: Optional[Callable[[], NoneType]] = None, print_list_exit_instructions: bool | None = None, click_activate: bool | None = None, always_highlight: bool | None = None, selectable: bool | None = None, scale_origin_stack_offset: Optional[Sequence[float]] = None, toolbar_visibility: str | None = None, on_select_call: Optional[Callable[[], NoneType]] = None, claim_outside_clicks: bool | None = None, claims_up_down: bool | None = None) -> ba.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.

class Context:
 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')
Context(source: Any)
148    def __init__(self, source: Any):
149        pass
class ContextCall:
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))
ContextCall(call: Callable)
202    def __init__(self, call: Callable):
203        pass
class ContextError(builtins.Exception):
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
class CloudSubsystem:
 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.

CloudSubsystem()
def is_connected(self) -> bool:
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.

def on_app_pause(self) -> None:
37    def on_app_pause(self) -> None:
38        """Should be called when the app pauses."""

Should be called when the app pauses.

def on_app_resume(self) -> None:
40    def on_app_resume(self) -> None:
41        """Should be called when the app resumes."""

Should be called when the app resumes.

def on_connectivity_changed(self, connected: bool) -> None:
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.

def send_message_cb( self, msg: efro.message.Message, on_response: Callable[[Any], NoneType]) -> None:
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.

def send_message(self, msg: efro.message.Message) -> efro.message.Response | None:
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.

class CoopGameActivity(ba.GameActivity[~PlayerType, ~TeamType]):
 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

CoopGameActivity(settings: dict)
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.

session: ba.Session

The ba.Session this ba.Activity belongs go.

Raises a ba.SessionNotFoundError if the Session no longer exists.

@classmethod
def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
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.

def on_begin(self) -> None:
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.

def get_score_type(self) -> str:
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.)

def celebrate(self, duration: float) -> None:
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.

def spawn_player_spaz( self, player: ~PlayerType, position: Sequence[float] = (0.0, 0.0, 0.0), angle: float | None = None) -> bastd.actor.playerspaz.PlayerSpaz:
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.

def fade_to_red(self) -> None:
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).

def setup_low_life_warning_sound(self) -> None:
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.

class CoopSession(ba.Session):
 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.

CoopSession()
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.

use_teams = True

Whether this session groups players into an explicit set of teams. If this is off, a unique team is generated for each player that joins.

use_team_colors = False

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.

campaign: ba.Campaign | None

The ba.Campaign instance this Session represents, or None if there is no associated Campaign.

def get_current_game_instance(self) -> ba.GameActivity:
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.

def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
 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.

def get_custom_menu_entries(self) -> list[dict[str, typing.Any]]:
167    def get_custom_menu_entries(self) -> list[dict[str, Any]]:
168        return self._custom_menu_ui

Subclasses can override this to provide custom menu entries.

The returned value should be a list of dicts, each containing a 'label' and 'call' entry, with 'label' being the text for the entry and 'call' being the callable to trigger if the entry is pressed.

def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None:
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.

def restart(self) -> None:
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.

def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
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.

class Data:
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()

A reference to a data object.

Category: Asset Classes

Use ba.getdata() to instantiate one.

Data()
def getvalue(self) -> Any:
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.

class DeathType(enum.Enum):
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

GENERIC = <DeathType.GENERIC: 'generic'>
OUT_OF_BOUNDS = <DeathType.OUT_OF_BOUNDS: 'out_of_bounds'>
IMPACT = <DeathType.IMPACT: 'impact'>
FALL = <DeathType.FALL: 'fall'>
REACHED_GOAL = <DeathType.REACHED_GOAL: 'reached_goal'>
LEFT_GAME = <DeathType.LEFT_GAME: 'left_game'>
Inherited Members
enum.Enum
name
value
class DelegateNotFoundError(ba.NotFoundError):
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
class Dependency(typing.Generic[~T]):
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().

Dependency(cls: type[~T], config: Any = None)
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.

def get_hash(self) -> int:
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.

class DependencyComponent:
 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

DependencyComponent()
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.

@classmethod
def dep_is_present(cls, config: Any = None) -> bool:
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.

@classmethod
def get_dynamic_deps(cls, config: Any = None) -> list[ba.Dependency]:
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)

class DependencyError(builtins.Exception):
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).

DependencyError(deps: list[ba.Dependency])
25    def __init__(self, deps: list[ba.Dependency]):
26        super().__init__()
27        self._deps = deps
deps: list[ba.Dependency]

The list of missing dependencies causing this error.

Inherited Members
builtins.BaseException
with_traceback
class DependencySet(typing.Generic[~T]):
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.

DependencySet(root_dependency: ba.Dependency[~T])
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] = {}
def resolve(self) -> None:
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).

resolved: bool

Whether this set has been successfully resolved.

def get_asset_package_ids(self) -> set[str]:
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.

def load(self) -> None:
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.

root: ~T

The instantiated root DependencyComponent instance for the set.

@dataclass
class DieMessage:
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."""

A message telling an object to die.

Category: Message Classes

Most ba.Actor-s respond to this.

DieMessage( immediate: bool = False, how: ba.DeathType = <DeathType.GENERIC: 'generic'>)
immediate: bool = False

If this is set to True, the actor should disappear immediately. This is for 'removing' stuff from the game more so than 'killing' it. If False, the actor should die a 'normal' death and can take its time with lingering corpses, sound effects, etc.

how: ba.DeathType = <DeathType.GENERIC: 'generic'>

The particular reason for death.

def do_once() -> bool:
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!')
@dataclass
class DropMessage:
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

DropMessage()
@dataclass
class DroppedMessage:
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

DroppedMessage(node: ba.Node)
node: ba.Node

The ba.Node doing the dropping.

class DualTeamSession(ba.MultiTeamSession):
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

DualTeamSession()
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.

use_teams = True

Whether this session groups players into an explicit set of teams. If this is off, a unique team is generated for each player that joins.

use_team_colors = True

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.

def emitfx( position: Sequence[float], velocity: Optional[Sequence[float]] = None, count: int = 10, scale: float = 1.0, spread: float = 1.0, chunk_type: str = 'rock', emit_type: str = 'chunks', tendril_type: str = 'smoke') -> None:
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.

class EmptyPlayer(ba.Player[ForwardRef('ba.EmptyTeam')]):
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.

EmptyPlayer()
class EmptyTeam(ba.Team[ForwardRef('ba.EmptyPlayer')]):
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.

EmptyTeam()
class Existable(typing.Protocol):
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

Existable(*args, **kwargs)
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)
def exists(self) -> bool:
29    def exists(self) -> bool:
30        """Whether this object exists."""

Whether this object exists.

def existing(obj: Optional[~ExistableT]) -> Optional[~ExistableT]:
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

@dataclass
class FloatChoiceSetting(ba.ChoiceSetting):
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

FloatChoiceSetting(name: str, default: float, choices: list[tuple[str, float]])
@dataclass
class FloatSetting(ba.Setting):
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

FloatSetting( name: str, default: float, min_value: float = 0.0, max_value: float = 9999.0, increment: float = 1.0)
class FreeForAllSession(ba.MultiTeamSession):
 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

FreeForAllSession()
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.

use_teams = False

Whether this session groups players into an explicit set of teams. If this is off, a unique team is generated for each player that joins.

use_team_colors = False

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.

def get_ffa_point_awards(self) -> dict[int, int]:
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.

@dataclass
class FreezeMessage:
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.

FreezeMessage()
class GameActivity(ba.Activity[~PlayerType, ~TeamType]):
  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

GameActivity(settings: dict)
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.

allow_pausing = True

Whether game-time should still progress when in menus/etc.

allow_kick_idle_players = True

Whether idle players can potentially be kicked (should not happen in menus/etc).

@classmethod
def create_settings_ui( cls, sessiontype: type[ba.Session], settings: dict | None, completion_call: Callable[[dict | None], NoneType]) -> None:
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.

@classmethod
def getscoreconfig(cls) -> ba.ScoreConfig:
 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.

@classmethod
def getname(cls) -> str:
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.

@classmethod
def get_display_string(cls, settings: dict | None = None) -> ba.Lstr:
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.

@classmethod
def get_team_display_string(cls, name: str) -> ba.Lstr:
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.

@classmethod
def get_description(cls, sessiontype: type[ba.Session]) -> str:
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.

@classmethod
def get_description_display_string(cls, sessiontype: type[ba.Session]) -> ba.Lstr:
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.

@classmethod
def get_available_settings( cls, sessiontype: type[ba.Session]) -> list[ba.Setting]:
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.

@classmethod
def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]:
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.

@classmethod
def get_settings_display_string(cls, config: dict[str, typing.Any]) -> ba.Lstr:
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.

@classmethod
def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
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.

map: ba.Map

The map being used for this game.

Raises a ba.MapNotFoundError if the map does not currently exist.

def get_instance_display_string(self) -> ba.Lstr:
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.

def get_instance_scoreboard_display_string(self) -> ba.Lstr:
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.

def get_instance_description(self) -> Union[str, Sequence]:
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.

def get_instance_description_short(self) -> Union[str, Sequence]:
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.

def on_transition_in(self) -> None:
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.

def on_continue(self) -> None:
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.

def is_waiting_for_continue(self) -> bool:
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)

def continue_or_end_game(self) -> None:
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

def on_begin(self) -> None:
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.

def on_player_join(self, player: ~PlayerType) -> None:
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)

def handlemessage(self, msg: Any) -> Any:
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.

def end( self, results: Any = None, delay: float = 0.0, force: bool = False) -> None:
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.

def end_game(self) -> None:
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.

def respawn_player(self, player: ~PlayerType, respawn_time: float | None = None) -> None:
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).

def spawn_player_if_exists(self, player: ~PlayerType) -> None:
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().

def spawn_player(self, player: ~PlayerType) -> ba.Actor:
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().

def spawn_player_spaz( self, player: ~PlayerType, position: Sequence[float] = (0, 0, 0), angle: float | None = None) -> bastd.actor.playerspaz.PlayerSpaz:
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.

def setup_standard_powerup_drops(self, enable_tnt: bool = True) -> None:
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.

def setup_standard_time_limit(self, duration: float) -> None:
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.

def show_zoom_message( self, message: ba.Lstr, color: Sequence[float] = (0.9, 0.4, 0.0), scale: float = 0.8, duration: float = 2.0, trail: bool = False) -> None:
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.

class GameResults:
 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.

GameResults()
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
def set_game(self, game: ba.GameActivity) -> 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.

def set_team_score(self, team: ba.Team, score: int | None) -> None:
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)

def get_sessionteam_score(self, sessionteam: ba.SessionTeam) -> int | None:
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.

sessionteams: list[ba.SessionTeam]

Return all ba.SessionTeams in the results.

def has_score_for_sessionteam(self, sessionteam: ba.SessionTeam) -> bool:
 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.

def get_sessionteam_score_str(self, sessionteam: ba.SessionTeam) -> ba.Lstr:
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.)

playerinfos: list[ba.PlayerInfo]

Get info about the players represented by the results.

scoretype: ba.ScoreType

The type of score.

score_label: str

The label associated with scores ('points', etc).

lower_is_better: bool

Whether lower scores are better.

winning_sessionteam: ba.SessionTeam | None

The winning ba.SessionTeam if there is exactly one, or else None.

winnergroups: list[ba._gameresults.WinnerGroup]

Get an ordered list of winner groups.

@dataclass
class GameTip:
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

GameTip( text: str, icon: ba.Texture | None = None, sound: ba.Sound | None = None)
def garbage_collect() -> None:
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().

def getactivity(doraise: bool = True) -> ba.Activity | None:
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.

def getclass(name: str, subclassof: type[~T]) -> type[~T]:
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.

def getcollidemodel(name: str) -> ba.CollideModel:
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.

def getcollision() -> ba.Collision:
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

def getdata(name: str) -> ba.Data:
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.

def getmaps(playtype: str) -> list[str]:
 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.

def getmodel(name: str) -> ba.Model:
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.

def getnodes() -> list:
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

def getsession(doraise: bool = True) -> ba.Session | None:
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.

def getsound(name: str) -> ba.Sound:
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.

def gettexture(name: str) -> ba.Texture:
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.

class HitMessage:
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.

HitMessage( srcnode: ba.Node | None = None, pos: Optional[Sequence[float]] = None, velocity: Optional[Sequence[float]] = None, magnitude: float = 1.0, velocity_magnitude: float = 0.0, radius: float = 1.0, source_player: ba.Player | None = None, kick_back: float = 1.0, flat_damage: float | None = None, hit_type: str = 'generic', force_direction: Optional[Sequence[float]] = None, hit_subtype: str = 'default')
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.

def get_source_player(self, playertype: type[~PlayerType]) -> Optional[~PlayerType]:
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.

def hscrollwidget( edit: ba.Widget | None = None, parent: ba.Widget | None = None, size: Optional[Sequence[float]] = None, position: Optional[Sequence[float]] = None, background: bool | None = None, selected_child: ba.Widget | None = None, capture_arrows: bool | None = None, on_select_call: Optional[Callable[[], NoneType]] = None, center_small_content: bool | None = None, color: Optional[Sequence[float]] = None, highlight: bool | None = None, border_opacity: float | None = None, simple_culling_h: float | None = None, claims_left_right: bool | None = None, claims_up_down: bool | None = None, claims_tab: bool | None = None) -> ba.Widget:
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.

def imagewidget( edit: ba.Widget | None = None, parent: ba.Widget | None = None, size: Optional[Sequence[float]] = None, position: Optional[Sequence[float]] = None, color: Optional[Sequence[float]] = None, texture: ba.Texture | None = None, opacity: float | None = None, model_transparent: ba.Model | None = None, model_opaque: ba.Model | None = None, has_alpha_channel: bool = True, tint_texture: ba.Texture | None = None, tint_color: Optional[Sequence[float]] = None, transition_delay: float | None = None, draw_controller: ba.Widget | None = None, tint2_color: Optional[Sequence[float]] = None, tilt_scale: float | None = None, mask_texture: ba.Texture | None = None, radial_amount: float | None = None) -> ba.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.

@dataclass
class ImpactDamageMessage:
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

ImpactDamageMessage(intensity: float)
intensity: float

The intensity of the impact.

class InputDevice:
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

InputDevice()
allows_configuring: bool

Whether the input-device can be configured.

has_meaningful_button_names: bool

Whether button names returned by this instance match labels on the actual device. (Can be used to determine whether to show them in controls-overlays, etc.).

player: ba.SessionPlayer | None

The player associated with this input device.

client_id: int

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.

name: str

The name of the device.

unique_identifier: str

A string that can be used to persistently identify the device, even among other devices of the same type. Used for saving prefs, etc.

id: int

The unique numeric id of this device.

instance_number: int

The number of this device among devices of the same type.

is_controller_app: bool

Whether this input-device represents a locally-connected controller-app.

is_remote_client: bool

Whether this input-device represents a remotely-connected client.

def exists(self) -> bool:
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.

def get_axis_name(self, axis_id: int) -> str:
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.

def get_button_name(self, button_id: int) -> ba.Lstr:
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='')

Given a button ID, return a human-readable name for that key/button.

Can return an empty string if the value is not meaningful to humans.

def get_v1_account_name(self, full: bool) -> str:
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)

class InputDeviceNotFoundError(ba.NotFoundError):
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
class InputType(enum.Enum):
 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

UP_DOWN = <InputType.UP_DOWN: 2>
LEFT_RIGHT = <InputType.LEFT_RIGHT: 3>
JUMP_PRESS = <InputType.JUMP_PRESS: 4>
JUMP_RELEASE = <InputType.JUMP_RELEASE: 5>
PUNCH_PRESS = <InputType.PUNCH_PRESS: 6>
PUNCH_RELEASE = <InputType.PUNCH_RELEASE: 7>
BOMB_PRESS = <InputType.BOMB_PRESS: 8>
BOMB_RELEASE = <InputType.BOMB_RELEASE: 9>
PICK_UP_PRESS = <InputType.PICK_UP_PRESS: 10>
PICK_UP_RELEASE = <InputType.PICK_UP_RELEASE: 11>
RUN = <InputType.RUN: 12>
FLY_PRESS = <InputType.FLY_PRESS: 13>
FLY_RELEASE = <InputType.FLY_RELEASE: 14>
START_PRESS = <InputType.START_PRESS: 15>
START_RELEASE = <InputType.START_RELEASE: 16>
HOLD_POSITION_PRESS = <InputType.HOLD_POSITION_PRESS: 17>
HOLD_POSITION_RELEASE = <InputType.HOLD_POSITION_RELEASE: 18>
LEFT_PRESS = <InputType.LEFT_PRESS: 19>
LEFT_RELEASE = <InputType.LEFT_RELEASE: 20>
RIGHT_PRESS = <InputType.RIGHT_PRESS: 21>
RIGHT_RELEASE = <InputType.RIGHT_RELEASE: 22>
UP_PRESS = <InputType.UP_PRESS: 23>
UP_RELEASE = <InputType.UP_RELEASE: 24>
DOWN_PRESS = <InputType.DOWN_PRESS: 25>
DOWN_RELEASE = <InputType.DOWN_RELEASE: 26>
Inherited Members
enum.Enum
name
value
@dataclass
class IntChoiceSetting(ba.ChoiceSetting):
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

IntChoiceSetting(name: str, default: int, choices: list[tuple[str, int]])
@dataclass
class IntSetting(ba.Setting):
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

IntSetting( name: str, default: int, min_value: int = 0, max_value: int = 9999, increment: int = 1)
def is_browser_likely_available() -> bool:
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.)

def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool:
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).

class Keyboard:
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.

Keyboard()
name: str

Displays when user selecting this keyboard.

chars: list[tuple[str, ...]]

Used for row/column lengths.

pages: dict[str, tuple[str, ...]]

Extra chars like emojis.

nums: tuple[str, ...]

The 'num' page.

class LanguageSubsystem:
 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'.

LanguageSubsystem()
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()
locale: str

Raw country/language code detected by the game (such as 'en_US').

Generally for language-specific code you should look at ba.App.language, which is the language the game is using (which may differ from locale if the user sets a language, etc.)

language: str

The name of the language the game is running in.

This can be selected explicitly by the user or may be set automatically based on ba.App.locale or other factors.

available_languages: list[str]

A list of all available languages.

Note that languages that may be present in game assets but which are not displayable on the running version of the game are not included here.

def setlanguage( self, language: str | None, print_change: bool = True, store_to_config: bool = True) -> None:
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.

def get_resource( self, resource: str, fallback_resource: str | None = None, fallback_value: Any = None) -> Any:
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.

def translate( self, category: str, strval: str, raise_exceptions: bool = False, print_errors: bool = False) -> str:
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.

def is_custom_unicode_char(self, char: str) -> bool:
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.

class Level:
 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

Level( name: str, gametype: type[ba.GameActivity], settings: dict, preview_texture_name: str, displayname: str | None = None)
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
name: str

The unique name for this Level.

def get_settings(self) -> dict[str, typing.Any]:
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.

preview_texture_name: str

The preview texture name for this Level.

def get_preview_texture(self) -> ba.Texture:
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.

displayname: ba.Lstr

The localized name for this Level.

gametype: type[ba.GameActivity]

The type of game used for this Level.

campaign: ba.Campaign | None

The ba.Campaign this Level is associated with, or None.

index: int

The zero-based index of this Level in its ba.Campaign.

Access results in a RuntimeError if the Level is not assigned to a Campaign.

complete: bool

Whether this Level has been completed.

def set_complete(self, val: bool) -> None:
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.

def get_high_scores(self) -> dict:
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.

def set_high_scores(self, high_scores: dict) -> None:
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.

def get_score_version_string(self) -> str:
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.

rating: float

The current rating for this Level.

def set_rating(self, rating: float) -> None:
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.

class Lobby:
 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

Lobby()
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
use_team_colors: bool

A bool for whether this lobby is using team colors.

If False, inidividual player colors are used instead.

sessionteams: list[ba.SessionTeam]

ba.SessionTeams available in this lobby.

def get_choosers(self) -> list[ba.Chooser]:
989    def get_choosers(self) -> list[Chooser]:
990        """Return the lobby's current choosers."""
991        return self.choosers

Return the lobby's current choosers.

def create_join_info(self) -> ba._lobby.JoinInfo:
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.

def reload_profiles(self) -> None:
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.

def update_positions(self) -> None:
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.

def check_all_ready(self) -> bool:
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.

def add_chooser(self, sessionplayer: ba.SessionPlayer) -> None:
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.

def remove_chooser(self, player: ba.SessionPlayer) -> None:
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.

def remove_all_choosers(self) -> None:
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.

def remove_all_choosers_and_kick_players(self) -> None:
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.

class Lstr:
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

>>> mytextnode.text = ba.Lstr(
...     resource='res_a',
...     subs=[('${NAME}', ba.Lstr(resource='res_b'))])
Lstr(*args: Any, **keywds: Any)
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).

def evaluate(self) -> str:
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.

def is_flat_value(self) -> bool:
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.

@staticmethod
def from_json(json_string: str) -> ba.Lstr:
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

Given a json string, returns a ba.Lstr. Does no data validation.

class Map(ba.Actor):
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.

Map(vr_overlay_offset: Optional[Sequence[float]] = None)
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.

@classmethod
def preload(cls) -> None:
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

@classmethod
def get_play_types(cls) -> list[str]:
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.

@classmethod
def get_preview_texture_name(cls) -> str | None:
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.

@classmethod
def on_preload(cls) -> Any:
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

@classmethod
def getname(cls) -> str:
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.

@classmethod
def get_music_type(cls) -> ba.MusicType | None:
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.

def is_point_near_edge(self, point: ba.Vec3, running: bool = False) -> bool:
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.

def get_def_bound_box( self, name: str) -> tuple[float, float, float, float, float, float] | None:
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.

def get_def_point(self, name: str) -> Optional[Sequence[float]]:
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.

def get_def_points(self, name: str) -> list[typing.Sequence[float]]:
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.

def get_start_position(self, team_index: int) -> Sequence[float]:
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.

def get_ffa_start_position(self, players: Sequence[ba.Player]) -> Sequence[float]:
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.

def get_flag_position(self, team_index: int | None = None) -> Sequence[float]:
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)

def exists(self) -> bool:
420    def exists(self) -> bool:
421        return bool(self.node)

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.

def handlemessage(self, msg: Any) -> Any:
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.

class MapNotFoundError(ba.NotFoundError):
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
class Material:
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.

Material(label: str | None = None)
362    def __init__(self, label: str | None = None):
363        pass
label: str

A label for the material; only used for debugging.

def add_actions(self, actions: tuple, conditions: tuple | None = None) -> None:
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 the modify_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', and message_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; if modify_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'. Using modify_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)))
class MetadataSubsystem:
 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'.

MetadataSubsystem()
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
def start_scan(self, scan_complete_cb: Callable[[], NoneType]) -> 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.

def start_extra_scan(self) -> None:
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.

def load_exported_classes( self, cls: type[~T], completion_cb: Callable[[list[type[~T]]], NoneType], completion_cb_in_bg_thread: bool = False) -> None:
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.

class Model:
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.

Model()
class MultiTeamSession(ba.Session):
 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.

MultiTeamSession()
 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.

def get_ffa_series_length(self) -> int:
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.

def get_series_length(self) -> int:
135    def get_series_length(self) -> int:
136        """Return teams series length."""
137        return self._series_length

Return teams series length.

def get_next_game_description(self) -> ba.Lstr:
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.

def get_game_number(self) -> int:
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.

def on_team_join(self, team: ba.SessionTeam) -> None:
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.

def get_max_players(self) -> int:
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

def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
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.

def announce_game_results( self, activity: ba.GameActivity, results: ba.GameResults, delay: float, announce_winning_team: bool = True) -> None:
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.

class MusicPlayer:
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.

MusicPlayer()
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
def select_entry( self, callback: Callable[[Any], NoneType], current_entry: Any, selection_target_name: str) -> Any:
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.

def set_volume(self, volume: float) -> None:
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).

def play(self, entry: Any) -> None:
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.

def stop(self) -> None:
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.

def shutdown(self) -> None:
456    def shutdown(self) -> None:
457        """Shutdown music playback completely."""
458        self.on_app_shutdown()

Shutdown music playback completely.

def on_select_entry( self, callback: Callable[[Any], NoneType], current_entry: Any, selection_target_name: str) -> Any:
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..

def on_set_volume(self, volume: float) -> None:
473    def on_set_volume(self, volume: float) -> None:
474        """Called when the volume should be changed."""

Called when the volume should be changed.

def on_play(self, entry: Any) -> None:
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.

def on_stop(self) -> None:
479    def on_stop(self) -> None:
480        """Called when the music should stop."""

Called when the music should stop.

def on_app_shutdown(self) -> None:
482    def on_app_shutdown(self) -> None:
483        """Called on final app shutdown."""

Called on final app shutdown.

class MusicPlayMode(enum.Enum):
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

REGULAR = <MusicPlayMode.REGULAR: 'regular'>
TEST = <MusicPlayMode.TEST: 'test'>
Inherited Members
enum.Enum
name
value
class MusicSubsystem:
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'.

MusicSubsystem()
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
def on_app_launch(self) -> None:
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().

def on_app_shutdown(self) -> None:
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.

def have_music_player(self) -> bool:
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.

def get_music_player(self) -> ba.MusicPlayer:
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.

def music_volume_changed(self, val: float) -> None:
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.

def set_music_play_mode(self, mode: ba.MusicPlayMode, force_restart: bool = False) -> None:
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.

def supports_soundtrack_entry_type(self, entry_type: str) -> bool:
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.

def get_soundtrack_entry_type(self, entry: Any) -> str:
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.

def get_soundtrack_entry_name(self, entry: Any) -> str:
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.

def on_app_resume(self) -> None:
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.

def do_play_music( self, musictype: ba.MusicType | str | None, continuous: bool = False, mode: ba.MusicPlayMode = <MusicPlayMode.REGULAR: 'regular'>, testsoundtrack: dict[str, typing.Any] | None = None) -> None:
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.

class MusicType(enum.Enum):
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.

MENU = <MusicType.MENU: 'Menu'>
VICTORY = <MusicType.VICTORY: 'Victory'>
CHAR_SELECT = <MusicType.CHAR_SELECT: 'CharSelect'>
RUN_AWAY = <MusicType.RUN_AWAY: 'RunAway'>
ONSLAUGHT = <MusicType.ONSLAUGHT: 'Onslaught'>
KEEP_AWAY = <MusicType.KEEP_AWAY: 'Keep Away'>
RACE = <MusicType.RACE: 'Race'>
EPIC_RACE = <MusicType.EPIC_RACE: 'Epic Race'>
SCORES = <MusicType.SCORES: 'Scores'>
GRAND_ROMP = <MusicType.GRAND_ROMP: 'GrandRomp'>
TO_THE_DEATH = <MusicType.TO_THE_DEATH: 'ToTheDeath'>
CHOSEN_ONE = <MusicType.CHOSEN_ONE: 'Chosen One'>
FORWARD_MARCH = <MusicType.FORWARD_MARCH: 'ForwardMarch'>
FLAG_CATCHER = <MusicType.FLAG_CATCHER: 'FlagCatcher'>
SURVIVAL = <MusicType.SURVIVAL: 'Survival'>
EPIC = <MusicType.EPIC: 'Epic'>
SPORTS = <MusicType.SPORTS: 'Sports'>
HOCKEY = <MusicType.HOCKEY: 'Hockey'>
FOOTBALL = <MusicType.FOOTBALL: 'Football'>
FLYING = <MusicType.FLYING: 'Flying'>
SCARY = <MusicType.SCARY: 'Scary'>
MARCHING = <MusicType.MARCHING: 'Marching'>
Inherited Members
enum.Enum
name
value
def newactivity( activity_type: type[ba.Activity], settings: dict | None = None) -> ba.Activity:
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.

def newnode( type: str, owner: ba.Node | None = None, attrs: dict | None = None, name: str | None = None, delegate: Any = None) -> ba.Node:
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

class Node:
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).

Node()
def add_death_action(self, action: Callable[[], NoneType]) -> None:
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.

def connectattr(self, srcattr: str, dstnode: ba.Node, dstattr: str) -> None:
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')
def delete(self, ignore_missing: bool = True) -> None:
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.

def exists(self) -> bool:
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.

def getdelegate(self, type: Any, doraise: bool = False) -> Any:
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.

def getname(self) -> str:
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

def getnodetype(self) -> str:
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)

def handlemessage(self, *args: Any) -> None:
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.

class NodeActor(ba.Actor):
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.

NodeActor(node: ba.Node)
27    def __init__(self, node: ba.Node):
28        super().__init__()
29        self.node = node

Instantiates an Actor in the current ba.Activity.

def handlemessage(self, msg: Any) -> Any:
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.

def exists(self) -> bool:
38    def exists(self) -> bool:
39        return bool(self.node)

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.

class NodeNotFoundError(ba.NotFoundError):
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
def normalized_color(color: Sequence[float]) -> tuple[float, ...]:
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

class NotFoundError(builtins.Exception):
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
def open_url(address: str, force_internal: bool = False) -> None:
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).

@dataclass
class OutOfBoundsMessage:
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

OutOfBoundsMessage()
class Permission(enum.Enum):
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

STORAGE = <Permission.STORAGE: 0>
Inherited Members
enum.Enum
name
value
@dataclass
class PickedUpMessage:
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

PickedUpMessage(node: ba.Node)
node: ba.Node

The ba.Node doing the picking up.

@dataclass
class PickUpMessage:
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

PickUpMessage(node: ba.Node)
node: ba.Node

The ba.Node that is getting picked up.

class Player(typing.Generic[~TeamType]):
 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.

Player()
actor: ba.Actor | None

The ba.Actor associated with the player.

def on_expire(self) -> None:
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.

team: ~TeamType

The ba.Team for this player.

customdata: dict

Arbitrary values associated with the player. Though it is encouraged that most player values be properly defined on the ba.Player subclass, it may be useful for player-agnostic objects to store values here. This dict is cleared when the player leaves or expires so objects stored here will be disposed of at the expected time, unlike the Player instance itself which may continue to be referenced after it is no longer part of the game.

sessionplayer: ba.SessionPlayer

Return the ba.SessionPlayer corresponding to this Player.

Throws a ba.SessionPlayerNotFoundError if it does not exist.

node: ba.Node

A ba.Node of type 'player' associated with this Player.

This node can be used to get a generic player position/etc.

position: ba.Vec3

The position of the player, as defined by its current ba.Actor.

If the player currently has no actor, raises a ba.ActorNotFoundError.

def exists(self) -> bool:
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.

def getname(self, full: bool = False, icon: bool = True) -> str:
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.

def is_alive(self) -> bool:
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.

def get_icon(self) -> dict[str, typing.Any]:
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)

def assigninput( self, inputtype: ba.InputType | tuple[ba.InputType, ...], call: Callable) -> None:
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.

def resetinput(self) -> None:
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()

Clears out the player's assigned input actions.

class PlayerDiedMessage:
 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

PlayerDiedMessage( player: ba.Player, was_killed: bool, killerplayer: ba.Player | None, how: ba.DeathType)
 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.

killed: bool

If True, the player was killed; If False, they left the game or the round ended.

The particular type of death.

def getkillerplayer(self, playertype: type[~PlayerType]) -> Optional[~PlayerType]:
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.

def getplayer(self, playertype: type[~PlayerType]) -> ~PlayerType:
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.

@dataclass
class PlayerInfo:
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

PlayerInfo(name: str, character: str)
class PlayerNotFoundError(ba.NotFoundError):
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
class PlayerRecord:
 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)

PlayerRecord( name: str, name_full: str, sessionplayer: ba.SessionPlayer, stats: ba.Stats)
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.

def getname(self, full: bool = False) -> str:
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.

def get_icon(self) -> dict[str, typing.Any]:
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.

def cancel_multi_kill_timer(self) -> None:
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.

def getactivity(self) -> ba.Activity | None:
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.

def associate_with_sessionplayer(self, sessionplayer: ba.SessionPlayer) -> None:
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.

def get_last_sessionplayer(self) -> 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.

def submit_kill(self, showpoints: bool = True) -> None:
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.

@dataclass
class PlayerScoredMessage:
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

PlayerScoredMessage(score: int)
score: int

The score value.

def playsound( sound: ba.Sound, volume: float = 1.0, position: Optional[Sequence[float]] = None, host_only: bool = False) -> None:
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.

class Plugin:
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.

Plugin()
def on_app_running(self) -> None:
225    def on_app_running(self) -> None:
226        """Called when the app reaches the running state."""

Called when the app reaches the running state.

def on_app_pause(self) -> None:
228    def on_app_pause(self) -> None:
229        """Called after pausing game activity."""

Called after pausing game activity.

def on_app_resume(self) -> None:
231    def on_app_resume(self) -> None:
232        """Called after the game continues."""

Called after the game continues.

def on_app_shutdown(self) -> None:
234    def on_app_shutdown(self) -> None:
235        """Called before closing the application."""

Called before closing the application.

def has_settings_ui(self) -> bool:
237    def has_settings_ui(self) -> bool:
238        """Called to ask if we have settings UI we can show."""
239        return False

Called to ask if we have settings UI we can show.

def show_settings_ui(self, source_widget: ba.Widget | None) -> None:
241    def show_settings_ui(self, source_widget: ba.Widget | None) -> None:
242        """Called to show our settings UI."""

Called to show our settings UI.

class PluginSubsystem:
 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.

PluginSubsystem()
29    def __init__(self) -> None:
30        self.potential_plugins: list[ba.PotentialPlugin] = []
31        self.active_plugins: dict[str, ba.Plugin] = {}
def on_meta_scan_complete(self) -> None:
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.

def on_app_running(self) -> None:
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.

def on_app_pause(self) -> None:
 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.

def on_app_resume(self) -> None:
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.

def on_app_shutdown(self) -> None:
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.

@dataclass
class PotentialPlugin:
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.

PotentialPlugin(display_name: ba.Lstr, class_path: str, available: bool)
@dataclass
class PowerupAcceptMessage:
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.

PowerupAcceptMessage()
@dataclass
class PowerupMessage:
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.

PowerupMessage(poweruptype: str, sourcenode: ba.Node | None = None)
poweruptype: str

The type of powerup to be granted (a string). See ba.Powerup.poweruptype for available type values.

sourcenode: ba.Node | None = None

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.

def printnodes() -> None:
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

def ls_objects() -> None:
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.

def ls_input_devices() -> None:
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.

def pushcall( call: Callable, from_other_thread: bool = False, suppress_other_thread_warning: bool = False, other_thread_use_fg_context: bool = False, raw: bool = False) -> None:
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.

def quit(soft: bool = False, back: bool = False) -> None:
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.

def rowwidget( edit: ba.Widget | None = None, parent: ba.Widget | None = None, size: Optional[Sequence[float]] = None, position: Optional[Sequence[float]] = None, background: bool | None = None, selected_child: ba.Widget | None = None, visible_child: ba.Widget | None = None, claims_left_right: bool | None = None, claims_tab: bool | None = None, selection_loops_to_parent: bool | None = None) -> ba.Widget:
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.

def safecolor( color: Sequence[float], target_intensity: float = 0.6) -> tuple[float, ...]:
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.

@dataclass
class ScoreConfig:
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

ScoreConfig( label: str = 'Score', scoretype: ba.ScoreType = <ScoreType.POINTS: 'p'>, lower_is_better: bool = False, none_is_winner: bool = False, version: str = '')
label: str = 'Score'

A label show to the user for scores; 'Score', 'Time Survived', etc.

scoretype: ba.ScoreType = <ScoreType.POINTS: 'p'>

How the score value should be displayed.

lower_is_better: bool = False

Whether lower scores are preferable. Higher scores are by default.

none_is_winner: bool = False

Whether a value of None is considered better than other scores. By default it is not.

version: str = ''

To change high-score lists used by a game without renaming the game, change this. Defaults to an empty string.

@unique
class ScoreType(enum.Enum):
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

SECONDS = <ScoreType.SECONDS: 's'>
MILLISECONDS = <ScoreType.MILLISECONDS: 'ms'>
POINTS = <ScoreType.POINTS: 'p'>
Inherited Members
enum.Enum
name
value
def screenmessage( message: str | ba.Lstr, color: Optional[Sequence[float]] = None, top: bool = False, image: dict[str, typing.Any] | None = None, log: bool = False, clients: Optional[Sequence[int]] = None, transient: bool = False) -> None:
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.

def scrollwidget( edit: ba.Widget | None = None, parent: ba.Widget | None = None, size: Optional[Sequence[float]] = None, position: Optional[Sequence[float]] = None, background: bool | None = None, selected_child: ba.Widget | None = None, capture_arrows: bool = False, on_select_call: Optional[Callable] = None, center_small_content: bool | None = None, color: Optional[Sequence[float]] = None, highlight: bool | None = None, border_opacity: float | None = None, simple_culling_v: float | None = None, selection_loops_to_parent: bool | None = None, claims_left_right: bool | None = None, claims_up_down: bool | None = None, claims_tab: bool | None = None, autoselect: bool | None = None) -> ba.Widget:
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.

class ServerController:
 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

ServerController(config: bacommon.servermanager.ServerConfig)
 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            )
def print_client_list(self) -> None:
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.

def kick(self, client_id: int, ban_time: int | None) -> None:
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.

def shutdown( self, reason: bacommon.servermanager.ShutdownReason, immediate: bool) -> None:
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.

def handle_transition(self) -> bool:
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.

class Session:
 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).

Session( depsets: Sequence[ba.DependencySet], team_names: Optional[Sequence[str]] = None, team_colors: Optional[Sequence[Sequence[float]]] = None, min_players: int = 1, max_players: int = 8)
 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.

use_teams: bool = False

Whether this session groups players into an explicit set of teams. If this is off, a unique team is generated for each player that joins.

use_team_colors: bool = True

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.

lobby: ba.Lobby

The ba.Lobby instance where new ba.Player-s go to select a Profile/Team/etc. before being added to games. Be aware this value may be None if a Session does not allow any such selection.

max_players: int

The maximum number of players allowed in the Session.

min_players: int

The minimum number of players who must be present for the Session to proceed past the initial joining screen

sessionplayers: list[ba.SessionPlayer]

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.

customdata: dict

A shared dictionary for objects to use as storage on this session. Ensure that keys here are unique to avoid collisions.

sessionteams: list[ba.SessionTeam]

All the ba.SessionTeams in the Session. Most things should use the list of ba.Team-s in ba.Activity; not this.

sessionglobalsnode: ba.Node

The sessionglobals ba.Node for the session.

def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
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.

def on_player_request(self, player: ba.SessionPlayer) -> bool:
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.

def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None:
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.

def end(self) -> None:
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.

def on_team_join(self, team: ba.SessionTeam) -> None:
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.

def on_team_leave(self, team: ba.SessionTeam) -> None:
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.

def end_activity( self, activity: ba.Activity, results: Any, delay: float, force: bool) -> None:
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.

def handlemessage(self, msg: Any) -> Any:
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.

def setactivity(self, activity: ba.Activity) -> None:
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)

def getactivity(self) -> ba.Activity | None:
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.

def get_custom_menu_entries(self) -> list[dict[str, typing.Any]]:
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 []

Subclasses can override this to provide custom menu entries.

The returned value should be a list of dicts, each containing a 'label' and 'call' entry, with 'label' being the text for the entry and 'call' being the callable to trigger if the entry is pressed.

def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
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.

def begin_next_activity(self) -> None:
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

class SessionNotFoundError(ba.NotFoundError):
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
class SessionPlayer:
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.Nodes, 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.

SessionPlayer()
id: int

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.

in_game: bool

This bool value will be True once the Player has completed any lobby character/team selection.

sessionteam: ba.SessionTeam

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.

inputdevice: ba.InputDevice

The input device associated with the player.

color: Sequence[float]

The base color for this Player. In team games this will match the ba.SessionTeam's color.

highlight: Sequence[float]

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.

character: str

The character this player has selected in their profile.

activityplayer: ba.Player | None

The current game-specific instance for this player.

def assigninput( self, type: ba.InputType | tuple[ba.InputType, ...], call: Callable) -> None:
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.

def exists(self) -> bool:
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.

def get_icon(self) -> dict[str, typing.Any]:
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.

def get_v1_account_id(self) -> str:
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).

def getname(self, full: bool = False, icon: bool = True) -> str:
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.

def remove_from_game(self) -> None:
883    def remove_from_game(self) -> None:
884
885        """Removes the player from the game."""
886        return None

Removes the player from the game.

def resetinput(self) -> None:
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.

def setname(self, name: str, full_name: str | None = None, real: bool = True) -> None:
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.

class SessionPlayerNotFoundError(ba.NotFoundError):
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
class SessionTeam:
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.

SessionTeam( team_id: int = 0, name: ba.Lstr | str = '', color: Sequence[float] = (1.0, 1.0, 1.0))
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.

name: ba.Lstr | str

The team's name.

color: tuple[float, ...]

The team's color.

players: list[ba.SessionPlayer]

The list of ba.SessionPlayer-s on the team.

customdata: dict

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.

id: int

The unique numeric id of the team.

class SessionTeamNotFoundError(ba.NotFoundError):
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
def set_analytics_screen(screen: str) -> None:
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.)

def setmusic(musictype: ba.MusicType | None, continuous: bool = False) -> None:
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.

@dataclass
class Setting:
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

Setting(name: str, default: Any)
@dataclass
class ShouldShatterMessage:
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

ShouldShatterMessage()
def show_damage_count( damage: str, position: Sequence[float], direction: Sequence[float]) -> None:
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

class Sound:
936class Sound:
937
938    """A reference to a sound.
939
940    Category: **Asset Classes**
941
942    Use ba.getsound() to instantiate one.
943    """
944
945    pass

A reference to a sound.

Category: Asset Classes

Use ba.getsound() to instantiate one.

Sound()
class SpecialChar(enum.Enum):
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

DOWN_ARROW = <SpecialChar.DOWN_ARROW: 0>
UP_ARROW = <SpecialChar.UP_ARROW: 1>
LEFT_ARROW = <SpecialChar.LEFT_ARROW: 2>
RIGHT_ARROW = <SpecialChar.RIGHT_ARROW: 3>
TOP_BUTTON = <SpecialChar.TOP_BUTTON: 4>
LEFT_BUTTON = <SpecialChar.LEFT_BUTTON: 5>
RIGHT_BUTTON = <SpecialChar.RIGHT_BUTTON: 6>
BOTTOM_BUTTON = <SpecialChar.BOTTOM_BUTTON: 7>
DELETE = <SpecialChar.DELETE: 8>
SHIFT = <SpecialChar.SHIFT: 9>
BACK = <SpecialChar.BACK: 10>
LOGO_FLAT = <SpecialChar.LOGO_FLAT: 11>
REWIND_BUTTON = <SpecialChar.REWIND_BUTTON: 12>
PLAY_PAUSE_BUTTON = <SpecialChar.PLAY_PAUSE_BUTTON: 13>
FAST_FORWARD_BUTTON = <SpecialChar.FAST_FORWARD_BUTTON: 14>
DPAD_CENTER_BUTTON = <SpecialChar.DPAD_CENTER_BUTTON: 15>
OUYA_BUTTON_O = <SpecialChar.OUYA_BUTTON_O: 16>
OUYA_BUTTON_U = <SpecialChar.OUYA_BUTTON_U: 17>
OUYA_BUTTON_Y = <SpecialChar.OUYA_BUTTON_Y: 18>
OUYA_BUTTON_A = <SpecialChar.OUYA_BUTTON_A: 19>
TICKET = <SpecialChar.TICKET: 22>
DICE_BUTTON1 = <SpecialChar.DICE_BUTTON1: 25>
DICE_BUTTON2 = <SpecialChar.DICE_BUTTON2: 26>
DICE_BUTTON3 = <SpecialChar.DICE_BUTTON3: 27>
DICE_BUTTON4 = <SpecialChar.DICE_BUTTON4: 28>
PARTY_ICON = <SpecialChar.PARTY_ICON: 30>
TEST_ACCOUNT = <SpecialChar.TEST_ACCOUNT: 31>
TICKET_BACKING = <SpecialChar.TICKET_BACKING: 32>
TROPHY1 = <SpecialChar.TROPHY1: 33>
TROPHY2 = <SpecialChar.TROPHY2: 34>
TROPHY3 = <SpecialChar.TROPHY3: 35>
TROPHY0A = <SpecialChar.TROPHY0A: 36>
TROPHY0B = <SpecialChar.TROPHY0B: 37>
TROPHY4 = <SpecialChar.TROPHY4: 38>
LOCAL_ACCOUNT = <SpecialChar.LOCAL_ACCOUNT: 39>
FLAG_UNITED_STATES = <SpecialChar.FLAG_UNITED_STATES: 41>
FLAG_MEXICO = <SpecialChar.FLAG_MEXICO: 42>
FLAG_GERMANY = <SpecialChar.FLAG_GERMANY: 43>
FLAG_BRAZIL = <SpecialChar.FLAG_BRAZIL: 44>
FLAG_RUSSIA = <SpecialChar.FLAG_RUSSIA: 45>
FLAG_CHINA = <SpecialChar.FLAG_CHINA: 46>
FLAG_UNITED_KINGDOM = <SpecialChar.FLAG_UNITED_KINGDOM: 47>
FLAG_CANADA = <SpecialChar.FLAG_CANADA: 48>
FLAG_INDIA = <SpecialChar.FLAG_INDIA: 49>
FLAG_JAPAN = <SpecialChar.FLAG_JAPAN: 50>
FLAG_FRANCE = <SpecialChar.FLAG_FRANCE: 51>
FLAG_INDONESIA = <SpecialChar.FLAG_INDONESIA: 52>
FLAG_ITALY = <SpecialChar.FLAG_ITALY: 53>
FLAG_SOUTH_KOREA = <SpecialChar.FLAG_SOUTH_KOREA: 54>
FLAG_NETHERLANDS = <SpecialChar.FLAG_NETHERLANDS: 55>
FEDORA = <SpecialChar.FEDORA: 56>
HAL = <SpecialChar.HAL: 57>
CROWN = <SpecialChar.CROWN: 58>
YIN_YANG = <SpecialChar.YIN_YANG: 59>
EYE_BALL = <SpecialChar.EYE_BALL: 60>
SKULL = <SpecialChar.SKULL: 61>
HEART = <SpecialChar.HEART: 62>
DRAGON = <SpecialChar.DRAGON: 63>
HELMET = <SpecialChar.HELMET: 64>
MUSHROOM = <SpecialChar.MUSHROOM: 65>
NINJA_STAR = <SpecialChar.NINJA_STAR: 66>
VIKING_HELMET = <SpecialChar.VIKING_HELMET: 67>
MOON = <SpecialChar.MOON: 68>
SPIDER = <SpecialChar.SPIDER: 69>
FIREBALL = <SpecialChar.FIREBALL: 70>
FLAG_UNITED_ARAB_EMIRATES = <SpecialChar.FLAG_UNITED_ARAB_EMIRATES: 71>
FLAG_QATAR = <SpecialChar.FLAG_QATAR: 72>
FLAG_EGYPT = <SpecialChar.FLAG_EGYPT: 73>
FLAG_KUWAIT = <SpecialChar.FLAG_KUWAIT: 74>
FLAG_ALGERIA = <SpecialChar.FLAG_ALGERIA: 75>
FLAG_SAUDI_ARABIA = <SpecialChar.FLAG_SAUDI_ARABIA: 76>
FLAG_MALAYSIA = <SpecialChar.FLAG_MALAYSIA: 77>
FLAG_CZECH_REPUBLIC = <SpecialChar.FLAG_CZECH_REPUBLIC: 78>
FLAG_AUSTRALIA = <SpecialChar.FLAG_AUSTRALIA: 79>
FLAG_SINGAPORE = <SpecialChar.FLAG_SINGAPORE: 80>
FLAG_IRAN = <SpecialChar.FLAG_IRAN: 84>
FLAG_POLAND = <SpecialChar.FLAG_POLAND: 85>
FLAG_ARGENTINA = <SpecialChar.FLAG_ARGENTINA: 86>
FLAG_PHILIPPINES = <SpecialChar.FLAG_PHILIPPINES: 87>
FLAG_CHILE = <SpecialChar.FLAG_CHILE: 88>
MIKIROG = <SpecialChar.MIKIROG: 89>
Inherited Members
enum.Enum
name
value
@dataclass
class StandLocation:
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

StandLocation(position: ba.Vec3, angle: float | None = None)
@dataclass
class StandMessage:
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.

StandMessage(position: Sequence[float] = (0.0, 0.0, 0.0), angle: float = 0.0)
position: Sequence[float] = (0.0, 0.0, 0.0)

Where to move to.

angle: float = 0.0

The angle to face (in degrees)

class Stats:
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

Stats()
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
def setactivity(self, activity: ba.Activity | 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.

def getactivity(self) -> ba.Activity | None:
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.

def reset(self) -> 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.

def reset_accum(self) -> None:
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.

def register_sessionplayer(self, player: ba.SessionPlayer) -> None:
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.

def get_records(self) -> dict[str, ba.PlayerRecord]:
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.

def player_scored( self, player: ba.Player, base_points: int = 1, target: Optional[Sequence[float]] = None, kill: bool = False, victim_player: ba.Player | None = None, scale: float = 1.0, color: Optional[Sequence[float]] = None, title: str | ba.Lstr | None = None, screenmessage: bool = True, display: bool = True, importance: int = 1, showpoints: bool = True, big_message: bool = False) -> int:
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.

def player_was_killed( self, player: ba.Player, killed: bool = False, killer: ba.Player | None = None) -> None:
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.

def storagename(suffix: str | None = None) -> str:
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] = {}
class Team(typing.Generic[~PlayerType]):
 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.

Team()
def manual_init( self, team_id: int, name: ba.Lstr | str, color: tuple[float, ...]) -> None:
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.

customdata: dict

Arbitrary values associated with the team. Though it is encouraged that most player values be properly defined on the ba.Team subclass, it may be useful for player-agnostic objects to store values here. This dict is cleared when the team leaves or expires so objects stored here will be disposed of at the expected time, unlike the Team instance itself which may continue to be referenced after it is no longer part of the game.

def on_expire(self) -> None:
179    def on_expire(self) -> None:
180        """Can be overridden to handle team expiration."""

Can be overridden to handle team expiration.

sessionteam: ba.SessionTeam

Return the ba.SessionTeam corresponding to this Team.

Throws a ba.SessionTeamNotFoundError if there is none.

class TeamGameActivity(ba.GameActivity[~PlayerType, ~TeamType]):
 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)

TeamGameActivity(settings: dict)
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.

@classmethod
def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
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.

def on_transition_in(self) -> None:
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.

def on_begin(self) -> None:
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.

def spawn_player_spaz( self, player: ~PlayerType, position: Optional[Sequence[float]] = None, angle: float | None = None) -> bastd.actor.playerspaz.PlayerSpaz:
 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)

Method override; spawns and wires up a standard ba.PlayerSpaz for a ba.Player.

If position or angle is not supplied, a default will be chosen based on the ba.Player and their ba.Team.

def end( self, results: Any = None, announce_winning_team: bool = True, announce_delay: float = 0.1, force: bool = False) -> None:
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).

class TeamNotFoundError(ba.NotFoundError):
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
class Texture:
948class Texture:
949
950    """A reference to a texture.
951
952    Category: **Asset Classes**
953
954    Use ba.gettexture() to instantiate one.
955    """
956
957    pass

A reference to a texture.

Category: Asset Classes

Use ba.gettexture() to instantiate one.

Texture()
def textwidget( edit: ba.Widget | None = None, parent: ba.Widget | None = None, size: Optional[Sequence[float]] = None, position: Optional[Sequence[float]] = None, text: str | ba.Lstr | None = None, v_align: str | None = None, h_align: str | None = None, editable: bool | None = None, padding: float | None = None, on_return_press_call: Optional[Callable[[], NoneType]] = None, on_activate_call: Optional[Callable[[], NoneType]] = None, selectable: bool | None = None, query: ba.Widget | None = None, max_chars: int | None = None, color: Optional[Sequence[float]] = None, click_activate: bool | None = None, on_select_call: Optional[Callable[[], NoneType]] = None, always_highlight: bool | None = None, draw_controller: ba.Widget | None = None, scale: float | None = None, corner_scale: float | None = None, description: str | ba.Lstr | None = None, transition_delay: float | None = None, maxwidth: float | None = None, max_height: float | None = None, flatness: float | None = None, shadow: float | None = None, autoselect: bool | None = None, rotate: float | None = None, enabled: bool | None = None, force_internal_editing: bool | None = None, always_show_carat: bool | None = None, big: bool | None = None, extra_touch_border_scale: float | None = None, res_scale: float | None = None) -> ba.Widget:
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.

@dataclass
class ThawMessage:
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

ThawMessage()
def time( timetype: ba.TimeType = <TimeType.SIM: 0>, timeformat: ba.TimeFormat = <TimeFormat.SECONDS: 0>) -> Any:
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().

class TimeFormat(enum.Enum):
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

SECONDS = <TimeFormat.SECONDS: 0>
MILLISECONDS = <TimeFormat.MILLISECONDS: 1>
Inherited Members
enum.Enum
name
value
class Timer:
 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)
Timer( time: float, call: Callable[[], Any], repeat: bool = False, timetype: ba.TimeType = <TimeType.SIM: 0>, timeformat: ba.TimeFormat = <TimeFormat.SECONDS: 0>, suppress_format_warning: bool = False)
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
def timer( time: float, call: Callable[[], Any], repeat: bool = False, timetype: ba.TimeType = <TimeType.SIM: 0>, timeformat: ba.TimeFormat = <TimeFormat.SECONDS: 0>, suppress_format_warning: bool = False) -> None:
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, or REAL. It defaults to SIM.

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!'))
def timestring( timeval: float | int, centi: bool = True, timeformat: ba.TimeFormat = <TimeFormat.SECONDS: 0>, suppress_format_warning: bool = False) -> ba.Lstr:
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.

class TimeType(enum.Enum):
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)

SIM = <TimeType.SIM: 0>
BASE = <TimeType.BASE: 1>
REAL = <TimeType.REAL: 2>
Inherited Members
enum.Enum
name
value
def uicleanupcheck(obj: Any, widget: ba.Widget) -> None:
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.

class UIController:
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

UIController()
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] = []
def show_main_menu(self, in_game: bool = True) -> None:
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()

Show the main menu, clearing other UIs from location stacks.

class UIScale(enum.Enum):
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.

LARGE = <UIScale.LARGE: 0>
MEDIUM = <UIScale.MEDIUM: 1>
SMALL = <UIScale.SMALL: 2>
Inherited Members
enum.Enum
name
value
class UISubsystem:
 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'.

UISubsystem()
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
uiscale: ba.UIScale

Current ui scale for the app.

def on_app_launch(self) -> None:
 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.

def set_main_menu_window(self, window: ba.Widget) -> None:
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

Set the current 'main' window, replacing any existing.

def clear_main_menu_window(self, transition: str | None = None) -> None:
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()

Clear any existing 'main' window with the provided transition.

def has_main_menu_window(self) -> bool:
164    def has_main_menu_window(self) -> bool:
165        """Return whether a main menu window is present."""
166        return bool(self._main_menu_window)

Return whether a main menu window is present.

def set_main_menu_location(self, location: str) -> None:
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

Set the location represented by the current main menu window.

def get_main_menu_location(self) -> str | None:
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

Return the current named main menu location, if any.

UNHANDLED = <ba._messages._UnhandledType object>
class Vec3(typing.Sequence[float]):
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)
Vec3(*args: Any, **kwds: Any)
1064    def __init__(self, *args: Any, **kwds: Any):
1065        pass
x: float

The vector's X component.

y: float

The vector's Y component.

z: float

The vector's Z component.

def cross(self, other: ba.Vec3) -> ba.Vec3:
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.

def dot(self, other: ba.Vec3) -> float:
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.

def length(self) -> float:
1125    def length(self) -> float:
1126
1127        """Returns the length of the vector."""
1128        return float()

Returns the length of the vector.

def normalized(self) -> ba.Vec3:
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
def vec3validate(value: Sequence[float]) -> Sequence[float]:
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.

def verify_object_death(obj: object) -> None:
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.

WeakCall = <class 'ba._general._WeakCall'>
class Widget:
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.

Widget()
def activate(self) -> None:
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.

def add_delete_callback(self, call: Callable) -> None:
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.

def delete(self, ignore_missing: bool = True) -> None:
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.

def exists(self) -> bool:
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.

def get_children(self) -> list[ba.Widget]:
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.

def get_screen_space_center(self) -> tuple[float, float]:
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.

def get_selected_child(self) -> ba.Widget | None:
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.

def get_widget_type(self) -> str:
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.

def widget( edit: ba.Widget | None = None, up_widget: ba.Widget | None = None, down_widget: ba.Widget | None = None, left_widget: ba.Widget | None = None, right_widget: ba.Widget | None = None, show_buffer_top: float | None = None, show_buffer_bottom: float | None = None, show_buffer_left: float | None = None, show_buffer_right: float | None = None, autoselect: bool | None = None) -> None:
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.

class WidgetNotFoundError(ba.NotFoundError):
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
class Window:
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

Window(root_widget: ba.Widget, cleanupcheck: bool = True)
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)
def get_root_widget(self) -> ba.Widget:
39    def get_root_widget(self) -> ba.Widget:
40        """Return the root widget."""
41        return self._root_widget

Return the root widget.